From ddcb5445a29a794dad1d1518e5fda0a26f5b6352 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Dec 2017 20:31:42 +0100 Subject: [PATCH 001/524] Initial refactoring for new key parsing --- qutebrowser/config/configtypes.py | 7 ++- qutebrowser/keyinput/basekeyparser.py | 80 +++++++++++---------------- qutebrowser/utils/utils.py | 22 ++++++-- 3 files changed, 53 insertions(+), 56 deletions(-) diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 71c32f59e..e7e96cc44 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -1651,6 +1651,7 @@ class Key(BaseType): self._basic_py_validation(value, str) if not value: return None - if utils.is_special_key(value): - value = '<{}>'.format(utils.normalize_keystr(value[1:-1])) - return value + #if utils.is_special_key(value): + # value = '<{}>'.format(utils.normalize_keystr(value[1:-1])) + #return value + return utils.parse_keystring(value) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index b435ac52c..ef902c82a 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -24,6 +24,7 @@ import re import unicodedata from PyQt5.QtCore import pyqtSignal, QObject +from PyQt5.QtGui import QKeySequence from qutebrowser.config import config from qutebrowser.utils import usertypes, log, utils @@ -53,7 +54,6 @@ class BaseKeyParser(QObject): Attributes: bindings: Bound key bindings - special_bindings: Bound special bindings (). _win_id: The window ID this keyparser is associated with. _warn_on_keychains: Whether a warning should be logged when binding keychains in a section which does not support them. @@ -76,7 +76,6 @@ class BaseKeyParser(QObject): do_log = True passthrough = False - Match = enum.Enum('Match', ['partial', 'definitive', 'other', 'none']) Type = enum.Enum('Type', ['chain', 'special']) def __init__(self, win_id, parent=None, supports_count=None, @@ -91,7 +90,6 @@ class BaseKeyParser(QObject): self._supports_chains = supports_chains self._warn_on_keychains = True self.bindings = {} - self.special_bindings = {} config.instance.changed.connect(self._on_config_changed) def __repr__(self): @@ -118,6 +116,7 @@ class BaseKeyParser(QObject): Return: True if event has been handled, False otherwise. """ + # FIXME remove? binding = utils.keyevent_to_string(e) if binding is None: self._debug_log("Ignoring only-modifier keyeevent.") @@ -161,8 +160,8 @@ class BaseKeyParser(QObject): count = None return count, cmd_input - def _handle_single_key(self, e): - """Handle a new keypress with a single key (no modifiers). + def _handle_key(self, e): + """Handle a new keypress. Separate the keypress into count/command, then check if it matches any possible command, and either run the command, ignore it, or @@ -186,11 +185,11 @@ class BaseKeyParser(QObject): if (not txt) or is_control_char: self._debug_log("Ignoring, no text char") - return self.Match.none + return QKeySequence.NoMatch count, cmd_input = self._split_count(self._keystring + txt) match, binding = self._match_key(cmd_input) - if match == self.Match.none: + if match == QKeySequence.NoMatch: mappings = config.val.bindings.key_mappings mapped = mappings.get(txt, None) if mapped is not None: @@ -199,19 +198,19 @@ class BaseKeyParser(QObject): match, binding = self._match_key(cmd_input) self._keystring += txt - if match == self.Match.definitive: + if match == QKeySequence.ExactMatch: self._debug_log("Definitive match for '{}'.".format( self._keystring)) self.clear_keystring() self.execute(binding, self.Type.chain, count) - elif match == self.Match.partial: + elif match == QKeySequence.PartialMatch: self._debug_log("No match for '{}' (added {})".format( self._keystring, txt)) - elif match == self.Match.none: + elif match == QKeySequence.NoMatch: self._debug_log("Giving up with '{}', no matches".format( self._keystring)) self.clear_keystring() - elif match == self.Match.other: + elif match is None: pass else: raise utils.Unreachable("Invalid match value {!r}".format(match)) @@ -231,29 +230,14 @@ class BaseKeyParser(QObject): """ if not cmd_input: # Only a count, no command yet, but we handled it - return (self.Match.other, None) - # A (cmd_input, binding) tuple (k, v of bindings) or None. - definitive_match = None - partial_match = False - # Check definitive match - try: - definitive_match = (cmd_input, self.bindings[cmd_input]) - except KeyError: - pass - # Check partial match - for binding in self.bindings: - if definitive_match is not None and binding == definitive_match[0]: - # We already matched that one - continue - elif binding.startswith(cmd_input): - partial_match = True - break - if definitive_match is not None: - return (self.Match.definitive, definitive_match[1]) - elif partial_match: - return (self.Match.partial, None) - else: - return (self.Match.none, None) + return (None, None) + + for seq, cmd in self.bindings.items(): + match = seq.matches(utils.parse_keystring(cmd_input)) + if match != QKeySequence.NoMatch: + return (match, cmd) + + return (QKeySequence.NoMatch, None) def handle(self, e): """Handle a new keypress and call the respective handlers. @@ -264,15 +248,17 @@ class BaseKeyParser(QObject): Return: True if the event was handled, False otherwise. """ - handled = self._handle_special_key(e) + match = self._handle_key(e) + + # FIXME + # if handled or not self._supports_chains: + # return handled - if handled or not self._supports_chains: - return handled - match = self._handle_single_key(e) # don't emit twice if the keystring was cleared in self.clear_keystring if self._keystring: self.keystring_updated.emit(self._keystring) - return match != self.Match.none + + return match != QKeySequence.NoMatch @config.change_filter('bindings') def _on_config_changed(self): @@ -295,22 +281,18 @@ class BaseKeyParser(QObject): else: self._modename = modename self.bindings = {} - self.special_bindings = {} for key, cmd in config.key_instance.get_bindings_for(modename).items(): assert cmd - self._parse_key_command(modename, key, cmd) + self.bindings[key] = cmd def _parse_key_command(self, modename, key, cmd): """Parse the keys and their command and store them in the object.""" - if utils.is_special_key(key): - self.special_bindings[key[1:-1]] = cmd - elif self._supports_chains: - self.bindings[key] = cmd - elif self._warn_on_keychains: - log.keyboard.warning("Ignoring keychain '{}' in mode '{}' because " - "keychains are not supported there." - .format(key, modename)) + # FIXME + # elif self._warn_on_keychains: + # log.keyboard.warning("Ignoring keychain '{}' in mode '{}' because " + # "keychains are not supported there." + # .format(key, modename)) def execute(self, cmdstr, keytype, count=None): """Handle a completed keychain. diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index f29db578e..eb6ebe901 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -511,12 +511,26 @@ def _parse_single_key(keystr): return KeyInfo(key, modifiers, text) +def _parse_keystring(keystr): + key = '' + special = False + for c in keystr: + if c == '>': + yield normalize_keystr(key) + key = '' + special = False + elif c == '<': + special = True + elif special: + key += c + else: + yield 'Shift+' + c if c.isupper() else c + + def parse_keystring(keystr): """Parse a keystring like or xyz and return a KeyInfo list.""" - if is_special_key(keystr): - return [_parse_single_key(keystr)] - else: - return [_parse_single_key(char) for char in keystr] + s = ', '.join(_parse_keystring(keystr)) + return QKeySequence(s) def normalize_keystr(keystr): From a8aaf01ff0fe2597c856c8efa5a192f640c42e6d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Dec 2017 23:03:59 +0100 Subject: [PATCH 002/524] Fix some more stuff (and break some :D) --- qutebrowser/config/config.py | 16 +++++++++------- qutebrowser/config/configcommands.py | 8 ++++---- qutebrowser/keyinput/basekeyparser.py | 22 +++++++++++++--------- qutebrowser/keyinput/keyparser.py | 3 ++- qutebrowser/misc/keyhintwidget.py | 1 - qutebrowser/misc/miscwidgets.py | 2 -- qutebrowser/utils/utils.py | 17 ++++++++++++++++- 7 files changed, 44 insertions(+), 25 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index c170d0705..1f910f549 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -136,9 +136,10 @@ class KeyConfig: """Make sure the given mode exists and normalize the key.""" if mode not in configdata.DATA['bindings.default'].default: raise configexc.KeybindingError("Invalid mode {}!".format(mode)) - if utils.is_special_key(key): - # , , and should be considered equivalent - return utils.normalize_keystr(key) + # FIXME needed? + # if utils.is_special_key(key): + # # , , and should be considered equivalent + # return utils.normalize_keystr(key) return key def get_bindings_for(self, mode): @@ -160,10 +161,11 @@ class KeyConfig: cmd = cmd.strip() cmd_to_keys.setdefault(cmd, []) # put special bindings last - if utils.is_special_key(key): - cmd_to_keys[cmd].append(key) - else: - cmd_to_keys[cmd].insert(0, key) + # FIXME update + # if utils.is_special_key(key): + # cmd_to_keys[cmd].append(key) + # else: + cmd_to_keys[cmd].insert(0, key.toString()) return cmd_to_keys def get_command(self, key, mode, default=False): diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 7d9adb475..711fe861c 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -117,10 +117,10 @@ class ConfigCommands: return # No --default -> print binding - if utils.is_special_key(key): - # self._keyconfig.get_command does this, but we also need it - # normalized for the output below - key = utils.normalize_keystr(key) + #if utils.is_special_key(key): + # # self._keyconfig.get_command does this, but we also need it + # # normalized for the output below + # key = utils.normalize_keystr(key) with self._handle_config_error(): cmd = self._keyconfig.get_command(key, mode) if cmd is None: diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index ef902c82a..95149df3e 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -173,20 +173,24 @@ class BaseKeyParser(QObject): Return: A self.Match member. """ - txt = e.text() key = e.key() + txt = utils.keyevent_to_string(e) self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) - if len(txt) == 1: - category = unicodedata.category(txt) - is_control_char = (category == 'Cc') - else: - is_control_char = False - - if (not txt) or is_control_char: + if txt is None: self._debug_log("Ignoring, no text char") return QKeySequence.NoMatch + # if len(txt) == 1: + # category = unicodedata.category(txt) + # is_control_char = (category == 'Cc') + # else: + # is_control_char = False + + # if (not txt) or is_control_char: + # self._debug_log("Ignoring, no text char") + # return QKeySequence.NoMatch + count, cmd_input = self._split_count(self._keystring + txt) match, binding = self._match_key(cmd_input) if match == QKeySequence.NoMatch: @@ -233,7 +237,7 @@ class BaseKeyParser(QObject): return (None, None) for seq, cmd in self.bindings.items(): - match = seq.matches(utils.parse_keystring(cmd_input)) + match = seq.matches(cmd_input) if match != QKeySequence.NoMatch: return (match, cmd) diff --git a/qutebrowser/keyinput/keyparser.py b/qutebrowser/keyinput/keyparser.py index 8fcb53035..3df8ab193 100644 --- a/qutebrowser/keyinput/keyparser.py +++ b/qutebrowser/keyinput/keyparser.py @@ -56,7 +56,8 @@ class PassthroughKeyParser(CommandKeyParser): _mode: The mode this keyparser is for. """ - do_log = False + # FIXME + # do_log = False passthrough = True def __init__(self, win_id, mode, parent=None, warn=True): diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 46d1d3f24..600095e0f 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -106,7 +106,6 @@ class KeyHintView(QLabel): bindings_dict = config.key_instance.get_bindings_for(modename) bindings = [(k, v) for (k, v) in sorted(bindings_dict.items()) if k.startswith(prefix) and - not utils.is_special_key(k) and not blacklisted(k) and (takes_count(v) or not countstr)] diff --git a/qutebrowser/misc/miscwidgets.py b/qutebrowser/misc/miscwidgets.py index 5d3e9b5ca..c52600bb1 100644 --- a/qutebrowser/misc/miscwidgets.py +++ b/qutebrowser/misc/miscwidgets.py @@ -293,8 +293,6 @@ class FullscreenNotification(QLabel): bindings = all_bindings.get('fullscreen --leave') if bindings: key = bindings[0] - if utils.is_special_key(key): - key = key.strip('<>').capitalize() self.setText("Press {} to exit fullscreen.".format(key)) else: self.setText("Page is now fullscreen.") diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index eb6ebe901..29d9c6671 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -33,6 +33,7 @@ import functools import contextlib import socket import shlex +import unicodedata import attr from PyQt5.QtCore import Qt, QUrl @@ -418,10 +419,22 @@ def keyevent_to_string(e): return None mod = e.modifiers() parts = [] + for (mask, s) in modmask2str.items(): if mod & mask and s not in parts: parts.append(s) - parts.append(key_to_string(e.key())) + + key_string = key_to_string(e.key()) + if len(key_string) == 1: + category = unicodedata.category(key_string) + is_control_char = (category == 'Cc') + else: + is_control_char = False + + if e.modifiers() == Qt.ShiftModifier and not is_control_char: + parts = [] + + parts.append(key_string) return normalize_keystr('+'.join(parts)) @@ -466,6 +479,8 @@ def is_special_key(keystr): def _parse_single_key(keystr): """Convert a single key string to a (Qt.Key, Qt.Modifiers, text) tuple.""" + # FIXME remove + if is_special_key(keystr): # Special key keystr = keystr[1:-1] From 55803afbd2da0e4a268c9fc214e95fdb82d9498c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Dec 2017 23:21:01 +0100 Subject: [PATCH 003/524] Fix matching --- qutebrowser/keyinput/basekeyparser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 95149df3e..bd52dec6a 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -237,7 +237,7 @@ class BaseKeyParser(QObject): return (None, None) for seq, cmd in self.bindings.items(): - match = seq.matches(cmd_input) + match = cmd_input.matches(seq) if match != QKeySequence.NoMatch: return (match, cmd) From 26fdc129d33cd3af9d68798971e9a7803f78c453 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Dec 2017 23:23:38 +0100 Subject: [PATCH 004/524] Split off counts --- qutebrowser/keyinput/basekeyparser.py | 41 ++++++++------------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index bd52dec6a..67dea1a15 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -84,6 +84,7 @@ class BaseKeyParser(QObject): self._win_id = win_id self._modename = None self._keystring = '' + self._count = '' if supports_count is None: supports_count = supports_chains self._supports_count = supports_count @@ -139,27 +140,6 @@ class BaseKeyParser(QObject): self.clear_keystring() return True - def _split_count(self, keystring): - """Get count and command from the current keystring. - - Args: - keystring: The key string to split. - - Return: - A (count, command) tuple. - """ - if self._supports_count: - (countstr, cmd_input) = re.fullmatch(r'(\d*)(.*)', - keystring).groups() - count = int(countstr) if countstr else None - if count == 0 and not cmd_input: - cmd_input = keystring - count = None - else: - cmd_input = keystring - count = None - return count, cmd_input - def _handle_key(self, e): """Handle a new keypress. @@ -191,14 +171,19 @@ class BaseKeyParser(QObject): # self._debug_log("Ignoring, no text char") # return QKeySequence.NoMatch - count, cmd_input = self._split_count(self._keystring + txt) + if txt.isdigit(): + assert len(txt) == 1, txt + self._count += txt + return None + + cmd_input = self._keystring + txt match, binding = self._match_key(cmd_input) if match == QKeySequence.NoMatch: mappings = config.val.bindings.key_mappings mapped = mappings.get(txt, None) if mapped is not None: txt = mapped - count, cmd_input = self._split_count(self._keystring + txt) + cmd_input = self._keystring + txt match, binding = self._match_key(cmd_input) self._keystring += txt @@ -206,6 +191,7 @@ class BaseKeyParser(QObject): self._debug_log("Definitive match for '{}'.".format( self._keystring)) self.clear_keystring() + count = int(self._count) if self._count else None self.execute(binding, self.Type.chain, count) elif match == QKeySequence.PartialMatch: self._debug_log("No match for '{}' (added {})".format( @@ -214,8 +200,6 @@ class BaseKeyParser(QObject): self._debug_log("Giving up with '{}', no matches".format( self._keystring)) self.clear_keystring() - elif match is None: - pass else: raise utils.Unreachable("Invalid match value {!r}".format(match)) return match @@ -232,9 +216,7 @@ class BaseKeyParser(QObject): binding: - None with Match.partial/Match.none. - The found binding with Match.definitive. """ - if not cmd_input: - # Only a count, no command yet, but we handled it - return (None, None) + assert cmd_input for seq, cmd in self.bindings.items(): match = cmd_input.matches(seq) @@ -260,7 +242,7 @@ class BaseKeyParser(QObject): # don't emit twice if the keystring was cleared in self.clear_keystring if self._keystring: - self.keystring_updated.emit(self._keystring) + self.keystring_updated.emit(self._count + self._keystring) return match != QKeySequence.NoMatch @@ -314,4 +296,5 @@ class BaseKeyParser(QObject): self._debug_log("discarding keystring '{}'.".format( self._keystring)) self._keystring = '' + self._count = '' self.keystring_updated.emit(self._keystring) From 8478a1ea3d0019db8d6c2e9baf461366a8941f36 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Dec 2017 23:33:06 +0100 Subject: [PATCH 005/524] Remove _handle_special_key --- qutebrowser/keyinput/basekeyparser.py | 34 --------------------------- 1 file changed, 34 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 67dea1a15..6220e2e33 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -106,40 +106,6 @@ class BaseKeyParser(QObject): if self.do_log: log.keyboard.debug(message) - def _handle_special_key(self, e): - """Handle a new keypress with special keys (). - - Return True if the keypress has been handled, and False if not. - - Args: - e: the KeyPressEvent from Qt. - - Return: - True if event has been handled, False otherwise. - """ - # FIXME remove? - binding = utils.keyevent_to_string(e) - if binding is None: - self._debug_log("Ignoring only-modifier keyeevent.") - return False - - if binding not in self.special_bindings: - key_mappings = config.val.bindings.key_mappings - try: - binding = key_mappings['<{}>'.format(binding)][1:-1] - except KeyError: - pass - - try: - cmdstr = self.special_bindings[binding] - except KeyError: - self._debug_log("No special binding found for {}.".format(binding)) - return False - count, _command = self._split_count(self._keystring) - self.execute(cmdstr, self.Type.special, count) - self.clear_keystring() - return True - def _handle_key(self, e): """Handle a new keypress. From a565b77bf0ec60c7bde7aecc9ddebfbe0d27be51 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Dec 2017 23:47:23 +0100 Subject: [PATCH 006/524] Switch from string to QKeySequence --- qutebrowser/keyinput/basekeyparser.py | 44 ++++++++++++++------------- qutebrowser/misc/keyhintwidget.py | 3 +- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 6220e2e33..33a1f808a 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -57,7 +57,7 @@ class BaseKeyParser(QObject): _win_id: The window ID this keyparser is associated with. _warn_on_keychains: Whether a warning should be logged when binding keychains in a section which does not support them. - _keystring: The currently entered key sequence + _sequence: The currently entered key sequence _modename: The name of the input mode associated with this keyparser. _supports_count: Whether count is supported _supports_chains: Whether keychains are supported @@ -83,7 +83,7 @@ class BaseKeyParser(QObject): super().__init__(parent) self._win_id = win_id self._modename = None - self._keystring = '' + self._sequence = QKeySequence() self._count = '' if supports_count is None: supports_count = supports_chains @@ -142,39 +142,41 @@ class BaseKeyParser(QObject): self._count += txt return None - cmd_input = self._keystring + txt - match, binding = self._match_key(cmd_input) + sequence = QKeySequence(*self._sequence, e.modifiers() | e.key()) + match, binding = self._match_key(sequence) if match == QKeySequence.NoMatch: mappings = config.val.bindings.key_mappings mapped = mappings.get(txt, None) if mapped is not None: + # FIXME + raise Exception txt = mapped - cmd_input = self._keystring + txt - match, binding = self._match_key(cmd_input) + sequence = QKeySequence(*self._sequence, e.modifiers() | e.key()) + match, binding = self._match_key(sequence) - self._keystring += txt + self._sequence = QKeySequence(*self._sequence, e.modifiers() | e.key()) if match == QKeySequence.ExactMatch: self._debug_log("Definitive match for '{}'.".format( - self._keystring)) - self.clear_keystring() + self._sequence.toString())) count = int(self._count) if self._count else None + self.clear_keystring() self.execute(binding, self.Type.chain, count) elif match == QKeySequence.PartialMatch: self._debug_log("No match for '{}' (added {})".format( - self._keystring, txt)) + self._sequence.toString(), txt)) elif match == QKeySequence.NoMatch: self._debug_log("Giving up with '{}', no matches".format( - self._keystring)) + self._sequence.toString())) self.clear_keystring() else: raise utils.Unreachable("Invalid match value {!r}".format(match)) return match - def _match_key(self, cmd_input): + def _match_key(self, sequence): """Try to match a given keystring with any bound keychain. Args: - cmd_input: The command string to find. + sequence: The command string to find. Return: A tuple (matchtype, binding). @@ -182,10 +184,10 @@ class BaseKeyParser(QObject): binding: - None with Match.partial/Match.none. - The found binding with Match.definitive. """ - assert cmd_input + assert sequence for seq, cmd in self.bindings.items(): - match = cmd_input.matches(seq) + match = sequence.matches(seq) if match != QKeySequence.NoMatch: return (match, cmd) @@ -207,8 +209,8 @@ class BaseKeyParser(QObject): # return handled # don't emit twice if the keystring was cleared in self.clear_keystring - if self._keystring: - self.keystring_updated.emit(self._count + self._keystring) + if self._sequence: + self.keystring_updated.emit(self._count + self._sequence.toString()) return match != QKeySequence.NoMatch @@ -258,9 +260,9 @@ class BaseKeyParser(QObject): def clear_keystring(self): """Clear the currently entered key sequence.""" - if self._keystring: + if self._sequence: self._debug_log("discarding keystring '{}'.".format( - self._keystring)) - self._keystring = '' + self._sequence.toString())) + self._sequence = QKeySequence() self._count = '' - self.keystring_updated.emit(self._keystring) + self.keystring_updated.emit('') diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 600095e0f..d70ddf23d 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -30,6 +30,7 @@ import re from PyQt5.QtWidgets import QLabel, QSizePolicy from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt +from PyQt5.QtGui import QKeySequence from qutebrowser.config import config from qutebrowser.utils import utils, usertypes @@ -105,7 +106,7 @@ class KeyHintView(QLabel): bindings_dict = config.key_instance.get_bindings_for(modename) bindings = [(k, v) for (k, v) in sorted(bindings_dict.items()) - if k.startswith(prefix) and + if k.matches(QKeySequence(prefix)) and # FIXME not blacklisted(k) and (takes_count(v) or not countstr)] From 600919a23ad9a811eeb1d3aaa43f596ad832c543 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 00:53:37 +0100 Subject: [PATCH 007/524] Add a custom KeySequence class --- qutebrowser/keyinput/basekeyparser.py | 21 +++++----- qutebrowser/keyinput/sequence.py | 58 +++++++++++++++++++++++++++ qutebrowser/misc/keyhintwidget.py | 3 +- qutebrowser/utils/utils.py | 5 ++- 4 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 qutebrowser/keyinput/sequence.py diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 33a1f808a..14a7ccc6f 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -28,6 +28,7 @@ from PyQt5.QtGui import QKeySequence from qutebrowser.config import config from qutebrowser.utils import usertypes, log, utils +from qutebrowser.keyinput import sequence class BaseKeyParser(QObject): @@ -83,7 +84,7 @@ class BaseKeyParser(QObject): super().__init__(parent) self._win_id = win_id self._modename = None - self._sequence = QKeySequence() + self._sequence = sequence.KeySequence() self._count = '' if supports_count is None: supports_count = supports_chains @@ -142,7 +143,7 @@ class BaseKeyParser(QObject): self._count += txt return None - sequence = QKeySequence(*self._sequence, e.modifiers() | e.key()) + sequence = self._sequence.append_event(e) match, binding = self._match_key(sequence) if match == QKeySequence.NoMatch: mappings = config.val.bindings.key_mappings @@ -151,22 +152,22 @@ class BaseKeyParser(QObject): # FIXME raise Exception txt = mapped - sequence = QKeySequence(*self._sequence, e.modifiers() | e.key()) + sequence = self._sequence.append_event(e) match, binding = self._match_key(sequence) - self._sequence = QKeySequence(*self._sequence, e.modifiers() | e.key()) + self._sequence = self._sequence.append_event(e) if match == QKeySequence.ExactMatch: self._debug_log("Definitive match for '{}'.".format( - self._sequence.toString())) + self._sequence)) count = int(self._count) if self._count else None self.clear_keystring() self.execute(binding, self.Type.chain, count) elif match == QKeySequence.PartialMatch: self._debug_log("No match for '{}' (added {})".format( - self._sequence.toString(), txt)) + self._sequence, txt)) elif match == QKeySequence.NoMatch: self._debug_log("Giving up with '{}', no matches".format( - self._sequence.toString())) + self._sequence)) self.clear_keystring() else: raise utils.Unreachable("Invalid match value {!r}".format(match)) @@ -210,7 +211,7 @@ class BaseKeyParser(QObject): # don't emit twice if the keystring was cleared in self.clear_keystring if self._sequence: - self.keystring_updated.emit(self._count + self._sequence.toString()) + self.keystring_updated.emit(self._count + str(self._sequence)) return match != QKeySequence.NoMatch @@ -262,7 +263,7 @@ class BaseKeyParser(QObject): """Clear the currently entered key sequence.""" if self._sequence: self._debug_log("discarding keystring '{}'.".format( - self._sequence.toString())) - self._sequence = QKeySequence() + self._sequence)) + self._sequence = sequence.KeySequence() self._count = '' self.keystring_updated.emit('') diff --git a/qutebrowser/keyinput/sequence.py b/qutebrowser/keyinput/sequence.py new file mode 100644 index 000000000..7db4f76a7 --- /dev/null +++ b/qutebrowser/keyinput/sequence.py @@ -0,0 +1,58 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2017 Florian Bruhin (The Compiler) +# +# 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 . + +"""Our own QKeySequence-like class and related utilities.""" + +from PyQt5.QtGui import QKeySequence + +from qutebrowser.utils import utils + + +class KeySequence: + + def __init__(self, *args): + self._sequence = QKeySequence(*args) + + def __str__(self): + return self._sequence.toString() + + def __repr__(self): + return utils.get_repr(self, keys=str(self)) + + def __lt__(self, other): + return self._sequence < other._sequence + + def __gt__(self, other): + return self._sequence > other._sequence + + def __eq__(self, other): + return self._sequence == other._sequence + + def __ne__(self, other): + return self._sequence != other._sequence + + def __hash__(self): + return hash(self._sequence) + + def matches(self, other): + # pylint: disable=protected-access + return self._sequence.matches(other._sequence) + + def append_event(self, ev): + return self.__class__(*self._sequence, ev.modifiers() | ev.key()) diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index d70ddf23d..d4ed43141 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -35,6 +35,7 @@ from PyQt5.QtGui import QKeySequence from qutebrowser.config import config from qutebrowser.utils import utils, usertypes from qutebrowser.commands import cmdutils +from qutebrowser.keyinput import sequence class KeyHintView(QLabel): @@ -106,7 +107,7 @@ class KeyHintView(QLabel): bindings_dict = config.key_instance.get_bindings_for(modename) bindings = [(k, v) for (k, v) in sorted(bindings_dict.items()) - if k.matches(QKeySequence(prefix)) and # FIXME + if k.matches(sequence.KeySequence(prefix)) and # FIXME not blacklisted(k) and (takes_count(v) or not countstr)] diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 29d9c6671..a11c67bc4 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -50,6 +50,7 @@ except ImportError: # pragma: no cover import qutebrowser from qutebrowser.utils import qtutils, log, debug +from qutebrowser.keyinput import sequence fake_clipboard = None @@ -491,7 +492,7 @@ def _parse_single_key(keystr): raise KeyParseError(keystr, "Expecting either a single key or a " " like keybinding.") - seq = QKeySequence(normalize_keystr(keystr), QKeySequence.PortableText) + seq = sequence.KeySequence(normalize_keystr(keystr), QKeySequence.PortableText) if len(seq) != 1: raise KeyParseError(keystr, "Got {} keys instead of 1.".format( len(seq))) @@ -545,7 +546,7 @@ def _parse_keystring(keystr): def parse_keystring(keystr): """Parse a keystring like or xyz and return a KeyInfo list.""" s = ', '.join(_parse_keystring(keystr)) - return QKeySequence(s) + return sequence.KeySequence(s) def normalize_keystr(keystr): From dcf0d21121067a9ccb3a59902e3c689d42765d4a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 01:02:28 +0100 Subject: [PATCH 008/524] Move key related utils to sequence.py --- qutebrowser/config/configtypes.py | 3 +- qutebrowser/keyinput/sequence.py | 292 +++++++++++++++++++++++++++++- qutebrowser/utils/utils.py | 285 ----------------------------- 3 files changed, 293 insertions(+), 287 deletions(-) diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index e7e96cc44..b7a21bb42 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -62,6 +62,7 @@ from PyQt5.QtWidgets import QTabWidget, QTabBar from qutebrowser.commands import cmdutils from qutebrowser.config import configexc from qutebrowser.utils import standarddir, utils, qtutils, urlutils +from qutebrowser.keyinput import sequence SYSTEM_PROXY = object() # Return value for Proxy type @@ -1654,4 +1655,4 @@ class Key(BaseType): #if utils.is_special_key(value): # value = '<{}>'.format(utils.normalize_keystr(value[1:-1])) #return value - return utils.parse_keystring(value) + return sequence.parse_keystring(value) diff --git a/qutebrowser/keyinput/sequence.py b/qutebrowser/keyinput/sequence.py index 7db4f76a7..e47b37579 100644 --- a/qutebrowser/keyinput/sequence.py +++ b/qutebrowser/keyinput/sequence.py @@ -19,9 +19,299 @@ """Our own QKeySequence-like class and related utilities.""" +import unicodedata +import collections + +import attr +from PyQt5.QtCore import Qt from PyQt5.QtGui import QKeySequence -from qutebrowser.utils import utils +from qutebrowser.utils import utils, debug + + +def key_to_string(key): + """Convert a Qt::Key member to a meaningful name. + + Args: + key: A Qt::Key member. + + Return: + A name of the key as a string. + """ + special_names_str = { + # Some keys handled in a weird way by QKeySequence::toString. + # See https://bugreports.qt.io/browse/QTBUG-40030 + # Most are unlikely to be ever needed, but you never know ;) + # For dead/combining keys, we return the corresponding non-combining + # key, as that's easier to add to the config. + 'Key_Blue': 'Blue', + 'Key_Calendar': 'Calendar', + 'Key_ChannelDown': 'Channel Down', + 'Key_ChannelUp': 'Channel Up', + 'Key_ContrastAdjust': 'Contrast Adjust', + 'Key_Dead_Abovedot': '˙', + 'Key_Dead_Abovering': '˚', + 'Key_Dead_Acute': '´', + 'Key_Dead_Belowdot': 'Belowdot', + 'Key_Dead_Breve': '˘', + 'Key_Dead_Caron': 'ˇ', + 'Key_Dead_Cedilla': '¸', + 'Key_Dead_Circumflex': '^', + 'Key_Dead_Diaeresis': '¨', + 'Key_Dead_Doubleacute': '˝', + 'Key_Dead_Grave': '`', + 'Key_Dead_Hook': 'Hook', + 'Key_Dead_Horn': 'Horn', + 'Key_Dead_Iota': 'Iota', + 'Key_Dead_Macron': '¯', + 'Key_Dead_Ogonek': '˛', + 'Key_Dead_Semivoiced_Sound': 'Semivoiced Sound', + 'Key_Dead_Tilde': '~', + 'Key_Dead_Voiced_Sound': 'Voiced Sound', + 'Key_Exit': 'Exit', + 'Key_Green': 'Green', + 'Key_Guide': 'Guide', + 'Key_Info': 'Info', + 'Key_LaunchG': 'LaunchG', + 'Key_LaunchH': 'LaunchH', + 'Key_MediaLast': 'MediaLast', + 'Key_Memo': 'Memo', + 'Key_MicMute': 'Mic Mute', + 'Key_Mode_switch': 'Mode switch', + 'Key_Multi_key': 'Multi key', + 'Key_PowerDown': 'Power Down', + 'Key_Red': 'Red', + 'Key_Settings': 'Settings', + 'Key_SingleCandidate': 'Single Candidate', + 'Key_ToDoList': 'Todo List', + 'Key_TouchpadOff': 'Touchpad Off', + 'Key_TouchpadOn': 'Touchpad On', + 'Key_TouchpadToggle': 'Touchpad toggle', + 'Key_Yellow': 'Yellow', + 'Key_Alt': 'Alt', + 'Key_AltGr': 'AltGr', + 'Key_Control': 'Control', + 'Key_Direction_L': 'Direction L', + 'Key_Direction_R': 'Direction R', + 'Key_Hyper_L': 'Hyper L', + 'Key_Hyper_R': 'Hyper R', + 'Key_Meta': 'Meta', + 'Key_Shift': 'Shift', + 'Key_Super_L': 'Super L', + 'Key_Super_R': 'Super R', + 'Key_unknown': 'Unknown', + } + # We now build our real special_names dict from the string mapping above. + # The reason we don't do this directly is that certain Qt versions don't + # have all the keys, so we want to ignore AttributeErrors. + special_names = {} + for k, v in special_names_str.items(): + try: + special_names[getattr(Qt, k)] = v + except AttributeError: + pass + # Now we check if the key is any special one - if not, we use + # QKeySequence::toString. + try: + return special_names[key] + except KeyError: + name = QKeySequence(key).toString() + morphings = { + 'Backtab': 'Tab', + 'Esc': 'Escape', + } + if name in morphings: + return morphings[name] + else: + return name + + +def keyevent_to_string(e): + """Convert a QKeyEvent to a meaningful name. + + Args: + e: A QKeyEvent. + + Return: + A name of the key (combination) as a string or + None if only modifiers are pressed.. + """ + if utils.is_mac: + # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the user + # can use it in the config as expected. See: + # https://github.com/qutebrowser/qutebrowser/issues/110 + # http://doc.qt.io/qt-5.4/osx-issues.html#special-keys + modmask2str = collections.OrderedDict([ + (Qt.MetaModifier, 'Ctrl'), + (Qt.AltModifier, 'Alt'), + (Qt.ControlModifier, 'Meta'), + (Qt.ShiftModifier, 'Shift'), + ]) + else: + modmask2str = collections.OrderedDict([ + (Qt.ControlModifier, 'Ctrl'), + (Qt.AltModifier, 'Alt'), + (Qt.MetaModifier, 'Meta'), + (Qt.ShiftModifier, 'Shift'), + ]) + modifiers = (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, + Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, Qt.Key_Hyper_L, + Qt.Key_Hyper_R, Qt.Key_Direction_L, Qt.Key_Direction_R) + if e.key() in modifiers: + # Only modifier pressed + return None + mod = e.modifiers() + parts = [] + + for (mask, s) in modmask2str.items(): + if mod & mask and s not in parts: + parts.append(s) + + key_string = key_to_string(e.key()) + if len(key_string) == 1: + category = unicodedata.category(key_string) + is_control_char = (category == 'Cc') + else: + is_control_char = False + + if e.modifiers() == Qt.ShiftModifier and not is_control_char: + parts = [] + + parts.append(key_string) + return normalize_keystr('+'.join(parts)) + + +@attr.s(repr=False) +class KeyInfo: + + """Stores information about a key, like used in a QKeyEvent. + + Attributes: + key: Qt::Key + modifiers: Qt::KeyboardModifiers + text: str + """ + + key = attr.ib() + modifiers = attr.ib() + text = attr.ib() + + def __repr__(self): + if self.modifiers is None: + modifiers = None + else: + #modifiers = qflags_key(Qt, self.modifiers) + modifiers = hex(int(self.modifiers)) + return utils.get_repr(self, constructor=True, + key=debug.qenum_key(Qt, self.key), + modifiers=modifiers, text=self.text) + + +class KeyParseError(Exception): + + """Raised by _parse_single_key/parse_keystring on parse errors.""" + + def __init__(self, keystr, error): + super().__init__("Could not parse {!r}: {}".format(keystr, error)) + + +def is_special_key(keystr): + """True if keystr is a 'special' keystring (e.g. or ).""" + return keystr.startswith('<') and keystr.endswith('>') + + +def _parse_single_key(keystr): + """Convert a single key string to a (Qt.Key, Qt.Modifiers, text) tuple.""" + # FIXME remove + + if is_special_key(keystr): + # Special key + keystr = keystr[1:-1] + elif len(keystr) == 1: + # vim-like key + pass + else: + raise KeyParseError(keystr, "Expecting either a single key or a " + " like keybinding.") + + seq = KeySequence(normalize_keystr(keystr), QKeySequence.PortableText) + if len(seq) != 1: + raise KeyParseError(keystr, "Got {} keys instead of 1.".format( + len(seq))) + result = seq[0] + + if result == Qt.Key_unknown: + raise KeyParseError(keystr, "Got unknown key.") + + modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier | + Qt.AltModifier | Qt.MetaModifier | Qt.KeypadModifier | + Qt.GroupSwitchModifier) + assert Qt.Key_unknown & ~modifier_mask == Qt.Key_unknown + + modifiers = result & modifier_mask + key = result & ~modifier_mask + + if len(keystr) == 1 and keystr.isupper(): + modifiers |= Qt.ShiftModifier + + assert key != 0, key + key = Qt.Key(key) + modifiers = Qt.KeyboardModifiers(modifiers) + + # Let's hope this is accurate... + if len(keystr) == 1 and not modifiers: + text = keystr + elif len(keystr) == 1 and modifiers == Qt.ShiftModifier: + text = keystr.upper() + else: + text = '' + + return KeyInfo(key, modifiers, text) + + +def _parse_keystring(keystr): + key = '' + special = False + for c in keystr: + if c == '>': + yield normalize_keystr(key) + key = '' + special = False + elif c == '<': + special = True + elif special: + key += c + else: + yield 'Shift+' + c if c.isupper() else c + + +def parse_keystring(keystr): + """Parse a keystring like or xyz and return a KeyInfo list.""" + s = ', '.join(_parse_keystring(keystr)) + return KeySequence(s) + + +def normalize_keystr(keystr): + """Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q. + + Args: + keystr: The key combination as a string. + + Return: + The normalized keystring. + """ + keystr = keystr.lower() + replacements = ( + ('control', 'ctrl'), + ('windows', 'meta'), + ('mod1', 'alt'), + ('mod4', 'meta'), + ) + for (orig, repl) in replacements: + keystr = keystr.replace(orig, repl) + for mod in ['ctrl', 'meta', 'alt', 'shift']: + keystr = keystr.replace(mod + '-', mod + '+') + return keystr class KeySequence: diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index a11c67bc4..e046b28b8 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -287,291 +287,6 @@ def format_size(size, base=1024, suffix=''): return '{:.02f}{}{}'.format(size, prefixes[-1], suffix) -def key_to_string(key): - """Convert a Qt::Key member to a meaningful name. - - Args: - key: A Qt::Key member. - - Return: - A name of the key as a string. - """ - special_names_str = { - # Some keys handled in a weird way by QKeySequence::toString. - # See https://bugreports.qt.io/browse/QTBUG-40030 - # Most are unlikely to be ever needed, but you never know ;) - # For dead/combining keys, we return the corresponding non-combining - # key, as that's easier to add to the config. - 'Key_Blue': 'Blue', - 'Key_Calendar': 'Calendar', - 'Key_ChannelDown': 'Channel Down', - 'Key_ChannelUp': 'Channel Up', - 'Key_ContrastAdjust': 'Contrast Adjust', - 'Key_Dead_Abovedot': '˙', - 'Key_Dead_Abovering': '˚', - 'Key_Dead_Acute': '´', - 'Key_Dead_Belowdot': 'Belowdot', - 'Key_Dead_Breve': '˘', - 'Key_Dead_Caron': 'ˇ', - 'Key_Dead_Cedilla': '¸', - 'Key_Dead_Circumflex': '^', - 'Key_Dead_Diaeresis': '¨', - 'Key_Dead_Doubleacute': '˝', - 'Key_Dead_Grave': '`', - 'Key_Dead_Hook': 'Hook', - 'Key_Dead_Horn': 'Horn', - 'Key_Dead_Iota': 'Iota', - 'Key_Dead_Macron': '¯', - 'Key_Dead_Ogonek': '˛', - 'Key_Dead_Semivoiced_Sound': 'Semivoiced Sound', - 'Key_Dead_Tilde': '~', - 'Key_Dead_Voiced_Sound': 'Voiced Sound', - 'Key_Exit': 'Exit', - 'Key_Green': 'Green', - 'Key_Guide': 'Guide', - 'Key_Info': 'Info', - 'Key_LaunchG': 'LaunchG', - 'Key_LaunchH': 'LaunchH', - 'Key_MediaLast': 'MediaLast', - 'Key_Memo': 'Memo', - 'Key_MicMute': 'Mic Mute', - 'Key_Mode_switch': 'Mode switch', - 'Key_Multi_key': 'Multi key', - 'Key_PowerDown': 'Power Down', - 'Key_Red': 'Red', - 'Key_Settings': 'Settings', - 'Key_SingleCandidate': 'Single Candidate', - 'Key_ToDoList': 'Todo List', - 'Key_TouchpadOff': 'Touchpad Off', - 'Key_TouchpadOn': 'Touchpad On', - 'Key_TouchpadToggle': 'Touchpad toggle', - 'Key_Yellow': 'Yellow', - 'Key_Alt': 'Alt', - 'Key_AltGr': 'AltGr', - 'Key_Control': 'Control', - 'Key_Direction_L': 'Direction L', - 'Key_Direction_R': 'Direction R', - 'Key_Hyper_L': 'Hyper L', - 'Key_Hyper_R': 'Hyper R', - 'Key_Meta': 'Meta', - 'Key_Shift': 'Shift', - 'Key_Super_L': 'Super L', - 'Key_Super_R': 'Super R', - 'Key_unknown': 'Unknown', - } - # We now build our real special_names dict from the string mapping above. - # The reason we don't do this directly is that certain Qt versions don't - # have all the keys, so we want to ignore AttributeErrors. - special_names = {} - for k, v in special_names_str.items(): - try: - special_names[getattr(Qt, k)] = v - except AttributeError: - pass - # Now we check if the key is any special one - if not, we use - # QKeySequence::toString. - try: - return special_names[key] - except KeyError: - name = QKeySequence(key).toString() - morphings = { - 'Backtab': 'Tab', - 'Esc': 'Escape', - } - if name in morphings: - return morphings[name] - else: - return name - - -def keyevent_to_string(e): - """Convert a QKeyEvent to a meaningful name. - - Args: - e: A QKeyEvent. - - Return: - A name of the key (combination) as a string or - None if only modifiers are pressed.. - """ - if is_mac: - # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the user - # can use it in the config as expected. See: - # https://github.com/qutebrowser/qutebrowser/issues/110 - # http://doc.qt.io/qt-5.4/osx-issues.html#special-keys - modmask2str = collections.OrderedDict([ - (Qt.MetaModifier, 'Ctrl'), - (Qt.AltModifier, 'Alt'), - (Qt.ControlModifier, 'Meta'), - (Qt.ShiftModifier, 'Shift'), - ]) - else: - modmask2str = collections.OrderedDict([ - (Qt.ControlModifier, 'Ctrl'), - (Qt.AltModifier, 'Alt'), - (Qt.MetaModifier, 'Meta'), - (Qt.ShiftModifier, 'Shift'), - ]) - modifiers = (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, - Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, Qt.Key_Hyper_L, - Qt.Key_Hyper_R, Qt.Key_Direction_L, Qt.Key_Direction_R) - if e.key() in modifiers: - # Only modifier pressed - return None - mod = e.modifiers() - parts = [] - - for (mask, s) in modmask2str.items(): - if mod & mask and s not in parts: - parts.append(s) - - key_string = key_to_string(e.key()) - if len(key_string) == 1: - category = unicodedata.category(key_string) - is_control_char = (category == 'Cc') - else: - is_control_char = False - - if e.modifiers() == Qt.ShiftModifier and not is_control_char: - parts = [] - - parts.append(key_string) - return normalize_keystr('+'.join(parts)) - - -@attr.s(repr=False) -class KeyInfo: - - """Stores information about a key, like used in a QKeyEvent. - - Attributes: - key: Qt::Key - modifiers: Qt::KeyboardModifiers - text: str - """ - - key = attr.ib() - modifiers = attr.ib() - text = attr.ib() - - def __repr__(self): - if self.modifiers is None: - modifiers = None - else: - #modifiers = qflags_key(Qt, self.modifiers) - modifiers = hex(int(self.modifiers)) - return get_repr(self, constructor=True, - key=debug.qenum_key(Qt, self.key), - modifiers=modifiers, text=self.text) - - -class KeyParseError(Exception): - - """Raised by _parse_single_key/parse_keystring on parse errors.""" - - def __init__(self, keystr, error): - super().__init__("Could not parse {!r}: {}".format(keystr, error)) - - -def is_special_key(keystr): - """True if keystr is a 'special' keystring (e.g. or ).""" - return keystr.startswith('<') and keystr.endswith('>') - - -def _parse_single_key(keystr): - """Convert a single key string to a (Qt.Key, Qt.Modifiers, text) tuple.""" - # FIXME remove - - if is_special_key(keystr): - # Special key - keystr = keystr[1:-1] - elif len(keystr) == 1: - # vim-like key - pass - else: - raise KeyParseError(keystr, "Expecting either a single key or a " - " like keybinding.") - - seq = sequence.KeySequence(normalize_keystr(keystr), QKeySequence.PortableText) - if len(seq) != 1: - raise KeyParseError(keystr, "Got {} keys instead of 1.".format( - len(seq))) - result = seq[0] - - if result == Qt.Key_unknown: - raise KeyParseError(keystr, "Got unknown key.") - - modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier | - Qt.AltModifier | Qt.MetaModifier | Qt.KeypadModifier | - Qt.GroupSwitchModifier) - assert Qt.Key_unknown & ~modifier_mask == Qt.Key_unknown - - modifiers = result & modifier_mask - key = result & ~modifier_mask - - if len(keystr) == 1 and keystr.isupper(): - modifiers |= Qt.ShiftModifier - - assert key != 0, key - key = Qt.Key(key) - modifiers = Qt.KeyboardModifiers(modifiers) - - # Let's hope this is accurate... - if len(keystr) == 1 and not modifiers: - text = keystr - elif len(keystr) == 1 and modifiers == Qt.ShiftModifier: - text = keystr.upper() - else: - text = '' - - return KeyInfo(key, modifiers, text) - - -def _parse_keystring(keystr): - key = '' - special = False - for c in keystr: - if c == '>': - yield normalize_keystr(key) - key = '' - special = False - elif c == '<': - special = True - elif special: - key += c - else: - yield 'Shift+' + c if c.isupper() else c - - -def parse_keystring(keystr): - """Parse a keystring like or xyz and return a KeyInfo list.""" - s = ', '.join(_parse_keystring(keystr)) - return sequence.KeySequence(s) - - -def normalize_keystr(keystr): - """Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q. - - Args: - keystr: The key combination as a string. - - Return: - The normalized keystring. - """ - keystr = keystr.lower() - replacements = ( - ('control', 'ctrl'), - ('windows', 'meta'), - ('mod1', 'alt'), - ('mod4', 'meta'), - ) - for (orig, repl) in replacements: - keystr = keystr.replace(orig, repl) - for mod in ['ctrl', 'meta', 'alt', 'shift']: - keystr = keystr.replace(mod + '-', mod + '+') - return keystr - - class FakeIOStream(io.TextIOBase): """A fake file-like stream which calls a function for write-calls.""" From b1dde41b740262d89d0cf21a8d4e1940053f206d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 01:09:06 +0100 Subject: [PATCH 009/524] Rename sequence.py to keyutils.py --- qutebrowser/config/configtypes.py | 4 ++-- qutebrowser/keyinput/basekeyparser.py | 8 ++++---- qutebrowser/keyinput/{sequence.py => keyutils.py} | 0 qutebrowser/keyinput/modeparsers.py | 4 ++-- qutebrowser/misc/keyhintwidget.py | 4 ++-- qutebrowser/utils/utils.py | 1 - 6 files changed, 10 insertions(+), 11 deletions(-) rename qutebrowser/keyinput/{sequence.py => keyutils.py} (100%) diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index b7a21bb42..d6e3aa5fc 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -62,7 +62,7 @@ from PyQt5.QtWidgets import QTabWidget, QTabBar from qutebrowser.commands import cmdutils from qutebrowser.config import configexc from qutebrowser.utils import standarddir, utils, qtutils, urlutils -from qutebrowser.keyinput import sequence +from qutebrowser.keyinput import keyutils SYSTEM_PROXY = object() # Return value for Proxy type @@ -1655,4 +1655,4 @@ class Key(BaseType): #if utils.is_special_key(value): # value = '<{}>'.format(utils.normalize_keystr(value[1:-1])) #return value - return sequence.parse_keystring(value) + return keyutils.parse_keystring(value) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 14a7ccc6f..a2e07cb67 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -28,7 +28,7 @@ from PyQt5.QtGui import QKeySequence from qutebrowser.config import config from qutebrowser.utils import usertypes, log, utils -from qutebrowser.keyinput import sequence +from qutebrowser.keyinput import keyutils class BaseKeyParser(QObject): @@ -84,7 +84,7 @@ class BaseKeyParser(QObject): super().__init__(parent) self._win_id = win_id self._modename = None - self._sequence = sequence.KeySequence() + self._sequence = keyutils.KeySequence() self._count = '' if supports_count is None: supports_count = supports_chains @@ -121,7 +121,7 @@ class BaseKeyParser(QObject): A self.Match member. """ key = e.key() - txt = utils.keyevent_to_string(e) + txt = keyutils.keyevent_to_string(e) self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) if txt is None: @@ -264,6 +264,6 @@ class BaseKeyParser(QObject): if self._sequence: self._debug_log("discarding keystring '{}'.".format( self._sequence)) - self._sequence = sequence.KeySequence() + self._sequence = keyutils.KeySequence() self._count = '' self.keystring_updated.emit('') diff --git a/qutebrowser/keyinput/sequence.py b/qutebrowser/keyinput/keyutils.py similarity index 100% rename from qutebrowser/keyinput/sequence.py rename to qutebrowser/keyinput/keyutils.py diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 7c2088133..55521512a 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -30,7 +30,7 @@ from PyQt5.QtCore import pyqtSlot, Qt from qutebrowser.commands import cmdexc from qutebrowser.config import config -from qutebrowser.keyinput import keyparser +from qutebrowser.keyinput import keyparser, keyutils from qutebrowser.utils import usertypes, log, message, objreg, utils @@ -298,7 +298,7 @@ class RegisterKeyParser(keyparser.CommandKeyParser): key = e.text() - if key == '' or utils.keyevent_to_string(e) is None: + if key == '' or keyutils.keyevent_to_string(e) is None: # this is not a proper register key, let it pass and keep going return False diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index d4ed43141..7e6791d4e 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -35,7 +35,7 @@ from PyQt5.QtGui import QKeySequence from qutebrowser.config import config from qutebrowser.utils import utils, usertypes from qutebrowser.commands import cmdutils -from qutebrowser.keyinput import sequence +from qutebrowser.keyinput import keyutils class KeyHintView(QLabel): @@ -107,7 +107,7 @@ class KeyHintView(QLabel): bindings_dict = config.key_instance.get_bindings_for(modename) bindings = [(k, v) for (k, v) in sorted(bindings_dict.items()) - if k.matches(sequence.KeySequence(prefix)) and # FIXME + if k.matches(keyutils.KeySequence(prefix)) and # FIXME not blacklisted(k) and (takes_count(v) or not countstr)] diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index e046b28b8..6cf38229b 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -50,7 +50,6 @@ except ImportError: # pragma: no cover import qutebrowser from qutebrowser.utils import qtutils, log, debug -from qutebrowser.keyinput import sequence fake_clipboard = None From 21b3e05ed01c8c9eae15b8fb9db3a4483249a7c5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 01:10:25 +0100 Subject: [PATCH 010/524] Fix getting reverse bindings --- qutebrowser/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 1f910f549..865ab2834 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -165,7 +165,7 @@ class KeyConfig: # if utils.is_special_key(key): # cmd_to_keys[cmd].append(key) # else: - cmd_to_keys[cmd].insert(0, key.toString()) + cmd_to_keys[cmd].insert(0, str(key)) return cmd_to_keys def get_command(self, key, mode, default=False): From d961211188159c5ac6c814998030535d913d73c5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 01:13:07 +0100 Subject: [PATCH 011/524] Delete some old code --- qutebrowser/keyinput/keyutils.py | 80 -------------------------------- 1 file changed, 80 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index e47b37579..9c7bc1169 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -181,32 +181,6 @@ def keyevent_to_string(e): return normalize_keystr('+'.join(parts)) -@attr.s(repr=False) -class KeyInfo: - - """Stores information about a key, like used in a QKeyEvent. - - Attributes: - key: Qt::Key - modifiers: Qt::KeyboardModifiers - text: str - """ - - key = attr.ib() - modifiers = attr.ib() - text = attr.ib() - - def __repr__(self): - if self.modifiers is None: - modifiers = None - else: - #modifiers = qflags_key(Qt, self.modifiers) - modifiers = hex(int(self.modifiers)) - return utils.get_repr(self, constructor=True, - key=debug.qenum_key(Qt, self.key), - modifiers=modifiers, text=self.text) - - class KeyParseError(Exception): """Raised by _parse_single_key/parse_keystring on parse errors.""" @@ -215,60 +189,6 @@ class KeyParseError(Exception): super().__init__("Could not parse {!r}: {}".format(keystr, error)) -def is_special_key(keystr): - """True if keystr is a 'special' keystring (e.g. or ).""" - return keystr.startswith('<') and keystr.endswith('>') - - -def _parse_single_key(keystr): - """Convert a single key string to a (Qt.Key, Qt.Modifiers, text) tuple.""" - # FIXME remove - - if is_special_key(keystr): - # Special key - keystr = keystr[1:-1] - elif len(keystr) == 1: - # vim-like key - pass - else: - raise KeyParseError(keystr, "Expecting either a single key or a " - " like keybinding.") - - seq = KeySequence(normalize_keystr(keystr), QKeySequence.PortableText) - if len(seq) != 1: - raise KeyParseError(keystr, "Got {} keys instead of 1.".format( - len(seq))) - result = seq[0] - - if result == Qt.Key_unknown: - raise KeyParseError(keystr, "Got unknown key.") - - modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier | - Qt.AltModifier | Qt.MetaModifier | Qt.KeypadModifier | - Qt.GroupSwitchModifier) - assert Qt.Key_unknown & ~modifier_mask == Qt.Key_unknown - - modifiers = result & modifier_mask - key = result & ~modifier_mask - - if len(keystr) == 1 and keystr.isupper(): - modifiers |= Qt.ShiftModifier - - assert key != 0, key - key = Qt.Key(key) - modifiers = Qt.KeyboardModifiers(modifiers) - - # Let's hope this is accurate... - if len(keystr) == 1 and not modifiers: - text = keystr - elif len(keystr) == 1 and modifiers == Qt.ShiftModifier: - text = keystr.upper() - else: - text = '' - - return KeyInfo(key, modifiers, text) - - def _parse_keystring(keystr): key = '' special = False From c98eb5502d968c52ac9c567977e27a63d91d2382 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 01:19:16 +0100 Subject: [PATCH 012/524] Add some FIXMEs --- qutebrowser/keyinput/modeparsers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 55521512a..86e03877a 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -68,6 +68,7 @@ class NormalKeyParser(keyparser.CommandKeyParser): Return: A self.Match member. """ + # FIXME rewrite this txt = e.text().strip() if self._inhibited: self._debug_log("Ignoring key '{}', because the normal mode is " @@ -166,6 +167,7 @@ class HintKeyParser(keyparser.CommandKeyParser): Return: True if event has been handled, False otherwise. """ + # FIXME rewrite this log.keyboard.debug("Got special key 0x{:x} text {}".format( e.key(), e.text())) hintmanager = objreg.get('hintmanager', scope='tab', @@ -209,6 +211,7 @@ class HintKeyParser(keyparser.CommandKeyParser): Returns: True if the match has been handled, False otherwise. """ + # FIXME rewrite this match = self._handle_single_key(e) if match == self.Match.partial: self.keystring_updated.emit(self._keystring) @@ -293,6 +296,7 @@ class RegisterKeyParser(keyparser.CommandKeyParser): Return: True if event has been handled, False otherwise. """ + # FIXME rewrite this if super().handle(e): return True From 5cee39d315250b82dc61f7eb9365f7cf7e1feb96 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 01:41:55 +0100 Subject: [PATCH 013/524] Initial move of keyutils tests --- tests/unit/keyinput/test_keyutils.py | 152 +++++++++++++++++++++++++++ tests/unit/utils/test_utils.py | 144 ------------------------- 2 files changed, 152 insertions(+), 144 deletions(-) create mode 100644 tests/unit/keyinput/test_keyutils.py diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py new file mode 100644 index 000000000..e901de0b5 --- /dev/null +++ b/tests/unit/keyinput/test_keyutils.py @@ -0,0 +1,152 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2017 Florian Bruhin (The Compiler) +# +# 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 . + +import pytest +from PyQt5.QtCore import Qt + +from qutebrowser.keyinput import keyutils + + +class TestKeyToString: + + """Test key_to_string.""" + + @pytest.mark.parametrize('key, expected', [ + (Qt.Key_Blue, 'Blue'), + (Qt.Key_Backtab, 'Tab'), + (Qt.Key_Escape, 'Escape'), + (Qt.Key_A, 'A'), + (Qt.Key_degree, '°'), + (Qt.Key_Meta, 'Meta'), + ]) + def test_normal(self, key, expected): + """Test a special key where QKeyEvent::toString works incorrectly.""" + assert keyutils.key_to_string(key) == expected + + def test_missing(self, monkeypatch): + """Test with a missing key.""" + monkeypatch.delattr(keyutils.Qt, 'Key_Blue') + # We don't want to test the key which is actually missing - we only + # want to know if the mapping still behaves properly. + assert keyutils.key_to_string(Qt.Key_A) == 'A' + + def test_all(self): + """Make sure there's some sensible output for all keys.""" + for name, value in sorted(vars(Qt).items()): + if not isinstance(value, Qt.Key): + continue + print(name) + string = keyutils.key_to_string(value) + assert string + string.encode('utf-8') # make sure it's encodable + + +class TestKeyEventToString: + + """Test keyevent_to_string.""" + + def test_only_control(self, fake_keyevent_factory): + """Test keyeevent when only control is pressed.""" + evt = fake_keyevent_factory(key=Qt.Key_Control, + modifiers=Qt.ControlModifier) + assert keyutils.keyevent_to_string(evt) is None + + def test_only_hyper_l(self, fake_keyevent_factory): + """Test keyeevent when only Hyper_L is pressed.""" + evt = fake_keyevent_factory(key=Qt.Key_Hyper_L, + modifiers=Qt.MetaModifier) + assert keyutils.keyevent_to_string(evt) is None + + def test_only_key(self, fake_keyevent_factory): + """Test with a simple key pressed.""" + evt = fake_keyevent_factory(key=Qt.Key_A) + assert keyutils.keyevent_to_string(evt) == 'a' + + def test_key_and_modifier(self, fake_keyevent_factory): + """Test with key and modifier pressed.""" + evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) + expected = 'meta+a' if keyutils.is_mac else 'ctrl+a' + assert keyutils.keyevent_to_string(evt) == expected + + def test_key_and_modifiers(self, fake_keyevent_factory): + """Test with key and multiple modifiers pressed.""" + evt = fake_keyevent_factory( + key=Qt.Key_A, modifiers=(Qt.ControlModifier | Qt.AltModifier | + Qt.MetaModifier | Qt.ShiftModifier)) + assert keyutils.keyevent_to_string(evt) == 'ctrl+alt+meta+shift+a' + + @pytest.mark.fake_os('mac') + def test_mac(self, fake_keyevent_factory): + """Test with a simulated mac.""" + evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) + assert keyutils.keyevent_to_string(evt) == 'meta+a' + + +@pytest.mark.parametrize('keystr, expected', [ + ('', keyutils.KeyInfo(Qt.Key_X, Qt.ControlModifier, '')), + ('', keyutils.KeyInfo(Qt.Key_X, Qt.MetaModifier, '')), + ('', + keyutils.KeyInfo(Qt.Key_Y, Qt.ControlModifier | Qt.AltModifier, '')), + ('x', keyutils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x')), + ('X', keyutils.KeyInfo(Qt.Key_X, Qt.ShiftModifier, 'X')), + ('', keyutils.KeyInfo(Qt.Key_Escape, Qt.NoModifier, '')), + + ('foobar', keyutils.KeyParseError), + ('x, y', keyutils.KeyParseError), + ('xyz', keyutils.KeyParseError), + ('Escape', keyutils.KeyParseError), + (', ', keyutils.KeyParseError), +]) +def test_parse_single_key(keystr, expected): + if expected is keyutils.KeyParseError: + with pytest.raises(keyutils.KeyParseError): + keyutils._parse_single_key(keystr) + else: + assert keyutils._parse_single_key(keystr) == expected + + +@pytest.mark.parametrize('keystr, expected', [ + ('', [keyutils.KeyInfo(Qt.Key_X, Qt.ControlModifier, '')]), + ('x', [keyutils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x')]), + ('xy', [keyutils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x'), + keyutils.KeyInfo(Qt.Key_Y, Qt.NoModifier, 'y')]), + + ('', keyutils.KeyParseError), +]) +def test_parse_keystring(keystr, expected): + if expected is keyutils.KeyParseError: + with pytest.raises(keyutils.KeyParseError): + keyutils.parse_keystring(keystr) + else: + assert keyutils.parse_keystring(keystr) == expected + + +@pytest.mark.parametrize('orig, repl', [ + ('Control+x', 'ctrl+x'), + ('Windows+x', 'meta+x'), + ('Mod1+x', 'alt+x'), + ('Mod4+x', 'meta+x'), + ('Control--', 'ctrl+-'), + ('Windows++', 'meta++'), + ('ctrl-x', 'ctrl+x'), + ('control+x', 'ctrl+x') +]) +def test_normalize_keystr(orig, repl): + assert keyutils.normalize_keystr(orig) == repl + diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index 28837e93c..2ca0bc91c 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -297,134 +297,6 @@ class TestFormatSize: assert utils.format_size(size, base=1000) == out -class TestKeyToString: - - """Test key_to_string.""" - - @pytest.mark.parametrize('key, expected', [ - (Qt.Key_Blue, 'Blue'), - (Qt.Key_Backtab, 'Tab'), - (Qt.Key_Escape, 'Escape'), - (Qt.Key_A, 'A'), - (Qt.Key_degree, '°'), - (Qt.Key_Meta, 'Meta'), - ]) - def test_normal(self, key, expected): - """Test a special key where QKeyEvent::toString works incorrectly.""" - assert utils.key_to_string(key) == expected - - def test_missing(self, monkeypatch): - """Test with a missing key.""" - monkeypatch.delattr(utils.Qt, 'Key_Blue') - # We don't want to test the key which is actually missing - we only - # want to know if the mapping still behaves properly. - assert utils.key_to_string(Qt.Key_A) == 'A' - - def test_all(self): - """Make sure there's some sensible output for all keys.""" - for name, value in sorted(vars(Qt).items()): - if not isinstance(value, Qt.Key): - continue - print(name) - string = utils.key_to_string(value) - assert string - string.encode('utf-8') # make sure it's encodable - - -class TestKeyEventToString: - - """Test keyevent_to_string.""" - - def test_only_control(self, fake_keyevent_factory): - """Test keyeevent when only control is pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_Control, - modifiers=Qt.ControlModifier) - assert utils.keyevent_to_string(evt) is None - - def test_only_hyper_l(self, fake_keyevent_factory): - """Test keyeevent when only Hyper_L is pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_Hyper_L, - modifiers=Qt.MetaModifier) - assert utils.keyevent_to_string(evt) is None - - def test_only_key(self, fake_keyevent_factory): - """Test with a simple key pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_A) - assert utils.keyevent_to_string(evt) == 'a' - - def test_key_and_modifier(self, fake_keyevent_factory): - """Test with key and modifier pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) - expected = 'meta+a' if utils.is_mac else 'ctrl+a' - assert utils.keyevent_to_string(evt) == expected - - def test_key_and_modifiers(self, fake_keyevent_factory): - """Test with key and multiple modifiers pressed.""" - evt = fake_keyevent_factory( - key=Qt.Key_A, modifiers=(Qt.ControlModifier | Qt.AltModifier | - Qt.MetaModifier | Qt.ShiftModifier)) - assert utils.keyevent_to_string(evt) == 'ctrl+alt+meta+shift+a' - - @pytest.mark.fake_os('mac') - def test_mac(self, fake_keyevent_factory): - """Test with a simulated mac.""" - evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) - assert utils.keyevent_to_string(evt) == 'meta+a' - - -@pytest.mark.parametrize('keystr, expected', [ - ('', utils.KeyInfo(Qt.Key_X, Qt.ControlModifier, '')), - ('', utils.KeyInfo(Qt.Key_X, Qt.MetaModifier, '')), - ('', - utils.KeyInfo(Qt.Key_Y, Qt.ControlModifier | Qt.AltModifier, '')), - ('x', utils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x')), - ('X', utils.KeyInfo(Qt.Key_X, Qt.ShiftModifier, 'X')), - ('', utils.KeyInfo(Qt.Key_Escape, Qt.NoModifier, '')), - - ('foobar', utils.KeyParseError), - ('x, y', utils.KeyParseError), - ('xyz', utils.KeyParseError), - ('Escape', utils.KeyParseError), - (', ', utils.KeyParseError), -]) -def test_parse_single_key(keystr, expected): - if expected is utils.KeyParseError: - with pytest.raises(utils.KeyParseError): - utils._parse_single_key(keystr) - else: - assert utils._parse_single_key(keystr) == expected - - -@pytest.mark.parametrize('keystr, expected', [ - ('', [utils.KeyInfo(Qt.Key_X, Qt.ControlModifier, '')]), - ('x', [utils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x')]), - ('xy', [utils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x'), - utils.KeyInfo(Qt.Key_Y, Qt.NoModifier, 'y')]), - - ('', utils.KeyParseError), -]) -def test_parse_keystring(keystr, expected): - if expected is utils.KeyParseError: - with pytest.raises(utils.KeyParseError): - utils.parse_keystring(keystr) - else: - assert utils.parse_keystring(keystr) == expected - - -@pytest.mark.parametrize('orig, repl', [ - ('Control+x', 'ctrl+x'), - ('Windows+x', 'meta+x'), - ('Mod1+x', 'alt+x'), - ('Mod4+x', 'meta+x'), - ('Control--', 'ctrl+-'), - ('Windows++', 'meta++'), - ('ctrl-x', 'ctrl+x'), - ('control+x', 'ctrl+x') -]) -def test_normalize_keystr(orig, repl): - assert utils.normalize_keystr(orig) == repl - - class TestFakeIOStream: """Test FakeIOStream.""" @@ -832,22 +704,6 @@ class TestGetSetClipboard: utils.get_clipboard(fallback=True) -@pytest.mark.parametrize('keystr, expected', [ - ('', True), - ('', True), - ('', True), - ('x', False), - ('X', False), - ('', True), - ('foobar', False), - ('foo>', False), - (' Date: Fri, 29 Dec 2017 01:43:47 +0100 Subject: [PATCH 014/524] fixme --- qutebrowser/browser/commands.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 92a841d72..823d12c99 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -2084,6 +2084,7 @@ class CommandDispatcher: keystring: The keystring to send. global_: If given, the keys are sent to the qutebrowser UI. """ + # FIXME: rewrite try: keyinfos = utils.parse_keystring(keystring) except utils.KeyParseError as e: From cc747b00ce617b61941e8edd29bcd7e1ec0df10e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 01:50:51 +0100 Subject: [PATCH 015/524] Move parsing to class --- qutebrowser/config/configtypes.py | 2 +- qutebrowser/keyinput/keyutils.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index d6e3aa5fc..218d31193 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -1655,4 +1655,4 @@ class Key(BaseType): #if utils.is_special_key(value): # value = '<{}>'.format(utils.normalize_keystr(value[1:-1])) #return value - return keyutils.parse_keystring(value) + return keyutils.KeySequence.parse(value) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 9c7bc1169..43808f8a5 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -205,12 +205,6 @@ def _parse_keystring(keystr): yield 'Shift+' + c if c.isupper() else c -def parse_keystring(keystr): - """Parse a keystring like or xyz and return a KeyInfo list.""" - s = ', '.join(_parse_keystring(keystr)) - return KeySequence(s) - - def normalize_keystr(keystr): """Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q. @@ -266,3 +260,9 @@ class KeySequence: def append_event(self, ev): return self.__class__(*self._sequence, ev.modifiers() | ev.key()) + + @classmethod + def parse(cls, keystr): + """Parse a keystring like or xyz and return a KeySequence.""" + s = ', '.join(_parse_keystring(keystr)) + return cls(s) From 917f2a30de5e0f104e555a718a8d33b6898c9a73 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 13:23:38 +0100 Subject: [PATCH 016/524] Get tests to collect --- qutebrowser/keyinput/keyutils.py | 1 + tests/unit/keyinput/test_keyutils.py | 41 ++++++++-------------------- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 43808f8a5..3a107142b 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -232,6 +232,7 @@ class KeySequence: def __init__(self, *args): self._sequence = QKeySequence(*args) + # FIXME handle more than 4 keys def __str__(self): return self._sequence.toString() diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index e901de0b5..4311a4aeb 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -99,21 +99,20 @@ class TestKeyEventToString: @pytest.mark.parametrize('keystr, expected', [ - ('', keyutils.KeyInfo(Qt.Key_X, Qt.ControlModifier, '')), - ('', keyutils.KeyInfo(Qt.Key_X, Qt.MetaModifier, '')), + ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), + ('', keyutils.KeySequence(Qt.MetaModifier | Qt.Key_X)), ('', - keyutils.KeyInfo(Qt.Key_Y, Qt.ControlModifier | Qt.AltModifier, '')), - ('x', keyutils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x')), - ('X', keyutils.KeyInfo(Qt.Key_X, Qt.ShiftModifier, 'X')), - ('', keyutils.KeyInfo(Qt.Key_Escape, Qt.NoModifier, '')), - - ('foobar', keyutils.KeyParseError), - ('x, y', keyutils.KeyParseError), - ('xyz', keyutils.KeyParseError), - ('Escape', keyutils.KeyParseError), - (', ', keyutils.KeyParseError), + keyutils.KeySequence(Qt.ControlModifier | Qt.AltModifier | Qt.Key_Y)), + ('x', keyutils.KeySequence(Qt.Key_X)), + ('X', keyutils.KeySequence(Qt.ShiftModifier | Qt.Key_X)), + ('', keyutils.KeySequence(Qt.Key_Escape)), + ('xyz', keyutils.KeySequence(Qt.Key_X, Qt.Key_Y, Qt.Key_Z)), + ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X, + Qt.MetaModifier | Qt.Key_Y)), + # FIXME + # (', ', keyutils.KeyParseError), ]) -def test_parse_single_key(keystr, expected): +def test_parse(keystr, expected): if expected is keyutils.KeyParseError: with pytest.raises(keyutils.KeyParseError): keyutils._parse_single_key(keystr) @@ -121,22 +120,6 @@ def test_parse_single_key(keystr, expected): assert keyutils._parse_single_key(keystr) == expected -@pytest.mark.parametrize('keystr, expected', [ - ('', [keyutils.KeyInfo(Qt.Key_X, Qt.ControlModifier, '')]), - ('x', [keyutils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x')]), - ('xy', [keyutils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x'), - keyutils.KeyInfo(Qt.Key_Y, Qt.NoModifier, 'y')]), - - ('', keyutils.KeyParseError), -]) -def test_parse_keystring(keystr, expected): - if expected is keyutils.KeyParseError: - with pytest.raises(keyutils.KeyParseError): - keyutils.parse_keystring(keystr) - else: - assert keyutils.parse_keystring(keystr) == expected - - @pytest.mark.parametrize('orig, repl', [ ('Control+x', 'ctrl+x'), ('Windows+x', 'meta+x'), From d9c768ed86a30a8608ab934b8591dcdc88907215 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 13:53:43 +0100 Subject: [PATCH 017/524] Strip out shift modifier for non-alpha bindings --- qutebrowser/keyinput/keyutils.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 3a107142b..8471f1479 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -260,7 +260,27 @@ class KeySequence: return self._sequence.matches(other._sequence) def append_event(self, ev): - return self.__class__(*self._sequence, ev.modifiers() | ev.key()) + """Create a new KeySequence object with the given QKeyEvent added. + + We need to do some sophisticated checking of modifiers here: + + We don't care about a shift modifier with symbols (Shift-: should match + a : binding even though we typed it with a shift on an US-keyboard) + + However, we *do* care about Shift being involved if we got an upper-case + letter, as Shift-A should match a Shift-A binding, but not an "a" + binding. + + In addition, Shift also *is* relevant when other modifiers are involved. + Shift-Ctrl-X should not be equivalent to Ctrl-X. + + FIXME: create test cases! + """ + modifiers = ev.modifiers() + if (modifiers == Qt.ShiftModifier and + unicodedata.category(ev.text()) != 'Lu'): + modifiers = Qt.KeyboardModifiers() + return self.__class__(*self._sequence, modifiers | ev.key()) @classmethod def parse(cls, keystr): From 7b17ab4b3f3de33c85ebe1b88ddbf319143b67f5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 14:22:20 +0100 Subject: [PATCH 018/524] Initial str() attempt --- qutebrowser/config/config.py | 7 ++----- qutebrowser/keyinput/keyutils.py | 27 +++++++++++++++------------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 865ab2834..222b0fda3 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -28,6 +28,7 @@ from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject from qutebrowser.config import configdata, configexc from qutebrowser.utils import utils, log, jinja from qutebrowser.misc import objects +from qutebrowser.keyinput import keyutils # An easy way to access the config from other code via config.val.foo val = None @@ -136,11 +137,7 @@ class KeyConfig: """Make sure the given mode exists and normalize the key.""" if mode not in configdata.DATA['bindings.default'].default: raise configexc.KeybindingError("Invalid mode {}!".format(mode)) - # FIXME needed? - # if utils.is_special_key(key): - # # , , and should be considered equivalent - # return utils.normalize_keystr(key) - return key + return str(keyutils.KeySequence.parse(key)) def get_bindings_for(self, mode): """Get the combined bindings for the given mode.""" diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 8471f1479..bbb081f3d 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -127,10 +127,12 @@ def key_to_string(key): def keyevent_to_string(e): - """Convert a QKeyEvent to a meaningful name. + """Convert a QKeyEvent to a meaningful name.""" + return key_with_modifiers_to_string(int(e.key()) | int(e.modifiers())) - Args: - e: A QKeyEvent. + +def key_with_modifiers_to_string(key): + """Convert a Qt.Key with modifiers to a meaningful name. Return: A name of the key (combination) as a string or @@ -154,27 +156,27 @@ def keyevent_to_string(e): (Qt.MetaModifier, 'Meta'), (Qt.ShiftModifier, 'Shift'), ]) - modifiers = (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, - Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, Qt.Key_Hyper_L, - Qt.Key_Hyper_R, Qt.Key_Direction_L, Qt.Key_Direction_R) - if e.key() in modifiers: + modifiers = (Qt.Key_Control | Qt.Key_Alt | Qt.Key_Shift | Qt.Key_Meta | + Qt.Key_AltGr | Qt.Key_Super_L | Qt.Key_Super_R | + Qt.Key_Hyper_L | Qt.Key_Hyper_R | Qt.Key_Direction_L | + Qt.Key_Direction_R) + if not (key & ~modifiers): # Only modifier pressed return None - mod = e.modifiers() parts = [] for (mask, s) in modmask2str.items(): - if mod & mask and s not in parts: + if key & mask and s not in parts: parts.append(s) - key_string = key_to_string(e.key()) + key_string = key_to_string(key & ~modifiers) if len(key_string) == 1: category = unicodedata.category(key_string) is_control_char = (category == 'Cc') else: is_control_char = False - if e.modifiers() == Qt.ShiftModifier and not is_control_char: + if key & ~modifiers == Qt.ShiftModifier and not is_control_char: parts = [] parts.append(key_string) @@ -235,7 +237,8 @@ class KeySequence: # FIXME handle more than 4 keys def __str__(self): - return self._sequence.toString() + return ''.join(key_with_modifiers_to_string(key) + for key in self._sequence) def __repr__(self): return utils.get_repr(self, keys=str(self)) From f1fe26b0b73326e092438134e72f8054abd7903b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 14:40:00 +0100 Subject: [PATCH 019/524] Handle modifiers correctly --- qutebrowser/keyinput/keyutils.py | 35 +++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index bbb081f3d..77a45a7fe 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -128,10 +128,10 @@ def key_to_string(key): def keyevent_to_string(e): """Convert a QKeyEvent to a meaningful name.""" - return key_with_modifiers_to_string(int(e.key()) | int(e.modifiers())) + return key_with_modifiers_to_string(e.key(), e.modifiers()) -def key_with_modifiers_to_string(key): +def key_with_modifiers_to_string(key, modifiers): """Convert a Qt.Key with modifiers to a meaningful name. Return: @@ -156,27 +156,30 @@ def key_with_modifiers_to_string(key): (Qt.MetaModifier, 'Meta'), (Qt.ShiftModifier, 'Shift'), ]) - modifiers = (Qt.Key_Control | Qt.Key_Alt | Qt.Key_Shift | Qt.Key_Meta | - Qt.Key_AltGr | Qt.Key_Super_L | Qt.Key_Super_R | - Qt.Key_Hyper_L | Qt.Key_Hyper_R | Qt.Key_Direction_L | - Qt.Key_Direction_R) - if not (key & ~modifiers): + + modifier_keys = (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, + Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, + Qt.Key_Hyper_L, Qt.Key_Hyper_R, Qt.Key_Direction_L, + Qt.Key_Direction_R) + if key in modifier_keys: # Only modifier pressed return None parts = [] for (mask, s) in modmask2str.items(): - if key & mask and s not in parts: + if modifiers & mask and s not in parts: parts.append(s) - key_string = key_to_string(key & ~modifiers) + key_string = key_to_string(key) + + # FIXME needed? if len(key_string) == 1: category = unicodedata.category(key_string) is_control_char = (category == 'Cc') else: is_control_char = False - if key & ~modifiers == Qt.ShiftModifier and not is_control_char: + if modifiers == Qt.ShiftModifier and not is_control_char: parts = [] parts.append(key_string) @@ -237,8 +240,16 @@ class KeySequence: # FIXME handle more than 4 keys def __str__(self): - return ''.join(key_with_modifiers_to_string(key) - for key in self._sequence) + modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier | + Qt.AltModifier | Qt.MetaModifier | + Qt.KeypadModifier | Qt.GroupSwitchModifier) + parts = [] + for key in self._sequence: + part = key_with_modifiers_to_string( + key=int(key) & ~modifier_mask, + modifiers=int(key) & modifier_mask) + parts.append(part) + return ''.join(parts) def __repr__(self): return utils.get_repr(self, keys=str(self)) From 737ff2cc690b9c848ab54b33eec128917be8709b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 14:43:04 +0100 Subject: [PATCH 020/524] Add <> around special keys in __str__ --- qutebrowser/keyinput/keyutils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 77a45a7fe..31c9507b3 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -183,7 +183,13 @@ def key_with_modifiers_to_string(key, modifiers): parts = [] parts.append(key_string) - return normalize_keystr('+'.join(parts)) + normalized = normalize_keystr('+'.join(parts)) + if len(normalized) > 1: + # "special" binding + return '<{}>'.format(normalized) + else: + # "normal" binding + return normalized class KeyParseError(Exception): From 28b6b97f39aab6f6402d6d76fb0f1e99378d5a60 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 15:41:12 +0100 Subject: [PATCH 021/524] Try to have strings in KeyConfig --- qutebrowser/config/config.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 222b0fda3..059ddc089 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -141,8 +141,11 @@ class KeyConfig: def get_bindings_for(self, mode): """Get the combined bindings for the given mode.""" - bindings = dict(val.bindings.default[mode]) - for key, binding in val.bindings.commands[mode].items(): + bindings = self._config.get_obj( + 'bindings.default', mutable=False)[mode] + bindings_commands = self._config.get_obj( + 'bindings.commands', mutable=False).get(mode, {}) + for key, binding in bindings_commands.items(): if binding is None: bindings.pop(key, None) else: @@ -169,7 +172,8 @@ class KeyConfig: """Get the command for a given key (or None).""" key = self._prepare(key, mode) if default: - bindings = dict(val.bindings.default[mode]) + bindings = self._config.get_obj( + 'bindings.default', mutable=False)[mode] else: bindings = self.get_bindings_for(mode) return bindings.get(key, None) @@ -208,11 +212,12 @@ class KeyConfig: key = self._prepare(key, mode) bindings_commands = self._config.get_obj('bindings.commands') + bindings_default = self._config.get_obj('bindings.default') - if val.bindings.commands[mode].get(key, None) is not None: + if key in bindings_commands[mode]: # In custom bindings -> remove it del bindings_commands[mode][key] - elif key in val.bindings.default[mode]: + elif key in bindings_default[mode]: # In default bindings -> shadow it with None if mode not in bindings_commands: bindings_commands[mode] = {} From 7b3cb14e6ec59a64b8b4911829cc5eda4aaf1f82 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 15:41:28 +0100 Subject: [PATCH 022/524] Revert "Try to have strings in KeyConfig" This reverts commit 28b6b97f39aab6f6402d6d76fb0f1e99378d5a60. --- qutebrowser/config/config.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 059ddc089..222b0fda3 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -141,11 +141,8 @@ class KeyConfig: def get_bindings_for(self, mode): """Get the combined bindings for the given mode.""" - bindings = self._config.get_obj( - 'bindings.default', mutable=False)[mode] - bindings_commands = self._config.get_obj( - 'bindings.commands', mutable=False).get(mode, {}) - for key, binding in bindings_commands.items(): + bindings = dict(val.bindings.default[mode]) + for key, binding in val.bindings.commands[mode].items(): if binding is None: bindings.pop(key, None) else: @@ -172,8 +169,7 @@ class KeyConfig: """Get the command for a given key (or None).""" key = self._prepare(key, mode) if default: - bindings = self._config.get_obj( - 'bindings.default', mutable=False)[mode] + bindings = dict(val.bindings.default[mode]) else: bindings = self.get_bindings_for(mode) return bindings.get(key, None) @@ -212,12 +208,11 @@ class KeyConfig: key = self._prepare(key, mode) bindings_commands = self._config.get_obj('bindings.commands') - bindings_default = self._config.get_obj('bindings.default') - if key in bindings_commands[mode]: + if val.bindings.commands[mode].get(key, None) is not None: # In custom bindings -> remove it del bindings_commands[mode][key] - elif key in bindings_default[mode]: + elif key in val.bindings.default[mode]: # In default bindings -> shadow it with None if mode not in bindings_commands: bindings_commands[mode] = {} From caa05df16d350b0119377a728040a7651be2968a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 15:58:20 +0100 Subject: [PATCH 023/524] Use KeySequences in config.py --- qutebrowser/completion/models/configmodel.py | 6 ++++-- qutebrowser/config/config.py | 14 ++++++++------ qutebrowser/config/configcommands.py | 11 +++++++---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index 445a57a66..1a433a460 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -22,6 +22,7 @@ from qutebrowser.config import configdata, configexc from qutebrowser.completion.models import completionmodel, listcategory, util from qutebrowser.commands import runners, cmdexc +from qutebrowser.keyinput import keyutils def option(*, info): @@ -79,8 +80,9 @@ def bind(key, *, info): """ model = completionmodel.CompletionModel(column_widths=(20, 60, 20)) data = [] + seq = keyutils.KeySequence.parse(key) - cmd_text = info.keyconf.get_command(key, 'normal') + cmd_text = info.keyconf.get_command(seq, 'normal') if cmd_text: parser = runners.CommandParser() try: @@ -90,7 +92,7 @@ def bind(key, *, info): else: data.append((cmd_text, '(Current) {}'.format(cmd.desc), key)) - cmd_text = info.keyconf.get_command(key, 'normal', default=True) + cmd_text = info.keyconf.get_command(seq, 'normal', default=True) if cmd_text: parser = runners.CommandParser() cmd = parser.parse(cmd_text).cmd diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 222b0fda3..4d83db409 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -135,9 +135,11 @@ class KeyConfig: def _prepare(self, key, mode): """Make sure the given mode exists and normalize the key.""" + # 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)) - return str(keyutils.KeySequence.parse(key)) + return key def get_bindings_for(self, mode): """Get the combined bindings for the given mode.""" @@ -188,7 +190,7 @@ class KeyConfig: bindings = self._config.get_obj('bindings.commands') if mode not in bindings: bindings[mode] = {} - bindings[mode][key] = command + bindings[mode][str(key)] = command self._config.update_mutables(save_yaml=save_yaml) def bind_default(self, key, *, mode='normal', save_yaml=False): @@ -197,7 +199,7 @@ class KeyConfig: bindings_commands = self._config.get_obj('bindings.commands') try: - del bindings_commands[mode][key] + del bindings_commands[mode][str(key)] except KeyError: raise configexc.KeybindingError( "Can't find binding '{}' in {} mode".format(key, mode)) @@ -209,14 +211,14 @@ class KeyConfig: bindings_commands = self._config.get_obj('bindings.commands') - if val.bindings.commands[mode].get(key, None) is not None: + if str(key) in bindings_commands.get(mode, {}): # In custom bindings -> remove it - del bindings_commands[mode][key] + 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][key] = None + bindings_commands[mode][str(key)] = None else: raise configexc.KeybindingError( "Can't find binding '{}' in {} mode".format(key, mode)) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 711fe861c..6d6d53c0c 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -29,6 +29,7 @@ from qutebrowser.completion.models import configmodel from qutebrowser.utils import objreg, utils, message, standarddir from qutebrowser.config import configtypes, configexc, configfiles, configdata from qutebrowser.misc import editor +from qutebrowser.keyinput import keyutils class ConfigCommands: @@ -108,11 +109,12 @@ class ConfigCommands: available modes. default: If given, restore a default binding. """ + seq = keyutils.KeySequence(key) if command is None: if default: # :bind --default: Restore default with self._handle_config_error(): - self._keyconfig.bind_default(key, mode=mode, + self._keyconfig.bind_default(seq, mode=mode, save_yaml=True) return @@ -122,7 +124,7 @@ class ConfigCommands: # # normalized for the output below # key = utils.normalize_keystr(key) with self._handle_config_error(): - cmd = self._keyconfig.get_command(key, mode) + cmd = self._keyconfig.get_command(seq, mode) if cmd is None: message.info("{} is unbound in {} mode".format(key, mode)) else: @@ -131,7 +133,7 @@ class ConfigCommands: return with self._handle_config_error(): - self._keyconfig.bind(key, command, mode=mode, save_yaml=True) + self._keyconfig.bind(seq, command, mode=mode, save_yaml=True) @cmdutils.register(instance='config-commands') def unbind(self, key, *, mode='normal'): @@ -143,7 +145,8 @@ class ConfigCommands: See `:help bindings.commands` for the available modes. """ with self._handle_config_error(): - self._keyconfig.unbind(key, mode=mode, save_yaml=True) + self._keyconfig.unbind(keyutils.KeySequence.parse(key), mode=mode, + save_yaml=True) @cmdutils.register(instance='config-commands', star_args_optional=True) @cmdutils.argument('option', completion=configmodel.option) From 81e90602393ae295d4dbbeeaf96eba797388334a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 16:01:30 +0100 Subject: [PATCH 024/524] Make sure KeySequence keys are valid --- qutebrowser/keyinput/keyutils.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 31c9507b3..c67ac7024 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -243,6 +243,8 @@ class KeySequence: def __init__(self, *args): self._sequence = QKeySequence(*args) + for key in self._sequence: + assert key != Qt.Key_unknown # FIXME handle more than 4 keys def __str__(self): @@ -275,6 +277,9 @@ class KeySequence: def __hash__(self): return hash(self._sequence) + def __len__(self): + return len(self._sequence) + def matches(self, other): # pylint: disable=protected-access return self._sequence.matches(other._sequence) @@ -306,4 +311,6 @@ class KeySequence: def parse(cls, keystr): """Parse a keystring like or xyz and return a KeySequence.""" s = ', '.join(_parse_keystring(keystr)) - return cls(s) + new = cls(s) + assert len(new) > 0 + return new From a145497c654ffbf35cbf549c20fb6da47a8a9290 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 16:05:16 +0100 Subject: [PATCH 025/524] Make :unbind work correctly --- qutebrowser/config/configcommands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 6d6d53c0c..d171d164f 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -109,7 +109,7 @@ class ConfigCommands: available modes. default: If given, restore a default binding. """ - seq = keyutils.KeySequence(key) + seq = keyutils.KeySequence.parse(key) if command is None: if default: # :bind --default: Restore default From dcf89f7a2889698b4a45e28c157581350bcdd2b2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Dec 2017 16:10:12 +0100 Subject: [PATCH 026/524] Fix KeyConfig._prepare --- qutebrowser/config/config.py | 11 +++++------ tests/unit/config/test_config.py | 19 +++++++++---------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 4d83db409..9ac05c7d6 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -134,12 +134,11 @@ class KeyConfig: self._config = config def _prepare(self, key, mode): - """Make sure the given mode exists and normalize the key.""" + """Make sure the given mode exists.""" # 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)) - return key def get_bindings_for(self, mode): """Get the combined bindings for the given mode.""" @@ -169,7 +168,7 @@ class KeyConfig: def get_command(self, key, mode, default=False): """Get the command for a given key (or None).""" - key = self._prepare(key, mode) + self._prepare(key, mode) if default: bindings = dict(val.bindings.default[mode]) else: @@ -183,7 +182,7 @@ class KeyConfig: "Can't add binding '{}' with empty command in {} " 'mode'.format(key, mode)) - key = self._prepare(key, mode) + self._prepare(key, mode) log.keyboard.vdebug("Adding binding {} -> {} in mode {}.".format( key, command, mode)) @@ -195,7 +194,7 @@ class KeyConfig: def bind_default(self, key, *, mode='normal', save_yaml=False): """Restore a default keybinding.""" - key = self._prepare(key, mode) + self._prepare(key, mode) bindings_commands = self._config.get_obj('bindings.commands') try: @@ -207,7 +206,7 @@ class KeyConfig: def unbind(self, key, *, mode='normal', save_yaml=False): """Unbind the given key in the given mode.""" - key = self._prepare(key, mode) + self._prepare(key, mode) bindings_commands = self._config.get_obj('bindings.commands') diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index d8bf73700..964391154 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -29,6 +29,7 @@ from PyQt5.QtGui import QColor from qutebrowser.config import config, configdata, configexc, configfiles from qutebrowser.utils import usertypes from qutebrowser.misc import objects +from qutebrowser.keyinput import keyutils @pytest.fixture(autouse=True) @@ -98,18 +99,16 @@ class TestKeyConfig: """Get a dict with no bindings.""" return {'normal': {}} - @pytest.mark.parametrize('key, expected', [ - ('A', 'A'), - ('', ''), - ]) - def test_prepare_valid(self, key_config_stub, key, expected): - """Make sure prepare normalizes the key.""" - assert key_config_stub._prepare(key, 'normal') == expected - - def test_prepare_invalid(self, key_config_stub): + def test_prepare_invalid_mode(self, key_config_stub): """Make sure prepare checks the mode.""" + seq = keyutils.KeySequence('x') with pytest.raises(configexc.KeybindingError): - assert key_config_stub._prepare('x', 'abnormal') + assert key_config_stub._prepare(seq, 'abnormal') + + def test_prepare_invalid_type(self, key_config_stub): + """Make sure prepare checks the type.""" + with pytest.raises(AssertionError): + assert key_config_stub._prepare('x', 'normal') @pytest.mark.parametrize('commands, expected', [ # Unbinding default key From dc66ec5d8c8cb9e7a92d6e5294805220ed91382c Mon Sep 17 00:00:00 2001 From: Fritz Reichwald Date: Sat, 6 Jan 2018 20:01:57 +0100 Subject: [PATCH 027/524] Fix expectation in Fullscreen info message to fit new description of --- tests/unit/misc/test_miscwidgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/misc/test_miscwidgets.py b/tests/unit/misc/test_miscwidgets.py index 1ee351a81..201b8ce30 100644 --- a/tests/unit/misc/test_miscwidgets.py +++ b/tests/unit/misc/test_miscwidgets.py @@ -106,7 +106,7 @@ class TestFullscreenNotification: @pytest.mark.parametrize('bindings, text', [ ({'': 'fullscreen --leave'}, - "Press Escape to exit fullscreen."), + "Press to exit fullscreen."), ({'': 'fullscreen'}, "Page is now fullscreen."), ({'a': 'fullscreen --leave'}, "Press a to exit fullscreen."), ({}, "Page is now fullscreen."), From 9b4da25578ea4dbaf73cd499e66b5bd8fc269aac Mon Sep 17 00:00:00 2001 From: Fritz Reichwald Date: Sun, 7 Jan 2018 00:11:47 +0100 Subject: [PATCH 028/524] Fix another test by using the new KeySequence --- tests/unit/keyinput/test_keyutils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 4311a4aeb..8a62071a3 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -131,5 +131,5 @@ def test_parse(keystr, expected): ('control+x', 'ctrl+x') ]) def test_normalize_keystr(orig, repl): - assert keyutils.normalize_keystr(orig) == repl + assert keyutils.KeySequence(orig) == repl From dea0aa9f7c68ae9ff5732325573369cd0a86688d Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Sun, 31 Dec 2017 14:38:20 +0100 Subject: [PATCH 029/524] Add tabs page --- qutebrowser/browser/qutescheme.py | 25 ++++++++++++++ qutebrowser/html/tabs.html | 56 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 qutebrowser/html/tabs.html diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 8bcb7ff37..57c32999b 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -201,6 +201,31 @@ def qute_bookmarks(_url): return 'text/html', html +def _tab_fields_to_tabs_page_info(fields): + return (fields['title'], fields['current_url']) + + +@add_handler('tabs') +def qute_tabs(_url): + """Handler for qute://tabs. Display information about all open tabs.""" + tabs = {} + for win_id in objreg.window_registry: + win_id_str = str(win_id) + tabs[win_id_str] = [] + tabbed_browser = objreg.get('tabbed-browser', + scope='window', + window=win_id) + for tab_idx in range(tabbed_browser.count()): + tabs[win_id_str].append( + _tab_fields_to_tabs_page_info( + tabbed_browser.get_tab_fields(tab_idx))) + + html = jinja.render('tabs.html', + title='Tabs', + tab_list_by_window=tabs) + return 'text/html', html + + def history_data(start_time, offset=None): """Return history data. diff --git a/qutebrowser/html/tabs.html b/qutebrowser/html/tabs.html new file mode 100644 index 000000000..80244ef51 --- /dev/null +++ b/qutebrowser/html/tabs.html @@ -0,0 +1,56 @@ +{% extends "styled.html" %} + +{% block style %} +{{super()}} +h1 { + margin-bottom: 10px; +} + +.url a { + color: #444; +} + +th { + text-align: left; +} + +.qmarks .name { + padding-left: 5px; +} + +.empty-msg { + background-color: #f8f8f8; + color: #444; + display: inline-block; + text-align: center; + width: 100%; +} +{% endblock %} + +{% block content %} + +

Tab list

+{% for win_id, tabs in tab_list_by_window.items() %} +

Window {{ win_id }}

+ + + {% for name, url in tabs %} + + + + + {% endfor %} + +
{{name}}{{url}}
+{% endfor %} + +

Raw list

+ +{% for win_id, tabs in tab_list_by_window.items() %} + {% for name, url in tabs %} + {{url}}
+ {% endfor %} +{% endfor %} +
+ +{% endblock %} From ab9f17b05365f90be456d87a9c1d1a437c1f1cb1 Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 22 Jan 2018 16:05:34 +0100 Subject: [PATCH 030/524] Use default value for dictionary item in tabs handler --- qutebrowser/browser/qutescheme.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 57c32999b..c96578d18 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -30,6 +30,7 @@ import time import textwrap import mimetypes import urllib +import collections import pkg_resources from PyQt5.QtCore import QUrlQuery, QUrl @@ -208,10 +209,9 @@ def _tab_fields_to_tabs_page_info(fields): @add_handler('tabs') def qute_tabs(_url): """Handler for qute://tabs. Display information about all open tabs.""" - tabs = {} + tabs = collections.defaultdict(list) for win_id in objreg.window_registry: win_id_str = str(win_id) - tabs[win_id_str] = [] tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) From f11d7ab489f7e91d520c2efd43ab4e16416e7196 Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 22 Jan 2018 16:11:59 +0100 Subject: [PATCH 031/524] Check if the window still exists --- qutebrowser/browser/qutescheme.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index c96578d18..af2f34f70 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -40,6 +40,7 @@ from qutebrowser.config import config, configdata, configexc, configdiff from qutebrowser.utils import (version, utils, jinja, log, message, docutils, objreg, urlutils) from qutebrowser.misc import objects +import sip pyeval_output = ":pyeval was never called" @@ -210,7 +211,9 @@ def _tab_fields_to_tabs_page_info(fields): def qute_tabs(_url): """Handler for qute://tabs. Display information about all open tabs.""" tabs = collections.defaultdict(list) - for win_id in objreg.window_registry: + for win_id, window in objreg.window_registry.items(): + if sip.isdeleted(window): + continue win_id_str = str(win_id) tabbed_browser = objreg.get('tabbed-browser', scope='window', From 02396cb455d7d1ffc7f01cc38498b658df50a066 Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 22 Jan 2018 16:12:45 +0100 Subject: [PATCH 032/524] Remove useless function --- qutebrowser/browser/qutescheme.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index af2f34f70..397e9f35e 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -203,10 +203,6 @@ def qute_bookmarks(_url): return 'text/html', html -def _tab_fields_to_tabs_page_info(fields): - return (fields['title'], fields['current_url']) - - @add_handler('tabs') def qute_tabs(_url): """Handler for qute://tabs. Display information about all open tabs.""" @@ -219,9 +215,9 @@ def qute_tabs(_url): scope='window', window=win_id) for tab_idx in range(tabbed_browser.count()): + tab_fields = tabbed_browser.get_tab_fields(tab_idx) tabs[win_id_str].append( - _tab_fields_to_tabs_page_info( - tabbed_browser.get_tab_fields(tab_idx))) + (tab_fields['title'], tab_fields['current_url'])) html = jinja.render('tabs.html', title='Tabs', From bfeac178e22f97289f5cb82e5443fcdaf4b1cbd9 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Mon, 5 Feb 2018 21:00:29 +0000 Subject: [PATCH 033/524] Make {suburl} expand to {url} This is useful for the following case from IRC: `:set aliases '{"twmpv": "spawn mpv {suburl}"}' which now sets: :twmpv -> spawn mpv {url} --- qutebrowser/commands/runners.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 9fe53f07a..5e4d0f687 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -65,6 +65,7 @@ def replace_variables(win_id, arglist): QUrl.DecodeReserved | QUrl.RemovePassword), 'clipboard': utils.get_clipboard, 'primary': lambda: utils.get_clipboard(selection=True), + 'suburl': lambda: '{url}' } values = {} args = [] From 8b29ce93ecec8590e2bb4135cca2fc7b2480265b Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Mon, 5 Feb 2018 21:40:12 +0000 Subject: [PATCH 034/524] Add substitutions for the other 3 types --- qutebrowser/commands/runners.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 5e4d0f687..793ac6682 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -65,7 +65,10 @@ def replace_variables(win_id, arglist): QUrl.DecodeReserved | QUrl.RemovePassword), 'clipboard': utils.get_clipboard, 'primary': lambda: utils.get_clipboard(selection=True), - 'suburl': lambda: '{url}' + 'suburl': lambda: '{url}', + 'suburl:pretty': lambda: '{url:pretty}', + 'subclipboard': lambda: '{clipboard}', + 'subprimary': lambda: '{primary}', } values = {} args = [] From 22c33ddfb8e128b34be586984bcebfd93b3ea8a4 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Mon, 5 Feb 2018 21:45:49 +0000 Subject: [PATCH 035/524] Add special cases of double quotes: eg {{url}} This allows a second level of indirection quite cheaply, but is a band-aid fix. This commit should be taken as temporary until command arguments are reworked. --- qutebrowser/commands/runners.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 793ac6682..40c927379 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -65,10 +65,10 @@ def replace_variables(win_id, arglist): QUrl.DecodeReserved | QUrl.RemovePassword), 'clipboard': utils.get_clipboard, 'primary': lambda: utils.get_clipboard(selection=True), - 'suburl': lambda: '{url}', - 'suburl:pretty': lambda: '{url:pretty}', - 'subclipboard': lambda: '{clipboard}', - 'subprimary': lambda: '{primary}', + '{url}': lambda: '{url}', + '{url:pretty}': lambda: '{url:pretty}', + '{clipboard}': lambda: '{clipboard}', + '{primary}': lambda: '{primary}', } values = {} args = [] From a6f09b1f73416b089987c38a49f33950c57283fc Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Sat, 10 Feb 2018 15:41:02 +0000 Subject: [PATCH 036/524] Add the modified keys with a loop --- qutebrowser/commands/runners.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 40c927379..a3d142434 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -65,11 +65,10 @@ def replace_variables(win_id, arglist): QUrl.DecodeReserved | QUrl.RemovePassword), 'clipboard': utils.get_clipboard, 'primary': lambda: utils.get_clipboard(selection=True), - '{url}': lambda: '{url}', - '{url:pretty}': lambda: '{url:pretty}', - '{clipboard}': lambda: '{clipboard}', - '{primary}': lambda: '{primary}', } + for key in list(variables): + modified_key = '{' + key + '}' + variables[modified_key] = lambda: modified_key values = {} args = [] tabbed_browser = objreg.get('tabbed-browser', scope='window', From 2f4910f1f2553e0faacb0221ac65533508284917 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Sun, 11 Feb 2018 14:17:28 +0000 Subject: [PATCH 037/524] Add test for escaping {{url}} --- tests/end2end/features/misc.feature | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 8f21b7421..e15af8ad5 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -436,6 +436,11 @@ Feature: Various utility commands. And I run :message-info {clipboard}bar{url} Then the message "foobarhttp://localhost:*/hello.txt" should be shown + Scenario: escaping {{url}} variable + When I open data/hello.txt + And I run :message-info foo{{url}}bar + Then the message "foo{url}bar" should be shown + @xfail_norun Scenario: {url} in clipboard should not be expanded When I open data/hello.txt From 7c0832daf26638aeeacdff6933ba4c68620060fb Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Sun, 11 Feb 2018 15:51:48 +0000 Subject: [PATCH 038/524] Change lambda definition - avoid mutability error --- qutebrowser/commands/runners.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index a3d142434..965f4a6b6 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -68,7 +68,11 @@ def replace_variables(win_id, arglist): } for key in list(variables): modified_key = '{' + key + '}' - variables[modified_key] = lambda: modified_key + + def key_function(modified_key): + return lambda: modified_key + + variables[modified_key] = key_function(modified_key) values = {} args = [] tabbed_browser = objreg.get('tabbed-browser', scope='window', From b2e85a8b838b2e8b6b0396609828ecd40ae1447f Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Sun, 11 Feb 2018 16:33:26 +0000 Subject: [PATCH 039/524] Simplify to lambda with default argument --- qutebrowser/commands/runners.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 965f4a6b6..0171f6c37 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -68,11 +68,7 @@ def replace_variables(win_id, arglist): } for key in list(variables): modified_key = '{' + key + '}' - - def key_function(modified_key): - return lambda: modified_key - - variables[modified_key] = key_function(modified_key) + variables[modified_key] = lambda x=modified_key: x values = {} args = [] tabbed_browser = objreg.get('tabbed-browser', scope='window', From 21a50cf9619423d8104c198b4636e1c548f7d248 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Sun, 11 Feb 2018 17:46:09 +0000 Subject: [PATCH 040/524] Use the repr() of the exception instead of str() --- qutebrowser/config/configexc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index 0a4986efa..b7df1eceb 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -92,7 +92,7 @@ class ConfigErrorDesc: traceback = attr.ib(None) def __str__(self): - return '{}: {}'.format(self.text, self.exception) + return '{}: {} '.format(self.text, repr(self.exception)) def with_text(self, text): """Get a new ConfigErrorDesc with the given text appended.""" From ddc41d2fa41f30bd6e8bb4c72206e89f098eff29 Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Sun, 11 Feb 2018 22:15:14 +0100 Subject: [PATCH 041/524] Remove raw list of open tabs --- qutebrowser/html/tabs.html | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/qutebrowser/html/tabs.html b/qutebrowser/html/tabs.html index 80244ef51..d929272c5 100644 --- a/qutebrowser/html/tabs.html +++ b/qutebrowser/html/tabs.html @@ -43,14 +43,4 @@ th { {% endfor %} - -

Raw list

- -{% for win_id, tabs in tab_list_by_window.items() %} - {% for name, url in tabs %} - {{url}}
- {% endfor %} -{% endfor %} -
- {% endblock %} From 4a8b23380ce38c532c9c5d6c9a2d8568ecead37b Mon Sep 17 00:00:00 2001 From: Ryan Roden-Corrent Date: Sun, 11 Feb 2018 16:19:28 -0500 Subject: [PATCH 042/524] Trigger save on bookmark-add --toggle. The toggle option was failing to fire the changed signal when it removed a bookmark. This means the bookmark file would not be marked as dirty, and would not be saved on exit/autosave (unless another change was made). --- qutebrowser/browser/urlmarks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/urlmarks.py b/qutebrowser/browser/urlmarks.py index d2f563bb6..0a0dfb4f2 100644 --- a/qutebrowser/browser/urlmarks.py +++ b/qutebrowser/browser/urlmarks.py @@ -280,7 +280,7 @@ class BookmarkManager(UrlMarkManager): if urlstr in self.marks: if toggle: - del self.marks[urlstr] + self.delete(urlstr) return False else: raise AlreadyExistsError("Bookmark already exists!") From d0ca54a0cfbaf98518ab156a22da14cf30296df9 Mon Sep 17 00:00:00 2001 From: Ryan Roden-Corrent Date: Tue, 6 Feb 2018 11:14:01 -0500 Subject: [PATCH 043/524] Add unit tests for urlmarks. --- tests/unit/browser/urlmarks.py | 127 +++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/unit/browser/urlmarks.py diff --git a/tests/unit/browser/urlmarks.py b/tests/unit/browser/urlmarks.py new file mode 100644 index 000000000..8e17bd326 --- /dev/null +++ b/tests/unit/browser/urlmarks.py @@ -0,0 +1,127 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Ryan Roden-Corrent (rcorre) +# +# 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 . + +"""Tests for the global page history.""" + +from unittest import mock +import pytest +from PyQt5.QtCore import QUrl + +from qutebrowser.browser import urlmarks + + +@pytest.fixture +def bm_file(config_tmpdir): + bm_dir = config_tmpdir / 'bookmarks' + bm_dir.mkdir() + bm_file = bm_dir / 'urls' + return bm_file + + +def test_init(bm_file, fake_save_manager): + bm_file.write('\n'.join([ + 'http://example.com Example Site', + 'http://example.com/foo Foo', + 'http://example.com/bar Bar', + 'http://example.com/notitle', + ])) + + bm = urlmarks.BookmarkManager() + fake_save_manager.add_saveable.assert_called_once_with( + 'bookmark-manager', + bm.save, + mock.ANY, # TODO: compare signal argument for equality + filename=str(bm_file), + ) + + assert list(bm.marks.items()) == [ + ('http://example.com', 'Example Site'), + ('http://example.com/foo', 'Foo'), + ('http://example.com/bar', 'Bar'), + ('http://example.com/notitle', ''), + ] + + +def test_add(bm_file, fake_save_manager, qtbot): + bm = urlmarks.BookmarkManager() + + with qtbot.wait_signal(bm.changed): + bm.add(QUrl('http://example.com'), 'Example Site') + assert list(bm.marks.items()) == [ + ('http://example.com', 'Example Site'), + ] + + with qtbot.wait_signal(bm.changed): + bm.add(QUrl('http://example.com/notitle'), '') + assert list(bm.marks.items()) == [ + ('http://example.com', 'Example Site'), + ('http://example.com/notitle', ''), + ] + + +def test_add_toggle(bm_file, fake_save_manager, qtbot): + bm = urlmarks.BookmarkManager() + + with qtbot.wait_signal(bm.changed): + bm.add(QUrl('http://example.com'), '', toggle=True) + assert 'http://example.com' in bm.marks + + with qtbot.wait_signal(bm.changed): + bm.add(QUrl('http://example.com'), '', toggle=True) + assert 'http://example.com' not in bm.marks + + with qtbot.wait_signal(bm.changed): + bm.add(QUrl('http://example.com'), '', toggle=True) + assert 'http://example.com' in bm.marks + + +def test_add_dupe(bm_file, fake_save_manager, qtbot): + bm = urlmarks.BookmarkManager() + + bm.add(QUrl('http://example.com'), '') + with pytest.raises(urlmarks.AlreadyExistsError): + bm.add(QUrl('http://example.com'), '') + + +def test_delete(bm_file, fake_save_manager, qtbot): + bm = urlmarks.BookmarkManager() + + bm.add(QUrl('http://example.com/foo'), 'Foo') + bm.add(QUrl('http://example.com/bar'), 'Bar') + bm.add(QUrl('http://example.com/baz'), 'Baz') + bm.save() + + with qtbot.wait_signal(bm.changed): + bm.delete('http://example.com/bar') + assert list(bm.marks.items()) == [ + ('http://example.com/foo', 'Foo'), + ('http://example.com/baz', 'Baz'), + ] + + +def test_save(bm_file, fake_save_manager, qtbot): + bm = urlmarks.BookmarkManager() + + bm.add(QUrl('http://example.com'), 'Example Site') + bm.add(QUrl('http://example.com/notitle'), '') + bm.save() + assert bm_file.read().splitlines() == [ + 'http://example.com Example Site', + 'http://example.com/notitle ', + ] From 72103ec73022b81b12923425a42e705f9f3ddadb Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Sun, 11 Feb 2018 23:16:04 +0000 Subject: [PATCH 044/524] Format error type in a different way --- qutebrowser/config/configexc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index b7df1eceb..1444f79ab 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -92,7 +92,8 @@ class ConfigErrorDesc: traceback = attr.ib(None) def __str__(self): - return '{}: {} '.format(self.text, repr(self.exception)) + return '{} - {}: {} '.format(self.text, + self.exception.__class__.__name__, self.exception) def with_text(self, text): """Get a new ConfigErrorDesc with the given text appended.""" From 164b2a3eef9b06ccfd150eb3d29369d0bff30151 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Sun, 11 Feb 2018 23:20:24 +0000 Subject: [PATCH 045/524] Fix a lengthy line --- qutebrowser/config/configexc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index 1444f79ab..7be1ccf2a 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -93,7 +93,8 @@ class ConfigErrorDesc: def __str__(self): return '{} - {}: {} '.format(self.text, - self.exception.__class__.__name__, self.exception) + self.exception.__class__.__name__, + self.exception) def with_text(self, text): """Get a new ConfigErrorDesc with the given text appended.""" From ce8b457baca6a6bc92eceeecd17e1932c896ee98 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Mon, 12 Feb 2018 13:43:22 +0000 Subject: [PATCH 046/524] Only display exception type if no traceback Update test to match --- qutebrowser/config/configexc.py | 8 +++++--- tests/unit/config/test_configexc.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index 7be1ccf2a..c85aa2220 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -92,9 +92,11 @@ class ConfigErrorDesc: traceback = attr.ib(None) def __str__(self): - return '{} - {}: {} '.format(self.text, - self.exception.__class__.__name__, - self.exception) + if self.traceback: + return '{} - {}: {} '.format(self.text, + self.exception.__class__.__name__, + self.exception) + return '{}: {}'.format(self.text, self.exception) def with_text(self, text): """Get a new ConfigErrorDesc with the given text appended.""" diff --git a/tests/unit/config/test_configexc.py b/tests/unit/config/test_configexc.py index f415ed2fb..2f2ce435f 100644 --- a/tests/unit/config/test_configexc.py +++ b/tests/unit/config/test_configexc.py @@ -74,7 +74,7 @@ def test_config_file_errors_str(errors): assert str(errors).splitlines() == [ 'Errors occurred while reading config.py:', ' Error text 1: Exception 1', - ' Error text 2: Exception 2', + ' Error text 2 - Exception: Exception 2 ', ] From b59a7cdcc0e0b7f961867a0d705aaa1c9b22f4c2 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Mon, 12 Feb 2018 14:07:05 +0000 Subject: [PATCH 047/524] Report syntax errors as unhandled exceptions Update tests accordingly --- qutebrowser/config/configfiles.py | 2 +- tests/unit/config/test_configfiles.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 692474075..3dccc129f 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -419,7 +419,7 @@ def read_config_py(filename, raising=False): desc = configexc.ConfigErrorDesc("Error while compiling", e) raise configexc.ConfigFileErrors(basename, [desc]) except SyntaxError as e: - desc = configexc.ConfigErrorDesc("Syntax Error", e, + desc = configexc.ConfigErrorDesc("Unhandled exception", e, traceback=traceback.format_exc()) raise configexc.ConfigFileErrors(basename, [desc]) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 341fad689..06633ffc7 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -550,7 +550,7 @@ class TestConfigPy: assert len(excinfo.value.errors) == 1 error = excinfo.value.errors[0] assert isinstance(error.exception, SyntaxError) - assert error.text == "Syntax Error" + assert error.text == "Unhandled exception" exception_text = 'invalid syntax (config.py, line 1)' assert str(error.exception) == exception_text From 5b718446b615c18d9f9274cc0317ff609245adce Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Mon, 12 Feb 2018 14:19:18 +0000 Subject: [PATCH 048/524] Add test for sourcing config with invalid source --- tests/unit/config/test_configcommands.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index c82195906..e1855349f 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -309,6 +309,18 @@ class TestSource: " While setting 'foo': No option 'foo'") assert str(excinfo.value) == expected + def test_invalid_source(self, commands, config_tmpdir): + pyfile = config_tmpdir / 'config.py' + pyfile.write_text('1/0', encoding='utf-8') + + with pytest.raises(cmdexc.CommandError) as excinfo: + commands.config_source() + + expected = ("Errors occurred while reading config.py:\n" + " Unhandled exception - ZeroDivisionError:" + " division by zero ") + assert str(excinfo.value) == expected + class TestEdit: From ad50a7bfd284eb58d9edf2d4a8666157181c51b7 Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 12 Feb 2018 15:20:06 +0100 Subject: [PATCH 049/524] Move import to external ressources --- qutebrowser/browser/qutescheme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 397e9f35e..e66cf637f 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -34,13 +34,13 @@ import collections import pkg_resources from PyQt5.QtCore import QUrlQuery, QUrl +import sip import qutebrowser from qutebrowser.config import config, configdata, configexc, configdiff from qutebrowser.utils import (version, utils, jinja, log, message, docutils, objreg, urlutils) from qutebrowser.misc import objects -import sip pyeval_output = ":pyeval was never called" From 71d33a47b383b2b0bb24bcd172d58e11a86e491c Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 12 Feb 2018 15:20:41 +0100 Subject: [PATCH 050/524] Remove useless intermediary variables --- qutebrowser/browser/qutescheme.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index e66cf637f..10790e8b5 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -210,13 +210,12 @@ def qute_tabs(_url): for win_id, window in objreg.window_registry.items(): if sip.isdeleted(window): continue - win_id_str = str(win_id) tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) for tab_idx in range(tabbed_browser.count()): tab_fields = tabbed_browser.get_tab_fields(tab_idx) - tabs[win_id_str].append( + tabs[str(win_id)].append( (tab_fields['title'], tab_fields['current_url'])) html = jinja.render('tabs.html', From 561e5d17b9450dec7a7b07de071f723f6d3610cc Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Mon, 12 Feb 2018 14:22:25 +0000 Subject: [PATCH 051/524] Remove extraneous space --- qutebrowser/config/configexc.py | 2 +- tests/unit/config/test_configcommands.py | 2 +- tests/unit/config/test_configexc.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index c85aa2220..2c0e5a58f 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -93,7 +93,7 @@ class ConfigErrorDesc: def __str__(self): if self.traceback: - return '{} - {}: {} '.format(self.text, + return '{} - {}: {}'.format(self.text, self.exception.__class__.__name__, self.exception) return '{}: {}'.format(self.text, self.exception) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index e1855349f..13b2ae943 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -318,7 +318,7 @@ class TestSource: expected = ("Errors occurred while reading config.py:\n" " Unhandled exception - ZeroDivisionError:" - " division by zero ") + " division by zero") assert str(excinfo.value) == expected diff --git a/tests/unit/config/test_configexc.py b/tests/unit/config/test_configexc.py index 2f2ce435f..87f3abb6a 100644 --- a/tests/unit/config/test_configexc.py +++ b/tests/unit/config/test_configexc.py @@ -74,7 +74,7 @@ def test_config_file_errors_str(errors): assert str(errors).splitlines() == [ 'Errors occurred while reading config.py:', ' Error text 1: Exception 1', - ' Error text 2 - Exception: Exception 2 ', + ' Error text 2 - Exception: Exception 2', ] From 9397cc74c1cd175d6a0ff08384290d8235fde050 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Mon, 12 Feb 2018 14:24:53 +0000 Subject: [PATCH 052/524] Pylint indentation fix --- qutebrowser/config/configexc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index 2c0e5a58f..2067878b9 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -94,8 +94,8 @@ class ConfigErrorDesc: def __str__(self): if self.traceback: return '{} - {}: {}'.format(self.text, - self.exception.__class__.__name__, - self.exception) + self.exception.__class__.__name__, + self.exception) return '{}: {}'.format(self.text, self.exception) def with_text(self, text): From 0caa5d04d35efd3dba6b2f27679a0c73d7b6c78e Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 12 Feb 2018 15:50:56 +0100 Subject: [PATCH 053/524] Use tabs directly also ignore tabs page url in list --- qutebrowser/browser/qutescheme.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 10790e8b5..6eb034836 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -213,10 +213,9 @@ def qute_tabs(_url): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) - for tab_idx in range(tabbed_browser.count()): - tab_fields = tabbed_browser.get_tab_fields(tab_idx) - tabs[str(win_id)].append( - (tab_fields['title'], tab_fields['current_url'])) + for tab in tabbed_browser.widgets(): + if not tab.url().url().startswith("qute://tabs"): + tabs[str(win_id)].append((tab.title(), tab.url().url())) html = jinja.render('tabs.html', title='Tabs', From ee57c30c53cc809b92ed89cd62cde612ca9e44c3 Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 12 Feb 2018 16:02:06 +0100 Subject: [PATCH 054/524] Re-add the raw list (with fixed alignment) --- qutebrowser/html/tabs.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/qutebrowser/html/tabs.html b/qutebrowser/html/tabs.html index d929272c5..af6659414 100644 --- a/qutebrowser/html/tabs.html +++ b/qutebrowser/html/tabs.html @@ -43,4 +43,12 @@ th { {% endfor %} +
+ Raw list + +{% for win_id, tabs in tab_list_by_window.items() %}{% for name, url in tabs %} +{{url}}
{% endfor %} +{% endfor %} +
+
{% endblock %} From d6912be2237962c69d155e7cafab2b98ec095053 Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 12 Feb 2018 16:04:48 +0100 Subject: [PATCH 055/524] Update import order --- qutebrowser/browser/qutescheme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 6eb034836..e8b682d99 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -33,8 +33,8 @@ import urllib import collections import pkg_resources -from PyQt5.QtCore import QUrlQuery, QUrl import sip +from PyQt5.QtCore import QUrlQuery, QUrl import qutebrowser from qutebrowser.config import config, configdata, configexc, configdiff From 417200fa709513bce8aba1c2d03fba2ed30640b4 Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 12 Feb 2018 16:06:17 +0100 Subject: [PATCH 056/524] Use QUrl instead of str to compare --- qutebrowser/browser/qutescheme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index e8b682d99..15823dc87 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -214,7 +214,7 @@ def qute_tabs(_url): scope='window', window=win_id) for tab in tabbed_browser.widgets(): - if not tab.url().url().startswith("qute://tabs"): + if tab.url() != QUrl("qute://tabs/"): tabs[str(win_id)].append((tab.title(), tab.url().url())) html = jinja.render('tabs.html', From 572257921d0dcaf875334b1d8f4ae86660c3a464 Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 12 Feb 2018 16:12:15 +0100 Subject: [PATCH 057/524] Use QUrl().toDisplayString() instead of url() --- qutebrowser/browser/qutescheme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 15823dc87..aa887f349 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -215,7 +215,7 @@ def qute_tabs(_url): window=win_id) for tab in tabbed_browser.widgets(): if tab.url() != QUrl("qute://tabs/"): - tabs[str(win_id)].append((tab.title(), tab.url().url())) + tabs[str(win_id)].append((tab.title(), tab.url().toDisplayString())) html = jinja.render('tabs.html', title='Tabs', From f64f873c11f0f1171f7324a6fc5c65d743948d4b Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 12 Feb 2018 17:01:21 +0100 Subject: [PATCH 058/524] Update coverage from 4.5 to 4.5.1 --- misc/requirements/requirements-codecov.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-codecov.txt b/misc/requirements/requirements-codecov.txt index df27fc428..aa5de8b34 100644 --- a/misc/requirements/requirements-codecov.txt +++ b/misc/requirements/requirements-codecov.txt @@ -3,7 +3,7 @@ certifi==2018.1.18 chardet==3.0.4 codecov==2.0.15 -coverage==4.5 +coverage==4.5.1 idna==2.6 requests==2.18.4 urllib3==1.22 From a506788f4fa02ac541d2242407b304b5bd192f08 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 12 Feb 2018 17:01:22 +0100 Subject: [PATCH 059/524] Update coverage from 4.5 to 4.5.1 --- misc/requirements/requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index eebd2945b..f91c66bb9 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -5,7 +5,7 @@ beautifulsoup4==4.6.0 cheroot==6.0.0 click==6.7 # colorama==0.3.9 -coverage==4.5 +coverage==4.5.1 EasyProcess==0.2.3 fields==5.0.0 Flask==0.12.2 From a31f775d70c4f957b5de2f17e8e2bf6d87c9ee70 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 12 Feb 2018 17:01:24 +0100 Subject: [PATCH 060/524] Update flake8-debugger from 3.0.0 to 3.1.0 --- misc/requirements/requirements-flake8.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt index 5a43e66d1..eb81fc37b 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -6,7 +6,7 @@ flake8-bugbear==18.2.0 flake8-builtins==1.0.post0 flake8-comprehensions==1.4.1 flake8-copyright==0.2.0 -flake8-debugger==3.0.0 +flake8-debugger==3.1.0 flake8-deprecated==1.3 flake8-docstrings==1.3.0 flake8-future-import==0.4.4 From 75e65b9d4af97b4b6cecce4cfa7dfc7eb302a434 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 12 Feb 2018 17:01:25 +0100 Subject: [PATCH 061/524] Update setuptools from 38.5.0 to 38.5.1 --- misc/requirements/requirements-pip.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt index bd9654b1b..b5e76ce0f 100644 --- a/misc/requirements/requirements-pip.txt +++ b/misc/requirements/requirements-pip.txt @@ -3,6 +3,6 @@ appdirs==1.4.3 packaging==16.8 pyparsing==2.2.0 -setuptools==38.5.0 +setuptools==38.5.1 six==1.11.0 wheel==0.30.0 From e74995e81afc601ce24b32936abaa0e524d536b0 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 12 Feb 2018 17:01:27 +0100 Subject: [PATCH 062/524] Update isort from 4.3.2 to 4.3.4 --- misc/requirements/requirements-pylint-master.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-pylint-master.txt b/misc/requirements/requirements-pylint-master.txt index d23d2ea57..6364f0fcf 100644 --- a/misc/requirements/requirements-pylint-master.txt +++ b/misc/requirements/requirements-pylint-master.txt @@ -5,7 +5,7 @@ certifi==2018.1.18 chardet==3.0.4 github3.py==0.9.6 idna==2.6 -isort==4.3.2 +isort==4.3.4 lazy-object-proxy==1.3.1 mccabe==0.6.1 -e git+https://github.com/PyCQA/pylint.git#egg=pylint From bd83ff2c64d9728618cc9586deed45272ca85766 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 12 Feb 2018 17:01:28 +0100 Subject: [PATCH 063/524] Update isort from 4.3.2 to 4.3.4 --- misc/requirements/requirements-pylint.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt index e86be34e2..de27257e1 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -5,7 +5,7 @@ certifi==2018.1.18 chardet==3.0.4 github3.py==0.9.6 idna==2.6 -isort==4.3.2 +isort==4.3.4 lazy-object-proxy==1.3.1 mccabe==0.6.1 pylint==1.8.2 From 3aa59ea2400eb827fc494eb1747073c266cca363 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 12 Feb 2018 17:01:30 +0100 Subject: [PATCH 064/524] Update hypothesis from 3.44.25 to 3.44.26 --- misc/requirements/requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index f91c66bb9..fb666a188 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -11,7 +11,7 @@ fields==5.0.0 Flask==0.12.2 glob2==0.6 hunter==2.0.2 -hypothesis==3.44.25 +hypothesis==3.44.26 itsdangerous==0.24 # Jinja2==2.10 Mako==1.0.7 From 301aaf57832f72a2fd43cac80ce2ccf23e3f7226 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 12 Feb 2018 17:01:32 +0100 Subject: [PATCH 065/524] Update pytest-faulthandler from 1.3.1 to 1.4.1 --- misc/requirements/requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index fb666a188..7aecdced2 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -26,7 +26,7 @@ pytest==3.4.0 pytest-bdd==2.20.0 pytest-benchmark==3.1.1 pytest-cov==2.5.1 -pytest-faulthandler==1.3.1 +pytest-faulthandler==1.4.1 pytest-instafail==0.3.0 pytest-mock==1.6.3 pytest-qt==2.3.1 From 7fe9f53c9738a6812f03ef47f9d29358c5b69b0b Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 12 Feb 2018 17:01:33 +0100 Subject: [PATCH 066/524] Update pytest-xvfb from 1.0.0 to 1.1.0 --- misc/requirements/requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 7aecdced2..6c3d69497 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -33,7 +33,7 @@ pytest-qt==2.3.1 pytest-repeat==0.4.1 pytest-rerunfailures==4.0 pytest-travis-fold==1.3.0 -pytest-xvfb==1.0.0 +pytest-xvfb==1.1.0 PyVirtualDisplay==0.2.1 six==1.11.0 vulture==0.26 From 80a72604c6ed8ae5f4128e627465035a30833c5e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 12 Feb 2018 17:49:20 +0100 Subject: [PATCH 067/524] Revive hostblock_blame.py --- scripts/hostblock_blame.py | 64 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 scripts/hostblock_blame.py diff --git a/scripts/hostblock_blame.py b/scripts/hostblock_blame.py new file mode 100644 index 000000000..dde83d91f --- /dev/null +++ b/scripts/hostblock_blame.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2017 Florian Bruhin (The Compiler) +# +# 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 . + +"""Check by which hostblock list a host was blocked.""" + +import sys +import io +import os +import os.path +import configparser +import urllib.request + +from PyQt5.QtCore import QStandardPaths + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +from qutebrowser.browser import adblock + + +def main(): + """Check by which hostblock list a host was blocked.""" + if len(sys.argv) != 2: + print("Usage: {} ".format(sys.argv[0]), file=sys.stderr) + sys.exit(1) + confdir = QStandardPaths.writableLocation(QStandardPaths.ConfigLocation) + confdir = confdir.replace('/', os.sep) + if confdir.split(os.sep)[-1] != 'qutebrowser': + confdir = os.path.join(confdir, 'qutebrowser') + confpath = os.path.join(confdir, 'qutebrowser.conf') + parser = configparser.ConfigParser() + print("config path: {}".format(confpath)) + successful = parser.read(confpath, encoding='utf-8') + if not successful: + raise OSError("configparser did not read files successfully!") + lists = parser['content']['host-block-lists'] + for url in lists.split(','): + print("checking {}...".format(url)) + raw_file = urllib.request.urlopen(url) + byte_io = io.BytesIO(raw_file.read()) + f = adblock.get_fileobj(byte_io) + for line in f: + if sys.argv[1] in line: + print("FOUND {} in {}:".format(sys.argv[1], url)) + print(" " + line.rstrip()) + + +if __name__ == '__main__': + main() From eca1fb7d3bfdb1844abcd6c97b978ad623118443 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 12 Feb 2018 17:54:34 +0100 Subject: [PATCH 068/524] Update hostblock_blame.py for new config --- scripts/hostblock_blame.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/scripts/hostblock_blame.py b/scripts/hostblock_blame.py index dde83d91f..f8c266b53 100644 --- a/scripts/hostblock_blame.py +++ b/scripts/hostblock_blame.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# Copyright 2014-2017 Florian Bruhin (The Compiler) +# Copyright 2014-2018 Florian Bruhin (The Compiler) # # This file is part of qutebrowser. # @@ -27,10 +27,9 @@ import os.path import configparser import urllib.request -from PyQt5.QtCore import QStandardPaths - sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) from qutebrowser.browser import adblock +from qutebrowser.config import configdata def main(): @@ -38,18 +37,10 @@ def main(): if len(sys.argv) != 2: print("Usage: {} ".format(sys.argv[0]), file=sys.stderr) sys.exit(1) - confdir = QStandardPaths.writableLocation(QStandardPaths.ConfigLocation) - confdir = confdir.replace('/', os.sep) - if confdir.split(os.sep)[-1] != 'qutebrowser': - confdir = os.path.join(confdir, 'qutebrowser') - confpath = os.path.join(confdir, 'qutebrowser.conf') - parser = configparser.ConfigParser() - print("config path: {}".format(confpath)) - successful = parser.read(confpath, encoding='utf-8') - if not successful: - raise OSError("configparser did not read files successfully!") - lists = parser['content']['host-block-lists'] - for url in lists.split(','): + + configdata.init() + + for url in configdata.DATA['content.host_blocking.lists'].default: print("checking {}...".format(url)) raw_file = urllib.request.urlopen(url) byte_io = io.BytesIO(raw_file.read()) From deb9ccb564cefe183bdd4a01a9cbc7e8c2ace282 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 12 Feb 2018 22:16:30 +0100 Subject: [PATCH 069/524] hostblock_blame: Remove unused import --- scripts/hostblock_blame.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/hostblock_blame.py b/scripts/hostblock_blame.py index f8c266b53..ed672eec9 100644 --- a/scripts/hostblock_blame.py +++ b/scripts/hostblock_blame.py @@ -24,7 +24,6 @@ import sys import io import os import os.path -import configparser import urllib.request sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) From 54bc22dfd4b770bf8621d33a4bf362dc173665fa Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 12 Feb 2018 22:16:41 +0100 Subject: [PATCH 070/524] hostblock_blame: Decode lines properly --- scripts/hostblock_blame.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/hostblock_blame.py b/scripts/hostblock_blame.py index ed672eec9..2f68d2961 100644 --- a/scripts/hostblock_blame.py +++ b/scripts/hostblock_blame.py @@ -45,6 +45,7 @@ def main(): byte_io = io.BytesIO(raw_file.read()) f = adblock.get_fileobj(byte_io) for line in f: + line = line.decode('utf-8') if sys.argv[1] in line: print("FOUND {} in {}:".format(sys.argv[1], url)) print(" " + line.rstrip()) From 9a0c113f8a448767a5d73bf0205bb2281e28d842 Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 12 Feb 2018 21:35:33 +0100 Subject: [PATCH 071/524] Fix pylint line-too-long error --- qutebrowser/browser/qutescheme.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index aa887f349..5335a97c8 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -215,7 +215,8 @@ def qute_tabs(_url): window=win_id) for tab in tabbed_browser.widgets(): if tab.url() != QUrl("qute://tabs/"): - tabs[str(win_id)].append((tab.title(), tab.url().toDisplayString())) + tabs[str(win_id)].append( + (tab.title(), tab.url().toDisplayString())) html = jinja.render('tabs.html', title='Tabs', From 15fd5526164cf64b3b6163f2152c8cd7e329071f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 12 Feb 2018 22:32:11 +0100 Subject: [PATCH 072/524] Update changelog --- doc/changelog.asciidoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 3375fe6a0..2d7a054c2 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -38,6 +38,8 @@ Added which order in the statusbar. - New `:prompt-yank` command (bound to `Alt-y` by default) to yank URLs referenced in prompts. +- The `hostblock_blame` script which was removed in v1.0 was updated for the new + config and re-added. Changed ~~~~~~~ @@ -83,6 +85,7 @@ Fixed is removed. - Suspended pages now should always load the correct page when being un-suspended. - Compatibility with Python 3.7 +- Exception types are now shown properly with `:config-source` and `:config-edit`. Removed ~~~~~~~ From 0b047e3e1057b0f19cf885605a09dfc17bee9aaa Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 12 Feb 2018 22:48:41 +0100 Subject: [PATCH 073/524] Handle url with trailing slash and without --- qutebrowser/browser/qutescheme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 5335a97c8..a93cd88a8 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -214,7 +214,7 @@ def qute_tabs(_url): scope='window', window=win_id) for tab in tabbed_browser.widgets(): - if tab.url() != QUrl("qute://tabs/"): + if tab.url() not in [QUrl("qute://tabs/"), QUrl("qute://tabs")]: tabs[str(win_id)].append( (tab.title(), tab.url().toDisplayString())) From 7ae0d584e67956960014af0e1606042ae02fb158 Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 12 Feb 2018 22:49:02 +0100 Subject: [PATCH 074/524] Add 20px margin above the raw list --- qutebrowser/html/tabs.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qutebrowser/html/tabs.html b/qutebrowser/html/tabs.html index af6659414..fff8bdca3 100644 --- a/qutebrowser/html/tabs.html +++ b/qutebrowser/html/tabs.html @@ -25,6 +25,10 @@ th { text-align: center; width: 100%; } + +details { + margin-top: 20px; +} {% endblock %} {% block content %} From ca199b0d3dc1fd6c7aab41e0832a90203ced396f Mon Sep 17 00:00:00 2001 From: Simon Doppler Date: Mon, 12 Feb 2018 22:51:36 +0100 Subject: [PATCH 075/524] Use separate variable to make pylint happy --- qutebrowser/browser/qutescheme.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index a93cd88a8..37b57f933 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -215,8 +215,8 @@ def qute_tabs(_url): window=win_id) for tab in tabbed_browser.widgets(): if tab.url() not in [QUrl("qute://tabs/"), QUrl("qute://tabs")]: - tabs[str(win_id)].append( - (tab.title(), tab.url().toDisplayString())) + urlstr = tab.url().toDisplayString() + tabs[str(win_id)].append((tab.title(), urlstr)) html = jinja.render('tabs.html', title='Tabs', From 47451aa495172f8faf34408b0fdd49424d9999c3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 12 Feb 2018 23:00:26 +0100 Subject: [PATCH 076/524] Open qute://tabs with :buffer --- doc/help/commands.asciidoc | 2 +- qutebrowser/browser/commands.py | 9 ++++++--- tests/end2end/features/tabs.feature | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index c5f013491..9580fe578 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -221,7 +221,7 @@ Syntax: +:buffer ['index']+ Select tab by index or url/title best match. -Focuses window if necessary when index is given. If both index and count are given, use count. +Focuses window if necessary when index is given. If both index and count are given, use count. With neither index nor count given, open the qute://tabs page. ==== positional arguments * +'index'+: The [win_id/]index of the tab to focus. Or a substring in which case the closest match will be focused. diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index b56c3d6ae..3e2eeaecb 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -1086,20 +1086,23 @@ class CommandDispatcher: maxsplit=0) @cmdutils.argument('index', completion=miscmodels.buffer) @cmdutils.argument('count', count=True) - def buffer(self, index=None, count=None): + @cmdutils.argument('win_id', win_id=True) + def buffer(self, win_id, index=None, count=None): """Select tab by index or url/title best match. Focuses window if necessary when index is given. If both index and count are given, use count. + With neither index nor count given, open the qute://tabs page. + Args: index: The [win_id/]index of the tab to focus. Or a substring in which case the closest match will be focused. count: The tab index to focus, starting with 1. """ if count is None and index is None: - raise cmdexc.CommandError("buffer: Either a count or the argument " - "index must be specified.") + self.openurl('qute://tabs/', tab=True) + return if count is not None: index = str(count) diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index 065b92096..037e1cdd0 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -904,7 +904,7 @@ Feature: Tab management Scenario: :buffer without args or count When I run :buffer - Then the error "buffer: Either a count or the argument index must be specified." should be shown + Then qute://tabs should be loaded Scenario: :buffer with a matching title When I open data/title.html From 0fae611021b7876404b17fd8cda8f630a9b1e9df Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 12 Feb 2018 23:00:55 +0100 Subject: [PATCH 077/524] Update changelog --- doc/changelog.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 2d7a054c2..4a23ec48c 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -40,6 +40,7 @@ Added referenced in prompts. - The `hostblock_blame` script which was removed in v1.0 was updated for the new config and re-added. +- New `qute://tabs` page (opened via `:buffer`) which lists all tabs. Changed ~~~~~~~ From 22d7490126a67fb6df5641cecd36799b3a70d140 Mon Sep 17 00:00:00 2001 From: Ryan Roden-Corrent Date: Mon, 12 Feb 2018 19:25:24 -0500 Subject: [PATCH 078/524] Remove unused import and TODO from urlmarks test. --- tests/unit/browser/urlmarks.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/browser/urlmarks.py b/tests/unit/browser/urlmarks.py index 8e17bd326..df7b3286d 100644 --- a/tests/unit/browser/urlmarks.py +++ b/tests/unit/browser/urlmarks.py @@ -19,7 +19,6 @@ """Tests for the global page history.""" -from unittest import mock import pytest from PyQt5.QtCore import QUrl @@ -46,7 +45,7 @@ def test_init(bm_file, fake_save_manager): fake_save_manager.add_saveable.assert_called_once_with( 'bookmark-manager', bm.save, - mock.ANY, # TODO: compare signal argument for equality + bm.changed, filename=str(bm_file), ) From 0e87c4684903d3a6aed940febb3ca2dffb9aa690 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 13 Feb 2018 09:43:21 +0100 Subject: [PATCH 079/524] Remove unused win_id argument --- qutebrowser/browser/commands.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 3e2eeaecb..69cb3142f 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -1086,8 +1086,7 @@ class CommandDispatcher: maxsplit=0) @cmdutils.argument('index', completion=miscmodels.buffer) @cmdutils.argument('count', count=True) - @cmdutils.argument('win_id', win_id=True) - def buffer(self, win_id, index=None, count=None): + def buffer(self, index=None, count=None): """Select tab by index or url/title best match. Focuses window if necessary when index is given. If both index and From 171392b582e30ba5f17344e618cd3d0c68429150 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 13 Feb 2018 09:44:10 +0100 Subject: [PATCH 080/524] Update changelog --- doc/changelog.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 4a23ec48c..66d22ddca 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -87,6 +87,7 @@ Fixed - Suspended pages now should always load the correct page when being un-suspended. - Compatibility with Python 3.7 - Exception types are now shown properly with `:config-source` and `:config-edit`. +- When using `:bookmark-add --toggle`, bookmarks are now saved properly. Removed ~~~~~~~ From 942dca3444a3fe978074fa180ca9e1106ad7ae88 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Tue, 13 Feb 2018 13:31:27 +0000 Subject: [PATCH 081/524] Add test for pastebin_version() --- qutebrowser/utils/version.py | 6 +-- tests/unit/utils/test_version.py | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 09a1a6efa..71e33886f 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -453,7 +453,7 @@ def opengl_vendor(): # pragma: no cover old_context.makeCurrent(old_surface) -def pastebin_version(): +def pastebin_version(pbclient=None): """Pastebin the version and log the url to messages.""" def _yank_url(url): utils.set_clipboard(url) @@ -478,8 +478,8 @@ def pastebin_version(): http_client = httpclient.HTTPClient() misc_api = pastebin.PastebinClient.MISC_API_URL - pbclient = pastebin.PastebinClient(http_client, parent=app, - api_url=misc_api) + pbclient = pbclient or pastebin.PastebinClient(http_client, parent=app, + api_url=misc_api) pbclient.success.connect(_on_paste_version_success) pbclient.error.connect(_on_paste_version_err) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index da65422a7..84d89680c 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -35,9 +35,11 @@ import datetime import attr import pkg_resources import pytest +from PyQt5.QtCore import pyqtSignal, QUrl, QObject import qutebrowser from qutebrowser.utils import version, usertypes, utils +from qutebrowser.misc import pastebin from qutebrowser.browser import pdfjs @@ -950,3 +952,68 @@ def test_opengl_vendor(): """Simply call version.opengl_vendor() and see if it doesn't crash.""" pytest.importorskip("PyQt5.QtOpenGL") return version.opengl_vendor() + + +class HTTPPostStub(QObject): + + """A stub class for HTTPClient. + + Attributes: + url: the last url send by post() + data: the last data send by post() + """ + + success = pyqtSignal(str) + error = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.url = None + self.data = None + + def post(self, url, data=None): + self.url = url + self.data = data + + +@pytest.fixture +def pbclient(): + http_stub = HTTPPostStub() + client = pastebin.PastebinClient(http_stub) + return client + + +def test_pastebin_version(pbclient, monkeypatch): + """Test version.pastebin_version() twice.""" + patches = { + '_path_info': lambda: {'PATH DESC': 'PATH NAME'}, + '_uptime': lambda: datetime.timedelta(hours=1, minutes=23, seconds=45), + } + + for name, val in patches.items(): + monkeypatch.setattr('qutebrowser.utils.version.' + name, val) + + version.pastebin_version(pbclient) + pbclient.success.emit("test") + assert version.pastebin_url == "test" + + version.pastebin_version(pbclient) + assert version.pastebin_url == "test" + + +def test_pastebin_version_error(pbclient, monkeypatch): + """Test version.pastebin_version() with errors.""" + patches = { + '_path_info': lambda: {'PATH DESC': 'PATH NAME'}, + '_uptime': lambda: datetime.timedelta(hours=1, minutes=23, seconds=45), + } + + for name, val in patches.items(): + monkeypatch.setattr('qutebrowser.utils.version.' + name, val) + + version.pastebin_url = None + version.pastebin_version(pbclient) + try: + pbclient.error.emit("test") + except: + assert version.pastebin_url is None From 6eeacfe82b8ec8fa1ed071ffb5107577e817c39e Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Mon, 12 Feb 2018 22:51:04 -0500 Subject: [PATCH 082/524] Fix caret being cleared when leaving any mode --- qutebrowser/browser/browsertab.py | 2 +- qutebrowser/browser/webengine/webenginetab.py | 5 ++++- qutebrowser/browser/webkit/webkittab.py | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 6ed5afe52..84e4838a7 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -342,7 +342,7 @@ class AbstractCaret(QObject): def _on_mode_entered(self, mode): raise NotImplementedError - def _on_mode_left(self): + def _on_mode_left(self, mode): raise NotImplementedError def move_to_next_line(self, count=1): diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index ed6697f03..4595f7a6e 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -223,7 +223,10 @@ class WebEngineCaret(browsertab.AbstractCaret): self._js_call('setInitialCursor') @pyqtSlot(usertypes.KeyMode) - def _on_mode_left(self): + def _on_mode_left(self, mode): + if mode != usertypes.KeyMode.caret: + return + self.drop_selection() self._js_call('disableCaret') diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 9395630db..aa3f5363e 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -205,8 +205,8 @@ class WebKitCaret(browsertab.AbstractCaret): self._widget.page().currentFrame().evaluateJavaScript( utils.read_file('javascript/position_caret.js')) - @pyqtSlot() - def _on_mode_left(self): + @pyqtSlot(usertypes.KeyMode) + def _on_mode_left(self, _mode): settings = self._widget.settings() if settings.testAttribute(QWebSettings.CaretBrowsingEnabled): if self.selection_enabled and self._widget.hasSelection(): From e349af7524819cd6982b7721f3b987ddfd7f8206 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Tue, 13 Feb 2018 14:49:15 +0000 Subject: [PATCH 083/524] Fix testing with error pastebin_version() --- tests/unit/utils/test_version.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 84d89680c..cbe174b38 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -1001,7 +1001,7 @@ def test_pastebin_version(pbclient, monkeypatch): assert version.pastebin_url == "test" -def test_pastebin_version_error(pbclient, monkeypatch): +def test_pastebin_version_error(pbclient, caplog, monkeypatch): """Test version.pastebin_version() with errors.""" patches = { '_path_info': lambda: {'PATH DESC': 'PATH NAME'}, @@ -1012,8 +1012,8 @@ def test_pastebin_version_error(pbclient, monkeypatch): monkeypatch.setattr('qutebrowser.utils.version.' + name, val) version.pastebin_url = None - version.pastebin_version(pbclient) - try: - pbclient.error.emit("test") - except: - assert version.pastebin_url is None + with caplog.at_level(logging.ERROR): + version.pastebin_version(pbclient) + pbclient._client.error.emit("test") + assert version.pastebin_url is None + assert caplog.records[0].message == "Failed to pastebin version info: test" From b959e885fc8dd512bba52aee7c989bc915d36578 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Tue, 13 Feb 2018 15:25:40 +0000 Subject: [PATCH 084/524] Pylint fix --- tests/unit/utils/test_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index cbe174b38..007171afc 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -35,7 +35,7 @@ import datetime import attr import pkg_resources import pytest -from PyQt5.QtCore import pyqtSignal, QUrl, QObject +from PyQt5.QtCore import pyqtSignal, QObject import qutebrowser from qutebrowser.utils import version, usertypes, utils From cfa779ecb7b417191f8596209a7f461a0d1f7317 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Tue, 13 Feb 2018 16:02:20 +0000 Subject: [PATCH 085/524] Add trivial test for _uptime --- tests/unit/utils/test_version.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 007171afc..96d0a5f4b 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -36,6 +36,7 @@ import attr import pkg_resources import pytest from PyQt5.QtCore import pyqtSignal, QObject +from PyQt5.QtWidgets import QApplication import qutebrowser from qutebrowser.utils import version, usertypes, utils @@ -1017,3 +1018,9 @@ def test_pastebin_version_error(pbclient, caplog, monkeypatch): pbclient._client.error.emit("test") assert version.pastebin_url is None assert caplog.records[0].message == "Failed to pastebin version info: test" + + +def test_uptime(monkeypatch): + """Test _uptime runs without failing. Its effects are tested elsewhere.""" + QApplication.instance().launch_time = datetime.datetime(1, 1, 1) + version._uptime() From ca8d935cf4ca581fdc16a705e0c4ea4e7d65aaaf Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Tue, 13 Feb 2018 18:38:27 +0000 Subject: [PATCH 086/524] Update tests as per code review --- tests/helpers/stubs.py | 32 ++++++++++++++ tests/unit/misc/test_pastebin.py | 26 +----------- tests/unit/utils/test_version.py | 73 ++++++++++++++------------------ 3 files changed, 66 insertions(+), 65 deletions(-) diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 64bc793cb..ede322b74 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -563,3 +563,35 @@ class ApplicationStub(QObject): """Stub to insert as the app object in objreg.""" new_window = pyqtSignal(mainwindow.MainWindow) + + +class HTTPPostStub(QObject): + + """A stub class for HTTPClient. + + Attributes: + url: the last url send by post() + data: the last data send by post() + """ + + success = pyqtSignal(str) + error = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.url = None + self.data = None + + def post(self, url, data=None): + self.url = url + self.data = data + + +@pytest.fixture +def pbclient(stubs): + http_stub = stubs.HTTPPostStub() + client = pastebin.PastebinClient(http_stub) + return client + + + diff --git a/tests/unit/misc/test_pastebin.py b/tests/unit/misc/test_pastebin.py index b352f52c8..9546bcf36 100644 --- a/tests/unit/misc/test_pastebin.py +++ b/tests/unit/misc/test_pastebin.py @@ -23,31 +23,9 @@ from PyQt5.QtCore import pyqtSignal, QUrl, QObject from qutebrowser.misc import httpclient, pastebin -class HTTPPostStub(QObject): - - """A stub class for HTTPClient. - - Attributes: - url: the last url send by post() - data: the last data send by post() - """ - - success = pyqtSignal(str) - error = pyqtSignal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self.url = None - self.data = None - - def post(self, url, data=None): - self.url = url - self.data = data - - @pytest.fixture -def pbclient(): - http_stub = HTTPPostStub() +def pbclient(stubs): + http_stub = stubs.HTTPPostStub() client = pastebin.PastebinClient(http_stub) return client diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 96d0a5f4b..f4f7270ba 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -955,62 +955,53 @@ def test_opengl_vendor(): return version.opengl_vendor() -class HTTPPostStub(QObject): - - """A stub class for HTTPClient. - - Attributes: - url: the last url send by post() - data: the last data send by post() - """ - - success = pyqtSignal(str) - error = pyqtSignal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self.url = None - self.data = None - - def post(self, url, data=None): - self.url = url - self.data = data - - @pytest.fixture -def pbclient(): - http_stub = HTTPPostStub() +def pbclient(stubs): + http_stub = stubs.HTTPPostStub() client = pastebin.PastebinClient(http_stub) return client -def test_pastebin_version(pbclient, monkeypatch): - """Test version.pastebin_version() twice.""" - patches = { - '_path_info': lambda: {'PATH DESC': 'PATH NAME'}, - '_uptime': lambda: datetime.timedelta(hours=1, minutes=23, seconds=45), - } - - for name, val in patches.items(): - monkeypatch.setattr('qutebrowser.utils.version.' + name, val) +def test_pastebin_version(pbclient, message_mock, monkeypatch, qtbot): + """Test version.pastebin_version() sets the url.""" + monkeypatch.setattr('qutebrowser.utils.version.version', + lambda: "dummy") + monkeypatch.setattr('qutebrowser.utils.utils.log_clipboard', True) version.pastebin_version(pbclient) pbclient.success.emit("test") + + msg = message_mock.getmsg(usertypes.MessageLevel.info) + assert msg.text == "Version url test yanked to clipboard." assert version.pastebin_url == "test" + version.pastebin_url = None + + +def test_pastebin_version_twice(pbclient, monkeypatch): + """Test whether calling pastebin_version twice sends no data.""" + monkeypatch.setattr('qutebrowser.utils.version.version', + lambda: "dummy") + version.pastebin_version(pbclient) - assert version.pastebin_url == "test" + pbclient.success.emit("test") + + pbclient.url = None + pbclient.data = None + version.pastebin_url = "test2" + + version.pastebin_version(pbclient) + assert pbclient.url is None + assert pbclient.data is None + assert version.pastebin_url == "test2" + + version.pastebin_url = None def test_pastebin_version_error(pbclient, caplog, monkeypatch): """Test version.pastebin_version() with errors.""" - patches = { - '_path_info': lambda: {'PATH DESC': 'PATH NAME'}, - '_uptime': lambda: datetime.timedelta(hours=1, minutes=23, seconds=45), - } - - for name, val in patches.items(): - monkeypatch.setattr('qutebrowser.utils.version.' + name, val) + monkeypatch.setattr('qutebrowser.utils.version.version', + lambda: "dummy") version.pastebin_url = None with caplog.at_level(logging.ERROR): From 81acba47003a3c0c1182ad15498685f398c21a8e Mon Sep 17 00:00:00 2001 From: Jonathan Berglind Date: Tue, 13 Feb 2018 15:01:45 +0100 Subject: [PATCH 087/524] Use HTTPStatus for existing tests, add more ones Add tests for endpoints being refactored --- tests/end2end/fixtures/test_webserver.py | 36 +++++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/tests/end2end/fixtures/test_webserver.py b/tests/end2end/fixtures/test_webserver.py index a59d425e2..8187e32cd 100644 --- a/tests/end2end/fixtures/test_webserver.py +++ b/tests/end2end/fixtures/test_webserver.py @@ -22,6 +22,7 @@ import json import urllib.request import urllib.error +from http import HTTPStatus import pytest @@ -52,11 +53,38 @@ def test_server(server, qtbot, path, content, expected): @pytest.mark.parametrize('line, verb, path, equal', [ - ({'verb': 'GET', 'path': '/', 'status': 200}, 'GET', '/', True), - ({'verb': 'GET', 'path': '/foo/', 'status': 200}, 'GET', '/foo', True), + ({'verb': 'GET', 'path': '/', 'status': HTTPStatus.OK}, 'GET', '/', True), + ({'verb': 'GET', 'path': '/foo/', 'status': HTTPStatus.OK}, + 'GET', '/foo', True), + ({'verb': 'GET', 'path': '/relative-redirect', 'status': HTTPStatus.FOUND}, + 'GET', '/relative-redirect', True), + ({'verb': 'GET', 'path': '/absolute-redirect', 'status': HTTPStatus.FOUND}, + 'GET', '/absolute-redirect', True), + ({'verb': 'GET', 'path': '/redirect-to', 'status': HTTPStatus.FOUND}, + 'GET', '/redirect-to', True), + ({'verb': 'GET', 'path': '/redirect-self', 'status': HTTPStatus.FOUND}, + 'GET', '/redirect-self', True), + ({'verb': 'GET', 'path': '/content-size', 'status': HTTPStatus.OK}, + 'GET', '/content-size', True), + ({'verb': 'GET', 'path': '/twenty-mb', 'status': HTTPStatus.OK}, + 'GET', '/twenty-mb', True), + ({'verb': 'GET', 'path': '/500-inline', + 'status': HTTPStatus.INTERNAL_SERVER_ERROR}, 'GET', '/500-inline', True), + ({'verb': 'GET', 'path': '/basic-auth/user1/password1', + 'status': HTTPStatus.UNAUTHORIZED}, + 'GET', '/basic-auth/user1/password1', True), + ({'verb': 'GET', 'path': '/drip', 'status': HTTPStatus.OK}, + 'GET', '/drip', True), + ({'verb': 'GET', 'path': '/404', 'status': HTTPStatus.NOT_FOUND}, + 'GET', '/404', True), - ({'verb': 'GET', 'path': '/', 'status': 200}, 'GET', '/foo', False), - ({'verb': 'POST', 'path': '/', 'status': 200}, 'GET', '/', False), + ({'verb': 'GET', 'path': '/', 'status': HTTPStatus.OK}, + 'GET', '/foo', False), + ({'verb': 'POST', 'path': '/', 'status': HTTPStatus.OK}, + 'GET', '/', False), + ({'verb': 'GET', 'path': '/basic-auth/user/password', + 'status': HTTPStatus.UNAUTHORIZED}, + 'GET', '/basic-auth/user/passwd', False), ]) def test_expected_request(server, line, verb, path, equal): expected = server.ExpectedRequest(verb, path) From 3d5bba9cff7d60f3803f595ad709e3881c85b830 Mon Sep 17 00:00:00 2001 From: Jonathan Berglind Date: Tue, 13 Feb 2018 15:05:15 +0100 Subject: [PATCH 088/524] Use HTTPStatus in flask test server --- tests/end2end/fixtures/webserver_sub.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/end2end/fixtures/webserver_sub.py b/tests/end2end/fixtures/webserver_sub.py index 4ec4619f7..f0720bab6 100644 --- a/tests/end2end/fixtures/webserver_sub.py +++ b/tests/end2end/fixtures/webserver_sub.py @@ -32,6 +32,7 @@ import time import signal import os import threading +from http import HTTPStatus import cheroot.wsgi import flask @@ -112,7 +113,7 @@ def redirect_n_times(n): def relative_redirect(): """302 Redirect once.""" response = app.make_response('') - response.status_code = 302 + response.status_code = HTTPStatus.FOUND response.headers['Location'] = flask.url_for('root') return response @@ -121,7 +122,7 @@ def relative_redirect(): def absolute_redirect(): """302 Redirect once.""" response = app.make_response('') - response.status_code = 302 + response.status_code = HTTPStatus.FOUND response.headers['Location'] = flask.url_for('root', _external=True) return response @@ -133,7 +134,7 @@ def redirect_to(): # werkzeug from "fixing" the URL. This endpoint should set the Location # header to the exact string supplied. response = app.make_response('') - response.status_code = 302 + response.status_code = HTTPStatus.FOUND response.headers['Location'] = flask.request.args['url'].encode('utf-8') return response @@ -149,7 +150,7 @@ def content_size(): response = flask.Response(generate_bytes(), headers={ "Content-Type": "application/octet-stream", }) - response.status_code = 200 + response.status_code = HTTPStatus.OK return response @@ -163,7 +164,7 @@ def twenty_mb(): "Content-Type": "application/octet-stream", "Content-Length": str(20 * 1024 * 1024), }) - response.status_code = 200 + response.status_code = HTTPStatus.OK return response @@ -174,7 +175,7 @@ def internal_error_attachment(): "Content-Type": "application/octet-stream", "Content-Disposition": 'inline; filename="attachment.jpg"', }) - response.status_code = 500 + response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR return response @@ -199,7 +200,7 @@ def basic_auth(user='user', passwd='passwd'): auth = flask.request.authorization if not auth or auth.username != user or auth.password != passwd: r = flask.make_response() - r.status_code = 401 + r.status_code = HTTPStatus.UNAUTHORIZED r.headers = {'WWW-Authenticate': 'Basic realm="Fake Realm"'} return r @@ -222,14 +223,14 @@ def drip(): "Content-Type": "application/octet-stream", "Content-Length": str(numbytes), }) - response.status_code = 200 + response.status_code = HTTPStatus.OK return response @app.route('/404') def status_404(): r = flask.make_response() - r.status_code = 404 + r.status_code = HTTPStatus.NOT_FOUND return r From 681bb058fa3ca862a0ba4184a23dfcef1990f9eb Mon Sep 17 00:00:00 2001 From: Jonathan Berglind Date: Tue, 13 Feb 2018 17:58:11 +0100 Subject: [PATCH 089/524] Use HTTPStatus enum instead of http.client in webserver fixture --- tests/end2end/fixtures/webserver.py | 30 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 2beb6fb95..9ba665d0c 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -23,7 +23,7 @@ import re import sys import json import os.path -import http.client +from http import HTTPStatus import attr import pytest @@ -65,28 +65,28 @@ class Request(testprocess.Line): # WORKAROUND for https://github.com/PyCQA/pylint/issues/399 (?) # pylint: disable=no-member path_to_statuses = { - '/favicon.ico': [http.client.NOT_FOUND], - '/does-not-exist': [http.client.NOT_FOUND], - '/does-not-exist-2': [http.client.NOT_FOUND], - '/404': [http.client.NOT_FOUND], + '/favicon.ico': [HTTPStatus.NOT_FOUND], + '/does-not-exist': [HTTPStatus.NOT_FOUND], + '/does-not-exist-2': [HTTPStatus.NOT_FOUND], + '/404': [HTTPStatus.NOT_FOUND], - '/redirect-later': [http.client.FOUND], - '/redirect-self': [http.client.FOUND], - '/redirect-to': [http.client.FOUND], - '/relative-redirect': [http.client.FOUND], - '/absolute-redirect': [http.client.FOUND], + '/redirect-later': [HTTPStatus.FOUND], + '/redirect-self': [HTTPStatus.FOUND], + '/redirect-to': [HTTPStatus.FOUND], + '/relative-redirect': [HTTPStatus.FOUND], + '/absolute-redirect': [HTTPStatus.FOUND], - '/cookies/set': [http.client.FOUND], + '/cookies/set': [HTTPStatus.FOUND], - '/500-inline': [http.client.INTERNAL_SERVER_ERROR], + '/500-inline': [HTTPStatus.INTERNAL_SERVER_ERROR], } for i in range(15): - path_to_statuses['/redirect/{}'.format(i)] = [http.client.FOUND] + path_to_statuses['/redirect/{}'.format(i)] = [HTTPStatus.FOUND] for suffix in ['', '1', '2', '3', '4', '5', '6']: key = '/basic-auth/user{}/password{}'.format(suffix, suffix) - path_to_statuses[key] = [http.client.UNAUTHORIZED, http.client.OK] + path_to_statuses[key] = [HTTPStatus.UNAUTHORIZED, HTTPStatus.OK] - default_statuses = [http.client.OK, http.client.NOT_MODIFIED] + default_statuses = [HTTPStatus.OK, HTTPStatus.NOT_MODIFIED] # pylint: enable=no-member sanitized = QUrl('http://localhost' + self.path).path() # Remove ?foo From 29ff4259d69f3e3c994c3e66e93350d78a5d972f Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Tue, 13 Feb 2018 20:09:19 +0000 Subject: [PATCH 090/524] Add test for _uptime() --- tests/helpers/stubs.py | 10 ---------- tests/unit/misc/test_pastebin.py | 2 +- tests/unit/utils/test_version.py | 17 +++++++++++------ 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index ede322b74..27a23650d 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -585,13 +585,3 @@ class HTTPPostStub(QObject): def post(self, url, data=None): self.url = url self.data = data - - -@pytest.fixture -def pbclient(stubs): - http_stub = stubs.HTTPPostStub() - client = pastebin.PastebinClient(http_stub) - return client - - - diff --git a/tests/unit/misc/test_pastebin.py b/tests/unit/misc/test_pastebin.py index 9546bcf36..1d684dc4e 100644 --- a/tests/unit/misc/test_pastebin.py +++ b/tests/unit/misc/test_pastebin.py @@ -18,7 +18,7 @@ # along with qutebrowser. If not, see . import pytest -from PyQt5.QtCore import pyqtSignal, QUrl, QObject +from PyQt5.QtCore import QUrl from qutebrowser.misc import httpclient, pastebin diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index f4f7270ba..f6e7efb16 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -35,8 +35,6 @@ import datetime import attr import pkg_resources import pytest -from PyQt5.QtCore import pyqtSignal, QObject -from PyQt5.QtWidgets import QApplication import qutebrowser from qutebrowser.utils import version, usertypes, utils @@ -1011,7 +1009,14 @@ def test_pastebin_version_error(pbclient, caplog, monkeypatch): assert caplog.records[0].message == "Failed to pastebin version info: test" -def test_uptime(monkeypatch): - """Test _uptime runs without failing. Its effects are tested elsewhere.""" - QApplication.instance().launch_time = datetime.datetime(1, 1, 1) - version._uptime() +def test_uptime(monkeypatch, qapp): + """Test _uptime runs and check if microseconds are dropped.""" + launch_time = datetime.datetime(1, 1, 1, 1, 1, 1, 1) + qapp.launch_time = launch_time + + class FakeDateTime(datetime.datetime): + now = lambda x=datetime.datetime(1, 1, 1, 1, 1, 1, 2): x + monkeypatch.setattr('datetime.datetime', FakeDateTime) + + uptime_delta = version._uptime() + assert uptime_delta == datetime.timedelta(0) From 1893a33708152fb0c30bc273e5f41a5a705f4543 Mon Sep 17 00:00:00 2001 From: George Edward Bulmer Date: Tue, 13 Feb 2018 20:51:18 +0000 Subject: [PATCH 091/524] Monkeypatch qapp.launch_time too --- tests/unit/utils/test_version.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index f6e7efb16..eff67dcd8 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -996,7 +996,7 @@ def test_pastebin_version_twice(pbclient, monkeypatch): version.pastebin_url = None -def test_pastebin_version_error(pbclient, caplog, monkeypatch): +def test_pastebin_version_error(pbclient, caplog, message_mock, monkeypatch): """Test version.pastebin_version() with errors.""" monkeypatch.setattr('qutebrowser.utils.version.version', lambda: "dummy") @@ -1005,14 +1005,17 @@ def test_pastebin_version_error(pbclient, caplog, monkeypatch): with caplog.at_level(logging.ERROR): version.pastebin_version(pbclient) pbclient._client.error.emit("test") + assert version.pastebin_url is None - assert caplog.records[0].message == "Failed to pastebin version info: test" + + msg = message_mock.getmsg(usertypes.MessageLevel.error) + assert msg.text == "Failed to pastebin version info: test" def test_uptime(monkeypatch, qapp): """Test _uptime runs and check if microseconds are dropped.""" launch_time = datetime.datetime(1, 1, 1, 1, 1, 1, 1) - qapp.launch_time = launch_time + monkeypatch.setattr(qapp, "launch_time", launch_time, raising=False) class FakeDateTime(datetime.datetime): now = lambda x=datetime.datetime(1, 1, 1, 1, 1, 1, 2): x From 12d74c5b52b860409bf96ecea738ab8290e3745c Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Wed, 14 Feb 2018 11:40:51 -0500 Subject: [PATCH 092/524] Fix broken language links in contributing --- doc/contributing.asciidoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/contributing.asciidoc b/doc/contributing.asciidoc index a75034b95..9937434b9 100644 --- a/doc/contributing.asciidoc +++ b/doc/contributing.asciidoc @@ -44,8 +44,8 @@ be easy to solve] If you prefer C++ or Javascript to Python, see the relevant issues which involve work in those languages: -* https://github.com/qutebrowser/qutebrowser/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3Ac%2B%2B[C++] (mostly work on Qt, the library behind qutebrowser) -* https://github.com/qutebrowser/qutebrowser/issues?q=is%3Aopen+is%3Aissue+label%3Ajavascript[JavaScript] +* https://github.com/qutebrowser/qutebrowser/issues?q=is%3Aopen+is%3Aissue+label%3A%22language%3A+c%2B%2B%22[C++] (mostly work on Qt, the library behind qutebrowser) +* https://github.com/qutebrowser/qutebrowser/issues?q=is%3Aopen+is%3Aissue+label%3A%22language%3A+javascript%22[JavaScript] There are also some things to do if you don't want to write code: From b93c0dad5ae08525d1998f5076a325eb0b4bf92f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 14 Feb 2018 20:03:01 +0100 Subject: [PATCH 093/524] urlmatch: Start UrlPattern --- qutebrowser/utils/urlmatch.py | 99 +++++++++++++++++++++++++++++++ tests/unit/utils/test_urlmatch.py | 58 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 qutebrowser/utils/urlmatch.py create mode 100644 tests/unit/utils/test_urlmatch.py diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py new file mode 100644 index 000000000..f4385ee4d --- /dev/null +++ b/qutebrowser/utils/urlmatch.py @@ -0,0 +1,99 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# 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 . + +"""A Chromium-like URL matching pattern. + +See: +https://developer.chrome.com/apps/match_patterns +https://cs.chromium.org/chromium/src/extensions/common/url_pattern.cc +https://cs.chromium.org/chromium/src/extensions/common/url_pattern.h +""" + +import contextlib +import urllib.parse + +from qutebrowser.utils import utils + + +class UrlPattern: + + """A Chromium-like URL matching pattern.""" + + SCHEMES = ['https', 'http', 'ftp', 'file', 'chrome', 'qute', 'about'] + + def __init__(self, pattern): + # Make sure all attributes are initialized if we exit early. + self._pattern = pattern + self._match_all = False + self._match_subdomains = False + self._scheme = None + self._host = None + + # > The special pattern matches any URL that starts with a + # > permitted scheme. + if pattern == '': + self._match_all = True + return + + # > If the scheme is *, then it matches either http or https, and not + # > file, or ftp. + # Note we deviate from that, as per-URL settings aren't security + # relevant. + if pattern.startswith('*:'): # Any scheme + self._scheme = '*' + pattern = 'any:' + pattern[2:] # Make it parseable again + + # We use urllib.parse instead of QUrl here because it can handle + # hosts with * in them. + parsed = urllib.parse.urlparse(pattern) + # "Changed in version 3.6: Out-of-range port numbers now raise + # ValueError, instead of returning None." + if parsed is None: + raise ValueError("Failed to parse {}".format(pattern)) + + self._init_scheme(parsed) + self._init_host(parsed) + self._init_path(parsed) + + def _init_scheme(self, parsed): + if not parsed.scheme: + raise ValueError("No scheme given") + if parsed.scheme not in self.SCHEMES: + raise ValueError("Unknown scheme {}".format(parsed.scheme)) + self._scheme = parsed.scheme + + def _init_path(self, parsed): + # FIXME store somewhere + if self._scheme == 'about' and not parsed.path.strip(): + raise ValueError("Pattern without path") + + def _init_host(self, parsed): + if self._scheme != 'about' and not parsed.netloc.strip(): + raise ValueError("Pattern without host") + host_parts = parsed.netloc.split('.') + if host_parts[0] == '*': + host_parts = host_parts[1:] + self._match_subdomains = True + self._host = '.'.join(host_parts) + if '*' in self._host: + # Only * or *.foo is allowed as host. + raise ValueError("Invalid host wildcard") + + def __repr__(self): + return utils.get_repr(self, pattern=self._pattern, constructor=True) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py new file mode 100644 index 000000000..fe7b9e9ed --- /dev/null +++ b/tests/unit/utils/test_urlmatch.py @@ -0,0 +1,58 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# 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 . + +"""Tests for qutebrowser.utils.urlmatch. + +Some data is inspired by Chromium's tests: +https://cs.chromium.org/chromium/src/extensions/common/url_pattern_unittest.cc +""" + +import pytest + +from qutebrowser.utils import urlmatch + + +@pytest.mark.parametrize('pattern, error', [ + # Chromium: PARSE_ERROR_MISSING_SCHEME_SEPARATOR + ("http", "No scheme given"), + ("http:", "Pattern without host"), + ("http:/", "Pattern without host"), + ("about://", "Pattern without path"), + ("http:/bar", "Pattern without host"), + + # Chromium: PARSE_ERROR_EMPTY_HOST + ("http://", "Pattern without host"), + ("http:///", "Pattern without host"), + ("http:// /", "Pattern without host"), + + # Chromium: PARSE_ERROR_EMPTY_PATH + # FIXME: should we allow this or not? + # ("http://bar", "URLPattern::"), + + # Chromium: PARSE_ERROR_INVALID_HOST_WILDCARD + ("http://*foo/bar", "Invalid host wildcard"), + ("http://foo.*.bar/baz", "Invalid host wildcard"), + ("http://fo.*.ba:123/baz", "Invalid host wildcard"), + ("http://foo.*/bar", "Invalid host wildcard"), + + # Some more tests +]) +def test_invalid_patterns(pattern, error): + with pytest.raises(ValueError, match=error): + urlmatch.UrlPattern(pattern) From 76efba296f63f5da7c093d603f61e01015d98ddf Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 14 Feb 2018 20:08:14 +0100 Subject: [PATCH 094/524] urlmatch: Store path/port --- qutebrowser/utils/urlmatch.py | 9 ++++++++- tests/unit/utils/test_urlmatch.py | 2 -- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index f4385ee4d..43c7fa8c8 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -44,6 +44,8 @@ class UrlPattern: self._match_subdomains = False self._scheme = None self._host = None + self._path = None + self._port = None # > The special pattern matches any URL that starts with a # > permitted scheme. @@ -70,6 +72,7 @@ class UrlPattern: self._init_scheme(parsed) self._init_host(parsed) self._init_path(parsed) + self._init_port(parsed) def _init_scheme(self, parsed): if not parsed.scheme: @@ -79,9 +82,9 @@ class UrlPattern: self._scheme = parsed.scheme def _init_path(self, parsed): - # FIXME store somewhere if self._scheme == 'about' and not parsed.path.strip(): raise ValueError("Pattern without path") + self._path = parsed.path def _init_host(self, parsed): if self._scheme != 'about' and not parsed.netloc.strip(): @@ -95,5 +98,9 @@ class UrlPattern: # Only * or *.foo is allowed as host. raise ValueError("Invalid host wildcard") + def _init_port(self, parsed): + # FIXME validation? + self._port = parsed.port + def __repr__(self): return utils.get_repr(self, pattern=self._pattern, constructor=True) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index fe7b9e9ed..de499aa87 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -50,8 +50,6 @@ from qutebrowser.utils import urlmatch ("http://foo.*.bar/baz", "Invalid host wildcard"), ("http://fo.*.ba:123/baz", "Invalid host wildcard"), ("http://foo.*/bar", "Invalid host wildcard"), - - # Some more tests ]) def test_invalid_patterns(pattern, error): with pytest.raises(ValueError, match=error): From 1b8dfb6c3638af9ee56b190b24b06f5cc7cd70d7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 14 Feb 2018 22:20:51 +0100 Subject: [PATCH 095/524] urlmatch: Disallow NUL byte See https://bugs.chromium.org/p/chromium/issues/detail?id=390624 With Qt, we might run into the same issue as well at some point, and it sure can't hurt to disallow it. --- qutebrowser/utils/urlmatch.py | 3 +++ tests/unit/utils/test_urlmatch.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 43c7fa8c8..211ac0475 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -53,6 +53,9 @@ class UrlPattern: self._match_all = True return + if '\0' in pattern: + raise ValueError("May not contain NUL byte") + # > If the scheme is *, then it matches either http or https, and not # > file, or ftp. # Note we deviate from that, as per-URL settings aren't security diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index de499aa87..26b4b6a59 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -45,6 +45,9 @@ from qutebrowser.utils import urlmatch # FIXME: should we allow this or not? # ("http://bar", "URLPattern::"), + # Chromium: PARSE_ERROR_INVALID_HOST + ("http://\0www/", "May not contain NUL byte"), + # Chromium: PARSE_ERROR_INVALID_HOST_WILDCARD ("http://*foo/bar", "Invalid host wildcard"), ("http://foo.*.bar/baz", "Invalid host wildcard"), From 3c17bb97c0cea3628620579d6e0818e2fac880ab Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 14 Feb 2018 22:30:35 +0100 Subject: [PATCH 096/524] urlmatch: Start with port parsing --- qutebrowser/utils/urlmatch.py | 4 ++-- tests/unit/utils/test_urlmatch.py | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 211ac0475..665536959 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -90,9 +90,9 @@ class UrlPattern: self._path = parsed.path def _init_host(self, parsed): - if self._scheme != 'about' and not parsed.netloc.strip(): + if self._scheme != 'about' and not parsed.hostname.strip(): raise ValueError("Pattern without host") - host_parts = parsed.netloc.split('.') + host_parts = parsed.hostname.split('.') if host_parts[0] == '*': host_parts = host_parts[1:] self._match_subdomains = True diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 26b4b6a59..03b7f947b 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -57,3 +57,30 @@ from qutebrowser.utils import urlmatch def test_invalid_patterns(pattern, error): with pytest.raises(ValueError, match=error): urlmatch.UrlPattern(pattern) + + +@pytest.mark.parametrize('pattern, port', [ + ("http://foo:1234/", 1234), + ("http://foo:1234/bar", 1234), + ("http://*.foo:1234/", 1234), + ("http://*.foo:1234/bar", 1234), + ("http://:1234/", 1234), + ("http://foo:*/", "*"), + ("file://foo:1234/bar", "*"), +]) +def test_port_valid(pattern, port): + up = urlmatch.UrlPattern(pattern) + assert up._port == port + + +@pytest.mark.parametrize('pattern', [ + "http://foo:/", + "http://*.foo:/", + "http://foo:com/", + "http://foo:123456/", + "http://foo:80:80/monkey", + "chrome://foo:1234/bar", +]) +def test_port_invalid(pattern): + with pytest.raises(ValueError, match='Invalid Port'): + urlmatch.UrlPattern(pattern) From 32abb67d1f07a5ddfd485fdbdffaa4e9e59f1bfe Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 14 Feb 2018 23:28:14 +0100 Subject: [PATCH 097/524] urlmatch: Use dedicated ParseError exception --- qutebrowser/utils/urlmatch.py | 24 ++++++++++++++++-------- tests/unit/utils/test_urlmatch.py | 4 ++-- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 665536959..5bf9aaba0 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -31,6 +31,11 @@ import urllib.parse from qutebrowser.utils import utils +class ParseError(Exception): + + """Raised when a pattern could not be parsed.""" + + class UrlPattern: """A Chromium-like URL matching pattern.""" @@ -54,7 +59,7 @@ class UrlPattern: return if '\0' in pattern: - raise ValueError("May not contain NUL byte") + raise ParseError("May not contain NUL byte") # > If the scheme is *, then it matches either http or https, and not # > file, or ftp. @@ -66,11 +71,14 @@ class UrlPattern: # We use urllib.parse instead of QUrl here because it can handle # hosts with * in them. - parsed = urllib.parse.urlparse(pattern) + try: + parsed = urllib.parse.urlparse(pattern) + except ValueError as e: + raise ParseError(str(e)) # "Changed in version 3.6: Out-of-range port numbers now raise # ValueError, instead of returning None." if parsed is None: - raise ValueError("Failed to parse {}".format(pattern)) + raise ParseError("Failed to parse {}".format(pattern)) self._init_scheme(parsed) self._init_host(parsed) @@ -79,19 +87,19 @@ class UrlPattern: def _init_scheme(self, parsed): if not parsed.scheme: - raise ValueError("No scheme given") + raise ParseError("No scheme given") if parsed.scheme not in self.SCHEMES: - raise ValueError("Unknown scheme {}".format(parsed.scheme)) + raise ParseError("Unknown scheme {}".format(parsed.scheme)) self._scheme = parsed.scheme def _init_path(self, parsed): if self._scheme == 'about' and not parsed.path.strip(): - raise ValueError("Pattern without path") + raise ParseError("Pattern without path") self._path = parsed.path def _init_host(self, parsed): if self._scheme != 'about' and not parsed.hostname.strip(): - raise ValueError("Pattern without host") + raise ParseError("Pattern without host") host_parts = parsed.hostname.split('.') if host_parts[0] == '*': host_parts = host_parts[1:] @@ -99,7 +107,7 @@ class UrlPattern: self._host = '.'.join(host_parts) if '*' in self._host: # Only * or *.foo is allowed as host. - raise ValueError("Invalid host wildcard") + raise ParseError("Invalid host wildcard") def _init_port(self, parsed): # FIXME validation? diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 03b7f947b..6845631ad 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -55,7 +55,7 @@ from qutebrowser.utils import urlmatch ("http://foo.*/bar", "Invalid host wildcard"), ]) def test_invalid_patterns(pattern, error): - with pytest.raises(ValueError, match=error): + with pytest.raises(urlmatch.ParseError, match=error): urlmatch.UrlPattern(pattern) @@ -82,5 +82,5 @@ def test_port_valid(pattern, port): "chrome://foo:1234/bar", ]) def test_port_invalid(pattern): - with pytest.raises(ValueError, match='Invalid Port'): + with pytest.raises(urlmatch.ParseError): urlmatch.UrlPattern(pattern) From c728d78beaefafb41e0764e40cab3dc8c9287585 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 14 Feb 2018 23:29:02 +0100 Subject: [PATCH 098/524] urlmatch: Host/port parsing --- qutebrowser/utils/urlmatch.py | 37 +++++++++++++++++++++++++++---- tests/unit/utils/test_urlmatch.py | 6 +++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 5bf9aaba0..07355e885 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -98,8 +98,17 @@ class UrlPattern: self._path = parsed.path def _init_host(self, parsed): - if self._scheme != 'about' and not parsed.hostname.strip(): - raise ParseError("Pattern without host") + """Parse the host from the given URL. + + Deviation from Chromium: + - http://:1234/ is not a valid URL because it has no host. + """ + if parsed.hostname is None or not parsed.hostname.strip(): + if self._scheme != 'about': + raise ParseError("Pattern without host") + assert self._host is None + return + host_parts = parsed.hostname.split('.') if host_parts[0] == '*': host_parts = host_parts[1:] @@ -110,8 +119,28 @@ class UrlPattern: raise ParseError("Invalid host wildcard") def _init_port(self, parsed): - # FIXME validation? - self._port = parsed.port + """Parse the port from the given URL. + + Deviation from Chromium: + - file://foo:1234/bar is invalid instead of falling back to * + """ + if parsed.netloc.endswith(':*'): + # We can't access parsed.port as it tries to run int() + self._port = '*' + elif parsed.netloc.endswith(':'): + raise ParseError("Empty port") + else: + try: + self._port = parsed.port + except ValueError: + raise ParseError("Invalid port") + + allows_ports = {'https': True, 'http': True, 'ftp': True, + 'file': False, 'chrome': False, 'qute': False, + 'about': False} + if not allows_ports[self._scheme] and self._port is not None: + raise ParseError("Ports unsupported with {} scheme".format( + self._scheme)) def __repr__(self): return utils.get_repr(self, pattern=self._pattern, constructor=True) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 6845631ad..eb630f99b 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -64,9 +64,11 @@ def test_invalid_patterns(pattern, error): ("http://foo:1234/bar", 1234), ("http://*.foo:1234/", 1234), ("http://*.foo:1234/bar", 1234), - ("http://:1234/", 1234), + # FIXME Why is this valid in Chromium? + # ("http://:1234/", 1234), ("http://foo:*/", "*"), - ("file://foo:1234/bar", "*"), + # FIXME Why is this valid in Chromium? + # ("file://foo:1234/bar", "*"), ]) def test_port_valid(pattern, port): up = urlmatch.UrlPattern(pattern) From d266190518d35221fb44a39b72d41761880db672 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 14 Feb 2018 23:32:40 +0100 Subject: [PATCH 099/524] urlmatch: Improve port tests --- qutebrowser/utils/urlmatch.py | 2 +- tests/unit/utils/test_urlmatch.py | 23 +++++++++-------------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 07355e885..f171d77be 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -139,7 +139,7 @@ class UrlPattern: 'file': False, 'chrome': False, 'qute': False, 'about': False} if not allows_ports[self._scheme] and self._port is not None: - raise ParseError("Ports unsupported with {} scheme".format( + raise ParseError("Ports are unsupported with {} scheme".format( self._scheme)) def __repr__(self): diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index eb630f99b..47fc8b82b 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -53,6 +53,14 @@ from qutebrowser.utils import urlmatch ("http://foo.*.bar/baz", "Invalid host wildcard"), ("http://fo.*.ba:123/baz", "Invalid host wildcard"), ("http://foo.*/bar", "Invalid host wildcard"), + + # Chromium: PARSE_ERROR_INVALID_PORT + ("http://foo:/", "Empty port"), + ("http://*.foo:/", "Empty port"), + ("http://foo:com/", "Invalid port"), + ("http://foo:123456/", "Invalid port"), + ("http://foo:80:80/monkey", "Invalid port"), + ("chrome://foo:1234/bar", "Ports are unsupported with chrome scheme"), ]) def test_invalid_patterns(pattern, error): with pytest.raises(urlmatch.ParseError, match=error): @@ -70,19 +78,6 @@ def test_invalid_patterns(pattern, error): # FIXME Why is this valid in Chromium? # ("file://foo:1234/bar", "*"), ]) -def test_port_valid(pattern, port): +def test_port(pattern, port): up = urlmatch.UrlPattern(pattern) assert up._port == port - - -@pytest.mark.parametrize('pattern', [ - "http://foo:/", - "http://*.foo:/", - "http://foo:com/", - "http://foo:123456/", - "http://foo:80:80/monkey", - "chrome://foo:1234/bar", -]) -def test_port_invalid(pattern): - with pytest.raises(urlmatch.ParseError): - urlmatch.UrlPattern(pattern) From a2a95f5feef7ec1e304b7929fae91de7b15970ff Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 14:24:07 +0100 Subject: [PATCH 100/524] urlmatch: Improve port handling --- qutebrowser/utils/urlmatch.py | 11 ++++++++++- tests/unit/utils/test_urlmatch.py | 3 +-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index f171d77be..5338a24b5 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -69,6 +69,11 @@ class UrlPattern: self._scheme = '*' pattern = 'any:' + pattern[2:] # Make it parseable again + # Chromium handles file://foo like file:///foo + if (pattern.startswith('file://') and + not pattern.startswith('file:///')): + pattern = 'file:///' + pattern[len("file://"):] + # We use urllib.parse instead of QUrl here because it can handle # hosts with * in them. try: @@ -104,7 +109,7 @@ class UrlPattern: - http://:1234/ is not a valid URL because it has no host. """ if parsed.hostname is None or not parsed.hostname.strip(): - if self._scheme != 'about': + if self._scheme not in ['about', 'file']: raise ParseError("Pattern without host") assert self._host is None return @@ -142,5 +147,9 @@ class UrlPattern: raise ParseError("Ports are unsupported with {} scheme".format( self._scheme)) + if self._port is None and self._scheme == 'file': + # FIXME compatibility with Chromium, but is this needed? + self._port = '*' + def __repr__(self): return utils.get_repr(self, pattern=self._pattern, constructor=True) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 47fc8b82b..8fecb6766 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -75,8 +75,7 @@ def test_invalid_patterns(pattern, error): # FIXME Why is this valid in Chromium? # ("http://:1234/", 1234), ("http://foo:*/", "*"), - # FIXME Why is this valid in Chromium? - # ("file://foo:1234/bar", "*"), + ("file://foo:1234/bar", "*"), ]) def test_port(pattern, port): up = urlmatch.UrlPattern(pattern) From fa329c698ea4403dfca6c76b9d0f88832754e662 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 14:29:39 +0100 Subject: [PATCH 101/524] urlmatch: Finish port parsing --- qutebrowser/utils/urlmatch.py | 8 ++------ tests/unit/utils/test_urlmatch.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 5338a24b5..49551cba2 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -127,11 +127,11 @@ class UrlPattern: """Parse the port from the given URL. Deviation from Chromium: - - file://foo:1234/bar is invalid instead of falling back to * + - We use None instead of "*" if there's no port filter. """ if parsed.netloc.endswith(':*'): # We can't access parsed.port as it tries to run int() - self._port = '*' + self._port = None elif parsed.netloc.endswith(':'): raise ParseError("Empty port") else: @@ -147,9 +147,5 @@ class UrlPattern: raise ParseError("Ports are unsupported with {} scheme".format( self._scheme)) - if self._port is None and self._scheme == 'file': - # FIXME compatibility with Chromium, but is this needed? - self._port = '*' - def __repr__(self): return utils.get_repr(self, pattern=self._pattern, constructor=True) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 8fecb6766..ff24053db 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -74,8 +74,15 @@ def test_invalid_patterns(pattern, error): ("http://*.foo:1234/bar", 1234), # FIXME Why is this valid in Chromium? # ("http://:1234/", 1234), - ("http://foo:*/", "*"), - ("file://foo:1234/bar", "*"), + ("http://foo:*/", None), + ("file://foo:1234/bar", None), + + # Port-like strings in the path should not trigger a warning. + ("http://*/:1234", None), + ("http://*.foo/bar:1234", None), + ("http://foo/bar:1234/path", None), + # We don't implement ALLOW_WILDCARD_FOR_EFFECTIVE_TLD yet. + # ("http://*.foo.*/:1234", None), ]) def test_port(pattern, port): up = urlmatch.UrlPattern(pattern) From 3d6cbcf3969d6779201364bc20fadeefbda767e2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 14:31:08 +0100 Subject: [PATCH 102/524] urlmatch: Improve matching error for TLD wildcards --- qutebrowser/utils/urlmatch.py | 6 +++++- tests/unit/utils/test_urlmatch.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 49551cba2..cf6a5d4da 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -119,7 +119,11 @@ class UrlPattern: host_parts = host_parts[1:] self._match_subdomains = True self._host = '.'.join(host_parts) - if '*' in self._host: + + if self._host.endswith('.*'): + # Special case to have a nicer error + raise ParseError("TLD wildcards are not implemented yet") + elif '*' in self._host: # Only * or *.foo is allowed as host. raise ParseError("Invalid host wildcard") diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index ff24053db..496a14c7f 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -52,7 +52,7 @@ from qutebrowser.utils import urlmatch ("http://*foo/bar", "Invalid host wildcard"), ("http://foo.*.bar/baz", "Invalid host wildcard"), ("http://fo.*.ba:123/baz", "Invalid host wildcard"), - ("http://foo.*/bar", "Invalid host wildcard"), + ("http://foo.*/bar", "TLD wildcards are not implemented yet"), # Chromium: PARSE_ERROR_INVALID_PORT ("http://foo:/", "Empty port"), From 2b274f8e0baf1a492d93599fd58aff6952f77d53 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 15:39:03 +0100 Subject: [PATCH 103/524] urlmatch: Implement initial matching --- qutebrowser/utils/urlmatch.py | 73 ++++++++++++++++++++++++++++++- tests/unit/utils/test_urlmatch.py | 23 ++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index cf6a5d4da..a093c6c63 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -25,6 +25,7 @@ https://cs.chromium.org/chromium/src/extensions/common/url_pattern.cc https://cs.chromium.org/chromium/src/extensions/common/url_pattern.h """ +import fnmatch import contextlib import urllib.parse @@ -114,10 +115,12 @@ class UrlPattern: assert self._host is None return - host_parts = parsed.hostname.split('.') + # FIXME what about multiple dots? + host_parts = parsed.hostname.rstrip('.').split('.') if host_parts[0] == '*': host_parts = host_parts[1:] self._match_subdomains = True + self._host = '.'.join(host_parts) if self._host.endswith('.*'): @@ -153,3 +156,71 @@ class UrlPattern: def __repr__(self): return utils.get_repr(self, pattern=self._pattern, constructor=True) + + def _matches_scheme(self, scheme): + if scheme not in self.SCHEMES: + return False + return self._scheme == '*' or self._scheme == scheme + + def _matches_host(self, host): + # FIXME what about multiple dots? + host = host.rstrip('.') + + # If the hosts are exactly equal, we have a match. + if host == self._host: + return True + + # If we're matching subdomains, and we have no host in the match pattern, + # that means that we're matching all hosts, which means we have a match no + # matter what the test host is. + if self._match_subdomains and not self._host: + return True + + # Otherwise, we can only match if our match pattern matches subdomains. + if not self._match_subdomains: + return False + + # FIXME + # We don't do subdomain matching against IP addresses, so we can give up now + # if the test host is an IP address. + # if (test.HostIsIPAddress()) + # return false; + + # Check if the test host is a subdomain of our host. + if len(host) <= (len(self._host) + 1): + return False + + if not host.endswith(self._host): + return False + + return host[len(host) - len(self._host) - 1] == '.' + + def _matches_port(self, port): + if port == '-1': # QUrl + port = None + return self._port == '*' or self._port == port + + def _matches_path(self, path): + # Match 'google.com' with 'google.com/' + # FIXME use the no-copy approach Chromium has in URLPattern::MatchesPath + # for performance? + if path + '/*' == self._path: + return True + + # FIXME Chromium seems to have a more optimized glob matching which + # doesn't rely on regexes. Do we need that too? + return fnmatch.fnmatchcase(path, self._path) + + def matches(self, qurl): + """Check if the pattern matches the given QUrl.""" + # FIXME do we need to check this early? + if not self._matches_scheme(qurl.scheme()): + return False + + if self._match_all: + return True + + # FIXME ignore host for file:// like Chromium? + return (self._matches_host(qurl.host()) and + self._matches_port(qurl.port()) and + self._matches_path(qurl.path())) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 496a14c7f..1fc53a41f 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -25,6 +25,8 @@ https://cs.chromium.org/chromium/src/extensions/common/url_pattern_unittest.cc import pytest +from PyQt5.QtCore import QUrl + from qutebrowser.utils import urlmatch @@ -87,3 +89,24 @@ def test_invalid_patterns(pattern, error): def test_port(pattern, port): up = urlmatch.UrlPattern(pattern) assert up._port == port + + +def test_match_all_pages_for_given_scheme_attrs(): + up = urlmatch.UrlPattern("http://*/*") + assert up._scheme == 'http' + assert up._host == '' # FIXME '' or None? + assert up._match_subdomains + assert not up._match_all + assert up._path == '/*' + + +@pytest.mark.parametrize('url, expected', [ + ("http://google.com", True), + ("http://yahoo.com", True), + ("http://google.com/foo", True), + ("https://google.com", False), + ("http://74.125.127.100/search", True), +]) +def test_match_all_pages_for_given_scheme_urls(url, expected): + up = urlmatch.UrlPattern("http://*/*") + assert up.matches(QUrl(url)) == expected From a8a976b32423c6ffee699f35ae1f5797e18562b3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 15:49:29 +0100 Subject: [PATCH 104/524] urlmatch: Simplify/fix matching by using None as sentinel --- qutebrowser/utils/urlmatch.py | 50 +++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index a093c6c63..0410090f2 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -39,7 +39,24 @@ class ParseError(Exception): class UrlPattern: - """A Chromium-like URL matching pattern.""" + """A Chromium-like URL matching pattern. + + Class attributes: + SCHEMES: Schemes which are allowed in patterns. + + Attributes: + _pattern: The given pattern as string. + _match_all: Whether the pattern should match all URLs. + _match_subdomains: Whether the pattern should match subdomains of the + given host. + _scheme: The scheme to match to, or None to match any scheme. + Note that with Chromium, '*'/None only matches http/https and + not file/ftp. We deviate from that as per-URL settings aren't + security relevant. + _host: The host to match to, or None for any host. (FIXME true?) + _path: The path to match to, or None for any path. (FIXME true?) + _port: The port to match to as integer, or None for any port. + """ SCHEMES = ['https', 'http', 'ftp', 'file', 'chrome', 'qute', 'about'] @@ -62,18 +79,7 @@ class UrlPattern: if '\0' in pattern: raise ParseError("May not contain NUL byte") - # > If the scheme is *, then it matches either http or https, and not - # > file, or ftp. - # Note we deviate from that, as per-URL settings aren't security - # relevant. - if pattern.startswith('*:'): # Any scheme - self._scheme = '*' - pattern = 'any:' + pattern[2:] # Make it parseable again - - # Chromium handles file://foo like file:///foo - if (pattern.startswith('file://') and - not pattern.startswith('file:///')): - pattern = 'file:///' + pattern[len("file://"):] + pattern = self._fixup_pattern(pattern) # We use urllib.parse instead of QUrl here because it can handle # hosts with * in them. @@ -91,6 +97,18 @@ class UrlPattern: self._init_path(parsed) self._init_port(parsed) + def _fixup_pattern(self, pattern): + """Make sure the given pattern is parseable by urllib.parse.""" + if pattern.startswith('*:'): # Any scheme, but *:// is unparseable + pattern = 'any:' + pattern[2:] + + # Chromium handles file://foo like file:///foo + if (pattern.startswith('file://') and + not pattern.startswith('file:///')): + pattern = 'file:///' + pattern[len("file://"):] + + return pattern + def _init_scheme(self, parsed): if not parsed.scheme: raise ParseError("No scheme given") @@ -160,7 +178,7 @@ class UrlPattern: def _matches_scheme(self, scheme): if scheme not in self.SCHEMES: return False - return self._scheme == '*' or self._scheme == scheme + return self._scheme is None or self._scheme == scheme def _matches_host(self, host): # FIXME what about multiple dots? @@ -196,9 +214,7 @@ class UrlPattern: return host[len(host) - len(self._host) - 1] == '.' def _matches_port(self, port): - if port == '-1': # QUrl - port = None - return self._port == '*' or self._port == port + return self._port is None or self._port == port def _matches_path(self, path): # Match 'google.com' with 'google.com/' From b7c3c10b87345e9d3b2c43c20ee1f965ef541c56 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 15:51:26 +0100 Subject: [PATCH 105/524] urlmatch: Use class in test --- tests/unit/utils/test_urlmatch.py | 37 +++++++++++++++++-------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 1fc53a41f..5c25ce457 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -91,22 +91,25 @@ def test_port(pattern, port): assert up._port == port -def test_match_all_pages_for_given_scheme_attrs(): - up = urlmatch.UrlPattern("http://*/*") - assert up._scheme == 'http' - assert up._host == '' # FIXME '' or None? - assert up._match_subdomains - assert not up._match_all - assert up._path == '/*' +class TestMatchAllPagesForGivenScheme: + @pytest.fixture + def up(self): + return urlmatch.UrlPattern("http://*/*") -@pytest.mark.parametrize('url, expected', [ - ("http://google.com", True), - ("http://yahoo.com", True), - ("http://google.com/foo", True), - ("https://google.com", False), - ("http://74.125.127.100/search", True), -]) -def test_match_all_pages_for_given_scheme_urls(url, expected): - up = urlmatch.UrlPattern("http://*/*") - assert up.matches(QUrl(url)) == expected + def test_attrs(self, up): + assert up._scheme == 'http' + assert up._host == '' # FIXME '' or None? + assert up._match_subdomains + assert not up._match_all + assert up._path == '/*' + + @pytest.mark.parametrize('url, expected', [ + ("http://google.com", True), + ("http://yahoo.com", True), + ("http://google.com/foo", True), + ("https://google.com", False), + ("http://74.125.127.100/search", True), + ]) + def test_urls(self, up, url, expected): + assert up.matches(QUrl(url)) == expected From faeca30dfa77c9eacc2d45ceeb74f53a1fc1f516 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 16:19:22 +0100 Subject: [PATCH 106/524] urlmatch: Add more tests --- tests/unit/utils/test_urlmatch.py | 54 ++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 5c25ce457..aadea5a1d 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -19,8 +19,12 @@ """Tests for qutebrowser.utils.urlmatch. -Some data is inspired by Chromium's tests: +The tests are mostly inspired by Chromium's: https://cs.chromium.org/chromium/src/extensions/common/url_pattern_unittest.cc + +Currently not tested: +- The match_effective_tld attribute as it doesn't exist yet. +- Nested filesystem:// URLs as we don't have those. """ import pytest @@ -113,3 +117,51 @@ class TestMatchAllPagesForGivenScheme: ]) def test_urls(self, up, url, expected): assert up.matches(QUrl(url)) == expected + + +class TestMatchAllDomains: + + @pytest.fixture + def up(self): + return urlmatch.UrlPattern("https://*/foo*") + + def test_attrs(self, up): + assert up._scheme == 'https' + assert up._host == '' # FIXME '' or None? + assert up._match_subdomains + assert not up._match_all + assert up._path == '/foo*' + + @pytest.mark.parametrize('url, expected', [ + ("https://google.com/foo", True), + ("https://google.com/foobar", True), + ("http://google.com/foo", False), + ("https://google.com/", False), + ]) + def test_urls(self, up, url, expected): + assert up.matches(QUrl(url)) == expected + + +class TestMatchSubdomains: + + @pytest.fixture + def up(self): + return urlmatch.UrlPattern("http://*.google.com/foo*bar") + + def test_attrs(self, up): + assert up._scheme == 'http' + assert up._host == 'google.com' + assert up._match_subdomains + assert not up._match_all + assert up._path == '/foo*bar' + + @pytest.mark.parametrize('url, expected', [ + ("http://google.com/foobar", True), + # FIXME The ?bar seems to be treated as path by GURL but as query by + # QUrl. + # ("http://www.google.com/foo?bar", True), + ("http://monkey.images.google.com/foooobar", True), + ("http://yahoo.com/foobar", False), + ]) + def test_urls(self, up, url, expected): + assert up.matches(QUrl(url)) == expected From 9092c3a87f0b949598d73ca9d545b91cefa15fb0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 16:22:29 +0100 Subject: [PATCH 107/524] urlmatch: Increase debuggability --- qutebrowser/utils/urlmatch.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 0410090f2..bd5f17fa5 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -236,7 +236,12 @@ class UrlPattern: if self._match_all: return True - # FIXME ignore host for file:// like Chromium? - return (self._matches_host(qurl.host()) and - self._matches_port(qurl.port()) and - self._matches_path(qurl.path())) + # FIXME ignore for file:// like Chromium? + if not self._matches_host(qurl.host()): + return False + if not self._matches_port(qurl.port()): + return False + if not self._matches_path(qurl.path()): + return False + + return True From 5419d1caa11da9e6dd398667936c5e66f15b1c2a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 16:39:07 +0100 Subject: [PATCH 108/524] urlmatch: Add glob escaping tests --- tests/unit/utils/test_urlmatch.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index aadea5a1d..accbb691f 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -165,3 +165,25 @@ class TestMatchSubdomains: ]) def test_urls(self, up, url, expected): assert up.matches(QUrl(url)) == expected + + +class TestMatchGlobEscaping: + + @pytest.fixture + def up(self): + return urlmatch.UrlPattern(r"file:///foo-bar\*baz") + + def test_attrs(self, up): + assert up._scheme == 'file' + assert up._host == '' # FIXME '' or None? + assert not up._match_subdomains + assert not up._match_all + assert up._path == r'/foo-bar\*baz' + + @pytest.mark.parametrize('url, expected', [ + # We use - instead of ? so it doesn't get treated as query + (r"file:///foo-bar\hellobaz", True), + (r"file:///fooXbar\hellobaz", False), + ]) + def test_urls(self, up, url, expected): + assert up.matches(QUrl(url)) == expected From 2d43a1d2e7b62429fc4bd4bf45aabd51f9115592 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 16:42:41 +0100 Subject: [PATCH 109/524] urlmatch: Use None as default for host --- qutebrowser/utils/urlmatch.py | 9 ++++++++- tests/unit/utils/test_urlmatch.py | 6 +++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index bd5f17fa5..76a72c32e 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -53,7 +53,7 @@ class UrlPattern: Note that with Chromium, '*'/None only matches http/https and not file/ftp. We deviate from that as per-URL settings aren't security relevant. - _host: The host to match to, or None for any host. (FIXME true?) + _host: The host to match to, or None for any host. _path: The path to match to, or None for any path. (FIXME true?) _port: The port to match to as integer, or None for any port. """ @@ -139,6 +139,10 @@ class UrlPattern: host_parts = host_parts[1:] self._match_subdomains = True + if not host_parts: + self._host = None + return + self._host = '.'.join(host_parts) if self._host.endswith('.*'): @@ -184,6 +188,9 @@ class UrlPattern: # FIXME what about multiple dots? host = host.rstrip('.') + if self._host is None: + return True + # If the hosts are exactly equal, we have a match. if host == self._host: return True diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index accbb691f..cc5f4006a 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -103,7 +103,7 @@ class TestMatchAllPagesForGivenScheme: def test_attrs(self, up): assert up._scheme == 'http' - assert up._host == '' # FIXME '' or None? + assert up._host is None assert up._match_subdomains assert not up._match_all assert up._path == '/*' @@ -127,7 +127,7 @@ class TestMatchAllDomains: def test_attrs(self, up): assert up._scheme == 'https' - assert up._host == '' # FIXME '' or None? + assert up._host is None assert up._match_subdomains assert not up._match_all assert up._path == '/foo*' @@ -175,7 +175,7 @@ class TestMatchGlobEscaping: def test_attrs(self, up): assert up._scheme == 'file' - assert up._host == '' # FIXME '' or None? + assert up._host is None assert not up._match_subdomains assert not up._match_all assert up._path == r'/foo-bar\*baz' From 978b90b5b15cb57da5c4581260548b7f238d4f4b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 16:52:35 +0100 Subject: [PATCH 110/524] urlmatch: Implement correct IP matching --- qutebrowser/utils/urlmatch.py | 6 +++--- tests/unit/utils/test_urlmatch.py | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 76a72c32e..5efa5cfb8 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -25,6 +25,7 @@ https://cs.chromium.org/chromium/src/extensions/common/url_pattern.cc https://cs.chromium.org/chromium/src/extensions/common/url_pattern.h """ +import ipaddress import fnmatch import contextlib import urllib.parse @@ -205,11 +206,10 @@ class UrlPattern: if not self._match_subdomains: return False - # FIXME # We don't do subdomain matching against IP addresses, so we can give up now # if the test host is an IP address. - # if (test.HostIsIPAddress()) - # return false; + if not utils.raises(ValueError, ipaddress.ip_address, host): + return False # Check if the test host is a subdomain of our host. if len(host) <= (len(self._host) + 1): diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index cc5f4006a..624736a22 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -187,3 +187,27 @@ class TestMatchGlobEscaping: ]) def test_urls(self, up, url, expected): assert up.matches(QUrl(url)) == expected + + +class TestMatchIpAddresses: + + @pytest.mark.parametrize('pattern, host, match_subdomains', [ + ("http://127.0.0.1/*", "127.0.0.1", False), + ("http://*.0.0.1/*", "0.0.1", True), + ]) + def test_attrs(self, pattern, host, match_subdomains): + up = urlmatch.UrlPattern(pattern) + assert up._scheme == 'http' + assert up._host == host + assert up._match_subdomains == match_subdomains + assert not up._match_all + assert up._path == '/*' + + @pytest.mark.parametrize('pattern, expected', [ + ("http://127.0.0.1/*", True), + # No subdomain matching is done with IPs + ("http://*.0.0.1/*", False), + ]) + def test_urls(self, pattern, expected): + up = urlmatch.UrlPattern(pattern) + assert up.matches(QUrl("http://127.0.0.1")) == expected From a8a9cdd81ee46f40689b96c9a804f729b01d9c20 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 17:03:47 +0100 Subject: [PATCH 111/524] urlmatch: Add more tests from Chromium --- tests/unit/utils/test_urlmatch.py | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 624736a22..4220876e9 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -25,6 +25,7 @@ https://cs.chromium.org/chromium/src/extensions/common/url_pattern_unittest.cc Currently not tested: - The match_effective_tld attribute as it doesn't exist yet. - Nested filesystem:// URLs as we don't have those. +- Unicode matching because QUrl doesn't like those URLs. """ import pytest @@ -211,3 +212,49 @@ class TestMatchIpAddresses: def test_urls(self, pattern, expected): up = urlmatch.UrlPattern(pattern) assert up.matches(QUrl("http://127.0.0.1")) == expected + + +class TestMatchChromeUrls: + + @pytest.fixture + def up(self): + return urlmatch.UrlPattern("chrome://favicon/*") + + def test_attrs(self, up): + assert up._scheme == 'chrome' + assert up._host == 'favicon' + assert not up._match_subdomains + assert not up._match_all + assert up._path == '/*' + + @pytest.mark.parametrize('url, expected', [ + ("chrome://favicon/http://google.com", True), + ("chrome://favicon/https://google.com", True), + ("chrome://history", False), + ]) + def test_urls(self, up, url, expected): + assert up.matches(QUrl(url)) == expected + + +class TestMatchAnything: + + @pytest.fixture + def up(self): + return urlmatch.UrlPattern("*://*/*") + + def test_attrs(self, up): + assert up._scheme is None + assert up._host is None + assert up._match_subdomains + assert not up._match_all + assert up._path == '/*' + + @pytest.mark.parametrize('url, expected', [ + ("http://127.0.0.1", True), + # We deviate from Chromium as we allow other schemes as well + ("chrome://favicon/http://google.com", True), + ("file:///foo/bar", True), + ("file://localhost/foo/bar", True), + ]) + def test_urls(self, up, url, expected): + assert up.matches(QUrl(url)) == expected From 8fd0690959652bbf0df972790f1de426f462f064 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 17:03:59 +0100 Subject: [PATCH 112/524] urlmatch: Fix handling of *:// as scheme --- qutebrowser/utils/urlmatch.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 5efa5cfb8..c3fb5b599 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -113,8 +113,12 @@ class UrlPattern: def _init_scheme(self, parsed): if not parsed.scheme: raise ParseError("No scheme given") - if parsed.scheme not in self.SCHEMES: + elif parsed.scheme == 'any': + self._scheme = None + return + elif parsed.scheme not in self.SCHEMES: raise ParseError("Unknown scheme {}".format(parsed.scheme)) + self._scheme = parsed.scheme def _init_path(self, parsed): @@ -172,7 +176,7 @@ class UrlPattern: allows_ports = {'https': True, 'http': True, 'ftp': True, 'file': False, 'chrome': False, 'qute': False, - 'about': False} + 'about': False, None: True} if not allows_ports[self._scheme] and self._port is not None: raise ParseError("Ports are unsupported with {} scheme".format( self._scheme)) From 867f2a8e2b1f4a3e8151e84648899b76c9712fe6 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 17:10:53 +0100 Subject: [PATCH 113/524] urlmatch: Use None for match-all path --- qutebrowser/utils/urlmatch.py | 8 ++++++-- tests/unit/utils/test_urlmatch.py | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index c3fb5b599..b42e4249d 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -55,7 +55,7 @@ class UrlPattern: not file/ftp. We deviate from that as per-URL settings aren't security relevant. _host: The host to match to, or None for any host. - _path: The path to match to, or None for any path. (FIXME true?) + _path: The path to match to, or None for any path. _port: The port to match to as integer, or None for any port. """ @@ -124,7 +124,8 @@ class UrlPattern: def _init_path(self, parsed): if self._scheme == 'about' and not parsed.path.strip(): raise ParseError("Pattern without path") - self._path = parsed.path + + self._path = None if parsed.path == '/*' else parsed.path def _init_host(self, parsed): """Parse the host from the given URL. @@ -228,6 +229,9 @@ class UrlPattern: return self._port is None or self._port == port def _matches_path(self, path): + if self._path is None: + return True + # Match 'google.com' with 'google.com/' # FIXME use the no-copy approach Chromium has in URLPattern::MatchesPath # for performance? diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 4220876e9..23ca58acc 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -107,7 +107,7 @@ class TestMatchAllPagesForGivenScheme: assert up._host is None assert up._match_subdomains assert not up._match_all - assert up._path == '/*' + assert up._path is None @pytest.mark.parametrize('url, expected', [ ("http://google.com", True), @@ -202,7 +202,7 @@ class TestMatchIpAddresses: assert up._host == host assert up._match_subdomains == match_subdomains assert not up._match_all - assert up._path == '/*' + assert up._path is None @pytest.mark.parametrize('pattern, expected', [ ("http://127.0.0.1/*", True), @@ -225,7 +225,7 @@ class TestMatchChromeUrls: assert up._host == 'favicon' assert not up._match_subdomains assert not up._match_all - assert up._path == '/*' + assert up._path is None @pytest.mark.parametrize('url, expected', [ ("chrome://favicon/http://google.com", True), From 28aadc4f96034465a0d33830bdff77c2f29705b5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 17:11:24 +0100 Subject: [PATCH 114/524] urlmatch: Add tests for --- tests/unit/utils/test_urlmatch.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 23ca58acc..2a33675ae 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -238,16 +238,24 @@ class TestMatchChromeUrls: class TestMatchAnything: - @pytest.fixture - def up(self): - return urlmatch.UrlPattern("*://*/*") + @pytest.fixture(params=['*://*/*', '']) + def up(self, request): + return urlmatch.UrlPattern(request.param) - def test_attrs(self, up): + def test_attrs_common(self, up): assert up._scheme is None assert up._host is None + assert up._path is None + + def test_attrs_wildcard(self): + up = urlmatch.UrlPattern('*://*/*') assert up._match_subdomains assert not up._match_all - assert up._path == '/*' + + def test_attrs_all(self): + up = urlmatch.UrlPattern('') + assert not up._match_subdomains + assert up._match_all @pytest.mark.parametrize('url, expected', [ ("http://127.0.0.1", True), From dae164abeeb3aedc9bda6bec2621140f3a6b5b4b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 17:16:20 +0100 Subject: [PATCH 115/524] urlmatch: Get rid of scheme whitelist There are more schemes like data: or javascript:, and we don't want to restrict schemes anyways. --- qutebrowser/utils/urlmatch.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index b42e4249d..72d026ba9 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -42,9 +42,6 @@ class UrlPattern: """A Chromium-like URL matching pattern. - Class attributes: - SCHEMES: Schemes which are allowed in patterns. - Attributes: _pattern: The given pattern as string. _match_all: Whether the pattern should match all URLs. @@ -59,8 +56,6 @@ class UrlPattern: _port: The port to match to as integer, or None for any port. """ - SCHEMES = ['https', 'http', 'ftp', 'file', 'chrome', 'qute', 'about'] - def __init__(self, pattern): # Make sure all attributes are initialized if we exit early. self._pattern = pattern @@ -116,8 +111,6 @@ class UrlPattern: elif parsed.scheme == 'any': self._scheme = None return - elif parsed.scheme not in self.SCHEMES: - raise ParseError("Unknown scheme {}".format(parsed.scheme)) self._scheme = parsed.scheme @@ -186,8 +179,6 @@ class UrlPattern: return utils.get_repr(self, pattern=self._pattern, constructor=True) def _matches_scheme(self, scheme): - if scheme not in self.SCHEMES: - return False return self._scheme is None or self._scheme == scheme def _matches_host(self, host): From 084d3de65b89fcf4941c8156d90ad35ea3ec0280 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 17:25:10 +0100 Subject: [PATCH 116/524] urlmatch: Add support for data: and javascript: --- qutebrowser/utils/urlmatch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 72d026ba9..92a122377 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -127,7 +127,7 @@ class UrlPattern: - http://:1234/ is not a valid URL because it has no host. """ if parsed.hostname is None or not parsed.hostname.strip(): - if self._scheme not in ['about', 'file']: + if self._scheme not in ['about', 'file', 'data', 'javascript']: raise ParseError("Pattern without host") assert self._host is None return @@ -170,7 +170,8 @@ class UrlPattern: allows_ports = {'https': True, 'http': True, 'ftp': True, 'file': False, 'chrome': False, 'qute': False, - 'about': False, None: True} + 'about': False, 'data': False, 'javascript': False, + None: True} if not allows_ports[self._scheme] and self._port is not None: raise ParseError("Ports are unsupported with {} scheme".format( self._scheme)) From 0a10a4f7518b5967b456708f48740672ee90b9a6 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 17:25:23 +0100 Subject: [PATCH 117/524] urlmatch: Add more tests for special schemes --- tests/unit/utils/test_urlmatch.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 2a33675ae..5a7a03a86 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -257,12 +257,27 @@ class TestMatchAnything: assert not up._match_subdomains assert up._match_all - @pytest.mark.parametrize('url, expected', [ - ("http://127.0.0.1", True), + @pytest.mark.parametrize('url', [ + "http://127.0.0.1", # We deviate from Chromium as we allow other schemes as well - ("chrome://favicon/http://google.com", True), - ("file:///foo/bar", True), - ("file://localhost/foo/bar", True), + "chrome://favicon/http://google.com", + "file:///foo/bar", + "file://localhost/foo/bar", + "qute://version", + "about:blank", + "data:text/html;charset=utf-8,asdf", ]) - def test_urls(self, up, url, expected): - assert up.matches(QUrl(url)) == expected + def test_urls(self, up, url): + assert up.matches(QUrl(url)) + + +@pytest.mark.parametrize('pattern, url, expected', [ + ("about:*", "about:blank", True), + ("about:blank", "about:blank", True), + ("about:*", "about:version", True), + ("data:*", "data:monkey", True), + ("javascript:*", "javascript:atemyhomework", True), + ("data:*", "about:blank", False), +]) +def test_special_schemes(pattern, url, expected): + assert urlmatch.UrlPattern(pattern).matches(QUrl(url)) == expected From a2836ba945e059558f6569309e34a57976daae43 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 17:26:06 +0100 Subject: [PATCH 118/524] urlmatch: Make sure URLs are valid --- qutebrowser/utils/urlmatch.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 92a122377..e49036339 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -30,7 +30,7 @@ import fnmatch import contextlib import urllib.parse -from qutebrowser.utils import utils +from qutebrowser.utils import utils, qtutils class ParseError(Exception): @@ -236,6 +236,8 @@ class UrlPattern: def matches(self, qurl): """Check if the pattern matches the given QUrl.""" + qtutils.ensure_valid(qurl) + # FIXME do we need to check this early? if not self._matches_scheme(qurl.scheme()): return False From 45cc1aaeb036a9010f04a56dc09e41ee12039b5c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 17:42:24 +0100 Subject: [PATCH 119/524] urlmatch: Add tests for file:// --- qutebrowser/utils/urlmatch.py | 1 + tests/unit/utils/test_urlmatch.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index e49036339..2291fe9e3 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -99,6 +99,7 @@ class UrlPattern: pattern = 'any:' + pattern[2:] # Chromium handles file://foo like file:///foo + # FIXME This doesn't actually strip the hostname correctly. if (pattern.startswith('file://') and not pattern.startswith('file:///')): pattern = 'file:///' + pattern[len("file://"):] diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 5a7a03a86..7b0e754f3 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -281,3 +281,33 @@ class TestMatchAnything: ]) def test_special_schemes(pattern, url, expected): assert urlmatch.UrlPattern(pattern).matches(QUrl(url)) == expected + + +class TestFileScheme: + + @pytest.fixture(params=[ + 'file:///foo*', + 'file://foo*', + # FIXME This doesn't pass all tests + pytest.param('file://localhost/foo*', marks=pytest.mark.skip( + reason="We're not handling this correctly in all cases")) + ]) + def up(self, request): + return urlmatch.UrlPattern(request.param) + + def test_attrs(self, up): + assert up._scheme == 'file' + assert up._host is None + assert not up._match_subdomains + assert not up._match_all + assert up._path == '/foo*' + + @pytest.mark.parametrize('url, expected', [ + ("file://foo", False), + ("file://foobar", False), + ("file:///foo", True), + ("file:///foobar", True), + ("file://localhost/foo", True), + ]) + def test_urls(self, up, url, expected): + assert up.matches(QUrl(url)) == expected From 33b7c4bdd0f5a9a97a0e2dcdfbf8a24ae859de1a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 17:51:50 +0100 Subject: [PATCH 120/524] urlmatch: Fix and test port handling --- qutebrowser/utils/urlmatch.py | 18 +++++++----- tests/unit/utils/test_urlmatch.py | 46 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 2291fe9e3..390ba733f 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -42,6 +42,9 @@ class UrlPattern: """A Chromium-like URL matching pattern. + Class attributes: + DEFAULT_PORTS: The default ports used for schemes which support ports. + Attributes: _pattern: The given pattern as string. _match_all: Whether the pattern should match all URLs. @@ -56,6 +59,8 @@ class UrlPattern: _port: The port to match to as integer, or None for any port. """ + DEFAULT_PORTS = {'https': 443, 'http': 80, 'ftp': 21} + def __init__(self, pattern): # Make sure all attributes are initialized if we exit early. self._pattern = pattern @@ -169,11 +174,8 @@ class UrlPattern: except ValueError: raise ParseError("Invalid port") - allows_ports = {'https': True, 'http': True, 'ftp': True, - 'file': False, 'chrome': False, 'qute': False, - 'about': False, 'data': False, 'javascript': False, - None: True} - if not allows_ports[self._scheme] and self._port is not None: + if (self._scheme not in list(self.DEFAULT_PORTS) + [None] and + self._port is not None): raise ParseError("Ports are unsupported with {} scheme".format( self._scheme)) @@ -218,7 +220,9 @@ class UrlPattern: return host[len(host) - len(self._host) - 1] == '.' - def _matches_port(self, port): + def _matches_port(self, scheme, port): + if port == -1 and scheme in self.DEFAULT_PORTS: + port = self.DEFAULT_PORTS[scheme] return self._port is None or self._port == port def _matches_path(self, path): @@ -249,7 +253,7 @@ class UrlPattern: # FIXME ignore for file:// like Chromium? if not self._matches_host(qurl.host()): return False - if not self._matches_port(qurl.port()): + if not self._matches_port(qurl.scheme(), qurl.port()): return False if not self._matches_path(qurl.path()): return False diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 7b0e754f3..b6f550bc8 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -311,3 +311,49 @@ class TestFileScheme: ]) def test_urls(self, up, url, expected): assert up.matches(QUrl(url)) == expected + + +class TestMatchSpecificPort: + + @pytest.fixture + def up(self): + return urlmatch.UrlPattern("http://www.example.com:80/foo") + + def test_attrs(self, up): + assert up._scheme == 'http' + assert up._host == 'www.example.com' + assert not up._match_subdomains + assert not up._match_all + assert up._path == '/foo' + assert up._port == 80 + + @pytest.mark.parametrize('url, expected', [ + ("http://www.example.com:80/foo", True), + ("http://www.example.com/foo", True), + ("http://www.example.com:8080/foo", False), + ]) + def test_urls(self, up, url, expected): + assert up.matches(QUrl(url)) == expected + + +class TestExplicitPortWildcard: + + @pytest.fixture + def up(self): + return urlmatch.UrlPattern("http://www.example.com:*/foo") + + def test_attrs(self, up): + assert up._scheme == 'http' + assert up._host == 'www.example.com' + assert not up._match_subdomains + assert not up._match_all + assert up._path == '/foo' + assert up._port is None + + @pytest.mark.parametrize('url, expected', [ + ("http://www.example.com:80/foo", True), + ("http://www.example.com/foo", True), + ("http://www.example.com:8080/foo", True), + ]) + def test_urls(self, up, url, expected): + assert up.matches(QUrl(url)) == expected From e161458f917af520f6746da5509e4d75cfb772f3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 18:07:06 +0100 Subject: [PATCH 121/524] urlmatch: Add test cases for oddballs --- tests/unit/utils/test_urlmatch.py | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index b6f550bc8..6a99e164f 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -26,6 +26,7 @@ Currently not tested: - The match_effective_tld attribute as it doesn't exist yet. - Nested filesystem:// URLs as we don't have those. - Unicode matching because QUrl doesn't like those URLs. +- Any other features we don't need, such as .GetAsString() or set operations. """ import pytest @@ -357,3 +358,40 @@ class TestExplicitPortWildcard: ]) def test_urls(self, up, url, expected): assert up.matches(QUrl(url)) == expected + + +def test_ignore_missing_slashes(): + pattern1 = urlmatch.UrlPattern("http://www.example.com/example") + pattern2 = urlmatch.UrlPattern("http://www.example.com/example/*") + url1 = QUrl('http://www.example.com/example') + url2 = QUrl('http://www.example.com/example/') + + # Same patterns should match same URLs. + assert pattern1.matches(url1) + assert pattern2.matches(url1) + # The not terminated path should match the terminated pattern. + assert pattern2.matches(url1) + # The terminated path however should not match the unterminated pattern. + assert not pattern1.matches(url2) + + +@pytest.mark.parametrize('pattern', ['*://example.com/*', '*://example.com./*']) +@pytest.mark.parametrize('url', ['http://example.com/', 'http://example.com./']) +def test_trailing_dot_domain(pattern, url): + """Both patterns should match trailing dot and non trailing dot domains. + + More information about this not obvious behaviour can be found in [1]. + + RFC 1738 [2] specifies clearly that the part of a URL is supposed to + contain a fully qualified domain name: + + 3.1. Common Internet Scheme Syntax + //:@:/ + + host + The fully qualified domain name of a network host + + [1] http://www.dns-sd.org./TrailingDotsInDomainNames.html + [2] http://www.ietf.org/rfc/rfc1738.txt + """ + assert urlmatch.UrlPattern(pattern).matches(QUrl(url)) From 5627a632655a04a81f364aca987b509dac996503 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 18:10:11 +0100 Subject: [PATCH 122/524] urlmatch: Fix lint --- qutebrowser/utils/urlmatch.py | 15 +++++++-------- tests/unit/utils/test_urlmatch.py | 6 ++++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 390ba733f..5a67d3ad1 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -27,7 +27,6 @@ https://cs.chromium.org/chromium/src/extensions/common/url_pattern.h import ipaddress import fnmatch -import contextlib import urllib.parse from qutebrowser.utils import utils, qtutils @@ -196,9 +195,9 @@ class UrlPattern: if host == self._host: return True - # If we're matching subdomains, and we have no host in the match pattern, - # that means that we're matching all hosts, which means we have a match no - # matter what the test host is. + # If we're matching subdomains, and we have no host in the match + # pattern, that means that we're matching all hosts, which means we + # have a match no matter what the test host is. if self._match_subdomains and not self._host: return True @@ -206,8 +205,8 @@ class UrlPattern: if not self._match_subdomains: return False - # We don't do subdomain matching against IP addresses, so we can give up now - # if the test host is an IP address. + # We don't do subdomain matching against IP addresses, so we can give + # up now if the test host is an IP address. if not utils.raises(ValueError, ipaddress.ip_address, host): return False @@ -230,8 +229,8 @@ class UrlPattern: return True # Match 'google.com' with 'google.com/' - # FIXME use the no-copy approach Chromium has in URLPattern::MatchesPath - # for performance? + # FIXME use the no-copy approach Chromium has in + # URLPattern::MatchesPath for performance? if path + '/*' == self._path: return True diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 6a99e164f..b7f8da70a 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -375,8 +375,10 @@ def test_ignore_missing_slashes(): assert not pattern1.matches(url2) -@pytest.mark.parametrize('pattern', ['*://example.com/*', '*://example.com./*']) -@pytest.mark.parametrize('url', ['http://example.com/', 'http://example.com./']) +@pytest.mark.parametrize('pattern', ['*://example.com/*', + '*://example.com./*']) +@pytest.mark.parametrize('url', ['http://example.com/', + 'http://example.com./']) def test_trailing_dot_domain(pattern, url): """Both patterns should match trailing dot and non trailing dot domains. From 41b7ac27d72a0b0f2fb572be6db289a49a794de3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 18:11:42 +0100 Subject: [PATCH 123/524] urlmatch: Postpone checking scheme --- qutebrowser/utils/urlmatch.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 5a67d3ad1..f15ab45fe 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -242,13 +242,11 @@ class UrlPattern: """Check if the pattern matches the given QUrl.""" qtutils.ensure_valid(qurl) - # FIXME do we need to check this early? - if not self._matches_scheme(qurl.scheme()): - return False - if self._match_all: return True + if not self._matches_scheme(qurl.scheme()): + return False # FIXME ignore for file:// like Chromium? if not self._matches_host(qurl.host()): return False From 5f6c8435a48d8abe7c325debb22caf8d555cf606 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 18:40:01 +0100 Subject: [PATCH 124/524] urlmatch: Add initial benchmark/hypothesis test --- tests/unit/utils/test_urlmatch.py | 45 ++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index b7f8da70a..67c7ea452 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -29,8 +29,11 @@ Currently not tested: - Any other features we don't need, such as .GetAsString() or set operations. """ -import pytest +import string +import pytest +import hypothesis +import hypothesis.strategies as hst from PyQt5.QtCore import QUrl from qutebrowser.utils import urlmatch @@ -397,3 +400,43 @@ def test_trailing_dot_domain(pattern, url): [2] http://www.ietf.org/rfc/rfc1738.txt """ assert urlmatch.UrlPattern(pattern).matches(QUrl(url)) + + +def test_urlpattern_benchmark(benchmark): + url = QUrl('https://www.example.com/barfoobar') + + def run(): + up = urlmatch.UrlPattern('https://*.example.com/*foo*') + up.matches(url) + + benchmark(run) + + +URL_TEXT = hst.text(alphabet=string.ascii_letters) + + +@hypothesis.given(pattern=hst.builds( + lambda *a: ''.join(a), + # Scheme + hst.one_of(hst.just('*'), hst.just('http'), hst.just('file')), + # Separator + hst.one_of(hst.just(':'), hst.just('://')), + # Host + hst.one_of(hst.just('*'), + hst.builds(lambda *a: ''.join(a), hst.just('*.'), URL_TEXT), + URL_TEXT), + # Port + hst.one_of(hst.just(''), + hst.builds(lambda *a: ''.join(a), hst.just(':'), + hst.integers(min_value=0, + max_value=65535).map(str))), + # Path + hst.one_of(hst.just(''), + hst.builds(lambda *a: ''.join(a), hst.just('/'), URL_TEXT)) +)) +def test_urlpattern_hypothesis(pattern): + try: + up = urlmatch.UrlPattern(pattern) + except urlmatch.ParseError: + return + up.matches(QUrl('https://www.example.com/')) From 174dd5dd9e49b3a55e588b6f460fd029a6074511 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 18:42:47 +0100 Subject: [PATCH 125/524] urlmatch: Remove performance FIXME --- qutebrowser/utils/urlmatch.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index f15ab45fe..4bf02a3b8 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -229,8 +229,6 @@ class UrlPattern: return True # Match 'google.com' with 'google.com/' - # FIXME use the no-copy approach Chromium has in - # URLPattern::MatchesPath for performance? if path + '/*' == self._path: return True From 3ee765869dcaa87663884c971f1635bd0364a17b Mon Sep 17 00:00:00 2001 From: jnphilipp Date: Fri, 16 Feb 2018 14:22:08 +0100 Subject: [PATCH 126/524] Add tor_identity userscript. --- misc/userscripts/tor_identity | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100755 misc/userscripts/tor_identity diff --git a/misc/userscripts/tor_identity b/misc/userscripts/tor_identity new file mode 100755 index 000000000..7eada7fbf --- /dev/null +++ b/misc/userscripts/tor_identity @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Copyright 2018 jnphilipp +# +# 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 . + +# Change your tor identity. +# +# Set a hotkey to launch this script, then: +# :bind ti spawn --userscript tor_identity PASSWORD +# +# Use the hotkey to change your tor identity, press 'ti' to change it. +# https://stem.torproject.org/faq.html#how-do-i-request-a-new-identity-from-tor +# + +import os +import sys + +from stem import Signal +from stem.control import Controller + + +password = sys.argv[1] +with Controller.from_port(port=9051) as controller: + controller.authenticate(password) + controller.signal(Signal.NEWNYM) + with open(os.environ['QUTE_FIFO'], 'w') as f: + f.write('message-info "Tor identity changed."') From 6219119476d35b24be5a83dd8956947db2463c3a Mon Sep 17 00:00:00 2001 From: jnphilipp Date: Sat, 17 Feb 2018 09:48:39 +0100 Subject: [PATCH 127/524] Update output. --- misc/userscripts/tor_identity | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/misc/userscripts/tor_identity b/misc/userscripts/tor_identity index 7eada7fbf..1b0e317ca 100755 --- a/misc/userscripts/tor_identity +++ b/misc/userscripts/tor_identity @@ -38,5 +38,8 @@ password = sys.argv[1] with Controller.from_port(port=9051) as controller: controller.authenticate(password) controller.signal(Signal.NEWNYM) - with open(os.environ['QUTE_FIFO'], 'w') as f: - f.write('message-info "Tor identity changed."') + if os.getenv('QUTE_FIFO'): + with open(os.environ['QUTE_FIFO'], 'w') as f: + f.write('message-info "Tor identity changed."') + else: + print('Tor identity changed.') From 60a7e483afc26012888227e48c7706e2be35cd5c Mon Sep 17 00:00:00 2001 From: jnphilipp Date: Sat, 17 Feb 2018 19:57:44 +0100 Subject: [PATCH 128/524] Add import error message for stem. --- misc/userscripts/tor_identity | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/misc/userscripts/tor_identity b/misc/userscripts/tor_identity index 1b0e317ca..93b6d4136 100755 --- a/misc/userscripts/tor_identity +++ b/misc/userscripts/tor_identity @@ -30,8 +30,15 @@ import os import sys -from stem import Signal -from stem.control import Controller +try: + from stem import Signal + from stem.control import Controller +except ImportError: + if os.getenv('QUTE_FIFO'): + with open(os.environ['QUTE_FIFO'], 'w') as f: + f.write('message-error "Failed to import stem."') + else: + print('Failed to import stem.') password = sys.argv[1] From bf72d81bd3c1993ce634e0ef979bb2f510d342da Mon Sep 17 00:00:00 2001 From: Ross Smith II Date: Sun, 18 Feb 2018 00:07:02 -0800 Subject: [PATCH 129/524] Add scoop installer See https://github.com/lukesampson/scoop-extras/pull/783 --- doc/install.asciidoc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/install.asciidoc b/doc/install.asciidoc index 4b1fd3f26..8916c1fdd 100644 --- a/doc/install.asciidoc +++ b/doc/install.asciidoc @@ -277,6 +277,11 @@ PS C:\> Install-Package qutebrowser ---- C:\> choco install qutebrowser ---- +* Scoop's client +---- +C:\> scoop bucket add extras +C:\> scoop install qutebrowser +---- Manual install ~~~~~~~~~~~~~~ From c84402307713ee71b9b6e5335bb1a0e4834c6c45 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Sun, 18 Feb 2018 14:28:46 -0500 Subject: [PATCH 130/524] Use QUTE_DATA_DIR in readability userscript --- misc/userscripts/readability | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/misc/userscripts/readability b/misc/userscripts/readability index a5425dbac..f6df5000f 100755 --- a/misc/userscripts/readability +++ b/misc/userscripts/readability @@ -13,7 +13,13 @@ from __future__ import absolute_import import codecs, os -tmpfile=os.path.expanduser('~/.local/share/qutebrowser/userscripts/readability.html') +if 'QUTE_DATA_DIR' in os.environ: + tmpfile = os.path.join(os.environ['QUTE_DATA_DIR'], + 'userscripts/readability.html') +else: + tmpfile = os.path.expanduser( + '~/.local/share/qutebrowser/userscripts/readability.html') + if not os.path.exists(os.path.dirname(tmpfile)): os.makedirs(os.path.dirname(tmpfile)) From 84907d5a2e6da150311d802fa4eb9a3e96270b52 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Sun, 18 Feb 2018 14:49:09 -0500 Subject: [PATCH 131/524] Simplify readability logic using get defaults :D --- misc/userscripts/readability | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/misc/userscripts/readability b/misc/userscripts/readability index f6df5000f..d0ef43795 100755 --- a/misc/userscripts/readability +++ b/misc/userscripts/readability @@ -13,12 +13,10 @@ from __future__ import absolute_import import codecs, os -if 'QUTE_DATA_DIR' in os.environ: - tmpfile = os.path.join(os.environ['QUTE_DATA_DIR'], - 'userscripts/readability.html') -else: - tmpfile = os.path.expanduser( - '~/.local/share/qutebrowser/userscripts/readability.html') +tmpfile = os.path.join( + os.environ.get('QUTE_DATA_DIR', + os.path.expanduser('~/.local/share/qutebrowser')), + 'userscripts/readability.html') if not os.path.exists(os.path.dirname(tmpfile)): os.makedirs(os.path.dirname(tmpfile)) From e169e2165da7892c0b9731a94481aebbc867df26 Mon Sep 17 00:00:00 2001 From: bttner <35602050+bttner@users.noreply.github.com> Date: Thu, 15 Feb 2018 12:02:42 +0100 Subject: [PATCH 132/524] Refactor TabbedBrowser from inheritance to composition --- qutebrowser/app.py | 2 +- qutebrowser/browser/commands.py | 51 ++++---- qutebrowser/browser/hints.py | 2 +- qutebrowser/browser/signalfilter.py | 4 +- qutebrowser/completion/models/miscmodels.py | 6 +- qutebrowser/mainwindow/mainwindow.py | 12 +- .../mainwindow/statusbar/backforward.py | 2 +- qutebrowser/mainwindow/statusbar/bar.py | 2 +- qutebrowser/mainwindow/tabbedbrowser.py | 122 +++++++++--------- qutebrowser/mainwindow/tabwidget.py | 16 +-- qutebrowser/misc/sessions.py | 9 +- qutebrowser/misc/utilcmds.py | 2 +- qutebrowser/utils/objreg.py | 2 +- tests/helpers/stubs.py | 38 +++--- tests/unit/browser/test_signalfilter.py | 16 +-- tests/unit/completion/test_models.py | 18 +-- .../mainwindow/statusbar/test_backforward.py | 12 +- tests/unit/mainwindow/test_tabwidget.py | 4 +- 18 files changed, 165 insertions(+), 155 deletions(-) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index ec477ce8f..546c1e17a 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -339,7 +339,7 @@ def _open_startpage(win_id=None): for cur_win_id in list(window_ids): # Copying as the dict could change tabbed_browser = objreg.get('tabbed-browser', scope='window', window=cur_win_id) - if tabbed_browser.count() == 0: + if tabbed_browser.widget.count() == 0: log.init.debug("Opening start pages") for url in config.val.url.start_pages: tabbed_browser.tabopen(url) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 69cb3142f..1eef43148 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -74,16 +74,16 @@ class CommandDispatcher: def _count(self): """Convenience method to get the widget count.""" - return self._tabbed_browser.count() + return self._tabbed_browser.widget.count() def _set_current_index(self, idx): """Convenience method to set the current widget index.""" cmdutils.check_overflow(idx, 'int') - self._tabbed_browser.setCurrentIndex(idx) + self._tabbed_browser.widget.setCurrentIndex(idx) def _current_index(self): """Convenience method to get the current widget index.""" - return self._tabbed_browser.currentIndex() + return self._tabbed_browser.widget.currentIndex() def _current_url(self): """Convenience method to get the current url.""" @@ -102,7 +102,7 @@ class CommandDispatcher: def _current_widget(self): """Get the currently active widget from a command.""" - widget = self._tabbed_browser.currentWidget() + widget = self._tabbed_browser.widget.currentWidget() if widget is None: raise cmdexc.CommandError("No WebView available yet!") return widget @@ -148,10 +148,10 @@ class CommandDispatcher: None if no widget was found. """ if count is None: - return self._tabbed_browser.currentWidget() + return self._tabbed_browser.widget.currentWidget() elif 1 <= count <= self._count(): cmdutils.check_overflow(count + 1, 'int') - return self._tabbed_browser.widget(count - 1) + return self._tabbed_browser.widget.widget(count - 1) else: return None @@ -164,7 +164,7 @@ class CommandDispatcher: if not show_error: return raise cmdexc.CommandError("No last focused tab!") - idx = self._tabbed_browser.indexOf(tab) + idx = self._tabbed_browser.widget.indexOf(tab) if idx == -1: raise cmdexc.CommandError("Last focused tab vanished!") self._set_current_index(idx) @@ -213,7 +213,7 @@ class CommandDispatcher: what's configured in 'tabs.select_on_remove'. count: The tab index to close, or None """ - tabbar = self._tabbed_browser.tabBar() + tabbar = self._tabbed_browser.widget.tabBar() selection_override = self._get_selection_override(prev, next_, opposite) @@ -265,7 +265,7 @@ class CommandDispatcher: return to_pin = not tab.data.pinned - self._tabbed_browser.set_tab_pinned(tab, to_pin) + self._tabbed_browser.widget.set_tab_pinned(tab, to_pin) @cmdutils.register(instance='command-dispatcher', name='open', maxsplit=0, scope='window') @@ -484,7 +484,8 @@ class CommandDispatcher: """ cmdutils.check_exclusive((bg, window), 'bw') curtab = self._current_widget() - cur_title = self._tabbed_browser.page_title(self._current_index()) + cur_title = self._tabbed_browser.widget.page_title( + self._current_index()) try: history = curtab.history.serialize() except browsertab.WebTabError as e: @@ -500,18 +501,18 @@ class CommandDispatcher: newtab = new_tabbed_browser.tabopen(background=bg) new_tabbed_browser = objreg.get('tabbed-browser', scope='window', window=newtab.win_id) - idx = new_tabbed_browser.indexOf(newtab) + idx = new_tabbed_browser.widget.indexOf(newtab) - new_tabbed_browser.set_page_title(idx, cur_title) + new_tabbed_browser.widget.set_page_title(idx, cur_title) if config.val.tabs.favicons.show: - new_tabbed_browser.setTabIcon(idx, curtab.icon()) + new_tabbed_browser.widget.setTabIcon(idx, curtab.icon()) if config.val.tabs.tabs_are_windows: - new_tabbed_browser.window().setWindowIcon(curtab.icon()) + new_tabbed_browser.widget.window().setWindowIcon(curtab.icon()) newtab.data.keep_icon = True newtab.history.deserialize(history) newtab.zoom.set_factor(curtab.zoom.factor()) - new_tabbed_browser.set_tab_pinned(newtab, curtab.data.pinned) + new_tabbed_browser.widget.set_tab_pinned(newtab, curtab.data.pinned) return newtab @cmdutils.register(instance='command-dispatcher', scope='window') @@ -847,7 +848,7 @@ class CommandDispatcher: keep: Stay in visual mode after yanking the selection. """ if what == 'title': - s = self._tabbed_browser.page_title(self._current_index()) + s = self._tabbed_browser.widget.page_title(self._current_index()) elif what == 'domain': port = self._current_url().port() s = '{}://{}{}'.format(self._current_url().scheme(), @@ -959,7 +960,7 @@ class CommandDispatcher: force: Avoid confirmation for pinned tabs. """ cmdutils.check_exclusive((prev, next_), 'pn') - cur_idx = self._tabbed_browser.currentIndex() + cur_idx = self._tabbed_browser.widget.currentIndex() assert cur_idx != -1 def _to_close(i): @@ -1076,11 +1077,11 @@ class CommandDispatcher: tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) - if not 0 < idx <= tabbed_browser.count(): + if not 0 < idx <= tabbed_browser.widget.count(): raise cmdexc.CommandError( "There's no tab with index {}!".format(idx)) - return (tabbed_browser, tabbed_browser.widget(idx-1)) + return (tabbed_browser, tabbed_browser.widget.widget(idx-1)) @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) @@ -1108,10 +1109,10 @@ class CommandDispatcher: tabbed_browser, tab = self._resolve_buffer_index(index) - window = tabbed_browser.window() + window = tabbed_browser.widget.window() window.activateWindow() window.raise_() - tabbed_browser.setCurrentWidget(tab) + tabbed_browser.widget.setCurrentWidget(tab) @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('index', choices=['last']) @@ -1195,7 +1196,7 @@ class CommandDispatcher: cur_idx = self._current_index() cmdutils.check_overflow(cur_idx, 'int') cmdutils.check_overflow(new_idx, 'int') - self._tabbed_browser.tabBar().moveTab(cur_idx, new_idx) + self._tabbed_browser.widget.tabBar().moveTab(cur_idx, new_idx) @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0, no_replace_variables=True) @@ -1279,10 +1280,10 @@ class CommandDispatcher: idx = self._current_index() if idx != -1: - env['QUTE_TITLE'] = self._tabbed_browser.page_title(idx) + env['QUTE_TITLE'] = self._tabbed_browser.widget.page_title(idx) # FIXME:qtwebengine: If tab is None, run_async will fail! - tab = self._tabbed_browser.currentWidget() + tab = self._tabbed_browser.widget.currentWidget() try: url = self._tabbed_browser.current_url() @@ -2220,5 +2221,5 @@ class CommandDispatcher: pass return - window = self._tabbed_browser.window() + window = self._tabbed_browser.widget.window() window.setWindowState(window.windowState() ^ Qt.WindowFullScreen) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 0390d5d1f..e22beeb1a 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -682,7 +682,7 @@ class HintManager(QObject): """ tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) - tab = tabbed_browser.currentWidget() + tab = tabbed_browser.widget.currentWidget() if tab is None: raise cmdexc.CommandError("No WebView available yet!") diff --git a/qutebrowser/browser/signalfilter.py b/qutebrowser/browser/signalfilter.py index 663aa67e7..7cc46abdb 100644 --- a/qutebrowser/browser/signalfilter.py +++ b/qutebrowser/browser/signalfilter.py @@ -76,11 +76,11 @@ class SignalFilter(QObject): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) try: - tabidx = tabbed_browser.indexOf(tab) + tabidx = tabbed_browser.widget.indexOf(tab) except RuntimeError: # The tab has been deleted already return - if tabidx == tabbed_browser.currentIndex(): + if tabidx == tabbed_browser.widget.currentIndex(): if log_signal: log.signals.debug("emitting: {} (tab {})".format( debug.dbg_signal(signal, args), tabidx)) diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py index 049d89295..8606bbf75 100644 --- a/qutebrowser/completion/models/miscmodels.py +++ b/qutebrowser/completion/models/miscmodels.py @@ -117,11 +117,11 @@ def _buffer(skip_win_id=None): if tabbed_browser.shutting_down: continue tabs = [] - for idx in range(tabbed_browser.count()): - tab = tabbed_browser.widget(idx) + for idx in range(tabbed_browser.widget.count()): + tab = tabbed_browser.widget.widget(idx) tabs.append(("{}/{}".format(win_id, idx + 1), tab.url().toDisplayString(), - tabbed_browser.page_title(idx))) + tabbed_browser.widget.page_title(idx))) cat = listcategory.ListCategory("{}".format(win_id), tabs, delete_func=delete_buffer) model.add_category(cat) diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 05482a1d5..b15e98e22 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -327,7 +327,7 @@ class MainWindow(QWidget): self.tabbed_browser) objreg.register('command-dispatcher', dispatcher, scope='window', window=self.win_id) - self.tabbed_browser.destroyed.connect( + self.tabbed_browser.widget.destroyed.connect( functools.partial(objreg.delete, 'command-dispatcher', scope='window', window=self.win_id)) @@ -347,10 +347,10 @@ class MainWindow(QWidget): def _add_widgets(self): """Add or readd all widgets to the VBox.""" - self._vbox.removeWidget(self.tabbed_browser) + self._vbox.removeWidget(self.tabbed_browser.widget) self._vbox.removeWidget(self._downloadview) self._vbox.removeWidget(self.status) - widgets = [self.tabbed_browser] + widgets = [self.tabbed_browser.widget] downloads_position = config.val.downloads.position if downloads_position == 'top': @@ -469,7 +469,7 @@ class MainWindow(QWidget): self.tabbed_browser.cur_scroll_perc_changed.connect( status.percentage.set_perc) - self.tabbed_browser.tab_index_changed.connect( + self.tabbed_browser.widget.tab_index_changed.connect( status.tabindex.on_tab_index_changed) self.tabbed_browser.cur_url_changed.connect(status.url.set_url) @@ -517,7 +517,7 @@ class MainWindow(QWidget): super().resizeEvent(e) self._update_overlay_geometries() self._downloadview.updateGeometry() - self.tabbed_browser.tabBar().refresh() + self.tabbed_browser.widget.tabBar().refresh() def showEvent(self, e): """Extend showEvent to register us as the last-visible-main-window. @@ -546,7 +546,7 @@ class MainWindow(QWidget): if crashsignal.is_crashing: e.accept() return - tab_count = self.tabbed_browser.count() + tab_count = self.tabbed_browser.widget.count() download_model = objreg.get('download-model', scope='window', window=self.win_id) download_count = download_model.running_downloads() diff --git a/qutebrowser/mainwindow/statusbar/backforward.py b/qutebrowser/mainwindow/statusbar/backforward.py index 8ea60ee75..5e244cf8c 100644 --- a/qutebrowser/mainwindow/statusbar/backforward.py +++ b/qutebrowser/mainwindow/statusbar/backforward.py @@ -32,7 +32,7 @@ class Backforward(textbase.TextBase): def on_tab_cur_url_changed(self, tabs): """Called on URL changes.""" - tab = tabs.currentWidget() + tab = tabs.widget.currentWidget() if tab is None: # pragma: no cover self.setText('') self.hide() diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py index 8057bfdb8..9efc26858 100644 --- a/qutebrowser/mainwindow/statusbar/bar.py +++ b/qutebrowser/mainwindow/statusbar/bar.py @@ -268,7 +268,7 @@ class StatusBar(QWidget): """Get the currently displayed tab.""" window = objreg.get('tabbed-browser', scope='window', window=self._win_id) - return window.currentWidget() + return window.widget.currentWidget() def set_mode_active(self, mode, val): """Setter for self.{insert,command,caret}_active. diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 8cee35524..8d1498acb 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -22,7 +22,7 @@ import functools import attr -from PyQt5.QtWidgets import QSizePolicy +from PyQt5.QtWidgets import QSizePolicy, QWidget from PyQt5.QtCore import pyqtSignal, pyqtSlot, QTimer, QUrl from PyQt5.QtGui import QIcon @@ -50,7 +50,7 @@ class TabDeletedError(Exception): """Exception raised when _tab_index is called for a deleted tab.""" -class TabbedBrowser(tabwidget.TabWidget): +class TabbedBrowser(QWidget): """A TabWidget with QWebViews inside. @@ -110,17 +110,18 @@ class TabbedBrowser(tabwidget.TabWidget): new_tab = pyqtSignal(browsertab.AbstractTab, int) def __init__(self, *, win_id, private, parent=None): - super().__init__(win_id, parent) + super().__init__(parent) + self.widget = tabwidget.TabWidget(win_id, parent) self._win_id = win_id self._tab_insert_idx_left = 0 self._tab_insert_idx_right = -1 self.shutting_down = False - self.tabCloseRequested.connect(self.on_tab_close_requested) - self.new_tab_requested.connect(self.tabopen) - self.currentChanged.connect(self.on_current_changed) + self.widget.tabCloseRequested.connect(self.on_tab_close_requested) + self.widget.new_tab_requested.connect(self.tabopen) + self.widget.currentChanged.connect(self.on_current_changed) self.cur_load_started.connect(self.on_cur_load_started) - self.cur_fullscreen_requested.connect(self.tabBar().maybe_hide) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.cur_fullscreen_requested.connect(self.widget.tabBar().maybe_hide) + self.widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self._undo_stack = [] self._filter = signalfilter.SignalFilter(win_id, self) self._now_focused = None @@ -128,12 +129,12 @@ class TabbedBrowser(tabwidget.TabWidget): self.search_options = {} self._local_marks = {} self._global_marks = {} - self.default_window_icon = self.window().windowIcon() + self.default_window_icon = self.widget.window().windowIcon() self.private = private config.instance.changed.connect(self._on_config_changed) def __repr__(self): - return utils.get_repr(self, count=self.count()) + return utils.get_repr(self, count=self.widget.count()) @pyqtSlot(str) def _on_config_changed(self, option): @@ -142,7 +143,7 @@ class TabbedBrowser(tabwidget.TabWidget): elif option == 'window.title_format': self._update_window_title() elif option in ['tabs.title.format', 'tabs.title.format_pinned']: - self._update_tab_titles() + self.widget.update_tab_titles() def _tab_index(self, tab): """Get the index of a given tab. @@ -150,7 +151,7 @@ class TabbedBrowser(tabwidget.TabWidget): Raises TabDeletedError if the tab doesn't exist anymore. """ try: - idx = self.indexOf(tab) + idx = self.widget.indexOf(tab) except RuntimeError as e: log.webview.debug("Got invalid tab ({})!".format(e)) raise TabDeletedError(e) @@ -166,8 +167,8 @@ class TabbedBrowser(tabwidget.TabWidget): iterating over the list. """ widgets = [] - for i in range(self.count()): - widget = self.widget(i) + for i in range(self.widget.count()): + widget = self.widget.widget(i) if widget is None: log.webview.debug("Got None-widget in tabbedbrowser!") else: @@ -186,12 +187,12 @@ class TabbedBrowser(tabwidget.TabWidget): if field is not None and ('{' + field + '}') not in title_format: return - idx = self.currentIndex() + idx = self.widget.currentIndex() if idx == -1: # (e.g. last tab removed) log.webview.debug("Not updating window title because index is -1") return - fields = self.get_tab_fields(idx) + fields = self.widget.get_tab_fields(idx) fields['id'] = self._win_id title = title_format.format(**fields) @@ -247,8 +248,8 @@ class TabbedBrowser(tabwidget.TabWidget): Return: The current URL as QUrl. """ - idx = self.currentIndex() - return super().tab_url(idx) + idx = self.widget.currentIndex() + return self.widget.tab_url(idx) def shutdown(self): """Try to shut down all tabs cleanly.""" @@ -284,7 +285,7 @@ class TabbedBrowser(tabwidget.TabWidget): new_undo: Whether the undo entry should be a new item in the stack. """ last_close = config.val.tabs.last_close - count = self.count() + count = self.widget.count() if last_close == 'ignore' and count == 1: return @@ -311,7 +312,7 @@ class TabbedBrowser(tabwidget.TabWidget): new_undo: Whether the undo entry should be a new item in the stack. crashed: Whether we're closing a tab with crashed renderer process. """ - idx = self.indexOf(tab) + idx = self.widget.indexOf(tab) if idx == -1: if crashed: return @@ -349,7 +350,7 @@ class TabbedBrowser(tabwidget.TabWidget): self._undo_stack[-1].append(entry) tab.shutdown() - self.removeTab(idx) + self.widget.removeTab(idx) if not crashed: # WORKAROUND for a segfault when we delete the crashed tab. # see https://bugreports.qt.io/browse/QTBUG-58698 @@ -362,14 +363,14 @@ class TabbedBrowser(tabwidget.TabWidget): last_close = config.val.tabs.last_close use_current_tab = False if last_close in ['blank', 'startpage', 'default-page']: - only_one_tab_open = self.count() == 1 - no_history = len(self.widget(0).history) == 1 + only_one_tab_open = self.widget.count() == 1 + no_history = len(self.widget.widget(0).history) == 1 urls = { 'blank': QUrl('about:blank'), 'startpage': config.val.url.start_pages[0], 'default-page': config.val.url.default_page, } - first_tab_url = self.widget(0).url() + first_tab_url = self.widget.widget(0).url() last_close_urlstr = urls[last_close].toString().rstrip('/') first_tab_urlstr = first_tab_url.toString().rstrip('/') last_close_url_used = first_tab_urlstr == last_close_urlstr @@ -379,14 +380,14 @@ class TabbedBrowser(tabwidget.TabWidget): for entry in reversed(self._undo_stack.pop()): if use_current_tab: self.openurl(entry.url, newtab=False) - newtab = self.widget(0) + newtab = self.widget.widget(0) use_current_tab = False else: newtab = self.tabopen(entry.url, background=False, idx=entry.index) newtab.history.deserialize(entry.history) - self.set_tab_pinned(newtab, entry.pinned) + self.widget.set_tab_pinned(newtab, entry.pinned) @pyqtSlot('QUrl', bool) def openurl(self, url, newtab): @@ -397,15 +398,15 @@ class TabbedBrowser(tabwidget.TabWidget): newtab: True to open URL in a new tab, False otherwise. """ qtutils.ensure_valid(url) - if newtab or self.currentWidget() is None: + if newtab or self.widget.currentWidget() is None: self.tabopen(url, background=False) else: - self.currentWidget().openurl(url) + self.widget.currentWidget().openurl(url) @pyqtSlot(int) def on_tab_close_requested(self, idx): """Close a tab via an index.""" - tab = self.widget(idx) + tab = self.widget.widget(idx) if tab is None: log.webview.debug("Got invalid tab {} for index {}!".format( tab, idx)) @@ -456,7 +457,7 @@ class TabbedBrowser(tabwidget.TabWidget): "related {}, idx {}".format( url, background, related, idx)) - if (config.val.tabs.tabs_are_windows and self.count() > 0 and + if (config.val.tabs.tabs_are_windows and self.widget.count() > 0 and not ignore_tabs_are_windows): window = mainwindow.MainWindow(private=self.private) window.show() @@ -466,12 +467,12 @@ class TabbedBrowser(tabwidget.TabWidget): related=related) tab = browsertab.create(win_id=self._win_id, private=self.private, - parent=self) + parent=self.widget) self._connect_tab_signals(tab) if idx is None: idx = self._get_new_tab_idx(related) - self.insertTab(idx, tab, "") + self.widget.insertTab(idx, tab, "") if url is not None: tab.openurl(url) @@ -482,10 +483,11 @@ class TabbedBrowser(tabwidget.TabWidget): # Make sure the background tab has the correct initial size. # With a foreground tab, it's going to be resized correctly by the # layout anyways. - tab.resize(self.currentWidget().size()) - self.tab_index_changed.emit(self.currentIndex(), self.count()) + tab.resize(self.widget.currentWidget().size()) + self.widget.tab_index_changed.emit(self.widget.currentIndex(), + self.widget.count()) else: - self.setCurrentWidget(tab) + self.widget.setCurrentWidget(tab) tab.show() self.new_tab.emit(tab, idx) @@ -530,11 +532,11 @@ class TabbedBrowser(tabwidget.TabWidget): """Update favicons when config was changed.""" for i, tab in enumerate(self.widgets()): if config.val.tabs.favicons.show: - self.setTabIcon(i, tab.icon()) + self.widget.setTabIcon(i, tab.icon()) if config.val.tabs.tabs_are_windows: self.window().setWindowIcon(tab.icon()) else: - self.setTabIcon(i, QIcon()) + self.widget.setTabIcon(i, QIcon()) if config.val.tabs.tabs_are_windows: self.window().setWindowIcon(self.default_window_icon) @@ -550,15 +552,15 @@ class TabbedBrowser(tabwidget.TabWidget): except TabDeletedError: # We can get signals for tabs we already deleted... return - self._update_tab_title(idx) + self.widget.update_tab_title(idx) if tab.data.keep_icon: tab.data.keep_icon = False else: - self.setTabIcon(idx, QIcon()) + self.widget.setTabIcon(idx, QIcon()) if (config.val.tabs.tabs_are_windows and config.val.tabs.favicons.show): self.window().setWindowIcon(self.default_window_icon) - if idx == self.currentIndex(): + if idx == self.widget.currentIndex(): self._update_window_title() @pyqtSlot() @@ -589,8 +591,8 @@ class TabbedBrowser(tabwidget.TabWidget): return log.webview.debug("Changing title for idx {} to '{}'".format( idx, text)) - self.set_page_title(idx, text) - if idx == self.currentIndex(): + self.widget.set_page_title(idx, text) + if idx == self.widget.currentIndex(): self._update_window_title() @pyqtSlot(browsertab.AbstractTab, QUrl) @@ -607,8 +609,8 @@ class TabbedBrowser(tabwidget.TabWidget): # We can get signals for tabs we already deleted... return - if not self.page_title(idx): - self.set_page_title(idx, url.toDisplayString()) + if not self.widget.page_title(idx): + self.widget.set_page_title(idx, url.toDisplayString()) @pyqtSlot(browsertab.AbstractTab, QIcon) def on_icon_changed(self, tab, icon): @@ -627,7 +629,7 @@ class TabbedBrowser(tabwidget.TabWidget): except TabDeletedError: # We can get signals for tabs we already deleted... return - self.setTabIcon(idx, icon) + self.widget.setTabIcon(idx, icon) if config.val.tabs.tabs_are_windows: self.window().setWindowIcon(icon) @@ -636,7 +638,7 @@ class TabbedBrowser(tabwidget.TabWidget): """Give focus to current tab if command mode was left.""" if mode in [usertypes.KeyMode.command, usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]: - widget = self.currentWidget() + widget = self.widget.currentWidget() log.modes.debug("Left status-input mode, focusing {!r}".format( widget)) if widget is None: @@ -652,7 +654,7 @@ class TabbedBrowser(tabwidget.TabWidget): if idx == -1 or self.shutting_down: # closing the last tab (before quitting) or shutting down return - tab = self.widget(idx) + tab = self.widget.widget(idx) if tab is None: log.webview.debug("on_current_changed got called with invalid " "index {}".format(idx)) @@ -680,8 +682,8 @@ class TabbedBrowser(tabwidget.TabWidget): self._now_focused = tab self.current_tab_changed.emit(tab) QTimer.singleShot(0, self._update_window_title) - self._tab_insert_idx_left = self.currentIndex() - self._tab_insert_idx_right = self.currentIndex() + 1 + self._tab_insert_idx_left = self.widget.currentIndex() + self._tab_insert_idx_right = self.widget.currentIndex() + 1 @pyqtSlot() def on_cmd_return_pressed(self): @@ -699,9 +701,9 @@ class TabbedBrowser(tabwidget.TabWidget): stop = config.val.colors.tabs.indicator.stop system = config.val.colors.tabs.indicator.system color = utils.interpolate_color(start, stop, perc, system) - self.set_tab_indicator_color(idx, color) - self._update_tab_title(idx) - if idx == self.currentIndex(): + self.widget.set_tab_indicator_color(idx, color) + self.widget.update_tab_title(idx) + if idx == self.widget.currentIndex(): self._update_window_title() def on_load_finished(self, tab, ok): @@ -718,23 +720,23 @@ class TabbedBrowser(tabwidget.TabWidget): color = utils.interpolate_color(start, stop, 100, system) else: color = config.val.colors.tabs.indicator.error - self.set_tab_indicator_color(idx, color) - self._update_tab_title(idx) - if idx == self.currentIndex(): + self.widget.set_tab_indicator_color(idx, color) + self.widget.update_tab_title(idx) + if idx == self.widget.currentIndex(): self._update_window_title() tab.handle_auto_insert_mode(ok) @pyqtSlot() def on_scroll_pos_changed(self): """Update tab and window title when scroll position changed.""" - idx = self.currentIndex() + idx = self.widget.currentIndex() if idx == -1: # (e.g. last tab removed) log.webview.debug("Not updating scroll position because index is " "-1") return self._update_window_title('scroll_pos') - self._update_tab_title(idx, 'scroll_pos') + self.widget.update_tab_title(idx, 'scroll_pos') def _on_renderer_process_terminated(self, tab, status, code): """Show an error when a renderer process terminated.""" @@ -767,7 +769,7 @@ class TabbedBrowser(tabwidget.TabWidget): # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-58698 message.error(msg) self._remove_tab(tab, crashed=True) - if self.count() == 0: + if self.widget.count() == 0: self.tabopen(QUrl('about:blank')) def resizeEvent(self, e): @@ -804,7 +806,7 @@ class TabbedBrowser(tabwidget.TabWidget): if key != "'": message.error("Failed to set mark: url invalid") return - point = self.currentWidget().scroller.pos_px() + point = self.widget.currentWidget().scroller.pos_px() if key.isupper(): self._global_marks[key] = point, url @@ -825,7 +827,7 @@ class TabbedBrowser(tabwidget.TabWidget): except qtutils.QtValueError: urlkey = None - tab = self.currentWidget() + tab = self.widget.currentWidget() if key.isupper(): if key in self._global_marks: diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 965e5b219..abc6cedae 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -60,7 +60,7 @@ class TabWidget(QTabWidget): self.setTabBar(bar) bar.tabCloseRequested.connect(self.tabCloseRequested) bar.tabMoved.connect(functools.partial( - QTimer.singleShot, 0, self._update_tab_titles)) + QTimer.singleShot, 0, self.update_tab_titles)) bar.currentChanged.connect(self._on_current_changed) bar.new_tab_requested.connect(self._on_new_tab_requested) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) @@ -108,7 +108,7 @@ class TabWidget(QTabWidget): bar.set_tab_data(idx, 'pinned', pinned) tab.data.pinned = pinned - self._update_tab_title(idx) + self.update_tab_title(idx) def tab_indicator_color(self, idx): """Get the tab indicator color for the given index.""" @@ -117,13 +117,13 @@ class TabWidget(QTabWidget): def set_page_title(self, idx, title): """Set the tab title user data.""" self.tabBar().set_tab_data(idx, 'page-title', title) - self._update_tab_title(idx) + self.update_tab_title(idx) def page_title(self, idx): """Get the tab title user data.""" return self.tabBar().page_title(idx) - def _update_tab_title(self, idx, field=None): + def update_tab_title(self, idx, field=None): """Update the tab text for the given tab. Args: @@ -197,20 +197,20 @@ class TabWidget(QTabWidget): fields['scroll_pos'] = scroll_pos return fields - def _update_tab_titles(self): + def update_tab_titles(self): """Update all texts.""" for idx in range(self.count()): - self._update_tab_title(idx) + self.update_tab_title(idx) def tabInserted(self, idx): """Update titles when a tab was inserted.""" super().tabInserted(idx) - self._update_tab_titles() + self.update_tab_titles() def tabRemoved(self, idx): """Update titles when a tab was removed.""" super().tabRemoved(idx) - self._update_tab_titles() + self.update_tab_titles() def addTab(self, page, icon_or_text, text_or_empty=None): """Override addTab to use our own text setting logic. diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index a8a652dbb..dddf48b05 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -246,7 +246,7 @@ class SessionManager(QObject): if tabbed_browser.private: win_data['private'] = True for i, tab in enumerate(tabbed_browser.widgets()): - active = i == tabbed_browser.currentIndex() + active = i == tabbed_browser.widget.currentIndex() win_data['tabs'].append(self._save_tab(tab, active)) data['windows'].append(win_data) return data @@ -427,11 +427,12 @@ class SessionManager(QObject): if tab.get('active', False): tab_to_focus = i if new_tab.data.pinned: - tabbed_browser.set_tab_pinned(new_tab, new_tab.data.pinned) + tabbed_browser.widget.set_tab_pinned(new_tab, + new_tab.data.pinned) if tab_to_focus is not None: - tabbed_browser.setCurrentIndex(tab_to_focus) + tabbed_browser.widget.setCurrentIndex(tab_to_focus) if win.get('active', False): - QTimer.singleShot(0, tabbed_browser.activateWindow) + QTimer.singleShot(0, tabbed_browser.widget.activateWindow) if data['windows']: self.did_load = True diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index d2743d56e..4b55eb04e 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -185,7 +185,7 @@ def debug_cache_stats(): tabbed_browser = objreg.get('tabbed-browser', scope='window', window='last-focused') # pylint: disable=protected-access - tab_bar = tabbed_browser.tabBar() + tab_bar = tabbed_browser.widget.tabBar() tabbed_browser_info = tab_bar._minimum_tab_size_hint_helper.cache_info() # pylint: enable=protected-access diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py index 8d44a9eb5..17fc34b92 100644 --- a/qutebrowser/utils/objreg.py +++ b/qutebrowser/utils/objreg.py @@ -171,7 +171,7 @@ def _get_tab_registry(win_id, tab_id): if tab_id == 'current': tabbed_browser = get('tabbed-browser', scope='window', window=win_id) - tab = tabbed_browser.currentWidget() + tab = tabbed_browser.widget.currentWidget() if tab is None: raise RegistryUnavailableError('window') tab_id = tab.tab_id diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 64bc793cb..56c0a808e 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -497,37 +497,50 @@ class SessionManagerStub: def list_sessions(self): return self.sessions - class TabbedBrowserStub(QObject): """Stub for the tabbed-browser object.""" + def __init__(self, parent=None): + super().__init__(parent) + self.widget = TabWidgetStub() + self.shutting_down = False + self.opened_url = None + + def on_tab_close_requested(self, idx): + del self.widget.tabs[idx] + + def widgets(self): + return self.widget.tabs + + def tabopen(self, url): + self.opened_url = url + + def openurl(self, url, *, newtab): + self.opened_url = url + +class TabWidgetStub(QObject): + + """Stub for the tab-widget object.""" + new_tab = pyqtSignal(browsertab.AbstractTab, int) def __init__(self, parent=None): super().__init__(parent) self.tabs = [] - self.shutting_down = False self._qtabbar = QTabBar() self.index_of = None self.current_index = None - self.opened_url = None def count(self): return len(self.tabs) - def widgets(self): - return self.tabs - def widget(self, i): return self.tabs[i] def page_title(self, i): return self.tabs[i].title() - def on_tab_close_requested(self, idx): - del self.tabs[idx] - def tabBar(self): return self._qtabbar @@ -551,13 +564,6 @@ class TabbedBrowserStub(QObject): return None return self.tabs[idx - 1] - def tabopen(self, url): - self.opened_url = url - - def openurl(self, url, *, newtab): - self.opened_url = url - - class ApplicationStub(QObject): """Stub to insert as the app object in objreg.""" diff --git a/tests/unit/browser/test_signalfilter.py b/tests/unit/browser/test_signalfilter.py index 18f52a32a..957b85943 100644 --- a/tests/unit/browser/test_signalfilter.py +++ b/tests/unit/browser/test_signalfilter.py @@ -68,8 +68,8 @@ def objects(): @pytest.mark.parametrize('index_of, emitted', [(0, True), (1, False)]) def test_filtering(objects, tabbed_browser_stubs, index_of, emitted): browser = tabbed_browser_stubs[0] - browser.current_index = 0 - browser.index_of = index_of + browser.widget.current_index = 0 + browser.widget.index_of = index_of objects.signaller.signal.emit('foo') if emitted: assert objects.signaller.filtered_signal_arg == 'foo' @@ -80,8 +80,8 @@ def test_filtering(objects, tabbed_browser_stubs, index_of, emitted): @pytest.mark.parametrize('index_of, verb', [(0, 'emitting'), (1, 'ignoring')]) def test_logging(caplog, objects, tabbed_browser_stubs, index_of, verb): browser = tabbed_browser_stubs[0] - browser.current_index = 0 - browser.index_of = index_of + browser.widget.current_index = 0 + browser.widget.index_of = index_of with caplog.at_level(logging.DEBUG, logger='signals'): objects.signaller.signal.emit('foo') @@ -94,8 +94,8 @@ def test_logging(caplog, objects, tabbed_browser_stubs, index_of, verb): @pytest.mark.parametrize('index_of', [0, 1]) def test_no_logging(caplog, objects, tabbed_browser_stubs, index_of): browser = tabbed_browser_stubs[0] - browser.current_index = 0 - browser.index_of = index_of + browser.widget.current_index = 0 + browser.widget.index_of = index_of with caplog.at_level(logging.DEBUG, logger='signals'): objects.signaller.link_hovered.emit('foo') @@ -106,7 +106,7 @@ def test_no_logging(caplog, objects, tabbed_browser_stubs, index_of): def test_runtime_error(objects, tabbed_browser_stubs): """Test that there's no crash if indexOf() raises RuntimeError.""" browser = tabbed_browser_stubs[0] - browser.current_index = 0 - browser.index_of = RuntimeError + browser.widget.current_index = 0 + browser.widget.index_of = RuntimeError objects.signaller.signal.emit('foo') assert objects.signaller.filtered_signal_arg is None diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index ff9a24112..b3865950c 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -528,12 +528,12 @@ def test_session_completion(qtmodeltester, session_manager_stub): def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, tabbed_browser_stubs): - tabbed_browser_stubs[0].tabs = [ + tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2), ] - tabbed_browser_stubs[1].tabs = [ + tabbed_browser_stubs[1].widget.tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] model = miscmodels.buffer() @@ -556,12 +556,12 @@ def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub, win_registry, tabbed_browser_stubs): """Verify closing a tab by deleting it from the completion widget.""" - tabbed_browser_stubs[0].tabs = [ + tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2) ] - tabbed_browser_stubs[1].tabs = [ + tabbed_browser_stubs[1].widget.tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] model = miscmodels.buffer() @@ -577,19 +577,19 @@ def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub, assert model.data(idx) == '0/2' model.delete_cur_item(idx) - actual = [tab.url() for tab in tabbed_browser_stubs[0].tabs] + actual = [tab.url() for tab in tabbed_browser_stubs[0].widget.tabs] assert actual == [QUrl('https://github.com'), QUrl('https://duckduckgo.com')] def test_other_buffer_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, tabbed_browser_stubs, info): - tabbed_browser_stubs[0].tabs = [ + tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2), ] - tabbed_browser_stubs[1].tabs = [ + tabbed_browser_stubs[1].widget.tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] info.win_id = 1 @@ -609,12 +609,12 @@ def test_other_buffer_completion(qtmodeltester, fake_web_tab, app_stub, def test_window_completion(qtmodeltester, fake_web_tab, tabbed_browser_stubs, info): - tabbed_browser_stubs[0].tabs = [ + tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2) ] - tabbed_browser_stubs[1].tabs = [ + tabbed_browser_stubs[1].widget.tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0) ] diff --git a/tests/unit/mainwindow/statusbar/test_backforward.py b/tests/unit/mainwindow/statusbar/test_backforward.py index 6e594c0d2..11e3da616 100644 --- a/tests/unit/mainwindow/statusbar/test_backforward.py +++ b/tests/unit/mainwindow/statusbar/test_backforward.py @@ -43,8 +43,8 @@ def test_backforward_widget(backforward_widget, tabbed_browser_stubs, """Ensure the Backforward widget shows the correct text.""" tab = fake_web_tab(can_go_back=can_go_back, can_go_forward=can_go_forward) tabbed_browser = tabbed_browser_stubs[0] - tabbed_browser.current_index = 1 - tabbed_browser.tabs = [tab] + tabbed_browser.widget.current_index = 1 + tabbed_browser.widget.tabs = [tab] backforward_widget.enabled = True backforward_widget.on_tab_cur_url_changed(tabbed_browser) assert backforward_widget.text() == expected_text @@ -59,7 +59,7 @@ def test_backforward_widget(backforward_widget, tabbed_browser_stubs, # Check that the widget gets reset if empty. if can_go_back and can_go_forward: tab = fake_web_tab(can_go_back=False, can_go_forward=False) - tabbed_browser.tabs = [tab] + tabbed_browser.widget.tabs = [tab] backforward_widget.enabled = True backforward_widget.on_tab_cur_url_changed(tabbed_browser) assert backforward_widget.text() == '' @@ -70,15 +70,15 @@ def test_none_tab(backforward_widget, tabbed_browser_stubs, fake_web_tab): """Make sure nothing crashes when passing None as tab.""" tab = fake_web_tab(can_go_back=True, can_go_forward=True) tabbed_browser = tabbed_browser_stubs[0] - tabbed_browser.current_index = 1 - tabbed_browser.tabs = [tab] + tabbed_browser.widget.current_index = 1 + tabbed_browser.widget.tabs = [tab] backforward_widget.enabled = True backforward_widget.on_tab_cur_url_changed(tabbed_browser) assert backforward_widget.text() == '[<>]' assert backforward_widget.isVisible() - tabbed_browser.current_index = -1 + tabbed_browser.widget.current_index = -1 backforward_widget.on_tab_cur_url_changed(tabbed_browser) assert backforward_widget.text() == '' diff --git a/tests/unit/mainwindow/test_tabwidget.py b/tests/unit/mainwindow/test_tabwidget.py index 7ad22fcc3..36e6a0c48 100644 --- a/tests/unit/mainwindow/test_tabwidget.py +++ b/tests/unit/mainwindow/test_tabwidget.py @@ -71,7 +71,7 @@ class TestTabWidget: with qtbot.waitExposed(widget): widget.show() - benchmark(widget._update_tab_titles) + benchmark(widget.update_tab_titles) @pytest.mark.parametrize("num_tabs", [4, 10]) def test_add_remove_tab_benchmark(self, benchmark, browser, @@ -79,7 +79,7 @@ class TestTabWidget: """Benchmark for addTab and removeTab.""" def _run_bench(): for i in range(num_tabs): - browser.addTab(fake_web_tab(), 'foobar' + str(i)) + browser.widget.addTab(fake_web_tab(), 'foobar' + str(i)) with qtbot.waitExposed(browser): browser.show() From 11579b351138a1f95afde7b976397d04087313e1 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 19 Feb 2018 17:04:12 +0100 Subject: [PATCH 133/524] Update hypothesis from 3.44.26 to 3.45.2 --- misc/requirements/requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 6c3d69497..03fadb7fb 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -11,7 +11,7 @@ fields==5.0.0 Flask==0.12.2 glob2==0.6 hunter==2.0.2 -hypothesis==3.44.26 +hypothesis==3.45.2 itsdangerous==0.24 # Jinja2==2.10 Mako==1.0.7 From 8a0be83e1e2e63277c600a1f9dc544cbadc9ba09 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 19 Feb 2018 17:04:13 +0100 Subject: [PATCH 134/524] Update pytest-mock from 1.6.3 to 1.7.0 --- misc/requirements/requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 03fadb7fb..796ed8085 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -28,7 +28,7 @@ pytest-benchmark==3.1.1 pytest-cov==2.5.1 pytest-faulthandler==1.4.1 pytest-instafail==0.3.0 -pytest-mock==1.6.3 +pytest-mock==1.7.0 pytest-qt==2.3.1 pytest-repeat==0.4.1 pytest-rerunfailures==4.0 From d6ea9b1e474313b3f78ce5f451610c0e3eec1079 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:47:34 +0100 Subject: [PATCH 135/524] urlmatch: Add test for invalid IPv6 URL --- tests/unit/utils/test_urlmatch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 67c7ea452..e60885516 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -72,6 +72,9 @@ from qutebrowser.utils import urlmatch ("http://foo:123456/", "Invalid port"), ("http://foo:80:80/monkey", "Invalid port"), ("chrome://foo:1234/bar", "Ports are unsupported with chrome scheme"), + + # Additional tests + ("http://[", "Invalid IPv6 URL"), ]) def test_invalid_patterns(pattern, error): with pytest.raises(urlmatch.ParseError, match=error): From eda15c53add1f7e49d436b1184e89f353bf5c529 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:50:08 +0100 Subject: [PATCH 136/524] urlmatch: Improve port error output --- qutebrowser/utils/urlmatch.py | 12 +++++------- tests/unit/utils/test_urlmatch.py | 20 ++++++++++++++------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 4bf02a3b8..faae3b091 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -87,10 +87,8 @@ class UrlPattern: parsed = urllib.parse.urlparse(pattern) except ValueError as e: raise ParseError(str(e)) - # "Changed in version 3.6: Out-of-range port numbers now raise - # ValueError, instead of returning None." - if parsed is None: - raise ParseError("Failed to parse {}".format(pattern)) + + assert parsed is not None self._init_scheme(parsed) self._init_host(parsed) @@ -166,12 +164,12 @@ class UrlPattern: # We can't access parsed.port as it tries to run int() self._port = None elif parsed.netloc.endswith(':'): - raise ParseError("Empty port") + raise ParseError("Invalid port: Port is empty") else: try: self._port = parsed.port - except ValueError: - raise ParseError("Invalid port") + except ValueError as e: + raise ParseError("Invalid port: {}".format(e)) if (self._scheme not in list(self.DEFAULT_PORTS) + [None] and self._port is not None): diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index e60885516..67368933d 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -29,6 +29,8 @@ Currently not tested: - Any other features we don't need, such as .GetAsString() or set operations. """ +import re +import sys import string import pytest @@ -66,18 +68,24 @@ from qutebrowser.utils import urlmatch ("http://foo.*/bar", "TLD wildcards are not implemented yet"), # Chromium: PARSE_ERROR_INVALID_PORT - ("http://foo:/", "Empty port"), - ("http://*.foo:/", "Empty port"), - ("http://foo:com/", "Invalid port"), - ("http://foo:123456/", "Invalid port"), - ("http://foo:80:80/monkey", "Invalid port"), + ("http://foo:/", "Invalid port: Port is empty"), + ("http://*.foo:/", "Invalid port: Port is empty"), + ("http://foo:com/", + "Invalid port: invalid literal for int() with base 10: 'com'"), + pytest.param("http://foo:123456/", + "Invalid port: Port out of range 0-65535", + marks=pytest.mark.skipif( + sys.hexversion < 0x03060000, + reason="Doesn't show an error on Python 3.5")), + ("http://foo:80:80/monkey", + "Invalid port: invalid literal for int() with base 10: '80:80'"), ("chrome://foo:1234/bar", "Ports are unsupported with chrome scheme"), # Additional tests ("http://[", "Invalid IPv6 URL"), ]) def test_invalid_patterns(pattern, error): - with pytest.raises(urlmatch.ParseError, match=error): + with pytest.raises(urlmatch.ParseError, match=re.escape(error)): urlmatch.UrlPattern(pattern) From 7033af816ac1d2b1b865a68756ace5956ed274a8 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:57:31 +0100 Subject: [PATCH 137/524] urlmatch: Add equality testcases --- tests/unit/utils/test_urlmatch.py | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 67368933d..ce300391b 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -451,3 +451,69 @@ def test_urlpattern_hypothesis(pattern): except urlmatch.ParseError: return up.matches(QUrl('https://www.example.com/')) + + +@pytest.mark.parametrize('text1, text2, equal', [ + # schemes + ("http://en.google.com/blah/*/foo", + "https://en.google.com/blah/*/foo", + False), + ("https://en.google.com/blah/*/foo", + "https://en.google.com/blah/*/foo", + True), + ("https://en.google.com/blah/*/foo", + "ftp://en.google.com/blah/*/foo", + False), + + # subdomains + ("https://en.google.com/blah/*/foo", + "https://fr.google.com/blah/*/foo", + False), + ("https://www.google.com/blah/*/foo", + "https://*.google.com/blah/*/foo", + False), + ("https://*.google.com/blah/*/foo", + "https://*.google.com/blah/*/foo", + True), + + # domains + ("http://en.example.com/blah/*/foo", + "http://en.google.com/blah/*/foo", + False), + + # ports + ("http://en.google.com:8000/blah/*/foo", + "http://en.google.com/blah/*/foo", + False), + ("http://fr.google.com:8000/blah/*/foo", + "http://fr.google.com:8000/blah/*/foo", + True), + ("http://en.google.com:8000/blah/*/foo", + "http://en.google.com:8080/blah/*/foo", + False), + + # paths + ("http://en.google.com/blah/*/foo", + "http://en.google.com/blah/*", + False), + ("http://en.google.com/*", + "http://en.google.com/", + False), + ("http://en.google.com/*", + "http://en.google.com/*", + True), + + # all_urls + ("", + "", + True), + ("", + "http://*/*", + False) +]) +def test_equal(text1, text2, equal): + pat1 = urlmatch.UrlPattern(text1) + pat2 = urlmatch.UrlPattern(text2) + + assert (pat1 == pat2) == equal + assert (hash(pat1) == hash(pat2)) == equal From 894da598d6eee6784416b562e3968687b94abf10 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 22:03:25 +0100 Subject: [PATCH 138/524] urlmatch: Remove dead code --- qutebrowser/utils/urlmatch.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index faae3b091..04811cb4a 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -186,6 +186,12 @@ class UrlPattern: # FIXME what about multiple dots? host = host.rstrip('.') + # If we have no host in the match pattern, that means that we're + # matching all hosts, which means we have a match no matter what the + # test host is. + # Contrary to Chromium, we don't need to check for + # self._match_subdomains, as we want to return True here for e.g. + # file:// as well. if self._host is None: return True @@ -193,12 +199,6 @@ class UrlPattern: if host == self._host: return True - # If we're matching subdomains, and we have no host in the match - # pattern, that means that we're matching all hosts, which means we - # have a match no matter what the test host is. - if self._match_subdomains and not self._host: - return True - # Otherwise, we can only match if our match pattern matches subdomains. if not self._match_subdomains: return False From 4ed07d606297424e0e3f78baa480fcf3cc04bf78 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 21:28:05 +0100 Subject: [PATCH 139/524] Initial implementation of per-URL setting storage --- qutebrowser/config/config.py | 93 +++++++++++++++++++++------- qutebrowser/config/configcommands.py | 43 +++++++++---- qutebrowser/config/configfiles.py | 63 ++++++++++++++----- qutebrowser/utils/urlmatch.py | 11 ++++ 4 files changed, 161 insertions(+), 49 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index f30acb8ca..ec6a36176 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -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 = [''] return '\n'.join(lines) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 8bc2a9ed8..3c4c9bdfc 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -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) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 3dccc129f..9db4384f9 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -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() diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 04811cb4a..446f53bb2 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -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 From 5e50824042b5a60c47ef3f70aaf2f7d5b1456967 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 15 Feb 2018 22:54:22 +0100 Subject: [PATCH 140/524] Broken per-URL proof-of-concept --- .../browser/webengine/webenginesettings.py | 4 ++ qutebrowser/browser/webengine/webenginetab.py | 3 + qutebrowser/browser/webkit/webkitsettings.py | 4 ++ qutebrowser/browser/webkit/webkittab.py | 3 + qutebrowser/config/config.py | 56 ++++++++++++++++++- qutebrowser/config/websettings.py | 7 ++- 6 files changed, 71 insertions(+), 6 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 607499401..cf734d78b 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -298,6 +298,10 @@ def init(args): config.instance.changed.connect(_update_settings) +def update_for_tab(tab, url): + websettings.update_mappings(MAPPINGS, option, url, tab.settings()) + + def shutdown(): # FIXME:qtwebengine do we need to do something for a clean shutdown here? pass diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index ed6697f03..197fa03bd 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -906,5 +906,8 @@ class WebEngineTab(browsertab.AbstractTab): page.loadFinished.connect(self._restore_zoom) page.loadFinished.connect(self._on_load_finished) + self.on_url_changed.connect( + functools.partial(webenginesettings.update_for_tab, self)) + def event_target(self): return self._widget.focusProxy() diff --git a/qutebrowser/browser/webkit/webkitsettings.py b/qutebrowser/browser/webkit/webkitsettings.py index ee01c40db..4b51177bb 100644 --- a/qutebrowser/browser/webkit/webkitsettings.py +++ b/qutebrowser/browser/webkit/webkitsettings.py @@ -118,6 +118,10 @@ def _update_settings(option): websettings.update_mappings(MAPPINGS, option) +def update_for_tab(tab, url, option): + websettings.update_mappings(MAPPINGS, option, url, tab.settings()) + + def init(_args): """Initialize the global QWebSettings.""" cache_path = standarddir.cache() diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 9395630db..414b0f16a 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -780,5 +780,8 @@ class WebKitTab(browsertab.AbstractTab): frame.contentsSizeChanged.connect(self._on_contents_size_changed) frame.initialLayoutCompleted.connect(self._on_history_trigger) + self.on_url_changed.connect( + functools.partial(webkitsettings.update_for_tab, self)) + def event_target(self): return self._widget diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index ec6a36176..af0fe28a7 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -38,6 +38,9 @@ key_instance = 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 @@ -279,7 +282,7 @@ class Config(QObject): """ self._yaml.init_save_manager(save_manager) - def _get_values(self, pattern=None, create=False): + 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. @@ -295,6 +298,24 @@ class Config(QObject): else: return {} + def _get_values_for_url(self, url): + """Get a temporary values container which merges all matching values. + + Note that this does *not* include global values. + + Currently, this iterates linearly over all patterns. This could probably + be optimized by storing patterns based on their scheme/host/port and + then searching all possible matches in a dict before doing a full match. + """ + # FIXME We could avoid the copy if there's no per-url match. + # values = self._values.copy() + values = {} + # FIXME:conf what order? + for options in self._per_url_values.values(): + if options.pattern.matches(url): + values.update(options.values) + return values + def _set_value(self, opt, value, pattern=None): """Set the given option to the given value.""" if not isinstance(objects.backend, objects.NoBackend): @@ -327,10 +348,10 @@ class Config(QObject): name, deleted=deleted, renamed=renamed) raise exception from None - def get(self, name, pattern=None): + def get(self, name): """Get the given setting converted for Python code.""" opt = self.get_opt(name) - obj = self.get_obj(name, mutable=False, pattern=pattern) + obj = self.get_obj(name, mutable=False) return opt.typ.to_py(obj) def get_obj(self, name, *, mutable=True, pattern=None): @@ -351,6 +372,7 @@ class Config(QObject): # internal value can never be changed by mutating the object returned. else: 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)): @@ -361,6 +383,34 @@ class Config(QObject): assert obj.__hash__ is not None, obj return obj + def get_for_url(self, name, url, *, maybe_unset=True): + """Get the given per-url setting converted for Python code. + + With maybe_unset=True, if the value isn't overridden for a given domain, + return UNSET. + + With maybe_unset=False, return the global/default value instead. + """ + opt = self.get_opt(name) + obj = self._get_obj_for_url(opt, url=url, maybe_unset=maybe_unset) + return opt.typ.to_py(obj) + + def _get_obj_for_url(self, opt, url, *, maybe_unset=True): + """Get the given setting as object (for YAML/config.py). + + With maybe_unset=True, if the value isn't overridden for a given domain, + return UNSET. + + With maybe_unset=False, return the global/default value instead. + """ + values = self._get_values_for_url(url) + if opt in values: + return values[opt] + elif maybe_unset: + return UNSET + else: + return self.get_obj(opt.name, mutable=False) + def get_str(self, name, *, pattern=None): """Get the given setting as string.""" opt = self.get_opt(name) diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index fa8abb76f..1d29c28a5 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -198,14 +198,15 @@ def init_mappings(mappings): mapping.set(value) -def update_mappings(mappings, option): +def update_mappings(mappings, option, url=None, settings=None): """Update global settings when QWeb(Engine)Settings changed.""" try: mapping = mappings[option] except KeyError: return - value = config.instance.get(option) - mapping.set(value) + value = config.instance.get(option, url=url) + # FIXME:conf handle settings != None with global/static setters + mapping.set(value, settings=settings) def init(args): From 8551288efbc2af0637a8b1da91b718a57fceeff0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 16 Feb 2018 11:18:43 +0100 Subject: [PATCH 141/524] Start working on different per-URL storage --- qutebrowser/config/config.py | 125 +++++++----------------------- qutebrowser/config/configfiles.py | 63 ++++++++------- 2 files changed, 57 insertions(+), 131 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index af0fe28a7..27f927708 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -26,7 +26,7 @@ import functools import attr from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject -from qutebrowser.config import configdata, configexc +from qutebrowser.config import configdata, configexc, configutils from qutebrowser.utils import utils, log, jinja from qutebrowser.misc import objects @@ -229,25 +229,12 @@ 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. + _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. @@ -261,18 +248,14 @@ 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 UrlPattern, Option, value tuples.""" - for name, value in sorted(self._values.items()): - 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) + """Iterate over Option, ScopedValue tuples.""" + for name, values in sorted(self._values.items()): + for scoped in values: + yield self.get_opt(name), scoped def init_save_manager(self, save_manager): """Make sure the config gets saved properly. @@ -282,40 +265,6 @@ class Config(QObject): """ self._yaml.init_save_manager(save_manager) - 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 _get_values_for_url(self, url): - """Get a temporary values container which merges all matching values. - - Note that this does *not* include global values. - - Currently, this iterates linearly over all patterns. This could probably - be optimized by storing patterns based on their scheme/host/port and - then searching all possible matches in a dict before doing a full match. - """ - # FIXME We could avoid the copy if there's no per-url match. - # values = self._values.copy() - values = {} - # FIXME:conf what order? - for options in self._per_url_values.values(): - if options.pattern.matches(url): - values.update(options.values) - return values - def _set_value(self, opt, value, pattern=None): """Set the given option to the given value.""" if not isinstance(objects.backend, objects.NoBackend): @@ -323,8 +272,8 @@ class Config(QObject): raise configexc.BackendError(opt.name, objects.backend) opt.typ.to_py(value) # for validation - values = self._get_values(pattern, create=True) - values[opt.name] = opt.typ.from_obj(value) + scoped = configutils.ScopedValue(opt.typ.from_obj(value), pattern) + self._values[opt.name].add(scoped) self.changed.emit(opt.name) log.config.debug("Config option changed: {} = {}".format( @@ -348,20 +297,18 @@ class Config(QObject): name, deleted=deleted, renamed=renamed) raise exception from None - def get(self, name): + def get(self, name, url=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, url=url) return opt.typ.to_py(obj) - def get_obj(self, name, *, mutable=True, pattern=None): + def get_obj(self, name, *, mutable=True, url=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). + If a URL is given, return the value which should be used for that URL. """ - opt = self.get_opt(name) obj = None # If we allow mutation, there is a chance that prior mutations already # entered the mutable dictionary and thus further copies are unneeded @@ -371,9 +318,11 @@ 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: - values = self._get_values(pattern) - - obj = copy.deepcopy(values.get(name, opt.default)) + if name in self._values: + value = self._values[name].get_any(url) + else: + value = self.get_opt(name).default + obj = copy.deepcopy(value) # Then we watch the returned object for changes. if isinstance(obj, (dict, list)): if mutable: @@ -383,39 +332,17 @@ class Config(QObject): assert obj.__hash__ is not None, obj return obj - def get_for_url(self, name, url, *, maybe_unset=True): - """Get the given per-url setting converted for Python code. - - With maybe_unset=True, if the value isn't overridden for a given domain, - return UNSET. - - With maybe_unset=False, return the global/default value instead. - """ - opt = self.get_opt(name) - obj = self._get_obj_for_url(opt, url=url, maybe_unset=maybe_unset) - return opt.typ.to_py(obj) - - def _get_obj_for_url(self, opt, url, *, maybe_unset=True): - """Get the given setting as object (for YAML/config.py). - - With maybe_unset=True, if the value isn't overridden for a given domain, - return UNSET. - - With maybe_unset=False, return the global/default value instead. - """ - values = self._get_values_for_url(url) - if opt in values: - return values[opt] - elif maybe_unset: - return UNSET - else: - return self.get_obj(opt.name, mutable=False) - def get_str(self, name, *, pattern=None): - """Get the given setting as string.""" + """Get the given setting as string. + + If a pattern is given, get the setting for the given pattern or + configutils.UNSET. + """ opt = self.get_opt(name) - values = self._get_values(pattern) - value = values.get(name, opt.default) + values = self._values[name] + value = values.get_for_pattern(pattern) + if value is configutils.UNSET: + return value return opt.typ.to_str(value) def set_obj(self, name, value, *, pattern=None, save_yaml=False): diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 9db4384f9..84ab36c8e 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -32,7 +32,7 @@ import yaml from PyQt5.QtCore import pyqtSignal, QObject, QSettings import qutebrowser -from qutebrowser.config import configexc, config, configdata +from qutebrowser.config import configexc, config, configdata, configutils from qutebrowser.utils import standarddir, utils, qtutils, log @@ -88,7 +88,6 @@ 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): @@ -100,23 +99,8 @@ class YamlConfig(QObject): save_manager.add_saveable('yaml-config', self._save, self.changed) def __iter__(self): - 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 + for name, values in sorted(self._values.items()): + yield from values def _mark_changed(self): """Mark the YAML config as changed.""" @@ -129,7 +113,7 @@ class YamlConfig(QObject): return data = {'config_version': self.VERSION, 'global': self._values} - for pattern, values in sorted(self._per_url_values.items()): + for pattern, values in sorted(self._values.items()): data[str(pattern)] = values with qtutils.savefile_open(self._filename) as f: @@ -155,18 +139,17 @@ class YamlConfig(QObject): raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) try: - global_obj = yaml_data.pop('global') + settings_obj = yaml_data.pop('settings') except KeyError: desc = configexc.ConfigErrorDesc( "While loading data", - "Toplevel object does not contain 'global' key") + "Toplevel object does not contain 'settings' key") raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) except TypeError: desc = configexc.ConfigErrorDesc("While loading data", "Toplevel object is not a dict") raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - # FIXME:conf test this try: yaml_data.pop('config_version') except KeyError: @@ -175,22 +158,38 @@ class YamlConfig(QObject): "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", - "'global' object is not a dict") - raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - - self._values = global_obj - self._per_url_values = yaml_data + self._load_settings_object(settings_obj) self._dirty = False - self._handle_migrations() self._validate() + def _load_settings_obj(self, settings_obj): + """Load the settings from the settings: key.""" + if not isinstance(settings_obj, dict): + desc = configexc.ConfigErrorDesc( + "While loading data", + "'settings' object is not a dict") + raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) + + # FIXME:conf test this + self._values = {} + for name, yaml_values in settings_obj.items(): + values = configutils.Values(self._config.get_opt(name)) + if 'global' in yaml_values: + scoped = configutils.ScopedValue(yaml_values.pop('global')) + values.add(scoped) + + for pattern, value in yaml_values.items(): + scoped = configutils.ScopedValue(value, pattern) + values.add(scoped) + + self._values[name] = values + def _handle_migrations(self): """Migrate older configs to the newest format.""" # FIXME:conf handle per-URL settings + # FIXME:conf migrate from older format with global: key + # Simple renamed/deleted options for name in list(self._values): if name in configdata.MIGRATIONS.renamed: From d09afdf0ee60ff10b235e5e69a5adcf3e89f7787 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 18 Feb 2018 19:11:12 +0100 Subject: [PATCH 142/524] Refactor handling of mutables with url/pattern in Config This also should not copy stuff coming from the config if it's not needed. --- qutebrowser/config/config.py | 67 +++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 27f927708..30f2003ac 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -233,6 +233,10 @@ 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. @@ -242,6 +246,7 @@ class Config(QObject): changed: Emitted with the option name when an option changed. """ + MUTABLE_TYPES = (dict, list) changed = pyqtSignal(str) def __init__(self, yaml_config, parent=None): @@ -303,34 +308,56 @@ class Config(QObject): obj = self.get_obj(name, mutable=False, url=url) return opt.typ.to_py(obj) - def get_obj(self, name, *, mutable=True, url=None): + def _maybe_copy(self, value): + """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, *, url=None): """Get the given setting as object (for YAML/config.py). - If mutable=True is set, watch the returned object for mutations. + 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. """ - obj = None + value = self._values[name].get_for_url(url) + return self._maybe_copy(value) + + def get_obj_for_pattern(self, name, *, pattern): + """Get the given setting as object (for YAML/config.py). + + This gets the overridden value for a given pattern, or configutils.UNSET + if no such override exists. + """ + value = self._values[name].get_for_pattern(pattern, fallback=False) + return self._maybe_copy(value) + + def get_mutable_obj(self, name, *, pattern=None): + """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 an URL as we + wouldn't know what pattern to apply. + """ # 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 and mutable: + if name in self._mutables: _copy, obj = self._mutables[name] - # Otherwise, we return a copy of the value stored internally, so the - # internal value can never be changed by mutating the object returned. - else: - if name in self._values: - value = self._values[name].get_any(url) - else: - value = self.get_opt(name).default - obj = copy.deepcopy(value) - # Then we watch the returned object for changes. - if isinstance(obj, (dict, list)): - if mutable: - self._mutables[name] = (copy.deepcopy(obj), obj) - else: - # Shouldn't be mutable (and thus hashable) - assert obj.__hash__ is not None, obj - return obj + return obj + + value = self._values[name].get_for_pattern(pattern) + + # Watch the returned object for changes if it's mutable. + if isinstance(value, self.MUTABLE_TYPES): + self._mutables[name] = (copy.deepcopy(value), value) + + return self._maybe_copy(value) def get_str(self, name, *, pattern=None): """Get the given setting as string. From 74a76761112bb41a143558652e3eb3c9a53262ec Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 18 Feb 2018 20:06:43 +0100 Subject: [PATCH 143/524] Fix issues with per-domain proof-of-concept --- .../browser/webengine/webenginesettings.py | 2 +- qutebrowser/browser/webengine/webenginetab.py | 2 +- qutebrowser/browser/webkit/webkitsettings.py | 4 +-- qutebrowser/browser/webkit/webkittab.py | 5 ++-- qutebrowser/config/config.py | 23 +++++++++-------- qutebrowser/config/configcommands.py | 6 ++--- qutebrowser/config/configfiles.py | 25 ++++++++----------- qutebrowser/config/websettings.py | 20 +++++++++++---- 8 files changed, 47 insertions(+), 40 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index cf734d78b..18516c719 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -299,7 +299,7 @@ def init(args): def update_for_tab(tab, url): - websettings.update_mappings(MAPPINGS, option, url, tab.settings()) + websettings.update_for_tab(MAPPINGS, tab, url) def shutdown(): diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 197fa03bd..4e91ebd72 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -906,7 +906,7 @@ class WebEngineTab(browsertab.AbstractTab): page.loadFinished.connect(self._restore_zoom) page.loadFinished.connect(self._on_load_finished) - self.on_url_changed.connect( + self.url_changed.connect( functools.partial(webenginesettings.update_for_tab, self)) def event_target(self): diff --git a/qutebrowser/browser/webkit/webkitsettings.py b/qutebrowser/browser/webkit/webkitsettings.py index 4b51177bb..976432418 100644 --- a/qutebrowser/browser/webkit/webkitsettings.py +++ b/qutebrowser/browser/webkit/webkitsettings.py @@ -118,8 +118,8 @@ def _update_settings(option): websettings.update_mappings(MAPPINGS, option) -def update_for_tab(tab, url, option): - websettings.update_mappings(MAPPINGS, option, url, tab.settings()) +def update_for_tab(tab, url): + websettings.update_for_tab(MAPPINGS, tab, url) def init(_args): diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 414b0f16a..41e2432b3 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -36,7 +36,8 @@ from PyQt5.QtWebKit import QWebSettings from PyQt5.QtPrintSupport import QPrinter from qutebrowser.browser import browsertab -from qutebrowser.browser.webkit import webview, tabhistory, webkitelem +from qutebrowser.browser.webkit import (webview, tabhistory, webkitelem, + webkitsettings) from qutebrowser.utils import qtutils, objreg, usertypes, utils, log, debug @@ -780,7 +781,7 @@ class WebKitTab(browsertab.AbstractTab): frame.contentsSizeChanged.connect(self._on_contents_size_changed) frame.initialLayoutCompleted.connect(self._on_history_trigger) - self.on_url_changed.connect( + self.url_changed.connect( functools.partial(webkitsettings.update_for_tab, self)) def event_target(self): diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 30f2003ac..341ecba93 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -190,7 +190,7 @@ class KeyConfig: log.keyboard.vdebug("Adding binding {} -> {} in mode {}.".format( key, command, mode)) - bindings = self._config.get_obj('bindings.commands') + bindings = self._config.get_mutable_obj('bindings.commands') if mode not in bindings: bindings[mode] = {} bindings[mode][key] = command @@ -200,7 +200,7 @@ class KeyConfig: """Restore a default keybinding.""" key = self._prepare(key, mode) - bindings_commands = self._config.get_obj('bindings.commands') + bindings_commands = self._config.get_mutable_obj('bindings.commands') try: del bindings_commands[mode][key] except KeyError: @@ -212,7 +212,7 @@ class KeyConfig: """Unbind the given key in the given mode.""" key = self._prepare(key, mode) - bindings_commands = self._config.get_obj('bindings.commands') + 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 @@ -252,15 +252,17 @@ class Config(QObject): def __init__(self, yaml_config, parent=None): super().__init__(parent) self.changed.connect(_render_stylesheet.cache_clear) - self._values = {} self._mutables = {} self._yaml = yaml_config + self._values = {} + for name, opt in configdata.DATA.items(): + self._values[name] = configutils.Values(opt) + def __iter__(self): - """Iterate over Option, ScopedValue tuples.""" + """Iterate over Option, configutils.Values tuples.""" for name, values in sorted(self._values.items()): - for scoped in values: - yield self.get_opt(name), scoped + yield self.get_opt(name), values def init_save_manager(self, save_manager): """Make sure the config gets saved properly. @@ -277,8 +279,7 @@ class Config(QObject): raise configexc.BackendError(opt.name, objects.backend) opt.typ.to_py(value) # for validation - scoped = configutils.ScopedValue(opt.typ.from_obj(value), pattern) - self._values[opt.name].add(scoped) + self._values[opt.name].add(opt.typ.from_obj(value), pattern) self.changed.emit(opt.name) log.config.debug("Config option changed: {} = {}".format( @@ -305,7 +306,7 @@ class Config(QObject): def get(self, name, url=None): """Get the given setting converted for Python code.""" opt = self.get_opt(name) - obj = self.get_obj(name, mutable=False, url=url) + obj = self.get_obj(name, url=url) return opt.typ.to_py(obj) def _maybe_copy(self, value): @@ -511,7 +512,7 @@ class ConfigContainer: return self._config.get(name) else: # access from config.py - return self._config.get_obj(name) + return self._config.get_mutable_obj(name) def __setattr__(self, attr, value): """Set the given option in the config.""" diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 3c4c9bdfc..a527d47f1 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -191,8 +191,8 @@ class ConfigCommands: with self._handle_config_error(): opt = self._config.get_opt(option) - old_value = self._config.get_obj(option, mutable=False, - pattern=pattern) + old_value = self._config.get_obj_for_pattern(option, + pattern=pattern) if not values and isinstance(opt.typ, configtypes.Bool): values = ['true', 'false'] @@ -318,7 +318,7 @@ class ConfigCommands: commented = True else: options = list(self._config) - bindings = dict(self._config.get_obj('bindings.commands')) + bindings = dict(self._config.get_mutable_obj('bindings.commands')) commented = False writer = configfiles.ConfigPyWriter(options, bindings, diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 84ab36c8e..8a7476ca9 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -87,9 +87,12 @@ class YamlConfig(QObject): super().__init__(parent) self._filename = os.path.join(standarddir.config(auto=True), 'autoconfig.yml') - self._values = {} 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. @@ -163,7 +166,7 @@ class YamlConfig(QObject): self._handle_migrations() self._validate() - def _load_settings_obj(self, settings_obj): + def _load_settings_object(self, settings_obj): """Load the settings from the settings: key.""" if not isinstance(settings_obj, dict): desc = configexc.ConfigErrorDesc( @@ -172,16 +175,13 @@ class YamlConfig(QObject): raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) # FIXME:conf test this - self._values = {} for name, yaml_values in settings_obj.items(): values = configutils.Values(self._config.get_opt(name)) if 'global' in yaml_values: - scoped = configutils.ScopedValue(yaml_values.pop('global')) - values.add(scoped) + values.add(yaml_values.pop('global')) for pattern, value in yaml_values.items(): - scoped = configutils.ScopedValue(value, pattern) - values.add(scoped) + values.add(value, pattern) self._values[name] = values @@ -230,16 +230,11 @@ class YamlConfig(QObject): def set_obj(self, name, value, *, pattern=None): """Set the given setting to the given value.""" - values = self._get_values(pattern) - values[name] = value + self._values[name].add(value, pattern) def unset(self, name, *, pattern=None): """Remove the given option name if it's configured.""" - values = self._get_values(pattern) - try: - del values[name] - except KeyError: - return + self._values[name].remove(pattern) self._mark_changed() def clear(self): @@ -294,7 +289,7 @@ class ConfigAPI: def get(self, name): with self._handle_error('getting', name): - return self._config.get_obj(name) + return self._config.get_mutable_obj(name) def set(self, name, value): with self._handle_error('setting', name): diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 1d29c28a5..32520b324 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -24,7 +24,7 @@ from PyQt5.QtGui import QFont -from qutebrowser.config import config +from qutebrowser.config import config, configutils from qutebrowser.utils import log, utils, debug, usertypes from qutebrowser.misc import objects @@ -198,15 +198,25 @@ def init_mappings(mappings): mapping.set(value) -def update_mappings(mappings, option, url=None, settings=None): +def update_mappings(mappings, option): """Update global settings when QWeb(Engine)Settings changed.""" try: mapping = mappings[option] except KeyError: return - value = config.instance.get(option, url=url) - # FIXME:conf handle settings != None with global/static setters - mapping.set(value, settings=settings) + value = config.instance.get(option) + mapping.set(value) + + +def update_for_tab(mappings, tab, url): + """Update settings customized for the given tab.""" + for opt, values in config.instance: + value = values.get_for_url(url, fallback=False) + if value is not configutils.UNSET and opt.name in mappings: + # FIXME:conf handle settings != None with global/static setters + mapping = mappings[opt.name] + # FIXME:conf have a proper API for this. + mapping.set(value, settings=tab._widget.settings()) def init(args): From 2a7998847fc70308399a26bf99f271eb4000be76 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 18 Feb 2018 20:30:38 +0100 Subject: [PATCH 144/524] Unset values properly --- qutebrowser/config/websettings.py | 33 ++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 32520b324..2831af01e 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -93,6 +93,13 @@ class Base: """ raise NotImplementedError + def unset(self, settings=None): + """Unset a customized setting. + + Must be overridden by subclasses. + """ + raise NotImplementedError + class Attribute(Base): @@ -118,6 +125,11 @@ class Attribute(Base): for attribute in self._attributes: obj.setAttribute(attribute, value) + def unset(self, settings=None): + for obj in self._get_settings(settings): + for attribute in self._attributes: + obj.resetAttribute(attribute) + class Setter(Base): @@ -211,12 +223,23 @@ def update_mappings(mappings, option): def update_for_tab(mappings, tab, url): """Update settings customized for the given tab.""" for opt, values in config.instance: + if opt.name not in mappings: + continue + + # FIXME:conf handle settings != None with global/static setters + mapping = mappings[opt.name] + value = values.get_for_url(url, fallback=False) - if value is not configutils.UNSET and opt.name in mappings: - # FIXME:conf handle settings != None with global/static setters - mapping = mappings[opt.name] - # FIXME:conf have a proper API for this. - mapping.set(value, settings=tab._widget.settings()) + # FIXME:conf have a proper API for this. + settings = tab._widget.settings() + + if value is configutils.UNSET: + try: + mapping.unset(settings=settings) + except NotImplementedError: + pass + else: + mapping.set(value, settings=settings) def init(args): From 7c1fb1d2155bcd23a8a58f066f28a5b47978c4f9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 04:24:40 +0100 Subject: [PATCH 145/524] Refactor acceptNavigationRequest handling to use signals --- qutebrowser/browser/browsertab.py | 22 ++++++- qutebrowser/browser/webengine/webenginetab.py | 1 + qutebrowser/browser/webengine/webview.py | 37 +++++++----- qutebrowser/browser/webkit/webkittab.py | 22 ++++++- qutebrowser/browser/webkit/webpage.py | 58 ++++++++----------- qutebrowser/browser/webkit/webview.py | 4 +- qutebrowser/utils/usertypes.py | 20 +++++++ tests/end2end/features/hints.feature | 6 +- tests/end2end/features/search.feature | 2 +- 9 files changed, 116 insertions(+), 56 deletions(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 6ed5afe52..5598a0904 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -30,7 +30,8 @@ from PyQt5.QtWidgets import QWidget, QApplication from qutebrowser.keyinput import modeman from qutebrowser.config import config -from qutebrowser.utils import utils, objreg, usertypes, log, qtutils +from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils, urlutils, + message) from qutebrowser.misc import miscwidgets, objects from qutebrowser.browser import mouse, hints @@ -94,6 +95,8 @@ class TabData: keep_icon: Whether the (e.g. cloned) icon should not be cleared on page load. inspector: The QWebInspector used for this webview. + open_target: Where to open the next link. + Only used for QtWebKit. override_target: Override for open_target for fake clicks (like hints). Only used for QtWebKit. pinned: Flag to pin the tab. @@ -104,6 +107,7 @@ class TabData: keep_icon = attr.ib(False) inspector = attr.ib(None) + open_target = attr.ib(usertypes.ClickTarget.normal) override_target = attr.ib(None) pinned = attr.ib(False) fullscreen = attr.ib(False) @@ -719,6 +723,22 @@ class AbstractTab(QWidget): self._set_load_status(usertypes.LoadStatus.loading) self.load_started.emit() + @pyqtSlot(usertypes.NavigationRequest) + def _on_navigation_request(self, navigation): + """Handle common acceptNavigationRequest code.""" + log.webview.debug("navigation request: url {}, type {}, is_main_frame " + "{}".format(navigation.url.toDisplayString(), + navigation.navigation_type, + navigation.is_main_frame)) + + if (navigation.navigation_type == navigation.Type.link_clicked and + not navigation.url.isValid()): + msg = urlutils.get_errstring(navigation.url, + "Invalid link clicked") + message.error(msg) + self.data.open_target = usertypes.ClickTarget.normal + navigation.accepted = False + def handle_auto_insert_mode(self, ok): """Handle `input.insert_mode.auto_load` after loading finished.""" if not config.val.input.insert_mode.auto_load or not ok: diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 4e91ebd72..be559de54 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -886,6 +886,7 @@ class WebEngineTab(browsertab.AbstractTab): self._on_proxy_authentication_required) page.fullScreenRequested.connect(self._on_fullscreen_requested) page.contentsSizeChanged.connect(self.contents_size_changed) + page.navigation_request.connect(self._on_navigation_request) view.titleChanged.connect(self.title_changed) view.urlChanged.connect(self._on_url_changed) diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index 1b3c15f9e..ef666c934 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -124,10 +124,12 @@ class WebEnginePage(QWebEnginePage): Signals: certificate_error: Emitted on certificate errors. shutting_down: Emitted when the page is shutting down. + navigation_request: Emitted on acceptNavigationRequest. """ certificate_error = pyqtSignal() shutting_down = pyqtSignal() + navigation_request = pyqtSignal(usertypes.NavigationRequest) def __init__(self, *, theme_color, profile, parent=None): super().__init__(profile, parent) @@ -288,21 +290,26 @@ class WebEnginePage(QWebEnginePage): url: QUrl, typ: QWebEnginePage.NavigationType, is_main_frame: bool): - """Override acceptNavigationRequest to handle clicked links. - - This only show an error on invalid links - everything else is handled - in createWindow. - """ - log.webview.debug("navigation request: url {}, type {}, is_main_frame " - "{}".format(url.toDisplayString(), - debug.qenum_key(QWebEnginePage, typ), - is_main_frame)) - if (typ == QWebEnginePage.NavigationTypeLinkClicked and - not url.isValid()): - msg = urlutils.get_errstring(url, "Invalid link clicked") - message.error(msg) - return False - return True + """Override acceptNavigationRequest to forward it to the tab API.""" + type_map = { + QWebEnginePage.NavigationTypeLinkClicked: + usertypes.NavigationRequest.Type.link_clicked, + QWebEnginePage.NavigationTypeTyped: + usertypes.NavigationRequest.Type.typed, + QWebEnginePage.NavigationTypeFormSubmitted: + usertypes.NavigationRequest.Type.form_submitted, + QWebEnginePage.NavigationTypeBackForward: + usertypes.NavigationRequest.Type.back_forward, + QWebEnginePage.NavigationTypeReload: + usertypes.NavigationRequest.Type.reloaded, + QWebEnginePage.NavigationTypeOther: + usertypes.NavigationRequest.Type.other, + } + navigation = usertypes.NavigationRequest(url=url, + navigation_type=type_map[typ], + is_main_frame=is_main_frame) + self.navigation_request.emit(navigation) + return navigation.accepted @pyqtSlot('QUrl') def _inject_userjs(self, url): diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 41e2432b3..165d2fdc4 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -35,7 +35,7 @@ from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame from PyQt5.QtWebKit import QWebSettings from PyQt5.QtPrintSupport import QPrinter -from qutebrowser.browser import browsertab +from qutebrowser.browser import browsertab, shared from qutebrowser.browser.webkit import (webview, tabhistory, webkitelem, webkitsettings) from qutebrowser.utils import qtutils, objreg, usertypes, utils, log, debug @@ -762,6 +762,25 @@ class WebKitTab(browsertab.AbstractTab): def _on_contents_size_changed(self, size): self.contents_size_changed.emit(QSizeF(size)) + @pyqtSlot(usertypes.NavigationRequest) + def _on_navigation_request(self, navigation): + super()._on_navigation_request(navigation) + log.webview.debug("target {} override {}".format( + self.data.open_target, self.data.override_target)) + + if self.data.override_target is not None: + target = self.data.override_target + self.data.override_target = None + else: + target = self.data.open_target + + if (navigation.navigation_type == navigation.Type.link_clicked and + target != usertypes.ClickTarget.normal): + tab = shared.get_tab(self.win_id, target) + tab.openurl(navigation.url) + self.data.open_target = usertypes.ClickTarget.normal + navigation.accepted = False + def _connect_signals(self): view = self._widget page = view.page() @@ -780,6 +799,7 @@ class WebKitTab(browsertab.AbstractTab): page.frameCreated.connect(self._on_frame_created) frame.contentsSizeChanged.connect(self._on_contents_size_changed) frame.initialLayoutCompleted.connect(self._on_history_trigger) + page.navigation_request.connect(self._on_navigation_request) self.url_changed.connect( functools.partial(webkitsettings.update_for_tab, self)) diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index 679ec2d88..89b205fa8 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -54,10 +54,12 @@ class BrowserPage(QWebPage): shutting_down: Emitted when the page is currently shutting down. reloading: Emitted before a web page reloads. arg: The URL which gets reloaded. + navigation_request: Emitted on acceptNavigationRequest. """ shutting_down = pyqtSignal() reloading = pyqtSignal(QUrl) + navigation_request = pyqtSignal(usertypes.NavigationRequest) def __init__(self, win_id, tab_id, tabdata, private, parent=None): super().__init__(parent) @@ -70,7 +72,6 @@ class BrowserPage(QWebPage): } self._ignore_load_started = False self.error_occurred = False - self.open_target = usertypes.ClickTarget.normal self._networkmanager = networkmanager.NetworkManager( win_id=win_id, tab_id=tab_id, private=private, parent=self) self.setNetworkAccessManager(self._networkmanager) @@ -474,7 +475,7 @@ class BrowserPage(QWebPage): source, line, msg) def acceptNavigationRequest(self, - _frame: QWebFrame, + frame: QWebFrame, request: QNetworkRequest, typ: QWebPage.NavigationType): """Override acceptNavigationRequest to handle clicked links. @@ -486,36 +487,27 @@ class BrowserPage(QWebPage): Checks if it should open it in a tab (middle-click or control) or not, and then conditionally opens the URL here or in another tab/window. """ - url = request.url() - log.webview.debug("navigation request: url {}, type {}, " - "target {} override {}".format( - url.toDisplayString(), - debug.qenum_key(QWebPage, typ), - self.open_target, - self._tabdata.override_target)) + type_map = { + QWebPage.NavigationTypeLinkClicked: + usertypes.NavigationRequest.Type.link_clicked, + QWebPage.NavigationTypeFormSubmitted: + usertypes.NavigationRequest.Type.form_submitted, + QWebPage.NavigationTypeFormResubmitted: + usertypes.NavigationRequest.Type.form_resubmitted, + QWebPage.NavigationTypeBackOrForward: + usertypes.NavigationRequest.Type.back_forward, + QWebPage.NavigationTypeReload: + usertypes.NavigationRequest.Type.reloaded, + QWebPage.NavigationTypeOther: + usertypes.NavigationRequest.Type.other, + } + is_main_frame = frame is self.mainFrame() + navigation = usertypes.NavigationRequest(url=request.url(), + navigation_type=type_map[typ], + is_main_frame=is_main_frame) - if self._tabdata.override_target is not None: - target = self._tabdata.override_target - self._tabdata.override_target = None - else: - target = self.open_target + if navigation.navigation_type == navigation.Type.reloaded: + self.reloading.emit(navigation.url) - if typ == QWebPage.NavigationTypeReload: - self.reloading.emit(url) - return True - elif typ != QWebPage.NavigationTypeLinkClicked: - return True - - if not url.isValid(): - msg = urlutils.get_errstring(url, "Invalid link clicked") - message.error(msg) - self.open_target = usertypes.ClickTarget.normal - return False - - if target == usertypes.ClickTarget.normal: - return True - - tab = shared.get_tab(self._win_id, target) - tab.openurl(url) - self.open_target = usertypes.ClickTarget.normal - return False + self.navigation_request.emit(navigation) + return navigation.accepted diff --git a/qutebrowser/browser/webkit/webview.py b/qutebrowser/browser/webkit/webview.py index 942e7265c..79da9778c 100644 --- a/qutebrowser/browser/webkit/webview.py +++ b/qutebrowser/browser/webkit/webview.py @@ -262,10 +262,10 @@ class WebView(QWebView): target = usertypes.ClickTarget.tab_bg else: target = usertypes.ClickTarget.tab - self.page().open_target = target + self._tabdata.open_target = target log.mouse.debug("Ctrl/Middle click, setting target: {}".format( target)) else: - self.page().open_target = usertypes.ClickTarget.normal + self._tabdata.open_target = usertypes.ClickTarget.normal log.mouse.debug("Normal click, setting normal target") super().mousePressEvent(e) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 8312cd803..06b70fc21 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -27,6 +27,7 @@ import operator import collections.abc import enum +import attr from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QTimer from qutebrowser.utils import log, qtutils, utils @@ -394,3 +395,22 @@ class AbstractCertificateErrorWrapper: def is_overridable(self): raise NotImplementedError + + +@attr.s +class NavigationRequest: + + Type = enum.Enum('Type', [ + 'link_clicked', + 'typed', # QtWebEngine only + 'form_submitted', + 'form_resubmitted', # QtWebKit only + 'back_forward', + 'reloaded', + 'other' + ]) + + url = attr.ib() + navigation_type = attr.ib() + is_main_frame = attr.ib() + accepted = attr.ib(default=True) diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index dc14750e1..eb6a24df9 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -207,7 +207,7 @@ Feature: Using hints Scenario: Using :follow-hint inside an iframe When I open data/hints/iframe.html And I hint with args "links normal" and follow a - Then "navigation request: url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, *" should be logged + Then "navigation request: url http://localhost:*/data/hello.txt, type Type.link_clicked, *" should be logged Scenario: Using :follow-hint inside an iframe button When I open data/hints/iframe_button.html @@ -228,12 +228,12 @@ Feature: Using hints And I hint with args "all normal" and follow a And I run :scroll bottom And I hint with args "links normal" and follow a - Then "navigation request: url http://localhost:*/data/hello2.txt, type NavigationTypeLinkClicked, *" should be logged + Then "navigation request: url http://localhost:*/data/hello2.txt, type Type.link_clicked, *" should be logged Scenario: Opening a link inside a specific iframe When I open data/hints/iframe_target.html And I hint with args "links normal" and follow a - Then "navigation request: url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, *" should be logged + Then "navigation request: url http://localhost:*/data/hello.txt, type Type.link_clicked, *" should be logged Scenario: Opening a link with specific target frame in a new tab When I open data/hints/iframe_target.html diff --git a/tests/end2end/features/search.feature b/tests/end2end/features/search.feature index ae3f07999..4b379b8fe 100644 --- a/tests/end2end/features/search.feature +++ b/tests/end2end/features/search.feature @@ -250,7 +250,7 @@ Feature: Searching on a page And I run :search follow And I wait for "search found follow" in the log And I run :follow-selected - Then "navigation request: url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, is_main_frame False" should be logged + Then "navigation request: url http://localhost:*/data/hello.txt, type Type.link_clicked, is_main_frame False" should be logged @qtwebkit_skip: Not supported in qtwebkit Scenario: Follow a tabbed searched link in an iframe From 14a69d90472278cc8edbb6d2363efee094fec7e5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 04:30:24 +0100 Subject: [PATCH 146/524] Fix lint --- qutebrowser/browser/browsertab.py | 4 ++-- qutebrowser/browser/webengine/webview.py | 3 +-- qutebrowser/browser/webkit/webpage.py | 3 +-- qutebrowser/config/config.py | 13 ++++++------- qutebrowser/config/configcommands.py | 3 ++- qutebrowser/config/configfiles.py | 4 ++-- qutebrowser/config/websettings.py | 2 +- qutebrowser/utils/urlmatch.py | 1 + qutebrowser/utils/usertypes.py | 2 ++ 9 files changed, 18 insertions(+), 17 deletions(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 5598a0904..4fa65eee0 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -30,8 +30,8 @@ from PyQt5.QtWidgets import QWidget, QApplication from qutebrowser.keyinput import modeman from qutebrowser.config import config -from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils, urlutils, - message) +from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils, + urlutils, message) from qutebrowser.misc import miscwidgets, objects from qutebrowser.browser import mouse, hints diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index ef666c934..91c5bfab6 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -29,8 +29,7 @@ from PyQt5.QtWebEngineWidgets import (QWebEngineView, QWebEnginePage, from qutebrowser.browser import shared from qutebrowser.browser.webengine import certificateerror, webenginesettings from qutebrowser.config import config -from qutebrowser.utils import (log, debug, usertypes, jinja, urlutils, message, - objreg, qtutils) +from qutebrowser.utils import log, debug, usertypes, jinja, objreg, qtutils class WebEngineView(QWebEngineView): diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index 89b205fa8..aebf53d87 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -33,8 +33,7 @@ from qutebrowser.config import config from qutebrowser.browser import pdfjs, shared from qutebrowser.browser.webkit import http from qutebrowser.browser.webkit.network import networkmanager -from qutebrowser.utils import (message, usertypes, log, jinja, objreg, debug, - urlutils) +from qutebrowser.utils import message, usertypes, log, jinja, objreg class BrowserPage(QWebPage): diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 341ecba93..be8aee85d 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -23,7 +23,6 @@ import copy import contextlib import functools -import attr from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject from qutebrowser.config import configdata, configexc, configutils @@ -234,8 +233,8 @@ class Config(QObject): """Main config object. Class attributes: - MUTABLE_TYPES: Types returned from the config which could potentially be - mutated. + MUTABLE_TYPES: Types returned from the config which could potentially + be mutated. Attributes: _values: A dict mapping setting names to configutils.Values objects. @@ -312,8 +311,8 @@ class Config(QObject): def _maybe_copy(self, value): """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. + # 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) @@ -332,8 +331,8 @@ class Config(QObject): def get_obj_for_pattern(self, name, *, pattern): """Get the given setting as object (for YAML/config.py). - This gets the overridden value for a given pattern, or configutils.UNSET - if no such override exists. + This gets the overridden value for a given pattern, or + configutils.UNSET if no such override exists. """ value = self._values[name].get_for_pattern(pattern, fallback=False) return self._maybe_copy(value) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index a527d47f1..f3f8e82b7 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -177,7 +177,8 @@ 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, url=None, temp=False, print_=False): + def config_cycle(self, option, *values, url=None, temp=False, + print_=False): """Cycle an option between multiple values. Args: diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 8a7476ca9..91735f536 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -102,7 +102,7 @@ class YamlConfig(QObject): save_manager.add_saveable('yaml-config', self._save, self.changed) def __iter__(self): - for name, values in sorted(self._values.items()): + for _name, values in sorted(self._values.items()): yield from values def _mark_changed(self): @@ -217,7 +217,7 @@ class YamlConfig(QObject): def _validate(self): """Make sure all settings exist.""" unknown = [] - for _pattern, name, value in self: + for _pattern, name, _value in self: # FIXME:conf show pattern if name not in configdata.DATA: unknown.append(name) diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 2831af01e..f4b5d9820 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -231,7 +231,7 @@ def update_for_tab(mappings, tab, url): value = values.get_for_url(url, fallback=False) # FIXME:conf have a proper API for this. - settings = tab._widget.settings() + settings = tab._widget.settings() # pylint: disable=protected-access if value is configutils.UNSET: try: diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 446f53bb2..c59db211b 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -104,6 +104,7 @@ class UrlPattern: return hash(self._to_tuple()) def __eq__(self, other): + # pylint: disable=protected-access return self._to_tuple() == other._to_tuple() def _fixup_pattern(self, pattern): diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 06b70fc21..039d805f9 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -400,6 +400,8 @@ class AbstractCertificateErrorWrapper: @attr.s class NavigationRequest: + """A request to navigate to the given URL.""" + Type = enum.Enum('Type', [ 'link_clicked', 'typed', # QtWebEngine only From a6b979539dd976f1d7741b68b898e353f8926b78 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 04:54:12 +0100 Subject: [PATCH 147/524] Add missing configutils.py --- qutebrowser/config/configutils.py | 139 ++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 qutebrowser/config/configutils.py diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py new file mode 100644 index 000000000..8b5593749 --- /dev/null +++ b/qutebrowser/config/configutils.py @@ -0,0 +1,139 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# 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 . + + +"""Utilities and data structures used by various config code.""" + + +import attr + + +# Sentinel object +UNSET = object() + + +@attr.s +class ScopedValue: + + """A configuration value which is valid for a UrlPattern. + + Attributes: + value: The value itself. + pattern: The UrlPattern for the value, or None for global values. + """ + + value = attr.ib() + pattern = attr.ib() + + +class Values: + + """A collection of values for a single setting. + + Currently, this is a list and iterates through all possible ScopedValues to + find matching ones. + + In the future, it should be possible to optimize this by doing + pre-selection based on hosts, by making this a dict mapping the + non-wildcard part of the host to a list of matching ScopedValues. + + That way, when searching for a setting for sub.example.com, we only have to + check 'sub.example.com', 'example.com', '.com' and '' instead of checking + all ScopedValues for the given setting. + + Attributes: + _opt: The Option being customized. + """ + + def __init__(self, opt): + self._opt = opt + self._values = [] + + def __iter__(self): + """Yield ScopedValue elements. + + This yields in "normal" order, i.e. global and then first-set settings + first. + """ + yield from self._values + + def add(self, value, pattern=None): + """Add a value with the given pattern to the list of values. + + Currently, we just add this to the end of the list, meaning the same + pattern can be in there multiple times. However, that avoids doing a + search through all values every time a setting is set. We can still + optimize this later when changing the data structure as mentioned in + the class docstring. + """ + scoped = ScopedValue(value, pattern) + self._values.append(scoped) + + def remove(self, pattern=None): + """Remove the value with the given pattern.""" + # FIXME:conf Should this ignore patterns which weren't found? + self._values = [v for v in self._values if v.pattern != pattern] + + def _get_fallback(self): + """Get the fallback global/default value.""" + if self._values: + scoped = self._values[-1] + if scoped.pattern is None: + # It's possible that the setting is only customized from the + # default for a given URL. + return scoped.value + + return self._opt.default + + def get_for_url(self, url=None, *, fallback=True): + """Get a config value, falling back when needed. + + This first tries to find a value matching the URL (if given). + If there's no match: + With fallback=True, the global/default setting is returned. + With fallback=False, UNSET is returned. + """ + if url is not None: + for scoped in reversed(self._values): + if scoped.pattern is not None and scoped.pattern.matches(url): + return scoped.value + + if not fallback: + return UNSET + + return self._get_fallback() + + def get_for_pattern(self, pattern, *, fallback=True): + """Get a value only if it's been overridden for the given pattern. + + This is useful when showing values to the user. + + If there's no match: + With fallback=True, the global/default setting is returned. + With fallback=False, UNSET is returned. + """ + if pattern is not None: + for scoped in reversed(self._values): + if scoped.pattern == pattern: + return scoped.value + + if not fallback: + return UNSET + + return self._get_fallback() From 9c670e13ceb8b9b65abe69202e9d41d1e0f2fddc Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 05:08:17 +0100 Subject: [PATCH 148/524] Make clearing config work --- qutebrowser/config/config.py | 6 ++---- qutebrowser/config/configfiles.py | 4 ++-- qutebrowser/config/configutils.py | 4 ++++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index be8aee85d..baadf1cfa 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -414,10 +414,8 @@ 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: + for name, values in self._values.items(): + values.clear() self.changed.emit(name) if save_yaml: diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 91735f536..f2e104c05 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -239,8 +239,8 @@ class YamlConfig(QObject): def clear(self): """Clear all values from the YAML file.""" - # FIXME:conf per-URL support? - self._values = [] + for values in self._values.values(): + values.clear() self._mark_changed() diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index 8b5593749..c915b7674 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -90,6 +90,10 @@ class Values: # FIXME:conf Should this ignore patterns which weren't found? self._values = [v for v in self._values if v.pattern != pattern] + def clear(self): + """Clear all customization for this value.""" + self._values = [] + def _get_fallback(self): """Get the fallback global/default value.""" if self._values: From 469175396500235c620030938befaa8b55f6af1d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 05:19:25 +0100 Subject: [PATCH 149/524] Avoid running change handlers on config.clear --- qutebrowser/config/config.py | 5 +++-- qutebrowser/config/configutils.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index baadf1cfa..b3ebc97c3 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -415,8 +415,9 @@ class Config(QObject): file. """ for name, values in self._values.items(): - values.clear() - self.changed.emit(name) + cleared = values.clear() + if cleared: + self.changed.emit(name) if save_yaml: self._yaml.clear() diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index c915b7674..cae1de7b7 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -92,7 +92,9 @@ class Values: def clear(self): """Clear all customization for this value.""" + had_values = bool(self._values) self._values = [] + return had_values def _get_fallback(self): """Get the fallback global/default value.""" From 6abb42a0665bc532ead67ec838823e10684f0de5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 06:25:28 +0100 Subject: [PATCH 150/524] Make saving in autoconfig.yml work --- qutebrowser/config/config.py | 4 ++-- qutebrowser/config/configfiles.py | 14 +++++++++++--- qutebrowser/config/configutils.py | 6 ++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index b3ebc97c3..13f745216 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -415,8 +415,8 @@ class Config(QObject): file. """ for name, values in self._values.items(): - cleared = values.clear() - if cleared: + if values: + values.clear() self.changed.emit(name) if save_yaml: diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index f2e104c05..d7ba4493c 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -115,10 +115,17 @@ class YamlConfig(QObject): if not self._dirty: return - data = {'config_version': self.VERSION, 'global': self._values} - for pattern, values in sorted(self._values.items()): - data[str(pattern)] = values + 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. @@ -231,6 +238,7 @@ class YamlConfig(QObject): 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.""" diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index cae1de7b7..ef6e87359 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -73,6 +73,10 @@ class Values: """ yield from self._values + def __bool__(self): + """Check whether this value is customized.""" + return bool(self._values) + def add(self, value, pattern=None): """Add a value with the given pattern to the list of values. @@ -92,9 +96,7 @@ class Values: def clear(self): """Clear all customization for this value.""" - had_values = bool(self._values) self._values = [] - return had_values def _get_fallback(self): """Get the fallback global/default value.""" From 8504ad6ff33360ffc34616fc4ad1787aac579faf Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 06:35:14 +0100 Subject: [PATCH 151/524] Change how iterating over Config/YamlConfig works --- qutebrowser/config/config.py | 29 +++++++++++++---------------- qutebrowser/config/configfiles.py | 13 ++++++------- qutebrowser/config/configutils.py | 21 ++++++++++++++++++--- qutebrowser/config/websettings.py | 6 +++--- 4 files changed, 40 insertions(+), 29 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 13f745216..d1f8b2b44 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -259,9 +259,8 @@ class Config(QObject): self._values[name] = configutils.Values(opt) def __iter__(self): - """Iterate over Option, configutils.Values tuples.""" - for name, values in sorted(self._values.items()): - yield self.get_opt(name), values + """Iterate over configutils.Values items.""" + yield from self._values.values() def init_save_manager(self, save_manager): """Make sure the config gets saved properly. @@ -287,9 +286,10 @@ class Config(QObject): def read_yaml(self): """Read the YAML settings from self._yaml.""" self._yaml.load() - # FIXME:conf implement in self._yaml - for pattern, name, value in self._yaml: - self._set_value(self.get_opt(name), value, pattern=pattern) + for values in self._yaml: + for scoped in values: + self._set_value(values.opt, scoped.value, + pattern=scoped.pattern) def get_opt(self, name): """Get a configdata.Option object for the given setting.""" @@ -443,17 +443,14 @@ class Config(QObject): Return: The changed config part as string. """ - lines = [] - for pattern, opt, value in self: - str_value = opt.typ.to_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: + blocks = [] + for values in self: + if values: + blocks.append(str(values)) + + if not blocks: lines = [''] - return '\n'.join(lines) + return '\n'.join(blocks) class ConfigContainer: diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index d7ba4493c..cbd6dd4f6 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -102,8 +102,8 @@ class YamlConfig(QObject): save_manager.add_saveable('yaml-config', self._save, self.changed) def __iter__(self): - for _name, values in sorted(self._values.items()): - yield from values + """Iterate over configutils.Values items.""" + yield from self._values.values() def _mark_changed(self): """Mark the YAML config as changed.""" @@ -183,7 +183,7 @@ class YamlConfig(QObject): # FIXME:conf test this for name, yaml_values in settings_obj.items(): - values = configutils.Values(self._config.get_opt(name)) + values = configutils.Values(configdata.DATA[name]) if 'global' in yaml_values: values.add(yaml_values.pop('global')) @@ -224,10 +224,9 @@ class YamlConfig(QObject): def _validate(self): """Make sure all settings exist.""" unknown = [] - for _pattern, name, _value in self: - # FIXME:conf show pattern - if name not in configdata.DATA: - unknown.append(name) + for values in self: + if values.opt.name not in configdata.DATA: + unknown.append(values.opt.name) if unknown: errors = [configexc.ConfigErrorDesc("While loading options", diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index ef6e87359..9ddd6e824 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -58,13 +58,28 @@ class Values: all ScopedValues for the given setting. Attributes: - _opt: The Option being customized. + opt: The Option being customized. """ def __init__(self, opt): - self._opt = opt + self.opt = opt self._values = [] + def __str__(self): + """Get the values as human-readable string.""" + if not self: + return '{}: '.format(self.opt.name) + + lines = [] + for scoped in self._values: + str_value = self.opt.typ.to_str(scoped.value) + if scoped.pattern is None: + lines.append('{} = {}'.format(self.opt.name, str_value)) + else: + lines.append('{}: {} = {}'.format( + scoped.pattern, self.opt.name, str_value)) + return '\n'.join(lines) + def __iter__(self): """Yield ScopedValue elements. @@ -107,7 +122,7 @@ class Values: # default for a given URL. return scoped.value - return self._opt.default + return self.opt.default def get_for_url(self, url=None, *, fallback=True): """Get a config value, falling back when needed. diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index f4b5d9820..83b45186a 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -222,12 +222,12 @@ def update_mappings(mappings, option): def update_for_tab(mappings, tab, url): """Update settings customized for the given tab.""" - for opt, values in config.instance: - if opt.name not in mappings: + for values in config.instance: + if values.opt.name not in mappings: continue # FIXME:conf handle settings != None with global/static setters - mapping = mappings[opt.name] + mapping = mappings[values.opt.name] value = values.get_for_url(url, fallback=False) # FIXME:conf have a proper API for this. From ddb914dc652626beec6e9992e462daf3edd04140 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 06:49:55 +0100 Subject: [PATCH 152/524] Refactor YAML init --- qutebrowser/config/configfiles.py | 74 ++++++++++++++++++------------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index cbd6dd4f6..a865709de 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -148,18 +148,6 @@ class YamlConfig(QObject): desc = configexc.ConfigErrorDesc("While parsing", e) raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - try: - settings_obj = yaml_data.pop('settings') - except KeyError: - desc = configexc.ConfigErrorDesc( - "While loading data", - "Toplevel object does not contain 'settings' key") - raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - except TypeError: - desc = configexc.ConfigErrorDesc("While loading data", - "Toplevel object is not a dict") - raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - try: yaml_data.pop('config_version') except KeyError: @@ -167,22 +155,42 @@ class YamlConfig(QObject): "While loading data", "Toplevel object does not contain 'config_version' key") raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) + except TypeError: + desc = configexc.ConfigErrorDesc("While loading data", + "Toplevel object is not a dict") + raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - self._load_settings_object(settings_obj) + settings = self._load_settings_object(yaml_data) self._dirty = False - self._handle_migrations() - self._validate() + 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. + + FIXME: conf migrate old settings + """ + try: + settings_obj = yaml_data.pop('settings') + except KeyError: + desc = configexc.ConfigErrorDesc( + "While loading data", + "Toplevel object does not contain 'settings' key") + raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - def _load_settings_object(self, settings_obj): - """Load the settings from the settings: key.""" if not isinstance(settings_obj, dict): desc = configexc.ConfigErrorDesc( "While loading data", "'settings' object is not a dict") raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) + return settings_obj + + def _build_values(self, settings): + """Build up self._values from the values in the given dict.""" # FIXME:conf test this - for name, yaml_values in settings_obj.items(): + for name, yaml_values in settings.items(): values = configutils.Values(configdata.DATA[name]) if 'global' in yaml_values: values.add(yaml_values.pop('global')) @@ -192,41 +200,43 @@ class YamlConfig(QObject): self._values[name] = values - def _handle_migrations(self): + def _handle_migrations(self, settings): """Migrate older configs to the newest format.""" # FIXME:conf handle per-URL settings # FIXME:conf migrate from older format with global: key # Simple renamed/deleted options - for name in list(self._values): + 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)) - self._values[new_name] = self._values[name] - del self._values[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 self._values[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 self._values: - if self._values[old]: - self._values[new] = 'persist' + if old in settings: + if settings[old]: + settings[new] = 'persist' else: - self._values[new] = 'normal' - del self._values[old] + settings[new] = 'normal' + del settings[old] self._mark_changed() - def _validate(self): + return settings + + def _validate(self, settings): """Make sure all settings exist.""" unknown = [] - for values in self: - if values.opt.name not in configdata.DATA: - unknown.append(values.opt.name) + for name in settings: + if name not in configdata.DATA: + unknown.append(name) if unknown: errors = [configexc.ConfigErrorDesc("While loading options", From 93972ff3f135cf344abad49dc62c257a4f956f8e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 07:12:27 +0100 Subject: [PATCH 153/524] Copy value before watching it for mutations in config If we copy it afterwards, we are going to mutate the copied object. --- qutebrowser/config/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index d1f8b2b44..7cfd326d3 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -352,12 +352,13 @@ class Config(QObject): return obj value = self._values[name].get_for_pattern(pattern) + value = self._maybe_copy(value) # Watch the returned object for changes if it's mutable. if isinstance(value, self.MUTABLE_TYPES): self._mutables[name] = (copy.deepcopy(value), value) - return self._maybe_copy(value) + return value def get_str(self, name, *, pattern=None): """Get the given setting as string. From bd6e99158e42a76880bcd2fdcd13de72d1c64324 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 07:14:48 +0100 Subject: [PATCH 154/524] Get rid of the second deepcopy for config values There were two reasons why we deepcopy mutable objects in the config: 1) So mutations don't mess with our internal/default values. 2) So we can detect mutations and update the config. If we're going to copy the value for 1) in maybe_copy(), we know the original value is not going to be mutated, so we can use that directly for self._mutables instead of making another copy. --- qutebrowser/config/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 7cfd326d3..1d87ca777 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -352,13 +352,13 @@ class Config(QObject): return obj value = self._values[name].get_for_pattern(pattern) - value = self._maybe_copy(value) + copy_value = self._maybe_copy(value) # Watch the returned object for changes if it's mutable. - if isinstance(value, self.MUTABLE_TYPES): - self._mutables[name] = (copy.deepcopy(value), value) + if isinstance(copy_value, self.MUTABLE_TYPES): + self._mutables[name] = (value, copy_value) # old, new - return value + return copy_value def get_str(self, name, *, pattern=None): """Get the given setting as string. From 8b666d2d2e5d5a4f330634a6121a559608cd2654 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 07:50:51 +0100 Subject: [PATCH 155/524] Try to update settings in acceptNavigationRequest This still doesn't seem to update them early enough? --- qutebrowser/browser/webengine/webenginetab.py | 9 ++++++--- qutebrowser/browser/webkit/webkittab.py | 7 ++++--- qutebrowser/config/configutils.py | 13 +++++++++++-- qutebrowser/config/websettings.py | 3 +++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index be559de54..fdabb195a 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -872,6 +872,12 @@ class WebEngineTab(browsertab.AbstractTab): if not ok: self._load_finished_fake.emit(False) + @pyqtSlot(usertypes.NavigationRequest) + def _on_navigation_request(self, navigation): + super()._on_navigation_request(navigation) + if navigation.accepted: + webenginesettings.update_for_tab(self, navigation.url) + def _connect_signals(self): view = self._widget page = view.page() @@ -907,8 +913,5 @@ class WebEngineTab(browsertab.AbstractTab): page.loadFinished.connect(self._restore_zoom) page.loadFinished.connect(self._on_load_finished) - self.url_changed.connect( - functools.partial(webenginesettings.update_for_tab, self)) - def event_target(self): return self._widget.focusProxy() diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 165d2fdc4..2decac683 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -781,6 +781,10 @@ class WebKitTab(browsertab.AbstractTab): self.data.open_target = usertypes.ClickTarget.normal navigation.accepted = False + if (navigation.accepted and navigation.navigation_type != + navigation.Type.reloaded): + webkitsettings.update_for_tab(self, navigation.url) + def _connect_signals(self): view = self._widget page = view.page() @@ -801,8 +805,5 @@ class WebKitTab(browsertab.AbstractTab): frame.initialLayoutCompleted.connect(self._on_history_trigger) page.navigation_request.connect(self._on_navigation_request) - self.url_changed.connect( - functools.partial(webkitsettings.update_for_tab, self)) - def event_target(self): return self._widget diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index 9ddd6e824..9234a5ece 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -24,8 +24,17 @@ import attr -# Sentinel object -UNSET = object() +class _UnsetObject: + + """Sentinel object.""" + + __slots__ = () + + def __repr__(self): + return '' + + +UNSET = _UnsetObject() @attr.s diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 83b45186a..0f057d865 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -230,6 +230,9 @@ def update_for_tab(mappings, tab, url): mapping = mappings[values.opt.name] value = values.get_for_url(url, fallback=False) + log.config.debug("Updating for {}: {} = {}".format( + url.toDisplayString(), values.opt.name, value)) + # FIXME:conf have a proper API for this. settings = tab._widget.settings() # pylint: disable=protected-access From cb631d532a3e82e2c1e8833c4cd13facf5bc7465 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 10:35:35 +0100 Subject: [PATCH 156/524] Fix getting global value from configutils.Values --- qutebrowser/config/configutils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index 9234a5ece..6c19ebab0 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -124,8 +124,7 @@ class Values: def _get_fallback(self): """Get the fallback global/default value.""" - if self._values: - scoped = self._values[-1] + for scoped in self._values: if scoped.pattern is None: # It's possible that the setting is only customized from the # default for a given URL. From d3e8d46593e7a427f343ae02f17d598c5f99a66a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 15:25:11 +0100 Subject: [PATCH 157/524] Use a real YamlConfig for tests --- tests/helpers/fixtures.py | 7 ++++--- tests/helpers/stubs.py | 27 --------------------------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index 686db4125..193a40a8a 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -42,7 +42,8 @@ from PyQt5.QtNetwork import QNetworkCookieJar import helpers.stubs as stubsmod import helpers.utils -from qutebrowser.config import config, configdata, configtypes, configexc +from qutebrowser.config import (config, configdata, configtypes, configexc, + configfiles) from qutebrowser.utils import objreg, standarddir from qutebrowser.browser.webkit import cookies from qutebrowser.misc import savemanager, sql @@ -193,9 +194,9 @@ def configdata_init(): @pytest.fixture -def config_stub(stubs, monkeypatch, configdata_init): +def config_stub(stubs, monkeypatch, configdata_init, config_tmpdir): """Fixture which provides a fake config object.""" - yaml_config = stubs.FakeYamlConfig() + yaml_config = configfiles.YamlConfig() conf = config.Config(yaml_config=yaml_config) monkeypatch.setattr(config, 'instance', conf) diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 64bc793cb..5a74b6a43 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -406,33 +406,6 @@ class InstaTimer(QObject): fun() -class FakeYamlConfig: - - """Fake configfiles.YamlConfig object.""" - - def __init__(self): - self.loaded = False - self._values = {} - - def __contains__(self, item): - return item in self._values - - def __iter__(self): - return iter(self._values.items()) - - def __setitem__(self, key, value): - self._values[key] = value - - def __getitem__(self, key): - return self._values[key] - - def unset(self, name): - self._values.pop(name, None) - - def clear(self): - self._values = [] - - class StatusBarCommandStub(QLineEdit): """Stub for the statusbar command prompt.""" From f43c7fa360fc6643af1ed26198a09208641d52fe Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 15:32:38 +0100 Subject: [PATCH 158/524] Fix changing values in configutils.Values --- qutebrowser/config/configutils.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index 6c19ebab0..e47195654 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -102,14 +102,8 @@ class Values: return bool(self._values) def add(self, value, pattern=None): - """Add a value with the given pattern to the list of values. - - Currently, we just add this to the end of the list, meaning the same - pattern can be in there multiple times. However, that avoids doing a - search through all values every time a setting is set. We can still - optimize this later when changing the data structure as mentioned in - the class docstring. - """ + """Add a value with the given pattern to the list of values.""" + self.remove(pattern) scoped = ScopedValue(value, pattern) self._values.append(scoped) From 5eeb2233384c5c8f1dc4892524ec2f1d65cb75e7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 15:35:29 +0100 Subject: [PATCH 159/524] Use a different directory for file prompt tests This way they aren't influenced by the config_tmpdir fixture. --- tests/unit/mainwindow/test_prompt.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/unit/mainwindow/test_prompt.py b/tests/unit/mainwindow/test_prompt.py index eb0ba9e69..e467b0316 100644 --- a/tests/unit/mainwindow/test_prompt.py +++ b/tests/unit/mainwindow/test_prompt.py @@ -56,22 +56,25 @@ class TestFileCompletion: def test_simple_completion(self, tmpdir, get_prompt, steps, where, subfolder): """Simply trying to tab through items.""" + testdir = tmpdir / 'test' for directory in 'abc': - (tmpdir / directory).ensure(dir=True) + (testdir / directory).ensure(dir=True) - prompt = get_prompt(str(tmpdir) + os.sep) + prompt = get_prompt(str(testdir) + os.sep) for _ in range(steps): prompt.item_focus(where) - assert prompt._lineedit.text() == str(tmpdir / subfolder) + assert prompt._lineedit.text() == str(testdir / subfolder) def test_backspacing_path(self, qtbot, tmpdir, get_prompt): """When we start deleting a path we want to see the subdir.""" - for directory in ['bar', 'foo']: - (tmpdir / directory).ensure(dir=True) + testdir = tmpdir / 'test' - prompt = get_prompt(str(tmpdir / 'foo') + os.sep) + for directory in ['bar', 'foo']: + (testdir / directory).ensure(dir=True) + + prompt = get_prompt(str(testdir / 'foo') + os.sep) # Deleting /f[oo/] with qtbot.wait_signal(prompt._file_model.directoryLoaded): @@ -81,7 +84,7 @@ class TestFileCompletion: # We should now show / again, so tabbing twice gives us .. -> bar prompt.item_focus('next') prompt.item_focus('next') - assert prompt._lineedit.text() == str(tmpdir / 'bar') + assert prompt._lineedit.text() == str(testdir / 'bar') @pytest.mark.linux def test_root_path(self, get_prompt): From 87e329aee3b52f4a2196b154da6ff3aac7f6ff27 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 16:36:18 +0100 Subject: [PATCH 160/524] Fix config.dump_userconfig() with defaults --- qutebrowser/config/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 1d87ca777..8f34b6e44 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -450,7 +450,8 @@ class Config(QObject): blocks.append(str(values)) if not blocks: - lines = [''] + return '' + return '\n'.join(blocks) From c89e8046535634021a1e855e9a952bcb3940c48f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 16:36:32 +0100 Subject: [PATCH 161/524] Fix handling of invalid types in YamlConfig --- qutebrowser/config/configfiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index a865709de..2c26058a8 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -155,7 +155,7 @@ class YamlConfig(QObject): "While loading data", "Toplevel object does not contain 'config_version' key") raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - except TypeError: + except (TypeError, AttributeError): desc = configexc.ConfigErrorDesc("While loading data", "Toplevel object is not a dict") raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) From 19f7b92abbe565fceb8131dfd44644af3d2ded3d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 16:36:45 +0100 Subject: [PATCH 162/524] Fix test_configinit.py --- tests/unit/config/test_configinit.py | 65 ++++++++++++++++++---------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index a845f84f2..230b64a54 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -100,14 +100,15 @@ class TestEarlyInit: # Check config values if config_py: - assert config.instance._values == {'colors.hints.bg': 'red'} + expected = 'colors.hints.bg = red' else: - assert config.instance._values == {} + expected = '' + assert config.instance.dump_userconfig() == expected @pytest.mark.parametrize('load_autoconfig', [True, False]) # noqa @pytest.mark.parametrize('config_py', [True, 'error', False]) - @pytest.mark.parametrize('invalid_yaml', ['42', 'unknown', 'wrong-type', - False]) + @pytest.mark.parametrize('invalid_yaml', ['42', 'list', 'unknown', + 'wrong-type', False]) def test_autoconfig_yml(self, init_patch, config_tmpdir, caplog, args, load_autoconfig, config_py, invalid_yaml): """Test interaction between config.py and autoconfig.yml.""" @@ -115,14 +116,30 @@ class TestEarlyInit: autoconfig_file = config_tmpdir / 'autoconfig.yml' config_py_file = config_tmpdir / 'config.py' - yaml_text = { + yaml_lines = { '42': '42', - 'unknown': 'global:\n colors.foobar: magenta\n', - 'wrong-type': 'global:\n tabs.position: true\n', - False: 'global:\n colors.hints.fg: magenta\n', + 'list': '[1, 2]', + 'unknown': [ + 'settings:', + ' colors.foobar:', + ' global: magenta', + 'config_version: 1', + ], + 'wrong-type': [ + 'settings:', + ' tabs.position:', + ' global: true', + 'config_version: 1', + ], + False: [ + 'settings:', + ' colors.hints.fg:', + ' global: magenta', + 'config_version: 1', + ], } - autoconfig_file.write_text(yaml_text[invalid_yaml], 'utf-8', - ensure=True) + text = '\n'.join(yaml_lines[invalid_yaml]) + autoconfig_file.write_text(text, 'utf-8', ensure=True) if config_py: config_py_lines = ['c.colors.hints.bg = "red"'] @@ -141,7 +158,7 @@ class TestEarlyInit: if load_autoconfig or not config_py: suffix = ' (autoconfig.yml)' if config_py else '' - if invalid_yaml == '42': + if invalid_yaml in ['42', 'list']: error = ("While loading data{}: Toplevel object is not a dict" .format(suffix)) expected_errors.append(error) @@ -165,17 +182,21 @@ class TestEarlyInit: assert actual_errors == expected_errors # Check config values + dump = config.instance.dump_userconfig() + if config_py and load_autoconfig and not invalid_yaml: - assert config.instance._values == { - 'colors.hints.bg': 'red', - 'colors.hints.fg': 'magenta', - } + expected = [ + 'colors.hints.fg = magenta', + 'colors.hints.bg = red', + ] elif config_py: - assert config.instance._values == {'colors.hints.bg': 'red'} + expected = ['colors.hints.bg = red'] elif invalid_yaml: - assert config.instance._values == {} + expected = [''] else: - assert config.instance._values == {'colors.hints.fg': 'magenta'} + expected = ['colors.hints.fg = magenta'] + + assert dump == '\n'.join(expected) def test_invalid_change_filter(self, init_patch, args): config.change_filter('foobar') @@ -185,7 +206,7 @@ class TestEarlyInit: def test_temp_settings_valid(self, init_patch, args): args.temp_settings = [('colors.completion.fg', 'magenta')] configinit.early_init(args) - assert config.instance._values['colors.completion.fg'] == 'magenta' + assert config.instance.get_obj('colors.completion.fg') == 'magenta' def test_temp_settings_invalid(self, caplog, init_patch, message_mock, args): @@ -198,7 +219,6 @@ class TestEarlyInit: msg = message_mock.getmsg() assert msg.level == usertypes.MessageLevel.error assert msg.text == "set: NoOptionError - No option 'foo'" - assert 'colors.completion.fg' not in config.instance._values @pytest.mark.parametrize('settings, size, family', [ # Only fonts.monospace customized @@ -220,8 +240,9 @@ class TestEarlyInit: args.temp_settings = settings elif method == 'auto': autoconfig_file = config_tmpdir / 'autoconfig.yml' - lines = ["global:"] + [" {}: '{}'".format(k, v) - for k, v in settings] + lines = (["config_version: 1", "settings:"] + + [" {}:\n global:\n '{}'".format(k, v) + for k, v in settings]) autoconfig_file.write_text('\n'.join(lines), 'utf-8', ensure=True) elif method == 'py': config_py_file = config_tmpdir / 'config.py' From 75181e16faca3c590308e3c8843d714064004448 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 19:16:50 +0100 Subject: [PATCH 163/524] Fix test_models.py The Config object got initialized via the config_stub fixture early, so we need to force it to re-init its values after patching configdata.DATA. --- qutebrowser/config/config.py | 3 +++ tests/unit/completion/test_models.py | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 8f34b6e44..4ad00fec4 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -253,7 +253,10 @@ class Config(QObject): self.changed.connect(_render_stylesheet.cache_clear) self._mutables = {} self._yaml = yaml_config + self._init_values() + def _init_values(self): + """Populate the self._values dict.""" self._values = {} for name, opt in configdata.DATA.items(): self._values[name] = configutils.Values(opt) diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index ff9a24112..32e6920fe 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -80,9 +80,9 @@ def cmdutils_stub(monkeypatch, stubs): @pytest.fixture() -def configdata_stub(monkeypatch, configdata_init): +def configdata_stub(config_stub, monkeypatch, configdata_init): """Patch the configdata module to provide fake data.""" - return monkeypatch.setattr(configdata, 'DATA', collections.OrderedDict([ + monkeypatch.setattr(configdata, 'DATA', collections.OrderedDict([ ('aliases', configdata.Option( name='aliases', description='Aliases for commands.', @@ -132,6 +132,7 @@ def configdata_stub(monkeypatch, configdata_init): backends=[], raw_backends=None)), ])) + config_stub._init_values() @pytest.fixture From 0f907b1a779dbca1f616a57494f7051ffd555209 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 19:34:30 +0100 Subject: [PATCH 164/524] Fix getting YAML values in test_configcommands.py --- qutebrowser/config/configutils.py | 11 +++-- tests/unit/config/test_configcommands.py | 51 ++++++++++++------------ 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index e47195654..043650524 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -116,7 +116,7 @@ class Values: """Clear all customization for this value.""" self._values = [] - def _get_fallback(self): + def _get_fallback(self, fallback): """Get the fallback global/default value.""" for scoped in self._values: if scoped.pattern is None: @@ -124,7 +124,10 @@ class Values: # default for a given URL. return scoped.value - return self.opt.default + if fallback: + return self.opt.default + else: + return UNSET def get_for_url(self, url=None, *, fallback=True): """Get a config value, falling back when needed. @@ -142,7 +145,7 @@ class Values: if not fallback: return UNSET - return self._get_fallback() + return self._get_fallback(fallback) def get_for_pattern(self, pattern, *, fallback=True): """Get a value only if it's been overridden for the given pattern. @@ -161,4 +164,4 @@ class Values: if not fallback: return UNSET - return self._get_fallback() + return self._get_fallback(fallback) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 13b2ae943..945eb2bc7 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -24,7 +24,7 @@ import unittest.mock import pytest from PyQt5.QtCore import QUrl -from qutebrowser.config import configcommands +from qutebrowser.config import configcommands, configutils from qutebrowser.commands import cmdexc from qutebrowser.utils import usertypes from qutebrowser.misc import objects @@ -35,6 +35,14 @@ def commands(config_stub, key_config_stub): return configcommands.ConfigCommands(config_stub, key_config_stub) +@pytest.fixture +def yaml_value(config_stub): + """Fixture which provides a getter for a YAML value.""" + def getter(option): + return config_stub._yaml._values[option].get_for_url(fallback=False) + return getter + + class TestSet: """Tests for :set.""" @@ -64,7 +72,7 @@ class TestSet: ['gvim', '-f', '{file}', '-c', 'normal {line}G{column0}l'], '[emacs, "{}"]', ['emacs', '{}']), ]) - def test_set_simple(self, monkeypatch, commands, config_stub, + def test_set_simple(self, monkeypatch, commands, config_stub, yaml_value, temp, option, old_value, inp, new_value): """Run ':set [-t] option value'. @@ -76,14 +84,10 @@ class TestSet: commands.set(0, option, inp, temp=temp) assert config_stub.get(option) == new_value - - if temp: - assert option not in config_stub._yaml - else: - assert config_stub._yaml[option] == new_value + assert yaml_value(option) == (configutils.UNSET if temp else new_value) @pytest.mark.parametrize('temp', [True, False]) - def test_set_temp_override(self, commands, config_stub, temp): + def test_set_temp_override(self, commands, config_stub, yaml_value, temp): """Invoking :set twice. :set url.auto_search dns @@ -96,7 +100,7 @@ class TestSet: commands.set(0, 'url.auto_search', 'never', temp=True) assert config_stub.val.url.auto_search == 'never' - assert config_stub._yaml['url.auto_search'] == 'dns' + assert yaml_value('url.auto_search') == 'dns' def test_set_print(self, config_stub, commands, message_mock): """Run ':set -p url.auto_search never'. @@ -177,13 +181,14 @@ class TestCycle: # Value which is not in the list ('red', 'green'), ]) - def test_cycling(self, commands, config_stub, initial, expected): + def test_cycling(self, commands, config_stub, yaml_value, + initial, expected): """Run ':set' with multiple values.""" opt = 'colors.statusbar.normal.bg' config_stub.set_obj(opt, initial) commands.config_cycle(opt, 'green', 'magenta', 'blue', 'yellow') assert config_stub.get(opt) == expected - assert config_stub._yaml[opt] == expected + assert yaml_value(opt) == expected def test_different_representation(self, commands, config_stub): """When using a different representation, cycling should work. @@ -205,7 +210,7 @@ class TestCycle: assert not config_stub.val.auto_save.session commands.config_cycle('auto_save.session') assert config_stub.val.auto_save.session - assert config_stub._yaml['auto_save.session'] + assert yaml_value('auto_save.session') @pytest.mark.parametrize('args', [ ['url.auto_search'], ['url.auto_search', 'foo'] @@ -239,34 +244,28 @@ class TestUnsetAndClear: """Test :config-unset and :config-clear.""" @pytest.mark.parametrize('temp', [True, False]) - def test_unset(self, commands, config_stub, temp): + def test_unset(self, commands, config_stub, yaml_value, temp): name = 'tabs.show' config_stub.set_obj(name, 'never', save_yaml=True) commands.config_unset(name, temp=temp) assert config_stub.get(name) == 'always' - if temp: - assert config_stub._yaml[name] == 'never' - else: - assert name not in config_stub._yaml + assert yaml_value(name) == ('never' if temp else configutils.UNSET) def test_unset_unknown_option(self, commands): with pytest.raises(cmdexc.CommandError, match="No option 'tabs'"): commands.config_unset('tabs') @pytest.mark.parametrize('save', [True, False]) - def test_clear(self, commands, config_stub, save): + def test_clear(self, commands, config_stub, yaml_value, save): name = 'tabs.show' config_stub.set_obj(name, 'never', save_yaml=True) commands.config_clear(save=save) assert config_stub.get(name) == 'always' - if save: - assert name not in config_stub._yaml - else: - assert config_stub._yaml[name] == 'never' + assert yaml_value(name) == (configutils.UNSET if save else 'never') class TestSource: @@ -453,7 +452,7 @@ class TestBind: @pytest.mark.parametrize('command', ['nop', 'nope']) def test_bind(self, commands, config_stub, no_bindings, key_config_stub, - command): + yaml_value, command): """Simple :bind test (and aliases).""" config_stub.val.aliases = {'nope': 'nop'} config_stub.val.bindings.default = no_bindings @@ -461,7 +460,7 @@ class TestBind: commands.bind(0, 'a', command) assert key_config_stub.get_command('a', 'normal') == command - yaml_bindings = config_stub._yaml['bindings.commands']['normal'] + yaml_bindings = yaml_value('bindings.commands')['normal'] assert yaml_bindings['a'] == command @pytest.mark.parametrize('key, mode, expected', [ @@ -573,7 +572,7 @@ class TestBind: ('c', 'c'), # :bind then :unbind ('', '') # normalized special binding ]) - def test_unbind(self, commands, key_config_stub, config_stub, + def test_unbind(self, commands, key_config_stub, config_stub, yaml_value, key, normalized): config_stub.val.bindings.default = { 'normal': {'a': 'nop', '': 'nop'}, @@ -590,7 +589,7 @@ class TestBind: commands.unbind(key) assert key_config_stub.get_command(key, 'normal') is None - yaml_bindings = config_stub._yaml['bindings.commands']['normal'] + yaml_bindings = yaml_value('bindings.commands')['normal'] if key in 'bc': # Custom binding assert normalized not in yaml_bindings From 615c6ffe5a5b0b9b4ef29007c8b6b45dbe8ac899 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 19:38:45 +0100 Subject: [PATCH 165/524] Make :config-write-py work again --- qutebrowser/config/configcommands.py | 7 +++++-- qutebrowser/config/configfiles.py | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index f3f8e82b7..3e55cbd57 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -313,12 +313,15 @@ class ConfigCommands: "overwrite!".format(filename)) if defaults: - options = [(opt, opt.default) + options = [(None, opt, opt.default) for _name, opt in sorted(configdata.DATA.items())] bindings = dict(configdata.DATA['bindings.default'].default) commented = True else: - options = list(self._config) + options = [] + for values in self._config: + for scoped in values: + options.append((scoped.pattern, values.opt, scoped.value)) bindings = dict(self._config.get_mutable_obj('bindings.commands')) commented = False diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 2c26058a8..cc306b1c9 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -388,7 +388,8 @@ class ConfigPyWriter: def _gen_options(self): """Generate the options part of the config.""" - for opt, value in self._options: + # FIXME:conf handle _pattern + for _pattern, opt, value in self._options: if opt.name in ['bindings.commands', 'bindings.default']: continue From 19148a45936378a48ddbe6a736205dd63b9fbb1a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 19:41:00 +0100 Subject: [PATCH 166/524] Fix :config-unset --- qutebrowser/config/config.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 4ad00fec4..232c5d4c8 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -401,12 +401,8 @@ class Config(QObject): 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 values[name] - except KeyError: - return + self.get_opt(name) # To check whether it exists + self._values[name].remove(pattern) self.changed.emit(name) if save_yaml: From 5d63dfb24c5e42bafd01289adba2d5ceb25342b2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 20:26:38 +0100 Subject: [PATCH 167/524] Start fixing test_configfiles.py --- tests/unit/config/test_configfiles.py | 157 ++++++++++++++------------ 1 file changed, 86 insertions(+), 71 deletions(-) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 06633ffc7..e71c7498d 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -38,6 +38,36 @@ def configdata_init(): configdata.init() +class AutoConfigHelper: + + """A helper to easily create/validate autoconfig.yml files.""" + + def __init__(self, config_tmpdir): + self.fobj = config_tmpdir / 'autoconfig.yml' + + def write(self, values): + data = {'config_version': 1, 'settings': values} + with self.fobj.open('w', encoding='utf-8') as f: + utils.yaml_dump(data, f) + + def write_raw(self, text): + self.fobj.write_text(text, encoding='utf-8', ensure=True) + + def read(self): + with self.fobj.open('r', encoding='utf-8') as f: + data = utils.yaml_load(f) + assert data['config_version'] == 1 + return data['settings'] + + def read_raw(self): + return self.fobj.read_text('utf-8') + + +@pytest.fixture +def autoconfig(config_tmpdir): + return AutoConfigHelper(config_tmpdir) + + @pytest.mark.parametrize('old_data, insert, new_data', [ (None, False, '[general]\n\n[geometry]\n\n'), ('[general]\nfooled = true', False, '[general]\n\n[geometry]\n\n'), @@ -75,49 +105,44 @@ class TestYaml: @pytest.mark.parametrize('old_config', [ None, - 'global:\n colors.hints.fg: magenta', + {'colors.hints.fg': {'global': 'magenta'}} ]) @pytest.mark.parametrize('insert', [True, False]) - def test_yaml_config(self, yaml, config_tmpdir, old_config, insert): - autoconfig = config_tmpdir / 'autoconfig.yml' + def test_yaml_config(self, yaml, autoconfig, old_config, insert): if old_config is not None: - autoconfig.write_text(old_config, 'utf-8') + autoconfig.write(old_config) yaml.load() if insert: - yaml['tabs.show'] = 'never' + yaml.set_obj('tabs.show', 'never') yaml._save() if not insert and old_config is None: lines = [] else: - text = autoconfig.read_text('utf-8') - lines = text.splitlines() + data = autoconfig.read() + lines = autoconfig.read_raw().splitlines() if insert: assert lines[0].startswith('# DO NOT edit this file by hand,') - assert 'config_version: {}'.format(yaml.VERSION) in lines - - assert 'global:' in lines print(lines) - if 'magenta' in (old_config or ''): - assert ' colors.hints.fg: magenta' in lines + if old_config is not None: + assert data['colors.hints.fg'] == {'global': 'magenta'} if insert: - assert ' tabs.show: never' in lines + assert data['tabs.show'] == {'global': 'never'} def test_init_save_manager(self, yaml, fake_save_manager): yaml.init_save_manager(fake_save_manager) fake_save_manager.add_saveable.assert_called_with( 'yaml-config', unittest.mock.ANY, unittest.mock.ANY) - def test_unknown_key(self, yaml, config_tmpdir): + def test_unknown_key(self, yaml, autoconfig): """An unknown setting should show an error.""" - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.write_text('global:\n hello: world', encoding='utf-8') + autoconfig.write({'hello': {'global': 'world'}}) with pytest.raises(configexc.ConfigFileErrors) as excinfo: yaml.load() @@ -127,10 +152,9 @@ class TestYaml: assert error.text == "While loading options" assert str(error.exception) == "Unknown option hello" - def test_multiple_unknown_keys(self, yaml, config_tmpdir): + def test_multiple_unknown_keys(self, yaml, autoconfig): """With multiple unknown settings, all should be shown.""" - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.write_text('global:\n one: 1\n two: 2', encoding='utf-8') + autoconfig.write({'one': {'global': 1}, 'two': {'global': 2}}) with pytest.raises(configexc.ConfigFileErrors) as excinfo: yaml.load() @@ -141,23 +165,21 @@ class TestYaml: assert str(error1.exception) == "Unknown option one" assert str(error2.exception) == "Unknown option two" - def test_deleted_key(self, monkeypatch, yaml, config_tmpdir): + def test_deleted_key(self, monkeypatch, yaml, autoconfig): """A key marked as deleted should be removed.""" - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.write_text('global:\n hello: world', encoding='utf-8') + autoconfig.write({'hello': {'global': 'world'}}) monkeypatch.setattr(configdata.MIGRATIONS, 'deleted', ['hello']) yaml.load() yaml._save() - lines = autoconfig.read_text('utf-8').splitlines() - assert ' hello: world' not in lines + data = autoconfig.read() + assert not data - def test_renamed_key(self, monkeypatch, yaml, config_tmpdir): + def test_renamed_key(self, monkeypatch, yaml, autoconfig): """A key marked as renamed should be renamed properly.""" - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.write_text('global:\n old: value', encoding='utf-8') + autoconfig.write({'old': {'global': 'value'}}) monkeypatch.setattr(configdata.MIGRATIONS, 'renamed', {'old': 'tabs.show'}) @@ -165,29 +187,25 @@ class TestYaml: yaml.load() yaml._save() - lines = autoconfig.read_text('utf-8').splitlines() - assert ' old: value' not in lines - assert ' tabs.show: value' in lines + data = autoconfig.read() + assert data == {'tabs.show': {'global': 'value'}} @pytest.mark.parametrize('persist', [True, False]) - def test_merge_persist(self, yaml, config_tmpdir, persist): + def test_merge_persist(self, yaml, autoconfig, persist): """Tests for migration of tabs.persist_mode_on_change.""" - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.write_text('global:\n tabs.persist_mode_on_change: {}'. - format(persist), encoding='utf-8') + autoconfig.write({'tabs.persist_mode_on_change': {'global': persist}}) yaml.load() yaml._save() - lines = autoconfig.read_text('utf-8').splitlines() + data = autoconfig.read() + assert 'tabs.persist_mode_on_change' not in data mode = 'persist' if persist else 'normal' - assert ' tabs.persist_mode_on_change:' not in lines - assert ' tabs.mode_on_change: {}'.format(mode) in lines + assert data['tabs.mode_on_change'] == mode def test_renamed_key_unknown_target(self, monkeypatch, yaml, - config_tmpdir): + autoconfig): """A key marked as renamed with invalid name should raise an error.""" - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.write_text('global:\n old: value', encoding='utf-8') + autoconfig.write_text({'old': {'global': 'value'}}) monkeypatch.setattr(configdata.MIGRATIONS, 'renamed', {'old': 'new'}) @@ -202,7 +220,7 @@ class TestYaml: @pytest.mark.parametrize('old_config', [ None, - 'global:\n colors.hints.fg: magenta', + {'colors.hints.fg': {'global': 'magenta'}}, ]) @pytest.mark.parametrize('key, value', [ ('colors.hints.fg', 'green'), @@ -210,18 +228,18 @@ class TestYaml: ('confirm_quit', True), ('confirm_quit', False), ]) - def test_changed(self, yaml, qtbot, config_tmpdir, old_config, key, value): - autoconfig = config_tmpdir / 'autoconfig.yml' + def test_changed(self, yaml, qtbot, autoconfig, + old_config, key, value): if old_config is not None: - autoconfig.write_text(old_config, 'utf-8') + autoconfig.write(old_config) yaml.load() with qtbot.wait_signal(yaml.changed): - yaml[key] = value + yaml.set_obj(key, value) assert key in yaml - assert yaml[key] == value + assert yaml._values[key].get_for_url(fallback=False) == value yaml._save() @@ -229,42 +247,40 @@ class TestYaml: yaml.load() assert key in yaml - assert yaml[key] == value + assert yaml._values[key].get_for_url(fallback=False) == value def test_iter(self, yaml): - yaml['foo'] = 23 - yaml['bar'] = 42 + yaml.set_obj('foo', 23) + yaml.set_obj('bar', 42) assert list(iter(yaml)) == [('bar', 42), ('foo', 23)] @pytest.mark.parametrize('old_config', [ None, - 'global:\n colors.hints.fg: magenta', + {'colors.hints.fg': {'global': 'magenta'}}, ]) - def test_unchanged(self, yaml, config_tmpdir, old_config): - autoconfig = config_tmpdir / 'autoconfig.yml' + def test_unchanged(self, yaml, autoconfig, old_config): mtime = None if old_config is not None: - autoconfig.write_text(old_config, 'utf-8') - mtime = autoconfig.stat().mtime + autoconfig.write(old_config) + mtime = autoconfig.fobj.stat().mtime yaml.load() yaml._save() if old_config is None: - assert not autoconfig.exists() + assert not autoconfig.fobj.exists() else: - assert autoconfig.stat().mtime == mtime + assert autoconfig.fobj.stat().mtime == mtime @pytest.mark.parametrize('line, text, exception', [ ('%', 'While parsing', 'while scanning a directive'), - ('global: 42', 'While loading data', "'global' object is not a dict"), + ('settings: 42', 'While loading data', "'settings' object is not a dict"), ('foo: 42', 'While loading data', - "Toplevel object does not contain 'global' key"), + "Toplevel object does not contain 'settings' key"), ('42', 'While loading data', "Toplevel object is not a dict"), ]) - def test_invalid(self, yaml, config_tmpdir, line, text, exception): - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.write_text(line, 'utf-8', ensure=True) + def test_invalid(self, yaml, autoconfig, line, text, exception): + autoconfig.write_raw(line) with pytest.raises(configexc.ConfigFileErrors) as excinfo: yaml.load() @@ -275,11 +291,10 @@ class TestYaml: assert str(error.exception).splitlines()[0] == exception assert error.traceback is None - def test_oserror(self, yaml, config_tmpdir): - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.ensure() - autoconfig.chmod(0) - if os.access(str(autoconfig), os.R_OK): + def test_oserror(self, yaml, autoconfig): + autoconfig.fobj.ensure() + autoconfig.fobj.chmod(0) + if os.access(str(autoconfig.fobj), os.R_OK): # Docker container or similar pytest.skip("File was still readable") @@ -292,22 +307,22 @@ class TestYaml: assert isinstance(error.exception, OSError) assert error.traceback is None - def test_unset(self, yaml, qtbot, config_tmpdir): + def test_unset(self, yaml, qtbot): name = 'tabs.show' - yaml[name] = 'never' + yaml.set_obj(name, 'never') with qtbot.wait_signal(yaml.changed): yaml.unset(name) assert name not in yaml - def test_unset_never_set(self, yaml, qtbot, config_tmpdir): + def test_unset_never_set(self, yaml, qtbot): with qtbot.assert_not_emitted(yaml.changed): yaml.unset('tabs.show') - def test_clear(self, yaml, qtbot, config_tmpdir): + def test_clear(self, yaml, qtbot): name = 'tabs.show' - yaml[name] = 'never' + yaml.set_obj(name, 'never') with qtbot.wait_signal(yaml.changed): yaml.clear() From 1409f4e564f9a35a7faa1209d7594d1258d75b2a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:01:52 +0100 Subject: [PATCH 168/524] Fix migration of tabs.persist_mode_on_change --- qutebrowser/config/configfiles.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index cc306b1c9..3a49f0e27 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -222,10 +222,13 @@ class YamlConfig(QObject): old = 'tabs.persist_mode_on_change' new = 'tabs.mode_on_change' if old in settings: - if settings[old]: - settings[new] = 'persist' - else: - settings[new] = 'normal' + 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() From 8fead148e2118467edea7e2f5adca44fcf9ec454 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:02:07 +0100 Subject: [PATCH 169/524] Add FIXME --- qutebrowser/config/configfiles.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 3a49f0e27..ee22ff8e0 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -195,6 +195,7 @@ class YamlConfig(QObject): if 'global' in yaml_values: values.add(yaml_values.pop('global')) + # FIXME:conf what if yaml_values is not a dict... for pattern, value in yaml_values.items(): values.add(value, pattern) From ab119975e742b06f20179210d82d2ea946f52919 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:02:29 +0100 Subject: [PATCH 170/524] Only emit changed in unset if there was a change --- qutebrowser/config/configfiles.py | 5 +++-- qutebrowser/config/configutils.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index ee22ff8e0..7a555e6a5 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -255,8 +255,9 @@ class YamlConfig(QObject): def unset(self, name, *, pattern=None): """Remove the given option name if it's configured.""" - self._values[name].remove(pattern) - self._mark_changed() + changed = self._values[name].remove(pattern) + if changed: + self._mark_changed() def clear(self): """Clear all values from the YAML file.""" diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index 043650524..cf5569b61 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -110,7 +110,9 @@ class Values: def remove(self, pattern=None): """Remove the value with the given pattern.""" # FIXME:conf Should this ignore patterns which weren't found? + old_len = len(self._values) self._values = [v for v in self._values if v.pattern != pattern] + return old_len != len(self._values) def clear(self): """Clear all customization for this value.""" From 7d80825853a4738a171babc81279ea012b4e2316 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:02:52 +0100 Subject: [PATCH 171/524] Fix test_configfiles.py --- tests/unit/config/test_configfiles.py | 51 +++++++++++++-------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index e71c7498d..4606bc38b 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -27,7 +27,7 @@ import pytest from PyQt5.QtCore import QSettings from qutebrowser.config import (config, configfiles, configexc, configdata, - configtypes) + configtypes, configutils) from qutebrowser.utils import utils, usertypes @@ -200,12 +200,12 @@ class TestYaml: data = autoconfig.read() assert 'tabs.persist_mode_on_change' not in data mode = 'persist' if persist else 'normal' - assert data['tabs.mode_on_change'] == mode + assert data['tabs.mode_on_change']['global'] == mode def test_renamed_key_unknown_target(self, monkeypatch, yaml, autoconfig): """A key marked as renamed with invalid name should raise an error.""" - autoconfig.write_text({'old': {'global': 'value'}}) + autoconfig.write({'old': {'global': 'value'}}) monkeypatch.setattr(configdata.MIGRATIONS, 'renamed', {'old': 'new'}) @@ -238,7 +238,6 @@ class TestYaml: with qtbot.wait_signal(yaml.changed): yaml.set_obj(key, value) - assert key in yaml assert yaml._values[key].get_for_url(fallback=False) == value yaml._save() @@ -246,13 +245,10 @@ class TestYaml: yaml = configfiles.YamlConfig() yaml.load() - assert key in yaml assert yaml._values[key].get_for_url(fallback=False) == value def test_iter(self, yaml): - yaml.set_obj('foo', 23) - yaml.set_obj('bar', 42) - assert list(iter(yaml)) == [('bar', 42), ('foo', 23)] + assert list(iter(yaml)) == list(iter(yaml._values.values())) @pytest.mark.parametrize('old_config', [ None, @@ -274,9 +270,12 @@ class TestYaml: @pytest.mark.parametrize('line, text, exception', [ ('%', 'While parsing', 'while scanning a directive'), - ('settings: 42', 'While loading data', "'settings' object is not a dict"), - ('foo: 42', 'While loading data', + ('settings: 42\nconfig_version: 1', + 'While loading data', "'settings' object is not a dict"), + ('foo: 42\nconfig_version: 1', 'While loading data', "Toplevel object does not contain 'settings' key"), + ('settings: {}', 'While loading data', + "Toplevel object does not contain 'config_version' key"), ('42', 'While loading data', "Toplevel object is not a dict"), ]) def test_invalid(self, yaml, autoconfig, line, text, exception): @@ -385,7 +384,7 @@ class TestConfigPyModules: confpy.write_qbmodule() confpy.read() expected = {'normal': {',a': 'message-info foo'}} - assert config.instance._values['bindings.commands'] == expected + assert config.instance.get_obj('bindings.commands') == expected assert "qbmodule" not in sys.modules.keys() assert tmpdir not in sys.path @@ -451,7 +450,7 @@ class TestConfigPy: def test_set(self, confpy, line): confpy.write(line) confpy.read() - assert config.instance._values['colors.hints.bg'] == 'red' + assert config.instance.get_obj('colors.hints.bg') == 'red' @pytest.mark.parametrize('set_first', [True, False]) @pytest.mark.parametrize('get_line', [ @@ -478,7 +477,7 @@ class TestConfigPy: confpy.write(line) confpy.read() expected = {mode: {',a': 'message-info foo'}} - assert config.instance._values['bindings.commands'] == expected + assert config.instance.get_obj('bindings.commands') == expected def test_bind_freshly_defined_alias(self, confpy): """Make sure we can bind to a new alias. @@ -494,14 +493,14 @@ class TestConfigPy: confpy.write("config.bind('H', 'message-info back')") confpy.read() expected = {'normal': {'H': 'message-info back'}} - assert config.instance._values['bindings.commands'] == expected + assert config.instance.get_obj('bindings.commands') == expected def test_bind_none(self, confpy): confpy.write("c.bindings.commands = None", "config.bind(',x', 'nop')") confpy.read() expected = {'normal': {',x': 'nop'}} - assert config.instance._values['bindings.commands'] == expected + assert config.instance.get_obj('bindings.commands') == expected @pytest.mark.parametrize('line, key, mode', [ ('config.unbind("o")', 'o', 'normal'), @@ -511,14 +510,14 @@ class TestConfigPy: confpy.write(line) confpy.read() expected = {mode: {key: None}} - assert config.instance._values['bindings.commands'] == expected + assert config.instance.get_obj('bindings.commands') == expected def test_mutating(self, confpy): confpy.write('c.aliases["foo"] = "message-info foo"', 'c.aliases["bar"] = "message-info bar"') confpy.read() - assert config.instance._values['aliases']['foo'] == 'message-info foo' - assert config.instance._values['aliases']['bar'] == 'message-info bar' + assert config.instance.get_obj('aliases')['foo'] == 'message-info foo' + assert config.instance.get_obj('aliases')['bar'] == 'message-info bar' @pytest.mark.parametrize('option, value', [ ('content.user_stylesheets', 'style.css'), @@ -532,7 +531,7 @@ class TestConfigPy: (config_tmpdir / 'style.css').ensure() confpy.write('c.{}.append("{}")'.format(option, value)) confpy.read() - assert config.instance._values[option][-1] == value + assert config.instance.get_obj(option)[-1] == value def test_oserror(self, tmpdir, data_tmpdir, config_tmpdir): with pytest.raises(configexc.ConfigFileErrors) as excinfo: @@ -653,7 +652,7 @@ class TestConfigPy: confpy.write("config.source({!r})".format(arg)) confpy.read() - assert not config.instance._values['content.javascript.enabled'] + assert not config.instance.get_obj('content.javascript.enabled') def test_source_errors(self, tmpdir, confpy): subfile = tmpdir / 'config' / 'subfile.py' @@ -697,7 +696,7 @@ class TestConfigPyWriter: name='opt', typ=configtypes.Int(), default='def', backends=[usertypes.Backend.QtWebEngine], raw_backends=None, description=desc) - options = [(opt, 'val')] + options = [(None, opt, 'val')] bindings = {'normal': {',x': 'message-info normal'}, 'caret': {',y': 'message-info caret'}} @@ -729,8 +728,8 @@ class TestConfigPyWriter: def test_binding_options_hidden(self): opt1 = configdata.DATA['bindings.default'] opt2 = configdata.DATA['bindings.commands'] - options = [(opt1, {'normal': {'x': 'message-info x'}}), - (opt2, {})] + options = [(None, opt1, {'normal': {'x': 'message-info x'}}), + (None, opt2, {})] writer = configfiles.ConfigPyWriter(options, bindings={}, commented=False) text = '\n'.join(writer._gen_lines()) @@ -742,7 +741,7 @@ class TestConfigPyWriter: name='opt', typ=configtypes.Int(), default='def', backends=[usertypes.Backend.QtWebEngine], raw_backends=None, description='Hello World') - options = [(opt, 'val')] + options = [(None, opt, 'val')] bindings = {'normal': {',x': 'message-info normal'}, 'caret': {',y': 'message-info caret'}} @@ -768,7 +767,7 @@ class TestConfigPyWriter: backends=[usertypes.Backend.QtWebEngine], raw_backends=None, description='All colors are beautiful!') - options = [(opt1, 'ask'), (opt2, 'rgb')] + options = [(None, opt1, 'ask'), (None, opt2, 'rgb')] writer = configfiles.ConfigPyWriter(options, bindings={}, commented=False) @@ -819,7 +818,7 @@ class TestConfigPyWriter: def test_defaults_work(self, confpy): """Get a config.py with default values and run it.""" - options = [(opt, opt.default) + options = [(None, opt, opt.default) for _name, opt in sorted(configdata.DATA.items())] bindings = dict(configdata.DATA['bindings.default'].default) writer = configfiles.ConfigPyWriter(options, bindings, commented=False) From fecebd6ced10347bfe305e55ba570b0278ff168e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:13:01 +0100 Subject: [PATCH 172/524] Start getting test_config.py to run --- tests/unit/config/test_config.py | 58 ++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index ce6e4e461..0011981b2 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -26,7 +26,8 @@ import pytest from PyQt5.QtCore import QObject from PyQt5.QtGui import QColor -from qutebrowser.config import config, configdata, configexc, configfiles +from qutebrowser.config import (config, configdata, configexc, configfiles, + configutils) from qutebrowser.utils import usertypes from qutebrowser.misc import objects @@ -303,9 +304,15 @@ class TestKeyConfig: class TestConfig: @pytest.fixture - def conf(self, config_tmpdir): - yaml_config = configfiles.YamlConfig() - return config.Config(yaml_config) + def conf(self, config_stub): + return config_stub + + @pytest.fixture + def yaml_value(self, conf): + """Fixture which provides a getter for a YAML value.""" + def getter(option): + return conf._yaml._values[option].get_for_url(fallback=False) + return getter def test_init_save_manager(self, conf, fake_save_manager): conf.init_save_manager(fake_save_manager) @@ -327,10 +334,10 @@ class TestConfig: monkeypatch.setattr(config.objects, 'backend', objects.NoBackend()) opt = conf.get_opt('tabs.show') conf._set_value(opt, 'never') - assert conf._values['tabs.show'] == 'never' + assert conf.get_obj('tabs.show') == 'never' @pytest.mark.parametrize('save_yaml', [True, False]) - def test_unset(self, conf, qtbot, save_yaml): + def test_unset(self, conf, qtbot, yaml_value, save_yaml): name = 'tabs.show' conf.set_obj(name, 'never', save_yaml=True) assert conf.get(name) == 'never' @@ -340,9 +347,9 @@ class TestConfig: assert conf.get(name) == 'always' if save_yaml: - assert name not in conf._yaml + assert yaml_value(name) is configutils.UNSET else: - assert conf._yaml[name] == 'never' + assert yaml_value(name) == 'never' def test_unset_never_set(self, conf, qtbot): name = 'tabs.show' @@ -358,13 +365,13 @@ class TestConfig: conf.unset('tabs') @pytest.mark.parametrize('save_yaml', [True, False]) - def test_clear(self, conf, qtbot, save_yaml): + def test_clear(self, conf, qtbot, yaml_value, save_yaml): name1 = 'tabs.show' name2 = 'content.plugins' conf.set_obj(name1, 'never', save_yaml=True) conf.set_obj(name2, True, save_yaml=True) - assert conf._values[name1] == 'never' - assert conf._values[name2] is True + assert conf.get_obj(name1) == 'never' + assert conf.get_obj(name2) is True with qtbot.waitSignals([conf.changed, conf.changed]) as blocker: conf.clear(save_yaml=save_yaml) @@ -373,16 +380,16 @@ class TestConfig: assert options == {name1, name2} if save_yaml: - assert name1 not in conf._yaml - assert name2 not in conf._yaml + assert yaml_value(name1) is configutils.UNSET + assert yaml_value(name2) is configutils.UNSET else: - assert conf._yaml[name1] == 'never' - assert conf._yaml[name2] is True + assert yaml_value(name1) == 'never' + assert yaml_value(name2) is True - def test_read_yaml(self, conf): - conf._yaml['content.plugins'] = True + def test_read_yaml(self, conf, yaml_value): + conf._yaml.set_obj('content.plugins', True) conf.read_yaml() - assert conf._values['content.plugins'] is True + assert conf.get_obj('content.plugins') is True def test_get_opt_valid(self, conf): assert conf.get_opt('tabs.show') == configdata.DATA['tabs.show'] @@ -399,7 +406,7 @@ class TestConfig: def test_get_bindings(self, config_stub, conf, value): """Test conf.get() with bindings which have missing keys.""" config_stub.val.aliases = {} - conf._values['bindings.commands'] = value + conf.set_obj('bindings.commands', value) assert conf.get('bindings.commands')['prompt'] == {} def test_get_mutable(self, conf): @@ -497,7 +504,7 @@ class TestConfig: def test_get_obj_unknown_mutable(self, conf): """Make sure we don't have unknown mutable types.""" - conf._values['aliases'] = set() # This would never happen + conf.set_obj('aliases', set()) # This would never happen with pytest.raises(AssertionError): conf.get_obj('aliases') @@ -509,16 +516,17 @@ class TestConfig: ('set_obj', True), ('set_str', 'true'), ]) - def test_set_valid(self, conf, qtbot, save_yaml, method, value): + def test_set_valid(self, conf, qtbot, yaml_value, + save_yaml, method, value): option = 'content.plugins' meth = getattr(conf, method) with qtbot.wait_signal(conf.changed): meth(option, value, save_yaml=save_yaml) - assert conf._values[option] is True + assert conf.get_obj(option) is True if save_yaml: - assert conf._yaml[option] is True + assert yaml_value(option) is True else: - assert option not in conf._yaml + assert yaml_value(option) is configutils.UNSET @pytest.mark.parametrize('method', ['set_obj', 'set_str']) def test_set_invalid(self, conf, qtbot, method): @@ -581,7 +589,7 @@ class TestContainer: def test_setattr_option(self, config_stub, container): container.content.cookies.store = False - assert config_stub._values['content.cookies.store'] is False + assert config_stub.get_obj('content.cookies.store') is False def test_confapi_errors(self, container): configapi = types.SimpleNamespace(errors=[]) From cea664e396fedc6458842bafe1ad296733dd8d8a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:13:31 +0100 Subject: [PATCH 173/524] Don't emit changed in unset if unneeded --- qutebrowser/config/config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 232c5d4c8..b29106a95 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -402,8 +402,9 @@ class Config(QObject): def unset(self, name, *, save_yaml=False, pattern=None): """Set the given setting back to its default.""" self.get_opt(name) # To check whether it exists - self._values[name].remove(pattern) - self.changed.emit(name) + changed = self._values[name].remove(pattern) + if changed: + self.changed.emit(name) if save_yaml: self._yaml.unset(name, pattern=pattern) From 1ada821092238ed59ccb2498adaf7a2eea39f333 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:22:26 +0100 Subject: [PATCH 174/524] Make sure config options exist --- qutebrowser/config/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index b29106a95..cc7f2b4c3 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -328,6 +328,7 @@ class Config(QObject): 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.get_opt(name) # To make sure it exists value = self._values[name].get_for_url(url) return self._maybe_copy(value) @@ -337,6 +338,7 @@ class Config(QObject): This gets the overridden value for a given pattern, or configutils.UNSET if no such override exists. """ + self.get_opt(name) # To make sure it exists value = self._values[name].get_for_pattern(pattern, fallback=False) return self._maybe_copy(value) @@ -347,6 +349,8 @@ class Config(QObject): Note that it's impossible to get a mutable object for an URL as we wouldn't know what pattern to apply. """ + self.get_opt(name) # To make sure it exists + # 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 From 463320b599b4735223a42ac8ce436a62e9609b0f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:22:43 +0100 Subject: [PATCH 175/524] Make test_config.py work --- tests/unit/config/test_config.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 0011981b2..4894d1dc6 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -422,7 +422,7 @@ class TestConfig: 'bindings.commands']) @pytest.mark.parametrize('mutable', [True, False]) @pytest.mark.parametrize('mutated', [True, False]) - def test_get_obj_mutable(self, conf, config_stub, qtbot, caplog, + def test_get_obj_mutable(self, conf, qtbot, caplog, option, mutable, mutated): """Make sure mutables are handled correctly. @@ -439,7 +439,7 @@ class TestConfig: (keyhint.blacklist). """ # Setting new value - obj = conf.get_obj(option, mutable=mutable) + obj = conf.get_mutable_obj(option) if mutable else conf.get_obj(option) with qtbot.assert_not_emitted(conf.changed): if option == 'content.headers.custom': old = {} @@ -461,7 +461,6 @@ class TestConfig: assert obj == new else: assert option == 'bindings.commands' - config_stub.val.aliases = {} old = {} new = {} assert obj == old @@ -492,9 +491,9 @@ class TestConfig: def test_get_mutable_twice(self, conf): """Get a mutable value twice.""" option = 'content.headers.custom' - obj = conf.get_obj(option, mutable=True) + obj = conf.get_mutable_obj(option) obj['X-Foo'] = 'fooval' - obj2 = conf.get_obj(option, mutable=True) + obj2 = conf.get_mutable_obj(option) obj2['X-Bar'] = 'barval' conf.update_mutables() @@ -504,9 +503,8 @@ class TestConfig: def test_get_obj_unknown_mutable(self, conf): """Make sure we don't have unknown mutable types.""" - conf.set_obj('aliases', set()) # This would never happen with pytest.raises(AssertionError): - conf.get_obj('aliases') + conf._maybe_copy(set()) def test_get_str(self, conf): assert conf.get_str('content.plugins') == 'false' @@ -534,7 +532,7 @@ class TestConfig: with pytest.raises(configexc.ValidationError): with qtbot.assert_not_emitted(conf.changed): meth('content.plugins', '42') - assert 'content.plugins' not in conf._values + assert not conf._values['content.plugins'] @pytest.mark.parametrize('method', ['set_obj', 'set_str']) def test_set_wrong_backend(self, conf, qtbot, monkeypatch, method): @@ -543,7 +541,7 @@ class TestConfig: with pytest.raises(configexc.BackendError): with qtbot.assert_not_emitted(conf.changed): meth('content.cookies.accept', 'all') - assert 'content.cookies.accept' not in conf._values + assert not conf._values['content.cookies.accept'] def test_dump_userconfig(self, conf): conf.set_obj('content.plugins', True) From 5978b7b35f4edac33248831d15ff4e136d67d136 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:31:07 +0100 Subject: [PATCH 176/524] config: Improve tests for non-existent options --- tests/unit/config/test_config.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 4894d1dc6..b9ea51e82 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -360,10 +360,6 @@ class TestConfig: assert conf.get(name) == 'always' - def test_unset_unknown(self, conf): - with pytest.raises(configexc.NoOptionError): - conf.unset('tabs') - @pytest.mark.parametrize('save_yaml', [True, False]) def test_clear(self, conf, qtbot, yaml_value, save_yaml): name1 = 'tabs.show' @@ -394,9 +390,21 @@ class TestConfig: def test_get_opt_valid(self, conf): assert conf.get_opt('tabs.show') == configdata.DATA['tabs.show'] - def test_get_opt_invalid(self, conf): + @pytest.mark.parametrize('code', [ + lambda c: c.get_opt('tabs'), + lambda c: c.get('tabs'), + lambda c: c.get_obj('tabs'), + lambda c: c.get_obj_for_pattern('tabs', pattern=None), + lambda c: c.get_mutable_obj('tabs'), + lambda c: c.get_str('tabs'), + + lambda c: c.set_obj('tabs', 42), + lambda c: c.set_str('tabs', '42'), + lambda c: c.unset('tabs'), + ]) + def test_no_option_error(self, conf, code): with pytest.raises(configexc.NoOptionError): - conf.get_opt('tabs') + code(conf) def test_get(self, conf): """Test conf.get() with a QColor (where get/get_obj is different).""" From 7fcb21573d3f555eeb8c7849608753f29886ebaa Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 08:51:47 +0100 Subject: [PATCH 177/524] Update backers file --- doc/backers.asciidoc | 61 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/doc/backers.asciidoc b/doc/backers.asciidoc index 2dd6f52b3..80f46fd6e 100644 --- a/doc/backers.asciidoc +++ b/doc/backers.asciidoc @@ -13,47 +13,75 @@ Thanks a lot to the following people who contributed to it: Gold sponsors ~~~~~~~~~~~~~ -TODO +- Iggy +- zwitschi +- 2x Anonymous Silver sponsors ~~~~~~~~~~~~~~~ -TODO +- https://benary.org[benaryorg] +- https://scratchbook.ch[Claude] +- Martin Tournoij +- http://supported.elsensohn.ch[Thomas Elsensohn] +- Christian Helbling +- Gavin Troy +- Chris King-Parra +- Tim Das Mool Wegener Other sponsors ~~~~~~~~~~~~~~ -TODO: people with t-shirts or higher pledge levels - - 7scan +- AMD1212 +- Alex - Alex Suykov - Alexey Zhikhartsev - Allan Nordhøy - Anirudh Sanjeev - Anssi Puustinen +- Anton Grensjö +- Aristaeus +- Armin Fisslthaler +- Ashley Hauck - Benedikt Steindorf - Bernardo Kuri - Blaise Duszynski - Bostan - Bruno Oliveira +- BunnyApocalypse +- Christian Kellermann - Colin Jacobs - Daniel Andersson +- Daniel Nelson +- Daniel P. Schmidt +- Daniel Salby - Danilo - David Beley - David Hollings +- David Keijser - David Parrish - Derin Yarsuvat - Dmytro Kostiuchenko +- Eero Kari +- Epictek +- Eric +- Faure Hu +- Ferus - Frederik Thorøe - G4v4g4i +- Granitosaurus - Gyula Teleki - H +- Heinz Bruhin - Hosaka +- Ihor Radchenko - Iordanis Grigoriou - Isaac Sandaljian - Jakub Podeszwik - Jamie Anderson - Jasper Woudenberg +- Jay Kamat - Jens Højgaard - Johannes - John Baber-Lucero @@ -61,9 +89,11 @@ TODO: people with t-shirts or higher pledge levels - Kenichiro Ito - Kenny Low - Lars Ivar Igesund +- Leulas - Lucas Aride Moulin - Ludovic Chabant - Lukas Gierth +- Magnus Lindström - Marulkan - Matthew Chun-Lum - Matthew Cronen @@ -80,7 +110,10 @@ TODO: people with t-shirts or higher pledge levels - Peter Rice - Philipp Middendorf - Pkill9 +- PluMGMK - Prescott +- ProXicT +- Ram-Z - Robotichead - Roshless - Ryan Ellis @@ -90,35 +123,53 @@ TODO: people with t-shirts or higher pledge levels - Sean Herman - Sebastian Frysztak - Shelby Cruver +- Simon Désaulniers - SirCmpwn - Soham Pal +- Stephan Jauernick - Stewart Webb - Sven Reinecke +- Timothée Floure - Tom Bass +- Tom Kirchner - Tomas Slusny - Tomasz Kramkowski - Tommy Thomas +- Tuscan +- Ulrich Pötter - Vasilij Schneidermann - Vlaaaaaaad +- XTaran +- Z2h-A6n +- ayekat - beanieuptop +- cee +- craftyguy - demure +- dlangevi +- epon - evenorbert - fishss - gsnewmark - guillermohs9 +- hernani - hubcaps +- jnphilipp - lobachevsky - neodarz - nihlaeth - notbenh +- nyctea +- ongy - patrick suwanvithaya - pyratebeard +- p≡p foundation - randm_dave - sabreman - toml - vimja - wiz -- 44 Anonymous +- 48 Anonymous 2016 ---- From 316b4b53408ec5ee22c1d20e0128e4ee32979abd Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 19 Feb 2018 21:32:51 +0100 Subject: [PATCH 178/524] Add new files to PERFECT_FILES --- scripts/dev/check_coverage.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index ea971c28c..0e79a6e02 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -143,6 +143,8 @@ PERFECT_FILES = [ 'config/configinit.py'), ('tests/unit/config/test_configcommands.py', 'config/configcommands.py'), + ('tests/unit/config/test_configutils.py', + 'config/configutils.py'), ('tests/unit/utils/test_qtutils.py', 'utils/qtutils.py'), @@ -164,6 +166,8 @@ PERFECT_FILES = [ 'utils/error.py'), ('tests/unit/utils/test_javascript.py', 'utils/javascript.py'), + ('tests/unit/utils/test_urlmatch.py', + 'utils/urlmatch.py'), (None, 'completion/models/util.py'), From ab02fcb116e6b0f0b09d30489979b2cbb6a0fd13 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 06:21:11 +0100 Subject: [PATCH 179/524] configutils.Values: Add __repr__ --- qutebrowser/config/configutils.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index cf5569b61..e70a9326b 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -23,6 +23,8 @@ import attr +from qutebrowser.utils import utils + class _UnsetObject: @@ -74,6 +76,10 @@ class Values: self.opt = opt self._values = [] + def __repr__(self): + return utils.get_repr(self, opt=self.opt, values=self._values, + constructor=True) + def __str__(self): """Get the values as human-readable string.""" if not self: From 50c847562f0669c6ce6f8e45e6203689eebf2590 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 06:21:25 +0100 Subject: [PATCH 180/524] configutils.Values: Make it possible to pass values --- qutebrowser/config/configutils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index e70a9326b..05bebf286 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -72,9 +72,9 @@ class Values: opt: The Option being customized. """ - def __init__(self, opt): + def __init__(self, opt, values=None): self.opt = opt - self._values = [] + self._values = values or [] def __repr__(self): return utils.get_repr(self, opt=self.opt, values=self._values, From 145a21449b59d5ffa97d7cc7f6d16769fb968fdd Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 06:22:32 +0100 Subject: [PATCH 181/524] configutils: Add first tests --- tests/unit/config/test_configutils.py | 60 +++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/unit/config/test_configutils.py diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py new file mode 100644 index 000000000..74a85edb2 --- /dev/null +++ b/tests/unit/config/test_configutils.py @@ -0,0 +1,60 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# 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 . + +import pytest + +from qutebrowser.config import configutils, configdata, configtypes +from qutebrowser.utils import urlmatch + + +def test_unset_object_identity(): + assert configutils._UnsetObject() is not configutils._UnsetObject() + assert configutils.UNSET is configutils.UNSET + + +def test_unset_object_repr(): + assert repr(configutils.UNSET) == '' + + +@pytest.fixture +def opt(): + return configdata.Option(name='example.option', typ=configtypes.String(), + default='default value', backends=None, + raw_backends=None, description=None) + +@pytest.fixture +def values(opt): + pattern = urlmatch.UrlPattern('*://www.example.com/') + scoped_values = [configutils.ScopedValue('global value', None), + configutils.ScopedValue('example value', pattern)] + return configutils.Values(opt, scoped_values) + + +def test_repr(opt, values): + expected = ("qutebrowser.config.configutils.Values(opt={!r}, " + "values=[ScopedValue(value='global value', pattern=None), " + "ScopedValue(value='example value', pattern=qutebrowser.utils." + "urlmatch.UrlPattern(pattern='*://www.example.com/'))])" + .format(opt)) + assert repr(values) == expected + + +def test_str_empty(opt): + values = configutils.Values(opt) + assert str(values) == 'example.option: ' From 685e3ffcfefd2e27d1151292b354a8af5c47b509 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 06:27:15 +0100 Subject: [PATCH 182/524] Fix and test UrlPattern/configutils.Values stringification --- qutebrowser/utils/urlmatch.py | 9 ++++++--- tests/unit/config/test_configutils.py | 8 ++++++++ tests/unit/utils/test_urlmatch.py | 13 +++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index c59db211b..d2f9c4fe6 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -107,6 +107,12 @@ class UrlPattern: # pylint: disable=protected-access return self._to_tuple() == other._to_tuple() + def __repr__(self): + return utils.get_repr(self, pattern=self._pattern, constructor=True) + + def __str__(self): + return self._pattern + def _fixup_pattern(self, pattern): """Make sure the given pattern is parseable by urllib.parse.""" if pattern.startswith('*:'): # Any scheme, but *:// is unparseable @@ -188,9 +194,6 @@ class UrlPattern: raise ParseError("Ports are unsupported with {} scheme".format( self._scheme)) - def __repr__(self): - return utils.get_repr(self, pattern=self._pattern, constructor=True) - def _matches_scheme(self, scheme): return self._scheme is None or self._scheme == scheme diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py index 74a85edb2..e961948d5 100644 --- a/tests/unit/config/test_configutils.py +++ b/tests/unit/config/test_configutils.py @@ -55,6 +55,14 @@ def test_repr(opt, values): assert repr(values) == expected +def test_str(values): + expected = [ + 'example.option = global value', + '*://www.example.com/: example.option = example value', + ] + assert str(values) == '\n'.join(expected) + + def test_str_empty(opt): values = configutils.Values(opt) assert str(values) == 'example.option: ' diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index ce300391b..83746dcca 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -517,3 +517,16 @@ def test_equal(text1, text2, equal): assert (pat1 == pat2) == equal assert (hash(pat1) == hash(pat2)) == equal + + +def test_repr(): + pat = urlmatch.UrlPattern('https://www.example.com/') + expected = ("qutebrowser.utils.urlmatch.UrlPattern(" + "pattern='https://www.example.com/')") + assert repr(pat) == expected + + +def test_str(): + text = 'https://www.example.com/' + pat = urlmatch.UrlPattern(text) + assert str(pat) == text From 63c77a4d76e07a89aa6a6415be900aa211953e08 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 06:47:04 +0100 Subject: [PATCH 183/524] urlmatch: Fix equality with non-UrlPattern types --- qutebrowser/utils/urlmatch.py | 2 ++ tests/unit/utils/test_urlmatch.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index d2f9c4fe6..6bded760b 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -104,6 +104,8 @@ class UrlPattern: return hash(self._to_tuple()) def __eq__(self, other): + if not isinstance(other, UrlPattern): + return NotImplemented # pylint: disable=protected-access return self._to_tuple() == other._to_tuple() diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 83746dcca..bda8c6eb0 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -519,6 +519,10 @@ def test_equal(text1, text2, equal): assert (hash(pat1) == hash(pat2)) == equal +def test_equal_string(): + assert urlmatch.UrlPattern("") != '' + + def test_repr(): pat = urlmatch.UrlPattern('https://www.example.com/') expected = ("qutebrowser.utils.urlmatch.UrlPattern(" From 19c00ff92af64e7da97dce46439a86509c6f065d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 07:11:23 +0100 Subject: [PATCH 184/524] configutils: Clean up comments --- qutebrowser/config/configutils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index 05bebf286..0d324bb47 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -114,8 +114,11 @@ class Values: self._values.append(scoped) def remove(self, pattern=None): - """Remove the value with the given pattern.""" - # FIXME:conf Should this ignore patterns which weren't found? + """Remove the value with the given pattern. + + If a matching pattern was removed, True is returned. + If no matching pattern was found, False is returned. + """ old_len = len(self._values) self._values = [v for v in self._values if v.pattern != pattern] return old_len != len(self._values) @@ -128,8 +131,6 @@ class Values: """Get the fallback global/default value.""" for scoped in self._values: if scoped.pattern is None: - # It's possible that the setting is only customized from the - # default for a given URL. return scoped.value if fallback: From 36f3e54e1d50cdee9dc63a0be80bc551d76ce28e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 07:11:34 +0100 Subject: [PATCH 185/524] Finish configutils tests --- tests/unit/config/test_configutils.py | 146 +++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 5 deletions(-) diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py index e961948d5..29305c6ed 100644 --- a/tests/unit/config/test_configutils.py +++ b/tests/unit/config/test_configutils.py @@ -19,6 +19,8 @@ import pytest +from PyQt5.QtCore import QUrl + from qutebrowser.config import configutils, configdata, configtypes from qutebrowser.utils import urlmatch @@ -39,13 +41,27 @@ def opt(): raw_backends=None, description=None) @pytest.fixture -def values(opt): - pattern = urlmatch.UrlPattern('*://www.example.com/') +def pattern(): + return urlmatch.UrlPattern('*://www.example.com/') + + +@pytest.fixture +def other_pattern(): + return urlmatch.UrlPattern('https://www.example.org/') + + +@pytest.fixture +def values(opt, pattern): scoped_values = [configutils.ScopedValue('global value', None), configutils.ScopedValue('example value', pattern)] return configutils.Values(opt, scoped_values) +@pytest.fixture +def empty_values(opt): + return configutils.Values(opt) + + def test_repr(opt, values): expected = ("qutebrowser.config.configutils.Values(opt={!r}, " "values=[ScopedValue(value='global value', pattern=None), " @@ -63,6 +79,126 @@ def test_str(values): assert str(values) == '\n'.join(expected) -def test_str_empty(opt): - values = configutils.Values(opt) - assert str(values) == 'example.option: ' +def test_str_empty(empty_values): + assert str(empty_values) == 'example.option: ' + + +def test_bool(values, empty_values): + assert values + assert not empty_values + + +def test_iter(values): + assert list(iter(values)) == list(iter(values._values)) + + +def test_add_existing(values): + values.add('new global value') + assert values.get_for_url() == 'new global value' + + +def test_add_new(values, other_pattern): + values.add('example.org value', other_pattern) + assert values.get_for_url() == 'global value' + example_com = QUrl('https://www.example.com/') + example_org = QUrl('https://www.example.org/') + assert values.get_for_url(example_com) == 'example value' + assert values.get_for_url(example_org) == 'example.org value' + + +def test_remove_existing(values, pattern): + removed = values.remove(pattern) + assert removed + + url = QUrl('https://www.example.com/') + assert values.get_for_url(url) == 'global value' + + +def test_remove_non_existing(values, other_pattern): + removed = values.remove(other_pattern) + assert not removed + + url = QUrl('https://www.example.com/') + assert values.get_for_url(url) == 'example value' + + +def test_clear(values): + assert values + values.clear() + assert not values + assert values.get_for_url(fallback=False) is configutils.UNSET + + +def test_get_matching(values): + url = QUrl('https://www.example.com/') + assert values.get_for_url(url, fallback=False) == 'example value' + + +def test_get_unset(empty_values): + assert empty_values.get_for_url(fallback=False) is configutils.UNSET + + +def test_get_no_global(empty_values, other_pattern): + empty_values.add('example.org value', pattern) + assert empty_values.get_for_url(fallback=False) is configutils.UNSET + + +def test_get_unset_fallback(empty_values): + assert empty_values.get_for_url() == 'default value' + + +def test_get_non_matching(values): + url = QUrl('https://www.example.ch/') + assert values.get_for_url(url, fallback=False) is configutils.UNSET + + +def test_get_non_matching_fallback(values): + url = QUrl('https://www.example.ch/') + assert values.get_for_url(url) == 'global value' + + +def test_get_multiple_matches(values): + """With multiple matching pattern, the last added should win.""" + all_pattern = urlmatch.UrlPattern('*://*/') + values.add('new value', all_pattern) + url = QUrl('https://www.example.com/') + assert values.get_for_url(url) == 'new value' + + +def test_get_matching_pattern(values, pattern): + assert values.get_for_pattern(pattern, fallback=False) == 'example value' + + +def test_get_unset_pattern(empty_values, pattern): + value = empty_values.get_for_pattern(pattern, fallback=False) + assert value is configutils.UNSET + + +def test_get_no_global_pattern(empty_values, pattern, other_pattern): + empty_values.add('example.org value', other_pattern) + value = empty_values.get_for_pattern(pattern, fallback=False) + assert value is configutils.UNSET + + +def test_get_unset_fallback_pattern(empty_values, pattern): + assert empty_values.get_for_pattern(pattern) == 'default value' + + +def test_get_non_matching_pattern(values, other_pattern): + value = values.get_for_pattern(other_pattern, fallback=False) + assert value is configutils.UNSET + + +def test_get_non_matching_fallback_pattern(values, other_pattern): + assert values.get_for_pattern(other_pattern) == 'global value' + + +def test_get_equivalent_patterns(empty_values): + """With multiple matching pattern, the last added should win.""" + pat1 = urlmatch.UrlPattern('https://www.example.com/') + pat2 = urlmatch.UrlPattern('https://www.example.com') + empty_values.add('pat1 value', pat1) + empty_values.add('pat2 value', pat2) + + assert empty_values.get_for_pattern(pat1) == 'pat1 value' + assert empty_values.get_for_pattern(pat2) == 'pat2 value' From d511c5436d94c4d0ba71044eff268a461b12bb86 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 09:44:39 +0100 Subject: [PATCH 186/524] Remove dead config code --- qutebrowser/config/config.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index cc7f2b4c3..95cd5723b 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -376,8 +376,6 @@ class Config(QObject): opt = self.get_opt(name) values = self._values[name] value = values.get_for_pattern(pattern) - if value is configutils.UNSET: - return value return opt.typ.to_str(value) def set_obj(self, name, value, *, pattern=None, save_yaml=False): From 05017c83d61150f645c6b49f3444b96bcf6bc437 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 09:44:46 +0100 Subject: [PATCH 187/524] Add more config tests --- tests/unit/config/test_config.py | 36 ++++++++++++++++++++++-- tests/unit/config/test_configcommands.py | 20 ++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index b9ea51e82..2a7e61e27 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -23,12 +23,12 @@ import types import unittest.mock import pytest -from PyQt5.QtCore import QObject +from PyQt5.QtCore import QObject, QUrl from PyQt5.QtGui import QColor from qutebrowser.config import (config, configdata, configexc, configfiles, configutils) -from qutebrowser.utils import usertypes +from qutebrowser.utils import usertypes, urlmatch from qutebrowser.misc import objects @@ -410,6 +410,14 @@ class TestConfig: """Test conf.get() with a QColor (where get/get_obj is different).""" assert conf.get('colors.completion.category.fg') == QColor('white') + def test_get_for_url(self, conf): + """Test conf.get() with an URL/pattern.""" + pattern = urlmatch.UrlPattern('*://example.com') + name = 'content.javascript.enabled' + conf.set_obj(name, False, pattern=pattern) + assert conf.get(name, url=QUrl('https://example.com/')) is True + + @pytest.mark.parametrize('value', [{}, {'normal': {'a': 'nop'}}]) def test_get_bindings(self, config_stub, conf, value): """Test conf.get() with bindings which have missing keys.""" @@ -514,6 +522,30 @@ class TestConfig: with pytest.raises(AssertionError): conf._maybe_copy(set()) + def test_copy_non_mutable(self, conf, mocker): + """Make sure no copies are done for non-mutable types.""" + spy = mocker.spy(config.copy, 'deepcopy') + conf.get_mutable_obj('content.plugins') + assert not spy.called + + def test_copy_mutable(self, conf, mocker): + """Make sure mutable types are only copied once.""" + spy = mocker.spy(config.copy, 'deepcopy') + conf.get_mutable_obj('bindings.commands') + spy.assert_called_once() + + def test_get_obj_for_pattern(self, conf): + pattern = urlmatch.UrlPattern('*://example.com') + name = 'content.javascript.enabled' + conf.set_obj(name, False, pattern=pattern) + assert conf.get_obj_for_pattern(name, pattern=pattern) is False + + def test_get_obj_for_pattern_no_match(self, conf): + pattern = urlmatch.UrlPattern('*://example.com') + name = 'content.javascript.enabled' + value = conf.get_obj_for_pattern(name, pattern=pattern) + assert value is configutils.UNSET + def test_get_str(self, conf): assert conf.get_str('content.plugins') == 'false' diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 945eb2bc7..5da8d36d1 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -26,7 +26,7 @@ from PyQt5.QtCore import QUrl from qutebrowser.config import configcommands, configutils from qutebrowser.commands import cmdexc -from qutebrowser.utils import usertypes +from qutebrowser.utils import usertypes, urlmatch from qutebrowser.misc import objects @@ -86,6 +86,24 @@ class TestSet: assert config_stub.get(option) == new_value assert yaml_value(option) == (configutils.UNSET if temp else new_value) + def test_set_with_pattern(self, monkeypatch, commands, config_stub): + monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit) + option = 'content.javascript.enabled' + + commands.set(0, option, 'false', url='*://example.com') + pattern = urlmatch.UrlPattern('*://example.com') + + assert config_stub.get(option) + assert not config_stub.get_obj_for_pattern(option, pattern=pattern) + + def test_set_invalid_pattern(self, monkeypatch, commands): + monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit) + option = 'content.javascript.enabled' + + with pytest.raises(cmdexc.CommandError, + match='Error while parsing :/: No scheme given'): + commands.set(0, option, 'false', url=':/') + @pytest.mark.parametrize('temp', [True, False]) def test_set_temp_override(self, commands, config_stub, yaml_value, temp): """Invoking :set twice. From 9c42d87e7d37aff83b7ffa1fa6ed27a664375bb5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 11:18:17 +0100 Subject: [PATCH 188/524] Add missing configutils test --- tests/unit/config/test_configutils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py index 29305c6ed..af90b760c 100644 --- a/tests/unit/config/test_configutils.py +++ b/tests/unit/config/test_configutils.py @@ -169,6 +169,10 @@ def test_get_matching_pattern(values, pattern): assert values.get_for_pattern(pattern, fallback=False) == 'example value' +def test_get_pattern_none(values, pattern): + assert values.get_for_pattern(None, fallback=False) == 'global value' + + def test_get_unset_pattern(empty_values, pattern): value = empty_values.get_for_pattern(pattern, fallback=False) assert value is configutils.UNSET From b9bb515b3bb618c169a3f8b822ecd76e4cee0956 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 11:40:24 +0100 Subject: [PATCH 189/524] Add missing configfiles tests --- tests/unit/config/test_configfiles.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 4606bc38b..d9d030501 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -105,7 +105,13 @@ class TestYaml: @pytest.mark.parametrize('old_config', [ None, - {'colors.hints.fg': {'global': 'magenta'}} + # Only global + {'colors.hints.fg': {'global': 'magenta'}}, + # Global and for pattern + {'content.javascript.enabled': + {'global': True, 'https://example.com/': False}}, + # Only for pattern + {'content.images': {'https://example.com/': False}}, ]) @pytest.mark.parametrize('insert', [True, False]) def test_yaml_config(self, yaml, autoconfig, old_config, insert): @@ -130,8 +136,16 @@ class TestYaml: print(lines) - if old_config is not None: + if old_config is None: + pass + elif 'colors.hints.fg' in old_config: assert data['colors.hints.fg'] == {'global': 'magenta'} + elif 'content.javascript.enabled' in old_config: + expected = {'global': True, 'https://example.com/': False} + assert data['content.javascript.enabled'] == expected + elif 'content.images' in old_config: + assert data['content.images'] == {'https://example.com/': False} + if insert: assert data['tabs.show'] == {'global': 'never'} From ae732157246d24b00c6c5fd73d8df70b3ff321b2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 11:43:41 +0100 Subject: [PATCH 190/524] Fix lint --- tests/unit/config/test_config.py | 4 +--- tests/unit/config/test_configfiles.py | 2 +- tests/unit/config/test_configutils.py | 1 + 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 2a7e61e27..66176b6ac 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -26,8 +26,7 @@ import pytest from PyQt5.QtCore import QObject, QUrl from PyQt5.QtGui import QColor -from qutebrowser.config import (config, configdata, configexc, configfiles, - configutils) +from qutebrowser.config import config, configdata, configexc, configutils from qutebrowser.utils import usertypes, urlmatch from qutebrowser.misc import objects @@ -417,7 +416,6 @@ class TestConfig: conf.set_obj(name, False, pattern=pattern) assert conf.get(name, url=QUrl('https://example.com/')) is True - @pytest.mark.parametrize('value', [{}, {'normal': {'a': 'nop'}}]) def test_get_bindings(self, config_stub, conf, value): """Test conf.get() with bindings which have missing keys.""" diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index d9d030501..d7d0b6173 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -27,7 +27,7 @@ import pytest from PyQt5.QtCore import QSettings from qutebrowser.config import (config, configfiles, configexc, configdata, - configtypes, configutils) + configtypes) from qutebrowser.utils import utils, usertypes diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py index af90b760c..ef91b13ee 100644 --- a/tests/unit/config/test_configutils.py +++ b/tests/unit/config/test_configutils.py @@ -40,6 +40,7 @@ def opt(): default='default value', backends=None, raw_backends=None, description=None) + @pytest.fixture def pattern(): return urlmatch.UrlPattern('*://www.example.com/') From f2bba2e4fa2aa0ce97d46fb8c9af8905c07dd9ef Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 12:25:59 +0100 Subject: [PATCH 191/524] Fix navigation handling --- qutebrowser/browser/webkit/webkittab.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 2decac683..815dbd286 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -765,6 +765,9 @@ class WebKitTab(browsertab.AbstractTab): @pyqtSlot(usertypes.NavigationRequest) def _on_navigation_request(self, navigation): super()._on_navigation_request(navigation) + if not navigation.accepted: + return + log.webview.debug("target {} override {}".format( self.data.open_target, self.data.override_target)) @@ -781,8 +784,7 @@ class WebKitTab(browsertab.AbstractTab): self.data.open_target = usertypes.ClickTarget.normal navigation.accepted = False - if (navigation.accepted and navigation.navigation_type != - navigation.Type.reloaded): + if navigation.navigation_type != navigation.Type.reloaded: webkitsettings.update_for_tab(self, navigation.url) def _connect_signals(self): From 96e8151ccef1ee4e497106678432e3025f39d6d2 Mon Sep 17 00:00:00 2001 From: Marco Zollinger Date: Tue, 20 Feb 2018 15:18:31 +0100 Subject: [PATCH 192/524] use up to date cheatsheet images from repo instead of qutebrowser.org --- README.asciidoc | 4 ++-- scripts/asciidoc2html.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.asciidoc b/README.asciidoc index aed2f61e2..c141aa0c3 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -44,8 +44,8 @@ Documentation In addition to the topics mentioned in this README, the following documents are available: -* https://qutebrowser.org/img/cheatsheet-big.png[Key binding cheatsheet]: + -image:https://qutebrowser.org/img/cheatsheet-small.png["qutebrowser key binding cheatsheet",link="https://qutebrowser.org/img/cheatsheet-big.png"] +* https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-big.png[Key binding cheatsheet]: + +image:https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-small.png["qutebrowser key binding cheatsheet",link="https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-big.png"] * link:doc/quickstart.asciidoc[Quick start guide] * https://www.shortcutfoo.com/app/dojos/qutebrowser[Free training course] to remember those key bindings * link:doc/faq.asciidoc[Frequently asked questions] diff --git a/scripts/asciidoc2html.py b/scripts/asciidoc2html.py index 1827dc2a3..c4af174b2 100755 --- a/scripts/asciidoc2html.py +++ b/scripts/asciidoc2html.py @@ -85,9 +85,9 @@ class AsciiDoc: # patch image links to use local copy replacements = [ - ("https://qutebrowser.org/img/cheatsheet-big.png", + ("https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-big.png", "qute://help/img/cheatsheet-big.png"), - ("https://qutebrowser.org/img/cheatsheet-small.png", + ("https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-small.png", "qute://help/img/cheatsheet-small.png") ] asciidoc_args = ['-a', 'source-highlighter=pygments'] From b3d788feadc7a4ac93168de1a944f7d9e548dac5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 15:46:05 +0100 Subject: [PATCH 193/524] Add YamlConfig._pop_object --- qutebrowser/config/configfiles.py | 57 +++++++++++++++---------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 7a555e6a5..f9a36ea31 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -134,6 +134,29 @@ class YamlConfig(QObject): """.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: @@ -148,44 +171,18 @@ class YamlConfig(QObject): desc = configexc.ConfigErrorDesc("While parsing", e) raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - 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]) - except (TypeError, AttributeError): - desc = configexc.ConfigErrorDesc("While loading data", - "Toplevel object is not a dict") - raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) + config_version = self._pop_object(yaml_data, 'config_version', int) 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. - - FIXME: conf migrate old settings - """ - try: - settings_obj = yaml_data.pop('settings') - except KeyError: - desc = configexc.ConfigErrorDesc( - "While loading data", - "Toplevel object does not contain 'settings' key") - raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - - if not isinstance(settings_obj, dict): - desc = configexc.ConfigErrorDesc( - "While loading data", - "'settings' object is not a dict") - raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - - return settings_obj + """Load the settings from the settings: key.""" + return self._pop_object(yaml_data, 'settings', dict) def _build_values(self, settings): """Build up self._values from the values in the given dict.""" From 03114ccf514db14aa2dde6cde7457a24a5311e59 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 16:14:06 +0100 Subject: [PATCH 194/524] Migrate YAML config files in old format --- qutebrowser/config/configfiles.py | 22 +++++++++------ tests/unit/config/test_configfiles.py | 40 ++++++++++++++++++++++----- tests/unit/config/test_configinit.py | 8 +++--- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index f9a36ea31..62e4d7970 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -80,7 +80,7 @@ class YamlConfig(QObject): VERSION: The current version number of the config file. """ - VERSION = 1 + VERSION = 2 changed = pyqtSignal() def __init__(self, parent=None): @@ -173,9 +173,12 @@ class YamlConfig(QObject): config_version = self._pop_object(yaml_data, 'config_version', int) - settings = self._load_settings_object(yaml_data) - - self._dirty = False + 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 settings = self._handle_migrations(settings) self._validate(settings) self._build_values(settings) @@ -184,9 +187,15 @@ class YamlConfig(QObject): """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.""" - # FIXME:conf test this for name, yaml_values in settings.items(): values = configutils.Values(configdata.DATA[name]) if 'global' in yaml_values: @@ -200,9 +209,6 @@ class YamlConfig(QObject): def _handle_migrations(self, settings): """Migrate older configs to the newest format.""" - # FIXME:conf handle per-URL settings - # FIXME:conf migrate from older format with global: key - # Simple renamed/deleted options for name in list(settings): if name in configdata.MIGRATIONS.renamed: diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index d7d0b6173..29314d87e 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -45,19 +45,25 @@ class AutoConfigHelper: def __init__(self, config_tmpdir): self.fobj = config_tmpdir / 'autoconfig.yml' - def write(self, values): - data = {'config_version': 1, 'settings': values} + def write_toplevel(self, data): with self.fobj.open('w', encoding='utf-8') as f: utils.yaml_dump(data, f) + def write(self, values): + data = {'config_version': 2, 'settings': values} + self.write_toplevel(data) + def write_raw(self, text): self.fobj.write_text(text, encoding='utf-8', ensure=True) - def read(self): + def read_toplevel(self): with self.fobj.open('r', encoding='utf-8') as f: data = utils.yaml_load(f) - assert data['config_version'] == 1 - return data['settings'] + assert data['config_version'] == 2 + return data + + def read(self): + return self.read_toplevel()['settings'] def read_raw(self): return self.fobj.read_text('utf-8') @@ -284,9 +290,9 @@ class TestYaml: @pytest.mark.parametrize('line, text, exception', [ ('%', 'While parsing', 'while scanning a directive'), - ('settings: 42\nconfig_version: 1', + ('settings: 42\nconfig_version: 2', 'While loading data', "'settings' object is not a dict"), - ('foo: 42\nconfig_version: 1', 'While loading data', + ('foo: 42\nconfig_version: 2', 'While loading data', "Toplevel object does not contain 'settings' key"), ('settings: {}', 'While loading data', "Toplevel object does not contain 'config_version' key"), @@ -304,6 +310,26 @@ class TestYaml: assert str(error.exception).splitlines()[0] == exception assert error.traceback is None + def test_legacy_migration(self, yaml, autoconfig, qtbot): + autoconfig.write_toplevel({ + 'config_version': 1, + 'global': {'content.javascript.enabled': True}, + }) + with qtbot.wait_signal(yaml.changed): + yaml.load() + + yaml._save() + + data = autoconfig.read_toplevel() + assert data == { + 'config_version': 2, + 'settings': { + 'content.javascript.enabled': { + 'global': True, + } + } + } + def test_oserror(self, yaml, autoconfig): autoconfig.fobj.ensure() autoconfig.fobj.chmod(0) diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index 230b64a54..c139ff6b9 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -123,19 +123,19 @@ class TestEarlyInit: 'settings:', ' colors.foobar:', ' global: magenta', - 'config_version: 1', + 'config_version: 2', ], 'wrong-type': [ 'settings:', ' tabs.position:', ' global: true', - 'config_version: 1', + 'config_version: 2', ], False: [ 'settings:', ' colors.hints.fg:', ' global: magenta', - 'config_version: 1', + 'config_version: 2', ], } text = '\n'.join(yaml_lines[invalid_yaml]) @@ -240,7 +240,7 @@ class TestEarlyInit: args.temp_settings = settings elif method == 'auto': autoconfig_file = config_tmpdir / 'autoconfig.yml' - lines = (["config_version: 1", "settings:"] + + lines = (["config_version: 2", "settings:"] + [" {}:\n global:\n '{}'".format(k, v) for k, v in settings]) autoconfig_file.write_text('\n'.join(lines), 'utf-8', ensure=True) From e482c768749ef1df1cb2a12b705b38336bd58d60 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 17:08:28 +0100 Subject: [PATCH 195/524] YamlConfig: Refuse to read a newer config version --- qutebrowser/config/configfiles.py | 7 ++++++- tests/unit/config/test_configfiles.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 62e4d7970..9d76d4ee5 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -172,13 +172,18 @@ class YamlConfig(QObject): 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) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 29314d87e..eadddf39b 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -330,6 +330,20 @@ class TestYaml: } } + def test_read_newer_version(self, yaml, autoconfig): + autoconfig.write_toplevel({ + 'config_version': 999, + 'settings': {}, + }) + with pytest.raises(configexc.ConfigFileErrors) as excinfo: + yaml.load() + + assert len(excinfo.value.errors) == 1 + error = excinfo.value.errors[0] + assert error.text == "While reading" + msg = "Can't read config from incompatible newer version" + assert error.exception == msg + def test_oserror(self, yaml, autoconfig): autoconfig.fobj.ensure() autoconfig.fobj.chmod(0) From 9685445559e557eb5ce57694a501894a4045feb9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 17:22:11 +0100 Subject: [PATCH 196/524] Fix issues with Python 3.5 --- qutebrowser/config/config.py | 2 +- tests/unit/config/test_config.py | 2 +- tests/unit/config/test_configinit.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 95cd5723b..9b90562c4 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -447,7 +447,7 @@ class Config(QObject): The changed config part as string. """ blocks = [] - for values in self: + for values in sorted(self, key=lambda v: v.opt.name): if values: blocks.append(str(values)) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 66176b6ac..4be4d8dec 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -530,7 +530,7 @@ class TestConfig: """Make sure mutable types are only copied once.""" spy = mocker.spy(config.copy, 'deepcopy') conf.get_mutable_obj('bindings.commands') - spy.assert_called_once() + spy.assert_called_once_with(mocker.ANY) def test_get_obj_for_pattern(self, conf): pattern = urlmatch.UrlPattern('*://example.com') diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index c139ff6b9..e7d217d8e 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -186,8 +186,8 @@ class TestEarlyInit: if config_py and load_autoconfig and not invalid_yaml: expected = [ - 'colors.hints.fg = magenta', 'colors.hints.bg = red', + 'colors.hints.fg = magenta', ] elif config_py: expected = ['colors.hints.bg = red'] From f8b1e7739da4253fd2bba3efe1d430b86eeffc4f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 17:23:24 +0100 Subject: [PATCH 197/524] Update docs --- doc/help/commands.asciidoc | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 9580fe578..0cd16259a 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -274,7 +274,8 @@ Set all settings back to their default. [[config-cycle]] === config-cycle -Syntax: +:config-cycle [*--temp*] [*--print*] 'option' ['values' ['values' ...]]+ +Syntax: +:config-cycle [*--url* 'url'] [*--temp*] [*--print*] + 'option' ['values' ['values' ...]]+ Cycle an option between multiple values. @@ -283,6 +284,7 @@ Cycle an option between multiple values. * +'values'+: The values to cycle through. ==== optional arguments +* +*-u*+, +*--url*+: The URL pattern to use. * +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed. * +*-p*+, +*--print*+: Print the value after setting. @@ -1110,7 +1112,7 @@ Save a session. [[set]] === set -Syntax: +:set [*--temp*] [*--print*] ['option'] ['value']+ +Syntax: +:set [*--temp*] [*--print*] [*--url* 'url'] ['option'] ['value']+ Set an option. @@ -1123,6 +1125,7 @@ If the option name ends with '?', the value of the option is shown instead. Usin ==== optional arguments * +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed. * +*-p*+, +*--print*+: Print the value after setting. +* +*-u*+, +*--url*+: The URL pattern to use. [[set-cmd-text]] === set-cmd-text From a3dfec20c18c87da8f49c991bb24c8953ce15730 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 17:55:40 +0100 Subject: [PATCH 198/524] Rename --url to --pattern --- doc/help/commands.asciidoc | 8 ++++---- qutebrowser/config/configcommands.py | 24 +++++++++++++----------- tests/unit/config/test_configcommands.py | 4 ++-- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 0cd16259a..58dfaaa16 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -274,7 +274,7 @@ Set all settings back to their default. [[config-cycle]] === config-cycle -Syntax: +:config-cycle [*--url* 'url'] [*--temp*] [*--print*] +Syntax: +:config-cycle [*--pattern* 'pattern'] [*--temp*] [*--print*] 'option' ['values' ['values' ...]]+ Cycle an option between multiple values. @@ -284,7 +284,7 @@ Cycle an option between multiple values. * +'values'+: The values to cycle through. ==== optional arguments -* +*-u*+, +*--url*+: The URL pattern to use. +* +*-u*+, +*--pattern*+: The URL pattern to use. * +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed. * +*-p*+, +*--print*+: Print the value after setting. @@ -1112,7 +1112,7 @@ Save a session. [[set]] === set -Syntax: +:set [*--temp*] [*--print*] [*--url* 'url'] ['option'] ['value']+ +Syntax: +:set [*--temp*] [*--print*] [*--pattern* 'pattern'] ['option'] ['value']+ Set an option. @@ -1125,7 +1125,7 @@ If the option name ends with '?', the value of the option is shown instead. Usin ==== optional arguments * +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed. * +*-p*+, +*--print*+: Print the value after setting. -* +*-u*+, +*--url*+: The URL pattern to use. +* +*-u*+, +*--pattern*+: The URL pattern to use. [[set-cmd-text]] === set-cmd-text diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 3e55cbd57..1c1b1c686 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -47,16 +47,16 @@ class ConfigCommands: except configexc.Error as e: raise cmdexc.CommandError(str(e)) - def _parse_pattern(self, url): - """Parse an URL argument to a pattern.""" - if url is None: + def _parse_pattern(self, pattern): + """Parse a pattern string argument to a pattern.""" + if pattern is None: return None try: - return urlmatch.UrlPattern(url) + return urlmatch.UrlPattern(pattern) except urlmatch.ParseError as e: raise cmdexc.CommandError("Error while parsing {}: {}" - .format(url, str(e))) + .format(pattern, str(e))) def _print_value(self, option, pattern): """Print the value of the given option.""" @@ -68,8 +68,9 @@ class ConfigCommands: @cmdutils.argument('option', completion=configmodel.option) @cmdutils.argument('value', completion=configmodel.value) @cmdutils.argument('win_id', win_id=True) + @cmdutils.argument('pattern', flag='u') def set(self, win_id, option=None, value=None, temp=False, print_=False, - *, url=None): + *, pattern=None): """Set an option. If the option name ends with '?', the value of the option is shown @@ -81,7 +82,7 @@ class ConfigCommands: Args: option: The name of the option. value: The value to set. - url: The URL pattern to use. + pattern: The URL pattern to use. temp: Set value temporarily until qutebrowser is closed. print_: Print the value after setting. """ @@ -95,7 +96,7 @@ class ConfigCommands: raise cmdexc.CommandError("Toggling values was moved to the " ":config-cycle command") - pattern = self._parse_pattern(url) + pattern = self._parse_pattern(pattern) if option.endswith('?') and option != '?': self._print_value(option[:-1], pattern=pattern) @@ -177,18 +178,19 @@ 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, url=None, temp=False, + @cmdutils.argument('pattern', flag='u') + def config_cycle(self, option, *values, pattern=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. + pattern: The URL pattern to use. temp: Set value temporarily until qutebrowser is closed. print_: Print the value after setting. """ - pattern = self._parse_pattern(url) + pattern = self._parse_pattern(pattern) with self._handle_config_error(): opt = self._config.get_opt(option) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 5da8d36d1..8d48c94f2 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -90,7 +90,7 @@ class TestSet: monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit) option = 'content.javascript.enabled' - commands.set(0, option, 'false', url='*://example.com') + commands.set(0, option, 'false', pattern='*://example.com') pattern = urlmatch.UrlPattern('*://example.com') assert config_stub.get(option) @@ -102,7 +102,7 @@ class TestSet: with pytest.raises(cmdexc.CommandError, match='Error while parsing :/: No scheme given'): - commands.set(0, option, 'false', url=':/') + commands.set(0, option, 'false', pattern=':/') @pytest.mark.parametrize('temp', [True, False]) def test_set_temp_override(self, commands, config_stub, yaml_value, temp): From 3ade923edb132e17e443ab805ba2412c20298e21 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 18:43:42 +0100 Subject: [PATCH 199/524] Add basic pattern support for config.py --- qutebrowser/config/configfiles.py | 24 +++++++---- tests/unit/config/test_configfiles.py | 62 ++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 9d76d4ee5..622e116fd 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -33,7 +33,7 @@ from PyQt5.QtCore import pyqtSignal, QObject, QSettings import qutebrowser from qutebrowser.config import configexc, config, configdata, configutils -from qutebrowser.utils import standarddir, utils, qtutils, log +from qutebrowser.utils import standarddir, utils, qtutils, log, urlmatch # The StateConfig instance @@ -308,6 +308,9 @@ class ConfigAPI: 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)) def finalize(self): """Do work which needs to be done after reading config.py.""" @@ -317,13 +320,15 @@ class ConfigAPI: with self._handle_error('reading', 'autoconfig.yml'): read_autoconfig() - def get(self, name): + def get(self, name, pattern=None): with self._handle_error('getting', name): - return self._config.get_mutable_obj(name) + urlpattern = urlmatch.UrlPattern(pattern) if pattern else None + return self._config.get_mutable_obj(name, pattern=urlpattern) - def set(self, name, value): + def set(self, name, value, pattern=None): with self._handle_error('setting', name): - self._config.set_obj(name, value) + urlpattern = urlmatch.UrlPattern(pattern) if pattern else None + self._config.set_obj(name, value, pattern=urlpattern) def bind(self, key, command, mode='normal'): with self._handle_error('binding', key): @@ -401,8 +406,7 @@ class ConfigPyWriter: def _gen_options(self): """Generate the options part of the config.""" - # FIXME:conf handle _pattern - for _pattern, opt, value in self._options: + for pattern, opt, value in self._options: if opt.name in ['bindings.commands', 'bindings.default']: continue @@ -421,7 +425,11 @@ class ConfigPyWriter: except KeyError: yield self._line("# - {}".format(val)) - yield self._line('c.{} = {!r}'.format(opt.name, value)) + 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): diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index eadddf39b..56ed1c635 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -28,7 +28,7 @@ from PyQt5.QtCore import QSettings from qutebrowser.config import (config, configfiles, configexc, configdata, configtypes) -from qutebrowser.utils import utils, usertypes +from qutebrowser.utils import utils, usertypes, urlmatch @pytest.fixture(autouse=True) @@ -500,16 +500,29 @@ class TestConfigPy: @pytest.mark.parametrize('line', [ 'c.colors.hints.bg = "red"', 'config.set("colors.hints.bg", "red")', + 'config.set("colors.hints.bg", "red", pattern=None)', ]) def test_set(self, confpy, line): confpy.write(line) confpy.read() assert config.instance.get_obj('colors.hints.bg') == 'red' + def test_set_with_pattern(self, confpy): + option = 'content.javascript.enabled' + pattern = 'https://www.example.com/' + + confpy.write('config.set({!r}, False, {!r})'.format(option, pattern)) + confpy.read() + + assert config.instance.get_obj(option) + assert not config.instance.get_obj_for_pattern( + option, pattern=urlmatch.UrlPattern(pattern)) + @pytest.mark.parametrize('set_first', [True, False]) @pytest.mark.parametrize('get_line', [ 'c.colors.hints.fg', 'config.get("colors.hints.fg")', + 'config.get("colors.hints.fg", pattern=None)', ]) def test_get(self, confpy, set_first, get_line): """Test whether getting options works correctly.""" @@ -523,6 +536,24 @@ class TestConfigPy: confpy.write('assert {} == "green"'.format(get_line)) confpy.read() + def test_get_with_pattern(self, confpy): + """Test whether we get a matching value with a pattern.""" + option = 'content.javascript.enabled' + pattern = 'https://www.example.com/' + config.instance.set_obj(option, False, + pattern=urlmatch.UrlPattern(pattern)) + confpy.write('assert config.get({!r})'.format(option), + 'assert not config.get({!r}, pattern={!r})' + .format(option, pattern)) + confpy.read() + + def test_get_with_pattern_no_match(self, confpy): + confpy.write( + 'val = config.get("content.images", "https://www.example.com")', + 'assert val is True', + ) + confpy.read() + @pytest.mark.parametrize('line, mode', [ ('config.bind(",a", "message-info foo")', 'normal'), ('config.bind(",a", "message-info foo", "prompt")', 'prompt'), @@ -672,6 +703,21 @@ class TestConfigPy: "'qt.args')") assert str(error.exception) == expected + @pytest.mark.parametrize('line, action', [ + ('config.get("content.images", "://")', 'getting'), + ('config.set("content.images", False, "://")', 'setting'), + ]) + def test_invalid_pattern(self, confpy, line, action): + confpy.write(line) + + error = confpy.read(error=True) + + assert error.text == ("While {} 'content.images' and parsing pattern" + .format(action)) + assert isinstance(error.exception, urlmatch.ParseError) + assert str(error.exception) == "No scheme given" + assert error.traceback is None + def test_multiple_errors(self, confpy): confpy.write("c.foo = 42", "config.set('foo', 42)", "1/0") @@ -862,6 +908,20 @@ class TestConfigPyWriter: """).lstrip() assert text == expected + def test_pattern(self): + opt = configdata.Option( + name='opt', typ=configtypes.BoolAsk(), default='ask', + backends=[usertypes.Backend.QtWebEngine], raw_backends=None, + description='Hello World') + options = [ + (urlmatch.UrlPattern('https://www.example.com/'), opt, 'ask'), + ] + writer = configfiles.ConfigPyWriter(options=options, bindings={}, + commented=False) + text = '\n'.join(writer._gen_lines()) + expected = "config.set('opt', 'ask', 'https://www.example.com/')" + assert expected in text + def test_write(self, tmpdir): pyfile = tmpdir / 'config.py' writer = configfiles.ConfigPyWriter(options=[], bindings={}, From de38566c1104646899b9677e6c50cf166296979f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 18:43:51 +0100 Subject: [PATCH 200/524] Update configuring.asciidoc with per-domain settings --- doc/help/configuring.asciidoc | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index bb43e1dfe..1dcbbe9a6 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -63,6 +63,10 @@ customizable. Using the link:commands.html#set[`:set`] command and command completion, you can quickly set settings interactively, for example `:set tabs.position left`. +Some settings are also customizable for a given +https://developer.chrome.com/apps/match_patterns[URL pattern] by doing e.g. +`:set --pattern=*://example.com/ content.images false`. + To get more help about a setting, use e.g. `:help tabs.position`. To bind and unbind keys, you can use the link:commands.html#bind[`:bind`] and @@ -147,7 +151,6 @@ prefix to preserve backslashes) or a Python regex object: If you want to read a setting, you can use the `c` object to do so as well: `c.colors.tabs.even.bg = c.colors.tabs.odd.bg`. - Using strings for setting names ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -171,6 +174,17 @@ To read a setting, use the `config.get` method: color = config.get('colors.completion.fg') ---- +Per-domain settings +~~~~~~~~~~~~~~~~~~~ + +Using `config.set`, some settings are also customizable for a given +https://developer.chrome.com/apps/match_patterns[URL pattern]: + +[source,python] +---- +config.set('content.images', False, '*://example.com/') +---- + Binding keys ~~~~~~~~~~~~ From 439d51875f2ec4fcef56eafef1740e7d0c865741 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 20:54:26 +0100 Subject: [PATCH 201/524] Add config.pattern() --- doc/help/configuring.asciidoc | 9 ++++++++ qutebrowser/config/config.py | 16 +++++++++---- qutebrowser/config/configfiles.py | 10 ++++++++ tests/unit/config/test_config.py | 6 +++++ tests/unit/config/test_configfiles.py | 33 +++++++++++++++++++-------- 5 files changed, 59 insertions(+), 15 deletions(-) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 1dcbbe9a6..266315d56 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -185,6 +185,15 @@ https://developer.chrome.com/apps/match_patterns[URL pattern]: config.set('content.images', False, '*://example.com/') ---- +Alternatively, you can use `with config.pattern(...) as p:` to get a shortcut +similar to `c.` which is scoped to the given domain: + +[source,python] +---- +with config.pattern('*://example.com/') as p: + p.content.images = False +---- + Binding keys ~~~~~~~~~~~~ diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 9b90562c4..010eeb3d5 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -466,16 +466,21 @@ class ConfigContainer: _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, configapi=None, prefix=''): + def __init__(self, config, configapi=None, prefix='', pattern=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): return utils.get_repr(self, constructor=True, config=self._config, - configapi=self._configapi, prefix=self._prefix) + configapi=self._configapi, prefix=self._prefix, + pattern=self._pattern) @contextlib.contextmanager def _handle_error(self, action, name): @@ -503,7 +508,7 @@ class ConfigContainer: if configdata.is_valid_prefix(name): return ConfigContainer(config=self._config, configapi=self._configapi, - prefix=name) + prefix=name, pattern=self._pattern) with self._handle_error('getting', name): if self._configapi is None: @@ -511,7 +516,8 @@ class ConfigContainer: return self._config.get(name) else: # access from config.py - return self._config.get_mutable_obj(name) + return self._config.get_mutable_obj( + name, pattern=self._pattern) def __setattr__(self, attr, value): """Set the given option in the config.""" @@ -521,7 +527,7 @@ class ConfigContainer: name = self._join(attr) with self._handle_error('setting', name): - self._config.set_obj(name, value) + self._config.set_obj(name, value, pattern=self._pattern) def _join(self, attr): """Get the prefix joined with the given attribute.""" diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 622e116fd..8b16c701a 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -348,6 +348,16 @@ class ConfigAPI: 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: diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 4be4d8dec..095bfa78d 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -637,6 +637,12 @@ class TestContainer: assert error.text == "While getting 'tabs.foobar'" assert str(error.exception) == "No option 'tabs.foobar'" + def test_pattern_no_configapi(self, config_stub): + pattern = urlmatch.UrlPattern('https://example.com/') + with pytest.raises(TypeError, + match="Can't use pattern without configapi!"): + config.ConfigContainer(config_stub, pattern=pattern) + class StyleObj(QObject): diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 56ed1c635..0e173e8ff 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -507,17 +507,29 @@ class TestConfigPy: confpy.read() assert config.instance.get_obj('colors.hints.bg') == 'red' - def test_set_with_pattern(self, confpy): + @pytest.mark.parametrize('template', [ + "config.set({opt!r}, False, {pattern!r})", + "with config.pattern({pattern!r}) as p: p.{opt} = False", + ]) + def test_set_with_pattern(self, confpy, template): option = 'content.javascript.enabled' pattern = 'https://www.example.com/' - confpy.write('config.set({!r}, False, {!r})'.format(option, pattern)) + confpy.write(template.format(opt=option, pattern=pattern)) confpy.read() assert config.instance.get_obj(option) assert not config.instance.get_obj_for_pattern( option, pattern=urlmatch.UrlPattern(pattern)) + def test_set_context_manager_global(self, confpy): + """When "with config.pattern" is used, "c." should still be global.""" + option = 'content.javascript.enabled' + confpy.write('with config.pattern("https://www.example.com/") as p:' + ' c.{} = False'.format(option)) + confpy.read() + assert not config.instance.get_obj(option) + @pytest.mark.parametrize('set_first', [True, False]) @pytest.mark.parametrize('get_line', [ 'c.colors.hints.fg', @@ -703,20 +715,21 @@ class TestConfigPy: "'qt.args')") assert str(error.exception) == expected - @pytest.mark.parametrize('line, action', [ - ('config.get("content.images", "://")', 'getting'), - ('config.set("content.images", False, "://")', 'setting'), + @pytest.mark.parametrize('line, text', [ + ('config.get("content.images", "://")', + "While getting 'content.images' and parsing pattern"), + ('config.set("content.images", False, "://")', + "While setting 'content.images' and parsing pattern"), + ('with config.pattern("://"): pass', + "Unhandled exception"), ]) - def test_invalid_pattern(self, confpy, line, action): + def test_invalid_pattern(self, confpy, line, text): confpy.write(line) - error = confpy.read(error=True) - assert error.text == ("While {} 'content.images' and parsing pattern" - .format(action)) + assert error.text == text assert isinstance(error.exception, urlmatch.ParseError) assert str(error.exception) == "No scheme given" - assert error.traceback is None def test_multiple_errors(self, confpy): confpy.write("c.foo = 42", "config.set('foo', 42)", "1/0") From 46aeb25e7eace7c53c1e4abec982b3f9d37c7d56 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 20:55:42 +0100 Subject: [PATCH 202/524] Fix lint --- tests/unit/utils/test_urlmatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index bda8c6eb0..a89913456 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -396,7 +396,7 @@ def test_ignore_missing_slashes(): def test_trailing_dot_domain(pattern, url): """Both patterns should match trailing dot and non trailing dot domains. - More information about this not obvious behaviour can be found in [1]. + More information about this not obvious behavior can be found in [1]. RFC 1738 [2] specifies clearly that the part of a URL is supposed to contain a fully qualified domain name: From 17b235b523427fe64a6343a67f90b5c723fdd478 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 22:29:21 +0100 Subject: [PATCH 203/524] Add error handling for parsing patterns from YAML --- qutebrowser/config/configfiles.py | 24 ++++++++++++++++++++++-- tests/unit/config/test_configfiles.py | 6 ++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 8b16c701a..11e059302 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -201,17 +201,37 @@ class YamlConfig(QObject): 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')) - # FIXME:conf what if yaml_values is not a dict... for pattern, value in yaml_values.items(): - values.add(value, pattern) + 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 _handle_migrations(self, settings): """Migrate older configs to the newest format.""" # Simple renamed/deleted options diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 0e173e8ff..728dbb794 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -297,6 +297,12 @@ class TestYaml: ('settings: {}', 'While loading data', "Toplevel object does not contain 'config_version' key"), ('42', 'While loading data', "Toplevel object is not a dict"), + ('settings: {"content.images": 42}\nconfig_version: 2', + "While parsing 'content.images'", "value is not a dict"), + ('settings: {"content.images": {"https://": true}}\nconfig_version: 2', + "While parsing pattern 'https://' for 'content.images'", "Pattern without host"), + ('settings: {"content.images": {true: true}}\nconfig_version: 2', + "While parsing 'content.images'", "pattern is not of type string"), ]) def test_invalid(self, yaml, autoconfig, line, text, exception): autoconfig.write_raw(line) From 18848315f5b810002dcdccbec6a1e43eb15531ef Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 22:45:16 +0100 Subject: [PATCH 204/524] urlmatch: Make it possible to leave off trailing slash --- qutebrowser/utils/urlmatch.py | 8 +++++++- tests/unit/utils/test_urlmatch.py | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 6bded760b..0e83c7420 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -141,7 +141,13 @@ class UrlPattern: if self._scheme == 'about' and not parsed.path.strip(): raise ParseError("Pattern without path") - self._path = None if parsed.path == '/*' else parsed.path + if parsed.path == '/*': + self._path = None + elif parsed.path == '': + # We want to make it possible to leave off a trailing slash. + self._path = '/' + else: + self._path = parsed.path def _init_host(self, parsed): """Parse the host from the given URL. diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index a89913456..9622d7794 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -389,6 +389,13 @@ def test_ignore_missing_slashes(): assert not pattern1.matches(url2) +def test_trailing_slash(): + """Contrary to Chromium, we allow to leave off a trailing slash.""" + url = QUrl('http://www.example.com/') + pattern = urlmatch.UrlPattern('http://www.example.com') + assert pattern.matches(url) + + @pytest.mark.parametrize('pattern', ['*://example.com/*', '*://example.com./*']) @pytest.mark.parametrize('url', ['http://example.com/', From 5fbd488fdf56eca8e5eaa87fe93e9137e30a0d24 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 22:45:29 +0100 Subject: [PATCH 205/524] Only change settings for main-frame navigations --- qutebrowser/browser/webengine/webenginetab.py | 2 +- qutebrowser/browser/webkit/webkittab.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index fdabb195a..e047c9d1e 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -875,7 +875,7 @@ class WebEngineTab(browsertab.AbstractTab): @pyqtSlot(usertypes.NavigationRequest) def _on_navigation_request(self, navigation): super()._on_navigation_request(navigation) - if navigation.accepted: + if navigation.accepted and navigation.is_main_frame: webenginesettings.update_for_tab(self, navigation.url) def _connect_signals(self): diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 815dbd286..73a2f2648 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -784,7 +784,7 @@ class WebKitTab(browsertab.AbstractTab): self.data.open_target = usertypes.ClickTarget.normal navigation.accepted = False - if navigation.navigation_type != navigation.Type.reloaded: + if navigation.is_main_frame: webkitsettings.update_for_tab(self, navigation.url) def _connect_signals(self): From 6c5876a494fbd6de4eb50f0e96acf9eab39af716 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 23:08:09 +0100 Subject: [PATCH 206/524] Fix tests broken by urlmatch trailing slash change --- tests/unit/config/test_config.py | 4 ++-- tests/unit/config/test_configutils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 095bfa78d..46559bffa 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -411,10 +411,10 @@ class TestConfig: def test_get_for_url(self, conf): """Test conf.get() with an URL/pattern.""" - pattern = urlmatch.UrlPattern('*://example.com') + pattern = urlmatch.UrlPattern('*://example.com/') name = 'content.javascript.enabled' conf.set_obj(name, False, pattern=pattern) - assert conf.get(name, url=QUrl('https://example.com/')) is True + assert conf.get(name, url=QUrl('https://example.com/')) is False @pytest.mark.parametrize('value', [{}, {'normal': {'a': 'nop'}}]) def test_get_bindings(self, config_stub, conf, value): diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py index ef91b13ee..0793b8271 100644 --- a/tests/unit/config/test_configutils.py +++ b/tests/unit/config/test_configutils.py @@ -201,7 +201,7 @@ def test_get_non_matching_fallback_pattern(values, other_pattern): def test_get_equivalent_patterns(empty_values): """With multiple matching pattern, the last added should win.""" pat1 = urlmatch.UrlPattern('https://www.example.com/') - pat2 = urlmatch.UrlPattern('https://www.example.com') + pat2 = urlmatch.UrlPattern('*://www.example.com/') empty_values.add('pat1 value', pat1) empty_values.add('pat2 value', pat2) From 0d4e20c39529a3ec180870e1f7662efca1724845 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 23:21:24 +0100 Subject: [PATCH 207/524] Whitelist config options which support URL patterns --- doc/help/settings.asciidoc | 40 ++++++++++++++++++++++++ qutebrowser/config/config.py | 1 + qutebrowser/config/configdata.py | 8 +++-- qutebrowser/config/configdata.yml | 20 ++++++++++++ qutebrowser/config/configexc.py | 9 ++++++ qutebrowser/config/configutils.py | 10 ++++++ qutebrowser/config/websettings.py | 8 ++--- scripts/dev/src2asciidoc.py | 2 ++ tests/unit/config/test_config.py | 8 +++++ tests/unit/config/test_configcommands.py | 10 ++++++ tests/unit/config/test_configexc.py | 6 ++++ tests/unit/config/test_configutils.py | 3 +- 12 files changed, 117 insertions(+), 8 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 3826e6e84..eebdc5072 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -1447,6 +1447,8 @@ Default: Enable support for the HTML 5 web application cache feature. An application cache acts like an HTTP cache in some sense. For documents that use the application cache via JavaScript, the loader engine will first ask the application cache for the contents, before hitting the network. +This setting supports URL patterns. + Type: <> Default: +pass:[true]+ @@ -1524,6 +1526,8 @@ This setting is only available with the QtWebKit backend. === content.dns_prefetch Try to pre-fetch DNS entries to speed up browsing. +This setting supports URL patterns. + Type: <> Default: +pass:[true]+ @@ -1535,6 +1539,8 @@ This setting is only available with the QtWebKit backend. Expand each subframe to its contents. This will flatten all the frames to become one scrollable page. +This setting supports URL patterns. + Type: <> Default: +pass:[false]+ @@ -1651,6 +1657,8 @@ Default: === content.hyperlink_auditing Enable hyperlink auditing (``). +This setting supports URL patterns. + Type: <> Default: +pass:[false]+ @@ -1659,6 +1667,8 @@ Default: +pass:[false]+ === content.images Load images automatically in web pages. +This setting supports URL patterns. + Type: <> Default: +pass:[true]+ @@ -1676,6 +1686,8 @@ Default: +pass:[true]+ Allow JavaScript to read from or write to the clipboard. With QtWebEngine, writing the clipboard as response to a user interaction is always allowed. +This setting supports URL patterns. + Type: <> Default: +pass:[false]+ @@ -1684,6 +1696,8 @@ Default: +pass:[false]+ === content.javascript.can_close_tabs Allow JavaScript to close tabs. +This setting supports URL patterns. + Type: <> Default: +pass:[false]+ @@ -1694,6 +1708,8 @@ This setting is only available with the QtWebKit backend. === content.javascript.can_open_tabs_automatically Allow JavaScript to open new tabs without user interaction. +This setting supports URL patterns. + Type: <> Default: +pass:[false]+ @@ -1702,6 +1718,8 @@ Default: +pass:[false]+ === content.javascript.enabled Enable JavaScript. +This setting supports URL patterns. + Type: <> Default: +pass:[true]+ @@ -1741,6 +1759,8 @@ Default: +pass:[true]+ === content.local_content_can_access_file_urls Allow locally loaded documents to access other local URLs. +This setting supports URL patterns. + Type: <> Default: +pass:[true]+ @@ -1749,6 +1769,8 @@ Default: +pass:[true]+ === content.local_content_can_access_remote_urls Allow locally loaded documents to access remote URLs. +This setting supports URL patterns. + Type: <> Default: +pass:[false]+ @@ -1757,6 +1779,8 @@ Default: +pass:[false]+ === content.local_storage Enable support for HTML 5 local storage and Web SQL. +This setting supports URL patterns. + Type: <> Default: +pass:[true]+ @@ -1817,6 +1841,8 @@ This setting is only available with the QtWebKit backend. === content.plugins Enable plugins in Web pages. +This setting supports URL patterns. + Type: <> Default: +pass:[false]+ @@ -1825,6 +1851,8 @@ Default: +pass:[false]+ === content.print_element_backgrounds Draw the background color and images also when the page is printed. +This setting supports URL patterns. + Type: <> Default: +pass:[true]+ @@ -1889,6 +1917,8 @@ Default: empty === content.webgl Enable WebGL. +This setting supports URL patterns. + Type: <> Default: +pass:[true]+ @@ -1906,6 +1936,8 @@ Default: +pass:[false]+ Monitor load requests for cross-site scripting attempts. Suspicious scripts will be blocked and reported in the inspector's JavaScript console. Enabling this feature might have an impact on performance. +This setting supports URL patterns. + Type: <> Default: +pass:[false]+ @@ -2379,6 +2411,8 @@ Default: +pass:[false]+ === input.links_included_in_focus_chain Include hyperlinks in the keyboard focus chain when tabbing. +This setting supports URL patterns. + Type: <> Default: +pass:[true]+ @@ -2406,6 +2440,8 @@ Default: +pass:[false]+ Enable spatial navigation. Spatial navigation consists in the ability to navigate between focusable elements in a Web page, such as hyperlinks and form controls, by using Left, Right, Up and Down arrow keys. For example, if the user presses the Right key, heuristics determine whether there is an element he might be trying to reach towards the right and which element he probably wants. +This setting supports URL patterns. + Type: <> Default: +pass:[false]+ @@ -2550,6 +2586,8 @@ Default: +pass:[false]+ Enable smooth scrolling for web pages. Note smooth scrolling does not work with the `:scroll-px` command. +This setting supports URL patterns. + Type: <> Default: +pass:[false]+ @@ -3137,6 +3175,8 @@ Default: +pass:[512]+ === zoom.text_only Apply the zoom factor on a frame only to the text or to all content. +This setting supports URL patterns. + Type: <> Default: +pass:[false]+ diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 010eeb3d5..33e04a90d 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -280,6 +280,7 @@ class Config(QObject): raise configexc.BackendError(opt.name, objects.backend) opt.typ.to_py(value) # for validation + self._values[opt.name].add(opt.typ.from_obj(value), pattern) self.changed.emit(opt.name) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 3e0a6d8b1..52ad123e1 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -48,6 +48,7 @@ class Option: backends = attr.ib() raw_backends = attr.ib() description = attr.ib() + supports_pattern = attr.ib(default=False) restart = attr.ib(default=False) @@ -197,7 +198,8 @@ def _read_yaml(yaml_data): migrations = Migrations() data = utils.yaml_load(yaml_data) - keys = {'type', 'default', 'desc', 'backend', 'restart'} + keys = {'type', 'default', 'desc', 'backend', 'restart', + 'supports_pattern'} for name, option in data.items(): if set(option.keys()) == {'renamed'}: @@ -223,7 +225,9 @@ def _read_yaml(yaml_data): backends=_parse_yaml_backends(name, backends), raw_backends=backends if isinstance(backends, dict) else None, description=option['desc'], - restart=option.get('restart', False)) + restart=option.get('restart', False), + supports_pattern=option.get('supports_pattern', False), + ) # Make sure no key shadows another. for key1 in parsed: diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 72450978b..2228bc1b4 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -240,6 +240,7 @@ content.cache.appcache: default: true type: Bool backend: QtWebKit + supports_pattern: true desc: >- Enable support for the HTML 5 web application cache feature. @@ -298,12 +299,14 @@ content.dns_prefetch: default: true type: Bool backend: QtWebKit + supports_pattern: true desc: Try to pre-fetch DNS entries to speed up browsing. content.frame_flattening: default: false type: Bool backend: QtWebKit + supports_pattern: true desc: >- Expand each subframe to its contents. @@ -459,12 +462,14 @@ content.host_blocking.whitelist: content.hyperlink_auditing: default: false type: Bool + supports_pattern: true desc: Enable hyperlink auditing (``). content.images: default: true type: Bool desc: Load images automatically in web pages. + supports_pattern: true content.javascript.alert: default: true @@ -474,6 +479,7 @@ content.javascript.alert: content.javascript.can_access_clipboard: default: false type: Bool + supports_pattern: true desc: >- Allow JavaScript to read from or write to the clipboard. @@ -484,16 +490,19 @@ content.javascript.can_close_tabs: default: false type: Bool backend: QtWebKit + supports_pattern: true desc: Allow JavaScript to close tabs. content.javascript.can_open_tabs_automatically: default: false type: Bool + supports_pattern: true desc: Allow JavaScript to open new tabs without user interaction. content.javascript.enabled: default: true type: Bool + supports_pattern: true desc: Enable JavaScript. content.javascript.log: @@ -536,16 +545,19 @@ content.javascript.prompt: content.local_content_can_access_remote_urls: default: false type: Bool + supports_pattern: true desc: Allow locally loaded documents to access remote URLs. content.local_content_can_access_file_urls: default: true type: Bool + supports_pattern: true desc: Allow locally loaded documents to access other local URLs. content.local_storage: default: true type: Bool + supports_pattern: true desc: Enable support for HTML 5 local storage and Web SQL. content.media_capture: @@ -583,6 +595,7 @@ content.pdfjs: content.plugins: default: false type: Bool + supports_pattern: true desc: Enable plugins in Web pages. content.print_element_backgrounds: @@ -591,6 +604,7 @@ content.print_element_backgrounds: backend: QtWebKit: true QtWebEngine: Qt 5.8 + supports_pattern: true desc: >- Draw the background color and images also when the page is printed. @@ -631,11 +645,13 @@ content.user_stylesheets: content.webgl: default: true type: Bool + supports_pattern: true desc: Enable WebGL. content.xss_auditing: type: Bool default: false + supports_pattern: true desc: >- Monitor load requests for cross-site scripting attempts. @@ -978,6 +994,7 @@ input.insert_mode.plugins: input.links_included_in_focus_chain: default: true type: Bool + supports_pattern: true desc: Include hyperlinks in the keyboard focus chain when tabbing. input.partial_timeout: @@ -1003,6 +1020,7 @@ input.rocker_gestures: input.spatial_navigation: default: false type: Bool + supports_pattern: true desc: >- Enable spatial navigation. @@ -1083,6 +1101,7 @@ scrolling.bar: scrolling.smooth: type: Bool default: false + supports_pattern: true desc: >- Enable smooth scrolling for web pages. @@ -1557,6 +1576,7 @@ zoom.text_only: type: Bool default: false backend: QtWebKit + supports_pattern: true desc: Apply the zoom factor on a frame only to the text or to all content. ## colors diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index 2067878b9..e08bec913 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -40,6 +40,15 @@ class BackendError(Error): "backend!".format(name, backend.name)) +class NoPatternError(Error): + + """Raised when the given setting does not support URL patterns.""" + + def __init__(self, name): + super().__init__("The {} setting does not support URL patterns!" + .format(name)) + + class ValidationError(Error): """Raised when a value for a config type was invalid. diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index 0d324bb47..96fc0f02d 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -24,6 +24,7 @@ import attr from qutebrowser.utils import utils +from qutebrowser.config import configexc class _UnsetObject: @@ -107,8 +108,14 @@ class Values: """Check whether this value is customized.""" return bool(self._values) + def _check_pattern_support(self, arg): + """Make sure patterns are supported if one was given.""" + if arg is not None and not self.opt.supports_pattern: + raise configexc.NoPatternError(self.opt.name) + def add(self, value, pattern=None): """Add a value with the given pattern to the list of values.""" + self._check_pattern_support(pattern) self.remove(pattern) scoped = ScopedValue(value, pattern) self._values.append(scoped) @@ -119,6 +126,7 @@ class Values: If a matching pattern was removed, True is returned. If no matching pattern was found, False is returned. """ + self._check_pattern_support(pattern) old_len = len(self._values) self._values = [v for v in self._values if v.pattern != pattern] return old_len != len(self._values) @@ -146,6 +154,7 @@ class Values: With fallback=True, the global/default setting is returned. With fallback=False, UNSET is returned. """ + self._check_pattern_support(url) if url is not None: for scoped in reversed(self._values): if scoped.pattern is not None and scoped.pattern.matches(url): @@ -165,6 +174,7 @@ class Values: With fallback=True, the global/default setting is returned. With fallback=False, UNSET is returned. """ + self._check_pattern_support(pattern) if pattern is not None: for scoped in reversed(self._values): if scoped.pattern == pattern: diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 0f057d865..eb6c2ce49 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -225,8 +225,9 @@ def update_for_tab(mappings, tab, url): for values in config.instance: if values.opt.name not in mappings: continue + if not values.opt.supports_pattern: + continue - # FIXME:conf handle settings != None with global/static setters mapping = mappings[values.opt.name] value = values.get_for_url(url, fallback=False) @@ -237,10 +238,7 @@ def update_for_tab(mappings, tab, url): settings = tab._widget.settings() # pylint: disable=protected-access if value is configutils.UNSET: - try: - mapping.unset(settings=settings) - except NotImplementedError: - pass + mapping.unset(settings=settings) else: mapping.set(value, settings=settings) diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py index 6307e3a5c..5fab901fc 100755 --- a/scripts/dev/src2asciidoc.py +++ b/scripts/dev/src2asciidoc.py @@ -419,6 +419,8 @@ def _generate_setting_option(f, opt): f.write(opt.description + "\n") if opt.restart: f.write("This setting requires a restart.\n") + if opt.supports_pattern: + f.write("\nThis setting supports URL patterns.\n") f.write("\n") typ = opt.typ.get_name().replace(',', ',') f.write('Type: <>\n'.format(typ=typ)) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 46559bffa..8a33cd393 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -581,6 +581,14 @@ class TestConfig: meth('content.cookies.accept', 'all') assert not conf._values['content.cookies.accept'] + @pytest.mark.parametrize('method', ['set_obj', 'set_str']) + def test_set_no_pattern(self, conf, method, qtbot): + meth = getattr(conf, method) + pattern = urlmatch.UrlPattern('https://www.example.com/') + with pytest.raises(configexc.NoPatternError): + with qtbot.assert_not_emitted(conf.changed): + meth('colors.statusbar.normal.bg', '#abcdef', pattern=pattern) + def test_dump_userconfig(self, conf): conf.set_obj('content.plugins', True) conf.set_obj('content.headers.custom', {'X-Foo': 'bar'}) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 8d48c94f2..27075e869 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -104,6 +104,16 @@ class TestSet: match='Error while parsing :/: No scheme given'): commands.set(0, option, 'false', pattern=':/') + def test_set_no_pattern(self, monkeypatch, commands): + """Run ':set --pattern=*://* colors.statusbar.normal.bg #abcdef. + + Should show an error as patterns are unsupported. + """ + with pytest.raises(cmdexc.CommandError, + match='does not support URL patterns'): + commands.set(0, 'colors.statusbar.normal.bg', '#abcdef', + pattern='*://*') + @pytest.mark.parametrize('temp', [True, False]) def test_set_temp_override(self, commands, config_stub, yaml_value, temp): """Invoking :set twice. diff --git a/tests/unit/config/test_configexc.py b/tests/unit/config/test_configexc.py index 87f3abb6a..c41e02b4c 100644 --- a/tests/unit/config/test_configexc.py +++ b/tests/unit/config/test_configexc.py @@ -54,6 +54,12 @@ def test_backend_error(): assert str(e) == expected +def test_no_pattern_error(): + e = configexc.NoPatternError('foo') + expected = "The foo setting does not support URL patterns!" + assert str(e) == expected + + def test_desc_with_text(): """Test ConfigErrorDesc.with_text.""" old = configexc.ConfigErrorDesc("Error text", Exception("Exception text")) diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py index 0793b8271..587a0bd68 100644 --- a/tests/unit/config/test_configutils.py +++ b/tests/unit/config/test_configutils.py @@ -38,7 +38,8 @@ def test_unset_object_repr(): def opt(): return configdata.Option(name='example.option', typ=configtypes.String(), default='default value', backends=None, - raw_backends=None, description=None) + raw_backends=None, description=None, + supports_pattern=True) @pytest.fixture From ea6a5de374ebf0083f70b5b563bd460b03f01d3a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 20 Feb 2018 23:28:11 +0100 Subject: [PATCH 208/524] Update FIXMEs --- tests/unit/utils/test_urlmatch.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 9622d7794..9cad6033b 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -55,8 +55,8 @@ from qutebrowser.utils import urlmatch ("http:// /", "Pattern without host"), # Chromium: PARSE_ERROR_EMPTY_PATH - # FIXME: should we allow this or not? - # ("http://bar", "URLPattern::"), + # We deviate from Chromium and allow this for ease of use + # ("http://bar", "..."), # Chromium: PARSE_ERROR_INVALID_HOST ("http://\0www/", "May not contain NUL byte"), @@ -94,7 +94,7 @@ def test_invalid_patterns(pattern, error): ("http://foo:1234/bar", 1234), ("http://*.foo:1234/", 1234), ("http://*.foo:1234/bar", 1234), - # FIXME Why is this valid in Chromium? + # https://bugs.chromium.org/p/chromium/issues/detail?id=812543 # ("http://:1234/", 1234), ("http://foo:*/", None), ("file://foo:1234/bar", None), From cfeeb7460b12a3aa930e1ceb0b4f39ee12f2dd86 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 21 Feb 2018 09:14:49 +0100 Subject: [PATCH 209/524] Add docstrings to ConfigAPI --- qutebrowser/config/configfiles.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 11e059302..ba43e5015 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -319,6 +319,7 @@ class ConfigAPI: @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: @@ -337,24 +338,29 @@ class ConfigAPI: 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): self._keyconfig.bind(key, 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): self._keyconfig.unbind(key, mode=mode) From 13bd4dd05dde3c11332f51db912ca73340e4f28d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 21 Feb 2018 10:49:42 +0100 Subject: [PATCH 210/524] Clean up version.pastebin_url in pbclient fixture --- tests/unit/utils/test_version.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index eff67dcd8..2f6aa648f 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -957,7 +957,8 @@ def test_opengl_vendor(): def pbclient(stubs): http_stub = stubs.HTTPPostStub() client = pastebin.PastebinClient(http_stub) - return client + yield client + version.pastebin_url = None def test_pastebin_version(pbclient, message_mock, monkeypatch, qtbot): @@ -973,8 +974,6 @@ def test_pastebin_version(pbclient, message_mock, monkeypatch, qtbot): assert msg.text == "Version url test yanked to clipboard." assert version.pastebin_url == "test" - version.pastebin_url = None - def test_pastebin_version_twice(pbclient, monkeypatch): """Test whether calling pastebin_version twice sends no data.""" @@ -993,8 +992,6 @@ def test_pastebin_version_twice(pbclient, monkeypatch): assert pbclient.data is None assert version.pastebin_url == "test2" - version.pastebin_url = None - def test_pastebin_version_error(pbclient, caplog, message_mock, monkeypatch): """Test version.pastebin_version() with errors.""" From ada15510a7d325cb949b9b38c4fe08a304b37e22 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 21 Feb 2018 11:07:55 +0100 Subject: [PATCH 211/524] Update changelog --- doc/changelog.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 66d22ddca..2f1a1f1db 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -67,6 +67,7 @@ Changed input modes (input/passthrough) per tab. - More performance improvements when opening/closing many tabs. - The `:version` page now has a button to pastebin the information. +- Replacements like `{url}` can now be replaced as `{{url}}`. Fixed ~~~~~ From 2ffb1604d36d442223819e69f2522664c251d2ce Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Wed, 21 Feb 2018 10:01:27 -0500 Subject: [PATCH 212/524] Convert search to blue selection when entering caret mode --- qutebrowser/browser/webengine/webenginetab.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 4595f7a6e..2c87acdc3 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -218,6 +218,13 @@ class WebEngineCaret(browsertab.AbstractCaret): if mode != usertypes.KeyMode.caret: return + # Clear search, replace with blue selection + if self._tab.search.search_displayed: + # We are currently in search mode. + # convert the search to a blue selection so we can operate on it + # https://bugreports.qt.io/browse/QTBUG-60673 + self._tab.search.clear() + self._tab.run_js_async( javascript.assemble('caret', 'setPlatform', sys.platform)) self._js_call('setInitialCursor') From 5c4277aac81228713f3c4582ece7be9bc3cd35d9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 22 Feb 2018 07:33:46 +0100 Subject: [PATCH 213/524] Add some default keybindings for toggling scripts Those follow the following pattern: 1) "t" for 'toggle" 2) "s" for "scripts", upper-casing ("S") to make the toggle permanent 3) "h" for host, "H" for host with subdomains, "u" for the exact URL --- doc/help/settings.asciidoc | 6 ++++++ qutebrowser/commands/runners.py | 1 + qutebrowser/config/configdata.yml | 6 ++++++ 3 files changed, 13 insertions(+) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index eebdc5072..a9e7becd2 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -582,8 +582,14 @@ Default: * +pass:[sk]+: +pass:[set-cmd-text -s :bind]+ * +pass:[sl]+: +pass:[set-cmd-text -s :set -t]+ * +pass:[ss]+: +pass:[set-cmd-text -s :set]+ +* +pass:[tSH]+: +pass:[config-cycle -p -u *://*.{url:host}/* content.javascript.enabled ;; reload]+ +* +pass:[tSh]+: +pass:[config-cycle -p -u *://{url:host}/* content.javascript.enabled ;; reload]+ +* +pass:[tSu]+: +pass:[config-cycle -p -u {url} content.javascript.enabled ;; reload]+ * +pass:[th]+: +pass:[back -t]+ * +pass:[tl]+: +pass:[forward -t]+ +* +pass:[tsH]+: +pass:[config-cycle -p -t -u *://*.{url:host}/* content.javascript.enabled ;; reload]+ +* +pass:[tsh]+: +pass:[config-cycle -p -t -u *://{url:host}/* content.javascript.enabled ;; reload]+ +* +pass:[tsu]+: +pass:[config-cycle -p -t -u {url} content.javascript.enabled ;; reload]+ * +pass:[u]+: +pass:[undo]+ * +pass:[v]+: +pass:[enter-mode caret]+ * +pass:[wB]+: +pass:[set-cmd-text -s :bookmark-load -w]+ diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 16c790eca..0932a8b89 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -63,6 +63,7 @@ def replace_variables(win_id, arglist): QUrl.FullyEncoded | QUrl.RemovePassword), 'url:pretty': lambda: _current_url(tabbed_browser).toString( QUrl.DecodeReserved | QUrl.RemovePassword), + 'url:host': lambda: _current_url(tabbed_browser).host(), 'clipboard': utils.get_clipboard, 'primary': lambda: utils.get_clipboard(selection=True), } diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 2228bc1b4..d23682db8 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2329,6 +2329,12 @@ bindings.default: : tab-pin q: record-macro "@": run-macro + tsh: config-cycle -p -t -u *://{url:host}/* content.javascript.enabled ;; reload + tSh: config-cycle -p -u *://{url:host}/* content.javascript.enabled ;; reload + tsH: config-cycle -p -t -u *://*.{url:host}/* content.javascript.enabled ;; reload + tSH: config-cycle -p -u *://*.{url:host}/* content.javascript.enabled ;; reload + tsu: config-cycle -p -t -u {url} content.javascript.enabled ;; reload + tSu: config-cycle -p -u {url} content.javascript.enabled ;; reload insert: : open-editor : insert-text {primary} From eb4c806ddb4d88b7221c27d77aa3934268371af8 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 22 Feb 2018 08:04:23 +0100 Subject: [PATCH 214/524] Add URL pattern to settings output --- qutebrowser/config/configcommands.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 1c1b1c686..fb8bb9fe5 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -62,7 +62,11 @@ class ConfigCommands: """Print the value of the given option.""" with self._handle_config_error(): value = self._config.get_str(option, pattern=pattern) - message.info("{} = {}".format(option, value)) + + text = "{} = {}".format(option, value) + if pattern is not None: + text += " for {}".format(pattern) + message.info(text) @cmdutils.register(instance='config-commands') @cmdutils.argument('option', completion=configmodel.option) From c16c625febe19933063a6ab6b17e99e3fe8343c3 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Wed, 21 Feb 2018 22:15:26 -0500 Subject: [PATCH 215/524] Add basic tests for searching and caret mode --- tests/end2end/features/caret.feature | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/end2end/features/caret.feature b/tests/end2end/features/caret.feature index a3ff325f4..8be03dec2 100644 --- a/tests/end2end/features/caret.feature +++ b/tests/end2end/features/caret.feature @@ -320,3 +320,25 @@ Feature: Caret mode And the following tabs should be open: - data/caret.html - data/hello.txt (active) + + # Search + caret mode + + Scenario: yanking a searched line + When I run :leave-mode + And I run :search fiv + And I wait for "search found fiv" in the log + And I run :enter-mode caret + And I run :move-to-end-of-line + And I run :yank selection + Then the clipboard should contain "five six" + + Scenario: yanking a searched line with multiple matches + When I run :leave-mode + And I run :search w + And I wait for "search found w" in the log + And I run :search-next + And I wait for "next_result found w" in the log + And I run :enter-mode caret + And I run :move-to-end-of-line + And I run :yank selection + Then the clipboard should contain "wei drei" From 7ecbae765d8718d7ece0ba78cbef7542c115036f Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Thu, 22 Feb 2018 16:42:07 -0500 Subject: [PATCH 216/524] Use baseNode over anchorNode in follow-selected baseNode isn't documented anywhere that I can find, but it seems to be getting us what anchorNode used to get us. --- qutebrowser/javascript/webelem.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/javascript/webelem.js b/qutebrowser/javascript/webelem.js index d635de412..eb6ce2790 100644 --- a/qutebrowser/javascript/webelem.js +++ b/qutebrowser/javascript/webelem.js @@ -331,13 +331,13 @@ window._qutebrowser.webelem = (function() { // Function for returning a selection to python (so we can click it) funcs.find_selected_link = () => { - const elem = window.getSelection().anchorNode; + const elem = window.getSelection().baseNode; if (elem) { return serialize_elem(elem.parentNode); } const serialized_frame_elem = run_frames((frame) => { - const node = frame.window.getSelection().anchorNode; + const node = frame.window.getSelection().baseNode; if (node) { return serialize_elem(node.parentNode, frame); } From cb8d62866c08271e9fa66275d9da368b3f97f357 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Thu, 22 Feb 2018 18:33:46 -0500 Subject: [PATCH 217/524] Blacklist qt versions 5.8.0 through 5.9.4 for caret tests --- tests/end2end/features/caret.feature | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/end2end/features/caret.feature b/tests/end2end/features/caret.feature index 8be03dec2..e9cf54c8d 100644 --- a/tests/end2end/features/caret.feature +++ b/tests/end2end/features/caret.feature @@ -323,6 +323,8 @@ Feature: Caret mode # Search + caret mode + # https://bugreports.qt.io/browse/QTBUG-60673 + @qt!=5.8.0 @qt!=5.9.0 @qt!=5.9.1 @qt!=5.9.2 @qt!=5.9.3 @qt!=5.9.4 Scenario: yanking a searched line When I run :leave-mode And I run :search fiv @@ -332,6 +334,7 @@ Feature: Caret mode And I run :yank selection Then the clipboard should contain "five six" + @qt!=5.8.0 @qt!=5.9.0 @qt!=5.9.1 @qt!=5.9.2 @qt!=5.9.3 @qt!=5.9.4 Scenario: yanking a searched line with multiple matches When I run :leave-mode And I run :search w From 49ead32f1379c5fffe24dba083c980a2de732cf8 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 23 Feb 2018 06:31:49 +0100 Subject: [PATCH 218/524] Update urlmatch tests for Chromium changes See: https://chromium.googlesource.com/chromium/src/+/0ab1294c92dfab538b185f0f25f44f6491a49759%5E%21/ https://bugs.chromium.org/p/chromium/issues/detail?id=812543 --- tests/unit/utils/test_urlmatch.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 9cad6033b..cb4e41767 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -53,6 +53,7 @@ from qutebrowser.utils import urlmatch ("http://", "Pattern without host"), ("http:///", "Pattern without host"), ("http:// /", "Pattern without host"), + ("http://:1234/", "Pattern without host"), # Chromium: PARSE_ERROR_EMPTY_PATH # We deviate from Chromium and allow this for ease of use @@ -94,8 +95,8 @@ def test_invalid_patterns(pattern, error): ("http://foo:1234/bar", 1234), ("http://*.foo:1234/", 1234), ("http://*.foo:1234/bar", 1234), - # https://bugs.chromium.org/p/chromium/issues/detail?id=812543 - # ("http://:1234/", 1234), + ("http://*:1234/", 1234), + ("http://*:*/", None), ("http://foo:*/", None), ("file://foo:1234/bar", None), @@ -253,7 +254,7 @@ class TestMatchChromeUrls: class TestMatchAnything: - @pytest.fixture(params=['*://*/*', '']) + @pytest.fixture(params=['*://*/*', '*://*:*/*', '']) def up(self, request): return urlmatch.UrlPattern(request.param) From 3956f81e730463adcba05d92d0043155609aa422 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 23 Feb 2018 09:51:28 +0100 Subject: [PATCH 219/524] Refactor websettings This refactors the whole web(kit|engine|) settings mess a bit so there's a Web(Kit|Engine)Settings object for (non-static) settings set on a QWeb(Engine)Settings object in Qt. Everything else is set on module-level a bit less declaratively. The whole inheritance mess is gone, and we can now also construct a Web(Kit|Engine)Settings object for a given tab. Fixes #2701 --- qutebrowser/browser/browsertab.py | 1 + .../browser/webengine/webenginesettings.py | 389 ++++++++---------- qutebrowser/browser/webengine/webenginetab.py | 4 +- qutebrowser/browser/webkit/webkitsettings.py | 269 ++++++------ qutebrowser/browser/webkit/webkittab.py | 4 +- qutebrowser/config/websettings.py | 260 +++--------- .../webengine/test_webenginesettings.py | 7 +- 7 files changed, 375 insertions(+), 559 deletions(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 4fa65eee0..3fb700420 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -675,6 +675,7 @@ class AbstractTab(QWidget): self.printing._widget = widget self.action._widget = widget self.elements._widget = widget + self.settings._settings = widget.settings() self._install_event_filter() self.zoom.set_default() diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 18516c719..7d8d14dc6 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -17,9 +17,6 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -# We get various "abstract but not overridden" warnings -# pylint: disable=abstract-method - """Bridge from QWebEngineSettings to our own settings. Module attributes: @@ -36,7 +33,7 @@ from PyQt5.QtWebEngineWidgets import (QWebEngineSettings, QWebEngineProfile, from qutebrowser.browser import shared from qutebrowser.browser.webengine import spell -from qutebrowser.config import config, websettings +from qutebrowser.config import config, websettings, configutils from qutebrowser.utils import (utils, standarddir, javascript, qtutils, message, log, objreg) @@ -44,116 +41,135 @@ from qutebrowser.utils import (utils, standarddir, javascript, qtutils, default_profile = None # The QWebEngineProfile used for private (off-the-record) windows private_profile = None +# The global WebEngineSettings object +global_settings = None -class Base(websettings.Base): +class _SettingsWrapper: - """Base settings class with appropriate _get_global_settings.""" + """Expose a QWebEngineSettings interface which acts on all profiles.""" - def _get_global_settings(self): - return [default_profile.settings(), private_profile.settings()] + def __init__(self): + self._settings = [default_profile.settings(), + private_profile.settings()] + + def setAttribute(self, *args, **kwargs): + for settings in self._settings: + settings.setAttribute(*args, **kwargs) + + def setFontFamily(self, *args, **kwargs): + for settings in self._settings: + settings.setFontFamily(*args, **kwargs) + + def setFontSize(self, *args, **kwargs): + for settings in self._settings: + settings.setFontSize(*args, **kwargs) + + def setDefaultTextEncoding(self, *args, **kwargs): + for settings in self._settings: + settings.setDefaultTextEncoding(*args, **kwargs) -class Attribute(Base, websettings.Attribute): +class WebEngineSettings(websettings.AbstractSettings): - """A setting set via QWebEngineSettings::setAttribute.""" + """A wrapper for the config for QWebEngineSettings.""" - ENUM_BASE = QWebEngineSettings + _ATTRIBUTES = { + 'content.xss_auditing': + QWebEngineSettings.XSSAuditingEnabled, + 'content.images': + QWebEngineSettings.AutoLoadImages, + 'content.javascript.enabled': + QWebEngineSettings.JavascriptEnabled, + 'content.javascript.can_open_tabs_automatically': + QWebEngineSettings.JavascriptCanOpenWindows, + 'content.javascript.can_access_clipboard': + QWebEngineSettings.JavascriptCanAccessClipboard, + 'content.plugins': + QWebEngineSettings.PluginsEnabled, + 'content.hyperlink_auditing': + QWebEngineSettings.HyperlinkAuditingEnabled, + 'content.local_content_can_access_remote_urls': + QWebEngineSettings.LocalContentCanAccessRemoteUrls, + 'content.local_content_can_access_file_urls': + QWebEngineSettings.LocalContentCanAccessFileUrls, + 'content.webgl': + QWebEngineSettings.WebGLEnabled, + 'content.local_storage': + QWebEngineSettings.LocalStorageEnabled, + 'input.spatial_navigation': + QWebEngineSettings.SpatialNavigationEnabled, + 'input.links_included_in_focus_chain': + QWebEngineSettings.LinksIncludedInFocusChain, -class Setter(Base, websettings.Setter): + 'scrolling.smooth': + QWebEngineSettings.ScrollAnimatorEnabled, - """A setting set via a QWebEngineSettings setter method.""" + # Missing QtWebEngine attributes: + # - ScreenCaptureEnabled + # - Accelerated2dCanvasEnabled + # - AutoLoadIconsForPage + # - TouchIconsEnabled + # - FocusOnNavigationEnabled (5.8) + # - AllowRunningInsecureContent (5.8) + } - pass + _FONT_SIZES = { + 'fonts.web.size.minimum': + QWebEngineSettings.MinimumFontSize, + 'fonts.web.size.minimum_logical': + QWebEngineSettings.MinimumLogicalFontSize, + 'fonts.web.size.default': + QWebEngineSettings.DefaultFontSize, + 'fonts.web.size.default_fixed': + QWebEngineSettings.DefaultFixedFontSize, + } + _FONT_FAMILIES = { + 'fonts.web.family.standard': QWebEngineSettings.StandardFont, + 'fonts.web.family.fixed': QWebEngineSettings.FixedFont, + 'fonts.web.family.serif': QWebEngineSettings.SerifFont, + 'fonts.web.family.sans_serif': QWebEngineSettings.SansSerifFont, + 'fonts.web.family.cursive': QWebEngineSettings.CursiveFont, + 'fonts.web.family.fantasy': QWebEngineSettings.FantasyFont, -class FontFamilySetter(Base, websettings.FontFamilySetter): + # Missing QtWebEngine fonts: + # - PictographFont + } - """A setter for a font family. + # Mapping from WebEngineSettings::initDefaults in + # qtwebengine/src/core/web_engine_settings.cpp + _FONT_TO_QFONT = { + QWebEngineSettings.StandardFont: QFont.Serif, + QWebEngineSettings.FixedFont: QFont.Monospace, + QWebEngineSettings.SerifFont: QFont.Serif, + QWebEngineSettings.SansSerifFont: QFont.SansSerif, + QWebEngineSettings.CursiveFont: QFont.Cursive, + QWebEngineSettings.FantasyFont: QFont.Fantasy, + } - Gets the default value from QFont. - """ - - def __init__(self, font): - # Mapping from WebEngineSettings::initDefaults in - # qtwebengine/src/core/web_engine_settings.cpp - font_to_qfont = { - QWebEngineSettings.StandardFont: QFont.Serif, - QWebEngineSettings.FixedFont: QFont.Monospace, - QWebEngineSettings.SerifFont: QFont.Serif, - QWebEngineSettings.SansSerifFont: QFont.SansSerif, - QWebEngineSettings.CursiveFont: QFont.Cursive, - QWebEngineSettings.FantasyFont: QFont.Fantasy, + def __init__(self, settings): + super().__init__(settings) + # Attributes which don't exist in all Qt versions. + new_attributes = { + # Qt 5.8 + 'content.print_element_backgrounds': 'PrintElementBackgrounds', } - super().__init__(setter=QWebEngineSettings.setFontFamily, font=font, - qfont=font_to_qfont[font]) + for name, attribute in new_attributes.items(): + try: + value = getattr(QWebEngineSettings, attribute) + except AttributeError: + continue + self._ATTRIBUTES[name] = value -class DefaultProfileSetter(websettings.Base): - - """A setting set on the QWebEngineProfile.""" - - def __init__(self, setter, converter=None, default=websettings.UNSET): - super().__init__(default) - self._setter = setter - self._converter = converter - - def __repr__(self): - return utils.get_repr(self, setter=self._setter, constructor=True) - - def _set(self, value, settings=None): - if settings is not None: - raise ValueError("'settings' may not be set with " - "DefaultProfileSetters!") - - setter = getattr(default_profile, self._setter) - if self._converter is not None: - value = self._converter(value) - - setter(value) - - -class PersistentCookiePolicy(DefaultProfileSetter): - - """The content.cookies.store setting is different from other settings.""" - - def __init__(self): - super().__init__('setPersistentCookiesPolicy') - - def _set(self, value, settings=None): - if settings is not None: - raise ValueError("'settings' may not be set with " - "PersistentCookiePolicy!") - setter = getattr(QWebEngineProfile.defaultProfile(), self._setter) - setter( - QWebEngineProfile.AllowPersistentCookies if value else - QWebEngineProfile.NoPersistentCookies - ) - - -class DictionaryLanguageSetter(DefaultProfileSetter): - - """Sets paths to dictionary files based on language codes.""" - - def __init__(self): - super().__init__('setSpellCheckLanguages', default=[]) - - def _find_installed(self, code): - local_filename = spell.local_filename(code) - if not local_filename: - message.warning( - "Language {} is not installed - see scripts/dictcli.py " - "in qutebrowser's sources".format(code)) - return local_filename - - def _set(self, value, settings=None): - if settings is not None: - raise ValueError("'settings' may not be set with " - "DictionaryLanguageSetter!") - filenames = [self._find_installed(code) for code in value] - log.config.debug("Found dicts: {}".format(filenames)) - super()._set([f for f in filenames if f], settings) + def set_attribute(self, name, value): + attribute = self._ATTRIBUTES[name] + if value is configutils.UNSET: + self._settings.resetAttribute(attribute) + else: + self._settings.setAttribute(attribute, value) def _init_stylesheet(profile): @@ -210,9 +226,47 @@ def _set_http_headers(profile): profile.setHttpAcceptLanguage(accept_language) +def _set_http_cache_size(profile): + """Initialize the HTTP cache size for the given profile.""" + size = config.val.content.cache.size + if size is None: + size = 0 + else: + size = qtutils.check_overflow(size, 'int', fatal=False) + + # 0: automatically managed by QtWebEngine + profile.setHttpCacheMaximumSize(size) + + +def _set_persistent_cookie_policy(profile): + """Set the HTTP Cookie size for the given profile.""" + if config.val.content.cookies.store: + value = QWebEngineProfile.AllowPersistentCookies + else: + value = QWebEngineProfile.NoPersistentCookies + profile.setPersistentCookiesPolicy(value) + + +def _set_dictionary_language(profile): + filenames = [] + for code in config.val.spellcheck.languages or []: + local_filename = spell.local_filename(code) + if not local_filename: + message.warning( + "Language {} is not installed - see scripts/dictcli.py " + "in qutebrowser's sources".format(code)) + continue + + filenames.append(local_filename) + + log.config.debug("Found dicts: {}".format(filenames)) + profile.setSpellCheckLanguages(filenames) + + def _update_settings(option): """Update global settings when qwebsettings changed.""" - websettings.update_mappings(MAPPINGS, option) + global_settings.update_setting(option) + if option in ['scrolling.bar', 'content.user_stylesheets']: _init_stylesheet(default_profile) _init_stylesheet(private_profile) @@ -221,27 +275,46 @@ def _update_settings(option): 'content.headers.accept_language']: _set_http_headers(default_profile) _set_http_headers(private_profile) + elif option == 'content.cache.size': + _set_http_cache_size(default_profile) + _set_http_cache_size(private_profile) + elif (option == 'content.cookies.store' and + # https://bugreports.qt.io/browse/QTBUG-58650 + qtutils.version_check('5.9', compiled=False)): + _set_persistent_cookie_policy(default_profile) + # We're not touching the private profile's cookie policy. + elif option == 'spellcheck.languages' and qtutils.version_check('5.8'): + _set_dictionary_language(default_profile) + _set_dictionary_language(private_profile) + + +def _init_profile(profile): + """Init the given profile.""" + _init_stylesheet(profile) + _set_http_headers(profile) + _set_http_cache_size(profile) + profile.settings().setAttribute( + QWebEngineSettings.FullScreenSupportEnabled, True) + if qtutils.version_check('5.8'): + profile.setSpellCheckEnabled(True) + _set_dictionary_language(profile) def _init_profiles(): """Init the two used QWebEngineProfiles.""" global default_profile, private_profile + default_profile = QWebEngineProfile.defaultProfile() default_profile.setCachePath( os.path.join(standarddir.cache(), 'webengine')) default_profile.setPersistentStoragePath( os.path.join(standarddir.data(), 'webengine')) - _init_stylesheet(default_profile) - _set_http_headers(default_profile) + _init_profile(default_profile) + _set_persistent_cookie_policy(default_profile) private_profile = QWebEngineProfile() assert private_profile.isOffTheRecord() - _init_stylesheet(private_profile) - _set_http_headers(private_profile) - - if qtutils.version_check('5.8'): - default_profile.setSpellCheckEnabled(True) - private_profile.setSpellCheckEnabled(True) + _init_profile(private_profile) def inject_userscripts(): @@ -287,115 +360,13 @@ def init(args): os.environ['QTWEBENGINE_REMOTE_DEBUGGING'] = str(utils.random_port()) _init_profiles() - - # We need to do this here as a WORKAROUND for - # https://bugreports.qt.io/browse/QTBUG-58650 - if not qtutils.version_check('5.9', compiled=False): - PersistentCookiePolicy().set(config.val.content.cookies.store) - Attribute(QWebEngineSettings.FullScreenSupportEnabled).set(True) - - websettings.init_mappings(MAPPINGS) config.instance.changed.connect(_update_settings) - -def update_for_tab(tab, url): - websettings.update_for_tab(MAPPINGS, tab, url) + global global_settings + global_settings = WebEngineSettings(_SettingsWrapper()) + global_settings.init_settings() def shutdown(): # FIXME:qtwebengine do we need to do something for a clean shutdown here? pass - - -# Missing QtWebEngine attributes: -# - ScreenCaptureEnabled -# - Accelerated2dCanvasEnabled -# - AutoLoadIconsForPage -# - TouchIconsEnabled -# - FocusOnNavigationEnabled (5.8) -# - AllowRunningInsecureContent (5.8) -# -# Missing QtWebEngine fonts: -# - PictographFont - - -MAPPINGS = { - 'content.images': - Attribute(QWebEngineSettings.AutoLoadImages), - 'content.javascript.enabled': - Attribute(QWebEngineSettings.JavascriptEnabled), - 'content.javascript.can_open_tabs_automatically': - Attribute(QWebEngineSettings.JavascriptCanOpenWindows), - 'content.javascript.can_access_clipboard': - Attribute(QWebEngineSettings.JavascriptCanAccessClipboard), - 'content.plugins': - Attribute(QWebEngineSettings.PluginsEnabled), - 'content.hyperlink_auditing': - Attribute(QWebEngineSettings.HyperlinkAuditingEnabled), - 'content.local_content_can_access_remote_urls': - Attribute(QWebEngineSettings.LocalContentCanAccessRemoteUrls), - 'content.local_content_can_access_file_urls': - Attribute(QWebEngineSettings.LocalContentCanAccessFileUrls), - 'content.webgl': - Attribute(QWebEngineSettings.WebGLEnabled), - 'content.local_storage': - Attribute(QWebEngineSettings.LocalStorageEnabled), - 'content.cache.size': - # 0: automatically managed by QtWebEngine - DefaultProfileSetter('setHttpCacheMaximumSize', default=0, - converter=lambda val: - qtutils.check_overflow(val, 'int', fatal=False)), - 'content.xss_auditing': - Attribute(QWebEngineSettings.XSSAuditingEnabled), - 'content.default_encoding': - Setter(QWebEngineSettings.setDefaultTextEncoding), - - 'input.spatial_navigation': - Attribute(QWebEngineSettings.SpatialNavigationEnabled), - 'input.links_included_in_focus_chain': - Attribute(QWebEngineSettings.LinksIncludedInFocusChain), - - 'fonts.web.family.standard': - FontFamilySetter(QWebEngineSettings.StandardFont), - 'fonts.web.family.fixed': - FontFamilySetter(QWebEngineSettings.FixedFont), - 'fonts.web.family.serif': - FontFamilySetter(QWebEngineSettings.SerifFont), - 'fonts.web.family.sans_serif': - FontFamilySetter(QWebEngineSettings.SansSerifFont), - 'fonts.web.family.cursive': - FontFamilySetter(QWebEngineSettings.CursiveFont), - 'fonts.web.family.fantasy': - FontFamilySetter(QWebEngineSettings.FantasyFont), - 'fonts.web.size.minimum': - Setter(QWebEngineSettings.setFontSize, - args=[QWebEngineSettings.MinimumFontSize]), - 'fonts.web.size.minimum_logical': - Setter(QWebEngineSettings.setFontSize, - args=[QWebEngineSettings.MinimumLogicalFontSize]), - 'fonts.web.size.default': - Setter(QWebEngineSettings.setFontSize, - args=[QWebEngineSettings.DefaultFontSize]), - 'fonts.web.size.default_fixed': - Setter(QWebEngineSettings.setFontSize, - args=[QWebEngineSettings.DefaultFixedFontSize]), - - 'scrolling.smooth': - Attribute(QWebEngineSettings.ScrollAnimatorEnabled), -} - -try: - MAPPINGS['content.print_element_backgrounds'] = Attribute( - QWebEngineSettings.PrintElementBackgrounds) -except AttributeError: - # Added in Qt 5.8 - pass - - -if qtutils.version_check('5.8'): - MAPPINGS['spellcheck.languages'] = DictionaryLanguageSetter() - - -if qtutils.version_check('5.9', compiled=False): - # https://bugreports.qt.io/browse/QTBUG-58650 - MAPPINGS['content.cookies.store'] = PersistentCookiePolicy() diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index e047c9d1e..828fe9e56 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -604,6 +604,8 @@ class WebEngineTab(browsertab.AbstractTab): self.printing = WebEnginePrinting() self.elements = WebEngineElements(tab=self) self.action = WebEngineAction(tab=self) + # We're assigning settings in _set_widget + self.settings = webenginesettings.WebEngineSettings(settings=None) self._set_widget(widget) self._connect_signals() self.backend = usertypes.Backend.QtWebEngine @@ -876,7 +878,7 @@ class WebEngineTab(browsertab.AbstractTab): def _on_navigation_request(self, navigation): super()._on_navigation_request(navigation) if navigation.accepted and navigation.is_main_frame: - webenginesettings.update_for_tab(self, navigation.url) + self.settings.update_for_url(navigation.url) def _connect_signals(self): view = self._widget diff --git a/qutebrowser/browser/webkit/webkitsettings.py b/qutebrowser/browser/webkit/webkitsettings.py index 976432418..6ba15f62a 100644 --- a/qutebrowser/browser/webkit/webkitsettings.py +++ b/qutebrowser/browser/webkit/webkitsettings.py @@ -17,9 +17,6 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -# We get various "abstract but not overridden" warnings -# pylint: disable=abstract-method - """Bridge from QWebSettings to our own settings. Module attributes: @@ -32,94 +29,142 @@ import os.path from PyQt5.QtGui import QFont from PyQt5.QtWebKit import QWebSettings -from qutebrowser.config import config, websettings +from qutebrowser.config import config, websettings, configutils from qutebrowser.utils import standarddir, urlutils from qutebrowser.browser import shared -class Base(websettings.Base): - - """Base settings class with appropriate _get_global_settings.""" - - def _get_global_settings(self): - return [QWebSettings.globalSettings()] +# The global WebKitSettings object +global_settings = None -class Attribute(Base, websettings.Attribute): +class WebKitSettings(websettings.AbstractSettings): - """A setting set via QWebSettings::setAttribute.""" + """A wrapper for the config for QWebSettings.""" - ENUM_BASE = QWebSettings + _ATTRIBUTES = { + 'content.images': + [QWebSettings.AutoLoadImages], + 'content.javascript.enabled': + [QWebSettings.JavascriptEnabled], + 'content.javascript.can_open_tabs_automatically': + [QWebSettings.JavascriptCanOpenWindows], + 'content.javascript.can_close_tabs': + [QWebSettings.JavascriptCanCloseWindows], + 'content.javascript.can_access_clipboard': + [QWebSettings.JavascriptCanAccessClipboard], + 'content.plugins': + [QWebSettings.PluginsEnabled], + 'content.webgl': + [QWebSettings.WebGLEnabled], + 'content.hyperlink_auditing': + [QWebSettings.HyperlinkAuditingEnabled], + 'content.local_content_can_access_remote_urls': + [QWebSettings.LocalContentCanAccessRemoteUrls], + 'content.local_content_can_access_file_urls': + [QWebSettings.LocalContentCanAccessFileUrls], + 'content.dns_prefetch': + [QWebSettings.DnsPrefetchEnabled], + 'content.frame_flattening': + [QWebSettings.FrameFlatteningEnabled], + 'content.cache.appcache': + [QWebSettings.OfflineWebApplicationCacheEnabled], + 'content.local_storage': + [QWebSettings.LocalStorageEnabled, + QWebSettings.OfflineStorageDatabaseEnabled], + 'content.developer_extras': + [QWebSettings.DeveloperExtrasEnabled], + 'content.print_element_backgrounds': + [QWebSettings.PrintElementBackgrounds], + 'content.xss_auditing': + [QWebSettings.XSSAuditingEnabled], + + 'input.spatial_navigation': + [QWebSettings.SpatialNavigationEnabled], + 'input.links_included_in_focus_chain': + [QWebSettings.LinksIncludedInFocusChain], + + 'zoom.text_only': + [QWebSettings.ZoomTextOnly], + 'scrolling.smooth': + [QWebSettings.ScrollAnimatorEnabled], + } + + _FONT_SIZES = { + 'fonts.web.size.minimum': + QWebSettings.MinimumFontSize, + 'fonts.web.size.minimum_logical': + QWebSettings.MinimumLogicalFontSize, + 'fonts.web.size.default': + QWebSettings.DefaultFontSize, + 'fonts.web.size.default_fixed': + QWebSettings.DefaultFixedFontSize, + } + + _FONT_FAMILIES = { + 'fonts.web.family.standard': QWebSettings.StandardFont, + 'fonts.web.family.fixed': QWebSettings.FixedFont, + 'fonts.web.family.serif': QWebSettings.SerifFont, + 'fonts.web.family.sans_serif': QWebSettings.SansSerifFont, + 'fonts.web.family.cursive': QWebSettings.CursiveFont, + 'fonts.web.family.fantasy': QWebSettings.FantasyFont, + } + + # Mapping from QWebSettings::QWebSettings() in + # qtwebkit/Source/WebKit/qt/Api/qwebsettings.cpp + _FONT_TO_QFONT = { + QWebSettings.StandardFont: QFont.Serif, + QWebSettings.FixedFont: QFont.Monospace, + QWebSettings.SerifFont: QFont.Serif, + QWebSettings.SansSerifFont: QFont.SansSerif, + QWebSettings.CursiveFont: QFont.Cursive, + QWebSettings.FantasyFont: QFont.Fantasy, + } + + def set_attribute(self, name, value): + for attribute in self._ATTRIBUTES[name]: + if value is configutils.UNSET: + self._settings.resetAttribute(attribute) + else: + self._settings.setAttribute(attribute, value) -class Setter(Base, websettings.Setter): - - """A setting set via a QWebSettings setter method.""" - - pass +def _set_user_stylesheet(settings): + """Set the generated user-stylesheet.""" + stylesheet = shared.get_user_stylesheet().encode('utf-8') + url = urlutils.data_url('text/css;charset=utf-8', stylesheet) + settings.setUserStyleSheetUrl(url) -class StaticSetter(Base, websettings.StaticSetter): - - """A setting set via a static QWebSettings setter method.""" - - pass - - -class FontFamilySetter(Base, websettings.FontFamilySetter): - - """A setter for a font family. - - Gets the default value from QFont. - """ - - def __init__(self, font): - # Mapping from QWebSettings::QWebSettings() in - # qtwebkit/Source/WebKit/qt/Api/qwebsettings.cpp - font_to_qfont = { - QWebSettings.StandardFont: QFont.Serif, - QWebSettings.FixedFont: QFont.Monospace, - QWebSettings.SerifFont: QFont.Serif, - QWebSettings.SansSerifFont: QFont.SansSerif, - QWebSettings.CursiveFont: QFont.Cursive, - QWebSettings.FantasyFont: QFont.Fantasy, - } - super().__init__(setter=QWebSettings.setFontFamily, font=font, - qfont=font_to_qfont[font]) - - -class CookiePolicy(Base): - - """The ThirdPartyCookiePolicy setting is different from other settings.""" - - MAPPING = { +def _set_cookie_accept_policy(settings): + """Update the content.cookies.accept setting.""" + mapping = { 'all': QWebSettings.AlwaysAllowThirdPartyCookies, 'no-3rdparty': QWebSettings.AlwaysBlockThirdPartyCookies, 'never': QWebSettings.AlwaysBlockThirdPartyCookies, 'no-unknown-3rdparty': QWebSettings.AllowThirdPartyWithExistingCookies, } - - def _set(self, value, settings=None): - for obj in self._get_settings(settings): - obj.setThirdPartyCookiePolicy(self.MAPPING[value]) + value = config.val.content.cookies.accept + settings.setThirdPartyCookiePolicy(mapping[value]) -def _set_user_stylesheet(): - """Set the generated user-stylesheet.""" - stylesheet = shared.get_user_stylesheet().encode('utf-8') - url = urlutils.data_url('text/css;charset=utf-8', stylesheet) - QWebSettings.globalSettings().setUserStyleSheetUrl(url) +def _set_cache_maximum_pages(settings): + """Update the content.cache.maximum_pages setting.""" + value = config.val.content.cache.maximum_pages + settings.setMaximumPagesInCache(value) def _update_settings(option): """Update global settings when qwebsettings changed.""" + global_settings.update_setting(option) + + settings = QWebSettings.globalSettings() if option in ['scrollbar.hide', 'content.user_stylesheets']: - _set_user_stylesheet() - websettings.update_mappings(MAPPINGS, option) - - -def update_for_tab(tab, url): - websettings.update_for_tab(MAPPINGS, tab, url) + _set_user_stylesheet(settings) + elif option == 'content.cookies.accept': + _set_cookie_accept_policy(settings) + elif option == 'content.cache.maximum_pages': + _set_cache_maximum_pages(settings) def init(_args): @@ -135,92 +180,20 @@ def init(_args): QWebSettings.setOfflineStoragePath( os.path.join(data_path, 'offline-storage')) - websettings.init_mappings(MAPPINGS) - _set_user_stylesheet() + settings = QWebSettings.globalSettings() + _set_user_stylesheet(settings) + _set_cookie_accept_policy(settings) + _set_cache_maximum_pages(settings) + config.instance.changed.connect(_update_settings) + global global_settings + global_settings = WebKitSettings(QWebSettings.globalSettings()) + global_settings.init_settings() + def shutdown(): """Disable storage so removing tmpdir will work.""" QWebSettings.setIconDatabasePath('') QWebSettings.setOfflineWebApplicationCachePath('') QWebSettings.globalSettings().setLocalStoragePath('') - - -MAPPINGS = { - 'content.images': - Attribute(QWebSettings.AutoLoadImages), - 'content.javascript.enabled': - Attribute(QWebSettings.JavascriptEnabled), - 'content.javascript.can_open_tabs_automatically': - Attribute(QWebSettings.JavascriptCanOpenWindows), - 'content.javascript.can_close_tabs': - Attribute(QWebSettings.JavascriptCanCloseWindows), - 'content.javascript.can_access_clipboard': - Attribute(QWebSettings.JavascriptCanAccessClipboard), - 'content.plugins': - Attribute(QWebSettings.PluginsEnabled), - 'content.webgl': - Attribute(QWebSettings.WebGLEnabled), - 'content.hyperlink_auditing': - Attribute(QWebSettings.HyperlinkAuditingEnabled), - 'content.local_content_can_access_remote_urls': - Attribute(QWebSettings.LocalContentCanAccessRemoteUrls), - 'content.local_content_can_access_file_urls': - Attribute(QWebSettings.LocalContentCanAccessFileUrls), - 'content.cookies.accept': - CookiePolicy(), - 'content.dns_prefetch': - Attribute(QWebSettings.DnsPrefetchEnabled), - 'content.frame_flattening': - Attribute(QWebSettings.FrameFlatteningEnabled), - 'content.cache.appcache': - Attribute(QWebSettings.OfflineWebApplicationCacheEnabled), - 'content.local_storage': - Attribute(QWebSettings.LocalStorageEnabled, - QWebSettings.OfflineStorageDatabaseEnabled), - 'content.cache.maximum_pages': - StaticSetter(QWebSettings.setMaximumPagesInCache), - 'content.developer_extras': - Attribute(QWebSettings.DeveloperExtrasEnabled), - 'content.print_element_backgrounds': - Attribute(QWebSettings.PrintElementBackgrounds), - 'content.xss_auditing': - Attribute(QWebSettings.XSSAuditingEnabled), - 'content.default_encoding': - Setter(QWebSettings.setDefaultTextEncoding), - # content.user_stylesheets is handled separately - - 'input.spatial_navigation': - Attribute(QWebSettings.SpatialNavigationEnabled), - 'input.links_included_in_focus_chain': - Attribute(QWebSettings.LinksIncludedInFocusChain), - - 'fonts.web.family.standard': - FontFamilySetter(QWebSettings.StandardFont), - 'fonts.web.family.fixed': - FontFamilySetter(QWebSettings.FixedFont), - 'fonts.web.family.serif': - FontFamilySetter(QWebSettings.SerifFont), - 'fonts.web.family.sans_serif': - FontFamilySetter(QWebSettings.SansSerifFont), - 'fonts.web.family.cursive': - FontFamilySetter(QWebSettings.CursiveFont), - 'fonts.web.family.fantasy': - FontFamilySetter(QWebSettings.FantasyFont), - 'fonts.web.size.minimum': - Setter(QWebSettings.setFontSize, args=[QWebSettings.MinimumFontSize]), - 'fonts.web.size.minimum_logical': - Setter(QWebSettings.setFontSize, - args=[QWebSettings.MinimumLogicalFontSize]), - 'fonts.web.size.default': - Setter(QWebSettings.setFontSize, args=[QWebSettings.DefaultFontSize]), - 'fonts.web.size.default_fixed': - Setter(QWebSettings.setFontSize, - args=[QWebSettings.DefaultFixedFontSize]), - - 'zoom.text_only': - Attribute(QWebSettings.ZoomTextOnly), - 'scrolling.smooth': - Attribute(QWebSettings.ScrollAnimatorEnabled), -} diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 73a2f2648..5184550cd 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -645,6 +645,8 @@ class WebKitTab(browsertab.AbstractTab): self.printing = WebKitPrinting() self.elements = WebKitElements(tab=self) self.action = WebKitAction(tab=self) + # We're assigning settings in _set_widget + self.settings = webkitsettings.WebKitSettings(settings=None) self._set_widget(widget) self._connect_signals() self.backend = usertypes.Backend.QtWebKit @@ -785,7 +787,7 @@ class WebKitTab(browsertab.AbstractTab): navigation.accepted = False if navigation.is_main_frame: - webkitsettings.update_for_tab(self, navigation.url) + self.settings.update_for_url(navigation.url) def _connect_signals(self): view = self._widget diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index eb6c2ce49..98d5339fe 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -17,230 +17,96 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -# We get various "abstract but not overridden" warnings -# pylint: disable=abstract-method - """Bridge from QWeb(Engine)Settings to our own settings.""" from PyQt5.QtGui import QFont from qutebrowser.config import config, configutils -from qutebrowser.utils import log, utils, debug, usertypes +from qutebrowser.utils import log, usertypes from qutebrowser.misc import objects UNSET = object() -class Base: +class AbstractSettings: - """Base class for QWeb(Engine)Settings wrappers.""" + """Abstract base class for settings set via QWeb(Engine)Settings.""" - def __init__(self, default=UNSET): - self._default = default + _ATTRIBUTES = None + _FONT_SIZES = None + _FONT_FAMILIES = None + _FONT_TO_QFONT = None - def _get_global_settings(self): - """Get a list of global QWeb(Engine)Settings to use.""" + def __init__(self, settings): + self._settings = settings + + def set_attribute(self, name, value): + """Set the given QWebSettings/QWebEngineSettings attribute. + + If the value is configutils.UNSET, the value is reset instead. + """ raise NotImplementedError - def _get_settings(self, settings): - """Get a list of QWeb(Engine)Settings objects to use. + def set_font_size(self, name, value): + """Set the given QWebSettings/QWebEngineSettings font size.""" + assert value is not configutils.UNSET + self._settings.setFontSize(self._FONT_SIZES[name], value) - Args: - settings: The QWeb(Engine)Settings instance to use, or None to use - the global instance. + def set_font_family(self, name, value): + """Set the given QWebSettings/QWebEngineSettings font family. - Return: - A list of QWeb(Engine)Settings objects. The first one should be - used for reading. - """ - if settings is None: - return self._get_global_settings() - else: - return [settings] - - def set(self, value, settings=None): - """Set the value of this setting. - - Args: - value: The value to set, or None to restore the default. - settings: The QWeb(Engine)Settings instance to use, or None to use - the global instance. + With None (the default), QFont is used to get the default font for the + family. """ + assert value is not configutils.UNSET if value is None: - self.set_default(settings=settings) - else: - self._set(value, settings=settings) + font = QFont() + font.setStyleHint(self._FONT_TO_QFONT[self._FONT_FAMILIES[name]]) + value = font.defaultFamily() - def set_default(self, settings=None): - """Set the default value for this setting. + self._settings.setFontFamily(self._FONT_FAMILIES[name], value) - Not implemented for most settings. + def set_default_text_encoding(self, encoding): + """Set the default text encoding to use.""" + assert encoding is not configutils.UNSET + self._settings.setDefaultTextEncoding(encoding) + + def _update_setting(self, setting, value): + """Update the given setting/value. + + Unknown settings are ignored. """ - if self._default is UNSET: - raise ValueError("No default set for {!r}".format(self)) - else: - self._set(self._default, settings=settings) + if setting in self._ATTRIBUTES: + self.set_attribute(setting, value) + elif setting in self._FONT_SIZES: + self.set_font_size(setting, value) + elif setting in self._FONT_FAMILIES: + self.set_font_family(setting, value) + elif setting == 'content.default_encoding': + self.set_default_text_encoding(value) - def _set(self, value, settings): - """Inner function to set the value of this setting. + def update_setting(self, setting): + """Update the given setting.""" + value = config.instance.get(setting) + self._update_setting(setting, value) - Must be overridden by subclasses. + def update_for_url(self, url): + """Update settings customized for the given tab.""" + for values in config.instance: + if not values.opt.supports_pattern: + continue - Args: - value: The value to set. - settings: The QWeb(Engine)Settings instance to use, or None to use - the global instance. - """ - raise NotImplementedError + value = values.get_for_url(url, fallback=False) + log.config.debug("Updating for {}: {} = {}".format( + url.toDisplayString(), values.opt.name, value)) - def unset(self, settings=None): - """Unset a customized setting. + self._update_setting(values.opt.name, value) - Must be overridden by subclasses. - """ - raise NotImplementedError - - -class Attribute(Base): - - """A setting set via QWeb(Engine)Settings::setAttribute. - - Attributes: - self._attributes: A list of QWeb(Engine)Settings::WebAttribute members. - """ - - ENUM_BASE = None - - def __init__(self, *attributes, default=UNSET): - super().__init__(default=default) - self._attributes = list(attributes) - - def __repr__(self): - attributes = [debug.qenum_key(self.ENUM_BASE, attr) - for attr in self._attributes] - return utils.get_repr(self, attributes=attributes, constructor=True) - - def _set(self, value, settings=None): - for obj in self._get_settings(settings): - for attribute in self._attributes: - obj.setAttribute(attribute, value) - - def unset(self, settings=None): - for obj in self._get_settings(settings): - for attribute in self._attributes: - obj.resetAttribute(attribute) - - -class Setter(Base): - - """A setting set via a QWeb(Engine)Settings setter method. - - This will pass the QWeb(Engine)Settings instance ("self") as first argument - to the methods, so self._setter is the *unbound* method. - - Attributes: - _setter: The unbound QWeb(Engine)Settings method to set this value. - _args: An iterable of the arguments to pass to the setter (before the - value). - _unpack: Whether to unpack args (True) or pass them directly (False). - """ - - def __init__(self, setter, args=(), unpack=False, default=UNSET): - super().__init__(default=default) - self._setter = setter - self._args = args - self._unpack = unpack - - def __repr__(self): - return utils.get_repr(self, setter=self._setter, args=self._args, - unpack=self._unpack, constructor=True) - - def _set(self, value, settings=None): - for obj in self._get_settings(settings): - args = [obj] - args.extend(self._args) - if self._unpack: - args.extend(value) - else: - args.append(value) - self._setter(*args) - - -class StaticSetter(Setter): - - """A setting set via a static QWeb(Engine)Settings method. - - self._setter is the *bound* method. - """ - - def _set(self, value, settings=None): - if settings is not None: - raise ValueError("'settings' may not be set with StaticSetters!") - args = list(self._args) - if self._unpack: - args.extend(value) - else: - args.append(value) - self._setter(*args) - - -class FontFamilySetter(Setter): - - """A setter for a font family. - - Gets the default value from QFont. - """ - - def __init__(self, setter, font, qfont): - super().__init__(setter=setter, args=[font]) - self._qfont = qfont - - def set_default(self, settings=None): - font = QFont() - font.setStyleHint(self._qfont) - value = font.defaultFamily() - self._set(value, settings=settings) - - -def init_mappings(mappings): - """Initialize all settings based on a settings mapping.""" - for option, mapping in mappings.items(): - value = config.instance.get(option) - log.config.vdebug("Setting {} to {!r}".format(option, value)) - mapping.set(value) - - -def update_mappings(mappings, option): - """Update global settings when QWeb(Engine)Settings changed.""" - try: - mapping = mappings[option] - except KeyError: - return - value = config.instance.get(option) - mapping.set(value) - - -def update_for_tab(mappings, tab, url): - """Update settings customized for the given tab.""" - for values in config.instance: - if values.opt.name not in mappings: - continue - if not values.opt.supports_pattern: - continue - - mapping = mappings[values.opt.name] - - value = values.get_for_url(url, fallback=False) - log.config.debug("Updating for {}: {} = {}".format( - url.toDisplayString(), values.opt.name, value)) - - # FIXME:conf have a proper API for this. - settings = tab._widget.settings() # pylint: disable=protected-access - - if value is configutils.UNSET: - mapping.unset(settings=settings) - else: - mapping.set(value, settings=settings) + def init_settings(self): + """Set all supported settings correctly.""" + for setting in (list(self._ATTRIBUTES) + list(self._FONT_SIZES) + + list(self._FONT_FAMILIES)): + self.update_setting(setting) def init(args): diff --git a/tests/unit/browser/webengine/test_webenginesettings.py b/tests/unit/browser/webengine/test_webenginesettings.py index 995dec44a..1fbe38f00 100644 --- a/tests/unit/browser/webengine/test_webenginesettings.py +++ b/tests/unit/browser/webengine/test_webenginesettings.py @@ -32,7 +32,8 @@ def init_profiles(qapp, config_stub, cache_tmpdir, data_tmpdir): def test_big_cache_size(config_stub): """Make sure a too big cache size is handled correctly.""" config_stub.val.content.cache.size = 2 ** 63 - 1 - webenginesettings._update_settings('content.cache.size') + profile = webenginesettings.default_profile - size = webenginesettings.default_profile.httpCacheMaximumSize() - assert size == 2 ** 31 - 1 + webenginesettings._set_http_cache_size(profile) + + assert profile.httpCacheMaximumSize() == 2 ** 31 - 1 From 98b2b67b8b9e28308dc858e7e81687eb48b75a58 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 23 Feb 2018 15:08:07 +0100 Subject: [PATCH 220/524] Add tests for per-URL JavaScript settings --- tests/end2end/data/javascript/enabled.html | 16 ++++++++++++++++ tests/end2end/data/javascript/localstorage.html | 2 ++ tests/end2end/features/javascript.feature | 16 ++++++++++++++++ tests/end2end/fixtures/webserver_sub.py | 1 + 4 files changed, 35 insertions(+) create mode 100644 tests/end2end/data/javascript/enabled.html diff --git a/tests/end2end/data/javascript/enabled.html b/tests/end2end/data/javascript/enabled.html new file mode 100644 index 000000000..a25f02566 --- /dev/null +++ b/tests/end2end/data/javascript/enabled.html @@ -0,0 +1,16 @@ + + + + + + +

JavaScript is disabled

+ + + diff --git a/tests/end2end/data/javascript/localstorage.html b/tests/end2end/data/javascript/localstorage.html index 28a11f24f..12c17bbc9 100644 --- a/tests/end2end/data/javascript/localstorage.html +++ b/tests/end2end/data/javascript/localstorage.html @@ -7,8 +7,10 @@ try { localStorage.qute_test = "foo"; elem.innerHTML = "working"; + console.log("local storage is working"); } catch (e) { elem.innerHTML = "not working"; + console.log("local storage is not working"); } } diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature index c74811b4b..8428306cb 100644 --- a/tests/end2end/features/javascript.feature +++ b/tests/end2end/features/javascript.feature @@ -151,3 +151,19 @@ Feature: Javascript stuff And I run :greasemonkey-reload And I open data/hints/iframe.html Then the javascript message "Script is running on /data/hints/html/wrapped.html" should not be logged + + Scenario: Per-URL localstorage setting + When I set content.local_storage to false + And I run :set -u http://localhost:*/data2/* content.local_storage true + And I open data/javascript/localstorage.html + And I wait for "[*] local storage is not working" in the log + And I open data2/javascript/localstorage.html + Then the javascript message "local storage is working" should be logged + + Scenario: Per-URL JavaScript setting + When I set content.javascript.enabled to false + And I run :set -u http://localhost:*/data2/* content.javascript.enabled true + And I open data2/javascript/enabled.html + And I wait for "[*] JavaScript is enabled" in the log + And I open data/javascript/enabled.html + Then the page should contain the plaintext "JavaScript is disabled" diff --git a/tests/end2end/fixtures/webserver_sub.py b/tests/end2end/fixtures/webserver_sub.py index 4ec4619f7..75c080c34 100644 --- a/tests/end2end/fixtures/webserver_sub.py +++ b/tests/end2end/fixtures/webserver_sub.py @@ -48,6 +48,7 @@ def root(): @app.route('/data/') +@app.route('/data2/') # for per-URL settings def send_data(path): """Send a given data file to qutebrowser. From fc6a0dbe646722f6321aff685a60241c16e6b55b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 23 Feb 2018 17:29:17 +0100 Subject: [PATCH 221/524] Show a simple error page on loading errors without JS We can't tell what exactly the error is, but it's surely better than nothing. --- qutebrowser/browser/webengine/webenginetab.py | 27 ++++++++++--------- qutebrowser/config/websettings.py | 4 +++ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 828fe9e56..ce57e498e 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -730,6 +730,15 @@ class WebEngineTab(browsertab.AbstractTab): self.send_event(press_evt) self.send_event(release_evt) + def _show_error_page(self, url, error): + """Show an error page in the tab.""" + url_string = url.toDisplayString() + error_page = jinja.render( + 'error.html', + title="Error loading page: {}".format(url_string), + url=url_string, error=error) + self.set_html(error_page) + @pyqtSlot() def _on_history_trigger(self): try: @@ -778,13 +787,7 @@ class WebEngineTab(browsertab.AbstractTab): sip.assign(authenticator, QAuthenticator()) # pylint: enable=no-member, useless-suppression except AttributeError: - url_string = url.toDisplayString() - error_page = jinja.render( - 'error.html', - title="Error loading page: {}".format(url_string), - url=url_string, error="Proxy authentication required", - icon='') - self.set_html(error_page) + self._show_error_page(url, "Proxy authentication required") @pyqtSlot(QUrl, 'QAuthenticator*') def _on_authentication_required(self, url, authenticator): @@ -804,12 +807,7 @@ class WebEngineTab(browsertab.AbstractTab): except AttributeError: # WORKAROUND for # https://www.riverbankcomputing.com/pipermail/pyqt/2016-December/038400.html - url_string = url.toDisplayString() - error_page = jinja.render( - 'error.html', - title="Error loading page: {}".format(url_string), - url=url_string, error="Authentication required") - self.set_html(error_page) + self._show_error_page(url, "Authentication required") @pyqtSlot('QWebEngineFullScreenRequest') def _on_fullscreen_requested(self, request): @@ -873,6 +871,9 @@ class WebEngineTab(browsertab.AbstractTab): """ if not ok: self._load_finished_fake.emit(False) + if not self.settings.test_attribute('content.javascript.enabled'): + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66643 + self._show_error_page(self.url(), error="") @pyqtSlot(usertypes.NavigationRequest) def _on_navigation_request(self, navigation): diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 98d5339fe..86f70ab69 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -47,6 +47,10 @@ class AbstractSettings: """ raise NotImplementedError + def test_attribute(self, name): + """Get the value for the given attribute.""" + return self._settings.testAttribute(self._ATTRIBUTES[name]) + def set_font_size(self, name, value): """Set the given QWebSettings/QWebEngineSettings font size.""" assert value is not configutils.UNSET From 75b65e2f11eda1d26e38c1c585a2445edf097662 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 23 Feb 2018 17:59:12 +0100 Subject: [PATCH 222/524] Simplify attribute handling in Web(Kit|Engine)Settings Let's just have lists in _ATTRIBUTES for WebEngineSettings as well, that allows us to share some more code. --- .../browser/webengine/webenginesettings.py | 37 ++++++++----------- qutebrowser/browser/webkit/webkitsettings.py | 7 ---- qutebrowser/config/websettings.py | 6 ++- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 7d8d14dc6..93f1b4494 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -76,35 +76,35 @@ class WebEngineSettings(websettings.AbstractSettings): _ATTRIBUTES = { 'content.xss_auditing': - QWebEngineSettings.XSSAuditingEnabled, + [QWebEngineSettings.XSSAuditingEnabled], 'content.images': - QWebEngineSettings.AutoLoadImages, + [QWebEngineSettings.AutoLoadImages], 'content.javascript.enabled': - QWebEngineSettings.JavascriptEnabled, + [QWebEngineSettings.JavascriptEnabled], 'content.javascript.can_open_tabs_automatically': - QWebEngineSettings.JavascriptCanOpenWindows, + [QWebEngineSettings.JavascriptCanOpenWindows], 'content.javascript.can_access_clipboard': - QWebEngineSettings.JavascriptCanAccessClipboard, + [QWebEngineSettings.JavascriptCanAccessClipboard], 'content.plugins': - QWebEngineSettings.PluginsEnabled, + [QWebEngineSettings.PluginsEnabled], 'content.hyperlink_auditing': - QWebEngineSettings.HyperlinkAuditingEnabled, + [QWebEngineSettings.HyperlinkAuditingEnabled], 'content.local_content_can_access_remote_urls': - QWebEngineSettings.LocalContentCanAccessRemoteUrls, + [QWebEngineSettings.LocalContentCanAccessRemoteUrls], 'content.local_content_can_access_file_urls': - QWebEngineSettings.LocalContentCanAccessFileUrls, + [QWebEngineSettings.LocalContentCanAccessFileUrls], 'content.webgl': - QWebEngineSettings.WebGLEnabled, + [QWebEngineSettings.WebGLEnabled], 'content.local_storage': - QWebEngineSettings.LocalStorageEnabled, + [QWebEngineSettings.LocalStorageEnabled], 'input.spatial_navigation': - QWebEngineSettings.SpatialNavigationEnabled, + [QWebEngineSettings.SpatialNavigationEnabled], 'input.links_included_in_focus_chain': - QWebEngineSettings.LinksIncludedInFocusChain, + [QWebEngineSettings.LinksIncludedInFocusChain], 'scrolling.smooth': - QWebEngineSettings.ScrollAnimatorEnabled, + [QWebEngineSettings.ScrollAnimatorEnabled], # Missing QtWebEngine attributes: # - ScreenCaptureEnabled @@ -162,14 +162,7 @@ class WebEngineSettings(websettings.AbstractSettings): except AttributeError: continue - self._ATTRIBUTES[name] = value - - def set_attribute(self, name, value): - attribute = self._ATTRIBUTES[name] - if value is configutils.UNSET: - self._settings.resetAttribute(attribute) - else: - self._settings.setAttribute(attribute, value) + self._ATTRIBUTES[name] = [value] def _init_stylesheet(profile): diff --git a/qutebrowser/browser/webkit/webkitsettings.py b/qutebrowser/browser/webkit/webkitsettings.py index 6ba15f62a..d8cbfbb2e 100644 --- a/qutebrowser/browser/webkit/webkitsettings.py +++ b/qutebrowser/browser/webkit/webkitsettings.py @@ -121,13 +121,6 @@ class WebKitSettings(websettings.AbstractSettings): QWebSettings.FantasyFont: QFont.Fantasy, } - def set_attribute(self, name, value): - for attribute in self._ATTRIBUTES[name]: - if value is configutils.UNSET: - self._settings.resetAttribute(attribute) - else: - self._settings.setAttribute(attribute, value) - def _set_user_stylesheet(settings): """Set the generated user-stylesheet.""" diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 86f70ab69..20b59c90c 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -45,7 +45,11 @@ class AbstractSettings: If the value is configutils.UNSET, the value is reset instead. """ - raise NotImplementedError + for attribute in self._ATTRIBUTES[name]: + if value is configutils.UNSET: + self._settings.resetAttribute(attribute) + else: + self._settings.setAttribute(attribute, value) def test_attribute(self, name): """Get the value for the given attribute.""" From 2c96446bb9dfffa9fa6894c75bd3127743904104 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 23 Feb 2018 18:11:33 +0100 Subject: [PATCH 223/524] Track which settings changed for a URL This is currently only used so only changed settings are logged, but will used for more in the next commit. --- .../browser/webengine/webenginesettings.py | 17 ++++- qutebrowser/config/websettings.py | 76 +++++++++++++++---- 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 93f1b4494..a4ca69856 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -47,7 +47,10 @@ global_settings = None class _SettingsWrapper: - """Expose a QWebEngineSettings interface which acts on all profiles.""" + """Expose a QWebEngineSettings interface which acts on all profiles. + + For read operations, the default profile value is always used. + """ def __init__(self): self._settings = [default_profile.settings(), @@ -69,6 +72,18 @@ class _SettingsWrapper: for settings in self._settings: settings.setDefaultTextEncoding(*args, **kwargs) + def testAttribute(self, *args, **kwargs): + return self._settings[0].testAttribute(*args, **kwargs) + + def fontSize(self, *args, **kwargs): + return self._settings[0].fontSize(*args, **kwargs) + + def fontFamily(self, *args, **kwargs): + return self._settings[0].fontFamily(*args, **kwargs) + + def defaultTextEncoding(self, *args, **kwargs): + return self._settings[0].defaultTextEncoding(*args, **kwargs) + class WebEngineSettings(websettings.AbstractSettings): diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 20b59c90c..dcfc11036 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -44,54 +44,91 @@ class AbstractSettings: """Set the given QWebSettings/QWebEngineSettings attribute. If the value is configutils.UNSET, the value is reset instead. + + Return: + True if there was a change, False otherwise. """ + old_value = self.test_attribute(name) + for attribute in self._ATTRIBUTES[name]: if value is configutils.UNSET: self._settings.resetAttribute(attribute) + new_value = self.test_attribute(name) else: self._settings.setAttribute(attribute, value) + new_value = value + + return old_value != new_value def test_attribute(self, name): - """Get the value for the given attribute.""" - return self._settings.testAttribute(self._ATTRIBUTES[name]) + """Get the value for the given attribute. + + If the setting resolves to a list of attributes, only the first + attribute is tested. + """ + return self._settings.testAttribute(self._ATTRIBUTES[name][0]) def set_font_size(self, name, value): - """Set the given QWebSettings/QWebEngineSettings font size.""" + """Set the given QWebSettings/QWebEngineSettings font size. + + Return: + True if there was a change, False otherwise. + """ assert value is not configutils.UNSET - self._settings.setFontSize(self._FONT_SIZES[name], value) + family = self._FONT_SIZES[name] + old_value = self._settings.fontSize(family) + self._settings.setFontSize(family, value) + return old_value != value def set_font_family(self, name, value): """Set the given QWebSettings/QWebEngineSettings font family. With None (the default), QFont is used to get the default font for the family. + + Return: + True if there was a change, False otherwise. """ assert value is not configutils.UNSET + family = self._FONT_FAMILIES[name] if value is None: font = QFont() - font.setStyleHint(self._FONT_TO_QFONT[self._FONT_FAMILIES[name]]) + font.setStyleHint(self._FONT_TO_QFONT[family]) value = font.defaultFamily() - self._settings.setFontFamily(self._FONT_FAMILIES[name], value) + old_value = self._settings.fontFamily(family) + self._settings.setFontFamily(family, value) + + return value != old_value def set_default_text_encoding(self, encoding): - """Set the default text encoding to use.""" + """Set the default text encoding to use. + + Return: + True if there was a change, False otherwise. + """ assert encoding is not configutils.UNSET + old_value = self._settings.defaultTextEncoding() self._settings.setDefaultTextEncoding(encoding) + return old_value != encoding def _update_setting(self, setting, value): """Update the given setting/value. Unknown settings are ignored. + + Return: + True if there was a change, False otherwise. """ if setting in self._ATTRIBUTES: - self.set_attribute(setting, value) + return self.set_attribute(setting, value) elif setting in self._FONT_SIZES: - self.set_font_size(setting, value) + return self.set_font_size(setting, value) elif setting in self._FONT_FAMILIES: - self.set_font_family(setting, value) + return self.set_font_family(setting, value) elif setting == 'content.default_encoding': - self.set_default_text_encoding(value) + return self.set_default_text_encoding(value) + return False def update_setting(self, setting): """Update the given setting.""" @@ -99,16 +136,25 @@ class AbstractSettings: self._update_setting(setting, value) def update_for_url(self, url): - """Update settings customized for the given tab.""" + """Update settings customized for the given tab. + + Return: + A list of settings which actually changed. + """ + changed_settings = [] for values in config.instance: if not values.opt.supports_pattern: continue value = values.get_for_url(url, fallback=False) - log.config.debug("Updating for {}: {} = {}".format( - url.toDisplayString(), values.opt.name, value)) - self._update_setting(values.opt.name, value) + changed = self._update_setting(values.opt.name, value) + if changed: + log.config.debug("Changed for {}: {} = {}".format( + url.toDisplayString(), values.opt.name, value)) + changed_settings.append(values.opt.name) + + return changed_settings def init_settings(self): """Set all supported settings correctly.""" From f926e7b850835fab7bfcde3a24d3faebee99562c Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Fri, 23 Feb 2018 17:49:47 -0500 Subject: [PATCH 224/524] Emulate webkit duplicate search behavior on webengine --- qutebrowser/browser/commands.py | 4 ++-- qutebrowser/browser/webengine/webenginetab.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 69cb3142f..6c2e3da8c 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -1778,10 +1778,10 @@ class CommandDispatcher: """ self.set_mark("'") tab = self._current_widget() - if tab.search.search_displayed: - tab.search.clear() if not text: + if tab.search.search_displayed: + tab.search.clear() return options = { diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 4595f7a6e..1b0ee5541 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -183,6 +183,11 @@ class WebEngineSearch(browsertab.AbstractSearch): def search(self, text, *, ignore_case='never', reverse=False, result_cb=None): + # When duplicate searching, don't search again (webkit behavior) + if self.text == text and self.search_displayed: + log.webview.debug("Ignoring duplicate search request") + return + self.text = text self._flags = QWebEnginePage.FindFlags(0) if self._is_case_sensitive(ignore_case): From 820ffed07f11cbfe9966261a62e71236d41595d1 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Fri, 23 Feb 2018 18:06:57 -0500 Subject: [PATCH 225/524] Remove test blacklists for 5.10 --- tests/end2end/features/search.feature | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/end2end/features/search.feature b/tests/end2end/features/search.feature index ae3f07999..458b86d60 100644 --- a/tests/end2end/features/search.feature +++ b/tests/end2end/features/search.feature @@ -225,15 +225,11 @@ Feature: Searching on a page Then the following tabs should be open: - data/search.html (active) - # Following a link selected via JS doesn't work in Qt 5.10 anymore. - @qt!=5.10 Scenario: Follow a manually selected link When I run :jseval --file (testdata)/search_select.js And I run :follow-selected Then data/hello.txt should be loaded - # Following a link selected via JS doesn't work in Qt 5.10 anymore. - @qt!=5.10 Scenario: Follow a manually selected link in a new tab When I run :window-only And I run :jseval --file (testdata)/search_select.js From 4602afe770007f3028cf6aaddd12bcb6dcfdb0cb Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Fri, 23 Feb 2018 18:13:10 -0500 Subject: [PATCH 226/524] Add a webengine duplicate search test --- qutebrowser/browser/webengine/webenginetab.py | 3 ++- tests/end2end/features/search.feature | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 1b0ee5541..4c4de14c0 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -185,7 +185,8 @@ class WebEngineSearch(browsertab.AbstractSearch): result_cb=None): # When duplicate searching, don't search again (webkit behavior) if self.text == text and self.search_displayed: - log.webview.debug("Ignoring duplicate search request") + log.webview.debug("Ignoring duplicate search request" + " for {}".format(text)) return self.text = text diff --git a/tests/end2end/features/search.feature b/tests/end2end/features/search.feature index ae3f07999..e322b93a2 100644 --- a/tests/end2end/features/search.feature +++ b/tests/end2end/features/search.feature @@ -52,6 +52,13 @@ Feature: Searching on a page And I wait for "search didn't find blub" in the log Then the warning "Text 'blub' not found on page!" should be shown + @qtwebkit_skip: Supported by default on qtwebkit + Scenario: Searching text duplicates + When I run :search foo + And I wait for "search found foo" in the log + And I run :search foo + Then "Ignoring duplicate search request for foo" should be logged + ## search.ignore_case Scenario: Searching text with search.ignore_case = always From 08bc55995b756908c9feeb04640c4b7cbfc1f6eb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 24 Feb 2018 18:25:02 +0100 Subject: [PATCH 227/524] First attempt at reloading pages after setting changes --- qutebrowser/browser/webengine/webenginetab.py | 23 +++++++++++++++---- qutebrowser/config/websettings.py | 6 ++--- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index ce57e498e..32a17916b 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -26,7 +26,7 @@ import html as html_utils import sip from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QEvent, QPoint, QPointF, - QUrl) + QUrl, QTimer) from PyQt5.QtGui import QKeyEvent from PyQt5.QtNetwork import QAuthenticator from PyQt5.QtWidgets import QApplication @@ -612,6 +612,7 @@ class WebEngineTab(browsertab.AbstractTab): self._init_js() self._child_event_filter = None self._saved_zoom = None + self._reload_url = None def _init_js(self): js_code = '\n'.join([ @@ -871,15 +872,27 @@ class WebEngineTab(browsertab.AbstractTab): """ if not ok: self._load_finished_fake.emit(False) - if not self.settings.test_attribute('content.javascript.enabled'): - # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66643 - self._show_error_page(self.url(), error="") + + @pyqtSlot(bool) + def _on_load_finished(self, ok): + super()._on_load_finished(ok) + if not ok and not self.settings.test_attribute('content.javascript.enabled'): + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66643 + self._show_error_page(self.url(), error="") + elif ok and self._reload_url is not None: + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656 + QTimer.singleShot(100, lambda url=self._reload_url: + self.openurl(url)) + self._reload_url = None @pyqtSlot(usertypes.NavigationRequest) def _on_navigation_request(self, navigation): super()._on_navigation_request(navigation) if navigation.accepted and navigation.is_main_frame: - self.settings.update_for_url(navigation.url) + changed = self.settings.update_for_url(navigation.url) + if changed & {'content.plugins', 'content.javascript.enabled'}: + navigation.accepted = False + self._reload_url = navigation.url def _connect_signals(self): view = self._widget diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index dcfc11036..517c3dd3c 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -139,9 +139,9 @@ class AbstractSettings: """Update settings customized for the given tab. Return: - A list of settings which actually changed. + A set of settings which actually changed. """ - changed_settings = [] + changed_settings = set() for values in config.instance: if not values.opt.supports_pattern: continue @@ -152,7 +152,7 @@ class AbstractSettings: if changed: log.config.debug("Changed for {}: {} = {}".format( url.toDisplayString(), values.opt.name, value)) - changed_settings.append(values.opt.name) + changed_settings.add(values.opt.name) return changed_settings From bfb3a6594f0cecd21dfda0a9d89b52113d905d22 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 24 Feb 2018 23:06:18 +0100 Subject: [PATCH 228/524] Try using tab.reload() on setting changes instead --- qutebrowser/browser/webengine/webenginetab.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 32a17916b..73e6ec18d 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -879,10 +879,16 @@ class WebEngineTab(browsertab.AbstractTab): if not ok and not self.settings.test_attribute('content.javascript.enabled'): # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66643 self._show_error_page(self.url(), error="") - elif ok and self._reload_url is not None: + + @pyqtSlot(QUrl) + def _on_url_changed(self, url): + super()._on_url_changed(url) + if self._reload_url is not None: # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656 - QTimer.singleShot(100, lambda url=self._reload_url: - self.openurl(url)) + log.config.debug( + "Reloading {} on {} because of config change".format( + self._reload_url.toDisplayString(), url.toDisplayString())) + self.reload() self._reload_url = None @pyqtSlot(usertypes.NavigationRequest) @@ -891,7 +897,6 @@ class WebEngineTab(browsertab.AbstractTab): if navigation.accepted and navigation.is_main_frame: changed = self.settings.update_for_url(navigation.url) if changed & {'content.plugins', 'content.javascript.enabled'}: - navigation.accepted = False self._reload_url = navigation.url def _connect_signals(self): From 638e8806048c6a66eb080ea5aa082f9bbd639a9a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 24 Feb 2018 23:17:35 +0100 Subject: [PATCH 229/524] Improve workaround for missing error pages --- qutebrowser/browser/webengine/webenginetab.py | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 73e6ec18d..130a18c30 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -22,6 +22,7 @@ import math import functools import sys +import re import html as html_utils import sip @@ -873,12 +874,27 @@ class WebEngineTab(browsertab.AbstractTab): if not ok: self._load_finished_fake.emit(False) + def _error_page_workaround(self, html): + """Check if we're displaying a Chromium error page. + + This gets only called if we got loadFinished(False) without JavaScript, + so we can display at least some error page. + + WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66643 + Needs to check the page content as a WORKAROUND for + https://bugreports.qt.io/browse/QTBUG-66661 + """ + match = re.search(r'"errorCode":"([^"]*)"', html) + if match is None: + return + self._show_error_page(self.url(), error=match.group(1)) + @pyqtSlot(bool) def _on_load_finished(self, ok): + """Display a static error page if JavaScript is disabled.""" super()._on_load_finished(ok) if not ok and not self.settings.test_attribute('content.javascript.enabled'): - # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66643 - self._show_error_page(self.url(), error="") + self.dump_async(self._error_page_workaround) @pyqtSlot(QUrl) def _on_url_changed(self, url): From 65a62b67a5aec4de8cf5f43dc8ae3647f69ee996 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 25 Feb 2018 14:43:30 +0100 Subject: [PATCH 230/524] Go back to using tab.openurl on config changes This seems to work most reliably at the moment... --- qutebrowser/browser/webengine/webenginetab.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 130a18c30..c3f493ba1 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -895,16 +895,12 @@ class WebEngineTab(browsertab.AbstractTab): super()._on_load_finished(ok) if not ok and not self.settings.test_attribute('content.javascript.enabled'): self.dump_async(self._error_page_workaround) - - @pyqtSlot(QUrl) - def _on_url_changed(self, url): - super()._on_url_changed(url) - if self._reload_url is not None: + if ok and self._reload_url is not None: # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656 log.config.debug( - "Reloading {} on {} because of config change".format( - self._reload_url.toDisplayString(), url.toDisplayString())) - self.reload() + "Reloading {} because of config change".format( + self._reload_url.toDisplayString())) + QTimer.singleShot(100, lambda url=self._reload_url: self.openurl(url)) self._reload_url = None @pyqtSlot(usertypes.NavigationRequest) From eade305965b68627d4313e0664979692a7025994 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 25 Feb 2018 15:02:38 +0100 Subject: [PATCH 231/524] Add a predicted_navigation signal This is emitted when we know that we're going to visit some URL, but Qt doesn't know yet. This way, we can change the settings early, and since we know which settings have actually changed, prevent a change needing a reload in _on_navigation_request. --- qutebrowser/browser/browsertab.py | 7 ++++++- qutebrowser/browser/webengine/webenginetab.py | 10 +++++++++- qutebrowser/browser/webkit/webkittab.py | 3 ++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 3fb700420..bc1b6cb5c 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -616,6 +616,7 @@ class AbstractTab(QWidget): process terminated. arg 0: A TerminationStatus member. arg 1: The exit code. + predicted_navigation: Emitted before we tell Qt to open a URL. """ window_close_requested = pyqtSignal() @@ -633,6 +634,7 @@ class AbstractTab(QWidget): add_history_item = pyqtSignal(QUrl, QUrl, str) # url, requested url, title fullscreen_requested = pyqtSignal(bool) renderer_process_terminated = pyqtSignal(TerminationStatus, int) + predicted_navigation = pyqtSignal(QUrl) def __init__(self, *, win_id, mode_manager, private, parent=None): self.private = private @@ -663,6 +665,9 @@ class AbstractTab(QWidget): objreg.register('hintmanager', hintmanager, scope='tab', window=self.win_id, tab=self.tab_id) + self.predicted_navigation.connect( + lambda url: self.title_changed.emit(url.toDisplayString())) + def _set_widget(self, widget): # pylint: disable=protected-access self._widget = widget @@ -811,7 +816,7 @@ class AbstractTab(QWidget): def _openurl_prepare(self, url): qtutils.ensure_valid(url) - self.title_changed.emit(url.toDisplayString()) + self.predicted_navigation.emit(url) def openurl(self, url): raise NotImplementedError diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index c3f493ba1..4fcb6d279 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -471,7 +471,8 @@ class WebEngineHistory(browsertab.AbstractHistory): return self._history.itemAt(i) def _go_to_item(self, item): - return self._history.goToItem(item) + self._tab.predicted_navigation.emit(item.url()) + self._history.goToItem(item) def serialize(self): if not qtutils.version_check('5.9', compiled=False): @@ -903,6 +904,11 @@ class WebEngineTab(browsertab.AbstractTab): QTimer.singleShot(100, lambda url=self._reload_url: self.openurl(url)) self._reload_url = None + @pyqtSlot(QUrl) + def _on_predicted_navigation(self, url): + """If we know we're going to visit an URL soon, change the settings.""" + self.settings.update_for_url(url) + @pyqtSlot(usertypes.NavigationRequest) def _on_navigation_request(self, navigation): super()._on_navigation_request(navigation) @@ -946,5 +952,7 @@ class WebEngineTab(browsertab.AbstractTab): page.loadFinished.connect(self._restore_zoom) page.loadFinished.connect(self._on_load_finished) + self.predicted_navigation.connect(self._on_predicted_navigation) + def event_target(self): return self._widget.focusProxy() diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 5184550cd..7af0b474d 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -518,7 +518,8 @@ class WebKitHistory(browsertab.AbstractHistory): return self._history.itemAt(i) def _go_to_item(self, item): - return self._history.goToItem(item) + self._tab.predicted_navigation.emit(item.url()) + self._history.goToItem(item) def serialize(self): return qtutils.serialize(self._history) From 97e00ba4b529e0791d6a306478f693ae3b12775b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 25 Feb 2018 15:03:30 +0100 Subject: [PATCH 232/524] Only reload after setting changes when needed Apparently, things work fine with Type.link_clicked even if we don't emit predicted_navigation there... --- qutebrowser/browser/webengine/webenginetab.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 4fcb6d279..be7929510 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -914,7 +914,9 @@ class WebEngineTab(browsertab.AbstractTab): super()._on_navigation_request(navigation) if navigation.accepted and navigation.is_main_frame: changed = self.settings.update_for_url(navigation.url) - if changed & {'content.plugins', 'content.javascript.enabled'}: + if (changed & {'content.plugins', 'content.javascript.enabled'} and + navigation.navigation_type != navigation.Type.link_clicked): + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656 self._reload_url = navigation.url def _connect_signals(self): From d44ff5ba01bea65444b96a05eb5252a39b99824f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 25 Feb 2018 15:53:01 +0100 Subject: [PATCH 233/524] Don't load the URL immediately on :undo On some pages like Qt's Gerrit, Indiegogo or Telegram Web, this caused a crash with QtWebEngine and Qt 5.10.1 in QtWebEngineCore::WebContentsAdapter::webContents(). I'm not sure what causes the crash exactly, but I'm guessing it's some kind of race condition between loading the URL initially and deserializing the history, which both ends up loading the URL. Since restoring the history means we end up on the given URL anyways, let's just not open the URL beforehand, which seems to fix this. Fixes #3619. --- doc/changelog.asciidoc | 1 + qutebrowser/mainwindow/tabbedbrowser.py | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 2f1a1f1db..13636f3b2 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -76,6 +76,7 @@ Fixed - QtWebEngine: Hinting and scrolling now works properly on special `view-source:` pages. - QtWebEngine: Scroll positions are now restored correctly from sessions. +- QtWebEngine: Crash with Qt 5.10.1 when using :undo on some tabs. - QtWebKit: `:view-source` now displays a valid URL. - URLs containing ampersands and other special chars are now shown correctly when filtering them in the completion. diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 8cee35524..299a5fb08 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -378,12 +378,10 @@ class TabbedBrowser(tabwidget.TabWidget): for entry in reversed(self._undo_stack.pop()): if use_current_tab: - self.openurl(entry.url, newtab=False) newtab = self.widget(0) use_current_tab = False else: - newtab = self.tabopen(entry.url, background=False, - idx=entry.index) + newtab = self.tabopen(background=False, idx=entry.index) newtab.history.deserialize(entry.history) self.set_tab_pinned(newtab, entry.pinned) From a32d74e9830d476f14dae086b81601df38d91144 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 25 Feb 2018 16:08:15 +0100 Subject: [PATCH 234/524] Fix lint --- qutebrowser/browser/webengine/webenginesettings.py | 2 +- qutebrowser/browser/webengine/webenginetab.py | 12 ++++++++---- qutebrowser/browser/webkit/webkitsettings.py | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index a4ca69856..2b9ae55e2 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -33,7 +33,7 @@ from PyQt5.QtWebEngineWidgets import (QWebEngineSettings, QWebEngineProfile, from qutebrowser.browser import shared from qutebrowser.browser.webengine import spell -from qutebrowser.config import config, websettings, configutils +from qutebrowser.config import config, websettings from qutebrowser.utils import (utils, standarddir, javascript, qtutils, message, log, objreg) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index be7929510..ab0b866ca 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -894,14 +894,17 @@ class WebEngineTab(browsertab.AbstractTab): def _on_load_finished(self, ok): """Display a static error page if JavaScript is disabled.""" super()._on_load_finished(ok) - if not ok and not self.settings.test_attribute('content.javascript.enabled'): + js_enabled = self.settings.test_attribute('content.javascript.enabled') + if not ok and not js_enabled: self.dump_async(self._error_page_workaround) + if ok and self._reload_url is not None: # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656 log.config.debug( "Reloading {} because of config change".format( self._reload_url.toDisplayString())) - QTimer.singleShot(100, lambda url=self._reload_url: self.openurl(url)) + QTimer.singleShot(100, lambda url=self._reload_url: + self.openurl(url)) self._reload_url = None @pyqtSlot(QUrl) @@ -914,8 +917,9 @@ class WebEngineTab(browsertab.AbstractTab): super()._on_navigation_request(navigation) if navigation.accepted and navigation.is_main_frame: changed = self.settings.update_for_url(navigation.url) - if (changed & {'content.plugins', 'content.javascript.enabled'} and - navigation.navigation_type != navigation.Type.link_clicked): + needs_reload = {'content.plugins', 'content.javascript.enabled'} + if (changed & needs_reload and navigation.navigation_type != + navigation.Type.link_clicked): # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656 self._reload_url = navigation.url diff --git a/qutebrowser/browser/webkit/webkitsettings.py b/qutebrowser/browser/webkit/webkitsettings.py index d8cbfbb2e..9b120e514 100644 --- a/qutebrowser/browser/webkit/webkitsettings.py +++ b/qutebrowser/browser/webkit/webkitsettings.py @@ -29,7 +29,7 @@ import os.path from PyQt5.QtGui import QFont from PyQt5.QtWebKit import QWebSettings -from qutebrowser.config import config, websettings, configutils +from qutebrowser.config import config, websettings from qutebrowser.utils import standarddir, urlutils from qutebrowser.browser import shared From 4c147b77c1bb8586705d6d2550c468827e7d47cb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 25 Feb 2018 16:35:02 +0100 Subject: [PATCH 235/524] Add a test for the error page workaround --- qutebrowser/browser/webengine/webenginetab.py | 1 + tests/end2end/features/javascript.feature | 6 ++++++ tests/end2end/fixtures/webserver.py | 1 + tests/end2end/fixtures/webserver_sub.py | 8 ++++++++ 4 files changed, 16 insertions(+) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index ab0b866ca..e66ed50c1 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -735,6 +735,7 @@ class WebEngineTab(browsertab.AbstractTab): def _show_error_page(self, url, error): """Show an error page in the tab.""" + log.misc.debug("Showing error page for {}".format(error)) url_string = url.toDisplayString() error_page = jinja.render( 'error.html', diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature index 8428306cb..392a291b5 100644 --- a/tests/end2end/features/javascript.feature +++ b/tests/end2end/features/javascript.feature @@ -167,3 +167,9 @@ Feature: Javascript stuff And I wait for "[*] JavaScript is enabled" in the log And I open data/javascript/enabled.html Then the page should contain the plaintext "JavaScript is disabled" + + @qtwebkit_skip + Scenario: Error pages without JS enabled + When I set content.javascript.enabled to false + And I open 500 + Then "Showing error page for* 500" should be logged diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 2beb6fb95..46065b6c8 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -79,6 +79,7 @@ class Request(testprocess.Line): '/cookies/set': [http.client.FOUND], '/500-inline': [http.client.INTERNAL_SERVER_ERROR], + '/500': [http.client.INTERNAL_SERVER_ERROR], } for i in range(15): path_to_statuses['/redirect/{}'.format(i)] = [http.client.FOUND] diff --git a/tests/end2end/fixtures/webserver_sub.py b/tests/end2end/fixtures/webserver_sub.py index 75c080c34..1bd9e8a66 100644 --- a/tests/end2end/fixtures/webserver_sub.py +++ b/tests/end2end/fixtures/webserver_sub.py @@ -179,6 +179,14 @@ def internal_error_attachment(): return response +@app.route('/500') +def internal_error(): + """A normal 500 error.""" + r = flask.make_response() + r.status_code = 500 + return r + + @app.route('/cookies') def view_cookies(): """Show cookies.""" From abf4d10d5bbac543c19817b533bea569aa00fec2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 25 Feb 2018 19:33:27 +0100 Subject: [PATCH 236/524] Add a test for :set -p with a pattern --- tests/unit/config/test_configcommands.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 27075e869..cafc1ac31 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -130,17 +130,26 @@ class TestSet: assert config_stub.val.url.auto_search == 'never' assert yaml_value('url.auto_search') == 'dns' - def test_set_print(self, config_stub, commands, message_mock): - """Run ':set -p url.auto_search never'. + @pytest.mark.parametrize('pattern', [None, '*://example.com']) + def test_set_print(self, config_stub, commands, message_mock, pattern): + """Run ':set -p [-u *://example.com] content.javascript.enabled false'. Should set show the value. """ - assert config_stub.val.url.auto_search == 'naive' - commands.set(0, 'url.auto_search', 'dns', print_=True) + assert config_stub.val.content.javascript.enabled + commands.set(0, 'content.javascript.enabled', 'false', print_=True, + pattern=pattern) - assert config_stub.val.url.auto_search == 'dns' + value = config_stub.get_obj_for_pattern( + 'content.javascript.enabled', + pattern=None if pattern is None else urlmatch.UrlPattern(pattern)) + assert not value + + expected = 'content.javascript.enabled = false' + if pattern is not None: + expected += ' for {}'.format(pattern) msg = message_mock.getmsg(usertypes.MessageLevel.info) - assert msg.text == 'url.auto_search = dns' + assert msg.text == expected def test_set_invalid_option(self, commands): """Run ':set foo bar'. From ba88fc43e05d2aa692cf132ffe5df6239933aa75 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 25 Feb 2018 19:40:38 +0100 Subject: [PATCH 237/524] Stabilize error page test --- tests/end2end/features/javascript.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature index 392a291b5..401487747 100644 --- a/tests/end2end/features/javascript.feature +++ b/tests/end2end/features/javascript.feature @@ -171,5 +171,5 @@ Feature: Javascript stuff @qtwebkit_skip Scenario: Error pages without JS enabled When I set content.javascript.enabled to false - And I open 500 + And I open 500 without waiting Then "Showing error page for* 500" should be logged From a98466336ecd6e0af1650ba735287d019f8be142 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 25 Feb 2018 19:58:23 +0100 Subject: [PATCH 238/524] Update changelog --- doc/changelog.asciidoc | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 13636f3b2..0d49416e0 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -21,6 +21,17 @@ v1.2.0 (unreleased) Added ~~~~~ +- Initial implementation of per-domain settings: + * `:set` and `:config-cycle` now have a `-u`/`--pattern` argument taking a + https://developer.chrome.com/extensions/match_patterns[URL match pattern] + for supported settings. + * `config.set` in `config.py` now takes a third argument which is the pattern. + * New `with config.pattern('...') as p:` context manager for `config.py` to + use the shorthand syntax with a pattern. + * New `tsh` keybinding to toggle scripts for the current host. With a capital + `S`, the toggle is saved. With a capital `H`, subdomains are included. + * New `tsu` keybinding to toggle scripts for the current URL. With a capital + `S`, the toggle is saved. - QtWebEngine: Caret/visual mode is now supported. - QtWebEngine: Authentication via ~/.netrc is now supported. - A new `qute://bindings` page, opened by `:bind`, shows all keybindings. From 3df066c6948fa3cbc645b4ad200f3b7b5a0af903 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 25 Feb 2018 21:10:35 +0100 Subject: [PATCH 239/524] Update changelog --- doc/changelog.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 0d49416e0..e85d9a188 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -88,6 +88,7 @@ Fixed `view-source:` pages. - QtWebEngine: Scroll positions are now restored correctly from sessions. - QtWebEngine: Crash with Qt 5.10.1 when using :undo on some tabs. +- QtWebEngine: `:follow-selected` should now work in more cases with Qt > 5.10. - QtWebKit: `:view-source` now displays a valid URL. - URLs containing ampersands and other special chars are now shown correctly when filtering them in the completion. From eeb565319fd755b36b3938916bdc666523bb851e Mon Sep 17 00:00:00 2001 From: Anton S Date: Sun, 25 Feb 2018 22:42:32 +0300 Subject: [PATCH 240/524] Handle invalid URLs on Apple events --- qutebrowser/app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index ec477ce8f..c755c2f41 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -840,7 +840,11 @@ class Application(QApplication): def event(self, e): """Handle macOS FileOpen events.""" if e.type() == QEvent.FileOpen: - open_url(e.url(), no_raise=True) + url = e.url() + if url.isValid(): + open_url(url, no_raise=True) + else: + message.error("Invalid URL: {}".format(url.errorString())) else: return super().event(e) From e273f163a634d4423bfb739a0c80b7bcd7529056 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 25 Feb 2018 22:09:39 +0100 Subject: [PATCH 241/524] Add a KeyInfo class --- qutebrowser/keyinput/keyutils.py | 151 +++++++++++++++++-------------- 1 file changed, 84 insertions(+), 67 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index c67ac7024..1e8b3f248 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -128,68 +128,7 @@ def key_to_string(key): def keyevent_to_string(e): """Convert a QKeyEvent to a meaningful name.""" - return key_with_modifiers_to_string(e.key(), e.modifiers()) - - -def key_with_modifiers_to_string(key, modifiers): - """Convert a Qt.Key with modifiers to a meaningful name. - - Return: - A name of the key (combination) as a string or - None if only modifiers are pressed.. - """ - if utils.is_mac: - # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the user - # can use it in the config as expected. See: - # https://github.com/qutebrowser/qutebrowser/issues/110 - # http://doc.qt.io/qt-5.4/osx-issues.html#special-keys - modmask2str = collections.OrderedDict([ - (Qt.MetaModifier, 'Ctrl'), - (Qt.AltModifier, 'Alt'), - (Qt.ControlModifier, 'Meta'), - (Qt.ShiftModifier, 'Shift'), - ]) - else: - modmask2str = collections.OrderedDict([ - (Qt.ControlModifier, 'Ctrl'), - (Qt.AltModifier, 'Alt'), - (Qt.MetaModifier, 'Meta'), - (Qt.ShiftModifier, 'Shift'), - ]) - - modifier_keys = (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, - Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, - Qt.Key_Hyper_L, Qt.Key_Hyper_R, Qt.Key_Direction_L, - Qt.Key_Direction_R) - if key in modifier_keys: - # Only modifier pressed - return None - parts = [] - - for (mask, s) in modmask2str.items(): - if modifiers & mask and s not in parts: - parts.append(s) - - key_string = key_to_string(key) - - # FIXME needed? - if len(key_string) == 1: - category = unicodedata.category(key_string) - is_control_char = (category == 'Cc') - else: - is_control_char = False - - if modifiers == Qt.ShiftModifier and not is_control_char: - parts = [] - - parts.append(key_string) - normalized = normalize_keystr('+'.join(parts)) - if len(normalized) > 1: - # "special" binding - return '<{}>'.format(normalized) - else: - # "normal" binding - return normalized + return str(KeyInfo(e.key(), e.modifiers())) class KeyParseError(Exception): @@ -239,6 +178,80 @@ def normalize_keystr(keystr): return keystr +@attr.s +class KeyInfo: + + """A key with optional modifiers. + + Attributes: + key: A Qt::Key member. + modifiers: A Qt::KeyboardModifiers enum value. + """ + + key = attr.ib() + modifiers = attr.ib() + + def __str__(self): + """Convert this KeyInfo to a meaningful name. + + Return: + A name of the key (combination) as a string or + an empty string if only modifiers are pressed. + """ + if utils.is_mac: + # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the user + # can use it in the config as expected. See: + # https://github.com/qutebrowser/qutebrowser/issues/110 + # http://doc.qt.io/qt-5.4/osx-issues.html#special-keys + modmask2str = collections.OrderedDict([ + (Qt.MetaModifier, 'Ctrl'), + (Qt.AltModifier, 'Alt'), + (Qt.ControlModifier, 'Meta'), + (Qt.ShiftModifier, 'Shift'), + ]) + else: + modmask2str = collections.OrderedDict([ + (Qt.ControlModifier, 'Ctrl'), + (Qt.AltModifier, 'Alt'), + (Qt.MetaModifier, 'Meta'), + (Qt.ShiftModifier, 'Shift'), + ]) + + modifier_keys = (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, + Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, + Qt.Key_Hyper_L, Qt.Key_Hyper_R, Qt.Key_Direction_L, + Qt.Key_Direction_R) + if self.key in modifier_keys: + # Only modifier pressed + return '' + parts = [] + + for (mask, s) in modmask2str.items(): + if self.modifiers & mask and s not in parts: + parts.append(s) + + key_string = key_to_string(self.key) + + # FIXME needed? + if len(key_string) == 1: + category = unicodedata.category(key_string) + is_control_char = (category == 'Cc') + else: + is_control_char = False + + if self.modifiers == Qt.ShiftModifier and not is_control_char: + parts = [] + + parts.append(key_string) + normalized = normalize_keystr('+'.join(parts)) + if len(normalized) > 1: + # "special" binding + return '<{}>'.format(normalized) + else: + # "normal" binding + return normalized + + class KeySequence: def __init__(self, *args): @@ -248,16 +261,20 @@ class KeySequence: # FIXME handle more than 4 keys def __str__(self): + parts = [] + for info in self._sequence: + parts.append(str(info)) + return ''.join(parts) + + def __iter__(self): + """Iterate over KeyInfo objects.""" modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier | Qt.KeypadModifier | Qt.GroupSwitchModifier) - parts = [] for key in self._sequence: - part = key_with_modifiers_to_string( + yield KeyInfo( key=int(key) & ~modifier_mask, - modifiers=int(key) & modifier_mask) - parts.append(part) - return ''.join(parts) + modifiers=Qt.KeyboardModifiers(int(key) & modifier_mask)) def __repr__(self): return utils.get_repr(self, keys=str(self)) From 7a8fa5f46eccb45cf5aeea828cff8b89aa46c1e3 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Sun, 25 Feb 2018 18:40:16 -0500 Subject: [PATCH 242/524] Implement deduplication of searches on webkit --- qutebrowser/browser/webengine/webenginetab.py | 2 +- qutebrowser/browser/webkit/webkittab.py | 8 +++++++- tests/end2end/features/search.feature | 1 - 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 4c4de14c0..9dc2c3737 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -183,7 +183,7 @@ class WebEngineSearch(browsertab.AbstractSearch): def search(self, text, *, ignore_case='never', reverse=False, result_cb=None): - # When duplicate searching, don't search again (webkit behavior) + # Don't go to next entry on duplicate search if self.text == text and self.search_displayed: log.webview.debug("Ignoring duplicate search request" " for {}".format(text)) diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index aa3f5363e..5c038de62 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -146,8 +146,14 @@ class WebKitSearch(browsertab.AbstractSearch): def search(self, text, *, ignore_case='never', reverse=False, result_cb=None): - self.search_displayed = True + # Don't go to next entry on duplicate search + if self.text == text and self.search_displayed: + log.webview.debug("Ignoring duplicate search request" + " for {}".format(text)) + return + self.text = text + self.search_displayed = True self._flags = QWebPage.FindWrapsAroundDocument if self._is_case_sensitive(ignore_case): self._flags |= QWebPage.FindCaseSensitively diff --git a/tests/end2end/features/search.feature b/tests/end2end/features/search.feature index e322b93a2..55d9a98e9 100644 --- a/tests/end2end/features/search.feature +++ b/tests/end2end/features/search.feature @@ -52,7 +52,6 @@ Feature: Searching on a page And I wait for "search didn't find blub" in the log Then the warning "Text 'blub' not found on page!" should be shown - @qtwebkit_skip: Supported by default on qtwebkit Scenario: Searching text duplicates When I run :search foo And I wait for "search found foo" in the log From 76bf35cbdd9fa2cea125a4d763da7bbe6ee5a2bd Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Sun, 25 Feb 2018 19:00:15 -0500 Subject: [PATCH 243/524] Add qtbug60673 markers to relevant tests --- pytest.ini | 1 + qutebrowser/browser/webengine/webenginetab.py | 1 - tests/conftest.py | 5 +++++ tests/end2end/features/caret.feature | 4 ++-- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pytest.ini b/pytest.ini index 89571aebc..d6226c03b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -27,6 +27,7 @@ markers = no_invalid_lines: Don't fail on unparseable lines in end2end tests issue2478: Tests which are broken on Windows with QtWebEngine, https://github.com/qutebrowser/qutebrowser/issues/2478 issue3572: Tests which are broken with QtWebEngine and Qt 5.10, https://github.com/qutebrowser/qutebrowser/issues/3572 + qtbug60673: Tests which are broken if the conversion from orange selection to real selection is flakey fake_os: Fake utils.is_* to a fake operating system unicode_locale: Tests which need an unicode locale to work qt_log_level_fail = WARNING diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 2c87acdc3..072d5276a 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -218,7 +218,6 @@ class WebEngineCaret(browsertab.AbstractCaret): if mode != usertypes.KeyMode.caret: return - # Clear search, replace with blue selection if self._tab.search.search_displayed: # We are currently in search mode. # convert the search to a blue selection so we can operate on it diff --git a/tests/conftest.py b/tests/conftest.py index d9d5fc034..ba9e4ab9c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,6 +68,11 @@ def _apply_platform_markers(config, item): qtutils.version_check('5.10', compiled=False, exact=True) and config.webengine and 'TRAVIS' in os.environ, "Broken with QtWebEngine with Qt 5.10 on Travis"), + ('qtbug60673', + qtutils.version_check('5.8') and + not qtutils.version_check('5.10') and + config.webengine, + "Broken on webengine due to qtbug60673"), ('unicode_locale', sys.getfilesystemencoding() == 'ascii', "Skipped because of ASCII locale"), ] diff --git a/tests/end2end/features/caret.feature b/tests/end2end/features/caret.feature index e9cf54c8d..803016539 100644 --- a/tests/end2end/features/caret.feature +++ b/tests/end2end/features/caret.feature @@ -324,7 +324,7 @@ Feature: Caret mode # Search + caret mode # https://bugreports.qt.io/browse/QTBUG-60673 - @qt!=5.8.0 @qt!=5.9.0 @qt!=5.9.1 @qt!=5.9.2 @qt!=5.9.3 @qt!=5.9.4 + @qtbug60673 Scenario: yanking a searched line When I run :leave-mode And I run :search fiv @@ -334,7 +334,7 @@ Feature: Caret mode And I run :yank selection Then the clipboard should contain "five six" - @qt!=5.8.0 @qt!=5.9.0 @qt!=5.9.1 @qt!=5.9.2 @qt!=5.9.3 @qt!=5.9.4 + @qtbug60673 Scenario: yanking a searched line with multiple matches When I run :leave-mode And I run :search w From edd2f89d5dda22105c7aa10881f209a1b3ea9414 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 07:16:55 +0100 Subject: [PATCH 244/524] Update changelog --- doc/changelog.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index e85d9a188..14f880364 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -102,6 +102,7 @@ Fixed - Compatibility with Python 3.7 - Exception types are now shown properly with `:config-source` and `:config-edit`. - When using `:bookmark-add --toggle`, bookmarks are now saved properly. +- Crash when opening an invalid URL from an application on macOS. Removed ~~~~~~~ From 6f9c62b24a13997455dce642d6a61defcacded87 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 07:56:51 +0100 Subject: [PATCH 245/524] Improve marker descriptions --- pytest.ini | 2 +- tests/conftest.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pytest.ini b/pytest.ini index d6226c03b..1a3f625e9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -27,7 +27,7 @@ markers = no_invalid_lines: Don't fail on unparseable lines in end2end tests issue2478: Tests which are broken on Windows with QtWebEngine, https://github.com/qutebrowser/qutebrowser/issues/2478 issue3572: Tests which are broken with QtWebEngine and Qt 5.10, https://github.com/qutebrowser/qutebrowser/issues/3572 - qtbug60673: Tests which are broken if the conversion from orange selection to real selection is flakey + qtbug60673: Tests which are broken if the conversion from orange selection to real selection is flaky fake_os: Fake utils.is_* to a fake operating system unicode_locale: Tests which need an unicode locale to work qt_log_level_fail = WARNING diff --git a/tests/conftest.py b/tests/conftest.py index ba9e4ab9c..465dacd10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,7 +72,8 @@ def _apply_platform_markers(config, item): qtutils.version_check('5.8') and not qtutils.version_check('5.10') and config.webengine, - "Broken on webengine due to qtbug60673"), + "Broken on webengine due to " + "https://bugreports.qt.io/browse/QTBUG-60673"), ('unicode_locale', sys.getfilesystemencoding() == 'ascii', "Skipped because of ASCII locale"), ] From cdf6f52d155de3f56925b3a753339a0d470d43db Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 08:15:34 +0100 Subject: [PATCH 246/524] Update changelog [ci skip] --- doc/changelog.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 14f880364..a9e37b559 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -79,6 +79,7 @@ Changed - More performance improvements when opening/closing many tabs. - The `:version` page now has a button to pastebin the information. - Replacements like `{url}` can now be replaced as `{{url}}`. +- Entering caret browsing with QtWebEngine now works directly after a search. Fixed ~~~~~ From 79a337767a70bc562abcbc347b50fca8cc26bdcd Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 05:20:57 +0100 Subject: [PATCH 247/524] Initial work at making :fake-key work --- qutebrowser/browser/commands.py | 16 +++++++--------- qutebrowser/keyinput/keyutils.py | 8 ++++++++ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 8d7c0c2cf..701c8324f 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -34,7 +34,7 @@ from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners from qutebrowser.config import config, configdata from qutebrowser.browser import (urlmarks, browsertab, inspector, navigate, webelem, downloads) -from qutebrowser.keyinput import modeman +from qutebrowser.keyinput import modeman, keyutils from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, objreg, utils, standarddir) from qutebrowser.utils.usertypes import KeyMode @@ -2111,17 +2111,15 @@ class CommandDispatcher: keystring: The keystring to send. global_: If given, the keys are sent to the qutebrowser UI. """ - # FIXME: rewrite try: - keyinfos = utils.parse_keystring(keystring) - except utils.KeyParseError as e: + sequence = keyutils.KeySequence.parse(keystring) + except keyutils.KeyParseError as e: raise cmdexc.CommandError(str(e)) - for keyinfo in keyinfos: - press_event = QKeyEvent(QEvent.KeyPress, keyinfo.key, - keyinfo.modifiers, keyinfo.text) - release_event = QKeyEvent(QEvent.KeyRelease, keyinfo.key, - keyinfo.modifiers, keyinfo.text) + for keyinfo in sequence: + args = (keyinfo.key, keyinfo.modifiers, keyinfo.text()) + press_event = QKeyEvent(QEvent.KeyPress, *args) + release_event = QKeyEvent(QEvent.KeyRelease, *args) if global_: window = QApplication.focusWindow() diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 1e8b3f248..b0b05851a 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -251,6 +251,13 @@ class KeyInfo: # "normal" binding return normalized + def text(self): + """Get the text which would be displayed when pressing this key.""" + text = QKeySequence(self.key).toString() + if not self.modifiers & Qt.ShiftModifier: + text = text.lower() + return text + class KeySequence: @@ -327,6 +334,7 @@ class KeySequence: @classmethod def parse(cls, keystr): """Parse a keystring like or xyz and return a KeySequence.""" + # FIXME have multiple sequences in self! s = ', '.join(_parse_keystring(keystr)) new = cls(s) assert len(new) > 0 From d077f38ac41da7e534e6f05350b4ab6afec417ad Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 09:13:32 +0100 Subject: [PATCH 248/524] Store multiple QKeySequences in KeySequence --- qutebrowser/keyinput/keyutils.py | 68 +++++++++++++++++++++++--------- qutebrowser/utils/utils.py | 10 +++++ 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index b0b05851a..519b9d86a 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -21,6 +21,7 @@ import unicodedata import collections +import itertools import attr from PyQt5.QtCore import Qt @@ -261,15 +262,23 @@ class KeyInfo: class KeySequence: - def __init__(self, *args): - self._sequence = QKeySequence(*args) - for key in self._sequence: - assert key != Qt.Key_unknown - # FIXME handle more than 4 keys + _MAX_LEN = 4 + + def __init__(self, strings=None): + self._sequences = [] + if strings is None: + strings = [] + + for sub in utils.chunk(strings, 4): + # Catch old API usage FIXME + assert all(isinstance(s, str) for s in sub) + sequence = QKeySequence(', '.join(sub)) + self._sequences.append(sequence) + self._validate() def __str__(self): parts = [] - for info in self._sequence: + for info in self: parts.append(str(info)) return ''.join(parts) @@ -278,7 +287,7 @@ class KeySequence: modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier | Qt.KeypadModifier | Qt.GroupSwitchModifier) - for key in self._sequence: + for key in itertools.chain.from_iterable(self._sequences): yield KeyInfo( key=int(key) & ~modifier_mask, modifiers=Qt.KeyboardModifiers(int(key) & modifier_mask)) @@ -287,26 +296,38 @@ class KeySequence: return utils.get_repr(self, keys=str(self)) def __lt__(self, other): - return self._sequence < other._sequence + return self._sequences < other._sequences def __gt__(self, other): - return self._sequence > other._sequence + return self._sequences > other._sequences def __eq__(self, other): - return self._sequence == other._sequence + return self._sequences == other._sequences def __ne__(self, other): - return self._sequence != other._sequence + return self._sequences != other._sequences def __hash__(self): - return hash(self._sequence) + # FIXME is this correct? + return hash(tuple(self._sequences)) def __len__(self): - return len(self._sequence) + return sum(len(seq) for seq in self._sequences) + + def _validate(self): + for info in self: + assert info.key != Qt.Key_unknown def matches(self, other): + # FIXME test this # pylint: disable=protected-access - return self._sequence.matches(other._sequence) + assert self._sequences + assert other._sequences + for seq1, seq2 in zip(self._sequences, other._sequences): + match = seq1.matches(seq2) + if match != QKeySequence.ExactMatch: + return match + return QKeySequence.ExactMatch def append_event(self, ev): """Create a new KeySequence object with the given QKeyEvent added. @@ -325,17 +346,28 @@ class KeySequence: FIXME: create test cases! """ + # pylint: disable=protected-access + new = self.__class__() + new._sequences = self._sequences[:] + modifiers = ev.modifiers() if (modifiers == Qt.ShiftModifier and unicodedata.category(ev.text()) != 'Lu'): modifiers = Qt.KeyboardModifiers() - return self.__class__(*self._sequence, modifiers | ev.key()) + + if new._sequences and len(new._sequences[-1]) < self._MAX_LEN: + new._sequences[-1] = QKeySequence(*new._sequences[-1], + ev.key() | int(modifiers)) + else: + new._sequences.append(QKeySequence(ev.key() | int(modifiers))) + + new._validate() + return new @classmethod def parse(cls, keystr): """Parse a keystring like or xyz and return a KeySequence.""" - # FIXME have multiple sequences in self! - s = ', '.join(_parse_keystring(keystr)) - new = cls(s) + parts = list(_parse_keystring(keystr)) + new = cls(parts) assert len(new) > 0 return new diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 7c1d43d2b..9d95069c5 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -659,3 +659,13 @@ def yaml_dump(data, f=None): return None else: return yaml_data.decode('utf-8') + + +def chunk(elems, n): + """Yield successive n-sized chunks from elems. + + If elems % n != 0, the last chunk will be smaller. + """ + # FIXME test this + for i in range(0, len(elems), n): + yield elems[i:i + n] From be4cd94207e3da5743227e32af8b949e61f1b34d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 10:14:30 +0100 Subject: [PATCH 249/524] Try getting hints to work --- qutebrowser/keyinput/basekeyparser.py | 5 ++- qutebrowser/keyinput/keyutils.py | 12 +++++++ qutebrowser/keyinput/modeparsers.py | 45 ++++++++++++++------------- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 1052c0eeb..774decd6e 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -118,7 +118,7 @@ class BaseKeyParser(QObject): e: the KeyPressEvent from Qt. Return: - A self.Match member. + A QKeySequence match or None. """ key = e.key() txt = keyutils.keyevent_to_string(e) @@ -186,8 +186,10 @@ class BaseKeyParser(QObject): - The found binding with Match.definitive. """ assert sequence + assert not isinstance(sequence, str) for seq, cmd in self.bindings.items(): + assert not isinstance(seq, str), seq match = sequence.matches(seq) if match != QKeySequence.NoMatch: return (match, cmd) @@ -238,6 +240,7 @@ class BaseKeyParser(QObject): self.bindings = {} for key, cmd in config.key_instance.get_bindings_for(modename).items(): + assert not isinstance(key, str), key assert cmd self.bindings[key] = cmd diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 519b9d86a..626808f4a 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -352,6 +352,7 @@ class KeySequence: modifiers = ev.modifiers() if (modifiers == Qt.ShiftModifier and + ev.text() and unicodedata.category(ev.text()) != 'Lu'): modifiers = Qt.KeyboardModifiers() @@ -364,6 +365,17 @@ class KeySequence: new._validate() return new + def remove_last(self): + """Create a new KeySequence with the last key removed.""" + new = self.__class__() + new._sequences = self._sequeces[:] + if len(new._sequences[-1]) == 1: + del new._sequences[-1] + else: + new._sequences[-1] = QKeySequence(*new._sequences[-1][:-1]) + new._validate() + return new + @classmethod def parse(cls, keystr): """Parse a keystring like or xyz and return a KeySequence.""" diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 2e23e2aa5..37fde6a14 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -27,6 +27,7 @@ import traceback import enum from PyQt5.QtCore import pyqtSlot, Qt +from PyQt5.QtGui import QKeySequence from qutebrowser.commands import cmdexc from qutebrowser.config import config @@ -73,9 +74,9 @@ class NormalKeyParser(keyparser.CommandKeyParser): if self._inhibited: self._debug_log("Ignoring key '{}', because the normal mode is " "currently inhibited.".format(txt)) - return self.Match.none + return QKeySequence.NoMatch match = super()._handle_single_key(e) - if match == self.Match.partial: + if match == QKeySequence.PartialMatch: timeout = config.val.input.partial_timeout if timeout != 0: self._partial_timer.setInterval(timeout) @@ -97,9 +98,9 @@ class NormalKeyParser(keyparser.CommandKeyParser): def _clear_partial_match(self): """Clear a partial keystring after a timeout.""" self._debug_log("Clearing partial keystring {}".format( - self._keystring)) - self._keystring = '' - self.keystring_updated.emit(self._keystring) + self._sequence)) + self._sequence = keyutils.KeySequence() + self.keystring_updated.emit(str(self._sequence)) @pyqtSlot() def _clear_inhibited(self): @@ -174,28 +175,28 @@ class HintKeyParser(keyparser.CommandKeyParser): window=self._win_id, tab='current') if e.key() == Qt.Key_Backspace: log.keyboard.debug("Got backspace, mode {}, filtertext '{}', " - "keystring '{}'".format(self._last_press, - self._filtertext, - self._keystring)) + "sequence '{}'".format(self._last_press, + self._filtertext, + self._sequence)) if self._last_press == LastPress.filtertext and self._filtertext: self._filtertext = self._filtertext[:-1] hintmanager.filter_hints(self._filtertext) return True - elif self._last_press == LastPress.keystring and self._keystring: - self._keystring = self._keystring[:-1] - self.keystring_updated.emit(self._keystring) - if not self._keystring and self._filtertext: + elif self._last_press == LastPress.keystring and self._sequence: + self._sequence = self._sequence.remove_last() + self.keystring_updated.emit(str(self._sequence)) + if not self._sequence and self._filtertext: # Switch back to hint filtering mode (this can happen only # in numeric mode after the number has been deleted). hintmanager.filter_hints(self._filtertext) self._last_press = LastPress.filtertext return True else: - return super()._handle_special_key(e) + return False elif hintmanager.current_mode() != 'number': - return super()._handle_special_key(e) + return False elif not e.text(): - return super()._handle_special_key(e) + return False else: self._filtertext += e.text() hintmanager.filter_hints(self._filtertext) @@ -212,17 +213,17 @@ class HintKeyParser(keyparser.CommandKeyParser): True if the match has been handled, False otherwise. """ # FIXME rewrite this - match = self._handle_single_key(e) - if match == self.Match.partial: - self.keystring_updated.emit(self._keystring) + match = super().handle(e) + if match == QKeySequence.PartialMatch: + self.keystring_updated.emit(str(self._sequence)) self._last_press = LastPress.keystring return True - elif match == self.Match.definitive: + elif match == QKeySequence.ExactMatch: self._last_press = LastPress.none return True - elif match == self.Match.other: + elif match is None: # FIXME return None - elif match == self.Match.none: + elif match == QKeySequence.NoMatch: # We couldn't find a keychain so we check if it's a special key. return self._handle_special_key(e) else: @@ -248,7 +249,7 @@ class HintKeyParser(keyparser.CommandKeyParser): preserve_filter: Whether to keep the current value of `self._filtertext`. """ - self.bindings = {s: s for s in strings} + self.bindings = {keyutils.KeySequence(s): s for s in strings} if not preserve_filter: self._filtertext = '' From 9aa37febbedf3232d050c83b724a98994882310e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 10:33:18 +0100 Subject: [PATCH 250/524] Make hints work --- qutebrowser/keyinput/basekeyparser.py | 3 ++- qutebrowser/keyinput/modeparsers.py | 32 ++++++++++++++++----------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 774decd6e..5830171b8 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -61,6 +61,7 @@ class BaseKeyParser(QObject): _sequence: The currently entered key sequence _modename: The name of the input mode associated with this keyparser. _supports_count: Whether count is supported + # FIXME is this still needed? _supports_chains: Whether keychains are supported Signals: @@ -138,7 +139,7 @@ class BaseKeyParser(QObject): # self._debug_log("Ignoring, no text char") # return QKeySequence.NoMatch - if txt.isdigit(): + if txt.isdigit() and self._supports_count: assert len(txt) == 1, txt self._count += txt return None diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 37fde6a14..d22b27d22 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -213,8 +213,9 @@ class HintKeyParser(keyparser.CommandKeyParser): True if the match has been handled, False otherwise. """ # FIXME rewrite this - match = super().handle(e) + match = self._handle_key(e) if match == QKeySequence.PartialMatch: + # FIXME do we need to check self._sequence here? self.keystring_updated.emit(str(self._sequence)) self._last_press = LastPress.keystring return True @@ -229,17 +230,20 @@ class HintKeyParser(keyparser.CommandKeyParser): else: raise ValueError("Got invalid match type {}!".format(match)) - def execute(self, cmdstr, keytype, count=None): - """Handle a completed keychain.""" - if not isinstance(keytype, self.Type): - raise TypeError("Type {} is no Type member!".format(keytype)) - if keytype == self.Type.chain: - hintmanager = objreg.get('hintmanager', scope='tab', - window=self._win_id, tab='current') - hintmanager.handle_partial_key(cmdstr) - else: - # execute as command - super().execute(cmdstr, keytype, count) + return match != QKeySequence.NoMatch + + # FIXME why is this needed? + # def execute(self, cmdstr, keytype, count=None): + # """Handle a completed keychain.""" + # if not isinstance(keytype, self.Type): + # raise TypeError("Type {} is no Type member!".format(keytype)) + # if keytype == self.Type.chain: + # hintmanager = objreg.get('hintmanager', scope='tab', + # window=self._win_id, tab='current') + # hintmanager.handle_partial_key(cmdstr) + # else: + # # execute as command + # super().execute(cmdstr, keytype, count) def update_bindings(self, strings, preserve_filter=False): """Update bindings when the hint strings changed. @@ -249,7 +253,9 @@ class HintKeyParser(keyparser.CommandKeyParser): preserve_filter: Whether to keep the current value of `self._filtertext`. """ - self.bindings = {keyutils.KeySequence(s): s for s in strings} + self._read_config() + self.bindings.update({keyutils.KeySequence(s): + 'follow-hint ' + s for s in strings}) if not preserve_filter: self._filtertext = '' From f92bb164083f66c88959857862a4ba1135a6a888 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 10:38:59 +0100 Subject: [PATCH 251/524] Make config.bind work --- qutebrowser/config/configfiles.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index ba43e5015..82f90db76 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -33,6 +33,7 @@ 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 @@ -357,12 +358,14 @@ class ConfigAPI: def bind(self, key, command, mode='normal'): """Bind a key to a command, with an optional key mode.""" with self._handle_error('binding', key): - self._keyconfig.bind(key, command, mode=mode) + 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): - self._keyconfig.unbind(key, mode=mode) + seq = keyutils.KeySequence.parse(key) + self._keyconfig.unbind(seq, mode=mode) def source(self, filename): """Read the given config file from disk.""" From 16940db8342a083cc56a6492f1b34a91a5513466 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 11:10:05 +0100 Subject: [PATCH 252/524] Refactor KeySequence initialization --- qutebrowser/keyinput/keyutils.py | 54 ++++++++++++----------------- qutebrowser/keyinput/modeparsers.py | 4 +-- qutebrowser/misc/keyhintwidget.py | 2 +- 3 files changed, 25 insertions(+), 35 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 626808f4a..bd12ccb2c 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -264,16 +264,13 @@ class KeySequence: _MAX_LEN = 4 - def __init__(self, strings=None): + def __init__(self, *keys): self._sequences = [] - if strings is None: - strings = [] - - for sub in utils.chunk(strings, 4): - # Catch old API usage FIXME - assert all(isinstance(s, str) for s in sub) - sequence = QKeySequence(', '.join(sub)) + for sub in utils.chunk(keys, self._MAX_LEN): + sequence = QKeySequence(*sub) self._sequences.append(sequence) + if keys: + assert len(self) > 0 self._validate() def __str__(self): @@ -287,7 +284,7 @@ class KeySequence: modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier | Qt.KeypadModifier | Qt.GroupSwitchModifier) - for key in itertools.chain.from_iterable(self._sequences): + for key in self._iter_keys(): yield KeyInfo( key=int(key) & ~modifier_mask, modifiers=Qt.KeyboardModifiers(int(key) & modifier_mask)) @@ -314,6 +311,13 @@ class KeySequence: def __len__(self): return sum(len(seq) for seq in self._sequences) + def __getitem__(self, item): + keys = list(self._iter_keys()) + return self.__class__(*keys[item]) + + def _iter_keys(self): + return itertools.chain.from_iterable(self._sequences) + def _validate(self): for info in self: assert info.key != Qt.Key_unknown @@ -347,39 +351,25 @@ class KeySequence: FIXME: create test cases! """ # pylint: disable=protected-access - new = self.__class__() - new._sequences = self._sequences[:] - modifiers = ev.modifiers() if (modifiers == Qt.ShiftModifier and ev.text() and unicodedata.category(ev.text()) != 'Lu'): modifiers = Qt.KeyboardModifiers() - if new._sequences and len(new._sequences[-1]) < self._MAX_LEN: - new._sequences[-1] = QKeySequence(*new._sequences[-1], - ev.key() | int(modifiers)) - else: - new._sequences.append(QKeySequence(ev.key() | int(modifiers))) + keys = list(self._iter_keys()) + keys.append(ev.key() | int(modifiers)) - new._validate() - return new - - def remove_last(self): - """Create a new KeySequence with the last key removed.""" - new = self.__class__() - new._sequences = self._sequeces[:] - if len(new._sequences[-1]) == 1: - del new._sequences[-1] - else: - new._sequences[-1] = QKeySequence(*new._sequences[-1][:-1]) - new._validate() - return new + return self.__class__(*keys) @classmethod def parse(cls, keystr): """Parse a keystring like or xyz and return a KeySequence.""" - parts = list(_parse_keystring(keystr)) - new = cls(parts) + new = cls() + strings = list(_parse_keystring(keystr)) + for sub in utils.chunk(strings, cls._MAX_LEN): + sequence = QKeySequence(', '.join(sub)) + new._sequences.append(sequence) assert len(new) > 0 + new._validate() return new diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index d22b27d22..7237a34de 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -183,7 +183,7 @@ class HintKeyParser(keyparser.CommandKeyParser): hintmanager.filter_hints(self._filtertext) return True elif self._last_press == LastPress.keystring and self._sequence: - self._sequence = self._sequence.remove_last() + self._sequence = self._sequence[:-1] self.keystring_updated.emit(str(self._sequence)) if not self._sequence and self._filtertext: # Switch back to hint filtering mode (this can happen only @@ -254,7 +254,7 @@ class HintKeyParser(keyparser.CommandKeyParser): `self._filtertext`. """ self._read_config() - self.bindings.update({keyutils.KeySequence(s): + self.bindings.update({keyutils.KeySequence.parse(s): 'follow-hint ' + s for s in strings}) if not preserve_filter: self._filtertext = '' diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 0aa52116e..967bdd541 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -107,7 +107,7 @@ class KeyHintView(QLabel): bindings_dict = config.key_instance.get_bindings_for(modename) bindings = [(k, v) for (k, v) in sorted(bindings_dict.items()) - if k.matches(keyutils.KeySequence(prefix)) and # FIXME + if k.matches(keyutils.KeySequence.parse(prefix)) and # FIXME not blacklisted(k) and (takes_count(v) or not countstr)] From 1609e0d445f270087e081992e06c790043d128b7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 11:16:56 +0100 Subject: [PATCH 253/524] Fix keyhint widget --- qutebrowser/misc/keyhintwidget.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 967bdd541..b9985dbd0 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -107,7 +107,7 @@ class KeyHintView(QLabel): bindings_dict = config.key_instance.get_bindings_for(modename) bindings = [(k, v) for (k, v) in sorted(bindings_dict.items()) - if k.matches(keyutils.KeySequence.parse(prefix)) and # FIXME + if keyutils.KeySequence.parse(prefix).matches(k) and not blacklisted(k) and (takes_count(v) or not countstr)] @@ -121,7 +121,7 @@ class KeyHintView(QLabel): suffix_color = html.escape(config.val.colors.keyhint.suffix.fg) text = '' - for key, cmd in bindings: + for seq, cmd in bindings: text += ( "" "{}" @@ -131,7 +131,7 @@ class KeyHintView(QLabel): ).format( html.escape(prefix), suffix_color, - html.escape(key[len(prefix):]), + html.escape(str(seq[len(prefix):])), html.escape(cmd) ) text = '{}
'.format(text) From 508a12a84cd80cb51b92038fdd4037e36052eb8a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 11:36:24 +0100 Subject: [PATCH 254/524] Try fixing KeyInfo.__str__ with lower-/uppercase chars --- qutebrowser/keyinput/keyutils.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index bd12ccb2c..2dfced76d 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -233,24 +233,28 @@ class KeyInfo: key_string = key_to_string(self.key) - # FIXME needed? if len(key_string) == 1: category = unicodedata.category(key_string) - is_control_char = (category == 'Cc') + is_special_char = (category == 'Cc') else: - is_control_char = False + is_special_char = False - if self.modifiers == Qt.ShiftModifier and not is_control_char: - parts = [] + if not is_special_char: + if self.modifiers == Qt.ShiftModifier: + parts = [] + key_string = key_string.upper() + else: + key_string = key_string.lower() parts.append(key_string) - normalized = normalize_keystr('+'.join(parts)) - if len(normalized) > 1: + part_string = '+'.join(parts) + + if len(part_string) > 1: # "special" binding - return '<{}>'.format(normalized) + return '<{}>'.format(part_string) else: # "normal" binding - return normalized + return part_string def text(self): """Get the text which would be displayed when pressing this key.""" From 0afaf2ce897fa090414253263fbac4d4d6ca66ec Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 11:48:10 +0100 Subject: [PATCH 255/524] Fix capital chars after string change --- qutebrowser/keyinput/basekeyparser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 5830171b8..8eafccbb5 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -125,7 +125,7 @@ class BaseKeyParser(QObject): txt = keyutils.keyevent_to_string(e) self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) - if txt is None: + if not txt: self._debug_log("Ignoring, no text char") return QKeySequence.NoMatch From f15e2285bab01e6175b3f457312c086cf0c85b17 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 13:41:01 +0100 Subject: [PATCH 256/524] Fix bindings.key_mappings --- qutebrowser/keyinput/basekeyparser.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 8eafccbb5..16691af4c 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -147,14 +147,9 @@ class BaseKeyParser(QObject): sequence = self._sequence.append_event(e) match, binding = self._match_key(sequence) if match == QKeySequence.NoMatch: - mappings = config.val.bindings.key_mappings - mapped = mappings.get(txt, None) + mapped = config.val.bindings.key_mappings.get(sequence, None) if mapped is not None: - # FIXME - raise Exception - txt = mapped - sequence = self._sequence.append_event(e) - match, binding = self._match_key(sequence) + match, binding = self._match_key(mapped) self._sequence = self._sequence.append_event(e) if match == QKeySequence.ExactMatch: From 2698b8bb63143501610eba5b47aa3c1e18cfc033 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 13:47:45 +0100 Subject: [PATCH 257/524] Fix unicodedata check --- qutebrowser/keyinput/keyutils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 2dfced76d..a82acbced 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -356,8 +356,9 @@ class KeySequence: """ # pylint: disable=protected-access modifiers = ev.modifiers() + if (modifiers == Qt.ShiftModifier and - ev.text() and + len(ev.text()) == 1 and unicodedata.category(ev.text()) != 'Lu'): modifiers = Qt.KeyboardModifiers() From bb647123b722e044400250bc079cb7d09c6718f5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 14:13:46 +0100 Subject: [PATCH 258/524] Fix invalid key sequences --- qutebrowser/keyinput/keyutils.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index a82acbced..53d9fe2c3 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -137,7 +137,11 @@ class KeyParseError(Exception): """Raised by _parse_single_key/parse_keystring on parse errors.""" def __init__(self, keystr, error): - super().__init__("Could not parse {!r}: {}".format(keystr, error)) + if keystr is None: + msg = "Could not parse keystring: {}".format(error) + else: + msg = "Could not parse {!r}: {}".format(keystr, error) + super().__init__(msg) def _parse_keystring(keystr): @@ -322,9 +326,10 @@ class KeySequence: def _iter_keys(self): return itertools.chain.from_iterable(self._sequences) - def _validate(self): + def _validate(self, keystr=None): for info in self: - assert info.key != Qt.Key_unknown + if info.key == Qt.Key_unknown: + raise KeyParseError(keystr, "Got unknown key!") def matches(self, other): # FIXME test this @@ -376,5 +381,5 @@ class KeySequence: sequence = QKeySequence(', '.join(sub)) new._sequences.append(sequence) assert len(new) > 0 - new._validate() + new._validate(keystr) return new From 1444634abb1ed2d1e3d4c34bb3dfd48a7dd73b29 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 14:26:12 +0100 Subject: [PATCH 259/524] Fix :fake-key test --- tests/end2end/features/keyinput.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index ee5b667e8..5628a5796 100644 --- a/tests/end2end/features/keyinput.feature +++ b/tests/end2end/features/keyinput.feature @@ -54,7 +54,7 @@ Feature: Keyboard input Scenario: :fake-key with an unparsable key When I run :fake-key - Then the error "Could not parse 'blub': Got unknown key." should be shown + Then the error "Could not parse '': Got unknown key." should be shown Scenario: :fake-key sending key to the website When I open data/keyinput/log.html From 3dedd0d17810b3d72066240978c6adb2271d246a Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 26 Feb 2018 17:04:17 +0100 Subject: [PATCH 260/524] Update hypothesis from 3.45.2 to 3.46.0 --- misc/requirements/requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 796ed8085..5bcb18f7b 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -11,7 +11,7 @@ fields==5.0.0 Flask==0.12.2 glob2==0.6 hunter==2.0.2 -hypothesis==3.45.2 +hypothesis==3.46.0 itsdangerous==0.24 # Jinja2==2.10 Mako==1.0.7 From 416712d2dc17e285b4aa0472f45e2154b13778bf Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 26 Feb 2018 17:04:18 +0100 Subject: [PATCH 261/524] Update pytest from 3.4.0 to 3.4.1 --- misc/requirements/requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 5bcb18f7b..cd0d2f557 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -22,7 +22,7 @@ parse-type==0.4.2 pluggy==0.6.0 py==1.5.2 py-cpuinfo==3.3.0 -pytest==3.4.0 +pytest==3.4.1 pytest-bdd==2.20.0 pytest-benchmark==3.1.1 pytest-cov==2.5.1 From f1b20f6dc478933efa361eb6ce0c9f5af2891357 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 20:02:43 +0100 Subject: [PATCH 262/524] Fix forward_unbound_keys test --- qutebrowser/keyinput/keyutils.py | 4 ++++ tests/end2end/features/keyinput.feature | 9 ++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 53d9fe2c3..f0832db15 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -263,6 +263,10 @@ class KeyInfo: def text(self): """Get the text which would be displayed when pressing this key.""" text = QKeySequence(self.key).toString() + if len(text) > 1: + # Special key? + return '' + if not self.modifiers & Qt.ShiftModifier: text = text.lower() return text diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index 5628a5796..b0cd765de 100644 --- a/tests/end2end/features/keyinput.feature +++ b/tests/end2end/features/keyinput.feature @@ -33,14 +33,13 @@ Feature: Keyboard input Scenario: Forwarding special keys When I open data/keyinput/log.html And I set input.forward_unbound_keys to auto - And I press the key "x" - And I press the key "" + And I press the keys "," # Then the javascript message "key press: 112" should be logged And the javascript message "key release: 112" should be logged - # x - And the javascript message "key press: 88" should not be logged - And the javascript message "key release: 88" should not be logged + # , + And the javascript message "key press: 188" should not be logged + And the javascript message "key release: 188" should not be logged Scenario: Forwarding no keys When I open data/keyinput/log.html From 8bce2ba8e806db1939f9c960c82040ef6222a867 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 20:03:21 +0100 Subject: [PATCH 263/524] Fix expected message --- tests/end2end/features/keyinput.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index b0cd765de..1337a48d3 100644 --- a/tests/end2end/features/keyinput.feature +++ b/tests/end2end/features/keyinput.feature @@ -53,7 +53,7 @@ Feature: Keyboard input Scenario: :fake-key with an unparsable key When I run :fake-key - Then the error "Could not parse '': Got unknown key." should be shown + Then the error "Could not parse '': Got unknown key!" should be shown Scenario: :fake-key sending key to the website When I open data/keyinput/log.html From 9f0e1a98a0c2bb255c7305984aefc553787ab84b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 20:22:52 +0100 Subject: [PATCH 264/524] Make hint keybinding inhibition work --- qutebrowser/keyinput/modeparsers.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 7237a34de..84eb5baf1 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -60,8 +60,8 @@ class NormalKeyParser(keyparser.CommandKeyParser): def __repr__(self): return utils.get_repr(self) - def _handle_single_key(self, e): - """Override _handle_single_key to abort if the key is a startchar. + def handle(self, e): + """Override to abort if the key is a startchar. Args: e: the KeyPressEvent from Qt. @@ -69,13 +69,14 @@ class NormalKeyParser(keyparser.CommandKeyParser): Return: A self.Match member. """ - # FIXME rewrite this txt = e.text().strip() if self._inhibited: self._debug_log("Ignoring key '{}', because the normal mode is " "currently inhibited.".format(txt)) return QKeySequence.NoMatch - match = super()._handle_single_key(e) + + match = super().handle(e) + if match == QKeySequence.PartialMatch: timeout = config.val.input.partial_timeout if timeout != 0: From e9d58dae2af9cf82291dd7b441800c1dc91ecbef Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 20:48:11 +0100 Subject: [PATCH 265/524] Fix getting individual items from KeySequence --- qutebrowser/keyinput/keyutils.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index f0832db15..e2df6a443 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -324,8 +324,12 @@ class KeySequence: return sum(len(seq) for seq in self._sequences) def __getitem__(self, item): - keys = list(self._iter_keys()) - return self.__class__(*keys[item]) + if isinstance(item, slice): + keys = list(self._iter_keys()) + return self.__class__(*keys[item]) + else: + infos = list(self) + return infos[item] def _iter_keys(self): return itertools.chain.from_iterable(self._sequences) From 6fc391986fe26aab29d79c6fad398db7318b4fef Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 20:48:22 +0100 Subject: [PATCH 266/524] Fix KeyInfo.text() for space --- qutebrowser/keyinput/keyutils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index e2df6a443..9be7b4d60 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -263,7 +263,9 @@ class KeyInfo: def text(self): """Get the text which would be displayed when pressing this key.""" text = QKeySequence(self.key).toString() - if len(text) > 1: + if self.key == Qt.Key_Space: + return ' ' + elif len(text) > 1: # Special key? return '' From de3b4adfd8fed7ad549aeb4f2f27993ad9aa2f3e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 20:48:49 +0100 Subject: [PATCH 267/524] Don't force-follow hints when typing chars --- qutebrowser/browser/hints.py | 11 +++++++++-- qutebrowser/keyinput/modeparsers.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 0390d5d1f..1d0184c73 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -909,20 +909,27 @@ class HintManager(QObject): @cmdutils.register(instance='hintmanager', scope='tab', modes=[usertypes.KeyMode.hint]) - def follow_hint(self, keystring=None): + def follow_hint(self, select=False, keystring=None): """Follow a hint. Args: + select: Only select the given hint, don't necessarily follow it. keystring: The hint to follow, or None. """ if keystring is None: if self._context.to_follow is None: raise cmdexc.CommandError("No hint to follow") + elif select: + raise cmdexc.CommandError("Can't use --select without hint.") else: keystring = self._context.to_follow elif keystring not in self._context.labels: raise cmdexc.CommandError("No hint {}!".format(keystring)) - self._fire(keystring) + + if select: + self._handle_auto_follow(keystring) + else: + self._fire(keystring) @pyqtSlot(usertypes.KeyMode) def on_mode_left(self, mode): diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 84eb5baf1..583a65707 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -256,7 +256,7 @@ class HintKeyParser(keyparser.CommandKeyParser): """ self._read_config() self.bindings.update({keyutils.KeySequence.parse(s): - 'follow-hint ' + s for s in strings}) + 'follow-hint -s ' + s for s in strings}) if not preserve_filter: self._filtertext = '' From d9ae3fd5aa70851e4e7b1f2625d8ec58652d1592 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 20:49:02 +0100 Subject: [PATCH 268/524] Fix more hinting issues --- qutebrowser/keyinput/modeparsers.py | 1 + tests/end2end/features/hints.feature | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 583a65707..72f5c5027 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -170,6 +170,7 @@ class HintKeyParser(keyparser.CommandKeyParser): True if event has been handled, False otherwise. """ # FIXME rewrite this + # FIXME should backspacing be a more generic hint feature? log.keyboard.debug("Got special key 0x{:x} text {}".format( e.key(), e.text())) hintmanager = objreg.get('hintmanager', scope='tab', diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index eb6a24df9..a2ac468d0 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -338,7 +338,7 @@ Feature: Using hints And I set hints.auto_follow to unique-match And I set hints.auto_follow_timeout to 0 And I hint with args "all" - And I press the keys "ten pos" + And I press the keys "ten p" Then data/numbers/11.txt should be loaded Scenario: Scattering is ignored with number hints From 01462008c9983d75c7e39d337bf25e06d97459eb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 22:48:32 +0100 Subject: [PATCH 269/524] Clearly separate yesno/prompt key modes --- qutebrowser/config/configdata.yml | 20 ++++++++++++-------- qutebrowser/keyinput/modeparsers.py | 4 +--- qutebrowser/mainwindow/prompt.py | 4 ++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index d23682db8..7be015fd5 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2379,8 +2379,6 @@ bindings.default: : leave-mode prompt: : prompt-accept - y: prompt-accept yes - n: prompt-accept no : prompt-open-download : prompt-item-focus prev : prompt-item-focus prev @@ -2403,6 +2401,14 @@ bindings.default: : rl-backward-delete-char : rl-yank : leave-mode + # FIXME can we do migrations? + yesno: + : prompt-accept + y: prompt-accept yes + n: prompt-accept no + : prompt-yank + : prompt-yank --sel + : leave-mode caret: v: toggle-selection : toggle-selection @@ -2438,7 +2444,7 @@ bindings.default: none_ok: true keytype: String # section name fixed_keys: ['normal', 'insert', 'hint', 'passthrough', 'command', - 'prompt', 'caret', 'register'] + 'prompt', 'yesno', 'caret', 'register'] valtype: name: Dict none_ok: true @@ -2462,7 +2468,7 @@ bindings.commands: none_ok: true keytype: String # section name fixed_keys: ['normal', 'insert', 'hint', 'passthrough', 'command', - 'prompt', 'caret', 'register'] + 'prompt', 'yesno', 'caret', 'register'] valtype: name: Dict none_ok: true @@ -2534,10 +2540,8 @@ bindings.commands: * prompt: Entered when there's a prompt to display, like for download locations or when invoked from JavaScript. - + - You can bind normal keys in this mode, but they will be only active when - a yes/no-prompt is asked. For other prompt modes, you can only bind - special keys. + + * yesno: Entered when there's a yes/no prompt displayed. * caret: Entered when pressing the `v` mode, used to select text using the keyboard. diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 72f5c5027..0b196c23d 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -133,9 +133,7 @@ class PromptKeyParser(keyparser.CommandKeyParser): def __init__(self, win_id, parent=None): super().__init__(win_id, parent, supports_count=False, supports_chains=True) - # We don't want an extra section for this in the config, so we just - # abuse the prompt section. - self._read_config('prompt') + self._read_config('yesno') def __repr__(self): return utils.get_repr(self) diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py index 931d32654..90415b261 100644 --- a/qutebrowser/mainwindow/prompt.py +++ b/qutebrowser/mainwindow/prompt.py @@ -507,8 +507,8 @@ class _BasePrompt(QWidget): self._key_grid = QGridLayout() self._key_grid.setVerticalSpacing(0) - # The bindings are all in the 'prompt' mode, even for yesno prompts - all_bindings = config.key_instance.get_reverse_bindings_for('prompt') + all_bindings = config.key_instance.get_reverse_bindings_for( + self.KEY_MODE.name) labels = [] for cmd, text in self._allowed_commands(): From 53fb5af99c2a376891ea3a6372827c5a52d77b8a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 26 Feb 2018 23:09:55 +0100 Subject: [PATCH 270/524] Paste version information privately --- qutebrowser/misc/pastebin.py | 6 +++++- qutebrowser/utils/version.py | 3 ++- tests/unit/misc/test_pastebin.py | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/qutebrowser/misc/pastebin.py b/qutebrowser/misc/pastebin.py index 0f2ed8ce4..f317670ec 100644 --- a/qutebrowser/misc/pastebin.py +++ b/qutebrowser/misc/pastebin.py @@ -60,7 +60,7 @@ class PastebinClient(QObject): self._client = client self._api_url = api_url - def paste(self, name, title, text, parent=None): + def paste(self, name, title, text, parent=None, private=False): """Paste the text into a pastebin and return the URL. Args: @@ -68,6 +68,7 @@ class PastebinClient(QObject): title: The post title. text: The text to post. parent: The parent paste to reply to. + private: Whether to paste privately. """ data = { 'text': text, @@ -77,6 +78,9 @@ class PastebinClient(QObject): } if parent is not None: data['reply'] = parent + if private: + data['private'] = '1' + url = QUrl(urllib.parse.urljoin(self._api_url, 'create')) self._client.post(url, data) diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 71e33886f..d77194075 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -486,4 +486,5 @@ def pastebin_version(pbclient=None): pbclient.paste(getpass.getuser(), "qute version info {}".format(qutebrowser.__version__), - version()) + version(), + private=True) diff --git a/tests/unit/misc/test_pastebin.py b/tests/unit/misc/test_pastebin.py index 1d684dc4e..cbd6f4c3e 100644 --- a/tests/unit/misc/test_pastebin.py +++ b/tests/unit/misc/test_pastebin.py @@ -79,6 +79,20 @@ def test_paste_without_parent(data, pbclient): assert http_stub.url == QUrl('https://crashes.qutebrowser.org/api/create') +def test_paste_private(pbclient): + data = { + "name": "the name", + "title": "the title", + "text": "some Text", + "apikey": "ihatespam", + "private": "1", + } + http_stub = pbclient._client + pbclient.paste(data["name"], data["title"], data["text"], private=True) + assert pbclient._client.data == data + assert http_stub.url == QUrl('https://crashes.qutebrowser.org/api/create') + + @pytest.mark.parametrize('http', [ "http://paste.the-compiler.org/view/ges83nt3", "http://paste.the-compiler.org/view/3gjnwg4" From bc3e1b316d91f76dc1b385b8b4f3533d2ae1738d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 06:23:00 +0100 Subject: [PATCH 271/524] Use "command -v" instead of "which" in bash scripts shellcheck recently added SC2330 checking for this. "which" is non-standard, and not guaranteed by POSIX to have a meaningful exit status, while "command -v" is specified by POSIX: https://stackoverflow.com/q/592620 --- misc/userscripts/open_download | 2 +- misc/userscripts/password_fill | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/misc/userscripts/open_download b/misc/userscripts/open_download index ecc1d7209..8dbb11384 100755 --- a/misc/userscripts/open_download +++ b/misc/userscripts/open_download @@ -52,7 +52,7 @@ die() { if ! [ -d "$DOWNLOAD_DIR" ] ; then die "Download directory »$DOWNLOAD_DIR« not found!" fi -if ! which "${ROFI_CMD}" > /dev/null ; then +if ! command -v "${ROFI_CMD}" > /dev/null ; then die "Rofi command »${ROFI_CMD}« not found in PATH!" fi diff --git a/misc/userscripts/password_fill b/misc/userscripts/password_fill index 8dba68c2b..a61a42c68 100755 --- a/misc/userscripts/password_fill +++ b/misc/userscripts/password_fill @@ -220,7 +220,7 @@ user_pattern='^(user|username|login): ' GPG_OPTS=( "--quiet" "--yes" "--compress-algo=none" "--no-encrypt-to" ) GPG="gpg" export GPG_TTY="${GPG_TTY:-$(tty 2>/dev/null)}" -which gpg2 &>/dev/null && GPG="gpg2" +command -v gpg2 &>/dev/null && GPG="gpg2" [[ -n $GPG_AGENT_INFO || $GPG == "gpg2" ]] && GPG_OPTS+=( "--batch" "--use-agent" ) pass_backend() { From 8416e97c6c8fba7088bc04934b2523e0f1b18da7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 06:50:57 +0100 Subject: [PATCH 272/524] Fix type which is stubbed in test_models --- tests/unit/completion/test_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 32e6920fe..c3593031c 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -99,7 +99,7 @@ def configdata_stub(config_stub, monkeypatch, configdata_init): typ=configtypes.Dict( keytype=configtypes.String(), valtype=configtypes.Dict( - keytype=configtypes.String(), + keytype=configtypes.Key(), valtype=configtypes.Command(), ), ), @@ -117,7 +117,7 @@ def configdata_stub(config_stub, monkeypatch, configdata_init): typ=configtypes.Dict( keytype=configtypes.String(), valtype=configtypes.Dict( - keytype=configtypes.String(), + keytype=configtypes.Key(), valtype=configtypes.Command(), ), ), From 1b0aea5e0578bb439a8b564c7e4a944a7fb0c468 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 06:56:57 +0100 Subject: [PATCH 273/524] Bring simple bindings to front in get_reverse_bindings_for --- qutebrowser/config/config.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 14022d1d2..0a106ea05 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -157,16 +157,15 @@ class KeyConfig: """Get a dict of commands to a list of bindings for the mode.""" cmd_to_keys = {} bindings = self.get_bindings_for(mode) - for key, full_cmd in sorted(bindings.items()): + for seq, full_cmd in sorted(bindings.items()): for cmd in full_cmd.split(';;'): cmd = cmd.strip() cmd_to_keys.setdefault(cmd, []) - # put special bindings last - # FIXME update - # if utils.is_special_key(key): - # cmd_to_keys[cmd].append(key) - # else: - cmd_to_keys[cmd].insert(0, str(key)) + # 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, mode, default=False): From fa29a0b68620d938bb201bda8f617f95fc22aec4 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 06:58:39 +0100 Subject: [PATCH 274/524] Expect capitalized bindings in test_models --- tests/unit/completion/test_models.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index c3593031c..d5ec4433b 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -105,7 +105,7 @@ def configdata_stub(config_stub, monkeypatch, configdata_init): ), default={ 'normal': collections.OrderedDict([ - ('', 'quit'), + ('', 'quit'), ('d', 'tab-close'), ]) }, @@ -123,7 +123,7 @@ def configdata_stub(config_stub, monkeypatch, configdata_init): ), default={ 'normal': collections.OrderedDict([ - ('', 'quit'), + ('', 'quit'), ('ZQ', 'quit'), ('I', 'invalid'), ('d', 'scroll down'), @@ -215,7 +215,7 @@ def test_command_completion(qtmodeltester, cmdutils_stub, configdata_stub, "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), - ('quit', 'quit qutebrowser', 'ZQ, '), + ('quit', 'quit qutebrowser', 'ZQ, '), ('tab-close', 'Close the current tab.', ''), ] }) @@ -240,7 +240,7 @@ def test_help_completion(qtmodeltester, cmdutils_stub, key_config_stub, _check_completions(model, { "Commands": [ (':open', 'open a url', ''), - (':quit', 'quit qutebrowser', 'ZQ, '), + (':quit', 'quit qutebrowser', 'ZQ, '), (':scroll', 'Scroll the current tab in the given direction.', ''), (':tab-close', 'Close the current tab.', ''), ], @@ -644,10 +644,10 @@ def test_setting_option_completion(qtmodeltester, config_stub, "Options": [ ('aliases', 'Aliases for commands.', '{"q": "quit"}'), ('bindings.commands', 'Default keybindings', ( - '{"normal": {"": "quit", "ZQ": "quit", ' + '{"normal": {"": "quit", "ZQ": "quit", ' '"I": "invalid", "d": "scroll down"}}')), ('bindings.default', 'Default keybindings', - '{"normal": {"": "quit", "d": "tab-close"}}'), + '{"normal": {"": "quit", "d": "tab-close"}}'), ] }) @@ -674,7 +674,7 @@ def test_bind_completion(qtmodeltester, cmdutils_stub, config_stub, "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), - ('quit', 'quit qutebrowser', 'ZQ, '), + ('quit', 'quit qutebrowser', 'ZQ, '), ('scroll', 'Scroll the current tab in the given direction.', ''), ('tab-close', 'Close the current tab.', ''), ], @@ -694,7 +694,7 @@ def test_bind_completion_invalid(cmdutils_stub, config_stub, key_config_stub, "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), - ('quit', 'quit qutebrowser', 'ZQ, '), + ('quit', 'quit qutebrowser', 'ZQ, '), ('scroll', 'Scroll the current tab in the given direction.', ''), ('tab-close', 'Close the current tab.', ''), ], @@ -713,7 +713,7 @@ def test_bind_completion_no_binding(qtmodeltester, cmdutils_stub, config_stub, "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), - ('quit', 'quit qutebrowser', 'ZQ, '), + ('quit', 'quit qutebrowser', 'ZQ, '), ('scroll', 'Scroll the current tab in the given direction.', ''), ('tab-close', 'Close the current tab.', ''), ], @@ -735,7 +735,7 @@ def test_bind_completion_changed(cmdutils_stub, config_stub, key_config_stub, "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), - ('quit', 'quit qutebrowser', 'ZQ, '), + ('quit', 'quit qutebrowser', 'ZQ, '), ('scroll', 'Scroll the current tab in the given direction.', ''), ('tab-close', 'Close the current tab.', ''), ], From 49f8bc3d638fc7572455fad476e96b0f303cd882 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 07:37:55 +0100 Subject: [PATCH 275/524] Use KeySequences correctly in test_config.py --- tests/unit/config/test_config.py | 146 ++++++++++++++++++++----------- 1 file changed, 95 insertions(+), 51 deletions(-) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 2800236f4..a2a2697b1 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -39,6 +39,11 @@ def configdata_init(): configdata.init() +# Alias because we need this a lot in here. +def keyseq(s): + return keyutils.KeySequence.parse(s) + + class TestChangeFilter: @pytest.fixture(autouse=True) @@ -101,9 +106,8 @@ class TestKeyConfig: def test_prepare_invalid_mode(self, key_config_stub): """Make sure prepare checks the mode.""" - seq = keyutils.KeySequence('x') with pytest.raises(configexc.KeybindingError): - assert key_config_stub._prepare(seq, 'abnormal') + assert key_config_stub._prepare(keyseq('x'), 'abnormal') def test_prepare_invalid_type(self, key_config_stub): """Make sure prepare checks the type.""" @@ -112,32 +116,50 @@ class TestKeyConfig: @pytest.mark.parametrize('commands, expected', [ # Unbinding default key - ({'a': None}, {'b': 'message-info bar'}), + ({'a': None}, {keyseq('b'): 'message-info bar'}), # Additional binding ({'c': 'message-info baz'}, - {'a': 'message-info foo', 'b': 'message-info bar', - 'c': 'message-info baz'}), + {keyseq('a'): 'message-info foo', + keyseq('b'): 'message-info bar', + keyseq('c'): 'message-info baz'}), # Unbinding unknown key - ({'x': None}, {'a': 'message-info foo', 'b': 'message-info bar'}), + ({'x': None}, {keyseq('a'): 'message-info foo', + keyseq('b'): 'message-info bar'}), ]) def test_get_bindings_for_and_get_command(self, key_config_stub, config_stub, commands, expected): - orig_default_bindings = {'normal': {'a': 'message-info foo', - 'b': 'message-info bar'}, - 'insert': {}, - 'hint': {}, - 'passthrough': {}, - 'command': {}, - 'prompt': {}, - 'caret': {}, - 'register': {}} - config_stub.val.bindings.default = copy.deepcopy(orig_default_bindings) + orig_default_bindings = { + 'normal': {'a': 'message-info foo', + 'b': 'message-info bar'}, + 'insert': {}, + 'hint': {}, + 'passthrough': {}, + 'command': {}, + 'prompt': {}, + 'caret': {}, + 'register': {}, + 'yesno': {} + } + expected_default_bindings = { + 'normal': {keyseq('a'): 'message-info foo', + keyseq('b'): 'message-info bar'}, + 'insert': {}, + 'hint': {}, + 'passthrough': {}, + 'command': {}, + 'prompt': {}, + 'caret': {}, + 'register': {}, + 'yesno': {} + } + + config_stub.val.bindings.default = orig_default_bindings config_stub.val.bindings.commands = {'normal': commands} bindings = key_config_stub.get_bindings_for('normal') # Make sure the code creates a copy and doesn't modify the setting - assert config_stub.val.bindings.default == orig_default_bindings + assert config_stub.val.bindings.default == expected_default_bindings assert bindings == expected for key, command in expected.items(): assert key_config_stub.get_command(key, 'normal') == command @@ -146,15 +168,18 @@ class TestKeyConfig: no_bindings): config_stub.val.bindings.default = no_bindings config_stub.val.bindings.commands = no_bindings - assert key_config_stub.get_command('foobar', 'normal') is None + command = key_config_stub.get_command(keyseq('foobar'), + 'normal') + assert command is None def test_get_command_default(self, key_config_stub, config_stub): config_stub.val.bindings.default = { 'normal': {'x': 'message-info default'}} config_stub.val.bindings.commands = { 'normal': {'x': 'message-info custom'}} - cmd = 'message-info default' - assert key_config_stub.get_command('x', 'normal', default=True) == cmd + command = key_config_stub.get_command(keyseq('x'), 'normal', + default=True) + assert command == 'message-info default' @pytest.mark.parametrize('bindings, expected', [ # Simple @@ -163,9 +188,9 @@ class TestKeyConfig: # Multiple bindings ({'a': 'message-info foo', 'b': 'message-info foo'}, {'message-info foo': ['b', 'a']}), - # With special keys (should be listed last and normalized) - ({'a': 'message-info foo', '': 'message-info foo'}, - {'message-info foo': ['a', '']}), + # With modifier keys (should be listed last and normalized) + ({'a': 'message-info foo', '': 'message-info foo'}, + {'message-info foo': ['a', '']}), # Chained command ({'a': 'message-info foo ;; message-info bar'}, {'message-info foo': ['a'], 'message-info bar': ['a']}), @@ -178,11 +203,14 @@ class TestKeyConfig: @pytest.mark.parametrize('key', ['a', '', 'b']) def test_bind_duplicate(self, key_config_stub, config_stub, key): + seq = keyseq(key) config_stub.val.bindings.default = {'normal': {'a': 'nop', '': 'nop'}} config_stub.val.bindings.commands = {'normal': {'b': 'nop'}} - key_config_stub.bind(key, 'message-info foo', mode='normal') - assert key_config_stub.get_command(key, 'normal') == 'message-info foo' + key_config_stub.bind(seq, 'message-info foo', mode='normal') + + command = key_config_stub.get_command(seq, 'normal') + assert command == 'message-info foo' @pytest.mark.parametrize('mode', ['normal', 'caret']) @pytest.mark.parametrize('command', [ @@ -193,13 +221,14 @@ class TestKeyConfig: mode, command): config_stub.val.bindings.default = no_bindings config_stub.val.bindings.commands = no_bindings + seq = keyseq('a') with qtbot.wait_signal(config_stub.changed): - key_config_stub.bind('a', command, mode=mode) + key_config_stub.bind(seq, command, mode=mode) - assert config_stub.val.bindings.commands[mode]['a'] == command - assert key_config_stub.get_bindings_for(mode)['a'] == command - assert key_config_stub.get_command('a', mode) == command + assert config_stub.val.bindings.commands[mode][seq] == command + assert key_config_stub.get_bindings_for(mode)[seq] == command + assert key_config_stub.get_command(seq, mode) == command def test_bind_mode_changing(self, key_config_stub, config_stub, no_bindings): @@ -209,7 +238,8 @@ class TestKeyConfig: """ config_stub.val.bindings.default = no_bindings config_stub.val.bindings.commands = no_bindings - key_config_stub.bind('a', 'set-cmd-text :nop ;; rl-beginning-of-line', + key_config_stub.bind(keyseq('a'), + 'set-cmd-text :nop ;; rl-beginning-of-line', mode='normal') def test_bind_default(self, key_config_stub, config_stub): @@ -218,11 +248,15 @@ class TestKeyConfig: bound_cmd = 'message-info bound' config_stub.val.bindings.default = {'normal': {'a': default_cmd}} config_stub.val.bindings.commands = {'normal': {'a': bound_cmd}} - assert key_config_stub.get_command('a', mode='normal') == bound_cmd + seq = keyseq('a') - key_config_stub.bind_default('a', mode='normal') + command = key_config_stub.get_command(seq, mode='normal') + assert command == bound_cmd - assert key_config_stub.get_command('a', mode='normal') == default_cmd + key_config_stub.bind_default(seq, mode='normal') + + command = key_config_stub.get_command(keyseq('a'), mode='normal') + assert command == default_cmd def test_bind_default_unbound(self, key_config_stub, config_stub, no_bindings): @@ -231,42 +265,51 @@ class TestKeyConfig: config_stub.val.bindings.commands = no_bindings with pytest.raises(configexc.KeybindingError, match="Can't find binding 'foobar' in normal mode"): - key_config_stub.bind_default('foobar', mode='normal') + key_config_stub.bind_default(keyseq('foobar'), mode='normal') - @pytest.mark.parametrize('key, normalized', [ - ('a', 'a'), # default bindings - ('b', 'b'), # custom bindings - ('', '') + @pytest.mark.parametrize('key', [ + 'a', # default bindings + 'b', # custom bindings + '', ]) @pytest.mark.parametrize('mode', ['normal', 'caret', 'prompt']) def test_unbind(self, key_config_stub, config_stub, qtbot, - key, normalized, mode): + key, mode): default_bindings = { 'normal': {'a': 'nop', '': 'nop'}, 'caret': {'a': 'nop', '': 'nop'}, # prompt: a mode which isn't in bindings.commands yet 'prompt': {'a': 'nop', 'b': 'nop', '': 'nop'}, } - old_default_bindings = copy.deepcopy(default_bindings) + expected_default_bindings = { + 'normal': {keyseq('a'): 'nop', keyseq(''): 'nop'}, + 'caret': {keyseq('a'): 'nop', keyseq(''): 'nop'}, + # prompt: a mode which isn't in bindings.commands yet + 'prompt': {keyseq('a'): 'nop', + keyseq('b'): 'nop', + keyseq(''): 'nop'}, + } + config_stub.val.bindings.default = default_bindings config_stub.val.bindings.commands = { 'normal': {'b': 'nop'}, 'caret': {'b': 'nop'}, } + seq = keyseq(key) with qtbot.wait_signal(config_stub.changed): - key_config_stub.unbind(key, mode=mode) + key_config_stub.unbind(seq, mode=mode) - assert key_config_stub.get_command(key, mode) is None + assert key_config_stub.get_command(seq, mode) is None mode_bindings = config_stub.val.bindings.commands[mode] if key == 'b' and mode != 'prompt': # Custom binding - assert normalized not in mode_bindings + assert seq not in mode_bindings else: default_bindings = config_stub.val.bindings.default - assert default_bindings[mode] == old_default_bindings[mode] - assert mode_bindings[normalized] is None + assert default_bindings[mode] == expected_default_bindings[mode] + assert mode_bindings[seq] is None def test_unbind_unbound(self, key_config_stub, config_stub, no_bindings): """Try unbinding a key which is not bound.""" @@ -274,7 +317,7 @@ class TestKeyConfig: config_stub.val.bindings.commands = no_bindings with pytest.raises(configexc.KeybindingError, match="Can't find binding 'foobar' in normal mode"): - key_config_stub.unbind('foobar', mode='normal') + key_config_stub.unbind(keyseq('foobar'), mode='normal') def test_unbound_twice(self, key_config_stub, config_stub, no_bindings): """Try unbinding an already-unbound default key. @@ -286,17 +329,18 @@ class TestKeyConfig: """ config_stub.val.bindings.default = {'normal': {'a': 'nop'}} config_stub.val.bindings.commands = no_bindings + seq = keyseq('a') - key_config_stub.unbind('a') - assert key_config_stub.get_command('a', mode='normal') is None - key_config_stub.unbind('a') - assert key_config_stub.get_command('a', mode='normal') is None + key_config_stub.unbind(seq) + assert key_config_stub.get_command(seq, mode='normal') is None + key_config_stub.unbind(seq) + assert key_config_stub.get_command(seq, mode='normal') is None def test_empty_command(self, key_config_stub): """Try binding a key to an empty command.""" message = "Can't add binding 'x' with empty command in normal mode" with pytest.raises(configexc.KeybindingError, match=message): - key_config_stub.bind('x', ' ', mode='normal') + key_config_stub.bind(keyseq('x'), ' ', mode='normal') class TestConfig: From b1f4b1eaba0621ba1d120280661864806574ec42 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 07:40:06 +0100 Subject: [PATCH 276/524] Fix :unbind with already bound keys The previous change was incorrect and caused a regression (test_unbound_twice) --- qutebrowser/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 0a106ea05..f8f9e7902 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -212,7 +212,7 @@ class KeyConfig: bindings_commands = self._config.get_mutable_obj('bindings.commands') - if str(key) in bindings_commands.get(mode, {}): + 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]: From 214e750c69bc9187c2dc1bc98b1576b2a19e4232 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 07:51:07 +0100 Subject: [PATCH 277/524] Adjust test_configcommands.py --- tests/unit/config/test_configcommands.py | 25 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index cafc1ac31..320eb2bd4 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -27,9 +27,15 @@ from PyQt5.QtCore import QUrl from qutebrowser.config import configcommands, configutils from qutebrowser.commands import cmdexc from qutebrowser.utils import usertypes, urlmatch +from qutebrowser.keyinput import keyutils from qutebrowser.misc import objects +# Alias because we need this a lot in here. +def keyseq(s): + return keyutils.KeySequence.parse(s) + + @pytest.fixture def commands(config_stub, key_config_stub): return configcommands.ConfigCommands(config_stub, key_config_stub) @@ -415,7 +421,7 @@ class TestWritePy: def test_custom(self, commands, config_stub, key_config_stub, tmpdir): confpy = tmpdir / 'config.py' config_stub.val.content.javascript.enabled = True - key_config_stub.bind(',x', 'message-info foo', mode='normal') + key_config_stub.bind(keyseq(',x'), 'message-info foo', mode='normal') commands.config_write_py(str(confpy)) @@ -496,7 +502,7 @@ class TestBind: config_stub.val.bindings.commands = no_bindings commands.bind(0, 'a', command) - assert key_config_stub.get_command('a', 'normal') == command + assert key_config_stub.get_command(keyseq('a'), 'normal') == command yaml_bindings = yaml_value('bindings.commands')['normal'] assert yaml_bindings['a'] == command @@ -509,7 +515,7 @@ class TestBind: ('c', 'normal', "c is bound to 'message-info c' in normal mode"), # Special key ('', 'normal', - " is bound to 'message-info C-x' in normal mode"), + " is bound to 'message-info C-x' in normal mode"), # unbound ('x', 'normal', "x is unbound in normal mode"), # non-default mode @@ -569,7 +575,8 @@ class TestBind: } commands.bind(0, key, 'message-info foo', mode='normal') - assert key_config_stub.get_command(key, 'normal') == 'message-info foo' + command = key_config_stub.get_command(keyseq(key), 'normal') + assert command == 'message-info foo' def test_bind_none(self, commands, config_stub): config_stub.val.bindings.commands = None @@ -581,11 +588,13 @@ class TestBind: bound_cmd = 'message-info bound' config_stub.val.bindings.default = {'normal': {'a': default_cmd}} config_stub.val.bindings.commands = {'normal': {'a': bound_cmd}} - assert key_config_stub.get_command('a', mode='normal') == bound_cmd + command = key_config_stub.get_command(keyseq('a'), mode='normal') + assert command == bound_cmd commands.bind(0, 'a', mode='normal', default=True) - assert key_config_stub.get_command('a', mode='normal') == default_cmd + command = key_config_stub.get_command(keyseq('a'), mode='normal') + assert command == default_cmd @pytest.mark.parametrize('key, mode, expected', [ ('foobar', 'normal', "Can't find binding 'foobar' in normal mode"), @@ -607,7 +616,7 @@ class TestBind: ('a', 'a'), # default bindings ('b', 'b'), # custom bindings ('c', 'c'), # :bind then :unbind - ('', '') # normalized special binding + ('', '') # normalized special binding ]) def test_unbind(self, commands, key_config_stub, config_stub, yaml_value, key, normalized): @@ -624,7 +633,7 @@ class TestBind: commands.bind(0, key, 'nop') commands.unbind(key) - assert key_config_stub.get_command(key, 'normal') is None + assert key_config_stub.get_command(keyseq(key), 'normal') is None yaml_bindings = yaml_value('bindings.commands')['normal'] if key in 'bc': From e8d5fb5cca1c95e5ddce7576cf53139f09480208 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 07:51:14 +0100 Subject: [PATCH 278/524] Normalize keybinding with :bind --- qutebrowser/config/configcommands.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 7322b3878..43b9641fd 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -154,17 +154,13 @@ class ConfigCommands: return # No --default -> print binding - #if utils.is_special_key(key): - # # self._keyconfig.get_command does this, but we also need it - # # normalized for the output below - # key = utils.normalize_keystr(key) with self._handle_config_error(): cmd = self._keyconfig.get_command(seq, mode) if cmd is None: - message.info("{} is unbound in {} mode".format(key, mode)) + message.info("{} is unbound in {} mode".format(seq, mode)) else: message.info("{} is bound to '{}' in {} mode".format( - key, cmd, mode)) + seq, cmd, mode)) return with self._handle_config_error(): From 612387633df9986827a0780f364aa0051bbe9ed9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 07:53:29 +0100 Subject: [PATCH 279/524] Adjust test_configfiles --- tests/unit/config/test_configfiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 728dbb794..c902bb42d 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -607,7 +607,7 @@ class TestConfigPy: @pytest.mark.parametrize('line, key, mode', [ ('config.unbind("o")', 'o', 'normal'), - ('config.unbind("y", mode="prompt")', 'y', 'prompt'), + ('config.unbind("y", mode="yesno")', 'y', 'yesno'), ]) def test_unbind(self, confpy, line, key, mode): confpy.write(line) From f40f4082baaf11ba09312f37eb34e2f97e9afffe Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 07:56:10 +0100 Subject: [PATCH 280/524] Validate configtypes.Key correctly --- qutebrowser/config/configtypes.py | 9 +++++---- tests/unit/config/test_configtypes.py | 4 ++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 196e19647..14855bf03 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -1652,7 +1652,8 @@ class Key(BaseType): self._basic_py_validation(value, str) if not value: return None - #if utils.is_special_key(value): - # value = '<{}>'.format(utils.normalize_keystr(value[1:-1])) - #return value - return keyutils.KeySequence.parse(value) + + try: + return keyutils.KeySequence.parse(value) + except keyutils.KeyParseError as e: + raise configexc.ValidationError(value, str(e)) diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 81a7d53e1..41da86ff8 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -2064,6 +2064,10 @@ class TestKey: def test_to_py_valid(self, klass, val, expected): assert klass().to_py(val) == expected + def test_to_py_invalid(self, klass): + with pytest.raises(configexc.ValidationError): + klass().to_py('\U00010000') + @pytest.mark.parametrize('first, second, equal', [ (re.compile('foo'), RegexEq('foo'), True), From 1e8f72dfe6283a3e692efbdbbd63cc508623be83 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 08:10:54 +0100 Subject: [PATCH 281/524] Adjust test_configtypes --- tests/unit/config/test_configtypes.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 41da86ff8..c64891e5a 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -37,6 +37,7 @@ from PyQt5.QtNetwork import QNetworkProxy from qutebrowser.config import configtypes, configexc from qutebrowser.utils import debug, utils, qtutils from qutebrowser.browser.network import pac +from qutebrowser.keyinput import keyutils from tests.helpers import utils as testutils @@ -2058,8 +2059,8 @@ class TestKey: return configtypes.Key @pytest.mark.parametrize('val, expected', [ - ('gC', 'gC'), - ('', '') + ('gC', keyutils.KeySequence.parse('gC')), + ('', keyutils.KeySequence.parse('')), ]) def test_to_py_valid(self, klass, val, expected): assert klass().to_py(val) == expected From ac4fd7c563832245b97846874a04721521aa5fbd Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 08:20:06 +0100 Subject: [PATCH 282/524] Add KeyInfo.to_event() --- qutebrowser/browser/commands.py | 6 ++---- qutebrowser/keyinput/keyutils.py | 8 ++++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 701c8324f..a5f60e2ac 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -27,7 +27,6 @@ import typing from PyQt5.QtWidgets import QApplication, QTabBar, QDialog from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QEvent, QUrlQuery -from PyQt5.QtGui import QKeyEvent from PyQt5.QtPrintSupport import QPrintDialog, QPrintPreviewDialog from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners @@ -2117,9 +2116,8 @@ class CommandDispatcher: raise cmdexc.CommandError(str(e)) for keyinfo in sequence: - args = (keyinfo.key, keyinfo.modifiers, keyinfo.text()) - press_event = QKeyEvent(QEvent.KeyPress, *args) - release_event = QKeyEvent(QEvent.KeyRelease, *args) + press_event = keyinfo.to_event(QEvent.KeyPress) + release_event = keyinfo.to_event(QEvent.KeyRelease) if global_: window = QApplication.focusWindow() diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 9be7b4d60..80bf59a35 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -24,8 +24,8 @@ import collections import itertools import attr -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QKeySequence +from PyQt5.QtCore import Qt, QEvent +from PyQt5.QtGui import QKeySequence, QKeyEvent from qutebrowser.utils import utils, debug @@ -273,6 +273,10 @@ class KeyInfo: text = text.lower() return text + def to_event(self, typ=QEvent.KeyPress): + """Get a QKeyEvent from this KeyInfo.""" + return QKeyEvent(typ, self.key, self.modifiers, self.text()) + class KeySequence: From 44b4cb92be40bb6ed0d97712416fd9c08e7baebb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 08:35:14 +0100 Subject: [PATCH 283/524] Make keyutils.KeySequence.parse('') work --- qutebrowser/keyinput/keyutils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 80bf59a35..e9abf476b 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -394,6 +394,9 @@ class KeySequence: for sub in utils.chunk(strings, cls._MAX_LEN): sequence = QKeySequence(', '.join(sub)) new._sequences.append(sequence) - assert len(new) > 0 + + if keystr: + assert len(new) > 0 + new._validate(keystr) return new From 9e27f2b3e7e9b4650af28d12266ddff68608f760 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 08:48:16 +0100 Subject: [PATCH 284/524] Initial attempts at fixing test_basekeyparser --- qutebrowser/keyinput/basekeyparser.py | 1 + tests/unit/keyinput/test_basekeyparser.py | 95 +++++++++++++---------- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 16691af4c..6636042ce 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -253,6 +253,7 @@ class BaseKeyParser(QObject): Args: cmdstr: The command to execute as a string. + # FIXME do we still need this? keytype: Type.chain or Type.special count: The count if given. """ diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 423076bdd..8b3ce2d70 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -25,10 +25,15 @@ from unittest import mock from PyQt5.QtCore import Qt import pytest -from qutebrowser.keyinput import basekeyparser +from qutebrowser.keyinput import basekeyparser, keyutils from qutebrowser.utils import utils +# Alias because we need this a lot in here. +def keyseq(s): + return keyutils.KeySequence.parse(s) + + @pytest.fixture def keyparser(key_config_stub): """Fixture providing a BaseKeyParser supporting count/chains.""" @@ -80,18 +85,24 @@ class TestDebugLog: assert not caplog.records -@pytest.mark.parametrize('input_key, supports_count, expected', [ +@pytest.mark.parametrize('input_key, supports_count, count, command', [ # (input_key, supports_count, expected) - ('10', True, (10, '')), - ('10foo', True, (10, 'foo')), - ('-1foo', True, (None, '-1foo')), - ('10e4foo', True, (10, 'e4foo')), - ('foo', True, (None, 'foo')), - ('10foo', False, (None, '10foo')), + ('10', True, '10', ''), + ('10g', True, '10', 'g'), + ('10e4g', True, '4', 'g'), + ('g', True, '', 'g'), + ('10g', False, '', 'g'), ]) -def test_split_count(config_stub, input_key, supports_count, expected): +def test_split_count(config_stub, key_config_stub, + input_key, supports_count, count, command): kp = basekeyparser.BaseKeyParser(0, supports_count=supports_count) - assert kp._split_count(input_key) == expected + kp._read_config('normal') + + for info in keyseq(input_key): + kp._handle_key(info.to_event()) + + assert kp._count == count + assert kp._sequence == keyseq(command) @pytest.mark.usefixtures('keyinput_bindings') @@ -106,18 +117,18 @@ class TestReadConfig: """Test reading config with _modename set.""" keyparser._modename = 'normal' keyparser._read_config() - assert 'a' in keyparser.bindings + assert keyseq('a') in keyparser.bindings def test_read_config_valid(self, keyparser): """Test reading config.""" keyparser._read_config('prompt') - assert 'ccc' in keyparser.bindings - assert 'ctrl+a' in keyparser.special_bindings + assert keyseq('ccc') in keyparser.bindings + assert keyseq('') in keyparser.bindings keyparser._read_config('command') - assert 'ccc' not in keyparser.bindings - assert 'ctrl+a' not in keyparser.special_bindings - assert 'foo' in keyparser.bindings - assert 'ctrl+x' in keyparser.special_bindings + assert keyseq('ccc') not in keyparser.bindings + assert keyseq('') not in keyparser.bindings + assert keyseq('foo') in keyparser.bindings + assert keyseq('') in keyparser.bindings def test_read_config_modename_none(self, keyparser): assert keyparser._modename is None @@ -134,15 +145,18 @@ class TestReadConfig: mode, changed_mode, expected): keyparser._read_config(mode) # Sanity checks - assert 'a' in keyparser.bindings - assert 'new' not in keyparser.bindings + assert keyseq('a') in keyparser.bindings + assert keyseq('new') not in keyparser.bindings - key_config_stub.bind('new', 'message-info new', mode=changed_mode) + key_config_stub.bind(keyseq('new'), 'message-info new', + mode=changed_mode) - assert 'a' in keyparser.bindings - assert ('new' in keyparser.bindings) == expected + assert keyseq('a') in keyparser.bindings + assert (keyseq('new') in keyparser.bindings) == expected + # FIXME do we still need this? @pytest.mark.parametrize('warn_on_keychains', [True, False]) + @pytest.mark.skip(reason='unneeded?') def test_warn_on_keychains(self, caplog, warn_on_keychains): """Test _warn_on_keychains.""" kp = basekeyparser.BaseKeyParser( @@ -168,27 +182,28 @@ class TestSpecialKeys: keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier)) keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.special, None) + 'message-info ctrla', keyparser.Type.chain, None) def test_valid_key_count(self, fake_keyevent_factory, keyparser): modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier - keyparser.handle(fake_keyevent_factory(5, text='5')) + keyparser.handle(fake_keyevent_factory(Qt.Key_5, text='5')) keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier, text='A')) keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.special, 5) + 'message-info ctrla', keyparser.Type.chain, 5) def test_invalid_key(self, fake_keyevent_factory, keyparser): keyparser.handle(fake_keyevent_factory( Qt.Key_A, (Qt.ControlModifier | Qt.AltModifier))) assert not keyparser.execute.called + @pytest.mark.skip(reason='unneeded?') def test_keychain(self, fake_keyevent_factory, keyparser): keyparser.handle(fake_keyevent_factory(Qt.Key_B)) keyparser.handle(fake_keyevent_factory(Qt.Key_A)) assert not keyparser.execute.called def test_no_binding(self, monkeypatch, fake_keyevent_factory, keyparser): - monkeypatch.setattr(utils, 'keyevent_to_string', lambda binding: None) + monkeypatch.setattr(keyutils, 'keyevent_to_string', lambda binding: None) keyparser.handle(fake_keyevent_factory(Qt.Key_A, Qt.NoModifier)) assert not keyparser.execute.called @@ -197,7 +212,7 @@ class TestSpecialKeys: keyparser.handle(fake_keyevent_factory(Qt.Key_B, modifier)) keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.special, None) + 'message-info ctrla', keyparser.Type.chain, None) def test_binding_and_mapping(self, config_stub, fake_keyevent_factory, keyparser): @@ -206,7 +221,7 @@ class TestSpecialKeys: keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.special, None) + 'message-info ctrla', keyparser.Type.chain, None) class TestKeyChain: @@ -225,14 +240,14 @@ class TestKeyChain: keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier)) keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.special, None) - assert keyparser._keystring == '' + 'message-info ctrla', keyparser.Type.chain, None) + assert not keyparser._sequence def test_invalid_special_key(self, fake_keyevent_factory, keyparser): keyparser.handle(fake_keyevent_factory( Qt.Key_A, (Qt.ControlModifier | Qt.AltModifier))) assert not keyparser.execute.called - assert keyparser._keystring == '' + assert not keyparser._sequence def test_valid_keychain(self, handle_text, keyparser): # Press 'x' which is ignored because of no match @@ -241,13 +256,13 @@ class TestKeyChain: (Qt.Key_B, 'b'), (Qt.Key_A, 'a')) keyparser.execute.assert_called_with( 'message-info ba', keyparser.Type.chain, None) - assert keyparser._keystring == '' + assert not keyparser._sequence def test_0_press(self, handle_text, keyparser): handle_text((Qt.Key_0, '0')) keyparser.execute.assert_called_once_with( 'message-info 0', keyparser.Type.chain, None) - assert keyparser._keystring == '' + assert not keyparser._sequence def test_ambiguous_keychain(self, handle_text, keyparser): handle_text((Qt.Key_A, 'a')) @@ -256,7 +271,7 @@ class TestKeyChain: def test_invalid_keychain(self, handle_text, keyparser): handle_text((Qt.Key_B, 'b')) handle_text((Qt.Key_C, 'c')) - assert keyparser._keystring == '' + assert not keyparser._sequence def test_mapping(self, config_stub, handle_text, keyparser): handle_text((Qt.Key_X, 'x')) @@ -282,34 +297,34 @@ class TestCount: handle_text((Qt.Key_B, 'b'), (Qt.Key_A, 'a')) keyparser.execute.assert_called_once_with( 'message-info ba', keyparser.Type.chain, None) - assert keyparser._keystring == '' + assert not keyparser._sequence def test_count_0(self, handle_text, keyparser): handle_text((Qt.Key_0, '0'), (Qt.Key_B, 'b'), (Qt.Key_A, 'a')) calls = [mock.call('message-info 0', keyparser.Type.chain, None), mock.call('message-info ba', keyparser.Type.chain, None)] keyparser.execute.assert_has_calls(calls) - assert keyparser._keystring == '' + assert not keyparser._sequence def test_count_42(self, handle_text, keyparser): handle_text((Qt.Key_4, '4'), (Qt.Key_2, '2'), (Qt.Key_B, 'b'), (Qt.Key_A, 'a')) keyparser.execute.assert_called_once_with( 'message-info ba', keyparser.Type.chain, 42) - assert keyparser._keystring == '' + assert not keyparser._sequence def test_count_42_invalid(self, handle_text, keyparser): # Invalid call with ccx gets ignored handle_text((Qt.Key_4, '4'), (Qt.Key_2, '2'), (Qt.Key_C, 'c'), (Qt.Key_C, 'c'), (Qt.Key_X, 'x')) assert not keyparser.execute.called - assert keyparser._keystring == '' + assert not keyparser._sequence # Valid call with ccc gets the correct count handle_text((Qt.Key_6, '2'), (Qt.Key_2, '3'), (Qt.Key_C, 'c'), (Qt.Key_C, 'c'), (Qt.Key_C, 'c')) keyparser.execute.assert_called_once_with( 'message-info ccc', keyparser.Type.chain, 23) - assert keyparser._keystring == '' + assert not keyparser._sequence def test_clear_keystring(qtbot, keyparser): @@ -317,4 +332,4 @@ def test_clear_keystring(qtbot, keyparser): keyparser._keystring = 'test' with qtbot.waitSignal(keyparser.keystring_updated): keyparser.clear_keystring() - assert keyparser._keystring == '' + assert not keyparser._sequence From eeeb763f8a1e10f9ee1c4ce88e292d7232328628 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 08:50:50 +0100 Subject: [PATCH 285/524] Make sure 0 is handled as command --- qutebrowser/keyinput/basekeyparser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 6636042ce..9510209df 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -139,7 +139,8 @@ class BaseKeyParser(QObject): # self._debug_log("Ignoring, no text char") # return QKeySequence.NoMatch - if txt.isdigit() and self._supports_count: + if (txt.isdigit() and self._supports_count and not + (not self._count and txt == '0')): assert len(txt) == 1, txt self._count += txt return None From 5a03d31f6f9f95dcd916e39765ab9682ff54a566 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 08:53:28 +0100 Subject: [PATCH 286/524] More test_basekeyparser fixes --- tests/unit/keyinput/test_basekeyparser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 8b3ce2d70..91acfa423 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -320,7 +320,7 @@ class TestCount: assert not keyparser.execute.called assert not keyparser._sequence # Valid call with ccc gets the correct count - handle_text((Qt.Key_6, '2'), (Qt.Key_2, '3'), (Qt.Key_C, 'c'), + handle_text((Qt.Key_2, '2'), (Qt.Key_3, '3'), (Qt.Key_C, 'c'), (Qt.Key_C, 'c'), (Qt.Key_C, 'c')) keyparser.execute.assert_called_once_with( 'message-info ccc', keyparser.Type.chain, 23) @@ -329,7 +329,7 @@ class TestCount: def test_clear_keystring(qtbot, keyparser): """Test that the keystring is cleared and the signal is emitted.""" - keyparser._keystring = 'test' + keyparser._sequence = keyseq('test') with qtbot.waitSignal(keyparser.keystring_updated): keyparser.clear_keystring() assert not keyparser._sequence From 911b2daebf5349b22de7b908058450fcd4ce635e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 09:07:20 +0100 Subject: [PATCH 287/524] Fix test_keyutils --- qutebrowser/keyinput/keyutils.py | 1 + tests/unit/keyinput/test_keyutils.py | 39 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index e9abf476b..6a149a0a6 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -396,6 +396,7 @@ class KeySequence: new._sequences.append(sequence) if keystr: + # FIXME fails with " 0 new._validate(keystr) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 8a62071a3..db5b380d7 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -20,6 +20,7 @@ import pytest from PyQt5.QtCore import Qt +from qutebrowser.utils import utils from qutebrowser.keyinput import keyutils @@ -65,13 +66,13 @@ class TestKeyEventToString: """Test keyeevent when only control is pressed.""" evt = fake_keyevent_factory(key=Qt.Key_Control, modifiers=Qt.ControlModifier) - assert keyutils.keyevent_to_string(evt) is None + assert not keyutils.keyevent_to_string(evt) def test_only_hyper_l(self, fake_keyevent_factory): """Test keyeevent when only Hyper_L is pressed.""" evt = fake_keyevent_factory(key=Qt.Key_Hyper_L, modifiers=Qt.MetaModifier) - assert keyutils.keyevent_to_string(evt) is None + assert not keyutils.keyevent_to_string(evt) def test_only_key(self, fake_keyevent_factory): """Test with a simple key pressed.""" @@ -81,7 +82,7 @@ class TestKeyEventToString: def test_key_and_modifier(self, fake_keyevent_factory): """Test with key and modifier pressed.""" evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) - expected = 'meta+a' if keyutils.is_mac else 'ctrl+a' + expected = '' if utils.is_mac else '' assert keyutils.keyevent_to_string(evt) == expected def test_key_and_modifiers(self, fake_keyevent_factory): @@ -89,13 +90,13 @@ class TestKeyEventToString: evt = fake_keyevent_factory( key=Qt.Key_A, modifiers=(Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier | Qt.ShiftModifier)) - assert keyutils.keyevent_to_string(evt) == 'ctrl+alt+meta+shift+a' + assert keyutils.keyevent_to_string(evt) == '' @pytest.mark.fake_os('mac') def test_mac(self, fake_keyevent_factory): """Test with a simulated mac.""" evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) - assert keyutils.keyevent_to_string(evt) == 'meta+a' + assert keyutils.keyevent_to_string(evt) == '' @pytest.mark.parametrize('keystr, expected', [ @@ -115,21 +116,21 @@ class TestKeyEventToString: def test_parse(keystr, expected): if expected is keyutils.KeyParseError: with pytest.raises(keyutils.KeyParseError): - keyutils._parse_single_key(keystr) + keyutils.KeySequence.parse(keystr) else: - assert keyutils._parse_single_key(keystr) == expected + assert keyutils.KeySequence.parse(keystr) == expected -@pytest.mark.parametrize('orig, repl', [ - ('Control+x', 'ctrl+x'), - ('Windows+x', 'meta+x'), - ('Mod1+x', 'alt+x'), - ('Mod4+x', 'meta+x'), - ('Control--', 'ctrl+-'), - ('Windows++', 'meta++'), - ('ctrl-x', 'ctrl+x'), - ('control+x', 'ctrl+x') +@pytest.mark.parametrize('orig, normalized', [ + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', '') ]) -def test_normalize_keystr(orig, repl): - assert keyutils.KeySequence(orig) == repl - +def test_normalize_keystr(orig, normalized): + expected = keyutils.KeySequence.parse(normalized) + assert keyutils.KeySequence.parse(orig) == expected From 1ba61bbcbee5ed19d43e307436d7e7b0e5cff4e1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 09:22:01 +0100 Subject: [PATCH 288/524] Fix test_modeparsers --- tests/unit/keyinput/test_modeparsers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py index ade8c15cc..50332369f 100644 --- a/tests/unit/keyinput/test_modeparsers.py +++ b/tests/unit/keyinput/test_modeparsers.py @@ -25,7 +25,7 @@ from PyQt5.QtCore import Qt import pytest -from qutebrowser.keyinput import modeparsers +from qutebrowser.keyinput import modeparsers, keyutils class TestsNormalKeyParser: @@ -58,7 +58,7 @@ class TestsNormalKeyParser: keyparser.handle(fake_keyevent_factory(Qt.Key_A, text='a')) keyparser.execute.assert_called_with( 'message-info ba', keyparser.Type.chain, None) - assert keyparser._keystring == '' + assert not keyparser._sequence def test_partial_keychain_timeout(self, keyparser, config_stub, fake_keyevent_factory): @@ -74,11 +74,11 @@ class TestsNormalKeyParser: assert timer.isActive() assert not keyparser.execute.called - assert keyparser._keystring == 'b' + assert keyparser._sequence == keyutils.KeySequence.parse('b') # Now simulate a timeout and check the keystring has been cleared. keystring_updated_mock = mock.Mock() keyparser.keystring_updated.connect(keystring_updated_mock) timer.timeout.emit() assert not keyparser.execute.called - assert keyparser._keystring == '' + assert not keyparser._sequence keystring_updated_mock.assert_called_once_with('') From 5d581d42f5cd843d29e585e13d1cbd03ae376cd4 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 09:22:11 +0100 Subject: [PATCH 289/524] Improve key parsing with simple keys containing --- qutebrowser/keyinput/keyutils.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 6a149a0a6..58b29123d 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -149,6 +149,7 @@ def _parse_keystring(keystr): special = False for c in keystr: if c == '>': + assert special yield normalize_keystr(key) key = '' special = False @@ -158,6 +159,10 @@ def _parse_keystring(keystr): key += c else: yield 'Shift+' + c if c.isupper() else c + if special: + yield '<' + for c in key: + yield 'Shift+' + c if c.isupper() else c def normalize_keystr(keystr): @@ -389,6 +394,7 @@ class KeySequence: @classmethod def parse(cls, keystr): """Parse a keystring like or xyz and return a KeySequence.""" + # FIXME: test stuff like new = cls() strings = list(_parse_keystring(keystr)) for sub in utils.chunk(strings, cls._MAX_LEN): @@ -396,8 +402,7 @@ class KeySequence: new._sequences.append(sequence) if keystr: - # FIXME fails with " 0 + assert len(new) > 0, keystr new._validate(keystr) return new From f18b5aa78298734421984df274b632d8820951f6 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 09:23:06 +0100 Subject: [PATCH 290/524] Fix searching for blacklisted keys in keyhintwidget --- qutebrowser/misc/keyhintwidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index b9985dbd0..35d453557 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -108,7 +108,7 @@ class KeyHintView(QLabel): bindings_dict = config.key_instance.get_bindings_for(modename) bindings = [(k, v) for (k, v) in sorted(bindings_dict.items()) if keyutils.KeySequence.parse(prefix).matches(k) and - not blacklisted(k) and + not blacklisted(str(k)) and (takes_count(v) or not countstr)] if not bindings: From 362f923f06cecbdcc211ad3063cdefd6935d7b8a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 09:33:50 +0100 Subject: [PATCH 291/524] Fix lint --- qutebrowser/config/configcommands.py | 2 +- qutebrowser/keyinput/basekeyparser.py | 19 +++++---- qutebrowser/keyinput/keyutils.py | 48 +++++++++++++++++------ qutebrowser/misc/keyhintwidget.py | 1 - qutebrowser/utils/utils.py | 9 ++--- tests/unit/config/test_config.py | 1 - tests/unit/keyinput/test_basekeyparser.py | 3 +- tests/unit/utils/test_utils.py | 2 +- 8 files changed, 51 insertions(+), 34 deletions(-) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 43b9641fd..311ee4102 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -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, urlmatch +from qutebrowser.utils import objreg, message, standarddir, urlmatch from qutebrowser.config import configtypes, configexc, configfiles, configdata from qutebrowser.misc import editor from qutebrowser.keyinput import keyutils diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 9510209df..63fc11eee 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -20,8 +20,6 @@ """Base class for vim-like key sequence parser.""" import enum -import re -import unicodedata from PyQt5.QtCore import pyqtSignal, QObject from PyQt5.QtGui import QKeySequence @@ -148,7 +146,8 @@ class BaseKeyParser(QObject): sequence = self._sequence.append_event(e) match, binding = self._match_key(sequence) if match == QKeySequence.NoMatch: - mapped = config.val.bindings.key_mappings.get(sequence, None) + mappings = config.val.bindings.key_mappings + mapped = mappings.get(sequence, None) if mapped is not None: match, binding = self._match_key(mapped) @@ -241,13 +240,13 @@ class BaseKeyParser(QObject): assert cmd self.bindings[key] = cmd - def _parse_key_command(self, modename, key, cmd): - """Parse the keys and their command and store them in the object.""" - # FIXME - # elif self._warn_on_keychains: - # log.keyboard.warning("Ignoring keychain '{}' in mode '{}' because " - # "keychains are not supported there." - # .format(key, modename)) + # FIXME + # def _parse_key_command(self, modename, key, cmd): + # """Parse the keys and their command and store them in the object.""" + # elif self._warn_on_keychains: + # log.keyboard.warning("Ignoring keychain '{}' in mode '{}' " + # "because keychains are not supported there." + # .format(key, modename)) def execute(self, cmdstr, keytype, count=None): """Handle a completed keychain. diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 58b29123d..52c41d339 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -27,7 +27,7 @@ import attr from PyQt5.QtCore import Qt, QEvent from PyQt5.QtGui import QKeySequence, QKeyEvent -from qutebrowser.utils import utils, debug +from qutebrowser.utils import utils def key_to_string(key): @@ -209,8 +209,8 @@ class KeyInfo: an empty string if only modifiers are pressed. """ if utils.is_mac: - # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the user - # can use it in the config as expected. See: + # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the + # user can use it in the config as expected. See: # https://github.com/qutebrowser/qutebrowser/issues/110 # http://doc.qt.io/qt-5.4/osx-issues.html#special-keys modmask2str = collections.OrderedDict([ @@ -228,9 +228,9 @@ class KeyInfo: ]) modifier_keys = (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, - Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, - Qt.Key_Hyper_L, Qt.Key_Hyper_R, Qt.Key_Direction_L, - Qt.Key_Direction_R) + Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, + Qt.Key_Hyper_L, Qt.Key_Hyper_R, Qt.Key_Direction_L, + Qt.Key_Direction_R) if self.key in modifier_keys: # Only modifier pressed return '' @@ -285,6 +285,18 @@ class KeyInfo: class KeySequence: + """A sequence of key presses. + + This internally uses chained QKeySequence objects and exposes a nicer + interface over it. + + Attributes: + _sequences: A list of QKeySequence + + Class attributes: + _MAX_LEN: The maximum amount of keys in a QKeySequence. + """ + _MAX_LEN = 4 def __init__(self, *keys): @@ -293,7 +305,7 @@ class KeySequence: sequence = QKeySequence(*sub) self._sequences.append(sequence) if keys: - assert len(self) > 0 + assert self self._validate() def __str__(self): @@ -316,15 +328,19 @@ class KeySequence: return utils.get_repr(self, keys=str(self)) def __lt__(self, other): + # pylint: disable=protected-access return self._sequences < other._sequences def __gt__(self, other): + # pylint: disable=protected-access return self._sequences > other._sequences def __eq__(self, other): + # pylint: disable=protected-access return self._sequences == other._sequences def __ne__(self, other): + # pylint: disable=protected-access return self._sequences != other._sequences def __hash__(self): @@ -334,6 +350,9 @@ class KeySequence: def __len__(self): return sum(len(seq) for seq in self._sequences) + def __bool__(self): + return bool(self._sequences) + def __getitem__(self, item): if isinstance(item, slice): keys = list(self._iter_keys()) @@ -351,6 +370,7 @@ class KeySequence: raise KeyParseError(keystr, "Got unknown key!") def matches(self, other): + """Check whether the given KeySequence matches with this one.""" # FIXME test this # pylint: disable=protected-access assert self._sequences @@ -369,16 +389,16 @@ class KeySequence: We don't care about a shift modifier with symbols (Shift-: should match a : binding even though we typed it with a shift on an US-keyboard) - However, we *do* care about Shift being involved if we got an upper-case - letter, as Shift-A should match a Shift-A binding, but not an "a" - binding. + However, we *do* care about Shift being involved if we got an + upper-case letter, as Shift-A should match a Shift-A binding, but not + an "a" binding. - In addition, Shift also *is* relevant when other modifiers are involved. + In addition, Shift also *is* relevant when other modifiers are + involved. Shift-Ctrl-X should not be equivalent to Ctrl-X. FIXME: create test cases! """ - # pylint: disable=protected-access modifiers = ev.modifiers() if (modifiers == Qt.ShiftModifier and @@ -394,6 +414,7 @@ class KeySequence: @classmethod def parse(cls, keystr): """Parse a keystring like or xyz and return a KeySequence.""" + # pylint: disable=protected-access # FIXME: test stuff like new = cls() strings = list(_parse_keystring(keystr)) @@ -402,7 +423,8 @@ class KeySequence: new._sequences.append(sequence) if keystr: - assert len(new) > 0, keystr + assert new, keystr + # pylint: disable=protected-access new._validate(keystr) return new diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 35d453557..11446aa40 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -30,7 +30,6 @@ import re from PyQt5.QtWidgets import QLabel, QSizePolicy from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt -from PyQt5.QtGui import QKeySequence from qutebrowser.config import config from qutebrowser.utils import utils, usertypes diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 9d95069c5..f7c1c90b0 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -26,18 +26,15 @@ import re import sys import enum import json -import collections import datetime import traceback import functools import contextlib import socket import shlex -import unicodedata -import attr -from PyQt5.QtCore import Qt, QUrl -from PyQt5.QtGui import QKeySequence, QColor, QClipboard, QDesktopServices +from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QColor, QClipboard, QDesktopServices from PyQt5.QtWidgets import QApplication import pkg_resources import yaml @@ -49,7 +46,7 @@ except ImportError: # pragma: no cover YAML_C_EXT = False import qutebrowser -from qutebrowser.utils import qtutils, log, debug +from qutebrowser.utils import qtutils, log fake_clipboard = None diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index a2a2697b1..f47ee7a0a 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -18,7 +18,6 @@ """Tests for qutebrowser.config.config.""" -import copy import types import unittest.mock diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 91acfa423..ffd57d5f4 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -203,7 +203,8 @@ class TestSpecialKeys: assert not keyparser.execute.called def test_no_binding(self, monkeypatch, fake_keyevent_factory, keyparser): - monkeypatch.setattr(keyutils, 'keyevent_to_string', lambda binding: None) + monkeypatch.setattr(keyutils, 'keyevent_to_string', + lambda binding: None) keyparser.handle(fake_keyevent_factory(Qt.Key_A, Qt.NoModifier)) assert not keyparser.execute.called diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index 577ede306..e8391a74f 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -30,7 +30,7 @@ import re import shlex import attr -from PyQt5.QtCore import Qt, QUrl +from PyQt5.QtCore import QUrl from PyQt5.QtGui import QColor, QClipboard import pytest From c0e2550046e48db647189c21a573d0ed5ac20090 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 09:36:56 +0100 Subject: [PATCH 292/524] Fix scripts.keytester --- scripts/keytester.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/keytester.py b/scripts/keytester.py index 80260f6bf..4d27a3dd1 100644 --- a/scripts/keytester.py +++ b/scripts/keytester.py @@ -25,7 +25,7 @@ Use python3 -m scripts.keytester to launch it. from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QHBoxLayout -from qutebrowser.utils import utils +from qutebrowser.keyinput import keyutils class KeyWidget(QWidget): @@ -41,7 +41,7 @@ class KeyWidget(QWidget): def keyPressEvent(self, e): """Show pressed keys.""" lines = [ - str(utils.keyevent_to_string(e)), + str(keyutils.keyevent_to_string(e)), '', 'key: 0x{:x}'.format(int(e.key())), 'modifiers: 0x{:x}'.format(int(e.modifiers())), From 079fcc7eeac7a486d4f8822738a3a8fa0e28c05b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 09:38:40 +0100 Subject: [PATCH 293/524] Add FIXME --- qutebrowser/keyinput/keyutils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 52c41d339..b3890f57c 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -416,6 +416,7 @@ class KeySequence: """Parse a keystring like or xyz and return a KeySequence.""" # pylint: disable=protected-access # FIXME: test stuff like + # FIXME make sure all callers handle KeyParseError new = cls() strings = list(_parse_keystring(keystr)) for sub in utils.chunk(strings, cls._MAX_LEN): From 72e30cc12c21bee189c71aa06baab3436632ebf0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 09:47:06 +0100 Subject: [PATCH 294/524] Fix following hints --- qutebrowser/browser/hints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 1d0184c73..48a0193e6 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -927,7 +927,7 @@ class HintManager(QObject): raise cmdexc.CommandError("No hint {}!".format(keystring)) if select: - self._handle_auto_follow(keystring) + self.handle_partial_key(keystring) else: self._fire(keystring) From b906d92053ffd559938785b60beb89167ad861de Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 10:06:11 +0100 Subject: [PATCH 295/524] Remove now uneeded pylint ignore --- tests/end2end/fixtures/webserver.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 2bc4d1cf9..254b5ffaf 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -62,8 +62,6 @@ class Request(testprocess.Line): def _check_status(self): """Check if the http status is what we expected.""" - # WORKAROUND for https://github.com/PyCQA/pylint/issues/399 (?) - # pylint: disable=no-member path_to_statuses = { '/favicon.ico': [HTTPStatus.NOT_FOUND], '/does-not-exist': [HTTPStatus.NOT_FOUND], @@ -88,7 +86,6 @@ class Request(testprocess.Line): path_to_statuses[key] = [HTTPStatus.UNAUTHORIZED, HTTPStatus.OK] default_statuses = [HTTPStatus.OK, HTTPStatus.NOT_MODIFIED] - # pylint: enable=no-member sanitized = QUrl('http://localhost' + self.path).path() # Remove ?foo expected_statuses = path_to_statuses.get(sanitized, default_statuses) From ba012c6ba894e2916ff1cfc5ba756f3f6da445da Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 10:35:19 +0100 Subject: [PATCH 296/524] Get rid of BaseKeyparser.Type --- qutebrowser/keyinput/basekeyparser.py | 14 ++------- qutebrowser/keyinput/keyparser.py | 2 +- qutebrowser/keyinput/modeparsers.py | 13 -------- tests/unit/keyinput/test_basekeyparser.py | 37 ++++++++--------------- tests/unit/keyinput/test_modeparsers.py | 3 +- 5 files changed, 17 insertions(+), 52 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 63fc11eee..35c3c8c6d 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -19,8 +19,6 @@ """Base class for vim-like key sequence parser.""" -import enum - from PyQt5.QtCore import pyqtSignal, QObject from PyQt5.QtGui import QKeySequence @@ -43,10 +41,6 @@ class BaseKeyParser(QObject): definitive: Keychain matches exactly. none: No more matches possible. - Types: type of a key binding. - chain: execute() was called via a chain-like key binding - special: execute() was called via a special key binding - do_log: Whether to log keypresses or not. passthrough: Whether unbound keys should be passed through with this handler. @@ -76,8 +70,6 @@ class BaseKeyParser(QObject): do_log = True passthrough = False - Type = enum.Enum('Type', ['chain', 'special']) - def __init__(self, win_id, parent=None, supports_count=None, supports_chains=False): super().__init__(parent) @@ -157,7 +149,7 @@ class BaseKeyParser(QObject): self._sequence)) count = int(self._count) if self._count else None self.clear_keystring() - self.execute(binding, self.Type.chain, count) + self.execute(binding, count) elif match == QKeySequence.PartialMatch: self._debug_log("No match for '{}' (added {})".format( self._sequence, txt)) @@ -248,13 +240,11 @@ class BaseKeyParser(QObject): # "because keychains are not supported there." # .format(key, modename)) - def execute(self, cmdstr, keytype, count=None): + def execute(self, cmdstr, count=None): """Handle a completed keychain. Args: cmdstr: The command to execute as a string. - # FIXME do we still need this? - keytype: Type.chain or Type.special count: The count if given. """ raise NotImplementedError diff --git a/qutebrowser/keyinput/keyparser.py b/qutebrowser/keyinput/keyparser.py index aab92bdb0..9914f0686 100644 --- a/qutebrowser/keyinput/keyparser.py +++ b/qutebrowser/keyinput/keyparser.py @@ -39,7 +39,7 @@ class CommandKeyParser(BaseKeyParser): super().__init__(win_id, parent, supports_count, supports_chains) self._commandrunner = runners.CommandRunner(win_id) - def execute(self, cmdstr, _keytype, count=None): + def execute(self, cmdstr, count=None): try: self._commandrunner.run(cmdstr, count) except cmdexc.Error as e: diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 0b196c23d..89f8f5ddb 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -232,19 +232,6 @@ class HintKeyParser(keyparser.CommandKeyParser): return match != QKeySequence.NoMatch - # FIXME why is this needed? - # def execute(self, cmdstr, keytype, count=None): - # """Handle a completed keychain.""" - # if not isinstance(keytype, self.Type): - # raise TypeError("Type {} is no Type member!".format(keytype)) - # if keytype == self.Type.chain: - # hintmanager = objreg.get('hintmanager', scope='tab', - # window=self._win_id, tab='current') - # hintmanager.handle_partial_key(cmdstr) - # else: - # # execute as command - # super().execute(cmdstr, keytype, count) - def update_bindings(self, strings, preserve_filter=False): """Update bindings when the hint strings changed. diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index ffd57d5f4..ce660777f 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -181,15 +181,13 @@ class TestSpecialKeys: modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier)) - keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.chain, None) + keyparser.execute.assert_called_once_with('message-info ctrla', None) def test_valid_key_count(self, fake_keyevent_factory, keyparser): modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier keyparser.handle(fake_keyevent_factory(Qt.Key_5, text='5')) keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier, text='A')) - keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.chain, 5) + keyparser.execute.assert_called_once_with('message-info ctrla', 5) def test_invalid_key(self, fake_keyevent_factory, keyparser): keyparser.handle(fake_keyevent_factory( @@ -212,8 +210,7 @@ class TestSpecialKeys: modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier keyparser.handle(fake_keyevent_factory(Qt.Key_B, modifier)) - keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.chain, None) + keyparser.execute.assert_called_once_with('message-info ctrla', None) def test_binding_and_mapping(self, config_stub, fake_keyevent_factory, keyparser): @@ -221,8 +218,7 @@ class TestSpecialKeys: modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) - keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.chain, None) + keyparser.execute.assert_called_once_with('message-info ctrla', None) class TestKeyChain: @@ -240,8 +236,7 @@ class TestKeyChain: modifier = Qt.ControlModifier keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier)) - keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.chain, None) + keyparser.execute.assert_called_once_with('message-info ctrla', None) assert not keyparser._sequence def test_invalid_special_key(self, fake_keyevent_factory, keyparser): @@ -255,14 +250,12 @@ class TestKeyChain: handle_text((Qt.Key_X, 'x'), # Then start the real chain (Qt.Key_B, 'b'), (Qt.Key_A, 'a')) - keyparser.execute.assert_called_with( - 'message-info ba', keyparser.Type.chain, None) + keyparser.execute.assert_called_with('message-info ba', None) assert not keyparser._sequence def test_0_press(self, handle_text, keyparser): handle_text((Qt.Key_0, '0')) - keyparser.execute.assert_called_once_with( - 'message-info 0', keyparser.Type.chain, None) + keyparser.execute.assert_called_once_with('message-info 0', None) assert not keyparser._sequence def test_ambiguous_keychain(self, handle_text, keyparser): @@ -276,8 +269,7 @@ class TestKeyChain: def test_mapping(self, config_stub, handle_text, keyparser): handle_text((Qt.Key_X, 'x')) - keyparser.execute.assert_called_once_with( - 'message-info a', keyparser.Type.chain, None) + keyparser.execute.assert_called_once_with('message-info a', None) def test_binding_and_mapping(self, config_stub, handle_text, keyparser): """with a conflicting binding/mapping, the binding should win.""" @@ -296,22 +288,20 @@ class TestCount: def test_no_count(self, handle_text, keyparser): """Test with no count added.""" handle_text((Qt.Key_B, 'b'), (Qt.Key_A, 'a')) - keyparser.execute.assert_called_once_with( - 'message-info ba', keyparser.Type.chain, None) + keyparser.execute.assert_called_once_with('message-info ba', None) assert not keyparser._sequence def test_count_0(self, handle_text, keyparser): handle_text((Qt.Key_0, '0'), (Qt.Key_B, 'b'), (Qt.Key_A, 'a')) - calls = [mock.call('message-info 0', keyparser.Type.chain, None), - mock.call('message-info ba', keyparser.Type.chain, None)] + calls = [mock.call('message-info 0', None), + mock.call('message-info ba', None)] keyparser.execute.assert_has_calls(calls) assert not keyparser._sequence def test_count_42(self, handle_text, keyparser): handle_text((Qt.Key_4, '4'), (Qt.Key_2, '2'), (Qt.Key_B, 'b'), (Qt.Key_A, 'a')) - keyparser.execute.assert_called_once_with( - 'message-info ba', keyparser.Type.chain, 42) + keyparser.execute.assert_called_once_with('message-info ba', 42) assert not keyparser._sequence def test_count_42_invalid(self, handle_text, keyparser): @@ -323,8 +313,7 @@ class TestCount: # Valid call with ccc gets the correct count handle_text((Qt.Key_2, '2'), (Qt.Key_3, '3'), (Qt.Key_C, 'c'), (Qt.Key_C, 'c'), (Qt.Key_C, 'c')) - keyparser.execute.assert_called_once_with( - 'message-info ccc', keyparser.Type.chain, 23) + keyparser.execute.assert_called_once_with('message-info ccc', 23) assert not keyparser._sequence diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py index 50332369f..d53328b7e 100644 --- a/tests/unit/keyinput/test_modeparsers.py +++ b/tests/unit/keyinput/test_modeparsers.py @@ -56,8 +56,7 @@ class TestsNormalKeyParser: # Then start the real chain keyparser.handle(fake_keyevent_factory(Qt.Key_B, text='b')) keyparser.handle(fake_keyevent_factory(Qt.Key_A, text='a')) - keyparser.execute.assert_called_with( - 'message-info ba', keyparser.Type.chain, None) + keyparser.execute.assert_called_with('message-info ba', None) assert not keyparser._sequence def test_partial_keychain_timeout(self, keyparser, config_stub, From ec3ad8a9698f6c936a0f63281ea34ae16101f03e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 10:55:16 +0100 Subject: [PATCH 297/524] Get rid of _warn_on_keychains and _supports_chains --- qutebrowser/keyinput/basekeyparser.py | 26 ++----------------- qutebrowser/keyinput/keyparser.py | 13 ++++------ qutebrowser/keyinput/modeman.py | 3 +-- qutebrowser/keyinput/modeparsers.py | 15 ++++------- tests/unit/keyinput/test_basekeyparser.py | 31 +---------------------- 5 files changed, 14 insertions(+), 74 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 35c3c8c6d..ec1004316 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -48,13 +48,9 @@ class BaseKeyParser(QObject): Attributes: bindings: Bound key bindings _win_id: The window ID this keyparser is associated with. - _warn_on_keychains: Whether a warning should be logged when binding - keychains in a section which does not support them. _sequence: The currently entered key sequence _modename: The name of the input mode associated with this keyparser. _supports_count: Whether count is supported - # FIXME is this still needed? - _supports_chains: Whether keychains are supported Signals: keystring_updated: Emitted when the keystring is updated. @@ -70,24 +66,18 @@ class BaseKeyParser(QObject): do_log = True passthrough = False - def __init__(self, win_id, parent=None, supports_count=None, - supports_chains=False): + def __init__(self, win_id, parent=None, supports_count=True): super().__init__(parent) self._win_id = win_id self._modename = None self._sequence = keyutils.KeySequence() self._count = '' - if supports_count is None: - supports_count = supports_chains self._supports_count = supports_count - self._supports_chains = supports_chains - self._warn_on_keychains = True self.bindings = {} config.instance.changed.connect(self._on_config_changed) def __repr__(self): - return utils.get_repr(self, supports_count=self._supports_count, - supports_chains=self._supports_chains) + return utils.get_repr(self, supports_count=self._supports_count) def _debug_log(self, message): """Log a message to the debug log if logging is active. @@ -195,10 +185,6 @@ class BaseKeyParser(QObject): """ match = self._handle_key(e) - # FIXME - # if handled or not self._supports_chains: - # return handled - # don't emit twice if the keystring was cleared in self.clear_keystring if self._sequence: self.keystring_updated.emit(self._count + str(self._sequence)) @@ -232,14 +218,6 @@ class BaseKeyParser(QObject): assert cmd self.bindings[key] = cmd - # FIXME - # def _parse_key_command(self, modename, key, cmd): - # """Parse the keys and their command and store them in the object.""" - # elif self._warn_on_keychains: - # log.keyboard.warning("Ignoring keychain '{}' in mode '{}' " - # "because keychains are not supported there." - # .format(key, modename)) - def execute(self, cmdstr, count=None): """Handle a completed keychain. diff --git a/qutebrowser/keyinput/keyparser.py b/qutebrowser/keyinput/keyparser.py index 9914f0686..4e7f032d0 100644 --- a/qutebrowser/keyinput/keyparser.py +++ b/qutebrowser/keyinput/keyparser.py @@ -34,9 +34,8 @@ class CommandKeyParser(BaseKeyParser): _commandrunner: CommandRunner instance. """ - def __init__(self, win_id, parent=None, supports_count=None, - supports_chains=False): - super().__init__(win_id, parent, supports_count, supports_chains) + def __init__(self, win_id, parent=None, supports_count=None): + super().__init__(win_id, parent, supports_count) self._commandrunner = runners.CommandRunner(win_id) def execute(self, cmdstr, count=None): @@ -60,7 +59,7 @@ class PassthroughKeyParser(CommandKeyParser): # do_log = False passthrough = True - def __init__(self, win_id, mode, parent=None, warn=True): + def __init__(self, win_id, mode, parent=None): """Constructor. Args: @@ -68,11 +67,9 @@ class PassthroughKeyParser(CommandKeyParser): parent: Qt parent. warn: Whether to warn if an ignored key was bound. """ - super().__init__(win_id, parent, supports_chains=False) - self._warn_on_keychains = warn + super().__init__(win_id, parent) self._read_config(mode) self._mode = mode def __repr__(self): - return utils.get_repr(self, mode=self._mode, - warn=self._warn_on_keychains) + return utils.get_repr(self, mode=self._mode) diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index e32830f50..75e3af367 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -71,8 +71,7 @@ def init(win_id, parent): KM.passthrough: keyparser.PassthroughKeyParser(win_id, 'passthrough', modeman), KM.command: keyparser.PassthroughKeyParser(win_id, 'command', modeman), - KM.prompt: keyparser.PassthroughKeyParser(win_id, 'prompt', modeman, - warn=False), + KM.prompt: keyparser.PassthroughKeyParser(win_id, 'prompt', modeman), KM.yesno: modeparsers.PromptKeyParser(win_id, modeman), KM.caret: modeparsers.CaretKeyParser(win_id, modeman), KM.set_mark: modeparsers.RegisterKeyParser(win_id, KM.set_mark, diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 89f8f5ddb..9c44e4818 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -48,8 +48,7 @@ class NormalKeyParser(keyparser.CommandKeyParser): """ def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=True, - supports_chains=True) + super().__init__(win_id, parent, supports_count=True) self._read_config('normal') self._partial_timer = usertypes.Timer(self, 'partial-match') self._partial_timer.setSingleShot(True) @@ -131,8 +130,7 @@ class PromptKeyParser(keyparser.CommandKeyParser): """KeyParser for yes/no prompts.""" def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=False, - supports_chains=True) + super().__init__(win_id, parent, supports_count=False) self._read_config('yesno') def __repr__(self): @@ -149,8 +147,7 @@ class HintKeyParser(keyparser.CommandKeyParser): """ def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=False, - supports_chains=True) + super().__init__(win_id, parent, supports_count=False) self._filtertext = '' self._last_press = LastPress.none self._read_config('hint') @@ -261,8 +258,7 @@ class CaretKeyParser(keyparser.CommandKeyParser): passthrough = True def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=True, - supports_chains=True) + super().__init__(win_id, parent, supports_count=True) self._read_config('caret') @@ -276,8 +272,7 @@ class RegisterKeyParser(keyparser.CommandKeyParser): """ def __init__(self, win_id, mode, parent=None): - super().__init__(win_id, parent, supports_count=False, - supports_chains=False) + super().__init__(win_id, parent, supports_count=False) self._mode = mode self._read_config('register') diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index ce660777f..0a8d44c60 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -19,7 +19,6 @@ """Tests for BaseKeyParser.""" -import logging from unittest import mock from PyQt5.QtCore import Qt @@ -37,8 +36,7 @@ def keyseq(s): @pytest.fixture def keyparser(key_config_stub): """Fixture providing a BaseKeyParser supporting count/chains.""" - kp = basekeyparser.BaseKeyParser( - 0, supports_count=True, supports_chains=True) + kp = basekeyparser.BaseKeyParser(0, supports_count=True) kp.execute = mock.Mock() yield kp @@ -56,19 +54,6 @@ def handle_text(fake_keyevent_factory, keyparser): return func -@pytest.mark.parametrize('count, chains, count_expected, chains_expected', [ - (True, False, True, False), - (False, True, False, True), - (None, True, True, True), -]) -def test_supports_args(config_stub, count, chains, count_expected, - chains_expected): - kp = basekeyparser.BaseKeyParser( - 0, supports_count=count, supports_chains=chains) - assert kp._supports_count == count_expected - assert kp._supports_chains == chains_expected - - class TestDebugLog: """Make sure _debug_log only logs when do_log is set.""" @@ -154,20 +139,6 @@ class TestReadConfig: assert keyseq('a') in keyparser.bindings assert (keyseq('new') in keyparser.bindings) == expected - # FIXME do we still need this? - @pytest.mark.parametrize('warn_on_keychains', [True, False]) - @pytest.mark.skip(reason='unneeded?') - def test_warn_on_keychains(self, caplog, warn_on_keychains): - """Test _warn_on_keychains.""" - kp = basekeyparser.BaseKeyParser( - 0, supports_count=False, supports_chains=False) - kp._warn_on_keychains = warn_on_keychains - - with caplog.at_level(logging.WARNING): - kp._read_config('normal') - - assert bool(caplog.records) == warn_on_keychains - class TestSpecialKeys: From bd87b4eb10a4541199d27621a7bf43537f8827e5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 11:04:05 +0100 Subject: [PATCH 298/524] Stop logging in PassthroughKeyParser --- qutebrowser/keyinput/keyparser.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qutebrowser/keyinput/keyparser.py b/qutebrowser/keyinput/keyparser.py index 4e7f032d0..0ce123bfc 100644 --- a/qutebrowser/keyinput/keyparser.py +++ b/qutebrowser/keyinput/keyparser.py @@ -55,8 +55,7 @@ class PassthroughKeyParser(CommandKeyParser): _mode: The mode this keyparser is for. """ - # FIXME - # do_log = False + do_log = False passthrough = True def __init__(self, win_id, mode, parent=None): From 898f5c50c4e023c225917a34a1e3855a1db9a7a2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 11:20:19 +0100 Subject: [PATCH 299/524] Add a test for utils.chunk --- qutebrowser/utils/utils.py | 3 ++- tests/unit/utils/test_utils.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index f7c1c90b0..f03d42844 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -663,6 +663,7 @@ def chunk(elems, n): If elems % n != 0, the last chunk will be smaller. """ - # FIXME test this + if n < 1: + raise ValueError("n needs to be at least 1!") for i in range(0, len(elems), n): yield elems[i:i + n] diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index e8391a74f..b2eef0237 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -787,3 +787,19 @@ class TestYaml: with tmpfile.open('w', encoding='utf-8') as f: utils.yaml_dump([1, 2], f) assert tmpfile.read() == '- 1\n- 2\n' + + +@pytest.mark.parametrize('elems, n, expected', [ + ([], 1, []), + ([1], 1, [[1]]), + ([1, 2], 2, [[1, 2]]), + ([1, 2, 3, 4], 2, [[1, 2], [3, 4]]), +]) +def test_chunk(elems, n, expected): + assert list(utils.chunk(elems, n)) == expected + + +@pytest.mark.parametrize('n', [-1, 0]) +def test_chunk_invalid(n): + with pytest.raises(ValueError): + list(utils.chunk([], n)) From 8090d3e28940f02e94858d80293dcfcd8a635fd6 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 12:28:59 +0100 Subject: [PATCH 300/524] Handle invalid keys in config.py --- qutebrowser/config/configfiles.py | 3 +++ tests/unit/config/test_configfiles.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 82f90db76..05ed23e60 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -333,6 +333,9 @@ class ConfigAPI: 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.""" diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index c902bb42d..37b565374 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -29,6 +29,7 @@ from PyQt5.QtCore import QSettings from qutebrowser.config import (config, configfiles, configexc, configdata, configtypes) from qutebrowser.utils import utils, usertypes, urlmatch +from qutebrowser.keyinput import keyutils @pytest.fixture(autouse=True) @@ -699,6 +700,20 @@ class TestConfigPy: message = "'ConfigAPI' object has no attribute 'val'" assert str(error.exception) == message + @pytest.mark.parametrize('line', [ + 'config.bind("", "nop")', + 'config.bind("\U00010000", "nop")', + 'config.unbind("")', + 'config.unbind("\U00010000")', + ]) + def test_invalid_keys(self, confpy, line): + confpy.write(line) + error = confpy.read(error=True) + assert error.text.endswith("and parsing key") + assert isinstance(error.exception, keyutils.KeyParseError) + assert str(error.exception).startswith("Could not parse") + assert str(error.exception).endswith("Got unknown key!") + @pytest.mark.parametrize('line', ["c.foo = 42", "config.set('foo', 42)"]) def test_config_error(self, confpy, line): confpy.write(line) From 7a27469ecdfe7db5e8f3519824c8191b0acc0f10 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 12:40:44 +0100 Subject: [PATCH 301/524] Handle unknown keys in :bind completion --- qutebrowser/completion/models/configmodel.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index 435eb0643..e89dab227 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -80,9 +80,16 @@ def bind(key, *, info): """ model = completionmodel.CompletionModel(column_widths=(20, 60, 20)) data = [] - seq = keyutils.KeySequence.parse(key) - cmd_text = info.keyconf.get_command(seq, 'normal') + try: + seq = keyutils.KeySequence.parse(key) + except keyutils.KeyParseError as e: + seq = None + cmd_text = None + data.append(('', str(e), key)) + + if seq: + cmd_text = info.keyconf.get_command(seq, 'normal') if cmd_text: parser = runners.CommandParser() try: @@ -92,7 +99,8 @@ def bind(key, *, info): else: data.append((cmd_text, '(Current) {}'.format(cmd.desc), key)) - cmd_text = info.keyconf.get_command(seq, 'normal', default=True) + if seq: + cmd_text = info.keyconf.get_command(seq, 'normal') if cmd_text: parser = runners.CommandParser() cmd = parser.parse(cmd_text).cmd From 88b50074570a8abcdb5d64ffe18a68923c1b7a11 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 12:54:11 +0100 Subject: [PATCH 302/524] Consolidate invalid :bind/:unbind tests --- tests/unit/config/test_configcommands.py | 67 +++++++++++------------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 320eb2bd4..0e2427c06 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -19,6 +19,7 @@ """Tests for qutebrowser.config.configcommands.""" import logging +import functools import unittest.mock import pytest @@ -543,23 +544,39 @@ class TestBind: msg = message_mock.getmsg(usertypes.MessageLevel.info) assert msg.text == expected - def test_bind_invalid_mode(self, commands): - """Run ':bind --mode=wrongmode a nop'. + @pytest.mark.parametrize('command, args, kwargs, expected', [ + # :bind --mode=wrongmode a nop + ('bind', ['a', 'nop'], {'mode': 'wrongmode'}, + 'Invalid mode wrongmode!'), + # :bind --mode=wrongmode a + ('bind', ['a'], {'mode': 'wrongmode'}, + 'Invalid mode wrongmode!'), + # :bind --default --mode=wrongmode a + ('bind', ['a'], {'mode': 'wrongmode', 'default': True}, + 'Invalid mode wrongmode!'), + # :bind --default foobar + ('bind', ['foobar'], {'default': True}, + "Can't find binding 'foobar' in normal mode"), + # :unbind foobar + ('unbind', ['foobar'], {}, + "Can't find binding 'foobar' in normal mode"), + # :unbind --mode=wrongmode x + ('unbind', ['x'], {'mode': 'wrongmode'}, + 'Invalid mode wrongmode!'), + ]) + def test_bind_invalid(self, commands, + command, args, kwargs, expected): + """Run various wrong :bind/:unbind invocations. Should show an error. """ - with pytest.raises(cmdexc.CommandError, - match='Invalid mode wrongmode!'): - commands.bind(0, 'a', 'nop', mode='wrongmode') + if command == 'bind': + func = functools.partial(commands.bind, 0) + elif command == 'unbind': + func = commands.unbind - def test_bind_print_invalid_mode(self, commands): - """Run ':bind --mode=wrongmode a'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, - match='Invalid mode wrongmode!'): - commands.bind(0, 'a', mode='wrongmode') + with pytest.raises(cmdexc.CommandError, match=expected): + func(*args, **kwargs) @pytest.mark.parametrize('key', ['a', 'b', '']) def test_bind_duplicate(self, commands, config_stub, key_config_stub, key): @@ -596,18 +613,6 @@ class TestBind: command = key_config_stub.get_command(keyseq('a'), mode='normal') assert command == default_cmd - @pytest.mark.parametrize('key, mode, expected', [ - ('foobar', 'normal', "Can't find binding 'foobar' in normal mode"), - ('x', 'wrongmode', "Invalid mode wrongmode!"), - ]) - def test_bind_default_invalid(self, commands, key, mode, expected): - """Run ':bind --default foobar' / ':bind --default x wrongmode'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, match=expected): - commands.bind(0, key, mode=mode, default=True) - def test_unbind_none(self, commands, config_stub): config_stub.val.bindings.commands = None commands.unbind('H') @@ -641,15 +646,3 @@ class TestBind: assert normalized not in yaml_bindings else: assert yaml_bindings[normalized] is None - - @pytest.mark.parametrize('key, mode, expected', [ - ('foobar', 'normal', "Can't find binding 'foobar' in normal mode"), - ('x', 'wrongmode', "Invalid mode wrongmode!"), - ]) - def test_unbind_invalid(self, commands, key, mode, expected): - """Run ':unbind foobar' / ':unbind x wrongmode'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, match=expected): - commands.unbind(key, mode=mode) From 244590f49df33dbbaec87e33ddaa8af7796eb128 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 12:59:23 +0100 Subject: [PATCH 303/524] Handle unknown keys with :bind/:unbind --- qutebrowser/config/configcommands.py | 11 +++++++++-- tests/unit/config/test_configcommands.py | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 311ee4102..f81c21aac 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -59,6 +59,13 @@ class ConfigCommands: raise cmdexc.CommandError("Error while parsing {}: {}" .format(pattern, str(e))) + def _parse_key(self, key): + """Parse a key argument.""" + try: + return keyutils.KeySequence.parse(key) + except keyutils.KeyParseError as e: + raise cmdexc.CommandError(str(e)) + def _print_value(self, option, pattern): """Print the value of the given option.""" with self._handle_config_error(): @@ -143,7 +150,7 @@ class ConfigCommands: tabbed_browser.openurl(QUrl('qute://bindings'), newtab=True) return - seq = keyutils.KeySequence.parse(key) + seq = self._parse_key(key) if command is None: if default: @@ -176,7 +183,7 @@ class ConfigCommands: See `:help bindings.commands` for the available modes. """ with self._handle_config_error(): - self._keyconfig.unbind(keyutils.KeySequence.parse(key), mode=mode, + self._keyconfig.unbind(self._parse_key(key), mode=mode, save_yaml=True) @cmdutils.register(instance='config-commands', star_args_optional=True) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 0e2427c06..a74b446d1 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -557,12 +557,18 @@ class TestBind: # :bind --default foobar ('bind', ['foobar'], {'default': True}, "Can't find binding 'foobar' in normal mode"), + # :bind nop + ('bind', ['', 'nop'], {}, + "Could not parse '': Got unknown key!"), # :unbind foobar ('unbind', ['foobar'], {}, "Can't find binding 'foobar' in normal mode"), # :unbind --mode=wrongmode x ('unbind', ['x'], {'mode': 'wrongmode'}, 'Invalid mode wrongmode!'), + # :unbind + ('unbind', [''], {}, + "Could not parse '': Got unknown key!"), ]) def test_bind_invalid(self, commands, command, args, kwargs, expected): From 3a79f1293f08fdadf462ece7cdaf068f3400d3fb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 12:59:45 +0100 Subject: [PATCH 304/524] Remove FIXMEs --- qutebrowser/keyinput/keyutils.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index b3890f57c..06fcaae4b 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -290,6 +290,9 @@ class KeySequence: This internally uses chained QKeySequence objects and exposes a nicer interface over it. + NOTE: While private members of this class are in theory mutable, they must + not be mutated in order to ensure consistent hashing. + Attributes: _sequences: A list of QKeySequence @@ -344,7 +347,6 @@ class KeySequence: return self._sequences != other._sequences def __hash__(self): - # FIXME is this correct? return hash(tuple(self._sequences)) def __len__(self): @@ -371,7 +373,6 @@ class KeySequence: def matches(self, other): """Check whether the given KeySequence matches with this one.""" - # FIXME test this # pylint: disable=protected-access assert self._sequences assert other._sequences @@ -396,8 +397,6 @@ class KeySequence: In addition, Shift also *is* relevant when other modifiers are involved. Shift-Ctrl-X should not be equivalent to Ctrl-X. - - FIXME: create test cases! """ modifiers = ev.modifiers() @@ -416,7 +415,6 @@ class KeySequence: """Parse a keystring like or xyz and return a KeySequence.""" # pylint: disable=protected-access # FIXME: test stuff like - # FIXME make sure all callers handle KeyParseError new = cls() strings = list(_parse_keystring(keystr)) for sub in utils.chunk(strings, cls._MAX_LEN): From b85fe8f678a39deabbd4498ce579b15f88f58803 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 14:07:20 +0100 Subject: [PATCH 305/524] Merge BaseKeyParser._handle_key into .handle --- qutebrowser/keyinput/basekeyparser.py | 126 ++++++++++++-------------- qutebrowser/keyinput/modeman.py | 8 +- qutebrowser/keyinput/modeparsers.py | 45 ++++----- 3 files changed, 78 insertions(+), 101 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index ec1004316..27d760d9a 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -88,69 +88,6 @@ class BaseKeyParser(QObject): if self.do_log: log.keyboard.debug(message) - def _handle_key(self, e): - """Handle a new keypress. - - Separate the keypress into count/command, then check if it matches - any possible command, and either run the command, ignore it, or - display an error. - - Args: - e: the KeyPressEvent from Qt. - - Return: - A QKeySequence match or None. - """ - key = e.key() - txt = keyutils.keyevent_to_string(e) - self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) - - if not txt: - self._debug_log("Ignoring, no text char") - return QKeySequence.NoMatch - - # if len(txt) == 1: - # category = unicodedata.category(txt) - # is_control_char = (category == 'Cc') - # else: - # is_control_char = False - - # if (not txt) or is_control_char: - # self._debug_log("Ignoring, no text char") - # return QKeySequence.NoMatch - - if (txt.isdigit() and self._supports_count and not - (not self._count and txt == '0')): - assert len(txt) == 1, txt - self._count += txt - return None - - sequence = self._sequence.append_event(e) - match, binding = self._match_key(sequence) - if match == QKeySequence.NoMatch: - mappings = config.val.bindings.key_mappings - mapped = mappings.get(sequence, None) - if mapped is not None: - match, binding = self._match_key(mapped) - - self._sequence = self._sequence.append_event(e) - if match == QKeySequence.ExactMatch: - self._debug_log("Definitive match for '{}'.".format( - self._sequence)) - count = int(self._count) if self._count else None - self.clear_keystring() - self.execute(binding, count) - elif match == QKeySequence.PartialMatch: - self._debug_log("No match for '{}' (added {})".format( - self._sequence, txt)) - elif match == QKeySequence.NoMatch: - self._debug_log("Giving up with '{}', no matches".format( - self._sequence)) - self.clear_keystring() - else: - raise utils.Unreachable("Invalid match value {!r}".format(match)) - return match - def _match_key(self, sequence): """Try to match a given keystring with any bound keychain. @@ -175,21 +112,70 @@ class BaseKeyParser(QObject): return (QKeySequence.NoMatch, None) def handle(self, e): - """Handle a new keypress and call the respective handlers. + """Handle a new keypress. + + Separate the keypress into count/command, then check if it matches + any possible command, and either run the command, ignore it, or + display an error. Args: - e: the KeyPressEvent from Qt + e: the KeyPressEvent from Qt. Return: - True if the event was handled, False otherwise. + A QKeySequence match. """ - match = self._handle_key(e) + key = e.key() + txt = keyutils.keyevent_to_string(e) + self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) - # don't emit twice if the keystring was cleared in self.clear_keystring - if self._sequence: + if not txt: + self._debug_log("Ignoring, no text char") + return QKeySequence.NoMatch + + # if len(txt) == 1: + # category = unicodedata.category(txt) + # is_control_char = (category == 'Cc') + # else: + # is_control_char = False + + # if (not txt) or is_control_char: + # self._debug_log("Ignoring, no text char") + # return QKeySequence.NoMatch + + if (txt.isdigit() and self._supports_count and not + (not self._count and txt == '0')): + assert len(txt) == 1, txt + self._count += txt + return QKeySequence.ExactMatch + + sequence = self._sequence.append_event(e) + match, binding = self._match_key(sequence) + if match == QKeySequence.NoMatch: + mappings = config.val.bindings.key_mappings + mapped = mappings.get(sequence, None) + if mapped is not None: + match, binding = self._match_key(mapped) + + self._sequence = self._sequence.append_event(e) + + if match == QKeySequence.ExactMatch: + self._debug_log("Definitive match for '{}'.".format( + self._sequence)) + count = int(self._count) if self._count else None + self.clear_keystring() + self.execute(binding, count) + elif match == QKeySequence.PartialMatch: + self._debug_log("No match for '{}' (added {})".format( + self._sequence, txt)) self.keystring_updated.emit(self._count + str(self._sequence)) + elif match == QKeySequence.NoMatch: + self._debug_log("Giving up with '{}', no matches".format( + self._sequence)) + self.clear_keystring() + else: + raise utils.Unreachable("Invalid match value {!r}".format(match)) - return match != QKeySequence.NoMatch + return match @config.change_filter('bindings') def _on_config_changed(self): diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index 75e3af367..94d76832d 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -157,7 +157,7 @@ class ModeManager(QObject): if curmode != usertypes.KeyMode.insert: log.modes.debug("got keypress in mode {} - delegating to " "{}".format(curmode, utils.qualname(parser))) - handled = parser.handle(event) + match = parser.handle(event) is_non_alnum = ( event.modifiers() not in [Qt.NoModifier, Qt.ShiftModifier] or @@ -165,7 +165,7 @@ class ModeManager(QObject): forward_unbound_keys = config.val.input.forward_unbound_keys - if handled: + if match: filter_this = True elif (parser.passthrough or forward_unbound_keys == 'all' or (forward_unbound_keys == 'auto' and is_non_alnum)): @@ -178,10 +178,10 @@ class ModeManager(QObject): if curmode != usertypes.KeyMode.insert: focus_widget = QApplication.instance().focusWidget() - log.modes.debug("handled: {}, forward_unbound_keys: {}, " + log.modes.debug("match: {}, forward_unbound_keys: {}, " "passthrough: {}, is_non_alnum: {} --> " "filter: {} (focused: {!r})".format( - handled, forward_unbound_keys, + match, forward_unbound_keys, parser.passthrough, is_non_alnum, filter_this, focus_widget)) return filter_this diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 9c44e4818..f397b9fd5 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -153,8 +153,8 @@ class HintKeyParser(keyparser.CommandKeyParser): self._read_config('hint') self.keystring_updated.connect(self.on_keystring_updated) - def _handle_special_key(self, e): - """Override _handle_special_key to handle string filtering. + def _handle_filter_key(self, e): + """Handle keys for string filtering. Return True if the keypress has been handled, and False if not. @@ -162,10 +162,8 @@ class HintKeyParser(keyparser.CommandKeyParser): e: the KeyPressEvent from Qt. Return: - True if event has been handled, False otherwise. + A QKeySequence match. """ - # FIXME rewrite this - # FIXME should backspacing be a more generic hint feature? log.keyboard.debug("Got special key 0x{:x} text {}".format( e.key(), e.text())) hintmanager = objreg.get('hintmanager', scope='tab', @@ -178,7 +176,7 @@ class HintKeyParser(keyparser.CommandKeyParser): if self._last_press == LastPress.filtertext and self._filtertext: self._filtertext = self._filtertext[:-1] hintmanager.filter_hints(self._filtertext) - return True + return QKeySequence.ExactMatch elif self._last_press == LastPress.keystring and self._sequence: self._sequence = self._sequence[:-1] self.keystring_updated.emit(str(self._sequence)) @@ -187,18 +185,18 @@ class HintKeyParser(keyparser.CommandKeyParser): # in numeric mode after the number has been deleted). hintmanager.filter_hints(self._filtertext) self._last_press = LastPress.filtertext - return True + return QKeySequence.ExactMatch else: - return False + return QKeySequence.NoMatch elif hintmanager.current_mode() != 'number': - return False + return QKeySequence.NoMatch elif not e.text(): - return False + return QKeySequence.NoMatch else: self._filtertext += e.text() hintmanager.filter_hints(self._filtertext) self._last_press = LastPress.filtertext - return True + return QKeySequence.ExactMatch def handle(self, e): """Handle a new keypress and call the respective handlers. @@ -209,25 +207,18 @@ class HintKeyParser(keyparser.CommandKeyParser): Returns: True if the match has been handled, False otherwise. """ - # FIXME rewrite this - match = self._handle_key(e) + match = super().handle(e) if match == QKeySequence.PartialMatch: - # FIXME do we need to check self._sequence here? - self.keystring_updated.emit(str(self._sequence)) self._last_press = LastPress.keystring - return True elif match == QKeySequence.ExactMatch: self._last_press = LastPress.none - return True - elif match is None: # FIXME - return None elif match == QKeySequence.NoMatch: # We couldn't find a keychain so we check if it's a special key. - return self._handle_special_key(e) + return self._handle_filter_key(e) else: raise ValueError("Got invalid match type {}!".format(match)) - return match != QKeySequence.NoMatch + return match def update_bindings(self, strings, preserve_filter=False): """Update bindings when the hint strings changed. @@ -285,15 +276,16 @@ class RegisterKeyParser(keyparser.CommandKeyParser): Return: True if event has been handled, False otherwise. """ - # FIXME rewrite this - if super().handle(e): - return True + match = super().handle(e) + if match: + return match key = e.text() if key == '' or keyutils.keyevent_to_string(e) is None: # this is not a proper register key, let it pass and keep going - return False + # FIXME can we simplify this when we refactor keyutils.py? + return QKeySequence.NoMatch tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) @@ -315,5 +307,4 @@ class RegisterKeyParser(keyparser.CommandKeyParser): message.error(str(err), stack=traceback.format_exc()) self.request_leave.emit(self._mode, "valid register key", True) - - return True + return QKeySequence.ExactMatch From 49d297f7bf33b4fcf46797c0158032d35867c5ec Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 14:10:55 +0100 Subject: [PATCH 306/524] Fix tests for parsing KeySequences --- tests/unit/keyinput/test_keyutils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index db5b380d7..92e52490d 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -110,8 +110,8 @@ class TestKeyEventToString: ('xyz', keyutils.KeySequence(Qt.Key_X, Qt.Key_Y, Qt.Key_Z)), ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X, Qt.MetaModifier | Qt.Key_Y)), - # FIXME - # (', ', keyutils.KeyParseError), + ('', keyutils.KeyParseError), + ('\U00010000', keyutils.KeyParseError), ]) def test_parse(keystr, expected): if expected is keyutils.KeyParseError: From 5a5873d4eebcec8fb5b380955b6c37b2c3da6edc Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 14:16:41 +0100 Subject: [PATCH 307/524] Rename KeyConfig._prepare to ._validate --- qutebrowser/config/config.py | 12 ++++++------ tests/unit/config/test_config.py | 10 ++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index f8f9e7902..eb2a81594 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -136,8 +136,8 @@ class KeyConfig: def __init__(self, config): self._config = config - def _prepare(self, key, mode): - """Make sure the given mode exists.""" + def _validate(self, key, mode): + """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: @@ -170,7 +170,7 @@ class KeyConfig: def get_command(self, key, mode, default=False): """Get the command for a given key (or None).""" - self._prepare(key, mode) + self._validate(key, mode) if default: bindings = dict(val.bindings.default[mode]) else: @@ -184,7 +184,7 @@ class KeyConfig: "Can't add binding '{}' with empty command in {} " 'mode'.format(key, mode)) - self._prepare(key, mode) + self._validate(key, mode) log.keyboard.vdebug("Adding binding {} -> {} in mode {}.".format( key, command, mode)) @@ -196,7 +196,7 @@ class KeyConfig: def bind_default(self, key, *, mode='normal', save_yaml=False): """Restore a default keybinding.""" - self._prepare(key, mode) + self._validate(key, mode) bindings_commands = self._config.get_mutable_obj('bindings.commands') try: @@ -208,7 +208,7 @@ class KeyConfig: def unbind(self, key, *, mode='normal', save_yaml=False): """Unbind the given key in the given mode.""" - self._prepare(key, mode) + self._validate(key, mode) bindings_commands = self._config.get_mutable_obj('bindings.commands') diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index f47ee7a0a..40e82ba4b 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -103,15 +103,13 @@ class TestKeyConfig: """Get a dict with no bindings.""" return {'normal': {}} - def test_prepare_invalid_mode(self, key_config_stub): - """Make sure prepare checks the mode.""" + def test_validate_invalid_mode(self, key_config_stub): with pytest.raises(configexc.KeybindingError): - assert key_config_stub._prepare(keyseq('x'), 'abnormal') + assert key_config_stub._validate(keyseq('x'), 'abnormal') - def test_prepare_invalid_type(self, key_config_stub): - """Make sure prepare checks the type.""" + def test_validate_invalid_type(self, key_config_stub): with pytest.raises(AssertionError): - assert key_config_stub._prepare('x', 'normal') + assert key_config_stub._validate('x', 'normal') @pytest.mark.parametrize('commands, expected', [ # Unbinding default key From 60f0175a368d8dfdc3e3991fea9cfac2f363edc1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 15:39:57 +0100 Subject: [PATCH 308/524] Fix getting customized options This was broken with per-domain settings Fixes #3649 --- qutebrowser/completion/models/configmodel.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index c433dbc12..b9ad6f626 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -36,8 +36,10 @@ def option(*, info): def customized_option(*, info): """A CompletionModel filled with set settings and their descriptions.""" model = completionmodel.CompletionModel(column_widths=(20, 70, 10)) - options = ((opt.name, opt.description, info.config.get_str(opt.name)) - for opt, _value in info.config) + options = ((values.opt.name, values.opt.description, + info.config.get_str(values.opt.name)) + for values in info.config + if values) model.add_category(listcategory.ListCategory("Customized options", options)) return model From 5eb340d4816d8d176401e2f6de884cfa24f69e85 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 15:55:00 +0100 Subject: [PATCH 309/524] Add missing tests for completions --- scripts/dev/check_coverage.py | 2 + tests/unit/completion/test_models.py | 69 +++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 0e79a6e02..09702eac4 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -173,6 +173,8 @@ PERFECT_FILES = [ 'completion/models/util.py'), ('tests/unit/completion/test_models.py', 'completion/models/urlmodel.py'), + ('tests/unit/completion/test_models.py', + 'completion/models/configmodel.py'), ('tests/unit/completion/test_histcategory.py', 'completion/models/histcategory.py'), ('tests/unit/completion/test_listcategory.py', diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 32e6920fe..9e355289f 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -28,7 +28,7 @@ from PyQt5.QtCore import QUrl from qutebrowser.completion import completer from qutebrowser.completion.models import miscmodels, urlmodel, configmodel from qutebrowser.config import configdata, configtypes -from qutebrowser.utils import objreg +from qutebrowser.utils import objreg, usertypes from qutebrowser.browser import history from qutebrowser.commands import cmdutils @@ -91,7 +91,8 @@ def configdata_stub(config_stub, monkeypatch, configdata_init): valtype=configtypes.Command(), ), default={'q': 'quit'}, - backends=[], + backends=[usertypes.Backend.QtWebKit, + usertypes.Backend.QtWebEngine], raw_backends=None)), ('bindings.default', configdata.Option( name='bindings.default', @@ -131,6 +132,13 @@ def configdata_stub(config_stub, monkeypatch, configdata_init): }, backends=[], raw_backends=None)), + ('content.javascript.enabled', configdata.Option( + name='content.javascript.enabled', + description='Enable/Disable JavaScript', + typ=configtypes.Bool(), + default=True, + backends=[], + raw_backends=None)), ])) config_stub._init_values() @@ -248,6 +256,7 @@ def test_help_completion(qtmodeltester, cmdutils_stub, key_config_stub, ('aliases', 'Aliases for commands.', None), ('bindings.commands', 'Default keybindings', None), ('bindings.default', 'Default keybindings', None), + ('content.javascript.enabled', 'Enable/Disable JavaScript', None), ] }) @@ -648,10 +657,66 @@ def test_setting_option_completion(qtmodeltester, config_stub, '"I": "invalid", "d": "scroll down"}}')), ('bindings.default', 'Default keybindings', '{"normal": {"": "quit", "d": "tab-close"}}'), + ('content.javascript.enabled', 'Enable/Disable JavaScript', + 'true'), ] }) +def test_setting_customized_option_completion(qtmodeltester, config_stub, + configdata_stub, info): + info.config.set_obj('aliases', {'foo': 'nop'}) + + model = configmodel.customized_option(info=info) + model.set_pattern('') + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + _check_completions(model, { + "Customized options": [ + ('aliases', 'Aliases for commands.', '{"foo": "nop"}'), + ] + }) + + +def test_setting_value_completion(qtmodeltester, config_stub, configdata_stub, + info): + model = configmodel.value(optname='content.javascript.enabled', info=info) + model.set_pattern('') + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + _check_completions(model, { + "Current/Default": [ + ('true', 'Current value', None), + ('true', 'Default value', None), + ], + "Completions": [ + ('false', '', None), + ('true', '', None), + ], + }) + + +def test_setting_value_no_completions(qtmodeltester, config_stub, + configdata_stub, info): + model = configmodel.value(optname='aliases', info=info) + model.set_pattern('') + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + _check_completions(model, { + "Current/Default": [ + ('{"q": "quit"}', 'Current value', None), + ('{"q": "quit"}', 'Default value', None), + ], + }) + + +def test_setting_value_completion_invalid(info): + assert configmodel.value(optname='foobarbaz', info=info) is None + + def test_bind_completion(qtmodeltester, cmdutils_stub, config_stub, key_config_stub, configdata_stub, info): """Test the results of keybinding command completion. From e6aa6b82353b452d69ff7a5742e622d65549ae52 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 17:29:36 +0100 Subject: [PATCH 310/524] Add missing docs for {url:host} --- doc/help/commands.asciidoc | 1 + qutebrowser/commands/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 58dfaaa16..7c8ea1688 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -14,6 +14,7 @@ For command arguments, there are also some variables you can use: - `{url}` expands to the URL of the current page - `{url:pretty}` expands to the URL in decoded format +- `{url:host}` expands to the host part of the URL - `{clipboard}` expands to the clipboard contents - `{primary}` expands to the primary selection contents diff --git a/qutebrowser/commands/__init__.py b/qutebrowser/commands/__init__.py index 0bbc9852b..6ba8a9ae3 100644 --- a/qutebrowser/commands/__init__.py +++ b/qutebrowser/commands/__init__.py @@ -26,6 +26,7 @@ For command arguments, there are also some variables you can use: - `{url}` expands to the URL of the current page - `{url:pretty}` expands to the URL in decoded format +- `{url:host}` expands to the host part of the URL - `{clipboard}` expands to the clipboard contents - `{primary}` expands to the primary selection contents From 889b03169a6b59f91b58a2f4ea7c6807bc0ac6fd Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 06:28:01 +0100 Subject: [PATCH 311/524] Upgrade to PyQt 5.10.1 --- misc/requirements/requirements-pyqt.txt | 4 ++-- tox.ini | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/misc/requirements/requirements-pyqt.txt b/misc/requirements/requirements-pyqt.txt index 99ba1b7cc..059ff2df7 100644 --- a/misc/requirements/requirements-pyqt.txt +++ b/misc/requirements/requirements-pyqt.txt @@ -1,4 +1,4 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -PyQt5==5.10 -sip==4.19.7 +PyQt5==5.10.1 +sip==4.19.8 diff --git a/tox.ini b/tox.ini index f09f92165..b27fa7906 100644 --- a/tox.ini +++ b/tox.ini @@ -28,7 +28,7 @@ deps = pyqt571: PyQt5==5.7.1 pyqt58: PyQt5==5.8.2 pyqt59: PyQt5==5.9.2 - pyqt510: PyQt5==5.10 + pyqt510: PyQt5==5.10.1 commands = {envpython} scripts/link_pyqt.py --tox {envdir} {envpython} -bb -m pytest {posargs:tests} From 824825e67da44a0eab49182db6efd5423a8aa8b0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 08:01:11 +0100 Subject: [PATCH 312/524] Make sure we only show dictionary warnings once After 3956f81e730463adcba05d92d0043155609aa422 where this was made a function, the warning was shown twice, causing AppVeyor to fail. --- qutebrowser/browser/webengine/webenginesettings.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 2b9ae55e2..83453beb2 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -255,14 +255,15 @@ def _set_persistent_cookie_policy(profile): profile.setPersistentCookiesPolicy(value) -def _set_dictionary_language(profile): +def _set_dictionary_language(profile, warn=True): filenames = [] for code in config.val.spellcheck.languages or []: local_filename = spell.local_filename(code) if not local_filename: - message.warning( - "Language {} is not installed - see scripts/dictcli.py " - "in qutebrowser's sources".format(code)) + if warn: + message.warning( + "Language {} is not installed - see scripts/dictcli.py " + "in qutebrowser's sources".format(code)) continue filenames.append(local_filename) @@ -293,7 +294,7 @@ def _update_settings(option): # We're not touching the private profile's cookie policy. elif option == 'spellcheck.languages' and qtutils.version_check('5.8'): _set_dictionary_language(default_profile) - _set_dictionary_language(private_profile) + _set_dictionary_language(private_profile, warn=False) def _init_profile(profile): From f3aaa1084a382296019ee79b479286d45a45a8e5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 08:08:23 +0100 Subject: [PATCH 313/524] Migrate spell tests to unittests --- tests/end2end/conftest.py | 23 +---------- tests/end2end/features/misc.feature | 14 +------ .../webengine/test_webenginesettings.py | 39 ++++++++++++++++++- 3 files changed, 39 insertions(+), 37 deletions(-) diff --git a/tests/end2end/conftest.py b/tests/end2end/conftest.py index d3d34097b..5226396a3 100644 --- a/tests/end2end/conftest.py +++ b/tests/end2end/conftest.py @@ -118,27 +118,6 @@ def _get_backend_tag(tag): return pytest_marks[name](desc) -def _get_dictionary_tag(tag): - """Handle tags like must_have_dict=en-US for BDD tests.""" - dict_re = re.compile(r""" - (?Pmust_have_dict|cannot_have_dict)=(?P[a-z]{2}-[A-Z]{2}) - """, re.VERBOSE) - - match = dict_re.fullmatch(tag) - if not match: - return None - - event = match.group('event') - dictionary = match.group('dict') - has_dict = spell.local_filename(dictionary) is not None - if event == 'must_have_dict': - return pytest.mark.skipif(not has_dict, reason=tag) - elif event == 'cannot_have_dict': - return pytest.mark.skipif(has_dict, reason=tag) - else: - return None - - if not getattr(sys, 'frozen', False): def pytest_bdd_apply_tag(tag, function): """Handle custom tags for BDD tests. @@ -146,7 +125,7 @@ if not getattr(sys, 'frozen', False): This tries various functions, and if none knows how to handle this tag, it returns None so it falls back to pytest-bdd's implementation. """ - funcs = [_get_version_tag, _get_backend_tag, _get_dictionary_tag] + funcs = [_get_version_tag, _get_backend_tag] for func in funcs: mark = func(tag) if mark is not None: diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index e15af8ad5..aaad14530 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -568,16 +568,4 @@ Feature: Various utility commands. Scenario: Simple adblock update When I set up "simple" as block lists And I run :adblock-update - Then the message "adblock: Read 1 hosts from 1 sources." should be shown - - ## Spellcheck - - @qtwebkit_skip @qt>=5.8 @cannot_have_dict=af-ZA - Scenario: Set valid but not installed language - When I run :set spellcheck.languages ['af-ZA'] - Then the warning "Language af-ZA is not installed *" should be shown - - @qtwebkit_skip @qt>=5.8 @must_have_dict=en-US - Scenario: Set valid and installed language - When I run :set spellcheck.languages ["en-US"] - Then the option spellcheck.languages should be set to ["en-US"] + Then the message "adblock: Read 1 hosts from 1 sources." should be shown \ No newline at end of file diff --git a/tests/unit/browser/webengine/test_webenginesettings.py b/tests/unit/browser/webengine/test_webenginesettings.py index 1fbe38f00..fab2a9847 100644 --- a/tests/unit/browser/webengine/test_webenginesettings.py +++ b/tests/unit/browser/webengine/test_webenginesettings.py @@ -17,16 +17,22 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import types +import logging + import pytest pytest.importorskip('PyQt5.QtWebEngineWidgets') from qutebrowser.browser.webengine import webenginesettings +from qutebrowser.utils import usertypes, qtutils @pytest.fixture(autouse=True) -def init_profiles(qapp, config_stub, cache_tmpdir, data_tmpdir): - webenginesettings._init_profiles() +def init(qapp, config_stub, cache_tmpdir, data_tmpdir): + init_args = types.SimpleNamespace(enable_webengine_inspector=False) + webenginesettings.init(init_args) + config_stub.changed.disconnect(webenginesettings._update_settings) def test_big_cache_size(config_stub): @@ -37,3 +43,32 @@ def test_big_cache_size(config_stub): webenginesettings._set_http_cache_size(profile) assert profile.httpCacheMaximumSize() == 2 ** 31 - 1 + + +@pytest.mark.skipif( + not qtutils.version_check('5.8'), reason="Needs Qt 5.8 or newer") +def test_non_existing_dict(config_stub, monkeypatch, message_mock, caplog): + monkeypatch.setattr(webenginesettings.spell, 'local_filename', + lambda _code: None) + config_stub.val.spellcheck.languages = ['af-ZA'] + + with caplog.at_level(logging.WARNING): + webenginesettings._update_settings('spellcheck.languages') + + msg = message_mock.getmsg(usertypes.MessageLevel.warning) + expected = ("Language af-ZA is not installed - see scripts/dictcli.py in " + "qutebrowser's sources") + assert msg.text == expected + + +@pytest.mark.skipif( + not qtutils.version_check('5.8'), reason="Needs Qt 5.8 or newer") +def test_existing_dict(config_stub, monkeypatch): + monkeypatch.setattr(webenginesettings.spell, 'local_filename', + lambda _code: 'en-US-8-0') + config_stub.val.spellcheck.languages = ['en-US'] + webenginesettings._update_settings('spellcheck.languages') + for profile in [webenginesettings.default_profile, + webenginesettings.private_profile]: + assert profile.isSpellCheckEnabled() + assert profile.spellCheckLanguages() == ['en-US-8-0'] From 8ea6cf352b0a396b2da56ee6df83f7acb3efbd8c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 08:08:47 +0100 Subject: [PATCH 314/524] Remove unneeded version check The option isn't going to magically change as the config system prevents that. --- qutebrowser/browser/webengine/webenginesettings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 83453beb2..02f527a9f 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -292,7 +292,7 @@ def _update_settings(option): qtutils.version_check('5.9', compiled=False)): _set_persistent_cookie_policy(default_profile) # We're not touching the private profile's cookie policy. - elif option == 'spellcheck.languages' and qtutils.version_check('5.8'): + elif option == 'spellcheck.languages': _set_dictionary_language(default_profile) _set_dictionary_language(private_profile, warn=False) From 7fd0b52360d55123e75f319913495526ac0593c2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 08:11:23 +0100 Subject: [PATCH 315/524] Add missing newline [ci skip] --- tests/end2end/features/misc.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index aaad14530..2fed9be66 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -568,4 +568,4 @@ Feature: Various utility commands. Scenario: Simple adblock update When I set up "simple" as block lists And I run :adblock-update - Then the message "adblock: Read 1 hosts from 1 sources." should be shown \ No newline at end of file + Then the message "adblock: Read 1 hosts from 1 sources." should be shown From b14a37cf1f27047eee3c6515711a259131821797 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 16:14:34 +0100 Subject: [PATCH 316/524] Update changelog for v1.1.2 [ci skip] --- doc/changelog.asciidoc | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index a9e37b559..2884221f3 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -88,7 +88,6 @@ Fixed - QtWebEngine: Hinting and scrolling now works properly on special `view-source:` pages. - QtWebEngine: Scroll positions are now restored correctly from sessions. -- QtWebEngine: Crash with Qt 5.10.1 when using :undo on some tabs. - QtWebEngine: `:follow-selected` should now work in more cases with Qt > 5.10. - QtWebKit: `:view-source` now displays a valid URL. - URLs containing ampersands and other special chars are now shown @@ -100,7 +99,6 @@ Fixed - QtWebEngine: Qt download objects are now cleaned up properly when a download is removed. - Suspended pages now should always load the correct page when being un-suspended. -- Compatibility with Python 3.7 - Exception types are now shown properly with `:config-source` and `:config-edit`. - When using `:bookmark-add --toggle`, bookmarks are now saved properly. - Crash when opening an invalid URL from an application on macOS. @@ -115,6 +113,21 @@ Removed - The `tabs.persist_mode_on_change` setting has been removed and replaced by `tabs.mode_on_change`. +v1.1.2 +------ + +Changed +~~~~~~~ + +- Windows/macOS releases now bundle Qt 5.10.1 which includes security fixes from + Chromium up to version 64.0.3282.140. + +Fixed +~~~~~ + +- QtWebEngine: Crash with Qt 5.10.1 when using :undo on some tabs. +- Compatibility with Python 3.7 + v1.1.1 ------ From f33d659924efaf13a881c6713ce551b59e3c9a3e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 1 Mar 2018 09:02:53 +0100 Subject: [PATCH 317/524] Release v1.1.2 --- qutebrowser/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py index 3da270437..6439b2f12 100644 --- a/qutebrowser/__init__.py +++ b/qutebrowser/__init__.py @@ -26,7 +26,7 @@ __copyright__ = "Copyright 2014-2018 Florian Bruhin (The Compiler)" __license__ = "GPL" __maintainer__ = __author__ __email__ = "mail@qutebrowser.org" -__version_info__ = (1, 1, 1) +__version_info__ = (1, 1, 2) __version__ = '.'.join(str(e) for e in __version_info__) __description__ = "A keyboard-driven, vim-like browser based on PyQt5." From 257011a6d22a3c889336e72d42d82479505cb64b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 1 Mar 2018 12:56:43 +0100 Subject: [PATCH 318/524] Fix installing codecov on Travis --- scripts/dev/ci/travis_install.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/dev/ci/travis_install.sh b/scripts/dev/ci/travis_install.sh index 04b118b9b..38e17965a 100644 --- a/scripts/dev/ci/travis_install.sh +++ b/scripts/dev/ci/travis_install.sh @@ -101,5 +101,8 @@ case $TESTENV in *) pip_install pip pip_install -r misc/requirements/requirements-tox.txt + if [[ $TESTENV == *-cov ]]; then + pip_install -r misc/requirements/requirements-codecov.txt + fi ;; esac From 2965f954ba05599ff6398779951d87598b1e29c2 Mon Sep 17 00:00:00 2001 From: Ryan Roden-Corrent Date: Wed, 28 Feb 2018 08:39:40 -0500 Subject: [PATCH 319/524] Resolve empty completion.timestamp_format crash. Resolves #3628. --- qutebrowser/completion/models/histcategory.py | 2 +- tests/unit/completion/test_histcategory.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py index a07f78143..60f801492 100644 --- a/qutebrowser/completion/models/histcategory.py +++ b/qutebrowser/completion/models/histcategory.py @@ -80,7 +80,7 @@ class HistoryCategory(QSqlQueryModel): for i in range(len(words))) # replace ' in timestamp-format to avoid breaking the query - timestamp_format = config.val.completion.timestamp_format + timestamp_format = config.val.completion.timestamp_format or '' timefmt = ("strftime('{}', last_atime, 'unixepoch', 'localtime')" .format(timestamp_format.replace("'", "`"))) diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py index d03098090..e7d7d8d28 100644 --- a/tests/unit/completion/test_histcategory.py +++ b/tests/unit/completion/test_histcategory.py @@ -205,3 +205,20 @@ def test_remove_rows_fetch(hist): hist.delete('url', '298') cat.removeRows(297, 1) assert cat.rowCount() == 299 + + +@pytest.mark.parametrize('fmt, expected', [ + ('%Y-%m-%d', '2018-02-27'), + ('%m/%d/%Y %H:%M', '02/27/2018 08:30'), + ('', ''), +]) +def test_timestamp_fmt(fmt, expected, model_validator, config_stub, init_sql): + """Validate the filtering and sorting results of set_pattern.""" + config_stub.val.completion.timestamp_format = fmt + hist = sql.SqlTable('CompletionHistory', ['url', 'title', 'last_atime']) + atime = datetime.datetime(2018, 2, 27, 8, 30) + hist.insert({'url': 'foo', 'title': '', 'last_atime': atime.timestamp()}) + cat = histcategory.HistoryCategory() + model_validator.set_model(cat) + cat.set_pattern('') + model_validator.validate([('foo', '', expected)]) From a2b5bf0b73bb95607e3b3ef8e5b8961ddc7445f8 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Thu, 1 Mar 2018 16:15:38 -0500 Subject: [PATCH 320/524] Clear old search results on webkit Fixes an issue with #3626 --- qutebrowser/browser/webkit/webkittab.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 48c479171..ce80678f1 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -153,6 +153,9 @@ class WebKitSearch(browsertab.AbstractSearch): " for {}".format(text)) return + # Clear old search results, this is done automatically on other backends + self.clear() + self.text = text self.search_displayed = True self._flags = QWebPage.FindWrapsAroundDocument From d5e30fd72810c633c17cebe6b42f316bebb3e6e9 Mon Sep 17 00:00:00 2001 From: Ryan Roden-Corrent Date: Thu, 1 Mar 2018 22:07:53 -0500 Subject: [PATCH 321/524] Don't crash first completion update with min_chars. When min_chars is nonzero, if the first command that opens the completion has < min_chars on the word under the cursor, it triggers a check for a condition where last_cursor_pos is None. By setting last_cursor_pos=-1 we ensure that the completer always updates the first time it is opened, and that there is never a check against None. This adds a test for the min_chars feature. Resolves #3635. --- qutebrowser/completion/completer.py | 2 +- tests/unit/completion/test_completer.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 09b80ed12..8506f3aa7 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -60,7 +60,7 @@ class Completer(QObject): self._timer.setSingleShot(True) self._timer.setInterval(0) self._timer.timeout.connect(self._update_completion) - self._last_cursor_pos = None + self._last_cursor_pos = -1 self._last_text = None self._last_completion_func = None self._cmd.update_completion.connect(self.schedule_completion_update) diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py index ecb58f3ec..e06990b5d 100644 --- a/tests/unit/completion/test_completer.py +++ b/tests/unit/completion/test_completer.py @@ -313,3 +313,22 @@ def test_quickcomplete_flicker(status_command_stub, completer_obj, completer_obj.on_selection_changed('http://example.com') completer_obj.schedule_completion_update() assert not completion_widget_stub.set_model.called + + +def test_min_chars(status_command_stub, completer_obj, completion_widget_stub, + config_stub, key_config_stub): + """Test that an update is delayed until min_chars characters are input.""" + config_stub.val.completion.min_chars = 3 + + # Test #3635, where min_chars could crash the first update + _set_cmd_prompt(status_command_stub, ':set c|') + completer_obj.schedule_completion_update() + assert completion_widget_stub.set_model.call_count == 0 + + _set_cmd_prompt(status_command_stub, ':set co|') + completer_obj.schedule_completion_update() + assert completion_widget_stub.set_model.call_count == 0 + + _set_cmd_prompt(status_command_stub, ':set com|') + completer_obj.schedule_completion_update() + assert completion_widget_stub.set_model.call_count == 1 From 6fc560fc782557120d394548adf19e2aae084ec5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 2 Mar 2018 06:31:23 +0100 Subject: [PATCH 322/524] Rewrite comment --- qutebrowser/browser/webkit/webkittab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index ce80678f1..ef38892cc 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -153,7 +153,7 @@ class WebKitSearch(browsertab.AbstractSearch): " for {}".format(text)) return - # Clear old search results, this is done automatically on other backends + # Clear old search results, this is done automatically on QtWebEngine. self.clear() self.text = text From 067be7aaa28358d44182a0df868dba84e0f6826a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 2 Mar 2018 06:33:56 +0100 Subject: [PATCH 323/524] Simplify mock checks --- tests/unit/completion/test_completer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py index e06990b5d..51aa091b9 100644 --- a/tests/unit/completion/test_completer.py +++ b/tests/unit/completion/test_completer.py @@ -200,7 +200,7 @@ def test_update_completion(txt, kind, pattern, pos_args, status_command_stub, _set_cmd_prompt(status_command_stub, txt) completer_obj.schedule_completion_update() if kind is None: - assert completion_widget_stub.set_pattern.call_count == 0 + assert not completion_widget_stub.set_pattern.called else: assert completion_widget_stub.set_model.call_count == 1 model = completion_widget_stub.set_model.call_args[0][0] @@ -323,11 +323,11 @@ def test_min_chars(status_command_stub, completer_obj, completion_widget_stub, # Test #3635, where min_chars could crash the first update _set_cmd_prompt(status_command_stub, ':set c|') completer_obj.schedule_completion_update() - assert completion_widget_stub.set_model.call_count == 0 + assert not completion_widget_stub.set_model.called _set_cmd_prompt(status_command_stub, ':set co|') completer_obj.schedule_completion_update() - assert completion_widget_stub.set_model.call_count == 0 + assert not completion_widget_stub.set_model.called _set_cmd_prompt(status_command_stub, ':set com|') completer_obj.schedule_completion_update() From 5d68dc08d52092f32653a05b80b68c85acb17c2b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 2 Mar 2018 06:35:04 +0100 Subject: [PATCH 324/524] Update changelog --- doc/changelog.asciidoc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 2884221f3..1f6cd4fad 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -89,6 +89,8 @@ Fixed `view-source:` pages. - QtWebEngine: Scroll positions are now restored correctly from sessions. - QtWebEngine: `:follow-selected` should now work in more cases with Qt > 5.10. +- QtWebEngine: Incremental search now flickers less and doesn't move to the + second result when pressing Enter. - QtWebKit: `:view-source` now displays a valid URL. - URLs containing ampersands and other special chars are now shown correctly when filtering them in the completion. @@ -102,6 +104,8 @@ Fixed - Exception types are now shown properly with `:config-source` and `:config-edit`. - When using `:bookmark-add --toggle`, bookmarks are now saved properly. - Crash when opening an invalid URL from an application on macOS. +- Crash with an empty `completion.timestamp_format`. +- Crash when `completion.min_chars` is set in some cases. Removed ~~~~~~~ From 9721881261629c45fcb9c08b4e00566d52c496ae Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 2 Mar 2018 06:37:01 +0100 Subject: [PATCH 325/524] Adjust @issue3572 check for Qt 5.10.1 --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 465dacd10..5eec6ce0b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -65,7 +65,8 @@ def _apply_platform_markers(config, item): ('issue2478', utils.is_windows and config.webengine, "Broken with QtWebEngine on Windows"), ('issue3572', - qtutils.version_check('5.10', compiled=False, exact=True) and + (qtutils.version_check('5.10', compiled=False, exact=True) or + qtutils.version_check('5.10.1', compiled=False, exact=True) and config.webengine and 'TRAVIS' in os.environ, "Broken with QtWebEngine with Qt 5.10 on Travis"), ('qtbug60673', From b0c25e1bed82b711252d83679b09453828bd51b9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 2 Mar 2018 06:58:37 +0100 Subject: [PATCH 326/524] Add missing ) I shouldn't be allowed to push at 6:30 AM... --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5eec6ce0b..bab8e404c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -66,7 +66,7 @@ def _apply_platform_markers(config, item): "Broken with QtWebEngine on Windows"), ('issue3572', (qtutils.version_check('5.10', compiled=False, exact=True) or - qtutils.version_check('5.10.1', compiled=False, exact=True) and + qtutils.version_check('5.10.1', compiled=False, exact=True)) and config.webengine and 'TRAVIS' in os.environ, "Broken with QtWebEngine with Qt 5.10 on Travis"), ('qtbug60673', From be7a21eb56cf7ec82bef0bbb40475b7bc6faaa1a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 2 Mar 2018 07:00:09 +0100 Subject: [PATCH 327/524] Make sure the backend is set in test_webenginesettings.py --- tests/unit/browser/webengine/test_webenginesettings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/browser/webengine/test_webenginesettings.py b/tests/unit/browser/webengine/test_webenginesettings.py index fab2a9847..00549f7f7 100644 --- a/tests/unit/browser/webengine/test_webenginesettings.py +++ b/tests/unit/browser/webengine/test_webenginesettings.py @@ -26,6 +26,7 @@ pytest.importorskip('PyQt5.QtWebEngineWidgets') from qutebrowser.browser.webengine import webenginesettings from qutebrowser.utils import usertypes, qtutils +from qutebrowser.misc import objects @pytest.fixture(autouse=True) @@ -48,6 +49,7 @@ def test_big_cache_size(config_stub): @pytest.mark.skipif( not qtutils.version_check('5.8'), reason="Needs Qt 5.8 or newer") def test_non_existing_dict(config_stub, monkeypatch, message_mock, caplog): + monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine) monkeypatch.setattr(webenginesettings.spell, 'local_filename', lambda _code: None) config_stub.val.spellcheck.languages = ['af-ZA'] @@ -64,6 +66,7 @@ def test_non_existing_dict(config_stub, monkeypatch, message_mock, caplog): @pytest.mark.skipif( not qtutils.version_check('5.8'), reason="Needs Qt 5.8 or newer") def test_existing_dict(config_stub, monkeypatch): + monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine) monkeypatch.setattr(webenginesettings.spell, 'local_filename', lambda _code: 'en-US-8-0') config_stub.val.spellcheck.languages = ['en-US'] From 02c313eafd3e4dd818acf7b94ebee2911c5c5bb0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 2 Mar 2018 10:22:59 +0100 Subject: [PATCH 328/524] Add packages needed with tox --- doc/install.asciidoc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/install.asciidoc b/doc/install.asciidoc index 8916c1fdd..f17a2c2a2 100644 --- a/doc/install.asciidoc +++ b/doc/install.asciidoc @@ -35,6 +35,12 @@ Ubuntu 16.04 doesn't come with an up-to-date engine (a new enough QtWebKit, or QtWebEngine). However, it comes with Python 3.5, so you can <>. +You'll need some basic libraries to use the tox-installed PyQt: + +---- +# apt install libglib2.0-0 libgl1 libfontconfig1 libx11-xcb1 libxi6 libxrender1 libdbus-1-3 +---- + Debian Stretch / Ubuntu 17.04 and 17.10 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From a76c0067e14e8c11ab2605fad7ab6dd8fef54d1b Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 31 Dec 2017 18:32:16 +1300 Subject: [PATCH 329/524] Greasemonkey: Add support for the @require rule. The greasemonkey spec states that user scripts should be able to put the URL of a javascript source as the value of an `@require` key and expect to have that script available in its scope. This commit supports deferring a user script from being available until it's required scripts are downloaded, downloading the scripts and prepending them onto the userscripts code before placing it all in an iffe. TODO: * should I be saving the scripts somewhere else? Maybe the cache dir? The are just going to data/greasemonkey/requires/ atm. --- qutebrowser/browser/greasemonkey.py | 132 ++++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 15 deletions(-) diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index fb064f6c1..071a0f71f 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -23,13 +23,16 @@ import re import os import json import fnmatch +import functools import glob +import base64 import attr from PyQt5.QtCore import pyqtSignal, QObject, QUrl from qutebrowser.utils import log, standarddir, jinja, objreg from qutebrowser.commands import cmdutils +from qutebrowser.browser import downloads def _scripts_dir(): @@ -45,6 +48,7 @@ class GreasemonkeyScript: self._code = code self.includes = [] self.excludes = [] + self.requires = [] self.description = None self.name = None self.namespace = None @@ -66,6 +70,8 @@ class GreasemonkeyScript: self.run_at = value elif name == 'noframes': self.runs_on_sub_frames = False + elif name == 'require': + self.requires.append(value) HEADER_REGEX = r'// ==UserScript==|\n+// ==/UserScript==\n' PROPS_REGEX = r'// @(?P[^\s]+)\s*(?P.*)' @@ -93,7 +99,7 @@ class GreasemonkeyScript: """Return the processed JavaScript code of this script. Adorns the source code with GM_* methods for Greasemonkey - compatibility and wraps it in an IFFE to hide it within a + compatibility and wraps it in an IIFE to hide it within a lexical scope. Note that this means line numbers in your browser's debugger/inspector will not match up to the line numbers in the source script directly. @@ -115,6 +121,13 @@ class GreasemonkeyScript: 'run-at': self.run_at, }) + def add_required_script(self, source): + """Add the source of a required script to this script.""" + # NOTE: If source also contains a greasemonkey metadata block then + # QWebengineScript will parse that instead of the actual one. + # Adding an indent to source would stop that. + self._code = "\n".join([source, self._code]) + @attr.s class MatchingScripts(object): @@ -145,6 +158,11 @@ class GreasemonkeyManager(QObject): def __init__(self, parent=None): super().__init__(parent) + self._run_start = [] + self._run_end = [] + self._run_idle = [] + self._in_progress_dls = [] + self.load_scripts() @cmdutils.register(name='greasemonkey-reload', @@ -170,23 +188,107 @@ class GreasemonkeyManager(QObject): if not script.name: script.name = script_filename - if script.run_at == 'document-start': - self._run_start.append(script) - elif script.run_at == 'document-end': - self._run_end.append(script) - elif script.run_at == 'document-idle': - self._run_idle.append(script) + if script.requires: + log.greasemonkey.debug( + "Deferring script until requirements are " + "fulfilled: {}".format(script.name)) + self._get_required_scripts(script) else: - if script.run_at: - log.greasemonkey.warning( - "Script {} has invalid run-at defined, " - "defaulting to document-end".format(script_path)) - # Default as per - # https://wiki.greasespot.net/Metadata_Block#.40run-at - self._run_end.append(script) - log.greasemonkey.debug("Loaded script: {}".format(script.name)) + self._add_script(script) + self.scripts_reloaded.emit() + def _add_script(self, script): + if script.run_at == 'document-start': + self._run_start.append(script) + elif script.run_at == 'document-end': + self._run_end.append(script) + elif script.run_at == 'document-idle': + self._run_idle.append(script) + else: + if script.run_at: + log.greasemonkey.warning("Script {} has invalid run-at " + "defined, defaulting to " + "document-end" + .format(script.name)) + # Default as per + # https://wiki.greasespot.net/Metadata_Block#.40run-at + self._run_end.append(script) + log.greasemonkey.debug("Loaded script: {}".format(script.name)) + + def _required_url_to_file_path(self, url): + # TODO: Save to a more readable name + # cf https://stackoverflow.com/questions/295135/turn-a-string-into-a-valid-filename + name = str(base64.urlsafe_b64encode(bytes(url, 'utf8')), encoding='utf8') + requires_dir = os.path.join(_scripts_dir(), 'requires') + if not os.path.exists(requires_dir): + os.mkdir(requires_dir) + return os.path.join(requires_dir, name) + + def _on_required_download_finished(self, script, download): + self._in_progress_dls.remove(download) + if not self._add_script_with_requires(script): + log.greasemonkey.debug( + "Finished download {} for script {} " + "but some requirements are still pending" + .format(download.basename, script.name)) + + def _add_script_with_requires(self, script, quiet=False): + """Add a script with pending downloads to this GreasemonkeyManager. + + Specifically a script that has dependancies specified via an + `@require` rule. + + Args: + script: The GreasemonkeyScript to add. + quiet: True to suppress the scripts_reloaded signal after + adding `script`. + Returns: True if the script was added, False if there are still + dependancies being downloaded. + """ + # See if we are still waiting on any required scripts for this one + for dl in self._in_progress_dls: + if dl.requested_url in script.requires: + return False + + # Need to add the required scripts to the IIFE now + for url in reversed(script.requires): + target_path = self._required_url_to_file_path(url) + log.greasemonkey.debug( + "Adding required script for {} to IIFE: {}" + .format(script.name, url)) + with open(target_path, encoding='utf8') as f: + script.add_required_script(f.read()) + + self._add_script(script) + if not quiet: + self.scripts_reloaded.emit() + return True + + def _get_required_scripts(self, script): + required_dls = [(url, self._required_url_to_file_path(url)) + for url in script.requires] + required_dls = [(url, path) for (url, path) in required_dls + if not os.path.exists(path)] + if not required_dls: + # All the files exist so we don't have to deal with + # potentially not having a download manager yet + # TODO: Consider supporting force reloading. + self._add_script_with_requires(script, quiet=True) + return + + download_manager = objreg.get('qtnetwork-download-manager') + + for url, target_path in required_dls: + target = downloads.FileDownloadTarget(target_path) + download = download_manager.get(QUrl(url), target=target, + auto_remove=True) + download.requested_url = url + self._in_progress_dls.append(download) + download.finished.connect( + functools.partial(self._on_required_download_finished, + script, download)) + def scripts_for(self, url): """Fetch scripts that are registered to run for url. From 33d66676c9521e3a70b25d093f2c865999e9d652 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Mon, 1 Jan 2018 16:10:20 +1300 Subject: [PATCH 330/524] Greasemonkey: mock the new GM4 promises based API. Based on the gm4-polyfill.js script from the greasemonkey devs. But not the same because that script doesn't work for us for a couple of reasons: * It assumes all GM_* functions are attributes of `this` which in this case is the global window object. Which breaks it out of our iife. It is possible to change what `this` is within the iife but then we would have to do something weird to ensure the functions were available with the leading `this.`. And I don't think user javascripts tend to call GM functions like that anyway, that polyfill script is just making weird assumptions and then claiming it'll work for "any user script engine". * It tries to provide implementations of GM_registerMenuCommand and GM_getResource text which do unexpected thins or implement a circular dependency on the new version, respectively. --- .../javascript/greasemonkey_wrapper.js | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/qutebrowser/javascript/greasemonkey_wrapper.js b/qutebrowser/javascript/greasemonkey_wrapper.js index 2d36220dc..d9ea736df 100644 --- a/qutebrowser/javascript/greasemonkey_wrapper.js +++ b/qutebrowser/javascript/greasemonkey_wrapper.js @@ -110,6 +110,42 @@ } } + // Stub these two so that the gm4 polyfill script doesn't try to + // create broken versions as attributes of window. + function GM_getResourceText(caption, commandFunc, accessKey) { + console.error(`${GM_info.script.name} called unimplemented GM_getResourceText`); + } + + function GM_registerMenuCommand(caption, commandFunc, accessKey) { + console.error(`${GM_info.script.name} called unimplemented GM_registerMenuCommand`); + } + + // Mock the greasemonkey 4.0 async API. + const GM = {}; + GM.info = GM_info; + Object.entries({ + 'log': GM_log, + 'addStyle': GM_addStyle, + 'deleteValue': GM_deleteValue, + 'getValue': GM_getValue, + 'listValues': GM_listValues, + 'openInTab': GM_openInTab, + 'setValue': GM_setValue, + 'xmlHttpRequest': GM_xmlhttpRequest, + }).forEach(([newKey, old]) => { + if (old && (typeof GM[newKey] == 'undefined')) { + GM[newKey] = function(...args) { + return new Promise((resolve, reject) => { + try { + resolve(old(...args)); + } catch (e) { + reject(e); + } + }); + }; + } + }); + const unsafeWindow = window; // ====== The actual user script source ====== // From a7b74d8e8311fc60ab168736c7f408e23b395bfa Mon Sep 17 00:00:00 2001 From: Jimmy Date: Tue, 2 Jan 2018 11:53:25 +1300 Subject: [PATCH 331/524] Greasemonkey: give required scripts a readable filename. --- qutebrowser/browser/greasemonkey.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index 071a0f71f..07e73acfa 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -25,12 +25,11 @@ import json import fnmatch import functools import glob -import base64 import attr from PyQt5.QtCore import pyqtSignal, QObject, QUrl -from qutebrowser.utils import log, standarddir, jinja, objreg +from qutebrowser.utils import log, standarddir, jinja, objreg, utils from qutebrowser.commands import cmdutils from qutebrowser.browser import downloads @@ -217,13 +216,10 @@ class GreasemonkeyManager(QObject): log.greasemonkey.debug("Loaded script: {}".format(script.name)) def _required_url_to_file_path(self, url): - # TODO: Save to a more readable name - # cf https://stackoverflow.com/questions/295135/turn-a-string-into-a-valid-filename - name = str(base64.urlsafe_b64encode(bytes(url, 'utf8')), encoding='utf8') requires_dir = os.path.join(_scripts_dir(), 'requires') if not os.path.exists(requires_dir): os.mkdir(requires_dir) - return os.path.join(requires_dir, name) + return os.path.join(requires_dir, utils.sanitize_filename(url)) def _on_required_download_finished(self, script, download): self._in_progress_dls.remove(download) From b91e2e32677ab296a752281d46a14b3f65542dab Mon Sep 17 00:00:00 2001 From: Jimmy Date: Tue, 2 Jan 2018 14:13:13 +1300 Subject: [PATCH 332/524] Allow download manager to overwrite existing files unprompted. This is to support the non-interactive use case of setting a `FileDownloadTarget` and passing auto_remove and not caring if the target file exists or not. An alternative to adding the attribute to `FileDownloadTarget` and having set_target pull it out would be to add a new param to `fetch()` and `set_target()`. But it would only be used for one target type anyway. --- qutebrowser/browser/downloads.py | 8 ++++++-- qutebrowser/browser/greasemonkey.py | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 4f390b18b..dd112e00a 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -238,11 +238,14 @@ class FileDownloadTarget(_DownloadTarget): Attributes: filename: Filename where the download should be saved. + force_overwrite: Whether to overwrite the target without + prompting the user. """ - def __init__(self, filename): + def __init__(self, filename, force_overwrite=False): # pylint: disable=super-init-not-called self.filename = filename + self.force_overwrite = force_overwrite def suggested_filename(self): return os.path.basename(self.filename) @@ -738,7 +741,8 @@ class AbstractDownloadItem(QObject): if isinstance(target, FileObjDownloadTarget): self._set_fileobj(target.fileobj, autoclose=False) elif isinstance(target, FileDownloadTarget): - self._set_filename(target.filename) + self._set_filename( + target.filename, force_overwrite=target.force_overwrite) elif isinstance(target, OpenFileDownloadTarget): try: fobj = temp_download_manager.get_tmpfile(self.basename) diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index 07e73acfa..bc6208e90 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -276,7 +276,8 @@ class GreasemonkeyManager(QObject): download_manager = objreg.get('qtnetwork-download-manager') for url, target_path in required_dls: - target = downloads.FileDownloadTarget(target_path) + target = downloads.FileDownloadTarget(target_path, + force_overwrite=True) download = download_manager.get(QUrl(url), target=target, auto_remove=True) download.requested_url = url From 2307a0850f7703083d7493c9a185d595d2ce7851 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Tue, 2 Jan 2018 14:19:21 +1300 Subject: [PATCH 333/524] Greasemonkey: Support greasemonkey-reload --force. Added a new argument to the greasemonkey-reload command to support also re-downloading any `@required` scripts. --- qutebrowser/browser/greasemonkey.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index bc6208e90..683985f54 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -166,11 +166,15 @@ class GreasemonkeyManager(QObject): @cmdutils.register(name='greasemonkey-reload', instance='greasemonkey') - def load_scripts(self): + def load_scripts(self, force=False): """Re-read Greasemonkey scripts from disk. The scripts are read from a 'greasemonkey' subdirectory in qutebrowser's data directory (see `:version`). + + Args: + force: For any scripts that have required dependencies, + re-download them. """ self._run_start = [] self._run_end = [] @@ -191,7 +195,7 @@ class GreasemonkeyManager(QObject): log.greasemonkey.debug( "Deferring script until requirements are " "fulfilled: {}".format(script.name)) - self._get_required_scripts(script) + self._get_required_scripts(script, force) else: self._add_script(script) @@ -261,15 +265,14 @@ class GreasemonkeyManager(QObject): self.scripts_reloaded.emit() return True - def _get_required_scripts(self, script): + def _get_required_scripts(self, script, force=False): required_dls = [(url, self._required_url_to_file_path(url)) for url in script.requires] - required_dls = [(url, path) for (url, path) in required_dls - if not os.path.exists(path)] + if not force: + required_dls = [(url, path) for (url, path) in required_dls + if not os.path.exists(path)] if not required_dls: - # All the files exist so we don't have to deal with - # potentially not having a download manager yet - # TODO: Consider supporting force reloading. + # All the required files exist already self._add_script_with_requires(script, quiet=True) return From cba93954cd09bd11fcd99466717d340b23c94a10 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Tue, 2 Jan 2018 15:56:28 +1300 Subject: [PATCH 334/524] Allow download_stub test fixture to handle file targets. --- tests/unit/browser/test_adblock.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/unit/browser/test_adblock.py b/tests/unit/browser/test_adblock.py index 09161e806..a6bbcd8ce 100644 --- a/tests/unit/browser/test_adblock.py +++ b/tests/unit/browser/test_adblock.py @@ -23,12 +23,13 @@ import os.path import zipfile import shutil import logging +import contextlib import pytest from PyQt5.QtCore import pyqtSignal, QUrl, QObject -from qutebrowser.browser import adblock +from qutebrowser.browser import adblock, downloads from qutebrowser.utils import objreg pytestmark = pytest.mark.usefixtures('qapp', 'config_tmpdir') @@ -89,14 +90,27 @@ class FakeDownloadManager: def __init__(self, tmpdir): self._tmpdir = tmpdir + @contextlib.contextmanager + def _open_fileobj(self, target): + """Ensure a DownloadTarget's fileobj attribute is available.""" + if isinstance(target, downloads.FileDownloadTarget): + target.fileobj = open(target.filename, 'wb') + try: + yield target.fileobj + finally: + target.fileobj.close() + else: + yield target.fileobj + def get(self, url, target, **kwargs): """Return a FakeDownloadItem instance with a fileobj. The content is copied from the file the given url links to. """ - download_item = FakeDownloadItem(target.fileobj, name=url.path()) - with (self._tmpdir / url.path()).open('rb') as fake_url_file: - shutil.copyfileobj(fake_url_file, download_item.fileobj) + with self._open_fileobj(target): + download_item = FakeDownloadItem(target.fileobj, name=url.path()) + with (self._tmpdir / url.path()).open('rb') as fake_url_file: + shutil.copyfileobj(fake_url_file, download_item.fileobj) return download_item From fa1ac8d93cdf5061a5df91b5f8ffe63e078018a5 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sat, 3 Mar 2018 13:00:27 +1300 Subject: [PATCH 335/524] Move download_stub to helpers/fixtures I am adding support for downloading dependant assets in browser/greasemonkey and want to mock the download manager for testing. --- tests/helpers/fixtures.py | 9 +++++ tests/helpers/stubs.py | 48 +++++++++++++++++++++++- tests/unit/browser/test_adblock.py | 60 +----------------------------- 3 files changed, 58 insertions(+), 59 deletions(-) diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index 193a40a8a..7c9fc93ae 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -523,3 +523,12 @@ class ModelValidator: @pytest.fixture def model_validator(qtmodeltester): return ModelValidator(qtmodeltester) + + +@pytest.fixture +def download_stub(win_registry, tmpdir, stubs): + """Register a FakeDownloadManager.""" + stub = stubs.FakeDownloadManager(tmpdir) + objreg.register('qtnetwork-download-manager', stub) + yield + objreg.delete('qtnetwork-download-manager') diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 3957a670a..0167926bd 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -22,6 +22,8 @@ """Fake objects/stubs.""" from unittest import mock +import contextlib +import shutil import attr from PyQt5.QtCore import pyqtSignal, QPoint, QProcess, QObject, QUrl @@ -29,7 +31,7 @@ from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache, QNetworkCacheMetaData) from PyQt5.QtWidgets import QCommonStyle, QLineEdit, QWidget, QTabBar -from qutebrowser.browser import browsertab +from qutebrowser.browser import browsertab, downloads from qutebrowser.utils import usertypes from qutebrowser.mainwindow import mainwindow @@ -558,3 +560,47 @@ class HTTPPostStub(QObject): def post(self, url, data=None): self.url = url self.data = data + + +class FakeDownloadItem(QObject): + + """Mock browser.downloads.DownloadItem.""" + + finished = pyqtSignal() + + def __init__(self, fileobj, name, parent=None): + super().__init__(parent) + self.fileobj = fileobj + self.name = name + self.successful = True + + +class FakeDownloadManager: + + """Mock browser.downloads.DownloadManager.""" + + def __init__(self, tmpdir): + self._tmpdir = tmpdir + + @contextlib.contextmanager + def _open_fileobj(self, target): + """Ensure a DownloadTarget's fileobj attribute is available.""" + if isinstance(target, downloads.FileDownloadTarget): + target.fileobj = open(target.filename, 'wb') + try: + yield target.fileobj + finally: + target.fileobj.close() + else: + yield target.fileobj + + def get(self, url, target, **kwargs): + """Return a FakeDownloadItem instance with a fileobj. + + The content is copied from the file the given url links to. + """ + with self._open_fileobj(target): + download_item = FakeDownloadItem(target.fileobj, name=url.path()) + with (self._tmpdir / url.path()).open('rb') as fake_url_file: + shutil.copyfileobj(fake_url_file, download_item.fileobj) + return download_item diff --git a/tests/unit/browser/test_adblock.py b/tests/unit/browser/test_adblock.py index a6bbcd8ce..e3b0e4576 100644 --- a/tests/unit/browser/test_adblock.py +++ b/tests/unit/browser/test_adblock.py @@ -21,16 +21,13 @@ import os import os.path import zipfile -import shutil import logging -import contextlib import pytest -from PyQt5.QtCore import pyqtSignal, QUrl, QObject +from PyQt5.QtCore import QUrl -from qutebrowser.browser import adblock, downloads -from qutebrowser.utils import objreg +from qutebrowser.browser import adblock pytestmark = pytest.mark.usefixtures('qapp', 'config_tmpdir') @@ -70,59 +67,6 @@ def basedir(fake_args): fake_args.basedir = None -class FakeDownloadItem(QObject): - - """Mock browser.downloads.DownloadItem.""" - - finished = pyqtSignal() - - def __init__(self, fileobj, name, parent=None): - super().__init__(parent) - self.fileobj = fileobj - self.name = name - self.successful = True - - -class FakeDownloadManager: - - """Mock browser.downloads.DownloadManager.""" - - def __init__(self, tmpdir): - self._tmpdir = tmpdir - - @contextlib.contextmanager - def _open_fileobj(self, target): - """Ensure a DownloadTarget's fileobj attribute is available.""" - if isinstance(target, downloads.FileDownloadTarget): - target.fileobj = open(target.filename, 'wb') - try: - yield target.fileobj - finally: - target.fileobj.close() - else: - yield target.fileobj - - def get(self, url, target, **kwargs): - """Return a FakeDownloadItem instance with a fileobj. - - The content is copied from the file the given url links to. - """ - with self._open_fileobj(target): - download_item = FakeDownloadItem(target.fileobj, name=url.path()) - with (self._tmpdir / url.path()).open('rb') as fake_url_file: - shutil.copyfileobj(fake_url_file, download_item.fileobj) - return download_item - - -@pytest.fixture -def download_stub(win_registry, tmpdir): - """Register a FakeDownloadManager.""" - stub = FakeDownloadManager(tmpdir) - objreg.register('qtnetwork-download-manager', stub) - yield - objreg.delete('qtnetwork-download-manager') - - def create_zipfile(directory, files, zipname='test'): """Return a path to a newly created zip file. From 919fe45813a5aa92affb29839a8425f72de723ab Mon Sep 17 00:00:00 2001 From: Jimmy Date: Tue, 2 Jan 2018 16:01:01 +1300 Subject: [PATCH 336/524] Greasemonkey: Add test for @require support. There's is a lot of asserts in that one test but it tests everything. --- tests/unit/javascript/test_greasemonkey.py | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/javascript/test_greasemonkey.py b/tests/unit/javascript/test_greasemonkey.py index 52af51a4b..7759f5d18 100644 --- a/tests/unit/javascript/test_greasemonkey.py +++ b/tests/unit/javascript/test_greasemonkey.py @@ -128,3 +128,33 @@ def test_load_emits_signal(qtbot): gm_manager = greasemonkey.GreasemonkeyManager() with qtbot.wait_signal(gm_manager.scripts_reloaded): gm_manager.load_scripts() + + +def test_required_scripts_are_included(download_stub, tmpdir): + test_require_script = textwrap.dedent(""" + // ==UserScript== + // @name qutebrowser test userscript + // @namespace invalid.org + // @include http://localhost:*/data/title.html + // @match http://trolol* + // @exclude https://badhost.xxx/* + // @run-at document-start + // @require http://localhost/test.js + // ==/UserScript== + console.log("Script is running."); + """) + _save_script(test_require_script, 'requiring.user.js') + with open(str(tmpdir / 'test.js'), 'w', encoding='UTF-8') as f: + f.write("REQUIRED SCRIPT") + + gm_manager = greasemonkey.GreasemonkeyManager() + assert len(gm_manager._in_progress_dls) == 1 + for download in gm_manager._in_progress_dls: + download.finished.emit() + + scripts = gm_manager.all_scripts() + assert len(scripts) == 1 + assert "REQUIRED SCRIPT" in scripts[0].code() + # Additionally check that the base script is still being parsed correctly + assert "Script is running." in scripts[0].code() + assert scripts[0].excludes From 60e6d28eb11bd941692aa16cae06d788bc01ba4b Mon Sep 17 00:00:00 2001 From: Jimmy Date: Fri, 5 Jan 2018 20:45:32 +1300 Subject: [PATCH 337/524] Greasemonkey: webkit: Don't use Object.entries in js. Apparently the currently available QtWebkit's javascript engine doesn't support Object.entries[1]. It was only using that because I had copied it from the official gm4 polyfill (maybe I should open an issue there?). Tested with libqt5webkit5 version 5.212.0~alpha2-5 (debian) and I was getting the same type of failures as Travis so it looks like this is the case in arch too. [1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries --- qutebrowser/javascript/greasemonkey_wrapper.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/qutebrowser/javascript/greasemonkey_wrapper.js b/qutebrowser/javascript/greasemonkey_wrapper.js index d9ea736df..71266755a 100644 --- a/qutebrowser/javascript/greasemonkey_wrapper.js +++ b/qutebrowser/javascript/greasemonkey_wrapper.js @@ -123,7 +123,7 @@ // Mock the greasemonkey 4.0 async API. const GM = {}; GM.info = GM_info; - Object.entries({ + const entries = { 'log': GM_log, 'addStyle': GM_addStyle, 'deleteValue': GM_deleteValue, @@ -132,7 +132,9 @@ 'openInTab': GM_openInTab, 'setValue': GM_setValue, 'xmlHttpRequest': GM_xmlhttpRequest, - }).forEach(([newKey, old]) => { + } + for (newKey in entries) { + let old = entries[newKey]; if (old && (typeof GM[newKey] == 'undefined')) { GM[newKey] = function(...args) { return new Promise((resolve, reject) => { @@ -144,7 +146,7 @@ }); }; } - }); + }; const unsafeWindow = window; From 87a0c2a7a70f2a6c1e73cf4f21682279d50cc8f3 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sat, 20 Jan 2018 16:31:10 +1300 Subject: [PATCH 338/524] Greasemonkey: indent source of required scripts This is for the case where a script uses `@require` to pull down another greasemonkey script. Since QWebEngineScript doesn't support `@require` we pass scripts to it with any required ones pre-pended. To avoid QWebEngineScript parsing the first metadata block, the one from the required script, we indent the whole lot. Because the greasemonkey spec says that the //==UserScript== text must start in the first column. --- qutebrowser/browser/greasemonkey.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index 683985f54..d37340d88 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -25,6 +25,7 @@ import json import fnmatch import functools import glob +import textwrap import attr from PyQt5.QtCore import pyqtSignal, QObject, QUrl @@ -122,10 +123,11 @@ class GreasemonkeyScript: def add_required_script(self, source): """Add the source of a required script to this script.""" - # NOTE: If source also contains a greasemonkey metadata block then - # QWebengineScript will parse that instead of the actual one. - # Adding an indent to source would stop that. - self._code = "\n".join([source, self._code]) + # The additional source is indented in case it also contains a + # metadata block. Because we pass everything at once to + # QWebEngineScript and that would parse the first metadata block + # found as the valid one. + self._code = "\n".join([textwrap.indent(source, " "), self._code]) @attr.s From 7dab8335e2f399ef9d9e0e43e39375fd36b15e4a Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 21 Jan 2018 15:37:22 +1300 Subject: [PATCH 339/524] Greasemonkey: handle downloads that complete fast When `@require`ing local files (with the `file://` scheme) the greasemonkey manager was not catching the DownloadItem.finished signal because it was being emitted before it had managed to connect. I didn't see this happening while testing with files that should have been in cache but I wouldn't be surprised. I had to change the download mock to be able to give it the appearance of asynchronicity. Now when using it one must set download.successful appropriately before firing download.finished. I also added a list of downloads to the stub so a test could enumerate them in case the unit-under-test didn't have a reference to them. --- qutebrowser/browser/greasemonkey.py | 9 ++++++--- tests/helpers/fixtures.py | 2 +- tests/helpers/stubs.py | 4 +++- tests/unit/browser/test_adblock.py | 14 +++++++++++--- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index d37340d88..9f7e26f53 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -287,9 +287,12 @@ class GreasemonkeyManager(QObject): auto_remove=True) download.requested_url = url self._in_progress_dls.append(download) - download.finished.connect( - functools.partial(self._on_required_download_finished, - script, download)) + if download.successful: + self._on_required_download_finished(script, download) + else: + download.finished.connect( + functools.partial(self._on_required_download_finished, + script, download)) def scripts_for(self, url): """Fetch scripts that are registered to run for url. diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index 7c9fc93ae..4ae9f125d 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -530,5 +530,5 @@ def download_stub(win_registry, tmpdir, stubs): """Register a FakeDownloadManager.""" stub = stubs.FakeDownloadManager(tmpdir) objreg.register('qtnetwork-download-manager', stub) - yield + yield stub objreg.delete('qtnetwork-download-manager') diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 0167926bd..fbe7035e3 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -572,7 +572,7 @@ class FakeDownloadItem(QObject): super().__init__(parent) self.fileobj = fileobj self.name = name - self.successful = True + self.successful = False class FakeDownloadManager: @@ -581,6 +581,7 @@ class FakeDownloadManager: def __init__(self, tmpdir): self._tmpdir = tmpdir + self.downloads = [] @contextlib.contextmanager def _open_fileobj(self, target): @@ -603,4 +604,5 @@ class FakeDownloadManager: download_item = FakeDownloadItem(target.fileobj, name=url.path()) with (self._tmpdir / url.path()).open('rb') as fake_url_file: shutil.copyfileobj(fake_url_file, download_item.fileobj) + self.downloads.append(download_item) return download_item diff --git a/tests/unit/browser/test_adblock.py b/tests/unit/browser/test_adblock.py index e3b0e4576..5b353efb9 100644 --- a/tests/unit/browser/test_adblock.py +++ b/tests/unit/browser/test_adblock.py @@ -206,6 +206,7 @@ def test_disabled_blocking_update(basedir, config_stub, download_stub, while host_blocker._in_progress: current_download = host_blocker._in_progress[0] with caplog.at_level(logging.ERROR): + current_download.successful = True current_download.finished.emit() host_blocker.read_hosts() for str_url in URLS_TO_CHECK: @@ -221,6 +222,8 @@ def test_no_blocklist_update(config_stub, download_stub, host_blocker = adblock.HostBlocker() host_blocker.adblock_update() host_blocker.read_hosts() + for dl in download_stub.downloads: + dl.successful = True for str_url in URLS_TO_CHECK: assert not host_blocker.is_blocked(QUrl(str_url)) @@ -238,6 +241,7 @@ def test_successful_update(config_stub, basedir, download_stub, while host_blocker._in_progress: current_download = host_blocker._in_progress[0] with caplog.at_level(logging.ERROR): + current_download.successful = True current_download.finished.emit() host_blocker.read_hosts() assert_urls(host_blocker, whitelisted=[]) @@ -265,6 +269,8 @@ def test_failed_dl_update(config_stub, basedir, download_stub, # if current download is the file we want to fail, make it fail if current_download.name == dl_fail_blocklist.path(): current_download.successful = False + else: + current_download.successful = True with caplog.at_level(logging.ERROR): current_download.finished.emit() host_blocker.read_hosts() @@ -294,16 +300,18 @@ def test_invalid_utf8(config_stub, download_stub, tmpdir, data_tmpdir, host_blocker = adblock.HostBlocker() host_blocker.adblock_update() - finished_signal = host_blocker._in_progress[0].finished + current_download = host_blocker._in_progress[0] if location == 'content': with caplog.at_level(logging.ERROR): - finished_signal.emit() + current_download.successful = True + current_download.finished.emit() expected = (r"Failed to decode: " r"b'https://www.example.org/\xa0localhost") assert caplog.records[-2].message.startswith(expected) else: - finished_signal.emit() + current_download.successful = True + current_download.finished.emit() host_blocker.read_hosts() assert_urls(host_blocker, whitelisted=[]) From 0adda22d3cd7470fd1e324f9e970ad32bc3bb042 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sat, 27 Jan 2018 22:03:45 +1300 Subject: [PATCH 340/524] Greasemonkey: add a way to register scripts directly. Previously to add a greasemonkey script you had to write it to the greasemonkey data directory and call load_scripts(). Now you can just make a new GreasemonkeyScript and pass it to add_script(), yay. There are no users of the method yet although I could have used it while writing the tests. --- qutebrowser/browser/greasemonkey.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index 9f7e26f53..6879f4cf6 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -192,17 +192,24 @@ class GreasemonkeyManager(QObject): script = GreasemonkeyScript.parse(script_file.read()) if not script.name: script.name = script_filename - - if script.requires: - log.greasemonkey.debug( - "Deferring script until requirements are " - "fulfilled: {}".format(script.name)) - self._get_required_scripts(script, force) - else: - self._add_script(script) - + self.add_script(script, force) self.scripts_reloaded.emit() + def add_script(self, script, force=False): + """Add a GreasemonkeyScript to this manager. + + Args: + force: Fetch and overwrite any dependancies which are + already locally cached. + """ + if script.requires: + log.greasemonkey.debug( + "Deferring script until requirements are " + "fulfilled: {}".format(script.name)) + self._get_required_scripts(script, force) + else: + self._add_script(script) + def _add_script(self, script): if script.run_at == 'document-start': self._run_start.append(script) From 6d415b6653a5060b39e45c291ee5c11714c0b51a Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sat, 3 Mar 2018 14:34:59 +1300 Subject: [PATCH 341/524] Greasemonkey: don't inject JS into dead frames Hopefully closes #3627 This feels like fixing the symptom instead of the problem but I am not sure how such a situation would arise. Never the less, the crash logs clearly show that `_inject_userjs()` is being called with a deleted frame sometimes. It is being called from a closure that gets triggered on frame.loadFinished so I am not sure how frame could be deleted at that time unless: * the error message is misleading and it is actually some reference to the object that is no longer valid * the frame gets deleted from some other handler of loadFinished. --- qutebrowser/browser/webkit/webpage.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index aebf53d87..7b0a5caf5 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -22,6 +22,7 @@ import html import functools +import sip from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QUrl, QPoint from PyQt5.QtGui import QDesktopServices from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest @@ -302,6 +303,10 @@ class BrowserPage(QWebPage): Args: frame: The QWebFrame to inject the user scripts into. """ + if sip.isdeleted(frame): + log.greasemonkey.debug("_inject_userjs called for deleted frame!") + return + url = frame.url() if url.isEmpty(): url = frame.requestedUrl() From 77e0b8983c0f5793659c324381dd9888b39a611c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 18:51:16 +0100 Subject: [PATCH 342/524] Point to Debian repos for .deb downloads [ci skip] Fixes #3669 --- doc/install.asciidoc | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/doc/install.asciidoc b/doc/install.asciidoc index f17a2c2a2..c48fc71d6 100644 --- a/doc/install.asciidoc +++ b/doc/install.asciidoc @@ -47,11 +47,9 @@ Debian Stretch / Ubuntu 17.04 and 17.10 Those versions come with QtWebEngine in the repositories. This makes it possible to install qutebrowser via the Debian package. -Get the qutebrowser package from the -https://github.com/qutebrowser/qutebrowser/releases[release page] and download -the https://qutebrowser.org/python3-pypeg2_2.15.2-1_all.deb[PyPEG2 package]. - -(If you are using debian testing you can just use the python3-pypeg2 package from the repos) +Download the https://packages.debian.org/sid/all/qutebrowser/download[qutebrowser] and +https://packages.debian.org/sid/all/python3-pypeg2/download[PyPEG2] +package from the Debian repositories. Install the packages: From c3485821c720f5633e0fdc560c319975dd0be0ec Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 14:16:59 +0100 Subject: [PATCH 343/524] Adjust copyright --- qutebrowser/keyinput/keyutils.py | 2 +- tests/unit/keyinput/test_keyutils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 06fcaae4b..d2c32e1b5 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -1,6 +1,6 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# Copyright 2014-2017 Florian Bruhin (The Compiler) +# Copyright 2014-2018 Florian Bruhin (The Compiler) # # This file is part of qutebrowser. # diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 92e52490d..760b58e19 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -1,6 +1,6 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# Copyright 2014-2017 Florian Bruhin (The Compiler) +# Copyright 2014-2018 Florian Bruhin (The Compiler) # # This file is part of qutebrowser. # From fdc2458657871aea8f1603c3fdcf65d7a1e98d3b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 16:03:09 +0100 Subject: [PATCH 344/524] Fix test_split_count after _handle_key merge --- tests/unit/keyinput/test_basekeyparser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 0a8d44c60..f99c6f4fd 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -84,7 +84,7 @@ def test_split_count(config_stub, key_config_stub, kp._read_config('normal') for info in keyseq(input_key): - kp._handle_key(info.to_event()) + kp.handle(info.to_event()) assert kp._count == count assert kp._sequence == keyseq(command) From 2ed480b40a00e709fc7eee04e628172651024d23 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 16:13:05 +0100 Subject: [PATCH 345/524] Refactor configmodel.bind --- qutebrowser/completion/models/configmodel.py | 31 +++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index e89dab227..0396459db 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -72,24 +72,16 @@ def value(optname, *_values, info): return model -def bind(key, *, info): - """A CompletionModel filled with all bindable commands and descriptions. - - Args: - key: the key being bound. - """ - model = completionmodel.CompletionModel(column_widths=(20, 60, 20)) +def _bind_current_default(key, info): + """Get current/default data for the given key.""" data = [] - try: seq = keyutils.KeySequence.parse(key) except keyutils.KeyParseError as e: - seq = None - cmd_text = None data.append(('', str(e), key)) + return data - if seq: - cmd_text = info.keyconf.get_command(seq, 'normal') + cmd_text = info.keyconf.get_command(seq, 'normal') if cmd_text: parser = runners.CommandParser() try: @@ -99,13 +91,24 @@ def bind(key, *, info): else: data.append((cmd_text, '(Current) {}'.format(cmd.desc), key)) - if seq: - cmd_text = info.keyconf.get_command(seq, 'normal') + cmd_text = info.keyconf.get_command(seq, 'normal') if cmd_text: parser = runners.CommandParser() cmd = parser.parse(cmd_text).cmd data.append((cmd_text, '(Default) {}'.format(cmd.desc), key)) + return data + + +def bind(key, *, info): + """A CompletionModel filled with all bindable commands and descriptions. + + Args: + key: the key being bound. + """ + model = completionmodel.CompletionModel(column_widths=(20, 60, 20)) + data = _bind_current_default(key, info) + if data: model.add_category(listcategory.ListCategory("Current/Default", data)) From 880da2d1432222248fec66481f578f8ba62bc71a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 16:17:51 +0100 Subject: [PATCH 346/524] Add missing default=True for configmodel.bind --- qutebrowser/completion/models/configmodel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index 0396459db..05c36cb2b 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -91,7 +91,7 @@ def _bind_current_default(key, info): else: data.append((cmd_text, '(Current) {}'.format(cmd.desc), key)) - cmd_text = info.keyconf.get_command(seq, 'normal') + cmd_text = info.keyconf.get_command(seq, 'normal', default=True) if cmd_text: parser = runners.CommandParser() cmd = parser.parse(cmd_text).cmd From 19512e988bff813d166a3fbd911eb5e83e051903 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 27 Feb 2018 22:21:35 +0100 Subject: [PATCH 347/524] Expose less from keyutils publicly --- qutebrowser/keyinput/basekeyparser.py | 2 +- qutebrowser/keyinput/keyutils.py | 17 ++++++++--------- qutebrowser/keyinput/modeparsers.py | 2 +- scripts/keytester.py | 2 +- tests/unit/keyinput/test_basekeyparser.py | 3 +-- tests/unit/keyinput/test_keyutils.py | 19 ++++++++++--------- 6 files changed, 22 insertions(+), 23 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 27d760d9a..27f671dcd 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -125,7 +125,7 @@ class BaseKeyParser(QObject): A QKeySequence match. """ key = e.key() - txt = keyutils.keyevent_to_string(e) + txt = str(keyutils.KeyInfo.from_event(e)) self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) if not txt: diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index d2c32e1b5..2cf151348 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -30,7 +30,7 @@ from PyQt5.QtGui import QKeySequence, QKeyEvent from qutebrowser.utils import utils -def key_to_string(key): +def _key_to_string(key): """Convert a Qt::Key member to a meaningful name. Args: @@ -127,11 +127,6 @@ def key_to_string(key): return name -def keyevent_to_string(e): - """Convert a QKeyEvent to a meaningful name.""" - return str(KeyInfo(e.key(), e.modifiers())) - - class KeyParseError(Exception): """Raised by _parse_single_key/parse_keystring on parse errors.""" @@ -150,7 +145,7 @@ def _parse_keystring(keystr): for c in keystr: if c == '>': assert special - yield normalize_keystr(key) + yield _normalize_keystr(key) key = '' special = False elif c == '<': @@ -165,7 +160,7 @@ def _parse_keystring(keystr): yield 'Shift+' + c if c.isupper() else c -def normalize_keystr(keystr): +def _normalize_keystr(keystr): """Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q. Args: @@ -201,6 +196,10 @@ class KeyInfo: key = attr.ib() modifiers = attr.ib() + @classmethod + def from_event(cls, e): + return cls(e.key(), e.modifiers()) + def __str__(self): """Convert this KeyInfo to a meaningful name. @@ -240,7 +239,7 @@ class KeyInfo: if self.modifiers & mask and s not in parts: parts.append(s) - key_string = key_to_string(self.key) + key_string = _key_to_string(self.key) if len(key_string) == 1: category = unicodedata.category(key_string) diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index f397b9fd5..42eeb53f8 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -282,7 +282,7 @@ class RegisterKeyParser(keyparser.CommandKeyParser): key = e.text() - if key == '' or keyutils.keyevent_to_string(e) is None: + if key == '' or not str(keyutils.KeyInfo.from_event(e)): # this is not a proper register key, let it pass and keep going # FIXME can we simplify this when we refactor keyutils.py? return QKeySequence.NoMatch diff --git a/scripts/keytester.py b/scripts/keytester.py index 4d27a3dd1..ee5eb347c 100644 --- a/scripts/keytester.py +++ b/scripts/keytester.py @@ -41,7 +41,7 @@ class KeyWidget(QWidget): def keyPressEvent(self, e): """Show pressed keys.""" lines = [ - str(keyutils.keyevent_to_string(e)), + str(keyutils.KeyInfo.from_event(e)), '', 'key: 0x{:x}'.format(int(e.key())), 'modifiers: 0x{:x}'.format(int(e.modifiers())), diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index f99c6f4fd..a32009390 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -172,8 +172,7 @@ class TestSpecialKeys: assert not keyparser.execute.called def test_no_binding(self, monkeypatch, fake_keyevent_factory, keyparser): - monkeypatch.setattr(keyutils, 'keyevent_to_string', - lambda binding: None) + monkeypatch.setattr(keyutils.KeyInfo, '__str__', lambda _self: '') keyparser.handle(fake_keyevent_factory(Qt.Key_A, Qt.NoModifier)) assert not keyparser.execute.called diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 760b58e19..db6855d23 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -38,14 +38,14 @@ class TestKeyToString: ]) def test_normal(self, key, expected): """Test a special key where QKeyEvent::toString works incorrectly.""" - assert keyutils.key_to_string(key) == expected + assert keyutils._key_to_string(key) == expected def test_missing(self, monkeypatch): """Test with a missing key.""" monkeypatch.delattr(keyutils.Qt, 'Key_Blue') # We don't want to test the key which is actually missing - we only # want to know if the mapping still behaves properly. - assert keyutils.key_to_string(Qt.Key_A) == 'A' + assert keyutils._key_to_string(Qt.Key_A) == 'A' def test_all(self): """Make sure there's some sensible output for all keys.""" @@ -53,7 +53,7 @@ class TestKeyToString: if not isinstance(value, Qt.Key): continue print(name) - string = keyutils.key_to_string(value) + string = keyutils._key_to_string(value) assert string string.encode('utf-8') # make sure it's encodable @@ -66,37 +66,38 @@ class TestKeyEventToString: """Test keyeevent when only control is pressed.""" evt = fake_keyevent_factory(key=Qt.Key_Control, modifiers=Qt.ControlModifier) - assert not keyutils.keyevent_to_string(evt) + assert not str(keyutils.KeyInfo.from_event(evt)) def test_only_hyper_l(self, fake_keyevent_factory): """Test keyeevent when only Hyper_L is pressed.""" evt = fake_keyevent_factory(key=Qt.Key_Hyper_L, modifiers=Qt.MetaModifier) - assert not keyutils.keyevent_to_string(evt) + assert not str(keyutils.KeyInfo.from_event(evt)) def test_only_key(self, fake_keyevent_factory): """Test with a simple key pressed.""" evt = fake_keyevent_factory(key=Qt.Key_A) - assert keyutils.keyevent_to_string(evt) == 'a' + assert str(keyutils.KeyInfo.from_event(evt)) == 'a' def test_key_and_modifier(self, fake_keyevent_factory): """Test with key and modifier pressed.""" evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) expected = '' if utils.is_mac else '' - assert keyutils.keyevent_to_string(evt) == expected + assert str(keyutils.KeyInfo.from_event(evt)) == expected def test_key_and_modifiers(self, fake_keyevent_factory): """Test with key and multiple modifiers pressed.""" evt = fake_keyevent_factory( key=Qt.Key_A, modifiers=(Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier | Qt.ShiftModifier)) - assert keyutils.keyevent_to_string(evt) == '' + s = str(keyutils.KeyInfo.from_event(evt)) + assert s == '' @pytest.mark.fake_os('mac') def test_mac(self, fake_keyevent_factory): """Test with a simulated mac.""" evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) - assert keyutils.keyevent_to_string(evt) == '' + assert str(keyutils.KeyInfo.from_event(evt)) == '' @pytest.mark.parametrize('keystr, expected', [ From f714be0ff77eb26f7d4c5bf84b37da28ec71062e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 09:03:47 +0100 Subject: [PATCH 348/524] Initial tests on all Qt keys --- tests/unit/keyinput/key_data.py | 566 +++++++++++++++++++++++++++ tests/unit/keyinput/test_keyutils.py | 18 +- 2 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 tests/unit/keyinput/key_data.py diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py new file mode 100644 index 000000000..fb70bab72 --- /dev/null +++ b/tests/unit/keyinput/key_data.py @@ -0,0 +1,566 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# 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 . + +import attr + +from PyQt5.QtCore import Qt + + +@attr.s +class Key: + + attribute = attr.ib() + name = attr.ib(None) # default: name == attribute + text = attr.ib('') + member = attr.ib(None) + + +# From enum Key in qt5/qtbase/src/corelib/global/qnamespace.h +KEYS = [ + ### misc keys + Key('Escape'), + Key('Tab'), + Key('Backtab'), + Key('Backspace'), + Key('Return'), + Key('Enter'), + Key('Insert'), + Key('Delete'), + Key('Pause'), + Key('Print'), # print screen + Key('SysReq'), + Key('Clear'), + ### cursor movement + Key('Home'), + Key('End'), + Key('Left'), + Key('Up'), + Key('Right'), + Key('Down'), + Key('PageUp'), + Key('PageDown'), + ### modifiers + Key('Shift'), + Key('Control'), + Key('Meta'), + Key('Alt'), + Key('CapsLock'), + Key('NumLock'), + Key('ScrollLock'), + ### function keys + Key('F1'), + Key('F2'), + Key('F3'), + Key('F4'), + Key('F5'), + Key('F6'), + Key('F7'), + Key('F8'), + Key('F9'), + Key('F10'), + Key('F11'), + Key('F12'), + Key('F13'), + Key('F14'), + Key('F15'), + Key('F16'), + Key('F17'), + Key('F18'), + Key('F19'), + Key('F20'), + Key('F21'), + Key('F22'), + Key('F23'), + Key('F24'), + # F25 .. F35 only on X11 + Key('F25'), + Key('F26'), + Key('F27'), + Key('F28'), + Key('F29'), + Key('F30'), + Key('F31'), + Key('F32'), + Key('F33'), + Key('F34'), + Key('F35'), + ### extra keys + Key('Super_L'), + Key('Super_R'), + Key('Menu'), + Key('Hyper_L'), + Key('Hyper_R'), + Key('Help'), + Key('Direction_L'), + Key('Direction_R'), + ### 7 bit printable ASCII + Key('Space'), + Key('Any'), + Key('Exclam'), + Key('QuoteDbl'), + Key('NumberSign'), + Key('Dollar'), + Key('Percent'), + Key('Ampersand'), + Key('Apostrophe'), + Key('ParenLeft'), + Key('ParenRight'), + Key('Asterisk'), + Key('Plus'), + Key('Comma'), + Key('Minus'), + Key('Period'), + Key('Slash'), + Key('0'), + Key('1'), + Key('2'), + Key('3'), + Key('4'), + Key('5'), + Key('6'), + Key('7'), + Key('8'), + Key('9'), + Key('Colon'), + Key('Semicolon'), + Key('Less'), + Key('Equal'), + Key('Greater'), + Key('Question'), + Key('At'), + Key('A'), + Key('B'), + Key('C'), + Key('D'), + Key('E'), + Key('F'), + Key('G'), + Key('H'), + Key('I'), + Key('J'), + Key('K'), + Key('L'), + Key('M'), + Key('N'), + Key('O'), + Key('P'), + Key('Q'), + Key('R'), + Key('S'), + Key('T'), + Key('U'), + Key('V'), + Key('W'), + Key('X'), + Key('Y'), + Key('Z'), + Key('BracketLeft'), + Key('Backslash'), + Key('BracketRight'), + Key('AsciiCircum'), + Key('Underscore'), + Key('QuoteLeft'), + Key('BraceLeft'), + Key('Bar'), + Key('BraceRight'), + Key('AsciiTilde'), + + Key('nobreakspace'), + Key('exclamdown'), + Key('cent'), + Key('sterling'), + Key('currency'), + Key('yen'), + Key('brokenbar'), + Key('section'), + Key('diaeresis'), + Key('copyright'), + Key('ordfeminine'), + Key('guillemotleft'), # left angle quotation mark + Key('notsign'), + Key('hyphen'), + Key('registered'), + Key('macron'), + Key('degree'), + Key('plusminus'), + Key('twosuperior'), + Key('threesuperior'), + Key('acute'), + Key('mu'), + Key('paragraph'), + Key('periodcentered'), + Key('cedilla'), + Key('onesuperior'), + Key('masculine'), + Key('guillemotright'), # right angle quotation mark + Key('onequarter'), + Key('onehalf'), + Key('threequarters'), + Key('questiondown'), + Key('Agrave'), + Key('Aacute'), + Key('Acircumflex'), + Key('Atilde'), + Key('Adiaeresis'), + Key('Aring'), + Key('AE'), + Key('Ccedilla'), + Key('Egrave'), + Key('Eacute'), + Key('Ecircumflex'), + Key('Ediaeresis'), + Key('Igrave'), + Key('Iacute'), + Key('Icircumflex'), + Key('Idiaeresis'), + Key('ETH'), + Key('Ntilde'), + Key('Ograve'), + Key('Oacute'), + Key('Ocircumflex'), + Key('Otilde'), + Key('Odiaeresis'), + Key('multiply'), + Key('Ooblique'), + Key('Ugrave'), + Key('Uacute'), + Key('Ucircumflex'), + Key('Udiaeresis'), + Key('Yacute'), + Key('THORN'), + Key('ssharp'), + Key('division'), + Key('ydiaeresis'), + + ### International input method support (X keycode - 0xEE00, the + ### definition follows Qt/Embedded 2.3.7) Only interesting if + ### you are writing your own input method + + ### International & multi-key character composition + Key('AltGr'), + Key('Multi_key'), # Multi-key character compose + Key('Codeinput'), + Key('SingleCandidate'), + Key('MultipleCandidate'), + Key('PreviousCandidate'), + + ### Misc Functions + Key('Mode_switch'), # Character set switch + # Key('script_switch'), # Alias for mode_switch + + ### Japanese keyboard support + Key('Kanji'), # Kanji, Kanji convert + Key('Muhenkan'), # Cancel Conversion + # Key('Henkan_Mode'), # Start/Stop Conversion + Key('Henkan'), # Alias for Henkan_Mode + Key('Romaji'), # to Romaji + Key('Hiragana'), # to Hiragana + Key('Katakana'), # to Katakana + # Hiragana/Katakana toggle + Key('Hiragana_Katakana'), + Key('Zenkaku'), # to Zenkaku + Key('Hankaku'), # to Hankaku + Key('Zenkaku_Hankaku'), # Zenkaku/Hankaku toggle + Key('Touroku'), # Add to Dictionary + Key('Massyo'), # Delete from Dictionary + Key('Kana_Lock'), # Kana Lock + Key('Kana_Shift'), # Kana Shift + Key('Eisu_Shift'), # Alphanumeric Shift + Key('Eisu_toggle'), # Alphanumeric toggle + # Key('Kanji_Bangou'), # Codeinput + # Key('Zen_Koho'), # Multiple/All Candidate(s) + # Key('Mae_Koho'), # Previous Candidate + + ### Korean keyboard support + ### + ### In fact, many Korean users need only 2 keys, Key_Hangul and + ### Key_Hangul_Hanja. But rest of the keys are good for future. + + Key('Hangul'), # Hangul start/stop(toggle) + Key('Hangul_Start'), # Hangul start + Key('Hangul_End'), # Hangul end, English start + Key('Hangul_Hanja'), # Start Hangul->Hanja Conversion + Key('Hangul_Jamo'), # Hangul Jamo mode + Key('Hangul_Romaja'), # Hangul Romaja mode + # Key('Hangul_Codeinput'),# Hangul code input mode + Key('Hangul_Jeonja'), # Jeonja mode + Key('Hangul_Banja'), # Banja mode + Key('Hangul_PreHanja'), # Pre Hanja conversion + Key('Hangul_PostHanja'), # Post Hanja conversion + # Key('Hangul_SingleCandidate'), # Single candidate + # Key('Hangul_MultipleCandidate'), # Multiple candidate + # Key('Hangul_PreviousCandidate'), # Previous candidate + Key('Hangul_Special'), # Special symbols + # Key('Hangul_switch'), # Alias for mode_switch + + # dead keys (X keycode - 0xED00 to avoid the conflict) + Key('Dead_Grave'), + Key('Dead_Acute'), + Key('Dead_Circumflex'), + Key('Dead_Tilde'), + Key('Dead_Macron'), + Key('Dead_Breve'), + Key('Dead_Abovedot'), + Key('Dead_Diaeresis'), + Key('Dead_Abovering'), + Key('Dead_Doubleacute'), + Key('Dead_Caron'), + Key('Dead_Cedilla'), + Key('Dead_Ogonek'), + Key('Dead_Iota'), + Key('Dead_Voiced_Sound'), + Key('Dead_Semivoiced_Sound'), + Key('Dead_Belowdot'), + Key('Dead_Hook'), + Key('Dead_Horn'), + + # Not in Qt 5.10, so data may be wrong! + Key('Dead_Stroke'), + Key('Dead_Abovecomma'), + Key('Dead_Abovereversedcomma'), + Key('Dead_Doublegrave'), + Key('Dead_Belowring'), + Key('Dead_Belowmacron'), + Key('Dead_Belowcircumflex'), + Key('Dead_Belowtilde'), + Key('Dead_Belowbreve'), + Key('Dead_Belowdiaeresis'), + Key('Dead_Invertedbreve'), + Key('Dead_Belowcomma'), + Key('Dead_Currency'), + Key('Dead_a'), + Key('Dead_A'), + Key('Dead_e'), + Key('Dead_E'), + Key('Dead_i'), + Key('Dead_I'), + Key('Dead_o'), + Key('Dead_O'), + Key('Dead_u'), + Key('Dead_U'), + Key('Dead_Small_Schwa'), + Key('Dead_Capital_Schwa'), + Key('Dead_Greek'), + Key('Dead_Lowline'), + Key('Dead_Aboveverticalline'), + Key('Dead_Belowverticalline'), + Key('Dead_Longsolidusoverlay'), + + ### multimedia/internet keys - ignored by default - see QKeyEvent c'tor + Key('Back'), + Key('Forward'), + Key('Stop'), + Key('Refresh'), + Key('VolumeDown'), + Key('VolumeMute'), + Key('VolumeUp'), + Key('BassBoost'), + Key('BassUp'), + Key('BassDown'), + Key('TrebleUp'), + Key('TrebleDown'), + Key('MediaPlay'), + Key('MediaStop'), + Key('MediaPrevious'), + Key('MediaNext'), + Key('MediaRecord'), + Key('MediaPause'), + Key('MediaTogglePlayPause'), + Key('HomePage'), + Key('Favorites'), + Key('Search'), + Key('Standby'), + Key('OpenUrl'), + Key('LaunchMail'), + Key('LaunchMedia'), + Key('Launch0'), + Key('Launch1'), + Key('Launch2'), + Key('Launch3'), + Key('Launch4'), + Key('Launch5'), + Key('Launch6'), + Key('Launch7'), + Key('Launch8'), + Key('Launch9'), + Key('LaunchA'), + Key('LaunchB'), + Key('LaunchC'), + Key('LaunchD'), + Key('LaunchE'), + Key('LaunchF'), + Key('MonBrightnessUp'), + Key('MonBrightnessDown'), + Key('KeyboardLightOnOff'), + Key('KeyboardBrightnessUp'), + Key('KeyboardBrightnessDown'), + Key('PowerOff'), + Key('WakeUp'), + Key('Eject'), + Key('ScreenSaver'), + Key('WWW'), + Key('Memo'), + Key('LightBulb'), + Key('Shop'), + Key('History'), + Key('AddFavorite'), + Key('HotLinks'), + Key('BrightnessAdjust'), + Key('Finance'), + Key('Community'), + Key('AudioRewind'), # Media rewind + Key('BackForward'), + Key('ApplicationLeft'), + Key('ApplicationRight'), + Key('Book'), + Key('CD'), + Key('Calculator'), + Key('ToDoList'), + Key('ClearGrab'), + Key('Close'), + Key('Copy'), + Key('Cut'), + Key('Display'), # Output switch key + Key('DOS'), + Key('Documents'), + Key('Excel'), + Key('Explorer'), + Key('Game'), + Key('Go'), + Key('iTouch'), + Key('LogOff'), + Key('Market'), + Key('Meeting'), + Key('MenuKB'), + Key('MenuPB'), + Key('MySites'), + Key('News'), + Key('OfficeHome'), + Key('Option'), + Key('Paste'), + Key('Phone'), + Key('Calendar'), + Key('Reply'), + Key('Reload'), + Key('RotateWindows'), + Key('RotationPB'), + Key('RotationKB'), + Key('Save'), + Key('Send'), + Key('Spell'), + Key('SplitScreen'), + Key('Support'), + Key('TaskPane'), + Key('Terminal'), + Key('Tools'), + Key('Travel'), + Key('Video'), + Key('Word'), + Key('Xfer'), + Key('ZoomIn'), + Key('ZoomOut'), + Key('Away'), + Key('Messenger'), + Key('WebCam'), + Key('MailForward'), + Key('Pictures'), + Key('Music'), + Key('Battery'), + Key('Bluetooth'), + Key('WLAN'), + Key('UWB'), + Key('AudioForward'), # Media fast-forward + Key('AudioRepeat'), # Toggle repeat mode + Key('AudioRandomPlay'), # Toggle shuffle mode + Key('Subtitle'), + Key('AudioCycleTrack'), + Key('Time'), + Key('Hibernate'), + Key('View'), + Key('TopMenu'), + Key('PowerDown'), + Key('Suspend'), + Key('ContrastAdjust'), + + Key('LaunchG'), + Key('LaunchH'), + + Key('TouchpadToggle'), + Key('TouchpadOn'), + Key('TouchpadOff'), + + Key('MicMute'), + + Key('Red'), + Key('Green'), + Key('Yellow'), + Key('Blue'), + + Key('ChannelUp'), + Key('ChannelDown'), + + Key('Guide'), + Key('Info'), + Key('Settings'), + + Key('MicVolumeUp'), + Key('MicVolumeDown'), + + Key('New'), + Key('Open'), + Key('Find'), + Key('Undo'), + Key('Redo'), + + Key('MediaLast'), + + ### Keypad navigation keys + Key('Select'), + Key('Yes'), + Key('No'), + + ### Newer misc keys + Key('Cancel'), + Key('Printer'), + Key('Execute'), + Key('Sleep'), + Key('Play'), # Not the same as Key_MediaPlay + Key('Zoom'), + # Key('Jisho'), # IME: Dictionary key + # Key('Oyayubi_Left'), # IME: Left Oyayubi key + # Key('Oyayubi_Right'), # IME: Right Oyayubi key + Key('Exit'), + + # Device keys + Key('Context1'), + Key('Context2'), + Key('Context3'), + Key('Context4'), + Key('Call'), # set absolute state to in a call (do not toggle state) + Key('Hangup'), # set absolute state to hang up (do not toggle state) + Key('Flip'), + Key('ToggleCallHangup'), # a toggle key for answering, or hanging up, based on current call state + Key('VoiceDial'), + Key('LastNumberRedial'), + + Key('Camera'), + Key('CameraFocus'), + + Key('unknown'), +] diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index db6855d23..3081b7558 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -20,13 +20,27 @@ import pytest from PyQt5.QtCore import Qt +from tests.unit.keyinput import key_data from qutebrowser.utils import utils from qutebrowser.keyinput import keyutils -class TestKeyToString: +@pytest.fixture(params=sorted(list(key_data.KEYS.items()))) +def qt_key(request): + attr, key = request.param + member = getattr(Qt, 'Key_' + attr, None) + if member is None: + pytest.skip("Did not find key {}".format(attr)) - """Test key_to_string.""" + key.member = member + return key + + +def test_new_to_string(qt_key): + assert keyutils._key_to_string(qt_key.member) == qt_key.name + + +class TestKeyToString: @pytest.mark.parametrize('key, expected', [ (Qt.Key_Blue, 'Blue'), From 8f479407a0f96094a6ccc0ea709de1513b4d72f7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 09:34:27 +0100 Subject: [PATCH 349/524] key_data: Update key names to reflect reality Generated by: import key_data from PyQt5.QtCore import Qt from PyQt5.QtGui import QKeySequence for key in key_data.KEYS: attr = key.attribute member = getattr(Qt, 'Key_' + attr, None) if member is None: continue name = QKeySequence(member).toString() if name != attr: try: print(" Key('{}', '{}')".format(attr, name)) except UnicodeEncodeError: print(" Key('{}', '{}') # FIXME".format(attr, name.encode('unicode-escape').decode('ascii'))) else: print() --- tests/unit/keyinput/key_data.py | 522 ++++++++++++++++---------------- 1 file changed, 262 insertions(+), 260 deletions(-) diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py index fb70bab72..430885ebe 100644 --- a/tests/unit/keyinput/key_data.py +++ b/tests/unit/keyinput/key_data.py @@ -34,14 +34,14 @@ class Key: # From enum Key in qt5/qtbase/src/corelib/global/qnamespace.h KEYS = [ ### misc keys - Key('Escape'), + Key('Escape', 'Esc'), Key('Tab'), Key('Backtab'), Key('Backspace'), Key('Return'), Key('Enter'), - Key('Insert'), - Key('Delete'), + Key('Insert', 'Ins'), + Key('Delete', 'Del'), Key('Pause'), Key('Print'), # print screen Key('SysReq'), @@ -53,13 +53,13 @@ KEYS = [ Key('Up'), Key('Right'), Key('Down'), - Key('PageUp'), - Key('PageDown'), + Key('PageUp', 'PgUp'), + Key('PageDown', 'PgDown'), ### modifiers - Key('Shift'), - Key('Control'), - Key('Meta'), - Key('Alt'), + Key('Shift', '\u17c0\udc20'), # FIXME + Key('Control', '\u17c0\udc21'), # FIXME + Key('Meta', '\u17c0\udc22'), # FIXME + Key('Alt', '\u17c0\udc23'), # FIXME Key('CapsLock'), Key('NumLock'), Key('ScrollLock'), @@ -101,32 +101,32 @@ KEYS = [ Key('F34'), Key('F35'), ### extra keys - Key('Super_L'), - Key('Super_R'), + Key('Super_L', '\u17c0\udc53'), # FIXME + Key('Super_R', '\u17c0\udc54'), # FIXME Key('Menu'), - Key('Hyper_L'), - Key('Hyper_R'), + Key('Hyper_L', '\u17c0\udc56'), # FIXME + Key('Hyper_R', '\u17c0\udc57'), # FIXME Key('Help'), - Key('Direction_L'), - Key('Direction_R'), + Key('Direction_L', '\u17c0\udc59'), # FIXME + Key('Direction_R', '\u17c0\udc60'), # FIXME ### 7 bit printable ASCII Key('Space'), - Key('Any'), - Key('Exclam'), - Key('QuoteDbl'), - Key('NumberSign'), - Key('Dollar'), - Key('Percent'), - Key('Ampersand'), - Key('Apostrophe'), - Key('ParenLeft'), - Key('ParenRight'), - Key('Asterisk'), - Key('Plus'), - Key('Comma'), - Key('Minus'), - Key('Period'), - Key('Slash'), + Key('Any', 'Space'), # FIXME + Key('Exclam', '!'), + Key('QuoteDbl', '"'), + Key('NumberSign', '#'), + Key('Dollar', '$'), + Key('Percent', '%'), + Key('Ampersand', '&'), + Key('Apostrophe', "'"), + Key('ParenLeft', '('), + Key('ParenRight', '),') + Key('Asterisk', '*'), + Key('Plus', '+'), + Key('Comma', ','), + Key('Minus', '-'), + Key('Period', '.'), + Key('Slash', '/'), Key('0'), Key('1'), Key('2'), @@ -137,12 +137,13 @@ KEYS = [ Key('7'), Key('8'), Key('9'), - Key('Colon'), - Key('Semicolon'), - Key('Less'), - Key('Equal'), - Key('Greater'), - Key('Question'), + Key('Colon', ':'), + Key('Semicolon', ';'), + Key('Less', '<'), + Key('Equal', '='), + Key('Greater', '>'), + Key('Question', '?'), + Key('At', '@'), Key('At'), Key('A'), Key('B'), @@ -170,95 +171,96 @@ KEYS = [ Key('X'), Key('Y'), Key('Z'), - Key('BracketLeft'), - Key('Backslash'), - Key('BracketRight'), - Key('AsciiCircum'), - Key('Underscore'), - Key('QuoteLeft'), - Key('BraceLeft'), - Key('Bar'), - Key('BraceRight'), - Key('AsciiTilde'), + Key('BracketLeft', '['), + Key('Backslash', '\\'), + Key('BracketRight', ']'), + Key('AsciiCircum', '^'), + Key('Underscore', '_'), + Key('QuoteLeft', '`'), + Key('BraceLeft', '{'), + Key('Bar', '|'), + Key('BraceRight', '}'), + Key('AsciiTilde', '~'), - Key('nobreakspace'), - Key('exclamdown'), - Key('cent'), - Key('sterling'), - Key('currency'), - Key('yen'), - Key('brokenbar'), - Key('section'), - Key('diaeresis'), - Key('copyright'), - Key('ordfeminine'), - Key('guillemotleft'), # left angle quotation mark - Key('notsign'), - Key('hyphen'), - Key('registered'), - Key('macron'), - Key('degree'), - Key('plusminus'), - Key('twosuperior'), - Key('threesuperior'), - Key('acute'), - Key('mu'), - Key('paragraph'), - Key('periodcentered'), - Key('cedilla'), - Key('onesuperior'), - Key('masculine'), - Key('guillemotright'), # right angle quotation mark - Key('onequarter'), - Key('onehalf'), - Key('threequarters'), - Key('questiondown'), - Key('Agrave'), - Key('Aacute'), - Key('Acircumflex'), - Key('Atilde'), - Key('Adiaeresis'), - Key('Aring'), - Key('AE'), - Key('Ccedilla'), - Key('Egrave'), - Key('Eacute'), - Key('Ecircumflex'), - Key('Ediaeresis'), - Key('Igrave'), - Key('Iacute'), - Key('Icircumflex'), - Key('Idiaeresis'), - Key('ETH'), - Key('Ntilde'), - Key('Ograve'), - Key('Oacute'), - Key('Ocircumflex'), - Key('Otilde'), - Key('Odiaeresis'), - Key('multiply'), - Key('Ooblique'), - Key('Ugrave'), - Key('Uacute'), - Key('Ucircumflex'), - Key('Udiaeresis'), - Key('Yacute'), - Key('THORN'), - Key('ssharp'), - Key('division'), - Key('ydiaeresis'), + Key('nobreakspace', ' '), + Key('exclamdown', '¡'), + Key('cent', '¢'), + Key('sterling', '£'), + Key('currency', '¤'), + Key('yen', '¥'), + Key('brokenbar', '¦'), + Key('section', '§'), + Key('diaeresis', '¨'), + Key('copyright', '©'), + Key('ordfeminine', 'ª'), + Key('guillemotleft', '«'), + Key('notsign', '¬'), + Key('hyphen', '­'), + Key('registered', '®'), + Key('macron', '¯'), + Key('degree', '°'), + Key('plusminus', '±'), + Key('twosuperior', '²'), + Key('threesuperior', '³'), + Key('acute', '´'), + Key('mu', 'Μ'), + Key('paragraph', '¶'), + Key('periodcentered', '·'), + Key('cedilla', '¸'), + Key('onesuperior', '¹'), + Key('masculine', 'º'), + Key('guillemotright', '»'), + Key('onequarter', '¼'), + Key('onehalf', '½'), + Key('threequarters', '¾'), + Key('questiondown', '¿'), + Key('Agrave', 'À'), + Key('Aacute', 'Á'), + Key('Acircumflex', 'Â'), + Key('Atilde', 'Ã'), + Key('Adiaeresis', 'Ä'), + Key('Aring', 'Å'), + Key('AE', 'Æ'), + Key('Ccedilla', 'Ç'), + Key('Egrave', 'È'), + Key('Eacute', 'É'), + Key('Ecircumflex', 'Ê'), + Key('Ediaeresis', 'Ë'), + Key('Igrave', 'Ì'), + Key('Iacute', 'Í'), + Key('Icircumflex', 'Î'), + Key('Idiaeresis', 'Ï'), + Key('ETH', 'Ð'), + Key('Ntilde', 'Ñ'), + Key('Ograve', 'Ò'), + Key('Oacute', 'Ó'), + Key('Ocircumflex', 'Ô'), + Key('Otilde', 'Õ'), + Key('Odiaeresis', 'Ö'), + Key('multiply', '×'), + Key('Ooblique', 'Ø'), + Key('Ugrave', 'Ù'), + Key('Uacute', 'Ú'), + Key('Ucircumflex', 'Û'), + Key('Udiaeresis', 'Ü'), + Key('Yacute', 'Ý'), + Key('THORN', 'Þ'), + Key('ssharp', 'ß'), + Key('division', '÷'), + Key('ydiaeresis', 'Ÿ'), ### International input method support (X keycode - 0xEE00, the ### definition follows Qt/Embedded 2.3.7) Only interesting if ### you are writing your own input method ### International & multi-key character composition - Key('AltGr'), - Key('Multi_key'), # Multi-key character compose - Key('Codeinput'), - Key('SingleCandidate'), - Key('MultipleCandidate'), - Key('PreviousCandidate'), + Key('AltGr', '\u17c4\udd03'), # FIXME + Key('Multi_key', '\u17c4\udd20'), # FIXME Multi-key character compose + Key('Codeinput', 'Code input'), + Key('SingleCandidate', '\u17c4\udd3c'), # FIXME + Key('MultipleCandidate', 'Multiple Candidate'), + Key('PreviousCandidate', 'Previous Candidate'), + Key('Mode_switch', '\u17c4\udd7e'), # FIXME ### Misc Functions Key('Mode_switch'), # Character set switch @@ -272,17 +274,16 @@ KEYS = [ Key('Romaji'), # to Romaji Key('Hiragana'), # to Hiragana Key('Katakana'), # to Katakana - # Hiragana/Katakana toggle - Key('Hiragana_Katakana'), + Key('Hiragana_Katakana', 'Hiragana Katakana'), # Hiragana/Katakana toggle Key('Zenkaku'), # to Zenkaku Key('Hankaku'), # to Hankaku - Key('Zenkaku_Hankaku'), # Zenkaku/Hankaku toggle + Key('Zenkaku_Hankaku', 'Zenkaku Hankaku'), # Zenkaku/Hankaku toggle Key('Touroku'), # Add to Dictionary Key('Massyo'), # Delete from Dictionary - Key('Kana_Lock'), # Kana Lock - Key('Kana_Shift'), # Kana Shift - Key('Eisu_Shift'), # Alphanumeric Shift - Key('Eisu_toggle'), # Alphanumeric toggle + Key('Kana_Lock', 'Kana Lock'), + Key('Kana_Shift', 'Kana Shift'), + Key('Eisu_Shift', 'Eisu Shift'), # Alphanumeric Shift + Key('Eisu_toggle', 'Eisu toggle'), # Alphanumeric toggle # Key('Kanji_Bangou'), # Codeinput # Key('Zen_Koho'), # Multiple/All Candidate(s) # Key('Mae_Koho'), # Previous Candidate @@ -292,43 +293,43 @@ KEYS = [ ### In fact, many Korean users need only 2 keys, Key_Hangul and ### Key_Hangul_Hanja. But rest of the keys are good for future. - Key('Hangul'), # Hangul start/stop(toggle) - Key('Hangul_Start'), # Hangul start - Key('Hangul_End'), # Hangul end, English start - Key('Hangul_Hanja'), # Start Hangul->Hanja Conversion - Key('Hangul_Jamo'), # Hangul Jamo mode - Key('Hangul_Romaja'), # Hangul Romaja mode - # Key('Hangul_Codeinput'),# Hangul code input mode - Key('Hangul_Jeonja'), # Jeonja mode - Key('Hangul_Banja'), # Banja mode - Key('Hangul_PreHanja'), # Pre Hanja conversion - Key('Hangul_PostHanja'), # Post Hanja conversion - # Key('Hangul_SingleCandidate'), # Single candidate - # Key('Hangul_MultipleCandidate'), # Multiple candidate - # Key('Hangul_PreviousCandidate'), # Previous candidate - Key('Hangul_Special'), # Special symbols - # Key('Hangul_switch'), # Alias for mode_switch + Key('Hangul'), # Hangul start/stop(toggle), + Key('Hangul_Start', 'Hangul Start'), # Hangul start + Key('Hangul_End', 'Hangul End'), # Hangul end, English start + Key('Hangul_Hanja', 'Hangul Hanja'), # Start Hangul->Hanja Conversion + Key('Hangul_Jamo', 'Hangul Jamo'), # Hangul Jamo mode + Key('Hangul_Romaja', 'Hangul Romaja'), # Hangul Romaja mode + # Key('Hangul_Codeinput', 'Hangul Codeinput'),# Hangul code input mode + Key('Hangul_Jeonja', 'Hangul Jeonja'), # Jeonja mode + Key('Hangul_Banja', 'Hangul Banja'), # Banja mode + Key('Hangul_PreHanja', 'Hangul PreHanja'), # Pre Hanja conversion + Key('Hangul_PostHanja', 'Hangul PostHanja'), # Post Hanja conversion + # Key('Hangul_SingleCandidate', 'Hangul SingleCandidate'), # Single candidate + # Key('Hangul_MultipleCandidate', 'Hangul MultipleCandidate'), # Multiple candidate + # Key('Hangul_PreviousCandidate', 'Hangul PreviousCandidate'), # Previous candidate + Key('Hangul_Special', 'Hangul Special'), # Special symbols + # Key('Hangul_switch', 'Hangul switch'), # Alias for mode_switch - # dead keys (X keycode - 0xED00 to avoid the conflict) - Key('Dead_Grave'), - Key('Dead_Acute'), - Key('Dead_Circumflex'), - Key('Dead_Tilde'), - Key('Dead_Macron'), - Key('Dead_Breve'), - Key('Dead_Abovedot'), - Key('Dead_Diaeresis'), - Key('Dead_Abovering'), - Key('Dead_Doubleacute'), - Key('Dead_Caron'), - Key('Dead_Cedilla'), - Key('Dead_Ogonek'), - Key('Dead_Iota'), - Key('Dead_Voiced_Sound'), - Key('Dead_Semivoiced_Sound'), - Key('Dead_Belowdot'), - Key('Dead_Hook'), - Key('Dead_Horn'), + # dead keys (X keycode - 0xED00 to avoid the conflict), + Key('Dead_Grave', '\u17c4\ude50'), # FIXME + Key('Dead_Acute', '\u17c4\ude51'), # FIXME + Key('Dead_Circumflex', '\u17c4\ude52'), # FIXME + Key('Dead_Tilde', '\u17c4\ude53'), # FIXME + Key('Dead_Macron', '\u17c4\ude54'), # FIXME + Key('Dead_Breve', '\u17c4\ude55'), # FIXME + Key('Dead_Abovedot', '\u17c4\ude56'), # FIXME + Key('Dead_Diaeresis', '\u17c4\ude57'), # FIXME + Key('Dead_Abovering', '\u17c4\ude58'), # FIXME + Key('Dead_Doubleacute', '\u17c4\ude59'), # FIXME + Key('Dead_Caron', '\u17c4\ude5a'), # FIXME + Key('Dead_Cedilla', '\u17c4\ude5b'), # FIXME + Key('Dead_Ogonek', '\u17c4\ude5c'), # FIXME + Key('Dead_Iota', '\u17c4\ude5d'), # FIXME + Key('Dead_Voiced_Sound', '\u17c4\ude5e'), # FIXME + Key('Dead_Semivoiced_Sound', '\u17c4\ude5f'), # FIXME + Key('Dead_Belowdot', '\u17c4\ude60'), # FIXME + Key('Dead_Hook', '\u17c4\ude61'), # FIXME + Key('Dead_Horn', '\u17c4\ude62'), # FIXME # Not in Qt 5.10, so data may be wrong! Key('Dead_Stroke'), @@ -367,160 +368,161 @@ KEYS = [ Key('Forward'), Key('Stop'), Key('Refresh'), - Key('VolumeDown'), - Key('VolumeMute'), - Key('VolumeUp'), - Key('BassBoost'), - Key('BassUp'), - Key('BassDown'), - Key('TrebleUp'), - Key('TrebleDown'), - Key('MediaPlay'), - Key('MediaStop'), - Key('MediaPrevious'), - Key('MediaNext'), - Key('MediaRecord'), - Key('MediaPause'), - Key('MediaTogglePlayPause'), - Key('HomePage'), + Key('VolumeDown', 'Volume Down'), + Key('VolumeMute', 'Volume Mute'), + Key('VolumeUp', 'Volume Up'), + Key('BassBoost', 'Bass Boost'), + Key('BassUp', 'Bass Up'), + Key('BassDown', 'Bass Down'), + Key('TrebleUp', 'Treble Up'), + Key('TrebleDown', 'Treble Down'), + Key('MediaPlay', 'Media Play'), + Key('MediaStop', 'Media Stop'), + Key('MediaPrevious', 'Media Previous'), + Key('MediaNext', 'Media Next'), + Key('MediaRecord', 'Media Record'), + Key('MediaPause', 'Media Pause'), + Key('MediaTogglePlayPause', 'Toggle Media Play/Pause'), + Key('HomePage', 'Home Page'), Key('Favorites'), Key('Search'), Key('Standby'), - Key('OpenUrl'), - Key('LaunchMail'), - Key('LaunchMedia'), - Key('Launch0'), - Key('Launch1'), - Key('Launch2'), - Key('Launch3'), - Key('Launch4'), - Key('Launch5'), - Key('Launch6'), - Key('Launch7'), - Key('Launch8'), - Key('Launch9'), - Key('LaunchA'), - Key('LaunchB'), - Key('LaunchC'), - Key('LaunchD'), - Key('LaunchE'), - Key('LaunchF'), - Key('MonBrightnessUp'), - Key('MonBrightnessDown'), - Key('KeyboardLightOnOff'), - Key('KeyboardBrightnessUp'), - Key('KeyboardBrightnessDown'), - Key('PowerOff'), - Key('WakeUp'), + + Key('OpenUrl', 'Open URL'), + Key('LaunchMail', 'Launch Mail'), + Key('LaunchMedia', 'Launch Media'), + Key('Launch0', 'Launch (0),') + Key('Launch1', 'Launch (1),') + Key('Launch2', 'Launch (2),') + Key('Launch3', 'Launch (3),') + Key('Launch4', 'Launch (4),') + Key('Launch5', 'Launch (5),') + Key('Launch6', 'Launch (6),') + Key('Launch7', 'Launch (7),') + Key('Launch8', 'Launch (8),') + Key('Launch9', 'Launch (9),') + Key('LaunchA', 'Launch (A),') + Key('LaunchB', 'Launch (B),') + Key('LaunchC', 'Launch (C),') + Key('LaunchD', 'Launch (D),') + Key('LaunchE', 'Launch (E),') + Key('LaunchF', 'Launch (F),') + Key('MonBrightnessUp', 'Monitor Brightness Up'), + Key('MonBrightnessDown', 'Monitor Brightness Down'), + Key('KeyboardLightOnOff', 'Keyboard Light On/Off'), + Key('KeyboardBrightnessUp', 'Keyboard Brightness Up'), + Key('KeyboardBrightnessDown', 'Keyboard Brightness Down'), + Key('PowerOff', 'Power Off'), + Key('WakeUp', 'Wake Up'), Key('Eject'), - Key('ScreenSaver'), + Key('ScreenSaver', 'Screensaver'), Key('WWW'), - Key('Memo'), + Key('Memo', '\u17c0\udcbc'), # FIXME Key('LightBulb'), Key('Shop'), Key('History'), - Key('AddFavorite'), - Key('HotLinks'), - Key('BrightnessAdjust'), + Key('AddFavorite', 'Add Favorite'), + Key('HotLinks', 'Hot Links'), + Key('BrightnessAdjust', 'Adjust Brightness'), Key('Finance'), Key('Community'), - Key('AudioRewind'), # Media rewind - Key('BackForward'), - Key('ApplicationLeft'), - Key('ApplicationRight'), + Key('AudioRewind', 'Media Rewind'), + Key('BackForward', 'Back Forward'), + Key('ApplicationLeft', 'Application Left'), + Key('ApplicationRight', 'Application Right'), Key('Book'), Key('CD'), Key('Calculator'), - Key('ToDoList'), - Key('ClearGrab'), + Key('ToDoList', '\u17c0\udccc'), # FIXME + Key('ClearGrab', 'Clear Grab'), Key('Close'), Key('Copy'), Key('Cut'), Key('Display'), # Output switch key Key('DOS'), Key('Documents'), - Key('Excel'), - Key('Explorer'), + Key('Excel', 'Spreadsheet'), + Key('Explorer', 'Browser'), Key('Game'), Key('Go'), Key('iTouch'), - Key('LogOff'), + Key('LogOff', 'Logoff'), Key('Market'), Key('Meeting'), - Key('MenuKB'), - Key('MenuPB'), - Key('MySites'), + Key('MenuKB', 'Keyboard Menu'), + Key('MenuPB', 'Menu PB'), + Key('MySites', 'My Sites'), Key('News'), - Key('OfficeHome'), + Key('OfficeHome', 'Home Office'), Key('Option'), Key('Paste'), Key('Phone'), - Key('Calendar'), + Key('Calendar', '\u17c0\udce4'), # FIXME Key('Reply'), Key('Reload'), - Key('RotateWindows'), - Key('RotationPB'), - Key('RotationKB'), + Key('RotateWindows', 'Rotate Windows'), + Key('RotationPB', 'Rotation PB'), + Key('RotationKB', 'Rotation KB'), Key('Save'), Key('Send'), - Key('Spell'), - Key('SplitScreen'), + Key('Spell', 'Spellchecker'), + Key('SplitScreen', 'Split Screen'), Key('Support'), - Key('TaskPane'), + Key('TaskPane', 'Task Panel'), Key('Terminal'), Key('Tools'), Key('Travel'), Key('Video'), - Key('Word'), - Key('Xfer'), - Key('ZoomIn'), - Key('ZoomOut'), + Key('Word', 'Word Processor'), + Key('Xfer', 'XFer'), + Key('ZoomIn', 'Zoom In'), + Key('ZoomOut', 'Zoom Out'), Key('Away'), Key('Messenger'), Key('WebCam'), - Key('MailForward'), + Key('MailForward', 'Mail Forward'), Key('Pictures'), Key('Music'), Key('Battery'), Key('Bluetooth'), - Key('WLAN'), - Key('UWB'), - Key('AudioForward'), # Media fast-forward - Key('AudioRepeat'), # Toggle repeat mode - Key('AudioRandomPlay'), # Toggle shuffle mode + Key('WLAN', 'Wireless'), + Key('UWB', 'Ultra Wide Band'), + Key('AudioForward', 'Media Fast Forward'), + Key('AudioRepeat', 'Audio Repeat'), # Toggle repeat mode + Key('AudioRandomPlay', 'Audio Random Play'), # Toggle shuffle mode Key('Subtitle'), - Key('AudioCycleTrack'), + Key('AudioCycleTrack', 'Audio Cycle Track'), Key('Time'), Key('Hibernate'), Key('View'), - Key('TopMenu'), - Key('PowerDown'), + Key('TopMenu', 'Top Menu'), + Key('PowerDown', 'Power Down'), Key('Suspend'), - Key('ContrastAdjust'), + Key('ContrastAdjust', '\u17c0\udd0d'), # FIXME - Key('LaunchG'), - Key('LaunchH'), + Key('LaunchG', '\u17c0\udd0e'), # FIXME + Key('LaunchH', '\u17c0\udd0f'), # FIXME - Key('TouchpadToggle'), - Key('TouchpadOn'), - Key('TouchpadOff'), + Key('TouchpadToggle', 'Touchpad Toggle'), + Key('TouchpadOn', 'Touchpad On'), + Key('TouchpadOff', 'Touchpad Off'), - Key('MicMute'), + Key('MicMute', 'Microphone Mute'), Key('Red'), Key('Green'), Key('Yellow'), Key('Blue'), - Key('ChannelUp'), - Key('ChannelDown'), + Key('ChannelUp', 'Channel Up'), + Key('ChannelDown', 'Channel Down'), Key('Guide'), Key('Info'), Key('Settings'), - Key('MicVolumeUp'), - Key('MicVolumeDown'), + Key('MicVolumeUp', 'Microphone Volume Up'), + Key('MicVolumeDown', 'Microphone Volume Down'), Key('New'), Key('Open'), @@ -528,7 +530,7 @@ KEYS = [ Key('Undo'), Key('Redo'), - Key('MediaLast'), + Key('MediaLast', '\u17ff\udfff'), # FIXME ### Keypad navigation keys Key('Select'), @@ -555,12 +557,12 @@ KEYS = [ Key('Call'), # set absolute state to in a call (do not toggle state) Key('Hangup'), # set absolute state to hang up (do not toggle state) Key('Flip'), - Key('ToggleCallHangup'), # a toggle key for answering, or hanging up, based on current call state - Key('VoiceDial'), - Key('LastNumberRedial'), + Key('ToggleCallHangup', 'Toggle Call/Hangup'), # a toggle key for answering, or hanging up, based on current call state + Key('VoiceDial', 'Voice Dial'), + Key('LastNumberRedial', 'Last Number Redial'), - Key('Camera'), - Key('CameraFocus'), + Key('Camera', 'Camera Shutter'), + Key('CameraFocus', 'Camera Focus'), - Key('unknown'), + Key('unknown', ''), # FIXME ] From 601e56d2fa341b9cdb5f9a459fc14b5839c7876d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 09:51:19 +0100 Subject: [PATCH 350/524] Make test_keyutils work --- qutebrowser/keyinput/keyutils.py | 1 + tests/unit/keyinput/key_data.py | 38 +++++++++++++--------------- tests/unit/keyinput/test_keyutils.py | 14 +++++----- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 2cf151348..c3c5baf40 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -39,6 +39,7 @@ def _key_to_string(key): Return: A name of the key as a string. """ + return QKeySequence(key).toString() # FIXME special_names_str = { # Some keys handled in a weird way by QKeySequence::toString. # See https://bugreports.qt.io/browse/QTBUG-40030 diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py index 430885ebe..071fc8f43 100644 --- a/tests/unit/keyinput/key_data.py +++ b/tests/unit/keyinput/key_data.py @@ -120,7 +120,7 @@ KEYS = [ Key('Ampersand', '&'), Key('Apostrophe', "'"), Key('ParenLeft', '('), - Key('ParenRight', '),') + Key('ParenRight', ')'), Key('Asterisk', '*'), Key('Plus', '+'), Key('Comma', ','), @@ -144,7 +144,6 @@ KEYS = [ Key('Greater', '>'), Key('Question', '?'), Key('At', '@'), - Key('At'), Key('A'), Key('B'), Key('C'), @@ -260,10 +259,9 @@ KEYS = [ Key('SingleCandidate', '\u17c4\udd3c'), # FIXME Key('MultipleCandidate', 'Multiple Candidate'), Key('PreviousCandidate', 'Previous Candidate'), - Key('Mode_switch', '\u17c4\udd7e'), # FIXME ### Misc Functions - Key('Mode_switch'), # Character set switch + Key('Mode_switch', '\u17c4\udd7e'), # FIXME Character set switch # Key('script_switch'), # Alias for mode_switch ### Japanese keyboard support @@ -391,22 +389,22 @@ KEYS = [ Key('OpenUrl', 'Open URL'), Key('LaunchMail', 'Launch Mail'), Key('LaunchMedia', 'Launch Media'), - Key('Launch0', 'Launch (0),') - Key('Launch1', 'Launch (1),') - Key('Launch2', 'Launch (2),') - Key('Launch3', 'Launch (3),') - Key('Launch4', 'Launch (4),') - Key('Launch5', 'Launch (5),') - Key('Launch6', 'Launch (6),') - Key('Launch7', 'Launch (7),') - Key('Launch8', 'Launch (8),') - Key('Launch9', 'Launch (9),') - Key('LaunchA', 'Launch (A),') - Key('LaunchB', 'Launch (B),') - Key('LaunchC', 'Launch (C),') - Key('LaunchD', 'Launch (D),') - Key('LaunchE', 'Launch (E),') - Key('LaunchF', 'Launch (F),') + Key('Launch0', 'Launch (0)'), + Key('Launch1', 'Launch (1)'), + Key('Launch2', 'Launch (2)'), + Key('Launch3', 'Launch (3)'), + Key('Launch4', 'Launch (4)'), + Key('Launch5', 'Launch (5)'), + Key('Launch6', 'Launch (6)'), + Key('Launch7', 'Launch (7)'), + Key('Launch8', 'Launch (8)'), + Key('Launch9', 'Launch (9)'), + Key('LaunchA', 'Launch (A)'), + Key('LaunchB', 'Launch (B)'), + Key('LaunchC', 'Launch (C)'), + Key('LaunchD', 'Launch (D)'), + Key('LaunchE', 'Launch (E)'), + Key('LaunchF', 'Launch (F)'), Key('MonBrightnessUp', 'Monitor Brightness Up'), Key('MonBrightnessDown', 'Monitor Brightness Down'), Key('KeyboardLightOnOff', 'Keyboard Light On/Off'), diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 3081b7558..e5b6268e5 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -25,19 +25,19 @@ from qutebrowser.utils import utils from qutebrowser.keyinput import keyutils -@pytest.fixture(params=sorted(list(key_data.KEYS.items()))) +@pytest.fixture(params=key_data.KEYS) def qt_key(request): - attr, key = request.param - member = getattr(Qt, 'Key_' + attr, None) + key = request.param + member = getattr(Qt, 'Key_' + key.attribute, None) if member is None: - pytest.skip("Did not find key {}".format(attr)) - + pytest.skip("Did not find key {}".format(key.attribute)) key.member = member return key def test_new_to_string(qt_key): - assert keyutils._key_to_string(qt_key.member) == qt_key.name + name = qt_key.attribute if qt_key.name is None else qt_key.name + assert keyutils._key_to_string(qt_key.member) == name class TestKeyToString: @@ -50,6 +50,7 @@ class TestKeyToString: (Qt.Key_degree, '°'), (Qt.Key_Meta, 'Meta'), ]) + @pytest.mark.skipif(True, reason='FIXME') def test_normal(self, key, expected): """Test a special key where QKeyEvent::toString works incorrectly.""" assert keyutils._key_to_string(key) == expected @@ -61,6 +62,7 @@ class TestKeyToString: # want to know if the mapping still behaves properly. assert keyutils._key_to_string(Qt.Key_A) == 'A' + @pytest.mark.skipif(True, reason='FIXME') def test_all(self): """Make sure there's some sensible output for all keys.""" for name, value in sorted(vars(Qt).items()): From 0b6d2c2b0a0b1101e4f84c024e4420c27fd2aee9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 10:21:02 +0100 Subject: [PATCH 351/524] Make all key names work --- qutebrowser/keyinput/keyutils.py | 127 ++++++++++++--------------- tests/unit/keyinput/key_data.py | 88 +++++++++---------- tests/unit/keyinput/test_keyutils.py | 37 +++----- 3 files changed, 110 insertions(+), 142 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index c3c5baf40..5280c8a9b 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -39,69 +39,63 @@ def _key_to_string(key): Return: A name of the key as a string. """ - return QKeySequence(key).toString() # FIXME special_names_str = { # Some keys handled in a weird way by QKeySequence::toString. # See https://bugreports.qt.io/browse/QTBUG-40030 # Most are unlikely to be ever needed, but you never know ;) # For dead/combining keys, we return the corresponding non-combining # key, as that's easier to add to the config. - 'Key_Blue': 'Blue', - 'Key_Calendar': 'Calendar', - 'Key_ChannelDown': 'Channel Down', - 'Key_ChannelUp': 'Channel Up', - 'Key_ContrastAdjust': 'Contrast Adjust', - 'Key_Dead_Abovedot': '˙', - 'Key_Dead_Abovering': '˚', - 'Key_Dead_Acute': '´', - 'Key_Dead_Belowdot': 'Belowdot', - 'Key_Dead_Breve': '˘', - 'Key_Dead_Caron': 'ˇ', - 'Key_Dead_Cedilla': '¸', - 'Key_Dead_Circumflex': '^', - 'Key_Dead_Diaeresis': '¨', - 'Key_Dead_Doubleacute': '˝', - 'Key_Dead_Grave': '`', - 'Key_Dead_Hook': 'Hook', - 'Key_Dead_Horn': 'Horn', - 'Key_Dead_Iota': 'Iota', - 'Key_Dead_Macron': '¯', - 'Key_Dead_Ogonek': '˛', - 'Key_Dead_Semivoiced_Sound': 'Semivoiced Sound', - 'Key_Dead_Tilde': '~', - 'Key_Dead_Voiced_Sound': 'Voiced Sound', - 'Key_Exit': 'Exit', - 'Key_Green': 'Green', - 'Key_Guide': 'Guide', - 'Key_Info': 'Info', - 'Key_LaunchG': 'LaunchG', - 'Key_LaunchH': 'LaunchH', - 'Key_MediaLast': 'MediaLast', - 'Key_Memo': 'Memo', - 'Key_MicMute': 'Mic Mute', - 'Key_Mode_switch': 'Mode switch', - 'Key_Multi_key': 'Multi key', - 'Key_PowerDown': 'Power Down', - 'Key_Red': 'Red', - 'Key_Settings': 'Settings', - 'Key_SingleCandidate': 'Single Candidate', - 'Key_ToDoList': 'Todo List', - 'Key_TouchpadOff': 'Touchpad Off', - 'Key_TouchpadOn': 'Touchpad On', - 'Key_TouchpadToggle': 'Touchpad toggle', - 'Key_Yellow': 'Yellow', - 'Key_Alt': 'Alt', - 'Key_AltGr': 'AltGr', - 'Key_Control': 'Control', - 'Key_Direction_L': 'Direction L', - 'Key_Direction_R': 'Direction R', - 'Key_Hyper_L': 'Hyper L', - 'Key_Hyper_R': 'Hyper R', - 'Key_Meta': 'Meta', - 'Key_Shift': 'Shift', - 'Key_Super_L': 'Super L', - 'Key_Super_R': 'Super R', - 'Key_unknown': 'Unknown', + + 'Super_L': 'Super L', + 'Super_R': 'Super R', + 'Hyper_L': 'Hyper L', + 'Hyper_R': 'Hyper R', + 'Direction_L': 'Direction L', + 'Direction_R': 'Direction R', + + 'Shift': 'Shift', + 'Control': 'Control', + 'Meta': 'Meta', + 'Alt': 'Alt', + + 'AltGr': 'AltGr', + 'Multi_key': 'Multi key', + 'SingleCandidate': 'Single Candidate', + 'Mode_switch': 'Mode switch', + 'Dead_Grave': '`', + 'Dead_Acute': '´', + 'Dead_Circumflex': '^', + 'Dead_Tilde': '~', + 'Dead_Macron': '¯', + 'Dead_Breve': '˘', + 'Dead_Abovedot': '˙', + 'Dead_Diaeresis': '¨', + 'Dead_Abovering': '˚', + 'Dead_Doubleacute': '˝', + 'Dead_Caron': 'ˇ', + 'Dead_Cedilla': '¸', + 'Dead_Ogonek': '˛', + 'Dead_Iota': 'Iota', + 'Dead_Voiced_Sound': 'Voiced Sound', + 'Dead_Semivoiced_Sound': 'Semivoiced Sound', + 'Dead_Belowdot': 'Belowdot', + 'Dead_Hook': 'Hook', + 'Dead_Horn': 'Horn', + + 'Memo': 'Memo', + 'ToDoList': 'To Do List', + 'Calendar': 'Calendar', + 'ContrastAdjust': 'Contrast Adjust', + 'LaunchG': 'Launch (G)', + 'LaunchH': 'Launch (H)', + + 'MediaLast': 'Media Last', + + 'unknown': 'Unknown', + + # For some keys, we just want a different name + 'Backtab': 'Tab', + 'Escape': 'Escape', } # We now build our real special_names dict from the string mapping above. # The reason we don't do this directly is that certain Qt versions don't @@ -109,23 +103,14 @@ def _key_to_string(key): special_names = {} for k, v in special_names_str.items(): try: - special_names[getattr(Qt, k)] = v + special_names[getattr(Qt, 'Key_' + k)] = v except AttributeError: pass - # Now we check if the key is any special one - if not, we use - # QKeySequence::toString. - try: + + if key in special_names: return special_names[key] - except KeyError: - name = QKeySequence(key).toString() - morphings = { - 'Backtab': 'Tab', - 'Esc': 'Escape', - } - if name in morphings: - return morphings[name] - else: - return name + + return QKeySequence(key).toString() class KeyParseError(Exception): diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py index 071fc8f43..2e9dda46a 100644 --- a/tests/unit/keyinput/key_data.py +++ b/tests/unit/keyinput/key_data.py @@ -34,9 +34,9 @@ class Key: # From enum Key in qt5/qtbase/src/corelib/global/qnamespace.h KEYS = [ ### misc keys - Key('Escape', 'Esc'), + Key('Escape'), # qutebrowser has a different name from Qt Key('Tab'), - Key('Backtab'), + Key('Backtab', 'Tab'), # qutebrowser has a different name from Qt Key('Backspace'), Key('Return'), Key('Enter'), @@ -56,10 +56,10 @@ KEYS = [ Key('PageUp', 'PgUp'), Key('PageDown', 'PgDown'), ### modifiers - Key('Shift', '\u17c0\udc20'), # FIXME - Key('Control', '\u17c0\udc21'), # FIXME - Key('Meta', '\u17c0\udc22'), # FIXME - Key('Alt', '\u17c0\udc23'), # FIXME + Key('Shift'), + Key('Control'), + Key('Meta'), + Key('Alt'), Key('CapsLock'), Key('NumLock'), Key('ScrollLock'), @@ -101,17 +101,17 @@ KEYS = [ Key('F34'), Key('F35'), ### extra keys - Key('Super_L', '\u17c0\udc53'), # FIXME - Key('Super_R', '\u17c0\udc54'), # FIXME + Key('Super_L', 'Super L'), + Key('Super_R', 'Super R'), Key('Menu'), - Key('Hyper_L', '\u17c0\udc56'), # FIXME - Key('Hyper_R', '\u17c0\udc57'), # FIXME + Key('Hyper_L', 'Hyper L'), + Key('Hyper_R', 'Hyper R'), Key('Help'), - Key('Direction_L', '\u17c0\udc59'), # FIXME - Key('Direction_R', '\u17c0\udc60'), # FIXME + Key('Direction_L', 'Direction L'), + Key('Direction_R', 'Direction R'), ### 7 bit printable ASCII Key('Space'), - Key('Any', 'Space'), # FIXME + Key('Any', 'Space'), # Same value Key('Exclam', '!'), Key('QuoteDbl', '"'), Key('NumberSign', '#'), @@ -253,15 +253,15 @@ KEYS = [ ### you are writing your own input method ### International & multi-key character composition - Key('AltGr', '\u17c4\udd03'), # FIXME - Key('Multi_key', '\u17c4\udd20'), # FIXME Multi-key character compose + Key('AltGr'), + Key('Multi_key', 'Multi key'), # Multi-key character compose Key('Codeinput', 'Code input'), - Key('SingleCandidate', '\u17c4\udd3c'), # FIXME + Key('SingleCandidate', 'Single Candidate'), Key('MultipleCandidate', 'Multiple Candidate'), Key('PreviousCandidate', 'Previous Candidate'), ### Misc Functions - Key('Mode_switch', '\u17c4\udd7e'), # FIXME Character set switch + Key('Mode_switch', 'Mode switch'), # Character set switch # Key('script_switch'), # Alias for mode_switch ### Japanese keyboard support @@ -309,25 +309,25 @@ KEYS = [ # Key('Hangul_switch', 'Hangul switch'), # Alias for mode_switch # dead keys (X keycode - 0xED00 to avoid the conflict), - Key('Dead_Grave', '\u17c4\ude50'), # FIXME - Key('Dead_Acute', '\u17c4\ude51'), # FIXME - Key('Dead_Circumflex', '\u17c4\ude52'), # FIXME - Key('Dead_Tilde', '\u17c4\ude53'), # FIXME - Key('Dead_Macron', '\u17c4\ude54'), # FIXME - Key('Dead_Breve', '\u17c4\ude55'), # FIXME - Key('Dead_Abovedot', '\u17c4\ude56'), # FIXME - Key('Dead_Diaeresis', '\u17c4\ude57'), # FIXME - Key('Dead_Abovering', '\u17c4\ude58'), # FIXME - Key('Dead_Doubleacute', '\u17c4\ude59'), # FIXME - Key('Dead_Caron', '\u17c4\ude5a'), # FIXME - Key('Dead_Cedilla', '\u17c4\ude5b'), # FIXME - Key('Dead_Ogonek', '\u17c4\ude5c'), # FIXME - Key('Dead_Iota', '\u17c4\ude5d'), # FIXME - Key('Dead_Voiced_Sound', '\u17c4\ude5e'), # FIXME - Key('Dead_Semivoiced_Sound', '\u17c4\ude5f'), # FIXME - Key('Dead_Belowdot', '\u17c4\ude60'), # FIXME - Key('Dead_Hook', '\u17c4\ude61'), # FIXME - Key('Dead_Horn', '\u17c4\ude62'), # FIXME + Key('Dead_Grave', '`'), + Key('Dead_Acute', '´'), + Key('Dead_Circumflex', '^'), + Key('Dead_Tilde', '~'), + Key('Dead_Macron', '¯'), + Key('Dead_Breve', '˘'), + Key('Dead_Abovedot', '˙'), + Key('Dead_Diaeresis', '¨'), + Key('Dead_Abovering', '˚'), + Key('Dead_Doubleacute', '˝'), + Key('Dead_Caron', 'ˇ'), + Key('Dead_Cedilla', '¸'), + Key('Dead_Ogonek', '˛'), + Key('Dead_Iota', 'Iota'), + Key('Dead_Voiced_Sound', 'Voiced Sound'), + Key('Dead_Semivoiced_Sound', 'Semivoiced Sound'), + Key('Dead_Belowdot', 'Belowdot'), + Key('Dead_Hook', 'Hook'), + Key('Dead_Horn', 'Horn'), # Not in Qt 5.10, so data may be wrong! Key('Dead_Stroke'), @@ -415,7 +415,7 @@ KEYS = [ Key('Eject'), Key('ScreenSaver', 'Screensaver'), Key('WWW'), - Key('Memo', '\u17c0\udcbc'), # FIXME + Key('Memo', 'Memo'), Key('LightBulb'), Key('Shop'), Key('History'), @@ -431,7 +431,7 @@ KEYS = [ Key('Book'), Key('CD'), Key('Calculator'), - Key('ToDoList', '\u17c0\udccc'), # FIXME + Key('ToDoList', 'To Do List'), Key('ClearGrab', 'Clear Grab'), Key('Close'), Key('Copy'), @@ -455,7 +455,7 @@ KEYS = [ Key('Option'), Key('Paste'), Key('Phone'), - Key('Calendar', '\u17c0\udce4'), # FIXME + Key('Calendar'), Key('Reply'), Key('Reload'), Key('RotateWindows', 'Rotate Windows'), @@ -496,10 +496,10 @@ KEYS = [ Key('TopMenu', 'Top Menu'), Key('PowerDown', 'Power Down'), Key('Suspend'), - Key('ContrastAdjust', '\u17c0\udd0d'), # FIXME + Key('ContrastAdjust', 'Contrast Adjust'), - Key('LaunchG', '\u17c0\udd0e'), # FIXME - Key('LaunchH', '\u17c0\udd0f'), # FIXME + Key('LaunchG', 'Launch (G)'), + Key('LaunchH', 'Launch (H)'), Key('TouchpadToggle', 'Touchpad Toggle'), Key('TouchpadOn', 'Touchpad On'), @@ -528,7 +528,7 @@ KEYS = [ Key('Undo'), Key('Redo'), - Key('MediaLast', '\u17ff\udfff'), # FIXME + Key('MediaLast', 'Media Last'), ### Keypad navigation keys Key('Select'), @@ -562,5 +562,5 @@ KEYS = [ Key('Camera', 'Camera Shutter'), Key('CameraFocus', 'Camera Focus'), - Key('unknown', ''), # FIXME + Key('unknown', 'Unknown'), ] diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index e5b6268e5..1e8bdfb74 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -35,43 +35,26 @@ def qt_key(request): return key -def test_new_to_string(qt_key): - name = qt_key.attribute if qt_key.name is None else qt_key.name - assert keyutils._key_to_string(qt_key.member) == name - - class TestKeyToString: - @pytest.mark.parametrize('key, expected', [ - (Qt.Key_Blue, 'Blue'), - (Qt.Key_Backtab, 'Tab'), - (Qt.Key_Escape, 'Escape'), - (Qt.Key_A, 'A'), - (Qt.Key_degree, '°'), - (Qt.Key_Meta, 'Meta'), - ]) - @pytest.mark.skipif(True, reason='FIXME') - def test_normal(self, key, expected): - """Test a special key where QKeyEvent::toString works incorrectly.""" - assert keyutils._key_to_string(key) == expected + def test_to_string(self, qt_key): + name = qt_key.attribute if qt_key.name is None else qt_key.name + assert keyutils._key_to_string(qt_key.member) == name def test_missing(self, monkeypatch): - """Test with a missing key.""" monkeypatch.delattr(keyutils.Qt, 'Key_Blue') # We don't want to test the key which is actually missing - we only # want to know if the mapping still behaves properly. assert keyutils._key_to_string(Qt.Key_A) == 'A' - @pytest.mark.skipif(True, reason='FIXME') def test_all(self): - """Make sure there's some sensible output for all keys.""" - for name, value in sorted(vars(Qt).items()): - if not isinstance(value, Qt.Key): - continue - print(name) - string = keyutils._key_to_string(value) - assert string - string.encode('utf-8') # make sure it's encodable + """Make sure all possible keys are in key_data.KEYS.""" + key_names = {name[len("Key_"):] + for name, value in sorted(vars(Qt).items()) + if isinstance(value, Qt.Key)} + key_data_names = {key.attribute for key in sorted(key_data.KEYS)} + diff = key_names - key_data_names + assert not diff class TestKeyEventToString: From 8c87040cb655239e83c3e706daf402f21a2c4eb2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 10:41:09 +0100 Subject: [PATCH 352/524] Improve IDs for qt_key fixture --- tests/unit/keyinput/test_keyutils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 1e8bdfb74..23354935c 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -25,7 +25,7 @@ from qutebrowser.utils import utils from qutebrowser.keyinput import keyutils -@pytest.fixture(params=key_data.KEYS) +@pytest.fixture(params=key_data.KEYS, ids=lambda k: k.attribute) def qt_key(request): key = request.param member = getattr(Qt, 'Key_' + key.attribute, None) From 2ca15d7667de0ef519deff16e0e24adcd6937738 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 10:59:01 +0100 Subject: [PATCH 353/524] Add tests for lower-/uppercase text --- tests/unit/keyinput/key_data.py | 273 ++++++++++++++------------- tests/unit/keyinput/test_keyutils.py | 8 + 2 files changed, 145 insertions(+), 136 deletions(-) diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py index 2e9dda46a..d9111d9aa 100644 --- a/tests/unit/keyinput/key_data.py +++ b/tests/unit/keyinput/key_data.py @@ -28,6 +28,7 @@ class Key: attribute = attr.ib() name = attr.ib(None) # default: name == attribute text = attr.ib('') + uppertext = attr.ib('') member = attr.ib(None) @@ -110,143 +111,143 @@ KEYS = [ Key('Direction_L', 'Direction L'), Key('Direction_R', 'Direction R'), ### 7 bit printable ASCII - Key('Space'), - Key('Any', 'Space'), # Same value - Key('Exclam', '!'), - Key('QuoteDbl', '"'), - Key('NumberSign', '#'), - Key('Dollar', '$'), - Key('Percent', '%'), - Key('Ampersand', '&'), - Key('Apostrophe', "'"), - Key('ParenLeft', '('), - Key('ParenRight', ')'), - Key('Asterisk', '*'), - Key('Plus', '+'), - Key('Comma', ','), - Key('Minus', '-'), - Key('Period', '.'), - Key('Slash', '/'), - Key('0'), - Key('1'), - Key('2'), - Key('3'), - Key('4'), - Key('5'), - Key('6'), - Key('7'), - Key('8'), - Key('9'), - Key('Colon', ':'), - Key('Semicolon', ';'), - Key('Less', '<'), - Key('Equal', '='), - Key('Greater', '>'), - Key('Question', '?'), - Key('At', '@'), - Key('A'), - Key('B'), - Key('C'), - Key('D'), - Key('E'), - Key('F'), - Key('G'), - Key('H'), - Key('I'), - Key('J'), - Key('K'), - Key('L'), - Key('M'), - Key('N'), - Key('O'), - Key('P'), - Key('Q'), - Key('R'), - Key('S'), - Key('T'), - Key('U'), - Key('V'), - Key('W'), - Key('X'), - Key('Y'), - Key('Z'), - Key('BracketLeft', '['), - Key('Backslash', '\\'), - Key('BracketRight', ']'), - Key('AsciiCircum', '^'), - Key('Underscore', '_'), - Key('QuoteLeft', '`'), - Key('BraceLeft', '{'), - Key('Bar', '|'), - Key('BraceRight', '}'), - Key('AsciiTilde', '~'), + Key('Space', text=' ', uppertext=' '), + Key('Any', 'Space', text=' ', uppertext=' '), # Same value + Key('Exclam', '!', text='!', uppertext='!'), + Key('QuoteDbl', '"', text='"', uppertext='"'), + Key('NumberSign', '#', text='#', uppertext='#'), + Key('Dollar', '$', text='$', uppertext='$'), + Key('Percent', '%', text='%', uppertext='%'), + Key('Ampersand', '&', text='&', uppertext='&'), + Key('Apostrophe', "'", text="'", uppertext="'"), + Key('ParenLeft', '(', text='(', uppertext='('), + Key('ParenRight', ')', text=')', uppertext=')'), + Key('Asterisk', '*', text='*', uppertext='*'), + Key('Plus', '+', text='+', uppertext='+'), + Key('Comma', ',', text=',', uppertext=','), + Key('Minus', '-', text='-', uppertext='-'), + Key('Period', '.', text='.', uppertext='.'), + Key('Slash', '/', text='/', uppertext='/'), + Key('0', text='0', uppertext='0'), + Key('1', text='1', uppertext='1'), + Key('2', text='2', uppertext='2'), + Key('3', text='3', uppertext='3'), + Key('4', text='4', uppertext='4'), + Key('5', text='5', uppertext='5'), + Key('6', text='6', uppertext='6'), + Key('7', text='7', uppertext='7'), + Key('8', text='8', uppertext='8'), + Key('9', text='9', uppertext='9'), + Key('Colon', ':', text=':', uppertext=':'), + Key('Semicolon', ';', text=';', uppertext=';'), + Key('Less', '<', text='<', uppertext='<'), + Key('Equal', '=', text='=', uppertext='='), + Key('Greater', '>', text='>', uppertext='>'), + Key('Question', '?', text='?', uppertext='?'), + Key('At', '@', text='@', uppertext='@'), + Key('A', text='a', uppertext='A'), + Key('B', text='b', uppertext='B'), + Key('C', text='c', uppertext='C'), + Key('D', text='d', uppertext='D'), + Key('E', text='e', uppertext='E'), + Key('F', text='f', uppertext='F'), + Key('G', text='g', uppertext='G'), + Key('H', text='h', uppertext='H'), + Key('I', text='i', uppertext='I'), + Key('J', text='j', uppertext='J'), + Key('K', text='k', uppertext='K'), + Key('L', text='l', uppertext='L'), + Key('M', text='m', uppertext='M'), + Key('N', text='n', uppertext='N'), + Key('O', text='o', uppertext='O'), + Key('P', text='p', uppertext='P'), + Key('Q', text='q', uppertext='Q'), + Key('R', text='r', uppertext='R'), + Key('S', text='s', uppertext='S'), + Key('T', text='t', uppertext='T'), + Key('U', text='u', uppertext='U'), + Key('V', text='v', uppertext='V'), + Key('W', text='w', uppertext='W'), + Key('X', text='x', uppertext='X'), + Key('Y', text='y', uppertext='Y'), + Key('Z', text='z', uppertext='Z'), + Key('BracketLeft', '[', text='[', uppertext='['), + Key('Backslash', '\\', text='\\', uppertext='\\'), + Key('BracketRight', ']', text=']', uppertext=']'), + Key('AsciiCircum', '^', text='^', uppertext='^'), + Key('Underscore', '_', text='_', uppertext='_'), + Key('QuoteLeft', '`', text='`', uppertext='`'), + Key('BraceLeft', '{', text='{', uppertext='{'), + Key('Bar', '|', text='|', uppertext='|'), + Key('BraceRight', '}', text='}', uppertext='}'), + Key('AsciiTilde', '~', text='~', uppertext='~'), - Key('nobreakspace', ' '), - Key('exclamdown', '¡'), - Key('cent', '¢'), - Key('sterling', '£'), - Key('currency', '¤'), - Key('yen', '¥'), - Key('brokenbar', '¦'), - Key('section', '§'), - Key('diaeresis', '¨'), - Key('copyright', '©'), - Key('ordfeminine', 'ª'), - Key('guillemotleft', '«'), - Key('notsign', '¬'), - Key('hyphen', '­'), - Key('registered', '®'), - Key('macron', '¯'), - Key('degree', '°'), - Key('plusminus', '±'), - Key('twosuperior', '²'), - Key('threesuperior', '³'), - Key('acute', '´'), - Key('mu', 'Μ'), - Key('paragraph', '¶'), - Key('periodcentered', '·'), - Key('cedilla', '¸'), - Key('onesuperior', '¹'), - Key('masculine', 'º'), - Key('guillemotright', '»'), - Key('onequarter', '¼'), - Key('onehalf', '½'), - Key('threequarters', '¾'), - Key('questiondown', '¿'), - Key('Agrave', 'À'), - Key('Aacute', 'Á'), - Key('Acircumflex', 'Â'), - Key('Atilde', 'Ã'), - Key('Adiaeresis', 'Ä'), - Key('Aring', 'Å'), - Key('AE', 'Æ'), - Key('Ccedilla', 'Ç'), - Key('Egrave', 'È'), - Key('Eacute', 'É'), - Key('Ecircumflex', 'Ê'), - Key('Ediaeresis', 'Ë'), - Key('Igrave', 'Ì'), - Key('Iacute', 'Í'), - Key('Icircumflex', 'Î'), - Key('Idiaeresis', 'Ï'), - Key('ETH', 'Ð'), - Key('Ntilde', 'Ñ'), - Key('Ograve', 'Ò'), - Key('Oacute', 'Ó'), - Key('Ocircumflex', 'Ô'), - Key('Otilde', 'Õ'), - Key('Odiaeresis', 'Ö'), - Key('multiply', '×'), - Key('Ooblique', 'Ø'), - Key('Ugrave', 'Ù'), - Key('Uacute', 'Ú'), - Key('Ucircumflex', 'Û'), - Key('Udiaeresis', 'Ü'), - Key('Yacute', 'Ý'), - Key('THORN', 'Þ'), - Key('ssharp', 'ß'), - Key('division', '÷'), - Key('ydiaeresis', 'Ÿ'), + Key('nobreakspace', ' ', text=' ', uppertext=' '), + Key('exclamdown', '¡', text='¡', uppertext='¡'), + Key('cent', '¢', text='¢', uppertext='¢'), + Key('sterling', '£', text='£', uppertext='£'), + Key('currency', '¤', text='¤', uppertext='¤'), + Key('yen', '¥', text='¥', uppertext='¥'), + Key('brokenbar', '¦', text='¦', uppertext='¦'), + Key('section', '§', text='§', uppertext='§'), + Key('diaeresis', '¨', text='¨', uppertext='¨'), + Key('copyright', '©', text='©', uppertext='©'), + Key('ordfeminine', 'ª', text='ª', uppertext='ª'), + Key('guillemotleft', '«', text='«', uppertext='«'), + Key('notsign', '¬', text='¬', uppertext='¬'), + Key('hyphen', '­', text='­', uppertext='­'), + Key('registered', '®', text='®', uppertext='®'), + Key('macron', '¯', text='¯', uppertext='¯'), + Key('degree', '°', text='°', uppertext='°'), + Key('plusminus', '±', text='±', uppertext='±'), + Key('twosuperior', '²', text='²', uppertext='²'), + Key('threesuperior', '³', text='³', uppertext='³'), + Key('acute', '´', text='´', uppertext='´'), + Key('mu', 'Μ', text='μ', uppertext='Μ'), + Key('paragraph', '¶', text='¶', uppertext='¶'), + Key('periodcentered', '·', text='·', uppertext='·'), + Key('cedilla', '¸', text='¸', uppertext='¸'), + Key('onesuperior', '¹', text='¹', uppertext='¹'), + Key('masculine', 'º', text='º', uppertext='º'), + Key('guillemotright', '»', text='»', uppertext='»'), + Key('onequarter', '¼', text='¼', uppertext='¼'), + Key('onehalf', '½', text='½', uppertext='½'), + Key('threequarters', '¾', text='¾', uppertext='¾'), + Key('questiondown', '¿', text='¿', uppertext='¿'), + Key('Agrave', 'À', text='à', uppertext='À'), + Key('Aacute', 'Á', text='á', uppertext='Á'), + Key('Acircumflex', 'Â', text='â', uppertext='Â'), + Key('Atilde', 'Ã', text='ã', uppertext='Ã'), + Key('Adiaeresis', 'Ä', text='ä', uppertext='Ä'), + Key('Aring', 'Å', text='å', uppertext='Å'), + Key('AE', 'Æ', text='æ', uppertext='Æ'), + Key('Ccedilla', 'Ç', text='ç', uppertext='Ç'), + Key('Egrave', 'È', text='è', uppertext='È'), + Key('Eacute', 'É', text='é', uppertext='É'), + Key('Ecircumflex', 'Ê', text='ê', uppertext='Ê'), + Key('Ediaeresis', 'Ë', text='ë', uppertext='Ë'), + Key('Igrave', 'Ì', text='ì', uppertext='Ì'), + Key('Iacute', 'Í', text='í', uppertext='Í'), + Key('Icircumflex', 'Î', text='î', uppertext='Î'), + Key('Idiaeresis', 'Ï', text='ï', uppertext='Ï'), + Key('ETH', 'Ð', text='ð', uppertext='Ð'), + Key('Ntilde', 'Ñ', text='ñ', uppertext='Ñ'), + Key('Ograve', 'Ò', text='ò', uppertext='Ò'), + Key('Oacute', 'Ó', text='ó', uppertext='Ó'), + Key('Ocircumflex', 'Ô', text='ô', uppertext='Ô'), + Key('Otilde', 'Õ', text='õ', uppertext='Õ'), + Key('Odiaeresis', 'Ö', text='ö', uppertext='Ö'), + Key('multiply', '×', text='×', uppertext='×'), + Key('Ooblique', 'Ø', text='ø', uppertext='Ø'), + Key('Ugrave', 'Ù', text='ù', uppertext='Ù'), + Key('Uacute', 'Ú', text='ú', uppertext='Ú'), + Key('Ucircumflex', 'Û', text='û', uppertext='Û'), + Key('Udiaeresis', 'Ü', text='ü', uppertext='Ü'), + Key('Yacute', 'Ý', text='ý', uppertext='Ý'), + Key('THORN', 'Þ', text='þ', uppertext='Þ'), + Key('ssharp', 'ß', text='ß', uppertext='ß'), + Key('division', '÷', text='÷', uppertext='÷'), + Key('ydiaeresis', 'Ÿ', text='ÿ', uppertext='Ÿ'), ### International input method support (X keycode - 0xEE00, the ### definition follows Qt/Embedded 2.3.7) Only interesting if diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 23354935c..b714cf77b 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -35,6 +35,14 @@ def qt_key(request): return key +@pytest.mark.parametrize('upper', [False, True]) +def test_key_text(qt_key, upper): + modifiers = Qt.ShiftModifier if upper else Qt.KeyboardModifiers() + info = keyutils.KeyInfo(qt_key.member, modifiers=modifiers) + expected = qt_key.uppertext if upper else qt_key.text + assert info.text() == expected + + class TestKeyToString: def test_to_string(self, qt_key): From b4d232badd7394875b78f61d31450fdeaee8d33b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 11:15:44 +0100 Subject: [PATCH 354/524] Simplify KeyInfo.text() --- qutebrowser/keyinput/keyutils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 5280c8a9b..7f0e669a6 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -252,13 +252,13 @@ class KeyInfo: def text(self): """Get the text which would be displayed when pressing this key.""" - text = QKeySequence(self.key).toString() if self.key == Qt.Key_Space: return ' ' - elif len(text) > 1: - # Special key? + elif self.key > 0xff: + # Unprintable keys return '' + text = QKeySequence(self.key).toString() if not self.modifiers & Qt.ShiftModifier: text = text.lower() return text From 1cd86d79d9e499c0fbbe6262c293c1d78e34ee0e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 11:33:19 +0100 Subject: [PATCH 355/524] Add keyutils.is_printable() --- qutebrowser/keyinput/keyutils.py | 11 +++++++---- qutebrowser/keyinput/modeparsers.py | 7 +++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 7f0e669a6..1bd9ee737 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -30,6 +30,10 @@ from PyQt5.QtGui import QKeySequence, QKeyEvent from qutebrowser.utils import utils +def is_printable(key): + return key <= 0xff + + def _key_to_string(key): """Convert a Qt::Key member to a meaningful name. @@ -227,7 +231,7 @@ class KeyInfo: key_string = _key_to_string(self.key) - if len(key_string) == 1: + if is_printable(self.key) and self.key != Qt.Key_Space: category = unicodedata.category(key_string) is_special_char = (category == 'Cc') else: @@ -254,8 +258,7 @@ class KeyInfo: """Get the text which would be displayed when pressing this key.""" if self.key == Qt.Key_Space: return ' ' - elif self.key > 0xff: - # Unprintable keys + elif not is_printable(self.key): return '' text = QKeySequence(self.key).toString() @@ -386,7 +389,7 @@ class KeySequence: modifiers = ev.modifiers() if (modifiers == Qt.ShiftModifier and - len(ev.text()) == 1 and + is_printable(ev.key()) and unicodedata.category(ev.text()) != 'Lu'): modifiers = Qt.KeyboardModifiers() diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 42eeb53f8..d56d7dcd1 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -280,13 +280,12 @@ class RegisterKeyParser(keyparser.CommandKeyParser): if match: return match - key = e.text() - - if key == '' or not str(keyutils.KeyInfo.from_event(e)): + if not keyutils.is_printable(e.key()): # this is not a proper register key, let it pass and keep going - # FIXME can we simplify this when we refactor keyutils.py? return QKeySequence.NoMatch + key = e.text() + tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) macro_recorder = objreg.get('macro-recorder') From e26eaaddc2c2de006c9bc9108cac512f9f2df65d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 12:52:19 +0100 Subject: [PATCH 356/524] Add keyutils.is_modifier_key() --- qutebrowser/keyinput/basekeyparser.py | 14 ++------------ qutebrowser/keyinput/keyutils.py | 16 ++++++++++------ 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 27f671dcd..2c934617b 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -128,20 +128,10 @@ class BaseKeyParser(QObject): txt = str(keyutils.KeyInfo.from_event(e)) self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) - if not txt: - self._debug_log("Ignoring, no text char") + if keyutils.is_modifier_key(key): + self._debug_log("Ignoring, only modifier") return QKeySequence.NoMatch - # if len(txt) == 1: - # category = unicodedata.category(txt) - # is_control_char = (category == 'Cc') - # else: - # is_control_char = False - - # if (not txt) or is_control_char: - # self._debug_log("Ignoring, no text char") - # return QKeySequence.NoMatch - if (txt.isdigit() and self._supports_count and not (not self._count and txt == '0')): assert len(txt) == 1, txt diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 1bd9ee737..b1d1cec45 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -34,6 +34,14 @@ def is_printable(key): return key <= 0xff +def is_modifier_key(key): + # FIXME docs + return key in (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, + Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, + Qt.Key_Hyper_L, Qt.Key_Hyper_R, Qt.Key_Direction_L, + Qt.Key_Direction_R) + + def _key_to_string(key): """Convert a Qt::Key member to a meaningful name. @@ -216,13 +224,9 @@ class KeyInfo: (Qt.ShiftModifier, 'Shift'), ]) - modifier_keys = (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, - Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, - Qt.Key_Hyper_L, Qt.Key_Hyper_R, Qt.Key_Direction_L, - Qt.Key_Direction_R) - if self.key in modifier_keys: - # Only modifier pressed + if is_modifier_key(self.key): return '' + parts = [] for (mask, s) in modmask2str.items(): From 63e05e12bad22f27a7182759a0fdfdcbd1c4435f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 12:57:25 +0100 Subject: [PATCH 357/524] Fix lint and tests --- tests/unit/keyinput/key_data.py | 20 +++++++++++++++++--- tests/unit/keyinput/test_basekeyparser.py | 11 +++-------- tests/unit/keyinput/test_keyutils.py | 4 ++-- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py index d9111d9aa..f967389b1 100644 --- a/tests/unit/keyinput/key_data.py +++ b/tests/unit/keyinput/key_data.py @@ -17,14 +17,28 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -import attr +# pylint: disable=line-too-long -from PyQt5.QtCore import Qt + +"""Data used by test_keyutils.py to test all keys.""" + + +import attr @attr.s class Key: + """A key with expected values. + + Attributes: + attribute: The name of the Qt::Key attribute ('Foo' -> Qt.Key_Foo) + name: The name returned by str(KeyInfo) with that key. + text: The text returned by KeyInfo.text(). + uppertext: The text returned by KeyInfo.text() with shift. + member: Filled by the test fixture, the numeric value. + """ + attribute = attr.ib() name = attr.ib(None) # default: name == attribute text = attr.ib('') @@ -289,7 +303,7 @@ KEYS = [ ### Korean keyboard support ### - ### In fact, many Korean users need only 2 keys, Key_Hangul and + ### In fact, many users from Korea need only 2 keys, Key_Hangul and ### Key_Hangul_Hanja. But rest of the keys are good for future. Key('Hangul'), # Hangul start/stop(toggle), diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index a32009390..5da4efa9b 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -165,15 +165,10 @@ class TestSpecialKeys: Qt.Key_A, (Qt.ControlModifier | Qt.AltModifier))) assert not keyparser.execute.called - @pytest.mark.skip(reason='unneeded?') - def test_keychain(self, fake_keyevent_factory, keyparser): - keyparser.handle(fake_keyevent_factory(Qt.Key_B)) - keyparser.handle(fake_keyevent_factory(Qt.Key_A)) - assert not keyparser.execute.called - - def test_no_binding(self, monkeypatch, fake_keyevent_factory, keyparser): + def test_only_modifiers(self, monkeypatch, fake_keyevent_factory, + keyparser): monkeypatch.setattr(keyutils.KeyInfo, '__str__', lambda _self: '') - keyparser.handle(fake_keyevent_factory(Qt.Key_A, Qt.NoModifier)) + keyparser.handle(fake_keyevent_factory(Qt.Key_Shift, Qt.NoModifier)) assert not keyparser.execute.called def test_mapping(self, config_stub, fake_keyevent_factory, keyparser): diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index b714cf77b..94b18eb57 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -118,8 +118,8 @@ class TestKeyEventToString: ('xyz', keyutils.KeySequence(Qt.Key_X, Qt.Key_Y, Qt.Key_Z)), ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X, Qt.MetaModifier | Qt.Key_Y)), - ('', keyutils.KeyParseError), - ('\U00010000', keyutils.KeyParseError), + ('', keyutils.KeyParseError), + ('\U00010000', keyutils.KeyParseError), ]) def test_parse(keystr, expected): if expected is keyutils.KeyParseError: From 0aa17bfa332bd563ea7cabefbc69aef8e5688e9a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 13:56:49 +0100 Subject: [PATCH 358/524] Simplify unicodedata.category calls --- qutebrowser/keyinput/keyutils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index b1d1cec45..dc04fcd7f 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -236,8 +236,7 @@ class KeyInfo: key_string = _key_to_string(self.key) if is_printable(self.key) and self.key != Qt.Key_Space: - category = unicodedata.category(key_string) - is_special_char = (category == 'Cc') + is_special_char = unicodedata.category(key_string) == 'Cc' else: is_special_char = False @@ -394,7 +393,7 @@ class KeySequence: if (modifiers == Qt.ShiftModifier and is_printable(ev.key()) and - unicodedata.category(ev.text()) != 'Lu'): + not ev.text().isupper()): modifiers = Qt.KeyboardModifiers() keys = list(self._iter_keys()) From 4e505d52dfe7e06d340dcd6b0585b279af9d9244 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 15:28:04 +0100 Subject: [PATCH 359/524] Regenerate docs --- doc/help/commands.asciidoc | 5 ++++- doc/help/settings.asciidoc | 15 +++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 58dfaaa16..2174e69ac 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -1497,13 +1497,16 @@ Drop selection and keep selection mode enabled. [[follow-hint]] === follow-hint -Syntax: +:follow-hint ['keystring']+ +Syntax: +:follow-hint [*--select*] ['keystring']+ Follow a hint. ==== positional arguments * +'keystring'+: The hint to follow. +==== optional arguments +* +*-s*+, +*--select*+: Only select the given hint, don't necessarily follow it. + [[leave-mode]] === leave-mode Leave the mode we're currently in. diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index a9e7becd2..fc809b98e 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -358,11 +358,8 @@ The following modes are available: * prompt: Entered when there's a prompt to display, like for download locations or when invoked from JavaScript. - + - You can bind normal keys in this mode, but they will be only active when - a yes/no-prompt is asked. For other prompt modes, you can only bind - special keys. +* yesno: Entered when there's a yes/no prompt displayed. * caret: Entered when pressing the `v` mode, used to select text using the keyboard. @@ -642,11 +639,17 @@ Default: * +pass:[<Shift-Tab>]+: +pass:[prompt-item-focus prev]+ * +pass:[<Tab>]+: +pass:[prompt-item-focus next]+ * +pass:[<Up>]+: +pass:[prompt-item-focus prev]+ -* +pass:[n]+: +pass:[prompt-accept no]+ -* +pass:[y]+: +pass:[prompt-accept yes]+ - +pass:[register]+: * +pass:[<Escape>]+: +pass:[leave-mode]+ +- +pass:[yesno]+: + +* +pass:[<Alt-Shift-Y>]+: +pass:[prompt-yank --sel]+ +* +pass:[<Alt-Y>]+: +pass:[prompt-yank]+ +* +pass:[<Escape>]+: +pass:[leave-mode]+ +* +pass:[<Return>]+: +pass:[prompt-accept]+ +* +pass:[n]+: +pass:[prompt-accept no]+ +* +pass:[y]+: +pass:[prompt-accept yes]+ [[bindings.key_mappings]] === bindings.key_mappings From 65a05f334e008caa567b07fc80ec48ccd51be7b0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 20:33:23 +0100 Subject: [PATCH 360/524] Fix KeyInfo.__str__ for --- qutebrowser/keyinput/keyutils.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index dc04fcd7f..b9dc47fa4 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -19,7 +19,6 @@ """Our own QKeySequence-like class and related utilities.""" -import unicodedata import collections import itertools @@ -235,12 +234,9 @@ class KeyInfo: key_string = _key_to_string(self.key) - if is_printable(self.key) and self.key != Qt.Key_Space: - is_special_char = unicodedata.category(key_string) == 'Cc' - else: - is_special_char = False - - if not is_special_char: + if is_printable(self.key): + # FIXME Add a test to make sure Tab doesn;t become TAB + assert len(key_string) == 1 or self.key == Qt.Key_Space, key_string if self.modifiers == Qt.ShiftModifier: parts = [] key_string = key_string.upper() From 934d5862861e7cb9765647cc47445c2a0e5ad3d0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 28 Feb 2018 20:46:52 +0100 Subject: [PATCH 361/524] Fix handling of Shift-Tab aka. Backtab --- qutebrowser/keyinput/keyutils.py | 10 ++++++++-- tests/unit/keyinput/key_data.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index b9dc47fa4..1feb371a8 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -105,7 +105,6 @@ def _key_to_string(key): 'unknown': 'Unknown', # For some keys, we just want a different name - 'Backtab': 'Tab', 'Escape': 'Escape', } # We now build our real special_names dict from the string mapping above. @@ -384,16 +383,23 @@ class KeySequence: In addition, Shift also *is* relevant when other modifiers are involved. Shift-Ctrl-X should not be equivalent to Ctrl-X. + + We also change Qt.Key_Backtab to Key_Tab here because nobody would + configure "Shift-Backtab" in their config. """ + key = ev.key() modifiers = ev.modifiers() + if modifiers & Qt.ShiftModifier and key == Qt.Key_Backtab: + key = Qt.Key_Tab + if (modifiers == Qt.ShiftModifier and is_printable(ev.key()) and not ev.text().isupper()): modifiers = Qt.KeyboardModifiers() keys = list(self._iter_keys()) - keys.append(ev.key() | int(modifiers)) + keys.append(key | int(modifiers)) return self.__class__(*keys) diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py index f967389b1..73a978fd2 100644 --- a/tests/unit/keyinput/key_data.py +++ b/tests/unit/keyinput/key_data.py @@ -51,7 +51,7 @@ KEYS = [ ### misc keys Key('Escape'), # qutebrowser has a different name from Qt Key('Tab'), - Key('Backtab', 'Tab'), # qutebrowser has a different name from Qt + Key('Backtab'), Key('Backspace'), Key('Return'), Key('Enter'), From d28c323074ffcfbdf011399fe1d24f59c1522ca1 Mon Sep 17 00:00:00 2001 From: Fritz Reichwald Date: Thu, 1 Mar 2018 00:34:33 +0100 Subject: [PATCH 362/524] Add printable and ismodifier test --- tests/unit/keyinput/test_keyutils.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 94b18eb57..df93d268c 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -142,3 +142,19 @@ def test_parse(keystr, expected): def test_normalize_keystr(orig, normalized): expected = keyutils.KeySequence.parse(normalized) assert keyutils.KeySequence.parse(orig) == expected + + +@pytest.mark.parametrize('key, printable', [ + (Qt.Key_Control, False), + (Qt.Key_X, True) +]) +def test_is_printable(key, printable): + assert keyutils.is_printable(key) == printable + + +@pytest.mark.parametrize('key, ismodifier', [ + (Qt.Key_Control, True), + (Qt.Key_X, False) +]) +def test_is_modifier_key(key, ismodifier): + assert keyutils.is_modifier_key(key) == ismodifier From 4223e2f85d9d753c6c330ce742ffc494b8681b44 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 1 Mar 2018 08:01:23 +0100 Subject: [PATCH 363/524] Check all keys against QTest::keyToAscii --- tests/unit/keyinput/key_data.py | 487 ++++++++++++++------------- tests/unit/keyinput/test_keyutils.py | 30 +- 2 files changed, 273 insertions(+), 244 deletions(-) diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py index 73a978fd2..cc5c249b5 100644 --- a/tests/unit/keyinput/key_data.py +++ b/tests/unit/keyinput/key_data.py @@ -44,17 +44,18 @@ class Key: text = attr.ib('') uppertext = attr.ib('') member = attr.ib(None) + qtest = attr.ib(True) # From enum Key in qt5/qtbase/src/corelib/global/qnamespace.h KEYS = [ ### misc keys - Key('Escape'), # qutebrowser has a different name from Qt - Key('Tab'), - Key('Backtab'), - Key('Backspace'), - Key('Return'), - Key('Enter'), + Key('Escape', text='\x1b', uppertext='\x1b'), + Key('Tab', text='\t', uppertext='\t'), + Key('Backtab', qtest=False), # Qt assumes VT (vertical tab) + Key('Backspace', text='\b', uppertext='\b'), + Key('Return', text='\r', uppertext='\r'), + Key('Enter', text='\r', uppertext='\r'), Key('Insert', 'Ins'), Key('Delete', 'Del'), Key('Pause'), @@ -217,7 +218,7 @@ KEYS = [ Key('twosuperior', '²', text='²', uppertext='²'), Key('threesuperior', '³', text='³', uppertext='³'), Key('acute', '´', text='´', uppertext='´'), - Key('mu', 'Μ', text='μ', uppertext='Μ'), + Key('mu', 'Μ', text='μ', uppertext='Μ', qtest=False), # Qt assumes U+00B5 instead of U+03BC Key('paragraph', '¶', text='¶', uppertext='¶'), Key('periodcentered', '·', text='·', uppertext='·'), Key('cedilla', '¸', text='¸', uppertext='¸'), @@ -268,113 +269,113 @@ KEYS = [ ### you are writing your own input method ### International & multi-key character composition - Key('AltGr'), - Key('Multi_key', 'Multi key'), # Multi-key character compose - Key('Codeinput', 'Code input'), - Key('SingleCandidate', 'Single Candidate'), - Key('MultipleCandidate', 'Multiple Candidate'), - Key('PreviousCandidate', 'Previous Candidate'), + Key('AltGr', qtest=False), + Key('Multi_key', 'Multi key', qtest=False), # Multi-key character compose + Key('Codeinput', 'Code input', qtest=False), + Key('SingleCandidate', 'Single Candidate', qtest=False), + Key('MultipleCandidate', 'Multiple Candidate', qtest=False), + Key('PreviousCandidate', 'Previous Candidate', qtest=False), ### Misc Functions - Key('Mode_switch', 'Mode switch'), # Character set switch + Key('Mode_switch', 'Mode switch', qtest=False), # Character set switch # Key('script_switch'), # Alias for mode_switch ### Japanese keyboard support - Key('Kanji'), # Kanji, Kanji convert - Key('Muhenkan'), # Cancel Conversion - # Key('Henkan_Mode'), # Start/Stop Conversion - Key('Henkan'), # Alias for Henkan_Mode - Key('Romaji'), # to Romaji - Key('Hiragana'), # to Hiragana - Key('Katakana'), # to Katakana - Key('Hiragana_Katakana', 'Hiragana Katakana'), # Hiragana/Katakana toggle - Key('Zenkaku'), # to Zenkaku - Key('Hankaku'), # to Hankaku - Key('Zenkaku_Hankaku', 'Zenkaku Hankaku'), # Zenkaku/Hankaku toggle - Key('Touroku'), # Add to Dictionary - Key('Massyo'), # Delete from Dictionary - Key('Kana_Lock', 'Kana Lock'), - Key('Kana_Shift', 'Kana Shift'), - Key('Eisu_Shift', 'Eisu Shift'), # Alphanumeric Shift - Key('Eisu_toggle', 'Eisu toggle'), # Alphanumeric toggle - # Key('Kanji_Bangou'), # Codeinput - # Key('Zen_Koho'), # Multiple/All Candidate(s) - # Key('Mae_Koho'), # Previous Candidate + Key('Kanji', qtest=False), # Kanji, Kanji convert + Key('Muhenkan', qtest=False), # Cancel Conversion + # Key('Henkan_Mode', qtest=False), # Start/Stop Conversion + Key('Henkan', qtest=False), # Alias for Henkan_Mode + Key('Romaji', qtest=False), # to Romaji + Key('Hiragana', qtest=False), # to Hiragana + Key('Katakana', qtest=False), # to Katakana + Key('Hiragana_Katakana', 'Hiragana Katakana', qtest=False), # Hiragana/Katakana toggle + Key('Zenkaku', qtest=False), # to Zenkaku + Key('Hankaku', qtest=False), # to Hankaku + Key('Zenkaku_Hankaku', 'Zenkaku Hankaku', qtest=False), # Zenkaku/Hankaku toggle + Key('Touroku', qtest=False), # Add to Dictionary + Key('Massyo', qtest=False), # Delete from Dictionary + Key('Kana_Lock', 'Kana Lock', qtest=False), + Key('Kana_Shift', 'Kana Shift', qtest=False), + Key('Eisu_Shift', 'Eisu Shift', qtest=False), # Alphanumeric Shift + Key('Eisu_toggle', 'Eisu toggle', qtest=False), # Alphanumeric toggle + # Key('Kanji_Bangou', qtest=False), # Codeinput + # Key('Zen_Koho', qtest=False), # Multiple/All Candidate(s) + # Key('Mae_Koho', qtest=False), # Previous Candidate ### Korean keyboard support ### ### In fact, many users from Korea need only 2 keys, Key_Hangul and ### Key_Hangul_Hanja. But rest of the keys are good for future. - Key('Hangul'), # Hangul start/stop(toggle), - Key('Hangul_Start', 'Hangul Start'), # Hangul start - Key('Hangul_End', 'Hangul End'), # Hangul end, English start - Key('Hangul_Hanja', 'Hangul Hanja'), # Start Hangul->Hanja Conversion - Key('Hangul_Jamo', 'Hangul Jamo'), # Hangul Jamo mode - Key('Hangul_Romaja', 'Hangul Romaja'), # Hangul Romaja mode - # Key('Hangul_Codeinput', 'Hangul Codeinput'),# Hangul code input mode - Key('Hangul_Jeonja', 'Hangul Jeonja'), # Jeonja mode - Key('Hangul_Banja', 'Hangul Banja'), # Banja mode - Key('Hangul_PreHanja', 'Hangul PreHanja'), # Pre Hanja conversion - Key('Hangul_PostHanja', 'Hangul PostHanja'), # Post Hanja conversion - # Key('Hangul_SingleCandidate', 'Hangul SingleCandidate'), # Single candidate - # Key('Hangul_MultipleCandidate', 'Hangul MultipleCandidate'), # Multiple candidate - # Key('Hangul_PreviousCandidate', 'Hangul PreviousCandidate'), # Previous candidate - Key('Hangul_Special', 'Hangul Special'), # Special symbols - # Key('Hangul_switch', 'Hangul switch'), # Alias for mode_switch + Key('Hangul', qtest=False), # Hangul start/stop(toggle), + Key('Hangul_Start', 'Hangul Start', qtest=False), # Hangul start + Key('Hangul_End', 'Hangul End', qtest=False), # Hangul end, English start + Key('Hangul_Hanja', 'Hangul Hanja', qtest=False), # Start Hangul->Hanja Conversion + Key('Hangul_Jamo', 'Hangul Jamo', qtest=False), # Hangul Jamo mode + Key('Hangul_Romaja', 'Hangul Romaja', qtest=False), # Hangul Romaja mode + # Key('Hangul_Codeinput', 'Hangul Codeinput', qtest=False),# Hangul code input mode + Key('Hangul_Jeonja', 'Hangul Jeonja', qtest=False), # Jeonja mode + Key('Hangul_Banja', 'Hangul Banja', qtest=False), # Banja mode + Key('Hangul_PreHanja', 'Hangul PreHanja', qtest=False), # Pre Hanja conversion + Key('Hangul_PostHanja', 'Hangul PostHanja', qtest=False), # Post Hanja conversion + # Key('Hangul_SingleCandidate', 'Hangul SingleCandidate', qtest=False), # Single candidate + # Key('Hangul_MultipleCandidate', 'Hangul MultipleCandidate', qtest=False), # Multiple candidate + # Key('Hangul_PreviousCandidate', 'Hangul PreviousCandidate', qtest=False), # Previous candidate + Key('Hangul_Special', 'Hangul Special', qtest=False), # Special symbols + # Key('Hangul_switch', 'Hangul switch', qtest=False), # Alias for mode_switch - # dead keys (X keycode - 0xED00 to avoid the conflict), - Key('Dead_Grave', '`'), - Key('Dead_Acute', '´'), - Key('Dead_Circumflex', '^'), - Key('Dead_Tilde', '~'), - Key('Dead_Macron', '¯'), - Key('Dead_Breve', '˘'), - Key('Dead_Abovedot', '˙'), - Key('Dead_Diaeresis', '¨'), - Key('Dead_Abovering', '˚'), - Key('Dead_Doubleacute', '˝'), - Key('Dead_Caron', 'ˇ'), - Key('Dead_Cedilla', '¸'), - Key('Dead_Ogonek', '˛'), - Key('Dead_Iota', 'Iota'), - Key('Dead_Voiced_Sound', 'Voiced Sound'), - Key('Dead_Semivoiced_Sound', 'Semivoiced Sound'), - Key('Dead_Belowdot', 'Belowdot'), - Key('Dead_Hook', 'Hook'), - Key('Dead_Horn', 'Horn'), + # dead keys (X keycode - 0xED00 to avoid the conflict, qtest=False), + Key('Dead_Grave', '`', qtest=False), + Key('Dead_Acute', '´', qtest=False), + Key('Dead_Circumflex', '^', qtest=False), + Key('Dead_Tilde', '~', qtest=False), + Key('Dead_Macron', '¯', qtest=False), + Key('Dead_Breve', '˘', qtest=False), + Key('Dead_Abovedot', '˙', qtest=False), + Key('Dead_Diaeresis', '¨', qtest=False), + Key('Dead_Abovering', '˚', qtest=False), + Key('Dead_Doubleacute', '˝', qtest=False), + Key('Dead_Caron', 'ˇ', qtest=False), + Key('Dead_Cedilla', '¸', qtest=False), + Key('Dead_Ogonek', '˛', qtest=False), + Key('Dead_Iota', 'Iota', qtest=False), + Key('Dead_Voiced_Sound', 'Voiced Sound', qtest=False), + Key('Dead_Semivoiced_Sound', 'Semivoiced Sound', qtest=False), + Key('Dead_Belowdot', 'Belowdot', qtest=False), + Key('Dead_Hook', 'Hook', qtest=False), + Key('Dead_Horn', 'Horn', qtest=False), # Not in Qt 5.10, so data may be wrong! - Key('Dead_Stroke'), - Key('Dead_Abovecomma'), - Key('Dead_Abovereversedcomma'), - Key('Dead_Doublegrave'), - Key('Dead_Belowring'), - Key('Dead_Belowmacron'), - Key('Dead_Belowcircumflex'), - Key('Dead_Belowtilde'), - Key('Dead_Belowbreve'), - Key('Dead_Belowdiaeresis'), - Key('Dead_Invertedbreve'), - Key('Dead_Belowcomma'), - Key('Dead_Currency'), - Key('Dead_a'), - Key('Dead_A'), - Key('Dead_e'), - Key('Dead_E'), - Key('Dead_i'), - Key('Dead_I'), - Key('Dead_o'), - Key('Dead_O'), - Key('Dead_u'), - Key('Dead_U'), - Key('Dead_Small_Schwa'), - Key('Dead_Capital_Schwa'), - Key('Dead_Greek'), - Key('Dead_Lowline'), - Key('Dead_Aboveverticalline'), - Key('Dead_Belowverticalline'), - Key('Dead_Longsolidusoverlay'), + Key('Dead_Stroke', qtest=False), + Key('Dead_Abovecomma', qtest=False), + Key('Dead_Abovereversedcomma', qtest=False), + Key('Dead_Doublegrave', qtest=False), + Key('Dead_Belowring', qtest=False), + Key('Dead_Belowmacron', qtest=False), + Key('Dead_Belowcircumflex', qtest=False), + Key('Dead_Belowtilde', qtest=False), + Key('Dead_Belowbreve', qtest=False), + Key('Dead_Belowdiaeresis', qtest=False), + Key('Dead_Invertedbreve', qtest=False), + Key('Dead_Belowcomma', qtest=False), + Key('Dead_Currency', qtest=False), + Key('Dead_a', qtest=False), + Key('Dead_A', qtest=False), + Key('Dead_e', qtest=False), + Key('Dead_E', qtest=False), + Key('Dead_i', qtest=False), + Key('Dead_I', qtest=False), + Key('Dead_o', qtest=False), + Key('Dead_O', qtest=False), + Key('Dead_u', qtest=False), + Key('Dead_U', qtest=False), + Key('Dead_Small_Schwa', qtest=False), + Key('Dead_Capital_Schwa', qtest=False), + Key('Dead_Greek', qtest=False), + Key('Dead_Lowline', qtest=False), + Key('Dead_Aboveverticalline', qtest=False), + Key('Dead_Belowverticalline', qtest=False), + Key('Dead_Longsolidusoverlay', qtest=False), ### multimedia/internet keys - ignored by default - see QKeyEvent c'tor Key('Back'), @@ -394,8 +395,8 @@ KEYS = [ Key('MediaPrevious', 'Media Previous'), Key('MediaNext', 'Media Next'), Key('MediaRecord', 'Media Record'), - Key('MediaPause', 'Media Pause'), - Key('MediaTogglePlayPause', 'Toggle Media Play/Pause'), + Key('MediaPause', 'Media Pause', qtest=False), + Key('MediaTogglePlayPause', 'Toggle Media Play/Pause', qtest=False), Key('HomePage', 'Home Page'), Key('Favorites'), Key('Search'), @@ -420,162 +421,162 @@ KEYS = [ Key('LaunchD', 'Launch (D)'), Key('LaunchE', 'Launch (E)'), Key('LaunchF', 'Launch (F)'), - Key('MonBrightnessUp', 'Monitor Brightness Up'), - Key('MonBrightnessDown', 'Monitor Brightness Down'), - Key('KeyboardLightOnOff', 'Keyboard Light On/Off'), - Key('KeyboardBrightnessUp', 'Keyboard Brightness Up'), - Key('KeyboardBrightnessDown', 'Keyboard Brightness Down'), - Key('PowerOff', 'Power Off'), - Key('WakeUp', 'Wake Up'), - Key('Eject'), - Key('ScreenSaver', 'Screensaver'), - Key('WWW'), - Key('Memo', 'Memo'), - Key('LightBulb'), - Key('Shop'), - Key('History'), - Key('AddFavorite', 'Add Favorite'), - Key('HotLinks', 'Hot Links'), - Key('BrightnessAdjust', 'Adjust Brightness'), - Key('Finance'), - Key('Community'), - Key('AudioRewind', 'Media Rewind'), - Key('BackForward', 'Back Forward'), - Key('ApplicationLeft', 'Application Left'), - Key('ApplicationRight', 'Application Right'), - Key('Book'), - Key('CD'), - Key('Calculator'), - Key('ToDoList', 'To Do List'), - Key('ClearGrab', 'Clear Grab'), - Key('Close'), - Key('Copy'), - Key('Cut'), - Key('Display'), # Output switch key - Key('DOS'), - Key('Documents'), - Key('Excel', 'Spreadsheet'), - Key('Explorer', 'Browser'), - Key('Game'), - Key('Go'), - Key('iTouch'), - Key('LogOff', 'Logoff'), - Key('Market'), - Key('Meeting'), - Key('MenuKB', 'Keyboard Menu'), - Key('MenuPB', 'Menu PB'), - Key('MySites', 'My Sites'), - Key('News'), - Key('OfficeHome', 'Home Office'), - Key('Option'), - Key('Paste'), - Key('Phone'), - Key('Calendar'), - Key('Reply'), - Key('Reload'), - Key('RotateWindows', 'Rotate Windows'), - Key('RotationPB', 'Rotation PB'), - Key('RotationKB', 'Rotation KB'), - Key('Save'), - Key('Send'), - Key('Spell', 'Spellchecker'), - Key('SplitScreen', 'Split Screen'), - Key('Support'), - Key('TaskPane', 'Task Panel'), - Key('Terminal'), - Key('Tools'), - Key('Travel'), - Key('Video'), - Key('Word', 'Word Processor'), - Key('Xfer', 'XFer'), - Key('ZoomIn', 'Zoom In'), - Key('ZoomOut', 'Zoom Out'), - Key('Away'), - Key('Messenger'), - Key('WebCam'), - Key('MailForward', 'Mail Forward'), - Key('Pictures'), - Key('Music'), - Key('Battery'), - Key('Bluetooth'), - Key('WLAN', 'Wireless'), - Key('UWB', 'Ultra Wide Band'), - Key('AudioForward', 'Media Fast Forward'), - Key('AudioRepeat', 'Audio Repeat'), # Toggle repeat mode - Key('AudioRandomPlay', 'Audio Random Play'), # Toggle shuffle mode - Key('Subtitle'), - Key('AudioCycleTrack', 'Audio Cycle Track'), - Key('Time'), - Key('Hibernate'), - Key('View'), - Key('TopMenu', 'Top Menu'), - Key('PowerDown', 'Power Down'), - Key('Suspend'), - Key('ContrastAdjust', 'Contrast Adjust'), + Key('MonBrightnessUp', 'Monitor Brightness Up', qtest=False), + Key('MonBrightnessDown', 'Monitor Brightness Down', qtest=False), + Key('KeyboardLightOnOff', 'Keyboard Light On/Off', qtest=False), + Key('KeyboardBrightnessUp', 'Keyboard Brightness Up', qtest=False), + Key('KeyboardBrightnessDown', 'Keyboard Brightness Down', qtest=False), + Key('PowerOff', 'Power Off', qtest=False), + Key('WakeUp', 'Wake Up', qtest=False), + Key('Eject', qtest=False), + Key('ScreenSaver', 'Screensaver', qtest=False), + Key('WWW', qtest=False), + Key('Memo', 'Memo', qtest=False), + Key('LightBulb', qtest=False), + Key('Shop', qtest=False), + Key('History', qtest=False), + Key('AddFavorite', 'Add Favorite', qtest=False), + Key('HotLinks', 'Hot Links', qtest=False), + Key('BrightnessAdjust', 'Adjust Brightness', qtest=False), + Key('Finance', qtest=False), + Key('Community', qtest=False), + Key('AudioRewind', 'Media Rewind', qtest=False), + Key('BackForward', 'Back Forward', qtest=False), + Key('ApplicationLeft', 'Application Left', qtest=False), + Key('ApplicationRight', 'Application Right', qtest=False), + Key('Book', qtest=False), + Key('CD', qtest=False), + Key('Calculator', qtest=False), + Key('ToDoList', 'To Do List', qtest=False), + Key('ClearGrab', 'Clear Grab', qtest=False), + Key('Close', qtest=False), + Key('Copy', qtest=False), + Key('Cut', qtest=False), + Key('Display', qtest=False), # Output switch key + Key('DOS', qtest=False), + Key('Documents', qtest=False), + Key('Excel', 'Spreadsheet', qtest=False), + Key('Explorer', 'Browser', qtest=False), + Key('Game', qtest=False), + Key('Go', qtest=False), + Key('iTouch', qtest=False), + Key('LogOff', 'Logoff', qtest=False), + Key('Market', qtest=False), + Key('Meeting', qtest=False), + Key('MenuKB', 'Keyboard Menu', qtest=False), + Key('MenuPB', 'Menu PB', qtest=False), + Key('MySites', 'My Sites', qtest=False), + Key('News', qtest=False), + Key('OfficeHome', 'Home Office', qtest=False), + Key('Option', qtest=False), + Key('Paste', qtest=False), + Key('Phone', qtest=False), + Key('Calendar', qtest=False), + Key('Reply', qtest=False), + Key('Reload', qtest=False), + Key('RotateWindows', 'Rotate Windows', qtest=False), + Key('RotationPB', 'Rotation PB', qtest=False), + Key('RotationKB', 'Rotation KB', qtest=False), + Key('Save', qtest=False), + Key('Send', qtest=False), + Key('Spell', 'Spellchecker', qtest=False), + Key('SplitScreen', 'Split Screen', qtest=False), + Key('Support', qtest=False), + Key('TaskPane', 'Task Panel', qtest=False), + Key('Terminal', qtest=False), + Key('Tools', qtest=False), + Key('Travel', qtest=False), + Key('Video', qtest=False), + Key('Word', 'Word Processor', qtest=False), + Key('Xfer', 'XFer', qtest=False), + Key('ZoomIn', 'Zoom In', qtest=False), + Key('ZoomOut', 'Zoom Out', qtest=False), + Key('Away', qtest=False), + Key('Messenger', qtest=False), + Key('WebCam', qtest=False), + Key('MailForward', 'Mail Forward', qtest=False), + Key('Pictures', qtest=False), + Key('Music', qtest=False), + Key('Battery', qtest=False), + Key('Bluetooth', qtest=False), + Key('WLAN', 'Wireless', qtest=False), + Key('UWB', 'Ultra Wide Band', qtest=False), + Key('AudioForward', 'Media Fast Forward', qtest=False), + Key('AudioRepeat', 'Audio Repeat', qtest=False), # Toggle repeat mode + Key('AudioRandomPlay', 'Audio Random Play', qtest=False), # Toggle shuffle mode + Key('Subtitle', qtest=False), + Key('AudioCycleTrack', 'Audio Cycle Track', qtest=False), + Key('Time', qtest=False), + Key('Hibernate', qtest=False), + Key('View', qtest=False), + Key('TopMenu', 'Top Menu', qtest=False), + Key('PowerDown', 'Power Down', qtest=False), + Key('Suspend', qtest=False), + Key('ContrastAdjust', 'Contrast Adjust', qtest=False), - Key('LaunchG', 'Launch (G)'), - Key('LaunchH', 'Launch (H)'), + Key('LaunchG', 'Launch (G)', qtest=False), + Key('LaunchH', 'Launch (H)', qtest=False), - Key('TouchpadToggle', 'Touchpad Toggle'), - Key('TouchpadOn', 'Touchpad On'), - Key('TouchpadOff', 'Touchpad Off'), + Key('TouchpadToggle', 'Touchpad Toggle', qtest=False), + Key('TouchpadOn', 'Touchpad On', qtest=False), + Key('TouchpadOff', 'Touchpad Off', qtest=False), - Key('MicMute', 'Microphone Mute'), + Key('MicMute', 'Microphone Mute', qtest=False), - Key('Red'), - Key('Green'), - Key('Yellow'), - Key('Blue'), + Key('Red', qtest=False), + Key('Green', qtest=False), + Key('Yellow', qtest=False), + Key('Blue', qtest=False), - Key('ChannelUp', 'Channel Up'), - Key('ChannelDown', 'Channel Down'), + Key('ChannelUp', 'Channel Up', qtest=False), + Key('ChannelDown', 'Channel Down', qtest=False), - Key('Guide'), - Key('Info'), - Key('Settings'), + Key('Guide', qtest=False), + Key('Info', qtest=False), + Key('Settings', qtest=False), - Key('MicVolumeUp', 'Microphone Volume Up'), - Key('MicVolumeDown', 'Microphone Volume Down'), + Key('MicVolumeUp', 'Microphone Volume Up', qtest=False), + Key('MicVolumeDown', 'Microphone Volume Down', qtest=False), - Key('New'), - Key('Open'), - Key('Find'), - Key('Undo'), - Key('Redo'), + Key('New', qtest=False), + Key('Open', qtest=False), + Key('Find', qtest=False), + Key('Undo', qtest=False), + Key('Redo', qtest=False), - Key('MediaLast', 'Media Last'), + Key('MediaLast', 'Media Last', qtest=False), ### Keypad navigation keys - Key('Select'), - Key('Yes'), - Key('No'), + Key('Select', qtest=False), + Key('Yes', qtest=False), + Key('No', qtest=False), ### Newer misc keys - Key('Cancel'), - Key('Printer'), - Key('Execute'), - Key('Sleep'), - Key('Play'), # Not the same as Key_MediaPlay - Key('Zoom'), - # Key('Jisho'), # IME: Dictionary key - # Key('Oyayubi_Left'), # IME: Left Oyayubi key - # Key('Oyayubi_Right'), # IME: Right Oyayubi key - Key('Exit'), + Key('Cancel', qtest=False), + Key('Printer', qtest=False), + Key('Execute', qtest=False), + Key('Sleep', qtest=False), + Key('Play', qtest=False), # Not the same as Key_MediaPlay + Key('Zoom', qtest=False), + # Key('Jisho', qtest=False), # IME: Dictionary key + # Key('Oyayubi_Left', qtest=False), # IME: Left Oyayubi key + # Key('Oyayubi_Right', qtest=False), # IME: Right Oyayubi key + Key('Exit', qtest=False), # Device keys - Key('Context1'), - Key('Context2'), - Key('Context3'), - Key('Context4'), - Key('Call'), # set absolute state to in a call (do not toggle state) - Key('Hangup'), # set absolute state to hang up (do not toggle state) - Key('Flip'), - Key('ToggleCallHangup', 'Toggle Call/Hangup'), # a toggle key for answering, or hanging up, based on current call state - Key('VoiceDial', 'Voice Dial'), - Key('LastNumberRedial', 'Last Number Redial'), + Key('Context1', qtest=False), + Key('Context2', qtest=False), + Key('Context3', qtest=False), + Key('Context4', qtest=False), + Key('Call', qtest=False), # set absolute state to in a call (do not toggle state) + Key('Hangup', qtest=False), # set absolute state to hang up (do not toggle state) + Key('Flip', qtest=False), + Key('ToggleCallHangup', 'Toggle Call/Hangup', qtest=False), # a toggle key for answering, or hanging up, based on current call state + Key('VoiceDial', 'Voice Dial', qtest=False), + Key('LastNumberRedial', 'Last Number Redial', qtest=False), - Key('Camera', 'Camera Shutter'), - Key('CameraFocus', 'Camera Focus'), + Key('Camera', 'Camera Shutter', qtest=False), + Key('CameraFocus', 'Camera Focus', qtest=False), - Key('unknown', 'Unknown'), + Key('unknown', 'Unknown', qtest=False), ] diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index df93d268c..df399497e 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -18,7 +18,8 @@ # along with qutebrowser. If not, see . import pytest -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtWidgets import QWidget from tests.unit.keyinput import key_data from qutebrowser.utils import utils @@ -43,6 +44,33 @@ def test_key_text(qt_key, upper): assert info.text() == expected +class KeyTestWidget(QWidget): + + got_text = pyqtSignal() + + def keyPressEvent(self, e): + self.text = e.text() + self.got_text.emit() + + +@pytest.fixture +def key_test(qtbot): + w = KeyTestWidget() + qtbot.add_widget(w) + return w + + +def test_key_test_qtest(qt_key, qtbot, key_test): + if not qt_key.qtest: + pytest.skip("Unsupported by QtTest") + + with qtbot.wait_signal(key_test.got_text): + qtbot.keyPress(key_test, qt_key.member) + + info = keyutils.KeyInfo(qt_key.member, modifiers=Qt.KeyboardModifiers()) + assert info.text() == key_test.text.lower() + + class TestKeyToString: def test_to_string(self, qt_key): From 50d2ef3b90d22c40306b40dccd7f9211d60b491c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 1 Mar 2018 08:01:39 +0100 Subject: [PATCH 364/524] Fix handling of printable control keys in KeyInfo.text() --- qutebrowser/keyinput/keyutils.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 1feb371a8..5c1097aa8 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -254,8 +254,17 @@ class KeyInfo: def text(self): """Get the text which would be displayed when pressing this key.""" - if self.key == Qt.Key_Space: - return ' ' + control = { + Qt.Key_Space: ' ', + Qt.Key_Tab: '\t', + Qt.Key_Backspace: '\b', + Qt.Key_Return: '\r', + Qt.Key_Enter: '\r', + Qt.Key_Escape: '\x1b', + } + + if self.key in control: + return control[self.key] elif not is_printable(self.key): return '' From a57fc5c746358dd08c2ab8703bcd0162be479ea3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 1 Mar 2018 08:12:19 +0100 Subject: [PATCH 365/524] Refactor keyutils tests involving all keys --- tests/unit/keyinput/key_data.py | 4 +++ tests/unit/keyinput/test_keyutils.py | 50 ++++++++++++++++------------ 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py index cc5c249b5..0164a839b 100644 --- a/tests/unit/keyinput/key_data.py +++ b/tests/unit/keyinput/key_data.py @@ -24,6 +24,7 @@ import attr +from PyQt5.QtCore import Qt @attr.s @@ -46,6 +47,9 @@ class Key: member = attr.ib(None) qtest = attr.ib(True) + def __attrs_post_init__(self): + self.member = getattr(Qt, 'Key_' + self.attribute, None) + # From enum Key in qt5/qtbase/src/corelib/global/qnamespace.h KEYS = [ diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index df399497e..43d9bf879 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -29,23 +29,25 @@ from qutebrowser.keyinput import keyutils @pytest.fixture(params=key_data.KEYS, ids=lambda k: k.attribute) def qt_key(request): key = request.param - member = getattr(Qt, 'Key_' + key.attribute, None) - if member is None: + if key.member is None: pytest.skip("Did not find key {}".format(key.attribute)) - key.member = member return key -@pytest.mark.parametrize('upper', [False, True]) -def test_key_text(qt_key, upper): - modifiers = Qt.ShiftModifier if upper else Qt.KeyboardModifiers() - info = keyutils.KeyInfo(qt_key.member, modifiers=modifiers) - expected = qt_key.uppertext if upper else qt_key.text - assert info.text() == expected +@pytest.fixture(params=[key for key in key_data.KEYS if key.qtest], + ids=lambda k: k.attribute) +def qtest_key(request): + return request.param class KeyTestWidget(QWidget): + """Widget to get the text of QKeyPressEvents. + + This is done so we can check QTest::keyToAscii (qasciikey.cpp) as we can't + call that directly, only via QTest::keyPress. + """ + got_text = pyqtSignal() def keyPressEvent(self, e): @@ -53,22 +55,28 @@ class KeyTestWidget(QWidget): self.got_text.emit() -@pytest.fixture -def key_test(qtbot): - w = KeyTestWidget() - qtbot.add_widget(w) - return w +class TestKeyText: + @pytest.mark.parametrize('upper', [False, True]) + def test_key_text(self, qt_key, upper): + modifiers = Qt.ShiftModifier if upper else Qt.KeyboardModifiers() + info = keyutils.KeyInfo(qt_key.member, modifiers=modifiers) + expected = qt_key.uppertext if upper else qt_key.text + assert info.text() == expected -def test_key_test_qtest(qt_key, qtbot, key_test): - if not qt_key.qtest: - pytest.skip("Unsupported by QtTest") + @pytest.fixture + def key_test(self, qtbot): + w = KeyTestWidget() + qtbot.add_widget(w) + return w - with qtbot.wait_signal(key_test.got_text): - qtbot.keyPress(key_test, qt_key.member) + def test_key_test_qtest(self, qtest_key, qtbot, key_test): + with qtbot.wait_signal(key_test.got_text): + qtbot.keyPress(key_test, qtest_key.member) - info = keyutils.KeyInfo(qt_key.member, modifiers=Qt.KeyboardModifiers()) - assert info.text() == key_test.text.lower() + info = keyutils.KeyInfo(qtest_key.member, + modifiers=Qt.KeyboardModifiers()) + assert info.text() == key_test.text.lower() class TestKeyToString: From fac8d72d8c204f526a845574603d3c5dbd55e80b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 1 Mar 2018 08:44:27 +0100 Subject: [PATCH 366/524] Capitalize Escape in TestFullscreenNotification --- tests/unit/misc/test_miscwidgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/misc/test_miscwidgets.py b/tests/unit/misc/test_miscwidgets.py index 39f078fa6..d6b099418 100644 --- a/tests/unit/misc/test_miscwidgets.py +++ b/tests/unit/misc/test_miscwidgets.py @@ -106,7 +106,7 @@ class TestFullscreenNotification: @pytest.mark.parametrize('bindings, text', [ ({'': 'fullscreen --leave'}, - "Press to exit fullscreen."), + "Press to exit fullscreen."), ({'': 'fullscreen'}, "Page is now fullscreen."), ({'a': 'fullscreen --leave'}, "Press a to exit fullscreen."), ({}, "Page is now fullscreen."), From af6e5b1838d5dd72a7c8fef10df7d920c16770ba Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 1 Mar 2018 08:56:39 +0100 Subject: [PATCH 367/524] Fix lint --- tests/unit/keyinput/test_keyutils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 43d9bf879..c1ddbcb5a 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -50,6 +50,10 @@ class KeyTestWidget(QWidget): got_text = pyqtSignal() + def __init__(self, parent=None): + super().__init__(parent) + self.text = None + def keyPressEvent(self, e): self.text = e.text() self.got_text.emit() From 693178c8ee0c22f5b0c168a1dcbca9db1e5dd49c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 1 Mar 2018 20:26:23 +0100 Subject: [PATCH 368/524] Refactor KeyInfo.__str__ This removes the special handling for macOS, but this is hopefully not needed anymore as we don't compare strings. --- qutebrowser/keyinput/keyutils.py | 46 ++++++---------------------- tests/unit/keyinput/test_keyutils.py | 8 +---- 2 files changed, 10 insertions(+), 44 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 5c1097aa8..c45dab290 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -203,54 +203,26 @@ class KeyInfo: A name of the key (combination) as a string or an empty string if only modifiers are pressed. """ - if utils.is_mac: - # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the - # user can use it in the config as expected. See: - # https://github.com/qutebrowser/qutebrowser/issues/110 - # http://doc.qt.io/qt-5.4/osx-issues.html#special-keys - modmask2str = collections.OrderedDict([ - (Qt.MetaModifier, 'Ctrl'), - (Qt.AltModifier, 'Alt'), - (Qt.ControlModifier, 'Meta'), - (Qt.ShiftModifier, 'Shift'), - ]) - else: - modmask2str = collections.OrderedDict([ - (Qt.ControlModifier, 'Ctrl'), - (Qt.AltModifier, 'Alt'), - (Qt.MetaModifier, 'Meta'), - (Qt.ShiftModifier, 'Shift'), - ]) - if is_modifier_key(self.key): return '' - parts = [] - - for (mask, s) in modmask2str.items(): - if self.modifiers & mask and s not in parts: - parts.append(s) - key_string = _key_to_string(self.key) if is_printable(self.key): - # FIXME Add a test to make sure Tab doesn;t become TAB + # "normal" binding + # FIXME Add a test to make sure Tab doesn't become TAB assert len(key_string) == 1 or self.key == Qt.Key_Space, key_string if self.modifiers == Qt.ShiftModifier: - parts = [] - key_string = key_string.upper() + return key_string.upper() + elif self.modifiers == Qt.NoModifier: + return key_string.lower() else: + # Use special binding syntax, but instead of key_string = key_string.lower() - parts.append(key_string) - part_string = '+'.join(parts) - - if len(part_string) > 1: - # "special" binding - return '<{}>'.format(part_string) - else: - # "normal" binding - return part_string + # "special" binding + modifier_string = QKeySequence(self.modifiers).toString() + return '<{}{}>'.format(modifier_string, key_string) def text(self): """Get the text which would be displayed when pressing this key.""" diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index c1ddbcb5a..7301b7747 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -138,13 +138,7 @@ class TestKeyEventToString: key=Qt.Key_A, modifiers=(Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier | Qt.ShiftModifier)) s = str(keyutils.KeyInfo.from_event(evt)) - assert s == '' - - @pytest.mark.fake_os('mac') - def test_mac(self, fake_keyevent_factory): - """Test with a simulated mac.""" - evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) - assert str(keyutils.KeyInfo.from_event(evt)) == '' + assert s == '' @pytest.mark.parametrize('keystr, expected', [ From 7cb781cc92a4d541659e243d3d92be6934e2b26c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 1 Mar 2018 20:45:55 +0100 Subject: [PATCH 369/524] Simplify handling of modifier-only keys Now that we don't rely on str(KeyInfo) being empty anywhere, there's no reason to return an empty string for only-modifier keypresses anymore. While those keys can't be bound (QKeySequence('Shift') == Qt.Key_unknown) there's also no reason to explicitly ignore them. --- qutebrowser/keyinput/basekeyparser.py | 4 ---- qutebrowser/keyinput/keyutils.py | 11 +++++------ tests/unit/keyinput/test_keyutils.py | 4 ++-- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 2c934617b..a8501ab4c 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -128,10 +128,6 @@ class BaseKeyParser(QObject): txt = str(keyutils.KeyInfo.from_event(e)) self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) - if keyutils.is_modifier_key(key): - self._debug_log("Ignoring, only modifier") - return QKeySequence.NoMatch - if (txt.isdigit() and self._supports_count and not (not self._count and txt == '0')): assert len(txt) == 1, txt diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index c45dab290..85c35895a 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -200,15 +200,14 @@ class KeyInfo: """Convert this KeyInfo to a meaningful name. Return: - A name of the key (combination) as a string or - an empty string if only modifiers are pressed. + A name of the key (combination) as a string. """ - if is_modifier_key(self.key): - return '' - key_string = _key_to_string(self.key) - if is_printable(self.key): + if is_modifier_key(self.key): + # Don't return e.g. + return '<{}>'.format(key_string) + elif is_printable(self.key): # "normal" binding # FIXME Add a test to make sure Tab doesn't become TAB assert len(key_string) == 1 or self.key == Qt.Key_Space, key_string diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 7301b7747..bf24de401 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -113,13 +113,13 @@ class TestKeyEventToString: """Test keyeevent when only control is pressed.""" evt = fake_keyevent_factory(key=Qt.Key_Control, modifiers=Qt.ControlModifier) - assert not str(keyutils.KeyInfo.from_event(evt)) + assert str(keyutils.KeyInfo.from_event(evt)) == '' def test_only_hyper_l(self, fake_keyevent_factory): """Test keyeevent when only Hyper_L is pressed.""" evt = fake_keyevent_factory(key=Qt.Key_Hyper_L, modifiers=Qt.MetaModifier) - assert not str(keyutils.KeyInfo.from_event(evt)) + assert str(keyutils.KeyInfo.from_event(evt)) == '' def test_only_key(self, fake_keyevent_factory): """Test with a simple key pressed.""" From 3a11a24be022cbc9115b49f3f63707a65a425b19 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 1 Mar 2018 23:04:00 +0100 Subject: [PATCH 370/524] Fix modifier handling We don't want to show , but should still work correctly. --- qutebrowser/keyinput/keyutils.py | 22 +++++++++++----------- tests/unit/keyinput/test_keyutils.py | 26 ++++++++++++-------------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 85c35895a..1cb941ac7 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -33,14 +33,6 @@ def is_printable(key): return key <= 0xff -def is_modifier_key(key): - # FIXME docs - return key in (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, - Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, - Qt.Key_Hyper_L, Qt.Key_Hyper_R, Qt.Key_Direction_L, - Qt.Key_Direction_R) - - def _key_to_string(key): """Convert a Qt::Key member to a meaningful name. @@ -203,10 +195,18 @@ class KeyInfo: A name of the key (combination) as a string. """ key_string = _key_to_string(self.key) + modifier_map = { + Qt.Key_Shift: Qt.ShiftModifier, + Qt.Key_Control: Qt.ControlModifier, + Qt.Key_Alt: Qt.AltModifier, + Qt.Key_Meta: Qt.MetaModifier, + Qt.Key_Mode_switch: Qt.GroupSwitchModifier, + } + modifiers = int(self.modifiers) - if is_modifier_key(self.key): + if self.key in modifier_map: # Don't return e.g. - return '<{}>'.format(key_string) + modifiers &= ~modifier_map[self.key] elif is_printable(self.key): # "normal" binding # FIXME Add a test to make sure Tab doesn't become TAB @@ -220,7 +220,7 @@ class KeyInfo: key_string = key_string.lower() # "special" binding - modifier_string = QKeySequence(self.modifiers).toString() + modifier_string = QKeySequence(modifiers).toString() return '<{}{}>'.format(modifier_string, key_string) def text(self): diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index bf24de401..25fe8e50e 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -115,12 +115,6 @@ class TestKeyEventToString: modifiers=Qt.ControlModifier) assert str(keyutils.KeyInfo.from_event(evt)) == '' - def test_only_hyper_l(self, fake_keyevent_factory): - """Test keyeevent when only Hyper_L is pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_Hyper_L, - modifiers=Qt.MetaModifier) - assert str(keyutils.KeyInfo.from_event(evt)) == '' - def test_only_key(self, fake_keyevent_factory): """Test with a simple key pressed.""" evt = fake_keyevent_factory(key=Qt.Key_A) @@ -140,6 +134,18 @@ class TestKeyEventToString: s = str(keyutils.KeyInfo.from_event(evt)) assert s == '' + def test_modifier_key(self, fake_keyevent_factory): + evt = fake_keyevent_factory(key=Qt.Key_Shift, + modifiers=Qt.ShiftModifier) + s = str(keyutils.KeyInfo.from_event(evt)) + assert s == '' + + def test_modifier_key_with_modifiers(self, fake_keyevent_factory): + evt = fake_keyevent_factory(key=Qt.Key_Shift, + modifiers=(Qt.ShiftModifier | + Qt.ControlModifier)) + s = str(keyutils.KeyInfo.from_event(evt)) + assert s == '' @pytest.mark.parametrize('keystr, expected', [ ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), @@ -184,11 +190,3 @@ def test_normalize_keystr(orig, normalized): ]) def test_is_printable(key, printable): assert keyutils.is_printable(key) == printable - - -@pytest.mark.parametrize('key, ismodifier', [ - (Qt.Key_Control, True), - (Qt.Key_X, False) -]) -def test_is_modifier_key(key, ismodifier): - assert keyutils.is_modifier_key(key) == ismodifier From d9a88e139c3a6dc4a4d09600f13f03cc2bb8a689 Mon Sep 17 00:00:00 2001 From: Fritz Reichwald Date: Fri, 2 Mar 2018 12:54:29 +0100 Subject: [PATCH 371/524] Test for modifiers and corner case 0xFF --- tests/unit/keyinput/test_keyutils.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 25fe8e50e..2d6e6310a 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -186,6 +186,13 @@ def test_normalize_keystr(orig, normalized): @pytest.mark.parametrize('key, printable', [ (Qt.Key_Control, False), + (Qt.Key_Space, False), + (Qt.Key_Escape, False), + (Qt.Key_Tab, False), + (Qt.Key_Backspace, False), + (Qt.Key_Return, False), + (Qt.Key_Enter, False), + (Qt.Key_ydiaeresis, True), (Qt.Key_X, True) ]) def test_is_printable(key, printable): From e306e2dadb3d2d38d7f9b9c794a59d6eb163ef81 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 2 Mar 2018 13:44:59 +0100 Subject: [PATCH 372/524] Improve and fix test_is_printable --- tests/unit/keyinput/test_keyutils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 2d6e6310a..154330af2 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -186,14 +186,17 @@ def test_normalize_keystr(orig, normalized): @pytest.mark.parametrize('key, printable', [ (Qt.Key_Control, False), - (Qt.Key_Space, False), (Qt.Key_Escape, False), (Qt.Key_Tab, False), + (Qt.Key_Backtab, False), (Qt.Key_Backspace, False), (Qt.Key_Return, False), (Qt.Key_Enter, False), + (Qt.Key_X | Qt.ControlModifier, False), # Wrong usage + + (Qt.Key_Space, True), (Qt.Key_ydiaeresis, True), - (Qt.Key_X, True) + (Qt.Key_X, True), ]) def test_is_printable(key, printable): assert keyutils.is_printable(key) == printable From b3834835edb32188bb53dcaab502fef689e6b07d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 2 Mar 2018 14:16:40 +0100 Subject: [PATCH 373/524] Bring back keyutils.is_modifier() and modifier handling Turns out when we press yY, we get three events: Qt.Key_Y, Qt.NoModifier Qt.Key_Shift, Qt.ShiftModifier Qt.Key_Y, Qt.ShiftModifier If we don't ignore the second one, our keychain will be interrupted by the Shift keypress. --- qutebrowser/keyinput/basekeyparser.py | 4 +++ qutebrowser/keyinput/keyutils.py | 30 ++++++++++++++++------- tests/unit/keyinput/conftest.py | 1 + tests/unit/keyinput/test_basekeyparser.py | 11 +++++++++ tests/unit/keyinput/test_keyutils.py | 9 +++++++ 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index a8501ab4c..2c934617b 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -128,6 +128,10 @@ class BaseKeyParser(QObject): txt = str(keyutils.KeyInfo.from_event(e)) self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) + if keyutils.is_modifier_key(key): + self._debug_log("Ignoring, only modifier") + return QKeySequence.NoMatch + if (txt.isdigit() and self._supports_count and not (not self._count and txt == '0')): assert len(txt) == 1, txt diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 1cb941ac7..e456da045 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -29,10 +29,29 @@ from PyQt5.QtGui import QKeySequence, QKeyEvent from qutebrowser.utils import utils +# Map Qt::Key values to their Qt::KeyboardModifier value. +_MODIFIER_MAP = { + Qt.Key_Shift: Qt.ShiftModifier, + Qt.Key_Control: Qt.ControlModifier, + Qt.Key_Alt: Qt.AltModifier, + Qt.Key_Meta: Qt.MetaModifier, + Qt.Key_Mode_switch: Qt.GroupSwitchModifier, +} + + def is_printable(key): return key <= 0xff +def is_modifier_key(key): + """Test whether the given key is a modifier. + + This only considers keys which are part of Qt::KeyboardModifiers, i.e. which + would interrupt a key chain like "yY" when handled. + """ + return key in _MODIFIER_MAP + + def _key_to_string(key): """Convert a Qt::Key member to a meaningful name. @@ -195,18 +214,11 @@ class KeyInfo: A name of the key (combination) as a string. """ key_string = _key_to_string(self.key) - modifier_map = { - Qt.Key_Shift: Qt.ShiftModifier, - Qt.Key_Control: Qt.ControlModifier, - Qt.Key_Alt: Qt.AltModifier, - Qt.Key_Meta: Qt.MetaModifier, - Qt.Key_Mode_switch: Qt.GroupSwitchModifier, - } modifiers = int(self.modifiers) - if self.key in modifier_map: + if self.key in _MODIFIER_MAP: # Don't return e.g. - modifiers &= ~modifier_map[self.key] + modifiers &= ~_MODIFIER_MAP[self.key] elif is_printable(self.key): # "normal" binding # FIXME Add a test to make sure Tab doesn't become TAB diff --git a/tests/unit/keyinput/conftest.py b/tests/unit/keyinput/conftest.py index 0713c5d26..f78ed61ad 100644 --- a/tests/unit/keyinput/conftest.py +++ b/tests/unit/keyinput/conftest.py @@ -27,6 +27,7 @@ BINDINGS = {'prompt': {'': 'message-info ctrla', 'ba': 'message-info ba', 'ax': 'message-info ax', 'ccc': 'message-info ccc', + 'yY': 'yank -s', '0': 'message-info 0'}, 'command': {'foo': 'message-info bar', '': 'message-info ctrlx'}, diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 5da4efa9b..872709d6e 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -241,6 +241,17 @@ class TestKeyChain: handle_text((Qt.Key_B, 'b')) assert not keyparser.execute.called + def test_binding_with_shift(self, keyparser, fake_keyevent_factory): + """Simulate a binding which involves shift.""" + keyparser.handle( + fake_keyevent_factory(Qt.Key_Y, text='y')) + keyparser.handle( + fake_keyevent_factory(Qt.Key_Shift, Qt.ShiftModifier, text='')) + keyparser.handle( + fake_keyevent_factory(Qt.Key_Y, Qt.ShiftModifier, text='Y')) + + keyparser.execute.assert_called_once_with('yank -s', None) + class TestCount: diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 154330af2..220d16dc6 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -200,3 +200,12 @@ def test_normalize_keystr(orig, normalized): ]) def test_is_printable(key, printable): assert keyutils.is_printable(key) == printable + + +@pytest.mark.parametrize('key, ismodifier', [ + (Qt.Key_Control, True), + (Qt.Key_X, False), + (Qt.Key_Super_L, False), # Modifier but not in _MODIFIER_MAP +]) +def test_is_modifier_key(key, ismodifier): + assert keyutils.is_modifier_key(key) == ismodifier From da60d11b2415e2671f6eb408695edd55362fd6cb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 3 Mar 2018 22:47:19 +0100 Subject: [PATCH 374/524] Refactor keyutils tests --- qutebrowser/keyinput/keyutils.py | 1 - tests/unit/keyinput/key_data.py | 4 +- tests/unit/keyinput/test_keyutils.py | 126 ++++++++++++--------------- 3 files changed, 60 insertions(+), 71 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index e456da045..61e3e53b4 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -221,7 +221,6 @@ class KeyInfo: modifiers &= ~_MODIFIER_MAP[self.key] elif is_printable(self.key): # "normal" binding - # FIXME Add a test to make sure Tab doesn't become TAB assert len(key_string) == 1 or self.key == Qt.Key_Space, key_string if self.modifiers == Qt.ShiftModifier: return key_string.upper() diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py index 0164a839b..2f13c8b5e 100644 --- a/tests/unit/keyinput/key_data.py +++ b/tests/unit/keyinput/key_data.py @@ -41,7 +41,7 @@ class Key: """ attribute = attr.ib() - name = attr.ib(None) # default: name == attribute + name = attr.ib(None) text = attr.ib('') uppertext = attr.ib('') member = attr.ib(None) @@ -49,6 +49,8 @@ class Key: def __attrs_post_init__(self): self.member = getattr(Qt, 'Key_' + self.attribute, None) + if self.name is None: + self.name = self.attribute # From enum Key in qt5/qtbase/src/corelib/global/qnamespace.h diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 220d16dc6..7fc6cc12c 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -28,6 +28,10 @@ from qutebrowser.keyinput import keyutils @pytest.fixture(params=key_data.KEYS, ids=lambda k: k.attribute) def qt_key(request): + """Get all existing keys from key_data.py. + + Keys which don't exist with this Qt version result in skipped tests. + """ key = request.param if key.member is None: pytest.skip("Did not find key {}".format(key.attribute)) @@ -37,10 +41,21 @@ def qt_key(request): @pytest.fixture(params=[key for key in key_data.KEYS if key.qtest], ids=lambda k: k.attribute) def qtest_key(request): + """Get keys from key_data.py which can be used with QTest.""" return request.param -class KeyTestWidget(QWidget): +def test_key_data(): + """Make sure all possible keys are in key_data.KEYS.""" + key_names = {name[len("Key_"):] + for name, value in sorted(vars(Qt).items()) + if isinstance(value, Qt.Key)} + key_data_names = {key.attribute for key in sorted(key_data.KEYS)} + diff = key_names - key_data_names + assert not diff + + +class KeyTesterWidget(QWidget): """Widget to get the text of QKeyPressEvents. @@ -59,35 +74,42 @@ class KeyTestWidget(QWidget): self.got_text.emit() -class TestKeyText: +class TestKeyInfoText: @pytest.mark.parametrize('upper', [False, True]) - def test_key_text(self, qt_key, upper): + def test_text(self, qt_key, upper): + """Test KeyInfo.text() with all possible keys. + + See key_data.py for inputs and expected values. + """ modifiers = Qt.ShiftModifier if upper else Qt.KeyboardModifiers() info = keyutils.KeyInfo(qt_key.member, modifiers=modifiers) expected = qt_key.uppertext if upper else qt_key.text assert info.text() == expected @pytest.fixture - def key_test(self, qtbot): - w = KeyTestWidget() + def key_tester(self, qtbot): + w = KeyTesterWidget() qtbot.add_widget(w) return w - def test_key_test_qtest(self, qtest_key, qtbot, key_test): - with qtbot.wait_signal(key_test.got_text): - qtbot.keyPress(key_test, qtest_key.member) + def test_text_qtest(self, qtest_key, qtbot, key_tester): + """Make sure KeyInfo.text() lines up with QTest::keyToAscii. + + See key_data.py for inputs and expected values. + """ + with qtbot.wait_signal(key_tester.got_text): + qtbot.keyPress(key_tester, qtest_key.member) info = keyutils.KeyInfo(qtest_key.member, modifiers=Qt.KeyboardModifiers()) - assert info.text() == key_test.text.lower() + assert info.text() == key_tester.text.lower() class TestKeyToString: def test_to_string(self, qt_key): - name = qt_key.attribute if qt_key.name is None else qt_key.name - assert keyutils._key_to_string(qt_key.member) == name + assert keyutils._key_to_string(qt_key.member) == qt_key.name def test_missing(self, monkeypatch): monkeypatch.delattr(keyutils.Qt, 'Key_Blue') @@ -95,57 +117,24 @@ class TestKeyToString: # want to know if the mapping still behaves properly. assert keyutils._key_to_string(Qt.Key_A) == 'A' - def test_all(self): - """Make sure all possible keys are in key_data.KEYS.""" - key_names = {name[len("Key_"):] - for name, value in sorted(vars(Qt).items()) - if isinstance(value, Qt.Key)} - key_data_names = {key.attribute for key in sorted(key_data.KEYS)} - diff = key_names - key_data_names - assert not diff +@pytest.mark.parametrize('key, modifiers, expected', [ + (Qt.Key_A, Qt.NoModifier, 'a'), + (Qt.Key_A, Qt.ShiftModifier, 'A'), -class TestKeyEventToString: + (Qt.Key_Tab, Qt.ShiftModifier, ''), + (Qt.Key_A, Qt.ControlModifier, ''), + (Qt.Key_A, Qt.ControlModifier | Qt.ShiftModifier, ''), + (Qt.Key_A, + Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier | Qt.ShiftModifier, + ''), - """Test keyevent_to_string.""" + (Qt.Key_Shift, Qt.ShiftModifier, ''), + (Qt.Key_Shift, Qt.ShiftModifier | Qt.ControlModifier, ''), +]) +def test_key_info_str(key, modifiers, expected): + assert str(keyutils.KeyInfo(key, modifiers)) == expected - def test_only_control(self, fake_keyevent_factory): - """Test keyeevent when only control is pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_Control, - modifiers=Qt.ControlModifier) - assert str(keyutils.KeyInfo.from_event(evt)) == '' - - def test_only_key(self, fake_keyevent_factory): - """Test with a simple key pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_A) - assert str(keyutils.KeyInfo.from_event(evt)) == 'a' - - def test_key_and_modifier(self, fake_keyevent_factory): - """Test with key and modifier pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) - expected = '' if utils.is_mac else '' - assert str(keyutils.KeyInfo.from_event(evt)) == expected - - def test_key_and_modifiers(self, fake_keyevent_factory): - """Test with key and multiple modifiers pressed.""" - evt = fake_keyevent_factory( - key=Qt.Key_A, modifiers=(Qt.ControlModifier | Qt.AltModifier | - Qt.MetaModifier | Qt.ShiftModifier)) - s = str(keyutils.KeyInfo.from_event(evt)) - assert s == '' - - def test_modifier_key(self, fake_keyevent_factory): - evt = fake_keyevent_factory(key=Qt.Key_Shift, - modifiers=Qt.ShiftModifier) - s = str(keyutils.KeyInfo.from_event(evt)) - assert s == '' - - def test_modifier_key_with_modifiers(self, fake_keyevent_factory): - evt = fake_keyevent_factory(key=Qt.Key_Shift, - modifiers=(Qt.ShiftModifier | - Qt.ControlModifier)) - s = str(keyutils.KeyInfo.from_event(evt)) - assert s == '' @pytest.mark.parametrize('keystr, expected', [ ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), @@ -170,18 +159,17 @@ def test_parse(keystr, expected): @pytest.mark.parametrize('orig, normalized', [ - ('', ''), - ('', ''), - ('', ''), - ('', ''), - ('', ''), - ('', ''), - ('', ''), - ('', '') + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', '') ]) def test_normalize_keystr(orig, normalized): - expected = keyutils.KeySequence.parse(normalized) - assert keyutils.KeySequence.parse(orig) == expected + assert str(keyutils.KeySequence.parse(orig)) == normalized @pytest.mark.parametrize('key, printable', [ @@ -194,7 +182,7 @@ def test_normalize_keystr(orig, normalized): (Qt.Key_Enter, False), (Qt.Key_X | Qt.ControlModifier, False), # Wrong usage - (Qt.Key_Space, True), + (Qt.Key_Space, True), # FIXME broken with upper/lower! (Qt.Key_ydiaeresis, True), (Qt.Key_X, True), ]) From 7f8508a367fdbd218b5e50ee018c611ca4030f7a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 3 Mar 2018 23:00:02 +0100 Subject: [PATCH 375/524] Change the way Space keybindings are handled Using it as " " in a keystring won't work anymore, but instead and does. --- qutebrowser/keyinput/keyutils.py | 4 ++-- tests/unit/keyinput/test_keyutils.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 61e3e53b4..3518e53dd 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -40,7 +40,7 @@ _MODIFIER_MAP = { def is_printable(key): - return key <= 0xff + return key <= 0xff and key != Qt.Key_Space def is_modifier_key(key): @@ -221,7 +221,7 @@ class KeyInfo: modifiers &= ~_MODIFIER_MAP[self.key] elif is_printable(self.key): # "normal" binding - assert len(key_string) == 1 or self.key == Qt.Key_Space, key_string + assert len(key_string) == 1, key_string if self.modifiers == Qt.ShiftModifier: return key_string.upper() elif self.modifiers == Qt.NoModifier: diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 7fc6cc12c..2e595c19a 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -122,6 +122,8 @@ class TestKeyToString: (Qt.Key_A, Qt.NoModifier, 'a'), (Qt.Key_A, Qt.ShiftModifier, 'A'), + (Qt.Key_Space, Qt.NoModifier, ''), + (Qt.Key_Space, Qt.ShiftModifier, ''), (Qt.Key_Tab, Qt.ShiftModifier, ''), (Qt.Key_A, Qt.ControlModifier, ''), (Qt.Key_A, Qt.ControlModifier | Qt.ShiftModifier, ''), @@ -180,9 +182,9 @@ def test_normalize_keystr(orig, normalized): (Qt.Key_Backspace, False), (Qt.Key_Return, False), (Qt.Key_Enter, False), + (Qt.Key_Space, False), (Qt.Key_X | Qt.ControlModifier, False), # Wrong usage - (Qt.Key_Space, True), # FIXME broken with upper/lower! (Qt.Key_ydiaeresis, True), (Qt.Key_X, True), ]) From 3c9e8ff9ab6cec113682837348c77e963ec94256 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 3 Mar 2018 23:22:03 +0100 Subject: [PATCH 376/524] Test and fix keyutils._parse_keystring --- qutebrowser/keyinput/keyutils.py | 12 ++++++++---- tests/unit/keyinput/test_keyutils.py | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 3518e53dd..1741fbe33 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -151,10 +151,14 @@ def _parse_keystring(keystr): special = False for c in keystr: if c == '>': - assert special - yield _normalize_keystr(key) - key = '' - special = False + if special: + yield _normalize_keystr(key) + key = '' + special = False + else: + yield '>' + for c in key: + yield 'Shift+' + c if c.isupper() else c elif c == '<': special = True elif special: diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 2e595c19a..f8b8382cd 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -138,6 +138,31 @@ def test_key_info_str(key, modifiers, expected): assert str(keyutils.KeyInfo(key, modifiers)) == expected +@pytest.mark.parametrize('keystr, expected', [ + ('foo', "Could not parse 'foo': error"), + (None, "Could not parse keystring: error"), +]) +def test_key_parse_error(keystr, expected): + exc = keyutils.KeyParseError(keystr, "error") + assert str(exc) == expected + + +@pytest.mark.parametrize('keystr, parts', [ + ('a', ['a']), + ('ab', ['a', 'b']), + ('a<', ['a', '<']), + ('a>', ['a', '>']), + ('a', ['>', 'a']), + ('aA', ['a', 'Shift+A']), + ('ab', ['a', 'ctrl+a', 'b']), + ('a', ['ctrl+a', 'a']), + ('a', ['a', 'ctrl+a']), +]) +def test_parse_keystr(keystr, parts): + assert list(keyutils._parse_keystring(keystr)) == parts + + @pytest.mark.parametrize('keystr, expected', [ ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), ('', keyutils.KeySequence(Qt.MetaModifier | Qt.Key_X)), From fb7c75a09092c5f7980f01d2af97f5a8c0030abe Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 3 Mar 2018 23:29:16 +0100 Subject: [PATCH 377/524] Improve keyutils tests --- qutebrowser/keyinput/keyutils.py | 3 +-- tests/unit/keyinput/test_keyutils.py | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 1741fbe33..6dbebfef0 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -157,8 +157,7 @@ def _parse_keystring(keystr): special = False else: yield '>' - for c in key: - yield 'Shift+' + c if c.isupper() else c + assert not key, key elif c == '<': special = True elif special: diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index f8b8382cd..b9d770c5c 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -18,7 +18,8 @@ # along with qutebrowser. If not, see . import pytest -from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtCore import Qt, QEvent, pyqtSignal +from PyQt5.QtGui import QKeyEvent from PyQt5.QtWidgets import QWidget from tests.unit.keyinput import key_data @@ -112,7 +113,7 @@ class TestKeyToString: assert keyutils._key_to_string(qt_key.member) == qt_key.name def test_missing(self, monkeypatch): - monkeypatch.delattr(keyutils.Qt, 'Key_Blue') + monkeypatch.delattr(keyutils.Qt, 'Key_AltGr') # We don't want to test the key which is actually missing - we only # want to know if the mapping still behaves properly. assert keyutils._key_to_string(Qt.Key_A) == 'A' @@ -199,6 +200,21 @@ def test_normalize_keystr(orig, normalized): assert str(keyutils.KeySequence.parse(orig)) == normalized +def test_key_info_from_event(): + ev = QKeyEvent(QEvent.KeyPress, Qt.Key_A, Qt.ShiftModifier, 'A') + info = keyutils.KeyInfo.from_event(ev) + assert info.key == Qt.Key_A + assert info.modifiers == Qt.ShiftModifier + + +def test_key_info_to_event(): + info = keyutils.KeyInfo(Qt.Key_A, Qt.ShiftModifier) + ev = info.to_event() + assert ev.key() == Qt.Key_A + assert ev.modifiers() == Qt.ShiftModifier + assert ev.text() == 'A' + + @pytest.mark.parametrize('key, printable', [ (Qt.Key_Control, False), (Qt.Key_Escape, False), From 3649a368692c0ebae5626bc6f161d147dea0b948 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 17:37:07 +0100 Subject: [PATCH 378/524] KeySequence: Add __le__/__ge__ --- qutebrowser/keyinput/keyutils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 6dbebfef0..90b6dae96 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -318,6 +318,14 @@ class KeySequence: # pylint: disable=protected-access return self._sequences > other._sequences + def __le__(self, other): + # pylint: disable=protected-access + return self._sequences <= other._sequences + + def __ge__(self, other): + # pylint: disable=protected-access + return self._sequences >= other._sequences + def __eq__(self, other): # pylint: disable=protected-access return self._sequences == other._sequences From 68db8d04adb2caf4a56475f9ea9b605e04105af1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 17:37:37 +0100 Subject: [PATCH 379/524] KeySequence: Make sure we got valid key codes --- qutebrowser/keyinput/keyutils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 90b6dae96..50041b75a 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -356,6 +356,7 @@ class KeySequence: def _validate(self, keystr=None): for info in self: + assert Qt.Key_Space <= info.key <= Qt.Key_unknown, info.key if info.key == Qt.Key_unknown: raise KeyParseError(keystr, "Got unknown key!") From 866c758660eef58783374823ed82d4d40345c302 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 17:38:49 +0100 Subject: [PATCH 380/524] Add more KeySequence tests --- tests/unit/keyinput/test_keyutils.py | 177 ++++++++++++++++++++++----- 1 file changed, 146 insertions(+), 31 deletions(-) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index b9d770c5c..0528618a8 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import operator + import pytest from PyQt5.QtCore import Qt, QEvent, pyqtSignal from PyQt5.QtGui import QKeyEvent @@ -164,40 +166,153 @@ def test_parse_keystr(keystr, parts): assert list(keyutils._parse_keystring(keystr)) == parts -@pytest.mark.parametrize('keystr, expected', [ - ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), - ('', keyutils.KeySequence(Qt.MetaModifier | Qt.Key_X)), - ('', - keyutils.KeySequence(Qt.ControlModifier | Qt.AltModifier | Qt.Key_Y)), - ('x', keyutils.KeySequence(Qt.Key_X)), - ('X', keyutils.KeySequence(Qt.ShiftModifier | Qt.Key_X)), - ('', keyutils.KeySequence(Qt.Key_Escape)), - ('xyz', keyutils.KeySequence(Qt.Key_X, Qt.Key_Y, Qt.Key_Z)), - ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X, - Qt.MetaModifier | Qt.Key_Y)), - ('', keyutils.KeyParseError), - ('\U00010000', keyutils.KeyParseError), -]) -def test_parse(keystr, expected): - if expected is keyutils.KeyParseError: +class TestKeySequence: + + def test_init(self): + seq = keyutils.KeySequence(Qt.Key_A, Qt.Key_B, Qt.Key_C, Qt.Key_D, + Qt.Key_E) + assert len(seq._sequences) == 2 + assert len(seq._sequences[0]) == 4 + assert len(seq._sequences[1]) == 1 + + def test_init_empty(self): + seq = keyutils.KeySequence() + assert not seq + + def test_init_unknown(self): with pytest.raises(keyutils.KeyParseError): - keyutils.KeySequence.parse(keystr) - else: - assert keyutils.KeySequence.parse(keystr) == expected + keyutils.KeySequence(Qt.Key_unknown) + + def test_init_invalid(self): + with pytest.raises(AssertionError): + keyutils.KeySequence(-1) + + @pytest.mark.parametrize('orig, normalized', [ + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('
b', 'ab'), + ]) + def test_str_normalization(self, orig, normalized): + assert str(keyutils.KeySequence.parse(orig)) == normalized + + def test_iter(self): + seq = keyutils.KeySequence(Qt.Key_A | Qt.ControlModifier, + Qt.Key_B | Qt.ShiftModifier, + Qt.Key_C, + Qt.Key_D, + Qt.Key_E) + expected = [keyutils.KeyInfo(Qt.Key_A, Qt.ControlModifier), + keyutils.KeyInfo(Qt.Key_B, Qt.ShiftModifier), + keyutils.KeyInfo(Qt.Key_C, Qt.NoModifier), + keyutils.KeyInfo(Qt.Key_D, Qt.NoModifier), + keyutils.KeyInfo(Qt.Key_E, Qt.NoModifier)] + assert list(seq) == expected + + def test_repr(self): + seq = keyutils.KeySequence(Qt.Key_A | Qt.ControlModifier, + Qt.Key_B | Qt.ShiftModifier) + assert repr(seq) == ("") + + @pytest.mark.parametrize('sequences, expected', [ + (['a', ''], ['', 'a']), + (['abcdf', 'abcd', 'abcde'], ['abcd', 'abcde', 'abcdf']), + ]) + def test_sorting(self, sequences, expected): + result = sorted(keyutils.KeySequence.parse(seq) for seq in sequences) + expected_result = [keyutils.KeySequence.parse(seq) for seq in expected] + assert result == expected_result + + @pytest.mark.parametrize('seq1, seq2, op, result', [ + ('a', 'a', operator.eq, True), + ('a', '', operator.eq, True), + ('a', '', operator.eq, False), + ('a', 'b', operator.lt, True), + ('a', 'b', operator.le, True), + ]) + def test_operators(self, seq1, seq2, op, result): + seq1 = keyutils.KeySequence.parse(seq1) + seq2 = keyutils.KeySequence.parse(seq2) + assert op(seq1, seq2) == result + + opposite = { + operator.lt: operator.ge, + operator.gt: operator.le, + operator.le: operator.gt, + operator.ge: operator.lt, + operator.eq: operator.ne, + operator.ne: operator.eq, + } + assert opposite[op](seq1, seq2) != result + + @pytest.mark.parametrize('seq1, seq2, equal', [ + ('a', 'a', True), + ('a', 'A', False), + ('a', '', True), + ('abcd', 'abcde', False), + ]) + def test_hash(self, seq1, seq2, equal): + seq1 = keyutils.KeySequence.parse(seq1) + seq2 = keyutils.KeySequence.parse(seq2) + assert (hash(seq1) == hash(seq2)) == equal + + @pytest.mark.parametrize('seq, length', [ + ('', 0), + ('a', 1), + ('A', 1), + ('', 1), + ('abcde', 5) + ]) + def test_len(self, seq, length): + assert len(keyutils.KeySequence.parse(seq)) == length + + def test_bool(self): + seq1 = keyutils.KeySequence.parse('abcd') + seq2 = keyutils.KeySequence() + assert seq1 + assert not seq2 + + def test_getitem(self): + seq = keyutils.KeySequence.parse('ab') + expected = keyutils.KeyInfo(Qt.Key_B, Qt.NoModifier) + assert seq[1] == expected + + def test_getitem_slice(self): + s1 = 'abcdef' + s2 = 'de' + seq = keyutils.KeySequence.parse(s1) + expected = keyutils.KeySequence.parse(s2) + assert s1[3:5] == s2 + assert seq[3:5] == expected -@pytest.mark.parametrize('orig, normalized', [ - ('', ''), - ('', ''), - ('', ''), - ('', ''), - ('', ''), - ('', ''), - ('', ''), - ('', '') -]) -def test_normalize_keystr(orig, normalized): - assert str(keyutils.KeySequence.parse(orig)) == normalized + @pytest.mark.parametrize('keystr, expected', [ + ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), + ('', keyutils.KeySequence(Qt.MetaModifier | Qt.Key_X)), + ('', + keyutils.KeySequence(Qt.ControlModifier | Qt.AltModifier | Qt.Key_Y)), + ('x', keyutils.KeySequence(Qt.Key_X)), + ('X', keyutils.KeySequence(Qt.ShiftModifier | Qt.Key_X)), + ('', keyutils.KeySequence(Qt.Key_Escape)), + ('xyz', keyutils.KeySequence(Qt.Key_X, Qt.Key_Y, Qt.Key_Z)), + ('', + keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X, + Qt.MetaModifier | Qt.Key_Y)), + ('', keyutils.KeyParseError), + ('\U00010000', keyutils.KeyParseError), + ]) + def test_parse(self, keystr, expected): + if expected is keyutils.KeyParseError: + with pytest.raises(keyutils.KeyParseError): + keyutils.KeySequence.parse(keystr) + else: + assert keyutils.KeySequence.parse(keystr) == expected def test_key_info_from_event(): From 8da878c77cb217f14057cea040e8406536f2282b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 17:46:26 +0100 Subject: [PATCH 381/524] Make KeySequence.matchs() work correctly --- qutebrowser/keyinput/keyutils.py | 35 +++++++++++++++++++++++----- tests/unit/keyinput/test_keyutils.py | 30 +++++++++++++++++++++++- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 50041b75a..ddc78b2b4 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -361,15 +361,38 @@ class KeySequence: raise KeyParseError(keystr, "Got unknown key!") def matches(self, other): - """Check whether the given KeySequence matches with this one.""" + """Check whether the given KeySequence matches with this one. + + We store multiple QKeySequences with <= 4 keys each, so we need to match + those pair-wise, and account for an unequal amount of sequences as well. + """ # pylint: disable=protected-access - assert self._sequences - assert other._sequences - for seq1, seq2 in zip(self._sequences, other._sequences): - match = seq1.matches(seq2) + + if len(self._sequences) > len(other._sequences): + # If we entered more sequences than there are in the config, there's + # no way there can be a match. + return QKeySequence.NoMatch + + for entered, configured in zip(self._sequences, other._sequences): + # If we get NoMatch/PartialMatch in a sequence, we can abort there. + match = entered.matches(configured) if match != QKeySequence.ExactMatch: return match - return QKeySequence.ExactMatch + + # We checked all common sequences and they had an ExactMatch. + # + # If there's still more sequences configured than entered, that's a + # PartialMatch, as more keypresses can still follow and new sequences + # will appear which we didn't check above. + # + # If there's the same amount of sequences configured and entered, that's + # an EqualMatch. + if len(self._sequences) == len(other._sequences): + return QKeySequence.ExactMatch + elif len(self._sequences) < len(other._sequences): + return QKeySequence.PartialMatch + else: + assert False, (self, other) def append_event(self, ev): """Create a new KeySequence object with the given QKeyEvent added. diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 0528618a8..0f13c762d 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -21,7 +21,7 @@ import operator import pytest from PyQt5.QtCore import Qt, QEvent, pyqtSignal -from PyQt5.QtGui import QKeyEvent +from PyQt5.QtGui import QKeyEvent, QKeySequence from PyQt5.QtWidgets import QWidget from tests.unit.keyinput import key_data @@ -291,6 +291,34 @@ class TestKeySequence: assert s1[3:5] == s2 assert seq[3:5] == expected + @pytest.mark.parametrize('entered, configured, expected', [ + # config: abcd + ('abc', 'abcd', QKeySequence.PartialMatch), + ('abcd', 'abcd', QKeySequence.ExactMatch), + ('ax', 'abcd', QKeySequence.NoMatch), + ('abcdef', 'abcd', QKeySequence.NoMatch), + + # config: abcd ef + ('abc', 'abcdef', QKeySequence.PartialMatch), + ('abcde', 'abcdef', QKeySequence.PartialMatch), + ('abcd', 'abcdef', QKeySequence.PartialMatch), + ('abcdx', 'abcdef', QKeySequence.NoMatch), + ('ax', 'abcdef', QKeySequence.NoMatch), + ('abcdefg', 'abcdef', QKeySequence.NoMatch), + ('abcdef', 'abcdef', QKeySequence.ExactMatch), + + # other examples + ('ab', 'a', QKeySequence.NoMatch), + + # empty strings + ('', '', QKeySequence.ExactMatch), + ('', 'a', QKeySequence.PartialMatch), + ('a', '', QKeySequence.NoMatch), + ]) + def test_matches(self, entered, configured, expected): + entered = keyutils.KeySequence.parse(entered) + configured = keyutils.KeySequence.parse(configured) + assert entered.matches(configured) == expected @pytest.mark.parametrize('keystr, expected', [ ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), From 2be7db29edd80c3020c9f1d91c4d11a98eb4c8d2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 19:12:24 +0100 Subject: [PATCH 382/524] 100% coverage for keyinput.keyutils --- qutebrowser/keyinput/keyutils.py | 2 +- scripts/dev/check_coverage.py | 2 ++ tests/unit/keyinput/test_keyutils.py | 24 ++++++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index ddc78b2b4..fd973eb80 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -391,7 +391,7 @@ class KeySequence: return QKeySequence.ExactMatch elif len(self._sequences) < len(other._sequences): return QKeySequence.PartialMatch - else: + else: # pragma: no cover assert False, (self, other) def append_event(self, ev): diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 0e79a6e02..b40188429 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -86,6 +86,8 @@ PERFECT_FILES = [ ('tests/unit/keyinput/test_basekeyparser.py', 'keyinput/basekeyparser.py'), + ('tests/unit/keyinput/test_keyutils.py', + 'keyinput/keyutils.py'), ('tests/unit/misc/test_autoupdate.py', 'misc/autoupdate.py'), diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 0f13c762d..3f03f883d 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -320,6 +320,30 @@ class TestKeySequence: configured = keyutils.KeySequence.parse(configured) assert entered.matches(configured) == expected + @pytest.mark.parametrize('old, key, modifiers, text, expected', [ + ('a', Qt.Key_B, Qt.NoModifier, 'b', 'ab'), + ('a', Qt.Key_B, Qt.ShiftModifier, 'B', 'aB'), + ('a', Qt.Key_B, Qt.ControlModifier | Qt.ShiftModifier, 'B', + 'a'), + + # Modifier stripping with symbols + ('', Qt.Key_Colon, Qt.NoModifier, ':', ':'), + ('', Qt.Key_Colon, Qt.ShiftModifier, ':', ':'), + ('', Qt.Key_Colon, Qt.ControlModifier | Qt.ShiftModifier, ':', + ''), + + # Handling of Backtab + ('', Qt.Key_Backtab, Qt.NoModifier, '', ''), + ('', Qt.Key_Backtab, Qt.ShiftModifier, '', ''), + ('', Qt.Key_Backtab, Qt.ControlModifier | Qt.ShiftModifier, '', + ''), + ]) + def test_append_event(self, old, key, modifiers, text, expected): + seq = keyutils.KeySequence.parse(old) + event = QKeyEvent(QKeyEvent.KeyPress, key, modifiers, text) + new = seq.append_event(event) + assert new == keyutils.KeySequence.parse(expected) + @pytest.mark.parametrize('keystr, expected', [ ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), ('', keyutils.KeySequence(Qt.MetaModifier | Qt.Key_X)), From f85e69ec7762a0fb7106d5f73a77438d8492b4bc Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 19:38:15 +0100 Subject: [PATCH 383/524] Refactor other keyinput tests --- tests/helpers/fixtures.py | 18 +-- tests/unit/keyinput/conftest.py | 18 ++- tests/unit/keyinput/test_basekeyparser.py | 137 ++++++++-------------- tests/unit/keyinput/test_modeman.py | 15 ++- tests/unit/keyinput/test_modeparsers.py | 12 +- 5 files changed, 77 insertions(+), 123 deletions(-) diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index 193a40a8a..d30514f83 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -35,8 +35,7 @@ import types import attr import pytest import py.path # pylint: disable=no-name-in-module -from PyQt5.QtCore import QEvent, QSize, Qt -from PyQt5.QtGui import QKeyEvent +from PyQt5.QtCore import QSize, Qt from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout from PyQt5.QtNetwork import QNetworkCookieJar @@ -354,21 +353,6 @@ def webframe(webpage): return webpage.mainFrame() -@pytest.fixture -def fake_keyevent_factory(): - """Fixture that when called will return a mock instance of a QKeyEvent.""" - def fake_keyevent(key, modifiers=0, text='', typ=QEvent.KeyPress): - """Generate a new fake QKeyPressEvent.""" - evtmock = unittest.mock.create_autospec(QKeyEvent, instance=True) - evtmock.key.return_value = key - evtmock.modifiers.return_value = modifiers - evtmock.text.return_value = text - evtmock.type.return_value = typ - return evtmock - - return fake_keyevent - - @pytest.fixture def cookiejar_and_cache(stubs): """Fixture providing a fake cookie jar and cache.""" diff --git a/tests/unit/keyinput/conftest.py b/tests/unit/keyinput/conftest.py index f78ed61ad..96d07f744 100644 --- a/tests/unit/keyinput/conftest.py +++ b/tests/unit/keyinput/conftest.py @@ -21,6 +21,11 @@ import pytest +from PyQt5.QtCore import QEvent, Qt +from PyQt5.QtGui import QKeyEvent + +from qutebrowser.keyinput import keyutils + BINDINGS = {'prompt': {'': 'message-info ctrla', 'a': 'message-info a', @@ -33,8 +38,6 @@ BINDINGS = {'prompt': {'': 'message-info ctrla', '': 'message-info ctrlx'}, 'normal': {'a': 'message-info a', 'ba': 'message-info ba'}} MAPPINGS = { - '': 'a', - '': '', 'x': 'a', 'b': 'a', } @@ -46,3 +49,14 @@ def keyinput_bindings(config_stub, key_config_stub): config_stub.val.bindings.default = {} config_stub.val.bindings.commands = dict(BINDINGS) config_stub.val.bindings.key_mappings = dict(MAPPINGS) + + +@pytest.fixture +def fake_keyevent(): + """Fixture that when called will return a mock instance of a QKeyEvent.""" + def func(key, modifiers=Qt.NoModifier, typ=QEvent.KeyPress): + """Generate a new fake QKeyPressEvent.""" + text = keyutils.KeyInfo(key, modifiers).text() + return QKeyEvent(QKeyEvent.KeyPress, key, modifiers, text) + + return func diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 872709d6e..95860399d 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -42,15 +42,15 @@ def keyparser(key_config_stub): @pytest.fixture -def handle_text(fake_keyevent_factory, keyparser): +def handle_text(fake_keyevent, keyparser): """Helper function to handle multiple fake keypresses. Automatically uses the keyparser of the current test via the keyparser fixture. """ def func(*args): - for enumval, text in args: - keyparser.handle(fake_keyevent_factory(enumval, text=text)) + for enumval in args: + keyparser.handle(fake_keyevent(enumval)) return func @@ -76,6 +76,7 @@ class TestDebugLog: ('10g', True, '10', 'g'), ('10e4g', True, '4', 'g'), ('g', True, '', 'g'), + ('0', True, '', ''), ('10g', False, '', 'g'), ]) def test_split_count(config_stub, key_config_stub, @@ -140,115 +141,65 @@ class TestReadConfig: assert (keyseq('new') in keyparser.bindings) == expected -class TestSpecialKeys: - - """Check execute() with special keys.""" +class TestHandle: @pytest.fixture(autouse=True) def read_config(self, keyinput_bindings, keyparser): keyparser._read_config('prompt') - def test_valid_key(self, fake_keyevent_factory, keyparser): + def test_valid_key(self, fake_keyevent, keyparser): modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier - keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) - keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier)) - keyparser.execute.assert_called_once_with('message-info ctrla', None) - - def test_valid_key_count(self, fake_keyevent_factory, keyparser): - modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier - keyparser.handle(fake_keyevent_factory(Qt.Key_5, text='5')) - keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier, text='A')) - keyparser.execute.assert_called_once_with('message-info ctrla', 5) - - def test_invalid_key(self, fake_keyevent_factory, keyparser): - keyparser.handle(fake_keyevent_factory( - Qt.Key_A, (Qt.ControlModifier | Qt.AltModifier))) - assert not keyparser.execute.called - - def test_only_modifiers(self, monkeypatch, fake_keyevent_factory, - keyparser): - monkeypatch.setattr(keyutils.KeyInfo, '__str__', lambda _self: '') - keyparser.handle(fake_keyevent_factory(Qt.Key_Shift, Qt.NoModifier)) - assert not keyparser.execute.called - - def test_mapping(self, config_stub, fake_keyevent_factory, keyparser): - modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier - - keyparser.handle(fake_keyevent_factory(Qt.Key_B, modifier)) - keyparser.execute.assert_called_once_with('message-info ctrla', None) - - def test_binding_and_mapping(self, config_stub, fake_keyevent_factory, - keyparser): - """with a conflicting binding/mapping, the binding should win.""" - modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier - - keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) - keyparser.execute.assert_called_once_with('message-info ctrla', None) - - -class TestKeyChain: - - """Test execute() with keychain support.""" - - @pytest.fixture(autouse=True) - def read_config(self, keyinput_bindings, keyparser): - keyparser._read_config('prompt') - - def test_valid_special_key(self, fake_keyevent_factory, keyparser): - if utils.is_mac: - modifier = Qt.MetaModifier - else: - modifier = Qt.ControlModifier - keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) - keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier)) + keyparser.handle(fake_keyevent(Qt.Key_A, modifier)) + keyparser.handle(fake_keyevent(Qt.Key_X, modifier)) keyparser.execute.assert_called_once_with('message-info ctrla', None) assert not keyparser._sequence - def test_invalid_special_key(self, fake_keyevent_factory, keyparser): - keyparser.handle(fake_keyevent_factory( - Qt.Key_A, (Qt.ControlModifier | Qt.AltModifier))) + def test_valid_key_count(self, fake_keyevent, keyparser): + modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier + keyparser.handle(fake_keyevent(Qt.Key_5)) + keyparser.handle(fake_keyevent(Qt.Key_A, modifier)) + keyparser.execute.assert_called_once_with('message-info ctrla', 5) + + @pytest.mark.parametrize('keys', [ + [(Qt.Key_B, Qt.NoModifier), (Qt.Key_C, Qt.NoModifier)], + [(Qt.Key_A, Qt.ControlModifier | Qt.AltModifier)], + # Only modifier + [(Qt.Key_Shift, Qt.ShiftModifier)], + ]) + def test_invalid_keys(self, fake_keyevent, keyparser, keys): + for key, modifiers in keys: + keyparser.handle(fake_keyevent(key, modifiers)) assert not keyparser.execute.called assert not keyparser._sequence def test_valid_keychain(self, handle_text, keyparser): # Press 'x' which is ignored because of no match - handle_text((Qt.Key_X, 'x'), + handle_text(Qt.Key_X, # Then start the real chain - (Qt.Key_B, 'b'), (Qt.Key_A, 'a')) + Qt.Key_B, Qt.Key_A) keyparser.execute.assert_called_with('message-info ba', None) assert not keyparser._sequence def test_0_press(self, handle_text, keyparser): - handle_text((Qt.Key_0, '0')) + handle_text(Qt.Key_0) keyparser.execute.assert_called_once_with('message-info 0', None) assert not keyparser._sequence - def test_ambiguous_keychain(self, handle_text, keyparser): - handle_text((Qt.Key_A, 'a')) - assert keyparser.execute.called - - def test_invalid_keychain(self, handle_text, keyparser): - handle_text((Qt.Key_B, 'b')) - handle_text((Qt.Key_C, 'c')) - assert not keyparser._sequence - def test_mapping(self, config_stub, handle_text, keyparser): - handle_text((Qt.Key_X, 'x')) + handle_text(Qt.Key_X) keyparser.execute.assert_called_once_with('message-info a', None) def test_binding_and_mapping(self, config_stub, handle_text, keyparser): """with a conflicting binding/mapping, the binding should win.""" - handle_text((Qt.Key_B, 'b')) + handle_text(Qt.Key_B) assert not keyparser.execute.called - def test_binding_with_shift(self, keyparser, fake_keyevent_factory): + def test_binding_with_shift(self, keyparser, fake_keyevent): """Simulate a binding which involves shift.""" - keyparser.handle( - fake_keyevent_factory(Qt.Key_Y, text='y')) - keyparser.handle( - fake_keyevent_factory(Qt.Key_Shift, Qt.ShiftModifier, text='')) - keyparser.handle( - fake_keyevent_factory(Qt.Key_Y, Qt.ShiftModifier, text='Y')) + for key, modifiers in [(Qt.Key_Y, Qt.NoModifier), + (Qt.Key_Shift, Qt.ShiftModifier), + (Qt.Key_Y, Qt.ShiftModifier)]: + keyparser.handle(fake_keyevent(key, modifiers)) keyparser.execute.assert_called_once_with('yank -s', None) @@ -263,32 +214,29 @@ class TestCount: def test_no_count(self, handle_text, keyparser): """Test with no count added.""" - handle_text((Qt.Key_B, 'b'), (Qt.Key_A, 'a')) + handle_text(Qt.Key_B, Qt.Key_A) keyparser.execute.assert_called_once_with('message-info ba', None) assert not keyparser._sequence def test_count_0(self, handle_text, keyparser): - handle_text((Qt.Key_0, '0'), (Qt.Key_B, 'b'), (Qt.Key_A, 'a')) + handle_text(Qt.Key_0, Qt.Key_B, Qt.Key_A) calls = [mock.call('message-info 0', None), mock.call('message-info ba', None)] keyparser.execute.assert_has_calls(calls) assert not keyparser._sequence def test_count_42(self, handle_text, keyparser): - handle_text((Qt.Key_4, '4'), (Qt.Key_2, '2'), (Qt.Key_B, 'b'), - (Qt.Key_A, 'a')) + handle_text(Qt.Key_4, Qt.Key_2, Qt.Key_B, Qt.Key_A) keyparser.execute.assert_called_once_with('message-info ba', 42) assert not keyparser._sequence def test_count_42_invalid(self, handle_text, keyparser): # Invalid call with ccx gets ignored - handle_text((Qt.Key_4, '4'), (Qt.Key_2, '2'), (Qt.Key_C, 'c'), - (Qt.Key_C, 'c'), (Qt.Key_X, 'x')) + handle_text(Qt.Key_4, Qt.Key_2, Qt.Key_C, Qt.Key_C, Qt.Key_X) assert not keyparser.execute.called assert not keyparser._sequence # Valid call with ccc gets the correct count - handle_text((Qt.Key_2, '2'), (Qt.Key_3, '3'), (Qt.Key_C, 'c'), - (Qt.Key_C, 'c'), (Qt.Key_C, 'c')) + handle_text(Qt.Key_2, Qt.Key_3, Qt.Key_C, Qt.Key_C, Qt.Key_C) keyparser.execute.assert_called_once_with('message-info ccc', 23) assert not keyparser._sequence @@ -296,6 +244,15 @@ class TestCount: def test_clear_keystring(qtbot, keyparser): """Test that the keystring is cleared and the signal is emitted.""" keyparser._sequence = keyseq('test') + keyparser._count = '23' with qtbot.waitSignal(keyparser.keystring_updated): keyparser.clear_keystring() assert not keyparser._sequence + assert not keyparser._count + + +def test_clear_keystring_empty(qtbot, keyparser): + """Test that no signal is emitted when clearing an empty keystring..""" + keyparser._sequence = keyseq('') + with qtbot.assert_not_emitted(keyparser.keystring_updated): + keyparser.clear_keystring() diff --git a/tests/unit/keyinput/test_modeman.py b/tests/unit/keyinput/test_modeman.py index 221b675be..de9671961 100644 --- a/tests/unit/keyinput/test_modeman.py +++ b/tests/unit/keyinput/test_modeman.py @@ -44,15 +44,14 @@ def modeman(mode_manager): return mode_manager -@pytest.mark.parametrize('key, modifiers, text, filtered', [ - (Qt.Key_A, Qt.NoModifier, 'a', True), - (Qt.Key_Up, Qt.NoModifier, '', False), +@pytest.mark.parametrize('key, modifiers, filtered', [ + (Qt.Key_A, Qt.NoModifier, True), + (Qt.Key_Up, Qt.NoModifier, False), # https://github.com/qutebrowser/qutebrowser/issues/1207 - (Qt.Key_A, Qt.ShiftModifier, 'A', True), - (Qt.Key_A, Qt.ShiftModifier | Qt.ControlModifier, 'x', False), + (Qt.Key_A, Qt.ShiftModifier, True), + (Qt.Key_A, Qt.ShiftModifier | Qt.ControlModifier, False), ]) -def test_non_alphanumeric(key, modifiers, text, filtered, - fake_keyevent_factory, modeman): +def test_non_alphanumeric(key, modifiers, filtered, fake_keyevent, modeman): """Make sure non-alphanumeric keys are passed through correctly.""" - evt = fake_keyevent_factory(key=key, modifiers=modifiers, text=text) + evt = fake_keyevent(key=key, modifiers=modifiers) assert modeman.eventFilter(evt) == filtered diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py index d53328b7e..c3be9d70f 100644 --- a/tests/unit/keyinput/test_modeparsers.py +++ b/tests/unit/keyinput/test_modeparsers.py @@ -49,25 +49,25 @@ class TestsNormalKeyParser: kp.execute = mock.Mock() return kp - def test_keychain(self, keyparser, fake_keyevent_factory): + def test_keychain(self, keyparser, fake_keyevent): """Test valid keychain.""" # Press 'x' which is ignored because of no match - keyparser.handle(fake_keyevent_factory(Qt.Key_X, text='x')) + keyparser.handle(fake_keyevent(Qt.Key_X)) # Then start the real chain - keyparser.handle(fake_keyevent_factory(Qt.Key_B, text='b')) - keyparser.handle(fake_keyevent_factory(Qt.Key_A, text='a')) + keyparser.handle(fake_keyevent(Qt.Key_B)) + keyparser.handle(fake_keyevent(Qt.Key_A)) keyparser.execute.assert_called_with('message-info ba', None) assert not keyparser._sequence def test_partial_keychain_timeout(self, keyparser, config_stub, - fake_keyevent_factory): + fake_keyevent): """Test partial keychain timeout.""" config_stub.val.input.partial_timeout = 100 timer = keyparser._partial_timer assert not timer.isActive() # Press 'b' for a partial match. # Then we check if the timer has been set up correctly - keyparser.handle(fake_keyevent_factory(Qt.Key_B, text='b')) + keyparser.handle(fake_keyevent(Qt.Key_B)) assert timer.isSingleShot() assert timer.interval() == 100 assert timer.isActive() From 58b7599152a523850e8bc699e311f6bc11de6e72 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 19:40:03 +0100 Subject: [PATCH 384/524] Remove old fixme --- qutebrowser/config/configdata.yml | 1 - qutebrowser/keyinput/keyutils.py | 1 - 2 files changed, 2 deletions(-) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 7be015fd5..306d744fe 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2401,7 +2401,6 @@ bindings.default: : rl-backward-delete-char : rl-yank : leave-mode - # FIXME can we do migrations? yesno: : prompt-accept y: prompt-accept yes diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index fd973eb80..2da32349a 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -433,7 +433,6 @@ class KeySequence: def parse(cls, keystr): """Parse a keystring like or xyz and return a KeySequence.""" # pylint: disable=protected-access - # FIXME: test stuff like new = cls() strings = list(_parse_keystring(keystr)) for sub in utils.chunk(strings, cls._MAX_LEN): From d8bfe23c0dfabf84ddafba3175b0e07cde75fec6 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 19:43:29 +0100 Subject: [PATCH 385/524] Fix lint --- qutebrowser/keyinput/keyutils.py | 22 +++++++++++----------- tests/unit/keyinput/test_keyutils.py | 3 +-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 2da32349a..dd2668569 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -19,7 +19,6 @@ """Our own QKeySequence-like class and related utilities.""" -import collections import itertools import attr @@ -46,8 +45,8 @@ def is_printable(key): def is_modifier_key(key): """Test whether the given key is a modifier. - This only considers keys which are part of Qt::KeyboardModifiers, i.e. which - would interrupt a key chain like "yY" when handled. + This only considers keys which are part of Qt::KeyboardModifiers, i.e. + which would interrupt a key chain like "yY" when handled. """ return key in _MODIFIER_MAP @@ -363,14 +362,15 @@ class KeySequence: def matches(self, other): """Check whether the given KeySequence matches with this one. - We store multiple QKeySequences with <= 4 keys each, so we need to match - those pair-wise, and account for an unequal amount of sequences as well. + We store multiple QKeySequences with <= 4 keys each, so we need to + match those pair-wise, and account for an unequal amount of sequences + as well. """ # pylint: disable=protected-access if len(self._sequences) > len(other._sequences): - # If we entered more sequences than there are in the config, there's - # no way there can be a match. + # If we entered more sequences than there are in the config, + # there's no way there can be a match. return QKeySequence.NoMatch for entered, configured in zip(self._sequences, other._sequences): @@ -385,14 +385,14 @@ class KeySequence: # PartialMatch, as more keypresses can still follow and new sequences # will appear which we didn't check above. # - # If there's the same amount of sequences configured and entered, that's - # an EqualMatch. + # If there's the same amount of sequences configured and entered, + # that's an EqualMatch. if len(self._sequences) == len(other._sequences): return QKeySequence.ExactMatch elif len(self._sequences) < len(other._sequences): return QKeySequence.PartialMatch - else: # pragma: no cover - assert False, (self, other) + else: + raise utils.Unreachable("self={!r} other={!r}".format(self, other)) def append_event(self, ev): """Create a new KeySequence object with the given QKeyEvent added. diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 3f03f883d..a1e078521 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -25,7 +25,6 @@ from PyQt5.QtGui import QKeyEvent, QKeySequence from PyQt5.QtWidgets import QWidget from tests.unit.keyinput import key_data -from qutebrowser.utils import utils from qutebrowser.keyinput import keyutils @@ -348,7 +347,7 @@ class TestKeySequence: ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), ('', keyutils.KeySequence(Qt.MetaModifier | Qt.Key_X)), ('', - keyutils.KeySequence(Qt.ControlModifier | Qt.AltModifier | Qt.Key_Y)), + keyutils.KeySequence(Qt.ControlModifier | Qt.AltModifier | Qt.Key_Y)), ('x', keyutils.KeySequence(Qt.Key_X)), ('X', keyutils.KeySequence(Qt.ShiftModifier | Qt.Key_X)), ('', keyutils.KeySequence(Qt.Key_Escape)), From c9c0bc0bbd514b30674dee0c8a6592c3c0607a6f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 20:28:46 +0100 Subject: [PATCH 386/524] Update docs --- doc/help/commands.asciidoc | 6 ++++-- doc/help/settings.asciidoc | 2 +- qutebrowser/config/configcommands.py | 6 ++++-- qutebrowser/config/configdata.yml | 1 - qutebrowser/keyinput/modeparsers.py | 2 +- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 2174e69ac..2fc8cd093 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -153,7 +153,8 @@ Bind a key to a command. If no command is given, show the current binding for the given key. Using :bind without any arguments opens a page showing all keybindings. ==== positional arguments -* +'key'+: The keychain or special key (inside `<...>`) to bind. +* +'key'+: The keychain to bind. Examples of valid keychains are `gC`, `` or `a`. + * +'command'+: The command to execute, with optional args. ==== optional arguments @@ -1316,7 +1317,8 @@ Syntax: +:unbind [*--mode* 'mode'] 'key'+ Unbind a keychain. ==== positional arguments -* +'key'+: The keychain or special key (inside <...>) to unbind. +* +'key'+: The keychain to unbind. See the help for `:bind` for the correct syntax for keychains. + ==== optional arguments * +*-m*+, +*--mode*+: A mode to unbind the key in (default: `normal`). See `:help bindings.commands` for the available modes. diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index fc809b98e..216718de1 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -322,7 +322,7 @@ While it's possible to add bindings with this setting, it's recommended to use ` This setting is a dictionary containing mode names and dictionaries mapping keys to commands: `{mode: {key: command}}` If you want to map a key to another key, check the `bindings.key_mappings` setting instead. -For special keys (can't be part of a keychain), enclose them in `<`...`>`. For modifiers, you can use either `-` or `+` as delimiters, and these names: +For modifiers, you can use either `-` or `+` as delimiters, and these names: * Control: `Control`, `Ctrl` diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index f81c21aac..792eacaf0 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -137,7 +137,8 @@ class ConfigCommands: Using :bind without any arguments opens a page showing all keybindings. Args: - key: The keychain or special key (inside `<...>`) to bind. + key: The keychain to bind. Examples of valid keychains are `gC`, + `` or `a`. command: The command to execute, with optional args. mode: A comma-separated list of modes to bind the key in (default: `normal`). See `:help bindings.commands` for the @@ -178,7 +179,8 @@ class ConfigCommands: """Unbind a keychain. Args: - key: The keychain or special key (inside <...>) to unbind. + key: The keychain to unbind. See the help for `:bind` for the + correct syntax for keychains. mode: A mode to unbind the key in (default: `normal`). See `:help bindings.commands` for the available modes. """ diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 306d744fe..dc730bcb3 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2490,7 +2490,6 @@ bindings.commands: If you want to map a key to another key, check the `bindings.key_mappings` setting instead. - For special keys (can't be part of a keychain), enclose them in `<`...`>`. For modifiers, you can use either `-` or `+` as delimiters, and these names: diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index d56d7dcd1..169232e01 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -164,7 +164,7 @@ class HintKeyParser(keyparser.CommandKeyParser): Return: A QKeySequence match. """ - log.keyboard.debug("Got special key 0x{:x} text {}".format( + log.keyboard.debug("Got filter key 0x{:x} text {}".format( e.key(), e.text())) hintmanager = objreg.get('hintmanager', scope='tab', window=self._win_id, tab='current') From 910bbc85216135a6f974e6724a6b40a99bbdd4dc Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 20:40:05 +0100 Subject: [PATCH 387/524] Refactor keyutils._parse_keystring --- qutebrowser/keyinput/keyutils.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index dd2668569..4441bb3b4 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -151,7 +151,7 @@ def _parse_keystring(keystr): for c in keystr: if c == '>': if special: - yield _normalize_keystr(key) + yield _parse_special_key(key) key = '' special = False else: @@ -162,14 +162,14 @@ def _parse_keystring(keystr): elif special: key += c else: - yield 'Shift+' + c if c.isupper() else c + yield _parse_single_key(c) if special: yield '<' for c in key: - yield 'Shift+' + c if c.isupper() else c + yield _parse_single_key(c) -def _normalize_keystr(keystr): +def _parse_special_key(keystr): """Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q. Args: @@ -187,11 +187,17 @@ def _normalize_keystr(keystr): ) for (orig, repl) in replacements: keystr = keystr.replace(orig, repl) + for mod in ['ctrl', 'meta', 'alt', 'shift']: keystr = keystr.replace(mod + '-', mod + '+') return keystr +def _parse_single_key(keystr): + """Get a keystring for QKeySequence for a single key.""" + return 'Shift+' + keystr if keystr.isupper() else keystr + + @attr.s class KeyInfo: From 0967b6abd276daf3f0543af3438fbf39335f8f2d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 20:40:16 +0100 Subject: [PATCH 388/524] Fix handling of keys --- qutebrowser/keyinput/keyutils.py | 2 ++ tests/unit/keyinput/test_keyutils.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 4441bb3b4..9a2c31572 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -184,6 +184,8 @@ def _parse_special_key(keystr): ('windows', 'meta'), ('mod1', 'alt'), ('mod4', 'meta'), + ('less', '<'), + ('greater', '>'), ) for (orig, repl) in replacements: keystr = keystr.replace(orig, repl) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index a1e078521..88710766b 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -355,6 +355,20 @@ class TestKeySequence: ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X, Qt.MetaModifier | Qt.Key_Y)), + + ('>', keyutils.KeySequence(Qt.Key_Greater)), + ('<', keyutils.KeySequence(Qt.Key_Less)), + ('a>', keyutils.KeySequence(Qt.Key_A, Qt.Key_Greater)), + ('a<', keyutils.KeySequence(Qt.Key_A, Qt.Key_Less)), + ('>a', keyutils.KeySequence(Qt.Key_Greater, Qt.Key_A)), + ('', + keyutils.KeySequence(Qt.Key_Greater | Qt.AltModifier)), + ('', + keyutils.KeySequence(Qt.Key_Less | Qt.AltModifier)), + + ('', keyutils.KeyParseError), + ('>', keyutils.KeyParseError), ('', keyutils.KeyParseError), ('\U00010000', keyutils.KeyParseError), ]) From f2fadd7addf3b86b2414f6ed1787588c3f0549ad Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 21:32:28 +0100 Subject: [PATCH 389/524] Fix handling of key_mappings --- qutebrowser/keyinput/basekeyparser.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 2c934617b..f22df8cf7 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -138,15 +138,16 @@ class BaseKeyParser(QObject): self._count += txt return QKeySequence.ExactMatch - sequence = self._sequence.append_event(e) - match, binding = self._match_key(sequence) + self._sequence = self._sequence.append_event(e) + match, binding = self._match_key(self._sequence) if match == QKeySequence.NoMatch: mappings = config.val.bindings.key_mappings - mapped = mappings.get(sequence, None) + mapped = mappings.get(self._sequence, None) if mapped is not None: + self._debug_log("Mapped {} -> {}".format( + self._sequence, mapped)) match, binding = self._match_key(mapped) - - self._sequence = self._sequence.append_event(e) + self._sequence = mapped if match == QKeySequence.ExactMatch: self._debug_log("Definitive match for '{}'.".format( From 40c3295cd1d6e980d46dc4080b0356e67ee921b2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 21:32:42 +0100 Subject: [PATCH 390/524] Improve logging message for clear_keystring --- qutebrowser/keyinput/basekeyparser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index f22df8cf7..e9347fa7b 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -207,7 +207,7 @@ class BaseKeyParser(QObject): def clear_keystring(self): """Clear the currently entered key sequence.""" if self._sequence: - self._debug_log("discarding keystring '{}'.".format( + self._debug_log("Clearing keystring (was: {}).".format( self._sequence)) self._sequence = keyutils.KeySequence() self._count = '' From e2f17c4be10c94331e1b37cc11bf7f10861c1e81 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 21:43:24 +0100 Subject: [PATCH 391/524] Always prefer exact over partial matches --- qutebrowser/keyinput/basekeyparser.py | 7 +++++-- tests/unit/keyinput/test_basekeyparser.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index e9347fa7b..901e96b55 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -102,14 +102,17 @@ class BaseKeyParser(QObject): """ assert sequence assert not isinstance(sequence, str) + result = QKeySequence.NoMatch for seq, cmd in self.bindings.items(): assert not isinstance(seq, str), seq match = sequence.matches(seq) - if match != QKeySequence.NoMatch: + if match == QKeySequence.ExactMatch: return (match, cmd) + elif match == QKeySequence.PartialMatch: + result = QKeySequence.PartialMatch - return (QKeySequence.NoMatch, None) + return (result, None) def handle(self, e): """Handle a new keypress. diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 95860399d..893836335 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -203,6 +203,19 @@ class TestHandle: keyparser.execute.assert_called_once_with('yank -s', None) + def test_partial_before_full_match(self, keyparser, fake_keyevent, + config_stub): + """Make sure full matches always take precedence over partial ones.""" + config_stub.val.bindings.commands = { + 'normal': { + 'ab': 'message-info bar', + 'a': 'message-info foo' + } + } + keyparser._read_config('normal') + keyparser.handle(fake_keyevent(Qt.Key_A)) + keyparser.execute.assert_called_once_with('message-info foo', None) + class TestCount: From 88a5c8d29dd42539d28d269b85d6b6f4f1f7c89c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 22:38:33 +0100 Subject: [PATCH 392/524] Make sure bindings with umlauts work See #303 --- tests/unit/keyinput/test_basekeyparser.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 893836335..9ed996922 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -185,6 +185,12 @@ class TestHandle: keyparser.execute.assert_called_once_with('message-info 0', None) assert not keyparser._sequence + def test_umlauts(self, handle_text, keyparser, config_stub): + config_stub.val.bindings.commands = {'normal': {'ü': 'message-info ü'}} + keyparser._read_config('normal') + handle_text(Qt.Key_Udiaeresis) + keyparser.execute.assert_called_once_with('message-info ü', None) + def test_mapping(self, config_stub, handle_text, keyparser): handle_text(Qt.Key_X) keyparser.execute.assert_called_once_with('message-info a', None) From 47525f6a09e9a3ab6627365b522b8f20a1152bc9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 22:58:33 +0100 Subject: [PATCH 393/524] Update changelog --- doc/changelog.asciidoc | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 1f6cd4fad..218a1d105 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -52,10 +52,18 @@ Added - The `hostblock_blame` script which was removed in v1.0 was updated for the new config and re-added. - New `qute://tabs` page (opened via `:buffer`) which lists all tabs. +- New `--select` flag for `:follow-hint` which acts like the given string was entered but doesn't necessary follow the hint. Changed ~~~~~~~ +- Complete refactoring of key input handling, with various effects: + * emacs-like keychains such as `` can now be bound. + * Key chains can now be bound in any mode (this allows binding unused keys in + hint mode). + * Yes/no prompts don't use keybindings from the `prompt` section anymore, they + have their own `yesno` section instead. + * Trying to bind invalid keys now shows an error. - The `hist_importer.py` script now only imports URL schemes qutebrowser can handle. - Deleting a prefix (`:`, `/` or `?`) via backspace now leaves command mode. From 4ef5db1bc4b5205812714a57d29daa59224afe8b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 4 Mar 2018 23:17:51 +0100 Subject: [PATCH 394/524] Disallow numbers in keybindings Fixes #1966 --- qutebrowser/config/configtypes.py | 9 ++++++++- tests/unit/config/test_configtypes.py | 7 +++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 14855bf03..217e94505 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -1654,6 +1654,13 @@ class Key(BaseType): return None try: - return keyutils.KeySequence.parse(value) + seq = keyutils.KeySequence.parse(value) except keyutils.KeyParseError as e: raise configexc.ValidationError(value, str(e)) + + for info in seq: + if Qt.Key_1 <= info.key <= Qt.Key_9 and not info.modifiers: + raise configexc.ValidationError( + value, "Numbers are reserved for counts!") + + return seq diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index c64891e5a..074a54f23 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -2061,13 +2061,16 @@ class TestKey: @pytest.mark.parametrize('val, expected', [ ('gC', keyutils.KeySequence.parse('gC')), ('', keyutils.KeySequence.parse('')), + ('', keyutils.KeySequence.parse('')), + ('0', keyutils.KeySequence.parse('0')), ]) def test_to_py_valid(self, klass, val, expected): assert klass().to_py(val) == expected - def test_to_py_invalid(self, klass): + @pytest.mark.parametrize('val', ['\U00010000', '', '1', 'a1']) + def test_to_py_invalid(self, klass, val): with pytest.raises(configexc.ValidationError): - klass().to_py('\U00010000') + klass().to_py(val) @pytest.mark.parametrize('first, second, equal', [ From e01db79ce9e28da1bfb5c8a09fe3a686932c5760 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 06:32:54 +0100 Subject: [PATCH 395/524] Filter out ShortcutOverride events properly Fixes #3419 --- doc/changelog.asciidoc | 2 ++ qutebrowser/app.py | 1 + qutebrowser/keyinput/basekeyparser.py | 33 +++++++++++++++++---------- qutebrowser/keyinput/modeman.py | 17 ++++++++------ qutebrowser/keyinput/modeparsers.py | 25 +++++++++++++------- 5 files changed, 51 insertions(+), 27 deletions(-) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 218a1d105..b38db88fa 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -99,6 +99,8 @@ Fixed - QtWebEngine: `:follow-selected` should now work in more cases with Qt > 5.10. - QtWebEngine: Incremental search now flickers less and doesn't move to the second result when pressing Enter. +- QtWebEngine: Keys like `Ctrl-V` or `Shift-Insert` are now correctly + handled/filtered with Qt 5.10. - QtWebKit: `:view-source` now displays a valid URL. - URLs containing ampersands and other special chars are now shown correctly when filtering them in the completion. diff --git a/qutebrowser/app.py b/qutebrowser/app.py index c755c2f41..2794d36e2 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -882,6 +882,7 @@ class EventFilter(QObject): self._handlers = { QEvent.KeyPress: self._handle_key_event, QEvent.KeyRelease: self._handle_key_event, + QEvent.ShortcutOverride: self._handle_key_event, } def _handle_key_event(self, event): diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 901e96b55..beb31a52c 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -114,7 +114,7 @@ class BaseKeyParser(QObject): return (result, None) - def handle(self, e): + def handle(self, e, *, dry_run=False): """Handle a new keypress. Separate the keypress into count/command, then check if it matches @@ -123,13 +123,16 @@ class BaseKeyParser(QObject): Args: e: the KeyPressEvent from Qt. + dry_run: Don't actually execute anything, only check whether there + would be a match. Return: A QKeySequence match. """ key = e.key() txt = str(keyutils.KeyInfo.from_event(e)) - self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) + self._debug_log("Got key: 0x{:x} / text: '{}' / dry_run {}".format( + key, txt, dry_run)) if keyutils.is_modifier_key(key): self._debug_log("Ignoring, only modifier") @@ -138,33 +141,39 @@ class BaseKeyParser(QObject): if (txt.isdigit() and self._supports_count and not (not self._count and txt == '0')): assert len(txt) == 1, txt - self._count += txt + if not dry_run: + self._count += txt return QKeySequence.ExactMatch - self._sequence = self._sequence.append_event(e) - match, binding = self._match_key(self._sequence) + sequence = self._sequence.append_event(e) + match, binding = self._match_key(sequence) if match == QKeySequence.NoMatch: mappings = config.val.bindings.key_mappings - mapped = mappings.get(self._sequence, None) + mapped = mappings.get(sequence, None) if mapped is not None: self._debug_log("Mapped {} -> {}".format( - self._sequence, mapped)) + sequence, mapped)) match, binding = self._match_key(mapped) - self._sequence = mapped + sequence = mapped + + if dry_run: + return match + + self._sequence = sequence if match == QKeySequence.ExactMatch: self._debug_log("Definitive match for '{}'.".format( - self._sequence)) + sequence)) count = int(self._count) if self._count else None self.clear_keystring() self.execute(binding, count) elif match == QKeySequence.PartialMatch: self._debug_log("No match for '{}' (added {})".format( - self._sequence, txt)) - self.keystring_updated.emit(self._count + str(self._sequence)) + sequence, txt)) + self.keystring_updated.emit(self._count + str(sequence)) elif match == QKeySequence.NoMatch: self._debug_log("Giving up with '{}', no matches".format( - self._sequence)) + sequence)) self.clear_keystring() else: raise utils.Unreachable("Invalid match value {!r}".format(match)) diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index 94d76832d..8d2830cc1 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -143,11 +143,12 @@ class ModeManager(QObject): def __repr__(self): return utils.get_repr(self, mode=self.mode) - def _eventFilter_keypress(self, event): + def _eventFilter_keypress(self, event, *, dry_run=False): """Handle filtering of KeyPress events. Args: event: The KeyPress to examine. + dry_run: Don't actually handle the key, only filter it. Return: True if event should be filtered, False otherwise. @@ -157,7 +158,7 @@ class ModeManager(QObject): if curmode != usertypes.KeyMode.insert: log.modes.debug("got keypress in mode {} - delegating to " "{}".format(curmode, utils.qualname(parser))) - match = parser.handle(event) + match = parser.handle(event, dry_run=dry_run) is_non_alnum = ( event.modifiers() not in [Qt.NoModifier, Qt.ShiftModifier] or @@ -173,17 +174,17 @@ class ModeManager(QObject): else: filter_this = True - if not filter_this: + if not filter_this and not dry_run: self._releaseevents_to_pass.add(KeyEvent.from_event(event)) if curmode != usertypes.KeyMode.insert: focus_widget = QApplication.instance().focusWidget() log.modes.debug("match: {}, forward_unbound_keys: {}, " - "passthrough: {}, is_non_alnum: {} --> " - "filter: {} (focused: {!r})".format( + "passthrough: {}, is_non_alnum: {}, dry_run: {} " + "--> filter: {} (focused: {!r})".format( match, forward_unbound_keys, - parser.passthrough, is_non_alnum, filter_this, - focus_widget)) + parser.passthrough, is_non_alnum, dry_run, + filter_this, focus_widget)) return filter_this def _eventFilter_keyrelease(self, event): @@ -320,6 +321,8 @@ class ModeManager(QObject): handlers = { QEvent.KeyPress: self._eventFilter_keypress, QEvent.KeyRelease: self._eventFilter_keyrelease, + QEvent.ShortcutOverride: + functools.partial(self._eventFilter_keypress, dry_run=True), } handler = handlers[event.type()] return handler(event) diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 169232e01..ce4cb71fa 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -59,11 +59,13 @@ class NormalKeyParser(keyparser.CommandKeyParser): def __repr__(self): return utils.get_repr(self) - def handle(self, e): + def handle(self, e, *, dry_run=False): """Override to abort if the key is a startchar. Args: e: the KeyPressEvent from Qt. + dry_run: Don't actually execute anything, only check whether there + would be a match. Return: A self.Match member. @@ -74,9 +76,9 @@ class NormalKeyParser(keyparser.CommandKeyParser): "currently inhibited.".format(txt)) return QKeySequence.NoMatch - match = super().handle(e) + match = super().handle(e, dry_run=dry_run) - if match == QKeySequence.PartialMatch: + if match == QKeySequence.PartialMatch and not dry_run: timeout = config.val.input.partial_timeout if timeout != 0: self._partial_timer.setInterval(timeout) @@ -198,16 +200,21 @@ class HintKeyParser(keyparser.CommandKeyParser): self._last_press = LastPress.filtertext return QKeySequence.ExactMatch - def handle(self, e): + def handle(self, e, *, dry_run=False): """Handle a new keypress and call the respective handlers. Args: e: the KeyPressEvent from Qt + dry_run: Don't actually execute anything, only check whether there + would be a match. Returns: True if the match has been handled, False otherwise. """ - match = super().handle(e) + match = super().handle(e, dry_run=dry_run) + if dry_run: + return match + if match == QKeySequence.PartialMatch: self._last_press = LastPress.keystring elif match == QKeySequence.ExactMatch: @@ -267,17 +274,19 @@ class RegisterKeyParser(keyparser.CommandKeyParser): self._mode = mode self._read_config('register') - def handle(self, e): + def handle(self, e, *, dry_run=False): """Override handle to always match the next key and use the register. Args: e: the KeyPressEvent from Qt. + dry_run: Don't actually execute anything, only check whether there + would be a match. Return: True if event has been handled, False otherwise. """ - match = super().handle(e) - if match: + match = super().handle(e, dry_run=dry_run) + if match or dry_run: return match if not keyutils.is_printable(e.key()): From 274f2a9d199684120ec0c66e805410a3a920fb46 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 06:36:01 +0100 Subject: [PATCH 396/524] Rename eventFilter methods in modeman --- qutebrowser/app.py | 2 +- qutebrowser/keyinput/modeman.py | 12 ++++++------ tests/unit/keyinput/test_modeman.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 2794d36e2..73b5557cc 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -900,7 +900,7 @@ class EventFilter(QObject): return False try: man = objreg.get('mode-manager', scope='window', window='current') - return man.eventFilter(event) + return man.handle_event(event) except objreg.RegistryUnavailableError: # No window available yet, or not a MainWindow return False diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index 8d2830cc1..168efde5b 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -143,7 +143,7 @@ class ModeManager(QObject): def __repr__(self): return utils.get_repr(self, mode=self.mode) - def _eventFilter_keypress(self, event, *, dry_run=False): + def _handle_keypress(self, event, *, dry_run=False): """Handle filtering of KeyPress events. Args: @@ -187,7 +187,7 @@ class ModeManager(QObject): filter_this, focus_widget)) return filter_this - def _eventFilter_keyrelease(self, event): + def _handle_keyrelease(self, event): """Handle filtering of KeyRelease events. Args: @@ -303,7 +303,7 @@ class ModeManager(QObject): raise ValueError("Can't leave normal mode!") self.leave(self.mode, 'leave current') - def eventFilter(self, event): + def handle_event(self, event): """Filter all events based on the currently set mode. Also calls the real keypress handler. @@ -319,10 +319,10 @@ class ModeManager(QObject): return False handlers = { - QEvent.KeyPress: self._eventFilter_keypress, - QEvent.KeyRelease: self._eventFilter_keyrelease, + QEvent.KeyPress: self._handle_keypress, + QEvent.KeyRelease: self._handle_keyrelease, QEvent.ShortcutOverride: - functools.partial(self._eventFilter_keypress, dry_run=True), + functools.partial(self._handle_keypress, dry_run=True), } handler = handlers[event.type()] return handler(event) diff --git a/tests/unit/keyinput/test_modeman.py b/tests/unit/keyinput/test_modeman.py index de9671961..d94d7d38b 100644 --- a/tests/unit/keyinput/test_modeman.py +++ b/tests/unit/keyinput/test_modeman.py @@ -54,4 +54,4 @@ def modeman(mode_manager): def test_non_alphanumeric(key, modifiers, filtered, fake_keyevent, modeman): """Make sure non-alphanumeric keys are passed through correctly.""" evt = fake_keyevent(key=key, modifiers=modifiers) - assert modeman.eventFilter(evt) == filtered + assert modeman.handle_event(evt) == filtered From 2a9d970641f2755f5b27a7e37ea46cb9babe1dd8 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 07:39:36 +0100 Subject: [PATCH 397/524] Uninstall application proxy factory before exit This should help with segfaults on exit. Fixes #3657 --- qutebrowser/app.py | 2 ++ qutebrowser/browser/network/proxy.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 73b5557cc..e9f9d2229 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -772,6 +772,8 @@ class Quitter: pre_text="Error while saving {}".format(key)) # Disable storage so removing tempdir will work websettings.shutdown() + # Disable application proxy factory to fix segfaults with Qt 5.10.1 + proxy.shutdown() # Re-enable faulthandler to stdout, then remove crash log log.destroy.debug("Deactivating crash log...") objreg.get('crash-handler').destroy_crashlogfile() diff --git a/qutebrowser/browser/network/proxy.py b/qutebrowser/browser/network/proxy.py index 96be78742..d3e25c23c 100644 --- a/qutebrowser/browser/network/proxy.py +++ b/qutebrowser/browser/network/proxy.py @@ -34,6 +34,10 @@ def init(): QNetworkProxyFactory.setApplicationProxyFactory(proxy_factory) +def shutdown(): + QNetworkProxyFactory.setApplicationProxyFactory(None) + + class ProxyFactory(QNetworkProxyFactory): """Factory for proxies to be used by qutebrowser.""" From 78623f4ec8f9fc98a90ace5efe581bac7262d52a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 08:15:47 +0100 Subject: [PATCH 398/524] Update changelog [ci skip] --- doc/changelog.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index b38db88fa..ffbf9ebfc 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -101,6 +101,7 @@ Fixed second result when pressing Enter. - QtWebEngine: Keys like `Ctrl-V` or `Shift-Insert` are now correctly handled/filtered with Qt 5.10. +- QtWebEngine: Fixed hangs/segfaults on exit with Qt 5.10.1. - QtWebKit: `:view-source` now displays a valid URL. - URLs containing ampersands and other special chars are now shown correctly when filtering them in the completion. From b4a2352833bfb06c86c1afb8b088cead0ef7c6d5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 09:03:58 +0100 Subject: [PATCH 399/524] Cache HTML/JS resource files when starting This mostly reverts 9edc5a665e414ae2ec8f5bc04a99bce2bc4e6d33 (see #1362). Fixes #1943 --- doc/changelog.asciidoc | 2 ++ qutebrowser/app.py | 1 + qutebrowser/html/undef_error.html | 22 ---------------------- qutebrowser/utils/jinja.py | 11 +---------- qutebrowser/utils/utils.py | 14 ++++++++++++++ tests/unit/utils/test_jinja.py | 17 ++++------------- tests/unit/utils/test_utils.py | 8 ++++++++ 7 files changed, 30 insertions(+), 45 deletions(-) delete mode 100644 qutebrowser/html/undef_error.html diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index ffbf9ebfc..d30eb3ca8 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -117,6 +117,8 @@ Fixed - Crash when opening an invalid URL from an application on macOS. - Crash with an empty `completion.timestamp_format`. - Crash when `completion.min_chars` is set in some cases. +- HTML/JS resource files are now read into RAM on start to avoid crashes when + changing qutebrowser versions while it's open. Removed ~~~~~~~ diff --git a/qutebrowser/app.py b/qutebrowser/app.py index e9f9d2229..014e02c5f 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -95,6 +95,7 @@ def run(args): log.init.debug("Initializing directories...") standarddir.init(args) + utils.preload_resources() log.init.debug("Initializing config...") configinit.early_init(args) diff --git a/qutebrowser/html/undef_error.html b/qutebrowser/html/undef_error.html deleted file mode 100644 index 55a47ca95..000000000 --- a/qutebrowser/html/undef_error.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - Error while rendering HTML - - -

Error while rendering internal qutebrowser page

-

There was an error while rendering {pagename}.

- -

This most likely happened because you updated qutebrowser but didn't restart yet.

- -

If you believe this isn't the case and this is a bug, please do :report.

- -

Traceback

-
{traceback}
- - diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py index d4ce3368f..b06444f93 100644 --- a/qutebrowser/utils/jinja.py +++ b/qutebrowser/utils/jinja.py @@ -22,12 +22,10 @@ import os import os.path import contextlib -import traceback import mimetypes import html import jinja2 -import jinja2.exceptions from PyQt5.QtCore import QUrl from qutebrowser.utils import utils, urlutils, log @@ -125,14 +123,7 @@ class Environment(jinja2.Environment): def render(template, **kwargs): """Render the given template and pass the given arguments to it.""" - try: - return environment.get_template(template).render(**kwargs) - except jinja2.exceptions.UndefinedError: - log.misc.exception("UndefinedError while rendering " + template) - err_path = os.path.join('html', 'undef_error.html') - err_template = utils.read_file(err_path) - tb = traceback.format_exc() - return err_template.format(pagename=template, traceback=tb) + return environment.get_template(template).render(**kwargs) environment = Environment() diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index f03d42844..9f3acde5a 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -32,6 +32,7 @@ import functools import contextlib import socket import shlex +import glob from PyQt5.QtCore import QUrl from PyQt5.QtGui import QColor, QClipboard, QDesktopServices @@ -51,6 +52,7 @@ from qutebrowser.utils import qtutils, log fake_clipboard = None log_clipboard = False +_resource_cache = {} is_mac = sys.platform.startswith('darwin') is_linux = sys.platform.startswith('linux') @@ -140,6 +142,15 @@ def compact_text(text, elidelength=None): return out +def preload_resources(): + """Load resource files into the cache.""" + for subdir, pattern in [('html', '*.html'), ('javascript', '*.js')]: + path = resource_filename(subdir) + for full_path in glob.glob(os.path.join(path, pattern)): + sub_path = os.path.join(subdir, os.path.basename(full_path)) + _resource_cache[sub_path] = read_file(sub_path) + + def read_file(filename, binary=False): """Get the contents of a file contained with qutebrowser. @@ -151,6 +162,9 @@ def read_file(filename, binary=False): Return: The file contents as string. """ + if not binary and filename in _resource_cache: + return _resource_cache[filename] + if hasattr(sys, 'frozen'): # PyInstaller doesn't support pkg_resources :( # https://github.com/pyinstaller/pyinstaller/wiki/FAQ#misc diff --git a/tests/unit/utils/test_jinja.py b/tests/unit/utils/test_jinja.py index f47155f91..b1a50772e 100644 --- a/tests/unit/utils/test_jinja.py +++ b/tests/unit/utils/test_jinja.py @@ -23,6 +23,7 @@ import os import os.path import logging +import jinja2.exceptions import pytest from PyQt5.QtCore import QUrl @@ -32,7 +33,6 @@ from qutebrowser.utils import utils, jinja @pytest.fixture(autouse=True) def patch_read_file(monkeypatch): """pytest fixture to patch utils.read_file.""" - real_read_file = utils.read_file real_resource_filename = utils.resource_filename def _read_file(path, binary=False): @@ -52,9 +52,6 @@ def patch_read_file(monkeypatch): elif path == os.path.join('html', 'undef.html'): assert not binary return """{{ does_not_exist() }}""" - elif path == os.path.join('html', 'undef_error.html'): - assert not binary - return real_read_file(path) elif path == os.path.join('html', 'attributeerror.html'): assert not binary return """{{ obj.foobar }}""" @@ -129,15 +126,9 @@ def test_utf8(): def test_undefined_function(caplog): - """Make sure we don't crash if an undefined function is called.""" - with caplog.at_level(logging.ERROR): - data = jinja.render('undef.html') - assert 'There was an error while rendering undef.html' in data - assert "'does_not_exist' is undefined" in data - assert data.startswith('') - - assert len(caplog.records) == 1 - assert caplog.records[0].msg == "UndefinedError while rendering undef.html" + """Make sure undefined attributes crash since we preload resources..""" + with pytest.raises(jinja2.exceptions.UndefinedError): + jinja.render('undef.html') def test_attribute_error(): diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index b2eef0237..df0eb9ecb 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -133,6 +133,14 @@ class TestReadFile: content = utils.read_file(os.path.join('utils', 'testfile')) assert content.splitlines()[0] == "Hello World!" + @pytest.mark.parametrize('filename', ['javascript/scroll.js', + 'html/error.html']) + def test_read_cached_file(self, mocker, filename): + utils.preload_resources() + m = mocker.patch('pkg_resources.resource_string') + utils.read_file(filename) + m.assert_not_called() + def test_readfile_binary(self): """Read a test file in binary mode.""" content = utils.read_file(os.path.join('utils', 'testfile'), From cc5da4d1fe2e9f648f3c2f9d8b6f31d3c0aad9b4 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 11:08:21 +0100 Subject: [PATCH 400/524] Fix test_modeman.py --- tests/unit/keyinput/test_modeman.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/keyinput/test_modeman.py b/tests/unit/keyinput/test_modeman.py index d94d7d38b..25b3fc776 100644 --- a/tests/unit/keyinput/test_modeman.py +++ b/tests/unit/keyinput/test_modeman.py @@ -34,7 +34,7 @@ class FakeKeyparser(QObject): super().__init__() self.passthrough = False - def handle(self, evt): + def handle(self, evt, *, dry_run=False): return False From 2f8686ec70dac6cad921b4481779a4470dbebf21 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 11:36:29 +0100 Subject: [PATCH 401/524] Fix test_mhtml_e2e with Qt 5.11 See #3661 --- .../data/downloads/mhtml/simple/simple-webengine.mht | 1 + tests/end2end/test_mhtml_e2e.py | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/end2end/data/downloads/mhtml/simple/simple-webengine.mht b/tests/end2end/data/downloads/mhtml/simple/simple-webengine.mht index 79bd1ae50..42a55ab7c 100644 --- a/tests/end2end/data/downloads/mhtml/simple/simple-webengine.mht +++ b/tests/end2end/data/downloads/mhtml/simple/simple-webengine.mht @@ -1,4 +1,5 @@ From: +Snapshot-Content-Location: http://localhost:(port)/data/downloads/mhtml/simple/simple.html Subject: Simple MHTML test Date: today MIME-Version: 1.0 diff --git a/tests/end2end/test_mhtml_e2e.py b/tests/end2end/test_mhtml_e2e.py index 78cc9eb81..feddc963d 100644 --- a/tests/end2end/test_mhtml_e2e.py +++ b/tests/end2end/test_mhtml_e2e.py @@ -26,6 +26,8 @@ import collections import pytest +from qutebrowser.utils import qtutils + def collect_tests(): basedir = os.path.dirname(__file__) @@ -51,6 +53,11 @@ def normalize_line(line): line = line.replace('Content-Type: application/x-javascript', 'Content-Type: application/javascript') + # Added with Qt 5.11 + if (line.startswith('Snapshot-Content-Location: ') and + not qtutils.version_check('5.11', compiled=False)): + line = None + return line @@ -74,7 +81,8 @@ class DownloadDir: def compare_mhtml(self, filename): with open(filename, 'r', encoding='utf-8') as f: - expected_data = [normalize_line(line) for line in f] + expected_data = [normalize_line(line) for line in f + if normalize_line(line) is not None] actual_data = self.read_file() actual_data = [normalize_line(line) for line in actual_data] assert actual_data == expected_data From 67b4502fdb48c511e661d0f8c6e32583a03cefe4 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 11:36:50 +0100 Subject: [PATCH 402/524] Fix test_version without cssutils --- tests/unit/utils/test_version.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 2f6aa648f..0884528aa 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -648,6 +648,8 @@ class TestModuleVersions: name: The name of the module to check. has_version: Whether a __version__ attribute is expected. """ + if name == 'cssutils': + pytest.importorskip(name) module = importlib.import_module(name) assert hasattr(module, '__version__') == has_version From 3275681afda81706d5a076cca53f4032d064e399 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 12:45:13 +0100 Subject: [PATCH 403/524] Show key when the key string is empty --- qutebrowser/keyinput/keyutils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 9a2c31572..4f34d0912 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -231,6 +231,10 @@ class KeyInfo: modifiers &= ~_MODIFIER_MAP[self.key] elif is_printable(self.key): # "normal" binding + if not key_string: + raise ValueError("Got empty string for key 0x{:x}!" + .format(self.key)) + assert len(key_string) == 1, key_string if self.modifiers == Qt.ShiftModifier: return key_string.upper() From 52c280ec12c2f4c5f6e565dd599c4578d4870e8b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 15:33:56 +0100 Subject: [PATCH 404/524] Add unit tests for BaseKeyParser.handle with dry_run=True --- tests/unit/keyinput/test_basekeyparser.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 9ed996922..6fd12d92e 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -172,6 +172,16 @@ class TestHandle: assert not keyparser.execute.called assert not keyparser._sequence + def test_dry_run(self, fake_keyevent, keyparser): + keyparser.handle(fake_keyevent(Qt.Key_B)) + keyparser.handle(fake_keyevent(Qt.Key_A), dry_run=True) + assert not keyparser.execute.called + assert keyparser._sequence + + def test_dry_run_count(self, fake_keyevent, keyparser): + keyparser.handle(fake_keyevent(Qt.Key_1), dry_run=True) + assert not keyparser._count + def test_valid_keychain(self, handle_text, keyparser): # Press 'x' which is ignored because of no match handle_text(Qt.Key_X, From d1854eddaffe66c4bbeb2e819a57dc8dbfc6d703 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 15:36:51 +0100 Subject: [PATCH 405/524] Handle invalid keys coming from Qt When pressing a key which doesn't exist as Qt.Key, we don't get Qt.Key_unknown like we'd expect, but we get 0x0 instead... Let's add that as a new "nil" key (to not conflict with None/unknown/zero/...) and handle it appropriately. This can be reproduced by doing: setxkbmap -layout us,gr -option grp:alt_shift_toggle and pressing Alt-Shift/Shift-Alt. --- qutebrowser/keyinput/basekeyparser.py | 8 +++++++- qutebrowser/keyinput/keyutils.py | 11 +++++++++-- tests/unit/keyinput/key_data.py | 5 ++++- tests/unit/keyinput/test_basekeyparser.py | 5 +++++ tests/unit/keyinput/test_keyutils.py | 11 ++++++++++- 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index beb31a52c..11a31d64e 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -145,7 +145,13 @@ class BaseKeyParser(QObject): self._count += txt return QKeySequence.ExactMatch - sequence = self._sequence.append_event(e) + try: + sequence = self._sequence.append_event(e) + except keyutils.KeyParseError as e: + self._debug_log("{} Aborting keychain.".format(e)) + self.clear_keystring() + return QKeySequence.NoMatch + match, binding = self._match_key(sequence) if match == QKeySequence.NoMatch: mappings = config.val.bindings.key_mappings diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 4f34d0912..6f63a914a 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -39,7 +39,7 @@ _MODIFIER_MAP = { def is_printable(key): - return key <= 0xff and key != Qt.Key_Space + return key <= 0xff and key not in [Qt.Key_Space, 0x0] def is_modifier_key(key): @@ -126,6 +126,7 @@ def _key_to_string(key): special_names[getattr(Qt, 'Key_' + k)] = v except AttributeError: pass + special_names[0x0] = 'nil' if key in special_names: return special_names[key] @@ -231,7 +232,7 @@ class KeyInfo: modifiers &= ~_MODIFIER_MAP[self.key] elif is_printable(self.key): # "normal" binding - if not key_string: + if not key_string: # pragma: no cover raise ValueError("Got empty string for key 0x{:x}!" .format(self.key)) @@ -371,6 +372,9 @@ class KeySequence: if info.key == Qt.Key_unknown: raise KeyParseError(keystr, "Got unknown key!") + for seq in self._sequences: + assert seq + def matches(self, other): """Check whether the given KeySequence matches with this one. @@ -428,6 +432,9 @@ class KeySequence: key = ev.key() modifiers = ev.modifiers() + if key == 0x0: + raise KeyParseError(None, "Got nil key!") + if modifiers & Qt.ShiftModifier and key == Qt.Key_Backtab: key = Qt.Key_Tab diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py index 2f13c8b5e..ec2f92c1b 100644 --- a/tests/unit/keyinput/key_data.py +++ b/tests/unit/keyinput/key_data.py @@ -48,7 +48,8 @@ class Key: qtest = attr.ib(True) def __attrs_post_init__(self): - self.member = getattr(Qt, 'Key_' + self.attribute, None) + if self.attribute: + self.member = getattr(Qt, 'Key_' + self.attribute, None) if self.name is None: self.name = self.attribute @@ -585,4 +586,6 @@ KEYS = [ Key('CameraFocus', 'Camera Focus', qtest=False), Key('unknown', 'Unknown', qtest=False), + # 0x0 is used by Qt for unknown keys... + Key(attribute='', name='nil', member=0x0, qtest=False), ] diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 6fd12d92e..65f59b0cf 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -182,6 +182,11 @@ class TestHandle: keyparser.handle(fake_keyevent(Qt.Key_1), dry_run=True) assert not keyparser._count + def test_invalid_key(self, fake_keyevent, keyparser): + keyparser.handle(fake_keyevent(Qt.Key_B)) + keyparser.handle(fake_keyevent(0x0)) + assert not keyparser._sequence + def test_valid_keychain(self, handle_text, keyparser): # Press 'x' which is ignored because of no match handle_text(Qt.Key_X, diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 88710766b..71f36accd 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -182,7 +182,8 @@ class TestKeySequence: with pytest.raises(keyutils.KeyParseError): keyutils.KeySequence(Qt.Key_unknown) - def test_init_invalid(self): + @pytest.mark.parametrize('key', [0, -1]) + def test_init_invalid(self, key): with pytest.raises(AssertionError): keyutils.KeySequence(-1) @@ -343,6 +344,13 @@ class TestKeySequence: new = seq.append_event(event) assert new == keyutils.KeySequence.parse(expected) + @pytest.mark.parametrize('key', [Qt.Key_unknown, 0x0]) + def test_append_event_invalid(self, key): + seq = keyutils.KeySequence() + event = QKeyEvent(QKeyEvent.KeyPress, key, Qt.NoModifier, '') + with pytest.raises(keyutils.KeyParseError): + seq.append_event(event) + @pytest.mark.parametrize('keystr, expected', [ ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), ('', keyutils.KeySequence(Qt.MetaModifier | Qt.Key_X)), @@ -405,6 +413,7 @@ def test_key_info_to_event(): (Qt.Key_Enter, False), (Qt.Key_Space, False), (Qt.Key_X | Qt.ControlModifier, False), # Wrong usage + (0x0, False), # Used by Qt for unknown keys (Qt.Key_ydiaeresis, True), (Qt.Key_X, True), From cdbff411d07b8e14f57f75c1f8dfdb032d62e516 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 15:51:26 +0100 Subject: [PATCH 406/524] Fix travis_install for newer Homebrew --- scripts/dev/ci/travis_install.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/dev/ci/travis_install.sh b/scripts/dev/ci/travis_install.sh index 38e17965a..20aa1c12d 100644 --- a/scripts/dev/ci/travis_install.sh +++ b/scripts/dev/ci/travis_install.sh @@ -83,7 +83,9 @@ elif [[ $TRAVIS_OS_NAME == osx ]]; then sudo -H python get-pip.py brew --version - brew_install python3 qt5 pyqt5 libyaml + brew update + brew upgrade python + brew install qt5 pyqt5 libyaml pip_install -r misc/requirements/requirements-tox.txt python3 -m pip --version From a1b73fc113f9e9cf312cc5bf6826832cecefab2c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 16:42:14 +0100 Subject: [PATCH 407/524] Elide long URLs in acceptNavigationRequest logging --- qutebrowser/browser/browsertab.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 79c3ae4d4..f04fa5c24 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -732,8 +732,9 @@ class AbstractTab(QWidget): @pyqtSlot(usertypes.NavigationRequest) def _on_navigation_request(self, navigation): """Handle common acceptNavigationRequest code.""" + url = utils.elide(navigation.url.toDisplayString(), 100) log.webview.debug("navigation request: url {}, type {}, is_main_frame " - "{}".format(navigation.url.toDisplayString(), + "{}".format(url, navigation.navigation_type, navigation.is_main_frame)) From 430d69f278de7a6602d22c9754609e92020321b5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 16:43:01 +0100 Subject: [PATCH 408/524] Fix lint --- qutebrowser/keyinput/basekeyparser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 11a31d64e..46d09870d 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -147,8 +147,8 @@ class BaseKeyParser(QObject): try: sequence = self._sequence.append_event(e) - except keyutils.KeyParseError as e: - self._debug_log("{} Aborting keychain.".format(e)) + except keyutils.KeyParseError as ex: + self._debug_log("{} Aborting keychain.".format(ex)) self.clear_keystring() return QKeySequence.NoMatch From 1006f181e2d90f730507d31e232ea02e2b677b84 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 5 Mar 2018 17:07:16 +0100 Subject: [PATCH 409/524] Update packaging from 16.8 to 17.1 --- misc/requirements/requirements-pip.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt index b5e76ce0f..fb4a1a933 100644 --- a/misc/requirements/requirements-pip.txt +++ b/misc/requirements/requirements-pip.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py appdirs==1.4.3 -packaging==16.8 +packaging==17.1 pyparsing==2.2.0 setuptools==38.5.1 six==1.11.0 From 17e291587601f39cb900d250ec4e842bc75d9121 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 5 Mar 2018 17:07:18 +0100 Subject: [PATCH 410/524] Update hypothesis from 3.46.0 to 3.48.0 --- misc/requirements/requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index cd0d2f557..2b9bc209a 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -11,7 +11,7 @@ fields==5.0.0 Flask==0.12.2 glob2==0.6 hunter==2.0.2 -hypothesis==3.46.0 +hypothesis==3.48.0 itsdangerous==0.24 # Jinja2==2.10 Mako==1.0.7 From 0299bd9764fcc5684c8b22c76d8a67eeac9f7a51 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 5 Mar 2018 17:07:19 +0100 Subject: [PATCH 411/524] Update pytest-mock from 1.7.0 to 1.7.1 --- misc/requirements/requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 2b9bc209a..53d33c04f 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -28,7 +28,7 @@ pytest-benchmark==3.1.1 pytest-cov==2.5.1 pytest-faulthandler==1.4.1 pytest-instafail==0.3.0 -pytest-mock==1.7.0 +pytest-mock==1.7.1 pytest-qt==2.3.1 pytest-repeat==0.4.1 pytest-rerunfailures==4.0 From a796d1f33f216369794a79fa7bf1147332ec35fe Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 17:09:47 +0100 Subject: [PATCH 412/524] Always enable JavaScript for file://, chrome:// and qute:// See #3622 --- qutebrowser/browser/qutescheme.py | 2 -- qutebrowser/config/websettings.py | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index e53907798..8866f1643 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -263,8 +263,6 @@ def qute_history(url): return 'text/html', json.dumps(history_data(start_time, offset)) else: - if not config.val.content.javascript.enabled: - return 'text/plain', b'JavaScript is required for qute://history' return 'text/html', jinja.render( 'history.html', title='History', diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 517c3dd3c..c069f7d56 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -22,7 +22,7 @@ from PyQt5.QtGui import QFont from qutebrowser.config import config, configutils -from qutebrowser.utils import log, usertypes +from qutebrowser.utils import log, usertypes, urlmatch from qutebrowser.misc import objects UNSET = object() @@ -172,6 +172,11 @@ def init(args): from qutebrowser.browser.webkit import webkitsettings webkitsettings.init(args) + # Make sure special URLs always get JS support + for pattern in ['file://*', 'chrome://*/*', 'qute://*/*']: + config.instance.set_obj('content.javascript.enabled', True, + pattern=urlmatch.UrlPattern(pattern)) + def shutdown(): """Shut down QWeb(Engine)Settings.""" From 4da8af0e1d04ef6d8f537b7d5a23f6962d67b1ff Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 18:08:51 +0100 Subject: [PATCH 413/524] Fix preloading resources on Windows We always pass paths like javascript/scroll.js no matter what the underlying OS is, so we also need to cache it with a / separator. --- qutebrowser/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 9f3acde5a..da1ddf085 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -147,7 +147,7 @@ def preload_resources(): for subdir, pattern in [('html', '*.html'), ('javascript', '*.js')]: path = resource_filename(subdir) for full_path in glob.glob(os.path.join(path, pattern)): - sub_path = os.path.join(subdir, os.path.basename(full_path)) + sub_path = '/'.join([subdir, os.path.basename(full_path)]) _resource_cache[sub_path] = read_file(sub_path) From 43cab4d9787682f7665ae00c0105355a5a6ed7a9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 18:20:06 +0100 Subject: [PATCH 414/524] Add bindings to toggle plugins See #3622 --- doc/help/settings.asciidoc | 6 ++++++ qutebrowser/config/configdata.yml | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 216718de1..0c2d09d5f 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -579,11 +579,17 @@ Default: * +pass:[sk]+: +pass:[set-cmd-text -s :bind]+ * +pass:[sl]+: +pass:[set-cmd-text -s :set -t]+ * +pass:[ss]+: +pass:[set-cmd-text -s :set]+ +* +pass:[tPH]+: +pass:[config-cycle -p -u *://*.{url:host}/* content.plugins ;; reload]+ +* +pass:[tPh]+: +pass:[config-cycle -p -u *://{url:host}/* content.plugins ;; reload]+ +* +pass:[tPu]+: +pass:[config-cycle -p -u {url} content.plugins ;; reload]+ * +pass:[tSH]+: +pass:[config-cycle -p -u *://*.{url:host}/* content.javascript.enabled ;; reload]+ * +pass:[tSh]+: +pass:[config-cycle -p -u *://{url:host}/* content.javascript.enabled ;; reload]+ * +pass:[tSu]+: +pass:[config-cycle -p -u {url} content.javascript.enabled ;; reload]+ * +pass:[th]+: +pass:[back -t]+ * +pass:[tl]+: +pass:[forward -t]+ +* +pass:[tpH]+: +pass:[config-cycle -p -t -u *://*.{url:host}/* content.plugins ;; reload]+ +* +pass:[tph]+: +pass:[config-cycle -p -t -u *://{url:host}/* content.plugins ;; reload]+ +* +pass:[tpu]+: +pass:[config-cycle -p -t -u {url} content.plugins ;; reload]+ * +pass:[tsH]+: +pass:[config-cycle -p -t -u *://*.{url:host}/* content.javascript.enabled ;; reload]+ * +pass:[tsh]+: +pass:[config-cycle -p -t -u *://{url:host}/* content.javascript.enabled ;; reload]+ * +pass:[tsu]+: +pass:[config-cycle -p -t -u {url} content.javascript.enabled ;; reload]+ diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index dc730bcb3..060343b7e 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2335,6 +2335,12 @@ bindings.default: tSH: config-cycle -p -u *://*.{url:host}/* content.javascript.enabled ;; reload tsu: config-cycle -p -t -u {url} content.javascript.enabled ;; reload tSu: config-cycle -p -u {url} content.javascript.enabled ;; reload + tph: config-cycle -p -t -u *://{url:host}/* content.plugins ;; reload + tPh: config-cycle -p -u *://{url:host}/* content.plugins ;; reload + tpH: config-cycle -p -t -u *://*.{url:host}/* content.plugins ;; reload + tPH: config-cycle -p -u *://*.{url:host}/* content.plugins ;; reload + tpu: config-cycle -p -t -u {url} content.plugins ;; reload + tPu: config-cycle -p -u {url} content.plugins ;; reload insert: : open-editor : insert-text {primary} From 9320214429e6dc269d7d87042e474873ab8ad4f4 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 18:29:01 +0100 Subject: [PATCH 415/524] Only clear favicons on load with QtWebKit QtWebEngine seems to automatically clear the favicon when loading e.g. about:blank, and not clearing it there again fixes #3469. Original issue: #187 --- doc/changelog.asciidoc | 1 + qutebrowser/browser/webkit/webkittab.py | 4 +++- qutebrowser/mainwindow/tabbedbrowser.py | 1 - 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index d30eb3ca8..c3efe08a8 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -102,6 +102,7 @@ Fixed - QtWebEngine: Keys like `Ctrl-V` or `Shift-Insert` are now correctly handled/filtered with Qt 5.10. - QtWebEngine: Fixed hangs/segfaults on exit with Qt 5.10.1. +- QtWebEngine: Fixed favicons sometimes getting cleared with Qt 5.10. - QtWebKit: `:view-source` now displays a valid URL. - URLs containing ampersands and other special chars are now shown correctly when filtering them in the completion. diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index ef38892cc..ac2610f5f 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -30,7 +30,7 @@ import pygments.formatters import sip from PyQt5.QtCore import (pyqtSlot, Qt, QEvent, QUrl, QPoint, QTimer, QSizeF, QSize) -from PyQt5.QtGui import QKeyEvent +from PyQt5.QtGui import QKeyEvent, QIcon from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame from PyQt5.QtWebKit import QWebSettings from PyQt5.QtPrintSupport import QPrinter @@ -743,6 +743,8 @@ class WebKitTab(browsertab.AbstractTab): def _on_load_started(self): super()._on_load_started() self.networkaccessmanager().netrc_used = False + # Make sure the icon is cleared when navigating to a page without one. + self.icon_changed.emit(QIcon()) @pyqtSlot() def _on_frame_load_finished(self): diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 299a5fb08..f59018c43 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -552,7 +552,6 @@ class TabbedBrowser(tabwidget.TabWidget): if tab.data.keep_icon: tab.data.keep_icon = False else: - self.setTabIcon(idx, QIcon()) if (config.val.tabs.tabs_are_windows and config.val.tabs.favicons.show): self.window().setWindowIcon(self.default_window_icon) From 333a37ffb2b0720f06f24bcf8925a15922318c41 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 18:30:34 +0100 Subject: [PATCH 416/524] Fix old macOS-specific test code --- tests/unit/keyinput/test_basekeyparser.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 65f59b0cf..662b1f82a 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -25,7 +25,6 @@ from PyQt5.QtCore import Qt import pytest from qutebrowser.keyinput import basekeyparser, keyutils -from qutebrowser.utils import utils # Alias because we need this a lot in here. @@ -148,16 +147,14 @@ class TestHandle: keyparser._read_config('prompt') def test_valid_key(self, fake_keyevent, keyparser): - modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier - keyparser.handle(fake_keyevent(Qt.Key_A, modifier)) - keyparser.handle(fake_keyevent(Qt.Key_X, modifier)) + keyparser.handle(fake_keyevent(Qt.Key_A, Qt.ControlModifier)) + keyparser.handle(fake_keyevent(Qt.Key_X, Qt.ControlModifier)) keyparser.execute.assert_called_once_with('message-info ctrla', None) assert not keyparser._sequence def test_valid_key_count(self, fake_keyevent, keyparser): - modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier keyparser.handle(fake_keyevent(Qt.Key_5)) - keyparser.handle(fake_keyevent(Qt.Key_A, modifier)) + keyparser.handle(fake_keyevent(Qt.Key_A, Qt.ControlModifier)) keyparser.execute.assert_called_once_with('message-info ctrla', 5) @pytest.mark.parametrize('keys', [ From fb626ca5a8607a244efc2000fbed1f6359d4d1eb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 18:40:14 +0100 Subject: [PATCH 417/524] Update changelog --- doc/changelog.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index c3efe08a8..ce5f34364 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -103,6 +103,7 @@ Fixed handled/filtered with Qt 5.10. - QtWebEngine: Fixed hangs/segfaults on exit with Qt 5.10.1. - QtWebEngine: Fixed favicons sometimes getting cleared with Qt 5.10. +- QtWebKit: Fixed GreaseMonkey-related crashes. - QtWebKit: `:view-source` now displays a valid URL. - URLs containing ampersands and other special chars are now shown correctly when filtering them in the completion. From 2ab270dfac841798c540f80597503a2885e78eb3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 19:32:21 +0100 Subject: [PATCH 418/524] Also log modifiers for key presses --- qutebrowser/keyinput/basekeyparser.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 46d09870d..33b49501d 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -131,8 +131,9 @@ class BaseKeyParser(QObject): """ key = e.key() txt = str(keyutils.KeyInfo.from_event(e)) - self._debug_log("Got key: 0x{:x} / text: '{}' / dry_run {}".format( - key, txt, dry_run)) + self._debug_log("Got key: 0x{:x} / modifiers: 0x{:x} / text: '{}' / " + "dry_run {}".format(key, int(e.modifiers()), txt, + dry_run)) if keyutils.is_modifier_key(key): self._debug_log("Ignoring, only modifier") From 29fdd1acc410da016c44f3cc921f538b575520c7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 22:11:26 +0100 Subject: [PATCH 419/524] Make sure all keyboard modifiers are handled correctly This handles Qt.KeypadModifier (Num+...) correctly, adds tests for converting modifiers to strings, and strips Qt.GroupSwitchModifier as QKeySequence doesn't know about it. Fixes #3675 --- qutebrowser/keyinput/keyutils.py | 74 ++++++++++++++++++++-------- tests/unit/keyinput/key_data.py | 34 ++++++++++++- tests/unit/keyinput/test_keyutils.py | 38 ++++++++++++-- 3 files changed, 121 insertions(+), 25 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 6f63a914a..f40314709 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -51,6 +51,19 @@ def is_modifier_key(key): return key in _MODIFIER_MAP +def _check_valid_utf8(s, data): + """Make sure the given string is valid UTF-8. + + Makes sure there are no chars where Qt did fall back to weird UTF-16 + surrogates. + """ + try: + s.encode('utf-8') + except UnicodeEncodeError as e: # pragma: no cover + raise ValueError("Invalid encoding in 0x{:x} -> {}: {}" + .format(data, s, e)) + + def _key_to_string(key): """Convert a Qt::Key member to a meaningful name. @@ -131,7 +144,27 @@ def _key_to_string(key): if key in special_names: return special_names[key] - return QKeySequence(key).toString() + result = QKeySequence(key).toString() + _check_valid_utf8(result, key) + return result + + +def _modifiers_to_string(modifiers): + """Convert the given Qt::KeyboardModifiers to a string. + + Handles Qt.GroupSwitchModifier because Qt doesn't handle that as a + modifier. + """ + if modifiers & Qt.GroupSwitchModifier: + modifiers &= ~Qt.GroupSwitchModifier + result = 'AltGr+' + else: + result = '' + + result += QKeySequence(modifiers).toString() + + _check_valid_utf8(result, modifiers) + return result class KeyParseError(Exception): @@ -191,7 +224,7 @@ def _parse_special_key(keystr): for (orig, repl) in replacements: keystr = keystr.replace(orig, repl) - for mod in ['ctrl', 'meta', 'alt', 'shift']: + for mod in ['ctrl', 'meta', 'alt', 'shift', 'num']: keystr = keystr.replace(mod + '-', mod + '+') return keystr @@ -246,7 +279,7 @@ class KeyInfo: key_string = key_string.lower() # "special" binding - modifier_string = QKeySequence(modifiers).toString() + modifier_string = _modifiers_to_string(modifiers) return '<{}{}>'.format(modifier_string, key_string) def text(self): @@ -411,33 +444,32 @@ class KeySequence: raise utils.Unreachable("self={!r} other={!r}".format(self, other)) def append_event(self, ev): - """Create a new KeySequence object with the given QKeyEvent added. - - We need to do some sophisticated checking of modifiers here: - - We don't care about a shift modifier with symbols (Shift-: should match - a : binding even though we typed it with a shift on an US-keyboard) - - However, we *do* care about Shift being involved if we got an - upper-case letter, as Shift-A should match a Shift-A binding, but not - an "a" binding. - - In addition, Shift also *is* relevant when other modifiers are - involved. - Shift-Ctrl-X should not be equivalent to Ctrl-X. - - We also change Qt.Key_Backtab to Key_Tab here because nobody would - configure "Shift-Backtab" in their config. - """ + """Create a new KeySequence object with the given QKeyEvent added.""" key = ev.key() modifiers = ev.modifiers() if key == 0x0: raise KeyParseError(None, "Got nil key!") + # We always remove Qt.GroupSwitchModifier because QKeySequence has no + # way to mention that in a binding anyways... + modifiers &= ~Qt.GroupSwitchModifier + + # We change Qt.Key_Backtab to Key_Tab here because nobody would + # configure "Shift-Backtab" in their config. if modifiers & Qt.ShiftModifier and key == Qt.Key_Backtab: key = Qt.Key_Tab + # We don't care about a shift modifier with symbols (Shift-: should + # match a : binding even though we typed it with a shift on an + # US-keyboard) + # + # However, we *do* care about Shift being involved if we got an + # upper-case letter, as Shift-A should match a Shift-A binding, but not + # an "a" binding. + # + # In addition, Shift also *is* relevant when other modifiers are + # involved. Shift-Ctrl-X should not be equivalent to Ctrl-X. if (modifiers == Qt.ShiftModifier and is_printable(ev.key()) and not ev.text().isupper()): diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py index ec2f92c1b..bf1ccdede 100644 --- a/tests/unit/keyinput/key_data.py +++ b/tests/unit/keyinput/key_data.py @@ -37,7 +37,7 @@ class Key: name: The name returned by str(KeyInfo) with that key. text: The text returned by KeyInfo.text(). uppertext: The text returned by KeyInfo.text() with shift. - member: Filled by the test fixture, the numeric value. + member: The numeric value. """ attribute = attr.ib() @@ -54,6 +54,28 @@ class Key: self.name = self.attribute +@attr.s +class Modifier: + + """A modifier with expected values. + + Attributes: + attribute: The name of the Qt::KeyboardModifier attribute + ('Shift' -> Qt.ShiftModifier) + name: The name returned by str(KeyInfo) with that modifier. + member: The numeric value. + """ + + attribute = attr.ib() + name = attr.ib(None) + member = attr.ib(None) + + def __attrs_post_init__(self): + self.member = getattr(Qt, self.attribute + 'Modifier') + if self.name is None: + self.name = self.attribute + + # From enum Key in qt5/qtbase/src/corelib/global/qnamespace.h KEYS = [ ### misc keys @@ -589,3 +611,13 @@ KEYS = [ # 0x0 is used by Qt for unknown keys... Key(attribute='', name='nil', member=0x0, qtest=False), ] + + +MODIFIERS = [ + Modifier('Shift'), + Modifier('Control', 'Ctrl'), + Modifier('Alt'), + Modifier('Meta'), + Modifier('Keypad', 'Num'), + Modifier('GroupSwitch', 'AltGr'), +] diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 71f36accd..8ed205fca 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -40,6 +40,14 @@ def qt_key(request): return key +@pytest.fixture(params=key_data.MODIFIERS, ids=lambda m: m.attribute) +def qt_mod(request): + """Get all existing modifiers from key_data.py.""" + mod = request.param + assert mod.member is not None + return mod + + @pytest.fixture(params=[key for key in key_data.KEYS if key.qtest], ids=lambda k: k.attribute) def qtest_key(request): @@ -47,7 +55,7 @@ def qtest_key(request): return request.param -def test_key_data(): +def test_key_data_keys(): """Make sure all possible keys are in key_data.KEYS.""" key_names = {name[len("Key_"):] for name, value in sorted(vars(Qt).items()) @@ -57,6 +65,17 @@ def test_key_data(): assert not diff +def test_key_data_modifiers(): + """Make sure all possible modifiers are in key_data.MODIFIERS.""" + mod_names = {name[:-len("Modifier")] + for name, value in sorted(vars(Qt).items()) + if isinstance(value, Qt.KeyboardModifier) and + value not in [Qt.NoModifier, Qt.KeyboardModifierMask]} + mod_data_names = {mod.attribute for mod in sorted(key_data.MODIFIERS)} + diff = mod_names - mod_data_names + assert not diff + + class KeyTesterWidget(QWidget): """Widget to get the text of QKeyPressEvents. @@ -113,6 +132,10 @@ class TestKeyToString: def test_to_string(self, qt_key): assert keyutils._key_to_string(qt_key.member) == qt_key.name + def test_modifiers_to_string(self, qt_mod): + expected = qt_mod.name + '+' + assert keyutils._modifiers_to_string(qt_mod.member) == expected + def test_missing(self, monkeypatch): monkeypatch.delattr(keyutils.Qt, 'Key_AltGr') # We don't want to test the key which is actually missing - we only @@ -160,6 +183,8 @@ def test_key_parse_error(keystr, expected): ('ab', ['a', 'ctrl+a', 'b']), ('a', ['ctrl+a', 'a']), ('a', ['a', 'ctrl+a']), + ('', ['ctrl+a']), + ('', ['num+a']), ]) def test_parse_keystr(keystr, parts): assert list(keyutils._parse_keystring(keystr)) == parts @@ -337,6 +362,9 @@ class TestKeySequence: ('', Qt.Key_Backtab, Qt.ShiftModifier, '', ''), ('', Qt.Key_Backtab, Qt.ControlModifier | Qt.ShiftModifier, '', ''), + + # Stripping of Qt.GroupSwitchModifier + ('', Qt.Key_A, Qt.GroupSwitchModifier, 'a', 'a'), ]) def test_append_event(self, old, key, modifiers, text, expected): seq = keyutils.KeySequence.parse(old) @@ -352,8 +380,6 @@ class TestKeySequence: seq.append_event(event) @pytest.mark.parametrize('keystr, expected', [ - ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), - ('', keyutils.KeySequence(Qt.MetaModifier | Qt.Key_X)), ('', keyutils.KeySequence(Qt.ControlModifier | Qt.AltModifier | Qt.Key_Y)), ('x', keyutils.KeySequence(Qt.Key_X)), @@ -364,6 +390,12 @@ class TestKeySequence: keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X, Qt.MetaModifier | Qt.Key_Y)), + ('', keyutils.KeySequence(Qt.ShiftModifier | Qt.Key_X)), + ('', keyutils.KeySequence(Qt.AltModifier | Qt.Key_X)), + ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), + ('', keyutils.KeySequence(Qt.MetaModifier | Qt.Key_X)), + ('', keyutils.KeySequence(Qt.KeypadModifier | Qt.Key_X)), + ('>', keyutils.KeySequence(Qt.Key_Greater)), ('<', keyutils.KeySequence(Qt.Key_Less)), ('a>', keyutils.KeySequence(Qt.Key_A, Qt.Key_Greater)), From 8deb38e22d44bfbbcc39a0c5daf8e332b5ee1567 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 22:21:57 +0100 Subject: [PATCH 420/524] Add test for :bind completion with invalid binding --- tests/unit/completion/test_models.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index e9f8cab57..86f9ddf93 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -766,6 +766,27 @@ def test_bind_completion_invalid(cmdutils_stub, config_stub, key_config_stub, }) +def test_bind_completion_invalid_binding(cmdutils_stub, config_stub, + key_config_stub, configdata_stub, + info): + """Test command completion with an invalid key binding.""" + model = configmodel.bind('', info=info) + model.set_pattern('') + + _check_completions(model, { + "Current/Default": [ + ('', "Could not parse '': Got unknown key!", ''), + ], + "Commands": [ + ('open', 'open a url', ''), + ('q', "Alias for 'quit'", ''), + ('quit', 'quit qutebrowser', 'ZQ, '), + ('scroll', 'Scroll the current tab in the given direction.', ''), + ('tab-close', 'Close the current tab.', ''), + ], + }) + + def test_bind_completion_no_binding(qtmodeltester, cmdutils_stub, config_stub, key_config_stub, configdata_stub, info): """Test keybinding completion with no current or default binding.""" From 78f6ad14c2f2df3b09fc1eca68a901412ea5cdf9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 22:33:16 +0100 Subject: [PATCH 421/524] Use Qt.KeyboardModifierMask --- qutebrowser/keyinput/keyutils.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index f40314709..d89f9aa5c 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -344,13 +344,11 @@ class KeySequence: def __iter__(self): """Iterate over KeyInfo objects.""" - modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier | - Qt.AltModifier | Qt.MetaModifier | - Qt.KeypadModifier | Qt.GroupSwitchModifier) - for key in self._iter_keys(): - yield KeyInfo( - key=int(key) & ~modifier_mask, - modifiers=Qt.KeyboardModifiers(int(key) & modifier_mask)) + for key_and_modifiers in self._iter_keys(): + key = int(key_and_modifiers) & ~Qt.KeyboardModifierMask + modifiers = Qt.KeyboardModifiers(int(key_and_modifiers) & + Qt.KeyboardModifierMask) + yield KeyInfo(key=key, modifiers=modifiers) def __repr__(self): return utils.get_repr(self, keys=str(self)) From 0ee7fac727f3cfa4bdf39e3b7ef55cad43a34e98 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 22:56:58 +0100 Subject: [PATCH 422/524] Update test_init_unknown/test_init_invalid for KeyboardModifierMask -1 & Qt.KeyboardModifierMask == Qt.Key_unknown --- tests/unit/keyinput/test_keyutils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 8ed205fca..097b72b71 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -203,14 +203,14 @@ class TestKeySequence: seq = keyutils.KeySequence() assert not seq - def test_init_unknown(self): + @pytest.mark.parametrize('key', [Qt.Key_unknown, -1]) + def test_init_unknown(self, key): with pytest.raises(keyutils.KeyParseError): - keyutils.KeySequence(Qt.Key_unknown) + keyutils.KeySequence(key) - @pytest.mark.parametrize('key', [0, -1]) - def test_init_invalid(self, key): + def test_init_invalid(self): with pytest.raises(AssertionError): - keyutils.KeySequence(-1) + keyutils.KeySequence(0) @pytest.mark.parametrize('orig, normalized', [ ('', ''), From 2b84ea9dbe4f90cfe385ebe22dfeb855a6806231 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 23:01:24 +0100 Subject: [PATCH 423/524] Make sure we have plain keys/modifiers where needed --- qutebrowser/keyinput/keyutils.py | 17 +++++++++++++++++ tests/unit/keyinput/test_keyutils.py | 14 +++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index d89f9aa5c..fd00cf5b5 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -38,7 +38,18 @@ _MODIFIER_MAP = { } +def _assert_plain_key(key): + """Make sure this is a key without KeyboardModifiers mixed in.""" + assert not key & Qt.KeyboardModifierMask, hex(key) + + +def _assert_plain_modifier(key): + """Make sure this is a modifier without a key mixed in.""" + assert not key & ~Qt.KeyboardModifierMask, hex(key) + + def is_printable(key): + _assert_plain_key(key) return key <= 0xff and key not in [Qt.Key_Space, 0x0] @@ -48,6 +59,7 @@ def is_modifier_key(key): This only considers keys which are part of Qt::KeyboardModifiers, i.e. which would interrupt a key chain like "yY" when handled. """ + _assert_plain_key(key) return key in _MODIFIER_MAP @@ -73,6 +85,7 @@ def _key_to_string(key): Return: A name of the key as a string. """ + _assert_plain_key(key) special_names_str = { # Some keys handled in a weird way by QKeySequence::toString. # See https://bugreports.qt.io/browse/QTBUG-40030 @@ -155,6 +168,7 @@ def _modifiers_to_string(modifiers): Handles Qt.GroupSwitchModifier because Qt doesn't handle that as a modifier. """ + _assert_plain_modifier(modifiers) if modifiers & Qt.GroupSwitchModifier: modifiers &= ~Qt.GroupSwitchModifier result = 'AltGr+' @@ -446,6 +460,9 @@ class KeySequence: key = ev.key() modifiers = ev.modifiers() + _assert_plain_key(key) + _assert_plain_modifier(modifiers) + if key == 0x0: raise KeyParseError(None, "Got nil key!") diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 097b72b71..0ec8814f1 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -444,7 +444,6 @@ def test_key_info_to_event(): (Qt.Key_Return, False), (Qt.Key_Enter, False), (Qt.Key_Space, False), - (Qt.Key_X | Qt.ControlModifier, False), # Wrong usage (0x0, False), # Used by Qt for unknown keys (Qt.Key_ydiaeresis, True), @@ -461,3 +460,16 @@ def test_is_printable(key, printable): ]) def test_is_modifier_key(key, ismodifier): assert keyutils.is_modifier_key(key) == ismodifier + + +@pytest.mark.parametrize('code', [ + lambda a: keyutils._assert_plain_key(a), + lambda a: keyutils._assert_plain_modifier(a), + lambda a: keyutils.is_printable(a), + lambda a: keyutils.is_modifier_key(a), + lambda a: keyutils._key_to_string(a), + lambda a: keyutils._modifiers_to_string(a), +]) +def test_non_plain(code): + with pytest.raises(AssertionError): + code(Qt.Key_X | Qt.ControlModifier) From 7a9f8fda72797f5cd075f37c6b884917be721d55 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 5 Mar 2018 23:06:53 +0100 Subject: [PATCH 424/524] Get rid of unnecessary lambda --- tests/unit/keyinput/test_keyutils.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 0ec8814f1..97c9da04c 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -462,14 +462,14 @@ def test_is_modifier_key(key, ismodifier): assert keyutils.is_modifier_key(key) == ismodifier -@pytest.mark.parametrize('code', [ - lambda a: keyutils._assert_plain_key(a), - lambda a: keyutils._assert_plain_modifier(a), - lambda a: keyutils.is_printable(a), - lambda a: keyutils.is_modifier_key(a), - lambda a: keyutils._key_to_string(a), - lambda a: keyutils._modifiers_to_string(a), +@pytest.mark.parametrize('func', [ + keyutils._assert_plain_key, + keyutils._assert_plain_modifier, + keyutils.is_printable, + keyutils.is_modifier_key, + keyutils._key_to_string, + keyutils._modifiers_to_string, ]) -def test_non_plain(code): +def test_non_plain(func): with pytest.raises(AssertionError): - code(Qt.Key_X | Qt.ControlModifier) + func(Qt.Key_X | Qt.ControlModifier) From 8a193e2dc530c1f853c3c9aad97896bfd22a204d Mon Sep 17 00:00:00 2001 From: Olmo Kramer Date: Tue, 6 Mar 2018 03:46:40 +0100 Subject: [PATCH 425/524] Add hints to
elements --- qutebrowser/browser/webelem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index 122e7d031..610b3dadb 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -41,8 +41,8 @@ Group = enum.Enum('Group', ['all', 'links', 'images', 'url', 'inputs']) SELECTORS = { Group.all: ('a, area, textarea, select, input:not([type=hidden]), button, ' - 'frame, iframe, link, [onclick], [onmousedown], [role=link], ' - '[role=option], [role=button], img, ' + 'frame, iframe, link, summary, [onclick], [onmousedown], ' + '[role=link], [role=option], [role=button], img, ' # Angular 1 selectors '[ng-click], [ngClick], [data-ng-click], [x-ng-click]'), Group.links: 'a[href], area[href], link[href], [role=link][href]', From 41dfa296483954e3f4eaf3285b9688b318211861 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 6 Mar 2018 06:29:03 +0100 Subject: [PATCH 426/524] Improve parsing of invalid keys This should handle "<>" and "\x1f" correctly. --- qutebrowser/keyinput/keyutils.py | 8 ++++---- tests/unit/keyinput/test_keyutils.py | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index fd00cf5b5..91d6a36a3 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -413,12 +413,12 @@ class KeySequence: def _validate(self, keystr=None): for info in self: - assert Qt.Key_Space <= info.key <= Qt.Key_unknown, info.key - if info.key == Qt.Key_unknown: - raise KeyParseError(keystr, "Got unknown key!") + if info.key < Qt.Key_Space or info.key >= Qt.Key_unknown: + raise KeyParseError(keystr, "Got invalid key!") for seq in self._sequences: - assert seq + if not seq: + raise KeyParseError(keystr, "Got invalid key!") def matches(self, other): """Check whether the given KeySequence matches with this one. diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 97c9da04c..11dd41b1f 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -19,6 +19,8 @@ import operator +import hypothesis +from hypothesis import strategies import pytest from PyQt5.QtCore import Qt, QEvent, pyqtSignal from PyQt5.QtGui import QKeyEvent, QKeySequence @@ -203,15 +205,11 @@ class TestKeySequence: seq = keyutils.KeySequence() assert not seq - @pytest.mark.parametrize('key', [Qt.Key_unknown, -1]) + @pytest.mark.parametrize('key', [Qt.Key_unknown, -1, '\x1f', 0]) def test_init_unknown(self, key): with pytest.raises(keyutils.KeyParseError): keyutils.KeySequence(key) - def test_init_invalid(self): - with pytest.raises(AssertionError): - keyutils.KeySequence(0) - @pytest.mark.parametrize('orig, normalized', [ ('', ''), ('', ''), @@ -410,6 +408,7 @@ class TestKeySequence: ('', keyutils.KeyParseError), ('>', keyutils.KeyParseError), ('', keyutils.KeyParseError), + ('<>', keyutils.KeyParseError), ('\U00010000', keyutils.KeyParseError), ]) def test_parse(self, keystr, expected): @@ -419,6 +418,15 @@ class TestKeySequence: else: assert keyutils.KeySequence.parse(keystr) == expected + @hypothesis.given(strategies.text()) + def test_parse_hypothesis(self, keystr): + try: + seq = keyutils.KeySequence.parse(keystr) + except keyutils.KeyParseError: + pass + else: + str(seq) + def test_key_info_from_event(): ev = QKeyEvent(QEvent.KeyPress, Qt.Key_A, Qt.ShiftModifier, 'A') From c9cd47b5b11bb23c043df7f997038dc7a085ee6e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 6 Mar 2018 07:38:01 +0100 Subject: [PATCH 427/524] Also clear favicons when possible with QtWebEngine See #3469 --- qutebrowser/browser/webengine/webenginetab.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 170650351..c9e58e69e 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -28,7 +28,7 @@ import html as html_utils import sip from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QEvent, QPoint, QPointF, QUrl, QTimer) -from PyQt5.QtGui import QKeyEvent +from PyQt5.QtGui import QKeyEvent, QIcon from PyQt5.QtNetwork import QAuthenticator from PyQt5.QtWidgets import QApplication from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript @@ -923,6 +923,13 @@ class WebEngineTab(browsertab.AbstractTab): self.openurl(url)) self._reload_url = None + if not qtutils.version_check('5.10', compiled=False): + # We can't do this when we have the loadFinished workaround as that + # sometimes clears icons without loading a new page. + # In general, this is handled by Qt, but when loading takes long, + # the old icon is still displayed. + self.icon_changed.emit(QIcon()) + @pyqtSlot(QUrl) def _on_predicted_navigation(self, url): """If we know we're going to visit an URL soon, change the settings.""" From 0e2a39da2a6dd2f349eba595ea8765599bff626d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 6 Mar 2018 07:39:41 +0100 Subject: [PATCH 428/524] Fix tests for keyboard parsing change --- tests/end2end/features/keyinput.feature | 2 +- tests/unit/completion/test_models.py | 2 +- tests/unit/config/test_configcommands.py | 4 ++-- tests/unit/config/test_configfiles.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index 1337a48d3..226891259 100644 --- a/tests/end2end/features/keyinput.feature +++ b/tests/end2end/features/keyinput.feature @@ -53,7 +53,7 @@ Feature: Keyboard input Scenario: :fake-key with an unparsable key When I run :fake-key - Then the error "Could not parse '': Got unknown key!" should be shown + Then the error "Could not parse '': Got invalid key!" should be shown Scenario: :fake-key sending key to the website When I open data/keyinput/log.html diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 86f9ddf93..5240f2813 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -775,7 +775,7 @@ def test_bind_completion_invalid_binding(cmdutils_stub, config_stub, _check_completions(model, { "Current/Default": [ - ('', "Could not parse '': Got unknown key!", ''), + ('', "Could not parse '': Got invalid key!", ''), ], "Commands": [ ('open', 'open a url', ''), diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index a74b446d1..4a09b5be2 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -559,7 +559,7 @@ class TestBind: "Can't find binding 'foobar' in normal mode"), # :bind nop ('bind', ['', 'nop'], {}, - "Could not parse '': Got unknown key!"), + "Could not parse '': Got invalid key!"), # :unbind foobar ('unbind', ['foobar'], {}, "Can't find binding 'foobar' in normal mode"), @@ -568,7 +568,7 @@ class TestBind: 'Invalid mode wrongmode!'), # :unbind ('unbind', [''], {}, - "Could not parse '': Got unknown key!"), + "Could not parse '': Got invalid key!"), ]) def test_bind_invalid(self, commands, command, args, kwargs, expected): diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 37b565374..186b3cd55 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -712,7 +712,7 @@ class TestConfigPy: assert error.text.endswith("and parsing key") assert isinstance(error.exception, keyutils.KeyParseError) assert str(error.exception).startswith("Could not parse") - assert str(error.exception).endswith("Got unknown key!") + assert str(error.exception).endswith("Got invalid key!") @pytest.mark.parametrize('line', ["c.foo = 42", "config.set('foo', 42)"]) def test_config_error(self, confpy, line): From afd5d2c728f00e5a5cfd3028b32c2d588c7d43cd Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 6 Mar 2018 07:41:35 +0100 Subject: [PATCH 429/524] Reload page after content.javascript.can_access_keyboard changed See #3648 --- qutebrowser/browser/webengine/webenginetab.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index c9e58e69e..f9130f27a 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -940,7 +940,8 @@ class WebEngineTab(browsertab.AbstractTab): super()._on_navigation_request(navigation) if navigation.accepted and navigation.is_main_frame: changed = self.settings.update_for_url(navigation.url) - needs_reload = {'content.plugins', 'content.javascript.enabled'} + needs_reload = {'content.plugins', 'content.javascript.enabled', + 'content.javascript.can_access_clipboard'} if (changed & needs_reload and navigation.navigation_type != navigation.Type.link_clicked): # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656 From 0a75c5a302e230dabe4457ce6321660d3cae04ff Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 6 Mar 2018 07:44:20 +0100 Subject: [PATCH 430/524] Make sure options in needs_reload are valid --- qutebrowser/browser/webengine/webenginetab.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index f9130f27a..972145edd 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -33,6 +33,7 @@ from PyQt5.QtNetwork import QAuthenticator from PyQt5.QtWidgets import QApplication from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript +from qutebrowser.config import configdata from qutebrowser.browser import browsertab, mouse, shared from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory, interceptor, webenginequtescheme, @@ -938,14 +939,18 @@ class WebEngineTab(browsertab.AbstractTab): @pyqtSlot(usertypes.NavigationRequest) def _on_navigation_request(self, navigation): super()._on_navigation_request(navigation) - if navigation.accepted and navigation.is_main_frame: - changed = self.settings.update_for_url(navigation.url) - needs_reload = {'content.plugins', 'content.javascript.enabled', - 'content.javascript.can_access_clipboard'} - if (changed & needs_reload and navigation.navigation_type != - navigation.Type.link_clicked): - # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656 - self._reload_url = navigation.url + if not navigation.accepted or not navigation.is_main_frame: + return + + needs_reload = {'content.plugins', 'content.javascript.enabled', + 'content.javascript.can_access_clipboard'} + assert needs_reload.issubset(configdata.DATA) + + changed = self.settings.update_for_url(navigation.url) + if (changed & needs_reload and navigation.navigation_type != + navigation.Type.link_clicked): + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656 + self._reload_url = navigation.url def _connect_signals(self): view = self._widget From 2c03bc3410a6a1ab2c4c6bba65bab79394cf59bb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 6 Mar 2018 07:47:11 +0100 Subject: [PATCH 431/524] Update changelog --- doc/changelog.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index ce5f34364..18e5b9a92 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -88,6 +88,7 @@ Changed - The `:version` page now has a button to pastebin the information. - Replacements like `{url}` can now be replaced as `{{url}}`. - Entering caret browsing with QtWebEngine now works directly after a search. +- ``/`
` elements now get hints assigned. Fixed ~~~~~ From de3d2b1cb1f6afdd5a07027be7cf46be1a6f256c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 6 Mar 2018 09:37:00 +0100 Subject: [PATCH 432/524] Update userscripts --- doc/changelog.asciidoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 18e5b9a92..6a404b322 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -53,6 +53,8 @@ Added config and re-added. - New `qute://tabs` page (opened via `:buffer`) which lists all tabs. - New `--select` flag for `:follow-hint` which acts like the given string was entered but doesn't necessary follow the hint. +- `@requires` and the GreaseMonkey 4.0 API are now supported for GreaseMonkey + userscripts. Changed ~~~~~~~ From 7fc53ae78ad8a41b5244924d6768b7249a694c78 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 6 Mar 2018 09:45:06 +0100 Subject: [PATCH 433/524] Make path optional in URL patterns See #3622 --- qutebrowser/utils/urlmatch.py | 5 +++-- tests/unit/utils/test_urlmatch.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 0e83c7420..fcddd0fe7 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -144,8 +144,9 @@ class UrlPattern: if parsed.path == '/*': self._path = None elif parsed.path == '': - # We want to make it possible to leave off a trailing slash. - self._path = '/' + # When the user doesn't add a trailing slash, we assume the pattern + # matches any path. + self._path = None else: self._path = parsed.path diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index cb4e41767..88da166ca 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -112,6 +112,16 @@ def test_port(pattern, port): assert up._port == port +@pytest.mark.parametrize('pattern, path', [ + ("http://foo/", '/'), + ("http://foo", None), + ("http://foo/*", None), +]) +def test_parse_path(pattern, path): + up = urlmatch.UrlPattern(pattern) + assert up._path == path + + class TestMatchAllPagesForGivenScheme: @pytest.fixture From 257753841bd9194a4bed0bbe7c088496f2eba905 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 6 Mar 2018 10:32:38 +0100 Subject: [PATCH 434/524] Allow lightweight URL patterns without a scheme See #3622 --- qutebrowser/utils/urlmatch.py | 23 +++++++++++++++++++---- tests/unit/utils/test_urlmatch.py | 29 +++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index fcddd0fe7..ba9d1d63c 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -43,6 +43,7 @@ class UrlPattern: Class attributes: DEFAULT_PORTS: The default ports used for schemes which support ports. + _SCHEMES_WITHOUT_HOST: Schemes which don't need a host. Attributes: _pattern: The given pattern as string. @@ -59,6 +60,7 @@ class UrlPattern: """ DEFAULT_PORTS = {'https': 443, 'http': 80, 'ftp': 21} + _SCHEMES_WITHOUT_HOST = ['about', 'file', 'data', 'javascript'] def __init__(self, pattern): # Make sure all attributes are initialized if we exit early. @@ -120,6 +122,10 @@ class UrlPattern: if pattern.startswith('*:'): # Any scheme, but *:// is unparseable pattern = 'any:' + pattern[2:] + schemes = tuple(s + ':' for s in self._SCHEMES_WITHOUT_HOST) + if '://' not in pattern and not pattern.startswith(schemes): + pattern = 'any://' + pattern + # Chromium handles file://foo like file:///foo # FIXME This doesn't actually strip the hostname correctly. if (pattern.startswith('file://') and @@ -129,15 +135,24 @@ class UrlPattern: return pattern def _init_scheme(self, parsed): - if not parsed.scheme: - raise ParseError("No scheme given") - elif parsed.scheme == 'any': + """Parse the scheme from the given URL. + + Deviation from Chromium: + - We assume * when no scheme has been given. + """ + assert parsed.scheme, parsed + if parsed.scheme == 'any': self._scheme = None return self._scheme = parsed.scheme def _init_path(self, parsed): + """Parse the path from the given URL. + + Deviation from Chromium: + - We assume * when no path has been given. + """ if self._scheme == 'about' and not parsed.path.strip(): raise ParseError("Pattern without path") @@ -157,7 +172,7 @@ class UrlPattern: - http://:1234/ is not a valid URL because it has no host. """ if parsed.hostname is None or not parsed.hostname.strip(): - if self._scheme not in ['about', 'file', 'data', 'javascript']: + if self._scheme not in self._SCHEMES_WITHOUT_HOST: raise ParseError("Pattern without host") assert self._host is None return diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 88da166ca..dcd703790 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -43,11 +43,11 @@ from qutebrowser.utils import urlmatch @pytest.mark.parametrize('pattern, error', [ # Chromium: PARSE_ERROR_MISSING_SCHEME_SEPARATOR - ("http", "No scheme given"), - ("http:", "Pattern without host"), - ("http:/", "Pattern without host"), + # ("http", "No scheme given"), + ("http:", "Invalid port: Port is empty"), + ("http:/", "Invalid port: Port is empty"), ("about://", "Pattern without path"), - ("http:/bar", "Pattern without host"), + ("http:/bar", "Invalid port: Port is empty"), # Chromium: PARSE_ERROR_EMPTY_HOST ("http://", "Pattern without host"), @@ -114,7 +114,6 @@ def test_port(pattern, port): @pytest.mark.parametrize('pattern, path', [ ("http://foo/", '/'), - ("http://foo", None), ("http://foo/*", None), ]) def test_parse_path(pattern, path): @@ -122,6 +121,24 @@ def test_parse_path(pattern, path): assert up._path == path +@pytest.mark.parametrize('pattern, scheme, host, path', [ + ("http://example.com", 'http', 'example.com', None), # no path + ("example.com/path", None, 'example.com', '/path'), # no scheme + ("example.com", None, 'example.com', None), # no scheme and no path + ("example.com:1234", None, 'example.com', None), # no scheme/path but port + ("data:monkey", 'data', None, 'monkey'), # existing scheme +]) +def test_lightweight_patterns(pattern, scheme, host, path): + """Make sure we can leave off parts of an URL. + + This is a deviation from Chromium to make patterns more user-friendly. + """ + up = urlmatch.UrlPattern(pattern) + assert up._scheme == scheme + assert up._host == host + assert up._path == path + + class TestMatchAllPagesForGivenScheme: @pytest.fixture @@ -264,7 +281,7 @@ class TestMatchChromeUrls: class TestMatchAnything: - @pytest.fixture(params=['*://*/*', '*://*:*/*', '']) + @pytest.fixture(params=['*://*/*', '*://*:*/*', '', '*://*']) def up(self, request): return urlmatch.UrlPattern(request.param) From e3b8372b8b01f6da409faef7dfa5ad5d8bfc7c6a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 6 Mar 2018 10:33:45 +0100 Subject: [PATCH 435/524] Make UrlPattern._DEFAULT_PORT private --- qutebrowser/utils/urlmatch.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index ba9d1d63c..5d9afc13e 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -42,7 +42,7 @@ class UrlPattern: """A Chromium-like URL matching pattern. Class attributes: - DEFAULT_PORTS: The default ports used for schemes which support ports. + _DEFAULT_PORTS: The default ports used for schemes which support ports. _SCHEMES_WITHOUT_HOST: Schemes which don't need a host. Attributes: @@ -59,7 +59,7 @@ class UrlPattern: _port: The port to match to as integer, or None for any port. """ - DEFAULT_PORTS = {'https': 443, 'http': 80, 'ftp': 21} + _DEFAULT_PORTS = {'https': 443, 'http': 80, 'ftp': 21} _SCHEMES_WITHOUT_HOST = ['about', 'file', 'data', 'javascript'] def __init__(self, pattern): @@ -213,7 +213,7 @@ class UrlPattern: except ValueError as e: raise ParseError("Invalid port: {}".format(e)) - if (self._scheme not in list(self.DEFAULT_PORTS) + [None] and + if (self._scheme not in list(self._DEFAULT_PORTS) + [None] and self._port is not None): raise ParseError("Ports are unsupported with {} scheme".format( self._scheme)) @@ -257,8 +257,8 @@ class UrlPattern: return host[len(host) - len(self._host) - 1] == '.' def _matches_port(self, scheme, port): - if port == -1 and scheme in self.DEFAULT_PORTS: - port = self.DEFAULT_PORTS[scheme] + if port == -1 and scheme in self._DEFAULT_PORTS: + port = self._DEFAULT_PORTS[scheme] return self._port is None or self._port == port def _matches_path(self, path): From e28a01351b6d6dbfaebcdab60108993a20e00eb0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 6 Mar 2018 10:49:53 +0100 Subject: [PATCH 436/524] Add toggling to cheatsheet --- doc/img/cheatsheet-big.png | Bin 1048195 -> 1100960 bytes doc/img/cheatsheet-small.png | Bin 45328 -> 47524 bytes misc/cheatsheet.svg | 80 +++++++++++++++++++++++++++++++---- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/doc/img/cheatsheet-big.png b/doc/img/cheatsheet-big.png index e9386d121b8c18ce630a1c0197774d244715bc7e..bc4b3e0534ee484535759c6d21ddec9cbf155bd7 100644 GIT binary patch delta 904592 zcmY(r1yq##w?90hw6t`C(%ncmQVNoSG$<-9NI#;a3?(8Vf`o{GgtRo$AfYrU4T?y2 zzI*Py@BQ8XT4$Z5=fKP}-`M+8d!C;vyh~Ae8gd~7tNbD}4$@YB=8iho5&fZFt2rJO zRJn?;zh`mvm|S4=<-dvje=h@kEy{yUDjPC-b{;GZ<7 z+jB<^R)WPz+j9NBeV$oneZCF-CRe+|a&tK@U%qVZ;u5p=vo>3eP_)xt?D6sO zEj#lnm(a7*W6wRSzst+GR#pm!60sd$v(%#AWo7l=PkQ^7h$Pl?V}HM+>|X!I=DY^p zC1t|`W1{wUB5MbS&~!O}MQ!bZ-cg_9Jw_itKenq^uS7;h#%X!JD<}vb`DEVQ*GJ4K zzUOChetWCY)z#Iula}=>*yiRk7BwFDM)`&xON7akH61i>-V8V7NpvN= zgk|{YP6fG!>i>CQZr8oN?D&+FYtw7r--|BwrhL0k!N?d*E9xj>OH7<#WB0?n;dJkx zOmB*4(x78I+UYkk@`+wAT{aXslMac9&>)R$6%rA7_URKpl|UK^KRK0my>gqLE7Vry!`0K!KPi#`}Y@6kG;HX938{t z10PwtDiEx!th`@I`hOOdRY6{hfycW0)kmZ-hgDZscWg3Nx#uTvlj&vo$vRRl_t=Ww}2?!^Nd2z}(#Y%^=6stCx68 zL{t?OQ34j#rb`d`lEqq=w>OX1MTOjE6uEc_-8Zv1C0u`DMXF+U?h_ zDSaAvtkVS-F0O9OC?yBfdi@>Q*qkkrfdBfRb0GZl3+2oZ-eQ9l`Q(V`s zVXv(*Rr?H5)IK>kntQmjv4J!dnt5*g`N28Tu&`i%aCFoHzbNCq+8TWEc(BwioP1yR z|2zn5NE|csHR3=j8k&eVlBcx77cXA4XjqMe9TAz^UhcP(WAJcuv%X4vcz8JbZ}Lf= z$$C%e+S)Qr$7Na(n*99yt$&})tAxp;P}VC$A5CjV$Pihkn@scyK@7hd0>cXndkw7S z;rC2OKi?lzKCJxsk-ODaxbo3BPb{OH-{mMO{*d%^%a52P``^XE9DO5j@~F0C`9PdY&*qrtYij+izvpztb=kKem8ToC z7sD>oz96LT%hr7((N@X%-!C0kT;aJa@My|(3%lTW?_bn&+@p9`5!7Pe~$V4;Xq-Pm>DV5g;{v)x@$kYctSE-;~vrk500%1tr0o2|ORmH%fMlsjYdWGTW8Zr{$#%nbf``-AOd<)`02 zI>LsRcAQL%A8pRPFU-j-x?9^ae&||V&Cs#Xm4L7$YQ-le8Z8_qBqRvm>$6b*PMXo* zfu{X2JL^vM;bsP=e9?B;-@9iwQBF&c@cMsNK1X&DN)sPd(!ou=n4HhyI-ST1!&LM9 zX{AzAi*XeWSVK+h%oi#d^*)==D9cJpxHdPnw6v1NoXB7EnZb4{tUYUsqEv&Y5TBCzf zC0)aQe7M=2fxr95?qcSx2s!^fD&MpDj=OQ`>3tF3(hg;^TlD+W<>0R|gwz7vc7m35 zPphg(9zF6)n$7Rp7Z4St(AU>T)5LbDWUEK>IH`sp|9MPOWgdFCTgC^?1gN1xvoga# zs7ftIyYvfz*REcj*;?!=si`VDz=b)YLmM z?5qouFEy`9J^n3}mUh-&`(*t}r!CtJg{(&J)(> zXMvR09sf_`X^>=MT6RA@@;dZ}$85OnuE~)wJ8IRzDtAheZ}cPk*d?cho0~h)+)B>x zaS|ud1>Y{(x7WRFuM%Hq>WJ;yYR!~+f@OB9lPnZm5jXXUo4AdG`0(hc)b@6+R0%W! zVstAj3MyE3YbJ7@C-Y>@`uD(Wp^t}$?KO1R|1QWvBk##9JKVPCWb?t{UBndca*K>_ zWV3~ky1bIwNqPO6ikFIl;`!0;-^A+Je4_%}o%{Y$(x?2fjPe0j;O4YvYmjXvr>4@u z?Y3AwoNkO2*sRgca=S(utoRhJZsPB;qg~{0(qB`&Lt{7{k(=|JENpC9W0h?2G~p!7 z&4=6Pa~TM6Q+GE(R~Pf<9>3^#p7EzUl;RezAC4By&Z*s0f3&+|bj!%7Z7{pT?d-%? zRb4%}-=yWecDj&|kaal`Q6TEclP8yO!&&6ziOtN$2Zo2$wwJyQ6k93m{IR>eO7I2| zNx3ZPGSxI(VCe&mE;14yTHpYj(mNFoGVMBue-9T(qNC~En9f&*A69?)!X_j{DlA;u z+1ncu7KXDj^67aDqm1oPUi_Borxur=1Wn^*_h?JsYbPmdXwVA?aL&xmvYzj|KT(Ln zA!?{IZtLi|DNq101sIc%@) zwPgZ!Dzk-NT9~ZL7Jq(vdc0qF8cio|--bq`-(_Yt*KFKv$jj4guPiDOSgtp|ecSte zN*2{P{C1uHKC#Gnk~2|s{=knP1$7t5Fz*wmW#>4;fG{zwr5(RYOl;h|k9X+$cVd{X z*s^|R>dDr^4L+-eE&AAqxbxS3yMO1}y{zmV9EIn?)WwA#k6B}D02*0**P7X{qoZS5 z3Ww+wk2@ZULoW^Q-g02)w{O^SRko8*GH9HkKRaC89LhH$eDL5wXZO+8k~s-Rc6`f;?*F4(?DAJoIO_(HTWwT35PahQ#ftPmOiw^oy zd!`Dmeyz82-?l=~3C-;fdMZ8#>>~}y$p}qIs9ea0#~SB@O*aQ{8(h-Hr5obQ;2kBIg$HqJd+2h2$$ z{y@yYz+j;iP(TPgC+1wvpgvh>|4-RgcoPz$T4>dnPDU?wpY-~ZO29v4WXdQZs&9Wr zi(Rk9gq-jZaBMF0baxBfwzB9qF*GuYlFgm*d>3F5)w9?UrNsbmo_)(-x6k{|ojU-I z<0v~PIy*aO9%*Z;m6rVO^obF6nJOM@2sFPI6Y*bn*^o*;gM^XsZ8sARs{(fBt-OtW zUES#V1ANw;$6|<>*g%ylbDFyyM{By5X9ueG>=mgt-eD1!(H^RST0)}UthD0cXem$h z!uED;7j3asBU82@G=m=>?$SC}8|xbC>jzgUgIK z#n`kI;69aaZ%>lo?C5b7%~E9vAw;$FucaLl!k&+ zPubSCrHaPoquVv23vtCm`#>~KwzL)caxz<#m6ZozvjPq_f<3K@EhW-!sI+x;bv1X! z?_r8AuT!2-yk-bIOOg+El<)Qupbu216>oM{mXK{Lm-_cxN={BrG&0@{Z0y-u$Yc5c zxpdrITwHiRzj)m{`Sa^-VE>|j{Hs?XRj$)r=O?|x*x!Hra9`-uZ_oJt%ZfeZmBP(_ zZPZg9qx`1seC=zoVJDZX$)99pXZH+IXY>d4wl8l-$%gUX;F6b5r|kLmZ4pgv=recI zg5k+=b+yz;8HclTYK}(d{{6qmh;y>&y`mFXD1Tmm&HqJJ+%0i7&&}|aq43TATfBXP z9aDA<-G7UEdU~3gnKk$K-()%lg!mk-K`ILjPPcyt*vZgVprg{Dc!n73VT@M zvG{^KBRp=sIZI8Ol$yHZML}Mk_1>x(jobv2y1M#SwdU0p028H7Kd5B=_bw~`TF6Jr z?$v0so8+G4WUdSsd>tw$1NgML^GEdX`LtRT)qESFW90aB5Pd8VK;_%FDH<9YOl!7C z0*-d@6@NYa32mB|^7ZGkGGXskDSJmpk)w}yYbDyzw9#Qix-3-M8@~tHeSFgFqTTz` z>o<0H9a9B=k}quqaz2D=`4^BjL$>GohNd{zH)>Fuu}_Kwtfwt-TDVD-u7t6DN%~; zf7{v>H4_tDIX|zC_fft6=%NpOO-Sy|bz5pWwvag2Q1WnsgX8u3-#=?f*x1<@OK)b# z+nk?WEUdI3@!s-UdlZ?g`-Y8;?OBCuZ}K1kvuxFaVe*F~Qnurt7tubNns`tcwESq* zJUk|%EbAYU($n+C-mP{cgCotCbcTP(G*#_>5&mM`w!6FQzE?<}D*mt?nfL%3A|oTS zv-XqzcCJnWvz0|fT6fd@W{LMFb_NE9g#;-m=gwMj2|LA$$D??|j?DfKSL0Urs&Tn( zxW_V13wgg~`e+q*YG+$x)MX@5LO>ahI|2@3o_;n?U45}_Us{b-{g<@oUopty4H*SN zC=h+0_zvB-*Rj>r!!VlDyppl6Fo-6xvDCoxqXC*yPNw%SXPcGfF3J-t^Lz)Xp)gsI zrD^tSxKu(I0Tj6BWn~m>?9;4#tB8#n(PqJAX8FqiN*r3tD=IFaMvJZPU6UcA6N{+O zDh^e{HEslPWg$|#FF*gB-q;9a|}4Zc?!s-Yk&ETIP#E#m_e%Z&f)9- z9G>vOYz@2F7B%$P&)O%OCx_c?)LuN1Pu73U0@f)!sCKvOdH*c=`B!`OxdCAJv-6%r zprF}S>~2`=9js4{8>EX}TKIX7o5u+ch3T6I8)QEI$1^L#Jwjq)eBS6^zvRd2{Uue| zyu}j`z-VLRM9PXy{C%o9S&yb0F2VxZwI1!1HY=PnEEIA){`37B%6E7GWHJ4GPcj1o z#_Nw20lghosoO<5dBYC8uDQ8+`tgO2&_wKJ6lJQv1l=&!($RVL-huk@-w`4@ zI-}Y3^;k*QUsRJxZ-1g5{ro}%EjOV90i|)Uri`-i(DIijPT4lwo)dKyEn>RHK3`E!+fdkrCSo{~LW;Xklit~G_ zjl4nfgl6*x=TGaO!z;Dbk}7ztucQnk8XDwdm=(eRJI+rdf-GFW5qS^><5E&C+`TI| zl&8Wyb4QVqzY~B_B9!1r%G#>DT-1+ICn!d>Hks-q~%o1^eLeXDhJb z_?IuS0N(|kJ`fTXR!S0hJ6P}kq}@I6XzZQdTS1EL0^?8J!z>r zNQ{h(Kqbb9z2;t@O^mv55r=DPe`T20xVO*6I`SxRcf~8XvC)3@wuy{K{dg5c4kaYrpb2bd40>+cu6+5 z1d8GmNOk4qV!!|V$+GK+0m5U47R^Xa<$-RQSyBwgO*Vjr7>dY5#ly_o9}qqG%`kYl z$6{q3MMXu#4_PC>lSj7V5fL>Pm=wpSral9dm*w*Fx{#z~_?It7CcC;XBp!|3D!fxc z2Rb17Ad-T!^uaK*)$o2-!Uxl+NG@JpI{*~~FfXi$mSJ zw``!h^{dX8P{L)ZH6ofum;pJf(Y6%-@@{{9x~G8A>(BR4&t^L1?ym=%^KNkKBHFCT z7e9agJhQyN$QlHSIVl6f$+z}$$7uF|z*CyY;Ex}Xk7#>kc6RoNHc;xX@~npUFVBya zvL7raHK3REp#!(U8Iz868Y^KvJMlD;w!l8Lg#97}HJR6+h7VL0Tf>PJ_>C}P#+Lh{ zPu67H{U5_A6b985h}ryl<6HM-Jr?=Eqf78He~*`Of-3!K!}Ae&)Ty zGEf*r7Z-uYj~~DL@F6OUh#rG0OifMS0gg`#j8L^rlk=y7!r`;SWpUknC02#^p8eyCe}3YL7t5@m4j^Ue+nXr3_$bt0(SJQj#X=a7 ztq?g6YLhSE!010b2?Yi8RD7nL5kG6G@hB(~wCKn%y+X5%UibmcNr0=!nlk`voc%SN z%=%DYG7WO&&MKuy6#_A*@0WB_#kjXOp+nKp(V=n0?b^}Yo0~j5-83ThLKxj+&Q5Ex z0X|Ck{pR#rH#=ij7rJhzZGuRUn4jVOZz2=XPU@@n{n02JW>f^VtH90b&YdqUfo(T* zPelfE+!Vwgj_|pU_`_=PzE)sGGmi2PZekCADmXY26%jENzI%6VK|_lH$N}c|oFt7; z5YJdxDDhEcd;OI_o1UMYnnmKAomG`M{ov@!=R<)|5DcZ?2DB0a>f)~MZlq;vk=JeK zx3IdGDJW`im~9uk7l9LWnm%m-;f0Cmo>?H29y-x`eXVb0g3;CK=}b_dqTpC&IZt|{ z12G5;U|yOXubzj82u5ND_>(p*Ze(aE1WeTQv;C$CCnuRpYHBKt)BrJPA~c)84sFIi zzjv^FkC<{KG!G9aQmzC2{aEn#ibMAmT%ELoYwn$slel2ofF1F&qwlRuf1U^MtLNZM z2_Kb~m3@V07|1u`9jT`+HATZ?W8@&;-XA|GKp5Lv8B(K_^&ti1@*Ea*9@hL|KGq10 zn`&ukc}-vy&P=f# zS`63yc4+47^^J@u1+5xd07%hpLM^NFTSUW6!yrZUz6p!qctB^I4UjRrXm&X{#`ySn zJP^7}OeCQd&cn|XdV>;?EY@`amai}n6nUS`Icy6Hy?1cv$I9+W{uOoiJK_Y><`tPJ zA}${K;REO1x(1jZQ6 z@ilUMbu zDs@=784qW(8QPh!u&>d@90Tqc<|jaet&A=9Hl#YF9Dfh6-qP2%X?5&7xjQQjikq-a z%O!PW?iQ3iCRrcU!-s#_Iz~%uR8CH-C=LSTQP%C~KT=Rofz~WITQ=C6R4&c^uEvph zB2tjVXZD$fkg8X3OX_BSo%{A-z}Q$oMq*QTd0ELZn;ysfN(YB%^1_UUY~N&|0k+eHvpDzO#aRc(XUuz~ zT7!c#fhTysmh&e?7#TIAqgkDQ(1{osg?^X|mkVicQrdFfEXnMgs?JOkjEKvi7v#oI z9r=kg9oJQx>P1lHCpR}v!WAc=fx1U+af2*R zx+vcDp34*pUTe`ue8HU7LE%}V)Xq+qXox~U$dcnX9w);P(wE`n;oTsjG_vyXT?TWz z^W?-ve)<{q_Pd-M!yD}E3(IkD-$nyfMoQ*DbK<{^fR}MF!5ZjEME_aazu2^t;on5X z{QbLz3PhV=K^%G;8&kn4d5Y1${rOCMH%m%{`YHj>(M&?OQ8T{6qxT$V*Q^wmgsG`L zSCXBImbNPo598D6awM>_vr|AH{kWP*x|MN-`a{T=>CzKbadBE5U1Xf%x2UM^#*Sh8 zw>QeN0i7BipUFi;dF~;g}Jb(W63aEG> zuY9cpoA)o1tth@00L9V?VW2lVbPr?^?0ro-?-4_=a-W5Rm0q~D`W(rnBolDkTDS2RVe@fgutBh z^0=lNkqr0VWZ`V-V}i?uO>J$$#xvy3W30@W7`;;Gr*$vb za&vR9(IvciL9qJopN*?)e+@Fz6nt%k(`vpwnwC(NY%5z^QxgSo48g`7B|!G1OlNrD zi{C4+dHhF2KIjCGk&zLlXlQt4zU=ueuGSdF$-x2>!Mu+j*(4;WFuU-xcAuuJ>JUDy zko(+K_3v>A`1vD+X3>`QeoAcdGY9U}0P)q%Qa1YDDz5&j$Msz9tD)5>jB?4{1{22pRlg$1z>OfoU1%7Rma9s^s% zeQl}{kC<2;mH~vp%;Mr$I`RB${cQE$zv^ZDc7FFAH5d+JSXW}=HyWfC${-5tp)=1c zU$&i{9cvIAaBy(c_L343p2OLj{aLFCHGmC1mBGg=z$5hmJS3-xo0uwnaZ?~+mT!d| z4Cr?_OtHAI`e1{;gSWy6I@y8B%2*;IBDCuchX7qzNds!0uw5ZAHM+PR2=XOh_KxFy zWIaATomf|wAH!qdOsNHFT+8{)o#7P@E@*(cn4=i4Q$$koC8FBk83q5>`wnIoQc~$! zRl->O4-FPp{+8`&nw zp2++Yu6jE`W23mjRHo0Iy2^WnDF~_~i(<}3C9~2+&L?A9Wq718w6zk6#}8RJ9;)s- zADAknZDAJ4c$jwGJ(WBhk2Nu-K{qiQKKBe`2kuj0yY^Q{vwg8kOS(vJy4)KwPmhV7 znu%XkR1iBkJ9VZl#U`Y(2I`Y2#%7?;q!5ILn`B()^y9OfB1Wk#N6O3NQd5sARE{0L zJXyy9ChHXZh7iI7PosL=L%X5?+yG<|lz3z+=n71P*`j`>?Y<;*MQHPh3IRQwR8j8x zYjTg*9^s>eL?6Zg+!N*Ss;RBDJ3Bed1EWROdpDn&NzS&YaJ1C!L$M!?tViTn3fcS>e<=4f? zRsMvY?oVY%_zgdYE0{-DA+Y#a0N$@KBJc`LfAp8=QS?0H+%%bLwf>eEUojliF%UeRY0u6 z=yh$ZVAS!xj9t^y3#cuQx@hKe`jZov?nzm{E5TrsbaToC!CCU(zb@PdQe>&?^fZ{% z*ia$hRATIoK7Xg)ztzoJMS12`0(u$pk?PTOO5i4gssnMT?gK=?)EGIfcq2a8+2te{ zM#9W7-Qcmvv-(e?2hk&84x8Wk^Mgh%Rp!t007id*e?H5)5VGy9Ep-bE1|Zb>`3)fO zNOR)FGRovCQw1LFj+Tx=l<9NXHV4wy+S&vd9+ZfgshVx{bOrRT(lwh%Ra>1m5{yht zOz*F|8Y8nhB*a8?G-bB=3?!LRwTMBr$T(CtZV4y6&SSamMr0UtSKG;iZugZ}1WUa( z0vzDu3AD7xzy4Az>3Sd}dC!Er(O(jqh@Q$SFotw%+vBlC^q(Iex~lH=kFZl~#YLe`aBWB@ z$CV7))u@BS*4Ea5XmPU}gU1;X8j6Lotn&#z+@^LD@OYV)hQ8OIJ_|RLLCTF3blB$Z zL|zE9RJ~ai^+i5kw0i{VP+KBzQmJj57K5B0dh^5zU|i$bb*jH3pB7J;?zt+^fhK8k z@a5vdwGt8<>nO;^xCd3e?y?`N9mR(htLowLxg`ieLkYS53g}z^iGuW% zb$ey0`^8Jx!Y1>`=@Aidf(Ym;o_(j_#&CQ|>AzQV^DlJiu(n>}3`;x;nb~6uxIW*lbt&m%0sEwl$k52L5ixT6 zMNC+CdVOcQFPQ6TN=@T1djBY21>n%O)B!bf1*l)p@bL5m%ys=x_-i;x2c!;YDXKC4i%Pg2jkL!lI`IdWK-J>BnU1+_B_$6g{s4&FXJoCI8yqjzhtrqX-Hm9tjdM-_a<03%Q75iZxk%5YRRVp$80u z`FYwCk{S|n31riY7|j3S111CtiG`qHU(K#dm!=$bH$){Q)Ek2+Wmvf&4)J7N#8qUp zz{Es;FrIWv4Vs=sOvbZ)Bj!*FXHO50xU^U3g&cd?7@ub>bzn;s%QQmc zf8;`3r?)o$78{WkKVKV+6p{WF_i^H{;E3Y60^rFjN1fIOUb@`UT+}Dndf`c$CR%9+yq@&ww zVq!IN66FT%LRC#o*tW9rlr{r-Ur|v}>h+hZ+;uuQUdU~!m)3V~0J4sS89%_KragrK z3nmaIrLXbh2;X4JFQFK z%uC#6pRr9YPERjx`uy(MY=UzeVGGH*j#;dPB&}y1^Q zp!bbYnp#?XZkIi`JRd(6d-Nzs39_jJKtw^`q9LM}cxCdED=pdmAKkXh2MGP>=<1sF z)4iciJ;(rq9FAwX(+~C8U3j>Q{`+f=^~(>a?u0hv5Hns!gZ|1r1%V4GH0|~@qIX*F z0g>Fcs3aD4_H4tBMgGfy>j&&?YL1TL{;Vi- zOXJJNbZ;mtC~(z&QR;QSc}L8JVW{#^Vup#=K2t=lUNHLCdXN;*N2Rao96p;T*Vxzm zCWv~B`Sjf&uV^R>$y<$kebwD2bprzf$idS4D1=I1KR#JBJmtpe{FvXQK;jZv%G#Yspu z-7+z;`y{K`Z34&8xY*mi6|73r*+`YXzGHU~EY%;46sEkAQq^QlrxvuCp2pQKr}MRm zWly*6>peVho|C%FEL@JIuF=`g&!LWg5$8S|PyPun^T~Z%TgVbAnL3bUPrZNtzO?AC zM*ia?tNn#8in<`=GYap`Vo5>wy6=cYBX2hv0W*pDeTe8X)o zgv@+0sL&K+JQ{ft5|Z32>f|8B8vs}ub9Rh)&E0|rhc}}sg(&YxAQuYFQ;Q+{p&Nv=>rRI_;2jSWNUUg8Tra zpV6b2y}Z0GGs)(SZPL-w&VGL%1>B-ic4>LpB)64Sz~S)R9C|*a3fZq-Rb`s2cE@N( zOWdYJqU`J!C0!1%ItwiBe$aa>5}g$1+6;K?Yq%(Z4mgURRIFL%W6q0`~(rR2b@$$O0Q9wqb^iSCNKmOJJzNf z`wt2Wz&L>db(v0#6z;r@4xZr2T^4b1N-#fo2Ysv>gKpc;-GZvpeNA6-8Z3|(Crsq@ z$C*=oYKYc7kmA(jDR?6x8HJ{Eh#AlK>K95KQmyj=b2QE*NWm6;qmb! z`Q4iB%$E4eTtA9mOJ>Rn$h#{0l6gEFepc;j#g!qGBpH?@n7QhWGpEnq<3p*?Ff(I~ zW<2Iq#U`)T>K+~@0(Dp_=wwW?F=>0Qjj+xK*~C)(^`sfH-6L2v5H7(08qs@aedFPv z0H=yMVS#HgvyBZEGNkeRQ-5OL7L?};ye4M==JtB*zyX5lMUq_%`e^oTPHl+fQwj?U z`#_AJNzTtOH-k3`I zxev+nmLw=et`MIAMa@UYD&H%3?Xl&jdDPUE1Ak%R5nd}JES|pQW4RgEe9OGbvf=pNj1c>0G#2Kxccf(2 zZ=1GZb^8B1nU76O7Rvl>l&jqh!YULkTgYH(&Y0FHX=vbr#X|}fU2bb8!ARzFY~H=C zMeTr-1G4MauTy$k7Ino?QLUDeZk4?Rm|Ec}DR8&$WcZX}3c+)7`jaf>B&+-KT230W zuz<2_zt5#ZM~;WO`?Ie92{*SEDRsTuRo0MK1fx&5*xA>@sUf@sk=b}nG{k7`KYS=k z<xUVT+^p}JLY1OivVq`2W*ZBuX&HZkU@ex-5>5=xVCm2 zKF5kYUyM{aoIfz52v=nzgMpcLEf=T3mdkL$lp)<3_xg1hP>-*pqe*32u}Mji42+{q zgYLUkbd?sMcS0nT8}w}$u=)Q9t9(J-yxmD}JmJ&V?L9og4lS@&`{Ihc0a z%=#ef4cP|Bighm=)de7jn6UfSsrZ8-MHn98O_7AM`Rms&bj~Ccua?0*ZDb_Q)ZBBe$7z@#<>AP?e7Uj- z+2V-&oH0lvx+e`WQDLt&+sHc==#8_}q;)@tIYZpadhNt4QA$L)vmZ?!-icU# zOGU&XXI?Q!|B^X9D_nYI#o^}JM}zks6yXy$EK$rxhhJmX;RA2&uB~!?yvkn-z!SorS z=*aHFht!BC7@H$_m+PLN9+Ncn_mkk`M?xl?4wAI3V0v3yTR$aZS8vL1il$RbnAIE~ zF1kx!@n9J9%8SB~T8)c~Bk*t9h4|^nS8-76Jdy2}pYU#U_FIp}G`e+d6D-SvQVc3S zM)v|;=v^V7>YY2+f7Jy=9V_iiQwaRj%TTI#;721Bk?%)lz_j=8w%UEJ?E=48Ci2TP zEYB(`E~%^c=4^m6Q5|Uhl!DXHZ8>JcoH&tmi@9~VjFWNEWK=+h&8B0U!jC}DxGM?C z1Z3h)AxgOaJ}=|hut$&9jpm$!mpsKr-uUJK-oZn#ll9#SAp;n#8c7ijrc%7gY1h5` z_d9)WX4mPa%(2{Lz#5}0fXw6F15&(8SnyAKNXg6$X+)c?uTcohAAl5fp8rliDJ>?3 zSXQ=gF3IX>78v~20R)qfne9WsBG z96@wW2vlZ_>&+IA2SLG*jA>nveih+o@7CUF9vPtukZ%l8;Fqv;^1%jofeCLcH|2YH*CTL#8BO%d%1GKoz!v#i5 zaPSjrC#SB1dW>bWN^oj5G&&mIATNbj6~m>Jnyz}8kN_+6HR5z{(g$XcP#EFBe{a=+ zPh@^&MLkkwq4wR$I(BYb%r{_2XZqepx|?O=+O5WvsQBdMOOlc% zFfjl{fet3K*QSgkg&3kj*C}BMUApAc_Nu0v4Y|^;6GtAavbBg4~RRQF~IJOLFYq=Rt0v*NCyPOACm^U8bs)c^LwE z>vnboC2@W0)`LP>EWIUeS&NI9CGtFd79O!M>8{`(BkB~5)!X%M~f~$Ud^lavpYR9 z7%CPFt*^)1U&9x=M{Lw`ZQ&tI7`*Zf9S;B~X<8m3O@{86p9LQhTcaefIPvK67)qPL3DL# z6U6~t0x(>Ntfl<4@dW#$;b55{WSehY+)VNeLc(5Jtmt9$<@OZ|2qg!Zoi#02@=c=hMs)|%uH7#w`fZyw`59~7EB z|DZ2p8^{IiunX>G8Wcajvrej5O8g1&f>HE?Q5R;gH_VnU$<9_hoT1b-?iCjcDZfF{ zR|(eFOruY8gaf~Mw-z0bu!DoVuoJ?G}yqBb_9V#7(k zl2Yzk&bA005(qFrltKk0p&p88s9TVSvxd6!b!-e18B+%d6Em`5YKoMfudENltXE+T z6B*g+DB06f`L5kZ$#LB*)m~AhZ@&t!e^R|lp65K)sY!>08g!kHlI8oLOV34Jy?zyb z0xUUhc1yqOmq?gJY`=Ru_l%g_p);&?jeC{~a`>={%UKo(N*5Ghuhatfb{pn7HWf;(lkm3i$cBu(N0*VX)_C7KZ46 z0YA;n#lhsr6GDwYxj)%&>U&!|v$RAYT81p417V!B&{H=wBTVTKw4CJj3ByPKOfuNVb7+uE=N1O(8@ zptE2KDK~eD*INkgF6%ICZP!ME8&cvzyyWT8{pQkVC~ST7NuYWmg`Uzf=P<44V!ha{ zVA`b%ViOFN?Pzg7JhjRJ&4aeTx7W~zy+KN?o|shm#XTFFV93UZ*fuw_QhHI(iggEEh8Ra&mLJ_rX_N{Z&D2Ug^r0g{!RTZdre{^>nzG z57))z&%mgOtWutk=QhJsb^J3ldMnW9EzHP5?LtIt`R|mg0#3t>JC)u0xe2o&IUhdQ z!PxX~_zr=BSonxjKyKEdVQXm-l$V!7nCK!{W|=VRuz1}VG%AeQZfpB}Iq_b_AjF5@ z|8<%CHoOWFEF^-Oqo@Vrl9MBVIRlmg+(iiaAdFKB@)OU=8lsPh#6rYMFTHo5BoiNp zxLgIK%bvwMH`?^e2Q(XUa#Z_#Aa%`SD`oX*BZQ{Z6f0h{^-wP5VRHoZIpOMo{Laiw zl8uu?&(IF<)v@5=*s^W;%k&cHQdA!tGt++re<7awF#auMA8h~K(KrMIB>|JU0+q#c zhQ)~G+r%k%;5S1>cMCdi2*K#-jT@2ES1&By!!%^bki2$B_<<8H_n#B`AM2|z`2)Iy z(I+8W7^iMyGO>L1>J>&!heXz^ir9jQTMbY)U`jMDzV~45r_A9cJ^74S7#g;Q@55MB z_$`tL!xpl#Q-f8m3IIjBka>uthP)(ahCr?R_L8n|1#Z5Cs^;OHdOsrvlHdS#m>m%k z6Em!C+}N(q7z7u@#@YE>KG(-$DwYf3e+dD(1hjrdU0q7>XKYphlF~?jPxo@o7XqIO=-Uj88Mq-&o`k>9 z8te+{&rs-q5irp%FN7o zE=MZd5>&U0Ld*3SHU0KN$}jCR=QvMdEP_i_W1i+g92n|%cG`DM3k!%00hn%PQ9mHu zF$Z6J;$Zoxto$+&^3cOrT|z{}wvC*kajHN|Tl;Hge74)Np7qB*lad#oA&W!BmmE7> zz(`m@HV@HRpOb^qlf&Zpcrr+LM_2{@xt+gFY3Ro0+yGz5;J%%Uly-1jEs4FhU_K9N zw4hA2=p1J)V|?w!PW=sLS=3EF96@2SX9Kr32(E4g_iG>{C%&X4%*OT#(x2`qjSuTK z@Z(kA-6CWO5yHb-$HJy?Nlc8t-ID+OK>=g4_KgdWg3;RCoSCjqs`|ZlPlSlbes!G4 zDO+7_FX6`*@mGXbiSb>h!w%?w_HZm}vyfU3#04Mtugj}XZMR%QGe}>)GcmoNH5d|i z&IC393okE12=K5O<~*XDd%Ut&VazsJ(lw}5auB!$CMhioXaS@Q?mY_t42q^>Dt&>_ ztdiyYCB($UX70=oDk?#|jYc6zc5SNW?rL-#%%3!m`)0HZtHT#@JWXT3hj|C%+(bk8 z5{VZ?^e-}Q`TaFULDVA>MusS5N|A!PG+AE)D5*T&iG84(L&D$^$fRGP2S@$epJ_(i z;Btl@!wG#3f!wB%k;)-E4xQB0sf2wv!rd0mIc{p$%9?*3|88pVWx8_ZQi_f-E3b#U z+~bvjU^%BvjqJS+9$z3d_Wcg)Nb;?+TFN4kVbMC>Hsv8FQ)1!{ZtmQ7zu%H~ za8NX>3E*{sfmg)#Bd`E!7)pj$J|tFTIT&R5?)F{Ch~Z_l`) ztenL23=hv+)mjZ(Nv&|K7H{vD|GI;T`#`iCw}-~>9eR-0rj0?)DVM~_BQL?=&DYob z2-nMaqf9OW=ZW(6e4|FumhM_3Lc%4I8P^@HH$5 zU}Isw9j=YT0GL03Zw0wg?v8UA@79S$ae7@=#n$QJ>G9_8p^t=^bWmWRoT~a+G<>BF zwG5)r*kD@97N==F{>6(IQYbTvi;=O+Wn>HtXaF4d&$PLpUATy2y*a1+oVw=ro*18b zWr*?MF28X6-CGuoK7GEmh0FP|C@m#SK>)q+`pLRmf z0?#8B4H6F*lX|YnTba(1_EfP*NDzZq(e}w4seWEuW% zU!{o}A6;C$-3#=N!u>@qsem^TKYnPsNiv~-Z`4F6!t1FE#(2ci>VfLb?z2q*%tz`x|zhj_<69|r4MPp;*+!-Dsjfary0DG{SRpskX+RY%fOI@C$wP^9K#PTL1pgH8RKJEz*^edBpOKynd-@Qk1s7U~-QAA9g z(pn)X@3CE6NFp71G@;JRvCe+$Dy1(msA%d=zbdYXCLiU(#VRtd{>c6Xroc3ZB2XSe zAXZ3F5Ej@|-DjKR+undrJ_y{A^IvBI1Ru+gUH(p02Qf!;^Yg(VbEQfa!I^GtgI(WE&^V~mF{#_K^qI3;Xq)4rt4Q_rnc%(_gLt0yv%xiWf z)+ei~ZlTFxTjiR%)Z<@W!%<-|2>JoO<4n8;KJ6%ga&n+3Dfj$AhYmMM^aJkQyJy}Q z5MuH~3i18o=3Wbbr^xIxA1JpXL|6a&uNSgJE34?mDj%pP(zrubW-#Wx>at^QE7Fo%2#eZs+?TXZDB;T5uC~J zn00X3Q(d;X5FZ@J^@#64qVq@Ml=obWZ0+R*&@`xP6ejBV?HQo6i=mnu7{- zJ8;%2NG|vTS$X^RZE&;twOxc{1$TEZhb@-+dGgIpLx+qTrIN@cAKy~JNAGuz`OL<) z2b%oBxrB)V>iFhmg|Xq6f>V)_fS831|*4@Y8R%sH2yx^d@I@; zm%qpOl6$;-x9!QdCSIVK%R>nvL`gB0NVhyV z5ok&L@4(e2r$i_T6B5g50@afzab8LJs8=i#rwZLu0By7TZv#3647H}J@8;aNJXJ>OkLq)h?UjrKGf&8vc z!R(kYWvaC6kJO_tUcAV5<%*g+6O$hua7@@p_&0g0Swfh0^xgft?u$YjPaPD*-@LFz z$G`K;*EbhV6D8-RPPt0gO$|S-$vPh@IExIJoSd9OIu>|`gI~cxAPSzgI}twjMd?Xd zAWlN5|B$aN(yyR6=ZQEVu|2Q7OM{fVt2@MN$j`Jn}%OBFF>f zY;4$33x>BAc-G*3R*fcO5D(1uY(o{y02`6hEKGcMGQO`MgQ;}rXOeJLf#{J5?;~+~ z2m&dxm?G`z>4C?aJi5*MD2ck~*J?b$Ay{RzK3l6_x2SSMiNV{gNu{A!O>d=ZBmSiE zHkX2A9;x!$B&|px{QhCeuyj#H6SQjwOSIjSUZy6UA@EJDP7Wdu(wkZH<@^{ZDWmMW z>)hs*wpxq~0q!(UJp84hAn9eP2+#{5{IT_QQV(0Q{g3ylY$56JKPZa(K0Y zr*DOvT*tc`EH?s{mrdZ@0)amScx@XazwODB*&cg<>~af!N~p z1LaZXzS<|>_Rj$hEMSGT3@WFpJ$N8X2SY>@&Azg@zWxHPkDRtP8MJBNp1)u%Nr3z@ zP_d<&{}p-}l%Av%nm6=w(@_7{eNW!s-|*aq3>2_2Wzlneo1^q&E4>N@?Na(Bj)|<7 zLSCK_C*o?gX`VHPzkdBN0s1e^(XlZ!b+|RG_~>K~S`p?+pTGd(BV`z@2H@pE(HtH^2}!C|h- z{LTQE90mKW*O3JDab{gee`fOd# zIjo+A0oXfus;zoA{FP2xw?uX~2B~h=+?;W+hkq{1@$TIT5u6fvhHL$xJ%#a>9ASW1 zJ6hqEmN24n*BJ{i4aULkqUYnO8@~N*?fZr%yfq`3+ ziS+5_bXY`$TWDBDD+DUiO?1Dy%G}>CK@J~T>osWH>^j-^j(Q7BKki%eKJV_;;sSvV zk0no8_i)+yV3Dw0im)Z3CGZ%6L!fV9f-XoQoU{Q_@)+P}vS05){XSGYi_sp6;iS!g zhmliPrVju6XxHU-ye~MPDNiu@7*voN9M8LK1T6SZid94UZF+)x4s*gyC3ps zE8OM6%ERcU4~O238vZ3VTOx`7(T}+&Ls~m}%Ug<=K{P)5v;C9bnll3R?)3+q8E{LK z5nFq1<<04t^H0qX^XQPnc4K=bcXZT56Pi9XdzkQAP=Rm<%UzL2;|47!Y=VN!038?% zKIlI=`r|6z9}jprkam%Q7(}wjptkGB4<)%KOp9|a2CM|GuC5?8rvaHgs!OwmKsCr` z;_RjmnE0|@Nyr0nDzA{Mw%qGC`au~BHms2F@LS$em*1OLo6Z0GuSL&naw@$Eq!bJL zZ{OZQ?TzvptKGf552#75IW33Xt3Au%lC7<&vFeC@1k7v90;sTTd*4op_g{EP@gtOA z;VK|VpFQ3~l*AX;8LjBlR)Yczctp@!1*!riDIOj?1T1lq{zt^`E$Z8&o;OfH99NNk zA*o;hbdjG|p!?4+t9_l823FfdqSY>ifB>2G9muR-2085J<~;xP&bxrVmP9MqoF&=TWJ} z{ri$;U!03|Wf7Qx9ezeMkXoQqO@;FqCFpBXH4p*IaHu|gB|{iFcqj6o3H1*T+xx}V zdV~x02nmCKDZXFJb7 z+nQG%_xb~5n8UvW9NhQ<%;BK|h_GmHupmOIXlxbVa3OSQxMT$%QcD@q9I=x#NQxFO ztG)Qi^F~j7s4miD%{f8hA^SGysSMgUUmhpul(Ug2NRdcPIT%HP4 z6N=HG3TKm%$(AZG6aZs58(Vf|m7@g}rj0=bD!(rCWehZW%B4W3Ho_Mbrv>+YC46s? zt2RO2ireXtp}5nsa-~m!UPe%F;d#%ePp@CtPEJlboI~uObZ~UEN5XlD@z>+Ie?g{a zj5?-#Hrhh8q9omvO3Z#snR_p}73gNkZu<}XCD!A;aQ7T<>er9z;EN)LNx7n*#M5wq zIwwe0wz$PrJo-x387VqRyDV|3rNQ4JIizUPeDy^prXjJKno5*r=SdIV{zN+c%2CmV)3(qO1VzNi+1qSFeWMo_2S4|K8a-Q1xa9Ld9r?VhHKP9aw+-_}*Fy z{Ru>5guragZv(YATTm^92Lh6o3-n+-2T(XE0(9Cr(9=l<)b&03+ZK(`Rw1&NwX}X~ z2;I7Q(*~k_qHN&`9UOfDi9Nz=14@8jzaEzBRh!=DrPtEZf)1A1g?#69t?f(CD~vb_Vo z?!*_ziRg?Bgze$%g~(E()*hi*78z`aXVnldu$p-4W|)`-zoz_j>M`-}1y#Byzlq-u zJSg4&eeFAoB)O|vh1RE@Uef`jI z%RmSY#`p}(M^NNC^gf36jw2Kw()Sb^15SS$1)!M-Qc_YhIjq3^f*k_To=7C>I&@{< z;F?A5fCfCM?C!Y^r-NmK-$h^;?=$C^HZV{tLkbitB*QQR&Y|*nTR%c6?~o);mHPqc zh?skNgaE<9(Rw2(uW=J=@eebHDrtCQW8--14|rr2eh(c1kwtZO`p*(1U(J(~OoC?O z9S~w>4Ufqang@s-71c9Q8f9JRdV&C8gtNTS4Nx1%!vG#57oKpS@Tnn}L*rJj5|?gN z*HYi`$5)i1VCw*%A^nAm>rh&1!Kl&Xz+~&Bxbd)W+GJ95z%FB}ZEUZZIS#(oRgeMmXosIBa<>dyYU5}bozo%RXqo%2ZmhT5z7&<9$5y97Epeq20V3F|$p*%eT^p1&4x~h%-e=xySeqP|g0q+Qn zfczh8@E(+Zc^?%Tiib})uDv$mJn@AYKrM$E3n3fu|K4d^>N2MO;33I`B6tA$1vNBW zp02x%rk|j#x7dSD;p{9{`Wl!Y{_Z+Q!Kpku-{v;&E6mM}3m!(*)1$CsRhl~ssyg>Xfi)p)%IN;eSxMMVZouENsJv%cufAjDW@VL(*;0>X?`d|e& zDw8)#;qhb2`S}fi#x9}39ia6Y7!+frB(QdN#%4@+bQrJX_4A(?r1VPaxbakgZfk2x z60m7NokKuvu?QLoKsexNLirW5N{3j>fWxq-+Ax(Xr9XJdomOTH7PU)fXJ=LJ9E2bO zu3XW4^l13S0phFZ$x?@Lb=}naH&$Y(k#j`CI-?&O7xy8)^99-}mKlma>NLMP*40JT z-ycS^VJAvY34_qPn|XYqn8h0q`mi7BW_0+VKzdp?w>|J1TbQDzOlI3U*$7B4AZk_V z$Iqk&?$-NK`b-8Y+=ATt6fWeeeVH2h$@~io`D#d;j}KLQt{xScjymbV$v4&b+7pTI zK*11zd^fO{=YnQQ@{52=X%fOy9;sg?c4l^9Y?%H1TQ7z8;SUI9hiL&gyp`7*U;)$yah zzt*EkNmZ(A-S*EZEDR^j@T2g1^^VkM_xsf+zs7%+8dTng=>^MVE*`rejN?w{G&ZVV ze@LW)T8B2|LB7Kk3bI)i7Rx-h?-S|f->uZM;9*vGjzRRPFT&Yn!@(yk{B>0VI}7^X znDk}n_Xy_VA*Kzm=7%l{zpWnMJN)w@L-I+)`?p%ReM%p@{8C~`+lK5EGi*&TXVcx2 zNWZ?vn%GUaY@Fm4@t?H*P%%Ti@cZ&45@~7a(TRyc^G2xiSVdmudg0tl)utvUQQ^1i zzHuVkx{zf~4d)Dp4g9AWe3?Jr=AK9!28M@+KmT+hL$Nk}XU#kECa6JQ*xth24}bZO z4xhl<#pJ??zkF-Ok3Mprmplm_FS+GiTh)|fI(x%e5Ae4QT01bTYeS!OBrgOat0IqS zh58(_lk)u_!TkYsysvl4`cYqhzx*{u#7RmcK&zF7Q_3n#Q9!`1sY}D$FLx6~$N;}q z1Okn^B3P*m-bjezJ!2a`KHTF{QVcw)uC}PJY2EQQ87-zHfJisu!W-)l>!85KfV~)7 z0q?n-i3wc-27He63X%pe9k%sCF9al?D)bRsb>PWRsH))Mj7NHFN|NKC=>}<_oRsBt zr{#;5Gy9UBzbo4}Uxwws!@uAc!y-!xw_)&r$@lp8XqCG(M8BZ;A1c?XuJ#eRemyuu zk>oS`t%3l;Q0x-4+NNA6{=NS`CN3^+{P%4HVo3xG#vy`4@U4u$f>TE*xR@ZDCkDbC zIS_t+BJ{W!rv0Y_y0X#n@p4>|nFxR^Y<>+`M)sZqZw<&3ALU;nh|FpN4%xx~;P_wZ zUmxtI>3ZJ^rx6#kH@z#iV;{LJ?MP>+fS~0G#baQ5DgS4eUWf8fI`KynCWq2Mm!F_6 zU831%N5x&haZtrJ+$v==6<9B19&lHsAI;5|t*v=<^9c{vAoiZpyjmg3THHEA{j&q~ zr{(2TZchZBL~Q_%4gXiY5*!~H^B!Ou@d`C*85l?uiRM>x-Wcg6a~ghEP!^OL29?ix zrKgcPCmarUtG(aXl+pnLr5f3^MAq~6K6!1r*jRSM$d7}IBsC0Ng(KR1Vbv+TMDvZ> z2e+WTWS+{c7*`2tJb}DypTP~_w=nYXfTy(zgiy#z%);4`=;2e zprFY2dQ^0+K3hw+=JS#Zh9xE7)Fymsv)f|r)39F z$8ygdo~m zA1oyE%#>5Q!eZDOe^kMM7QRtm64_P!k~ zA&IP)!lJoaR^;Omn1ioNwP|yqiIpi)@}`2lV~NRVmW&@}^S5DyIJLv4q>ST#uarlx zITh2scZ#ZhO@BucZu_z5zpj>Tv4!79w)o-DJW%DZ*+KDkU>t)1f#Zz>k@prd&*lCj zY4RTRzs*63->*v~P%sQ`xOOIYDYFKk%|O$jlPlmlLoV(7xiy#n0!Kby1(-ujVbb4=~lQ9hT>?*p{H=nM_IYFCycMYOVzda|h6Ss|o zd=v0&Mj)?VydasAvb+Qgf%x@a>tb)P{-Rd(3wSSk{NOVRxkff-v_D*5{53`mr*TQ& zjZ0xLXbpd`>^i&i5dXZDV3maVy<|`N)qC}BY67_b`Z@m2&o|wErvB+|Z^kV=t0Hwn zc-J&qI61tAH5a?nOutApZFk@B+F%mAL|26bbHj<9vzci0+#4*6z}RF)`$Lh6ddWZjwv;H`;NIu}ByslMgOuhJ}SKsV*Xr9aIV7n1L@y zcE%&QizY_=cAm=^`DB?1Uchv(W*1e}oW5=Gctv$;cua|Zflyzo;6|}gf+1e!hv`rf z%#$ap{$hqAf+V6;wt?TUlD4m1dF~g9o#*L@!^bV9_nZl*`13VEmEs5q!rA-%9wztg zcUOL81~FV?igInmDC@c*ZsmnjT}9K!hSUH`hUMBy1Lk5_tMzMTgciC*CFfrQZLnO1 zC?Wpn?9?}`xdIN40 zgkM^NE$&s(aC%yrN<^fINrP(M@Ni|lN6*C9_>^YkNrY}_TEUswY#rB~ntUUpz%;%s z6d2<$92;54%g1KdP52Yl*33D(oL8urqavwPTR^`-Vj3V7^WsHc!6TI zlOod6N1M+loez+;%U?T3lyB|eUwHC>T~Kg;p!CYo?yr#Z(@kwNKLDO#WeU|d z`X9iH!ajt=T8$Kl;+RC+K#D(ug%V&t0*K(n&WbQ<{xdQ%S{?sP1kHH1i6$+8!2U<# z&?t0(kc8Uq$MNp3vi)&iofg$KS8CpOhA?!G8pp+!h13s17SGKeQbC5;DZ{c0x0hn zsx66N8AIDl0ce;3Q^*CUsE85riS0*@LIe3t4Z_lhuHC%^2}jN{`s7r+(+%n7dS6-t z3d5-#`<}pe5ql2SU*Go;5GO^)VjNt@iRZF{;y=zz$co3Y@KAP8D>qNopTyWxQvFIs z%+1Z|CTePG$XnO|X@IQ&kfDukkq{Ei`CkWXRJ(flATi2|8H za@W5%Wo0A)u5s9?s3beACuPh76mk1_+SA?Ler;xKER2Q}2-Xv7k##nBpB)W`6qy0Q zj4@RCxo(?E!@WQ?wAqghzr)tNk{+~`1#OTSk@)gIe{v@-cjCb}LKadVidz3owJa%^ zZmz)`~@)Z;G^cJ)ai5`Z*eU2?z6g{noR)B#$l6oS*}yqoYeY^0R$D zvpG`o^xK>cM8gQ8c`gv85h}oS)dX~WXbLp!`*{0Qdv$pkY)<3=d}kCbU;O@W3grAD z5aijf`MF_o<>HC{A1B;NYNXDEV+YJ#5K|T}&b*&;E{Ea>Jjh zueNMP`fNJBQ_mv6-mcXBkDlij??Itf|GXyl0Je9<;@b}gcoHUd*2rN>g!JTX3hxH; z$xys_5xQje>4q?)RcQMD8SxJc*SJx@pWpx6b((Y2VdRPaGZ&h!xZ5m^7LvZpQk;~L z>?w)cmr~E`u{To$8|HpMVral6lf4$&S0zMkZBzYfk_KgI*RDjdhu+jAvJ9--Ch&Jo zr~FZAW1W^MrG>~2Bg#_oMJ<&@Ut!-71aWBY{AWlG2iNT5>eke)xTEt9c1z6?xYU=W zSY4~)maycbSFe6W9D>KV?=YPA zMF!XXk%+K;oz2F%zfW_Md*#|aD|RvQ`_BAg;+&z11}eBky_sybFAQ zObz1YD3Wz35`9XF1xP^wT#Z|6Yirc=JC?{P2_!jM1?Ql8u*D^sC_V@fp_>ROy8s}8 zp(HPFUyYltyFl?KzWcW*nnD~o=&I)zUYD|7f^Nj=oyF-b!S|la0tc2QJ`hOvb9*0fLS}le;x>=vnHi2Md(uJ6BxCKGORi9n4+(c0|nT^%dD)LvDaR zG8U4N=9+@CM2%I-ZQNkq5E>Vc<#ji>w};0~K}pnwynrGj?mQY10rHYdg9UdCJ#f1a zih^sS+aezahlcX^?&-F$!E{_+QNc#J2W?+CS{i&$(a1Ldej+{iA(72Fc^tlvHGr<6 zHy*nE)lK7#`PhVvOiVC#rJSnQH2|9qf+VGNNf^|DX|SEtI7*9{N)KeAERkGp?|(5t zNPhSwTvMAYY2YTM@DXEi{+SIjrzcT0 zxUb<=0$~RwIG;lR5N5k}jRe*YI`lQsV+S35HbQ_KFt2|`4~HcxFUu|y49v)2PN3b} z<-LcogbwXsU7|WN#6+MS@JKL+K8NuJsvEo)Ej>M7M*aAra%Q1b3kdMP7SIc$7RsI$ zXlFi8N}@(NEg)PD`~=}|=REWDeU&aY*YWl3X5>w3PQ4G&lay0J zD3|0Cd+|gOyL)o=+86&#`~Ou`jNH5+C#1!vPTcyUS~;BzWKF-~=@}L0O~@vF|Iu8X zUZ03*VdJ~SShKUN=(R-|;b*3>w#-fIJlRy<(1fbDqXqK83e_5ka zI!TY`0&&Jbvy6`Qw;BC*=*z-_Q3Huj&|SUk5BV6NR?7OmSOL^sb9?(GQqr)<0nOyt zPQAn__((XBl%71PWPb`0_15-w%5w<_!Ds=YScC`U=b+m{^y0epu0qN@85Y)Q19T}eP?m!l&l@hlU^;&dcDjI8hPN|=HjF#DA7VCO-N>dm9{Z99w4hF7qMXfn2o*=u&>-KOgDzpVNE66B#~6HJCFu)US#VdhKjm4-oUi$Pfzl3a=B ztoW1FuX=6Nv9IWVE}?-~xiDm_7g{AX&K#~M++18VV`M}c%GqKF+d;M^W$G0|7LJyo zFH3oOfsBlJ7UoQn+KHI_6~Tw=X5TZUOIMq>!-||-Vi)r`Bl3Tj77u;?)HeE1AA;_C z55`)2&X~9x&!WsQuOK|Oa={Jt8^I0(W^mQ><#2T&VOTiWA_#J(zU1$lW%E;i8ql8e5M zDZ#D4l&X~ApfzTSR$|{>8)JlIT-6s0q~4G!YxKcF z@tBbXvN$RTTp&tb8B!7;7talCchNLjq&7vUj}-sPB+g?`Px--u%aG?(!~VstizVv{ zA%_aF@w;_&*Gxr)JNlG@4C^_#!d-=#9EW4bNwnZ^6++wf~Exh$60>VDWM1v)+y;XJ17w%sB59COsbd*)lh~ z^V={m?c`?9pOIM^T}qtoL-Nm$O1CBfUS7q&^&~L%quXjGk|2A({D?}4-65k9BY5ZW z5-~{vkPM$cINfW|MAkag9DfGp8412v?%uZT6K*0)Wj|*dK6xv1N9D=SJL2vSJQCpJ z_%0a%;cHapH@mv3R+R~ubrzWPK=;oC#=+*L$6$>GJ5Dc{0mA_s`Ixf~RUO^Q(*`o| zkjzZk<=)J2KuBhR`UMD=Sy+C@__Ix4yQhc*x-pjqI$1^n$hBwNay$pEz{3M9mQa|oWK`@AF@Qwb3XQ1BJ?2K1FLCIeWL{Jq-)ES#Zk_!w+#T&w0_G#pYUF%fxdwX-biz!(79VC$D8 z4T=C0fxP#N_?$ZIAMo?PWi?qo*0BDT~dKF_X)rKdB(hz)ie zcSx|9zi9;SpRn6+z)uA=_TXSEcmA}sZK`=~%p>BT>iVl z1EB);iYVsHd_Miz9&>HeamijECaUiD1E8^pI(ir{V zg{GD*EaANL=g%Ji?&O55gtVB(z!9w=nUs(aw9uhV-dhOLKVUU2@1v{}mM}N&$wCKB zN6v5=tGj!_*V@N4&p1?qcQ!J{8(J@K$U%?vnpkCM-JM;SEE6+{`AaEx6A5(2Bx4LN z_L0lIc+ksM=XWvFcQ06zHOrHWNechcpOFP!M+QBN5RscGZ`!A7Fi> zv6yn$5B+ZBONLRLUlZJ_0{lC|&!0#+mPudxcjNAtLt0ixrP1jU=+ra#T$GUuGU&-VL|z7 z|H?(|XM0QAHV|GQ>ANogVycn|X;=sgJ|iPvE!bxXy}+u?@$ldvtQ3|&+;_PBWeZW! zUccrofPlim6@7(R9cWhYSH%Exr^rek*tLXiw{5*q;;S};tqTUCR3bG_3}z!&HA`=h zRbQW%%XMGC;T|#G*CskW?vS3ki+!)Z23FMnY{#wkST16={KIEjY&`n@9`AHpT$~!G zK!Yv?e%P*)jt25);@lrbdOswhuAa@a9B-xT471YB`1k$H6^~@{heIU?b)w`sqz#S} ze-#x75`xNYRn@?{G+2cqGA;5r*`Mso*zr{Uv|OxCc+aqK?@mHh4eNs)ZlCv#+oYo+SVobE{{aaGlE@m>IxT1kA!9BWstXk<8_2a^iH2FI zH>5;GebvsK1}=;mQVU8WAk=^m-XYyv1uymeK_8dTWN#S&&;YBTV8-%raZGY@a!d6m zPLwXAph&37Hc&W*MNL&eC&A}~bw^y?`I?Bxfu31)pY96Kqoa+DB3noa9Fts>q_8{W zBJfG74i4FW#NKGE{=b-@7Y0`lczJ(bLAea*(0_Upr+ny2yH-lUGJ^%HBH8C*TmYd$ z2cX{8_V$EC(R^?eaR9BNO7$HIN0uQ!3y2=PicV7ivYY!^ECR3nPPAGDi;xB9y&R<4 zT!@qjUjaS=N_!DVvdy+?fLVqo5W2tj5f7c57REpGJKhV&#>R$Cg|eaHw6UJcKTH)+ zfd7{Bgo=^t;J`o&IQnM)OuUT%*FMGZ{>6v8Z<(2xVCw}gx}<|znT?A}xk4T^ZH@{t z*cdPcb0h}2z%Cn0WF|ILhZpRcG^GB@P}*l#P2!4{_u!+^1!=J9M86fXLn0TWKpR5d zk_{dlFvF&gNEFEy=e>xi%8syt&J#1F^o>zYNitA*sw7U3@eH!f4Nr7P|^7`8f|em}{XY ziU5H`Q-8lM1p+E>$f9mW*Ga+>@wKr}wMs2)5)u+%uA%&g4JkzQ5bW6s>rklKkGM2a zC|BGinb441cV|!&*S&?Tc?e{;0xTM{*!v!oW_(X&PLK9-4NH&=Sjs#wFfa??1@v2x zglSaAuMpqdqNlqmV6N%Rx^n03kI~qd2^M({YWjpFo~t+x_cZUGC0MZvv)mv~cDK8E z^E=}W%A1XX;aq_(ET0lm6Cx*lJi4S*Q&<=wTo+bkP;|tGk#A*X;Xp0G75r~ttQzah zo8L$`FJDA7QX2v}OKj}yiQGm*gM%S3oX60M;li*}n2`<^W%#vOIXRO=YOxl38z3_n zjXDLz*f+_2*xnZDsgJ4hc@b2Q<3v7+~bS4ye?T}`ZZuE zC}^`g{=P{AsQK*lHrqEy!R;?V?zm@O;0%`bjj**IdG9#Uc>#M-=+%e)y+ro4;m<&y z%ayybz$g~7&(&Pd9g&r(~=0}sw|l)tpTHO z&{aSR8no&-)XK2Q#rD6%d_a*-C0=uYFs*B(J<$45y%cfAtCIg18BzpIE3QD`UvTig zbxY`Nd?F(LFaFR^Zh>N!)k-8t#|{?cLc4O!x+(|NDO6bF)MM+y2;jE8=ni-nB?1~3 zP78hGDUG|e^R0$y>=0m=^Yl@+;a`8=gh1p(ayxlAH!0zv6UT9@`zqcp-Uobl8dlmN zWW4@v8GaRFj*qzNjyLSpq^(v(8ywJD+4h2ls8rHosK z-zCk?PjGrZtwh;c$8FeYMcG>)BD~pG7RfFXGw1tLob~6%v}CUltu)m_a)#4Lvrpm$ zt!E5HKg9!cSO@!qevUIFKHFn;8KTU2x=z=fk~D{hN7Zqt7@pMYYh!&FnP60mo&7=h zz;Yw9xK7F;EF)Maza+nP`Z7#WOCwJV#H)lY1J&=~Bqw0~J&gbLN6~DG6q!HL0$3vz z;ihK8&iVK;D?cffXK*O2Jkv6)PBq0f8VO_pE|GPPZDXc3?r6q=B?JoIN+1m>F9gB+ zgLf61Lp4?b#?i>0B{xYBmfeD=5ao#oyA}XeVEdiy>hA%n8;x8bgoJ~dq?xaC2^*RK z_rJ42dV{*yP4*_C8I0-7KLu7?a+M6Z6f z+11p*-4y$3__|h;)k;~>k^<-Xi~B^;oueN;7bIShz+2PX0-TXMtRjVV*7qogAR5Wr zTM||pSR;`0@#Du28~y`oJY`)_5`txBRZU8NV+MiTbrv1pY1kd8t6J*%uSmpe&p7ijZ89elIY*qbFE;?R-}X+j3Y3bdc_E+oARe&z#<m*07UIyh0ZXtdJ2gF-rIEbHXZw*lr|UqCb%nc1dk%@)8hZ zP=p%RaQE@aH$tcq+-`Z;!~S&RM1$=zUhHcXum$epl(VqF7anD-MXX@V_dAq z1)60*Q$OQ}%@*Sev|hf<^(U9OD1a0sufaQOH}ce-^aZ$9c2-;$V#cdI9-z$xh?(_T z5s)upw0rI8M6zUob|8vWiURZ|^!a7gWWGx<&v{cwC&Ggd4ojQ%7=@QtVc8S3NwzSQ zf#aEw&4BN)`~+WQJtNg{_I0N4ed=}t zSlq2r$%HX?S5f`cg0l+Omg{umaZ+}TLnH1dyv$WJC zfSuVtAKWuz1nGpCp#o$20HfOy8MEFMChR8JXPRX&$=|yMn+chN*G#dFBs0KbQXafs1Lr4hbNc z66Wrxj=mIZbPV!=%^qgJEO20y`Z?h>Tj$R|^KxN}waoEi#UEkv{Z-PdA-9#Wes;t~ zxnlx%=9Q}%#Nb+KE|M|eH@DxJ^)v053lC-Y>R0$46BF^25)V&}i7DQa)X5Mq3%AV< z{@AuV(w*i@5n*AH>hTz^uj~*Em+XTTK65(zJ(;mPJBy=~Q;DdK31Rjey1hlDOnS-|(SRZsCJ~=&w zJzY3a^3u>c#lGj;h%_uUqv*g63LGxbVty7D4~-z?SHJXjZEK!$`@52P4#o+=tr5#x zXo?813QWQ#DA+Y!*9;2q}T}<&LE}G6c7jB=K993 zKl&|?AC9Sx=7A>anYVY<0&;%*GNP?@lAF&`Sw#im3jhT8IjkUq(-9$ruThW@cn?a&19H@>Nx^Co-s z6$S#hxVyf@1>HiZO<`M!b=DTdbm>2?Ju6$WQq;BwIo4MI-1&(f<@V0;2i5as8U-OU*mkh~YP0Sx;B#!A{-+~MC4dAUHOVx14QXJ$K+)VF-sFgB)zjz1zI;^310{Xe9N4QBdwZYv@(|zCK)-VV4vvtmYj@PN?BD;;A3sx%g1N>!;I>qXdOkWGL)v%K z(qoOO!Um*421I1&pvM=3e-XP|a~4fl0z)YvWf`wt#>T!^@rSL(^`qBeftHHH>eUy^ zxdb^Wf^6=dSQ6JsF-_5tSQ0XqDf1mC#U+{Wk9VJ!YrA6NXK3}Q?=wv6>u1UW{|lt< zb~p+DY5GBP!hiSCZGcQnOzNIzFwP?mS8W%fs&OCmyot6zxFJzh-|eTtUu=wvJ$Kc=1-8+zWPq?Gd^*)`Z?(0C6{}FoCxsP!n}^TYkndJ% zWsMnX8KCV_F8B~vSM5PEvaAQam!n2u3=_Zf6wRI(8d7$DXZ|8r5`-=w*R@ps z(mygH{DlMqjafSSO%YWa?#CD zppX3K^5_7n)ex5e#@?j%i`%!qaAu$@W~m8__3CRfRBS&#n!tsDw%=PzAio1j|J)pH zg{vj`7nf5OL3bs3u&07k5wbOjmL1h=5i{ko!-Mh7vdxh${MAapR_85^UMy#fm;En6=1m52-_MW7t#3U=r z!XtWqvJ*F7WvAh){1_JHJfexTLKRw$jwK3N3{)P`*6-%01{=LNhf1kB3_9=1Ap-Ap zj3sY(r^Oq+Cyv$2zdpUhzx|Fie^Y+?^Pj-PL2*n(z0}Ztm(O05WXia)@nB*;yA@@e z{R4#WW!Gi2vkrsB72S`9Iy=?x{84~ixk{2hEoz@Ke@JSeA|$>JUXM8_r!d-><7+~R*Cz$o$&^I;A@nL%2Ih~rN+<>Wht5Ew)ZH4&AZ03?Q4 zj4XiVohZF4C7nJGXL0MP;7j>%&=U2$l@0;z9>*1j#+-pdP(PrX2VukN9Bcuq{AXvc zB5}dWnwPBT4bxx6?@Jamf^1OakG&rx*_C#JMP;9HjJ>OWn zq4XwKkKULbAOXTTatwZOF!;)eFsyKvz;7t@@earv>AEBN5?5Cp(i|eb%~^qQ3^GwL z;5YdTYvkYyv0qumJX&{O{xK%r-vH!xRTU&#>*;a)LP`v081CJxQNJSb^$B)X z5_L^~?+6Uh<5h_F7B)a7;pq~tDZb}W>0lWY4!V38vIQcYZqq>A4X-}8?a}VT{L9*` z$?$FMjo=fPk3svPgH`72r@9S6bOO4+^1|3l!1$TMik2Bj76h5pW>+UYL!ARTSrrOm!Wb_VRgleupW&jm0T+?P6@p!rjr>17!! z{DwgcakNPBP!);mq&Efw!ncei{`j4al*C2?=wKHAkp)^Ele$q+V!pR|`{b$`s%g9eA`YRaF1jFpMDlg1mg|<=Y!?Zp8cNtA`8B zJb&)em}Rt{=6!M{A%f)wQ;ZMMa?uSW8UXx!=kgXd5?+mKba#{$XZYJ9pLd3Aj@^hZ zyRf_PZDC8~{&y=_FHZ-x6)?|l@Tfpa8)2jpHsDdvrM67eUfgYXOsVh?Owes#bUhy% z(hwB0Uy8o^X#9zSICKg9h1#ws+g-v@ob4cxVmHaJs~6R5Y^3^Rq%|e|wFrDK z!A+^wWazSh05$pHI8=-8*Tn*>&Y2cN~9axkfCU1M zU~=LOC|~M-j||7Mugl7^fUtD-v5$yIpYZr*%44-w@D zEGS){&6%A|O`?Lm7I1Z|745Asx9g*BKh{;ra8TQ-;hLJ1@6@TU!MHD+fAX5?*EMiu zX=)U^`*P|@Vjlq(FP1_A=|D6#c8LtQznw&{x&YcFzSYa{gXqF6%<#2-enDkE6G z5_JZ>KGp7=QG6WxZ>>NsF=NV4cEyd<<6qAYs_$!+$CHKC&Ze~wghMMU^|ZNJ_vpdK z2`Y6%S z)nlOO!J6_Eu(y`m3=}doo_?7ybzj|VJa2}jYVLs7k2+s})otI}Zlns6MPK7Nj>}hA zlxFqhchWIZ%K)zRRWaw$kdG^_@cZ!{(Y;V<>zyn~eyqTXfovdAw3P;1(E=o!i!i*1 zh;t2uvstllaPAw~(nv4&%gEmQ|NVjs(+0O-{ptKCfbPKCf(2-A+nXD%Ri?L&>pL`r z>tg_nMmMW|?1gZdIG3r7b}--&Au+b#c10EL@V}#PZB!}lj}4bih*-BMe$3-CZak}D zhDJ!(jRk{}CjiY0%`7Uw!l;@Ua68UscuhaOI~Dh{7($X^tx&-`(NB$Td3Bh{jzGtJlhypLyLz_)WEX1+h>aDk+a)F+Mrox(fJ8 z^OTpD@b)t>OL!c|P7Q8Z2)qN8!Lfi>b>Kf&3@bfxx#z}(yQM~MN&I+QlQ-xt2^kpp z3~x-6oOULcPkUwBFq-;5Q+)OqH#GfU{t-(G$3i~s++PuE9*QN!T>0$}6Y6%x`>;4- zU0ttuExPC(@;FI2?$1gO^<)K|NqfxGyLRy>6HW3se}8v<=o$|g`@#&*vFcK1ave1d z#i1BCDTVH9N?O|RDm^;ZB`4C$Ki<#IJO2LJv9&v>{onZ0svjF`TGRCNL2L958jky2 z;Ff~is#zuQ#IBY)=+5HyhuowW_iAY8 z!-#t9=F&v1g^G@)1G&AfSH%1pv}P6^t$uU3nkyo3VsxD&<5zZg_EZ%?Y>8pk#4F6B zw_`qtpkjE-oP8DnrpZ2J`Je3Kok#!2-CKB7wSM2jbQv@PQXV>#mIjeLfCxxP2#Az) zNy8>oNaJWQ2+awxMiY~gHZMF04GD>Kq#zr6dTodFU5}*% z8#<)@chGCE@A}k(XLS#19Gr*mOVpQMu>~E{ss)42VN?FgI=5~)QRwQ^RMmT4D{)_p zCy7J+VY&_vpvZ`YzU2DT2AFX9xcaUppMiR-kAFuVm7SdpW_zVwXYX*A!3Z+@5K!st z$eNpuMyxk$BsDXo6F2L8q8W-9W~BWO1q?xz88B~yo8~@kPl;cPaEqmwgC)54-l&23 z&9rN13jKE`?8eBFmerSM*Gxb087Coa>gOh@URk#-#u>sX8A=(r9#`Yod<`$uzaudy zC)a#d91~gl4!!ZL)YN!XQg4SEY}Pic)}JXI^at?Cc<1 z!ocKp`}yvnA2p6qyS~%bl~6->QK1#U2s*&1!n(O-lnP`e1fAONgx<$xZB#+2PkGn57 zw&hpul`6u^-9MjPy;*mMAsz=eZgh@m$(@6&+9><{_^PF5ny;}ODQMi2FO;u8DAO)8 z(o2`kNPF|gMg0BzNxrxLPbz+8WyQ1JlUzLA`O^crmO|}E>4^0g@8X%n@bG^vddYaf z3_fPc`AB~L!*!ztbrOSj5lj+O0S7WBI|95Vs~4J#$!TcbdW+XXtbh)U>UU3&j{$K~ z#qTs!R8IjZoBn=Qv}9DH`?a(dn6p?IRq6~!IHwihu*m46k0xN0tn#|y45H|s^^96#hAXpfKUSKcm&%Gl z<+N?T%FF!i)}PDaLm2T&xjH+<_VO8V`(jq#&$UZ)aieITyUTf_ZQAOASxE7C?X-do zkNr+s^m!{`*ehS1tWFDzCV-_Q;6=|h4L)`ddySG88+geXDrjgt8FvE}Xb5~elYub# zMePXv8?v2OFMCd;7lR3zZsn<40d-A;??LJVd1nw}IZ*12_&+WIfG_F0!myxDHaC-< z8JHG6-Hk&;OkbaC@J<)JT=X(Xepswd9^F8=FH1XO{ok2cA`kBVGIJ&9if{ zSIohRF1%Yba9I^KFf{b=+uLXaQ&DF6{CVu~W2-Xf_H-Pp3V2Y_FYaJu zjQ_mww_e7GLQK?L_g=3y*!q*^JTqoiOKlfYaWH*;%Ts!3&lhw>si32-2-qd$xy%?; zcA;-kf1%2#%zLx(;KHw)MWLDn3I`j#ksLsP?gWy%-u$}b#Que|{(ILEJSAKC{@_IJs%bi_GOWC8R6X!NP=C}Sl>J5I|)_E@N#J?$pZWp_7fg~AFv~Z^c79wx+ zAUBHaOLCknA6{r$D=8xEQ4tC^FyJrXOOogF8N`>c0GHlA*+ja}K(M=RoSr;x)8!z@ zeX79+s3>Nt@lSz-0)~3mAOrGmnkWJqo#X=D(i70w@mTvo#~dJX1rx`CtNY%flSCu^ zkO=fyeGLyAcD}}QqbV@M5YH|&cVfP7*`tH;f4$P<6VLiNmYJ~8-U2U?#0xD0+oc_z zFs9LBlU9Gl0a8Zyv(ul|_J?3l2ufV0N^)L3=%!vFk3zm+m0DNUr>ng@w>z(|ZfvMN zs3N(}*5l!j<%Y41PXOC&y1&OJ$KgQ;>VM;i4r^bO@) z?V-2dmZ_*mD|XoWXfOJ9zBxzun+|cX;7+L-`en&BlBW(dS_I~~Em-!=?%6Z>CNcbu zl-};szA>zD_nd}t{g(H~ij~1gYNfPKfq%ZVou4H*k2m-TE5|ZLLBlCqzwBq>gZj%n z`lT;G`xc|;8n@SYeipbiUKy5*VizTW%mQwBdLJn0gumb`SUNyvqxi+M7Sj5yt9tJB zOFQSRf>}`MkI$5MH4++0$&|>2t);V<4t57VZ9cDcoOpZa|Fi1xV4d{DMPg!ZUG{Pa z3!KJIus`T;zDTYHbw!XZepgWgLFak9b`^>9yiEIZnL=BT7Xg({{kR%dG=(V=@OEP0 z`DMKgh3GoOWBEk_=Xu=eUUP?=6xdobj2&uT4AGmk!sy6VLr9npOb2rwytx@|z_ z%W#AOAx3J`fh0q7u~Ze}0Y{q5aE8}(z}3mR$>{U5zKjn`0CKnQ4m7`6x~h9o*lrR* zw8&3Wn0g&Hg)0AMR(HHPRr0Ew!-spm#f=uI`O4dhW1D`s`TB+IK4{a#8*R zQ!zt4Dj!{Ty5Rx1FE8GXNhlmGaroiaE>%7)g_HR`(-iGsi%Wnk*_ z=~XVO+8(;lvyGOvwzTmk@3!GSQH}lZ8E}8MXbk=FHSrz0pH)RUWk7$ZSnS7g`MadO zI1O^J%$<@O#lB*^GjxyL+=P%DCNHzfYn{)jKYS3S4%pQfY;;>7!N|6NTZodGP8Rs+ zzJa7TQccf(?CdH21zC{WpSw_snf0u#+ z>&Fh;bX|O}QVaRLOF8P*!jt9AZ4@P?_ti05t5c<9boPvJqy1MbP4~~ScSppbVHXuu zRnLg$6cv?PBNePnY;6nINGZd@dpYFE1iUuF1k(B=2i)SHZ#30QacKH~Y;n|kXF>-r zWoci@1WksAU;|md9dzt2j5Y)E0YDu(&iXiiHc{-u;xuv>eAzeC`#{^pcGg-%7bHbC z8g?}ocR(~8qJXSn794tY8HvNmRq9~DmP|2P*TMYWz4 zu#dmyS$_q^6C^WGox7CL75v<}DcEO_%y9T+uD?%SsbS)#Xpu(d5BxBa^TrZf{?rN(|j`Gl+P2 z>T6{T+zu*7;Jz(=E2u4Xgv8&f(sitm+|O9JcVcc9B1lg{P}vM#;%wg02~3_fX6{Vq zlrBve7`FEQ#$xgpAX16(M$R5oP6Xp$c=1|X@WdxKSyK)x2rxP@LrJWV6En#)c=ZbA zXaAjSK3>d%c2UxytopacgZ(xzgu?W_I^C@rO=F>VHyfwIVbK+1CTRa%W631bnH*c| z%i9ZFyvurpZ~T*%T)23%Kh3qHEl%HUKQ`=-a1(X;=>z@i1CnN3qVF<6( z=)=!;dPT+B=hR^)d?ts{wF)e#c<9*NJpLOuU}D+58w6tbJCU;RSSqY?hYFYOl_WLP zDJF2-0aGJu=q?jp72}uzrVBthFv5Eod48EOlAQJVqsh9x$o1F#97?|g1xMdIO&UQi z+XnpI575psY49eeiK14{C12=MnfofWsR*?lm7KA>YuzIO+O6-#&K4tuafcAf!AgAs zOfetd=c8Hjkkl>I!p6v*&9sI>2Opae}$EdF`Qw~_1R~(6>uJsN_nom zs*Yxop)m{C1BrpF;uFx_0GQ>PpddM^EKtt|i}iRF5$GHU3J#4sZ_jQD+78~TvP%q@ zwg(dOzg>QD-t+nH6O+!2gQ*iN=*%efr;aCH1}=7J?Ub+aP&pWlI*nnfemJ#?+>byM zuVMf=1I4ea-f%fM8rj&S{7M&5=F z$OMl~EMW;uX$R+zVrr`&KbX&IH~YKvQ0e~br}ks>=LJ{zBSG+!3YXyiU|AaVXvswO zH|&TA)c)F##kf!?-v`@{a1H;nPhK21CcvkM$!A{Ma3bk?npGEy8Vr~iOnsM?7Arsy z0HV!B0-2Cd2}&MnjdBB1;YAr_3E5L(7GR`eJ9nxzDg(vh&>l7<`5HPNRm91~)qQl( zuqg}dV1OH_UCnrBj(<)*bp*!ivv)tsz7;J_g^=vG;2vP=vcu(qCl#LclQGb`dI*eF zOfw8*E}xM=nQC1*2eBI94&DN|eC?SS$dI!N3(EOFfCwlp%}X z-;abcBq^mVkto<7sH=H%8~LdvGcz-{JC_kOM3HT%YpHyZdB%-+KNHFzD7NGTh`X7p z{;rgibARuN6AtQ~wv;}?tC|Q5Ht)g1T1vigFJShiQy)!>QyA-8dMuXR>@b#vl-2~b z>DR8gF2{fA{?!=EUag;W>Pj88|FBVzrCp%uDB{2n3uV_eadBFLP;5{Y`WSeM5NMwJ z(W}HTbjl%F<`qEvNqh|TB~&&A4PHk(edX^x*NtGNj}j^trjmywAzCwyrmk_lN?11% zg`W;X55n%*;~lD{EBtYy6F(=Pjqo=I&F@H;3G95nv9QY*-5UeK{e~3kF@R9}R@Ze7 zW|Pi-yDOK#KAJA!PQWOIc;5E+$L;9QRq^$uKRcXbp`fG`e6lk4H4rZGuG}dV6XIW7 z*>CY72l5Tu@d|tm4$43f6f?9S$HL4I!bl^xKn+k9s~)>wfrAt`S~&_tOn50?N_uU) z@MS7q6M_5;23@+Go4I*o?5d40UhXZlJFL&1w=qp)A*s{n-IE(r0T>v!c9W~bd$s27 zYE8vL6{%d%lWub*x&AZU@dY=xSz?lqvWM+$&o{k7LfE8tH6$~=Z%g=S>C?P-$;Q5L z<&WI^X2%J>7grY>JFf9C5`<1|@&uRk_aqej(!bRW+PBDL4YTy2P4@o1?A!QRXU(Ph zWvMyq2%K6VtyomL4iP<)L=J*a22!q*AGs!sj&}jKZD_IZ+k=Epx&IvTrAtbX-|?Y~ z9)FYC?iVv#JP&<&mjVyleKGK<-{yKW=v4E{o}X+sZ~wF}zw$T@V<7`4dL*x6ju1Pv zg{Ym7~HmhxexSMpGQO#JqH#hHkRzaEmpq!TOTI&0I$1Yd=sI)_|bd2p>!Pzu2xK! z1U7!VtNU9;1EVsU(-XNMNcQQ-od`w){|-riNfW#K-E(DxSo-{5)Zw42KKXeCnZERu z*8LrD8-Z~c(#4b(2|#w=F;NH@5kG(QHU0da^vp{(V&}%an;3KvtkvpIQ?PI44gpWK z=6)06pVB3(IA5=3;d19O=eEFO)r^(VCXUUnJU&hWzA=K*nJ5`Rm0Oz-szZwH@7-UF} zCmQa`CSV8U<@I4wh1~~cC8H=r@Ph|M*xL5?;Kr{7lkW_2raULq@65t{4vB1$M6)g< zLekqmrJQk<8qM6r-yAJ68zz)t^D2>Tt=C3T4G1V{cfgMF<<4>NZDg88nxVXy3QOR0 z=gf^~E7HOp#q>I1i#!BBjjuN$I>0!`$8}~p!^_iwCwHas63*|XM?-U1JKf!? z4X68QM^1CuVV~>oFksS>oE(yO7Di-!Z{&$Q&_^$hjR4_8Q*Gz^ zX4eD^M}{NiP4S!y{)fL!{I{Pq-2VOR)6T&=iPb;0qs7!sr=HrGPySfIu;^DSz!G5e zlvOT=D+gXsg;zDS!CL_&z;G60SHU&c8BQ|-Kn+tj_t62cs38v|g?8_}BV*;Q{ilIY z=7bG(&l~3$?{!dq1HPyVG}Km5NcmOZI(9}i4;-A-{rIsk7tWS#J2p_4CDtNh&G)qG zSMXaM7Xh6s0T+xPPG?2#1dw=YvZ!ES4kI{;eD_y^H=7ggey;V{76PsNs5zF1!utZQ zg;W5UQ3JEF+lvM&Fc4a80z!w>d%IB*T^O-yS(n}E&b?p1pecUrWL6n)crXCeX1 zn^funLD)R3S2O=@_TFUvSU^*6?~h+~ZipDKJTGrKomlq-=5$R2f1OtC_GJAhK6D9EavC8McCtxeLrFl5aK$X}a#XWfWO=hm;cLTx`Vl$d zZ)V# zxd6%zZAZI1#8pth=@-(HSTG9FVwX2w{{2Vz$z;-XvV`~GWMpFwvjU#@b} znruLjGOqub4-bc?$fKj3w&piX0xcn8Q>qGDFCi0D6SzgXzd0QXRoE;hPagn+R0t4_ zC6l8+V|i?58skfGFAJR=uL=N(|3%c5b3jmgRMF^Q7NYQQB9;G4gC`$A&EXU`=O)hZ z15}YA8%BN)=PLpZlYJ-cJJgY47oYzt5hK@bg^pY>bhj!xz!pUR;o1*Z%nWp?K5yWe z4=zY`avAeWO48#J{PntV00Q)owMjdD)U7hEu;i|&a9Dzk2&EaAdHmUz;wVQaFhPdM zo4z;+E&ac%2UwYbz61c~h5>sDVdE^A>4-qygODblSrY>x=~)CF5GiM0#zulYSR{3y zAW&Qz_P$Rzaq9Hlg|UT9zYV&JjFMC#VSZvL9tLfyZNO`0@{>(5+NqP8e^)gj2j6NX z+|1U=S7T>*c$53K=|j~9{lmzi=L51TGcO7(6Ynun^P=|Zop;NSU#SLDY6CHDrGGUr zyDIfq#)Vm*&x|;V{h;v|hV1X!&hdB2rxs+|QAle?HpTvCbAh1$p(>R0ng#6v2YXN! zh;Bo--(q`~{rz}_%?daqUxeo#^Ym#nev;%@G(C^O4IFOnqWAatDh5E9;L=GyA+Egq zc&PYp6Ox`O<}Ax`H*-EczuUT!n}-(zK*A(u=(xjY7zB6O_$9FhvnYT8@U6%ZLOF^B zgZDH}lwokx{CQhj5nJB@u&=o7%fk0~@+~d$r$bHc4-Ws7DE(-7d;#ra~yuu=mgji(rK;V+9&WqhH`3QsE%126qMx&R)MlAHz~svlpn zt#0YJx!ab=OLjLiBv-j~;w+U_g>0nSuW4e0;U8sdhGgGQC$JUl>d(V(wQw~aS9Sl( zg;Sup%V1w=%|sbi<#*7Vw7xgXvi&pni{I~OSHNX7&o)ciP)3j9i?y(+AF@s@z5C_f z4=^YOr0WHMcP&n}fREbhzB9px{6QHg;}d(E^_#MQTN5JxT*;t^g2|V`Qy1ndLg4&) z14F{$d=^5{rsZ8}0JnhPjRkNPJ_7oRgNr)^@OYK|*k$_Yw=IsgkbW`CUW9<{S)&KV zafTG)#iPTQscJ1;ihJC?HNH50>E=}rIleHfHt*Wgd-`RsF2(a+RME3?d*!ri4XFQD zE8~fq@4GswAJ?Dtn!nsyF5vdwl4q1XWWkd1TG{!Nhy(fpY#!^W&mt&=2OE4gE-qZV zMxp$oTlX(1aU7Ln=fYg9T&?)JFa$p7Cq4j9y2O-cDQEd}%& z26+E9CkaD@O1EwWRrXs7{a#2mYkL$cb7Qq0`Z1;V_cFp1*o87g4^n{YCgL zuv=^-CoqnU=!Caa%QmdjIiq%hkB4(YhOU2LRT90TB)WiOQhs zZrL2G=;2pobluc%ofb1R-&6z^2Y*oQdr=^1;Rdc(XnFIvQr^4H5zt=TF~nm> zgO|bySQBOXKVsW?^85G0@QAnpE#n{K#u44ZchR!XQe-|nsIMoZr45I9V?=twVfo=D z)J`4*%hzY~bF^X)Vc_aVM1UXZ%#WeDkN!s$!XGB}AR)9UHc;4G5e#?!0dhQ@tm5Jy zN9H(o2)!l~pVe>h__i^T7e#SJdLRA{1NM&h8c=#LZ8RWXx`J;jOl3fwI=-$^lYeAx zMsgu^FVhdY0Y8!Px98g`mYSKmc7jxdlRQW26n&wjzDsB;ATJqg9j^*+cV02$k+lc; zb)%B^^m^vzEO3Woft_&0=YcjANdK@xg;RL{Q6vx{h?yu{N#?vW@mjMLnE70UN^`o? zx7q$iC5x9_rjtuedvK~!Uh4hGS?kcSYWLBO?fL3=KbxX6#nnf9U&Hdnx&p#s2Hc zAogQOqn7{7w<%p?lHf6}q0TnKupr2(V}nDF=L(p|dOok2G#oMqj_>nIdhi{e;G<9m z8Vo%0fUcAn^bv}JU5rW+%zZ)z1hW&k|B&r2d7Fuyg}w_K45{MV4{h3XV5vc7%B{|T z69lR>=720>RhS9H(g_OF}6#H{sl@1d}LM!UiNEK~PcUfK}ho zXVv*t_&5?i_LaJbYW@b9l*7-wQgK~?Epf&~X)mg({s?+;@u<{a6p{Y&T2m4K0x|LD z$peqB$%A?QE8VICE!ALj{NX;3c3^M8R$1!gf-1)M$mqfUkqJiBsgy9LA`m0?J-M6= zm1E;MFJFJKaxX#w>cc_D$rY0%92|2-@s=oZ?Z>IYiQ@7E=i$FY*UmaQ6-zcD<^VkG%5zdImP{$a z(_yRkm+4GOPkOQ^Ye|1PHj5{hooXovJ$w}VXGb~|YwI7njKOy4Uw>x5Vg zX5@tISN;r`1%dw(M&1?j!cM^Aqd~eyzI^Nv$oW(} zp%-<_T9X0-KgN{^nDT8kj?J(kb3qb_XU$V8B;*SevGglrfu*e9wtb(Dw5jyEY5h^|#z-pGv<4I@IwaGU}h0hRx|g)MTJO2%qU`k|@lV z+%Rnjcf2vsU;+)`@>fXiwVV(zQp*Vl^Hlxu5&ivavNmd{NkNJzWai-=R8EMXYat#0 zeGHEu@6i3ld}&} z8Gv1|Ee&YEdQcK}L=24Vc65#^L3T3q{-FX44N)UNaKrzMR#beI3?vZ{4Y;teBo7AY zQG)a+H%=A-EbQP+!aD$q#cjT?uD`Hf_ayt1;qP1SO9CQb3btYW=f3eN!N(8HOZ*oY z7kVYmZ=`6{R$ZdKs9KF+LFI!*efOYZR(8^m(E_$fBcuh&lVyubU9!|f`-$OJS( z5hk$0K%)-)Pqea+{dBZOTfC-x>IWxCE?j8uh@cd7o$Z*2gcIUl6cq)Iar^VZjpFT()-V_j|9#T$4j$r#Wer$VwSJx8LcxxxE zZ)U1Hm@?I1K=CLeXFlC?+scY7B8RQPKVH2zyouchc-s5L+D6-{oD@t&VAcf{fyZ9) z4*MTSJK-vNZkPI9@~oGccANJ}2y!zO`NZ?ZLFpam6)f+gg=9gy5!)ZmO)vUgp}z%V zU6g=7uHj(;;}??PZ{B~0R7ieda{T|^d?Hv_<`%9zWO^xOK74pasOm0e-zZP2qES6D zk;OH2cGJz$s`cZ{qs1Hm(03!2At>$e0yfe5iaVi^Q8~rq{PYy`^r|SvYpBSA{)$a= z#W^lo0c7$W`&8Y^EYHchay$hQu>_oGe@)^aB%JS!u78z<(lws_GNXhWRXiKD_}n6( zrPuwEDZvUxg()T6CM)3UIEH5DvumweE?fLDkPTX(s2%L@*Fvo#6zW9f+Jx*Zi`I;5 z?W!Hdfho=snP1_I`Ih(+qiPbPYNuev39p`?R{|@;fm7;%7{3+3L556Rh^vFT%XI|H~CZu-W9a60Ma3qeebfK!X6Z$DyW9uh#z zIz*ZtpP`VR9~N^uEUW)=)s^762-JSJ*j zrv51_Pw`VPhi8FEf9$8e5Ujqf-A-xGHV=2IaccZKmS7G@wwpna#sat}Tzb zOFQ~_I6<75uVccP0}HiQuU`Pjb0Wa&i2Eh`%4juHS)c*&`>?Lc9A_KMzzm|rjYBJ%j?C*85@WP8dU%qqzoMwf-1^98m@n7Wxj%adP zTO61++-1cW2UV>@Z|BFlr>)2ic3Mvl!X4S9oGa9dxi{he%s!744I=eKx7~!WS_Hgg zg}P$!|L%NH1HyKcW7g-`KYwo6igbIQeX^LiNsa{qNNI^V5e*nt%;E8Bc>^N>TwW1Y zfdwDROSri|bq_f_oVyD;nQEmbpC|xYS^nn$>0Ct&5`>JxBm`s+i>hO@{QL35{~jLNKq} zKTG!pPRc!IuNzU|V7WDUz+NLa`MnG{DrxTHLVx9d%+0DfU<`mUyGaJ}NJU_1-3CP~ z=)%oANI`ck(|6_Z;?Cjr92v9R1uQVBZwJwvYQ7X(z+~=!uwaMLju7ysg`q=SxP2q1VzxG2po!1?+{`V@a+78zu z6(lH6lVo>!gc|HXE@6W_}`N+5yatSvU1uN%b`VrwOOg>bX!*W~B zu(7f4HyrxCV3Hw#*0x8DahZO)xN8oZ!gZRIl$1ykg?FHf6beH}$rg&b!wmr%zJBM5 zc^z}{s*F`et&6O3kAc5XajAe3ebhW;Gy z&$o9G98i*00JIHwTTwLqz-3T}B#Ha@Yg#SVom3HdsDt2;iRfbTy9mRXwN!mjCJ~hB zM}Z7it6hH^c$9*oKg;U9YCF9u$B92lHFsCK9>o)B^*wBvi{nc3jlr+(_#F`68ic%9 z%)Fi$Upn0dc)Zr4x$yh zLuyEeA-Vp#pq9w=8QY&{}rU27hYc8^Hs`v^M+-7=ic4^ z7AS^*kQxXhjz-(N7eX-U491TQ258V&qePI=H}bfO%F4a* z0hnS0szs?}n=jrJK*>-9k~cX-VG!t*kedKhYvW_?P3Qr@d_A^hvjIE?ZXE{b!o;&- zx-GBekl?OBV0{UR&`7h2m%aQMWq=?paHu@I#Ph4e~Sr!!+`vLX>!PZ$#H!h^df zM%);-d=t6SjAD8k9({-?bh!8JFh6I@clKqf)7f;uLB{O^$M2Z0GZ^Pp2tfT_%phE+2u0%V`aPJTSetIDFCuy zSt14ubwGFn`_t#8e7u0ykyBFQ=EiZ#BYDr0%5XOlW{gxF{F}%w# zyt;>(K7$bDeV^~U{|Ws?-v*FPqeX5yh?jo<)fCSrk?0O${p+VM z0e86#vM?)0NDykP@8k@>eSV@Uno$tZpmeFrB4+J?slhbALj6uxF4Ef46Bxmjocfuc z`WX*JSnp4DUg(z4i&{Vmmks9<%nbKD+GF40x9bTSv8#soI(0b|`nhfzBq3PQ%I#s~ zx^Ev>n>ITTU_E{Qbap2(FGPyssAy}D$T@5#FKH7W<=oSxtUKlk)Y7=b#B+0V6!JF7 zPZev>wkrKn0@5oj%9!9kMe@tfnIAGzT;QW(tXqM0B|7d`1k6K;^Qmh4EC2p}GlDkB zRhU%4iR#3WC(|l7Wq3}?90Q{zzcDBPU`hi-5+#{~2WTL20uzwkZpGf0W<@ z)(QnsTuKTtU`9Dg?$Y}|9)UZEIzoJU;gzGE6jMa6WHn=gKJRpZ)_CH^ z(d_Ks#jjKL|2?ZbTwS{M->1rqf8i(etgQ>AgCkTg_K>di@*aU6V=R_=?L=I<|K!9& zGM_u65GqCJpzh#BNGqB#~lnIDctv@mF;n=Wo2i_x32u8NFnR= z>mznTPlshZh4oEBM84pgpVY;ISFf&G$-+GEp+2r?id@f2vQ%@a^YSg=l3UJIt*Mgf}SP!Og*b z5_p#^k*50e%)VN3N_`3ytsFWyI7o3->ncUgyVklhRWdxie$5M5l8l?|MQb_}o6VX9 z_xh1avz%d$$efjT3Wp7ey+WdsD#hG1uwiPZkqphgRXv`{imuAy)f7z+U7!CRG}%J? zG=8!7y}VI7y#xHay5I5e@Nl%cqMviw9csPo715>w2H7etEbrp0PnC6zU=#~Z-UQQf zmBijrNRi?UMqZRHtdmi__>3i6iWCTOpGd?=mS3vebd2a_0?J-p9#-R2i=`}20-k)( zP+{gtH-p`ah(eLV?WTTnfx?Z3JG%5EhZ_oIi;g+N<|4(Lbdf{0x|2R>g(7ea(X#?2 z?^>k)TLr6Fmhj@@rOZ283li|P!tD&RQ69l!bpf`VVLjP8oG(0Se?KBzy1TlPEG_ep zJ@)d6$`+Cm5zQ;e!>b;MSUEX0n7c%$jqYPwD||?V|1PQ&d@FIPG0BNflKG*XabVvNn^&P4jV8IZ-@?zat8JM3-S4JoR(*t8 zywUWj6={rq|9j#b9V8pBT}&3Mo9TT&yyU2n!v?)Ol)My)NkLdoWWV)fibM{06grSA zJuxk}6E)I}En0>r-mMM?;6h%PQg$k*IeqkV{`iQ74oe{gM>I(e7fL`YfK(-1E!DK# zVEqf8w81^Ygn%15)0+*NB?=}P1jYWgXk7~E7|gER+(JBQndqXi8G_JOkwdg^jyuv@ zZV3-0=)T1xNYy=&D*v{uCt;pem?zx)lM|D$FA!7ZE)}qOJ-<}4hW8l*TfdG zrD&`m=^j_Higk-4-N@tMU=440qu(znD;KCQ8liA&9~W!NgDmk~%dG@E<_65xZ?@&+ zaiWjbA0=H0UURQbWlkcaz9LU%ev?pvO!vvBH7aVl=pQTTolpN|u@Kp6mB$tL{rlr@ z=3;HcbHPJ0bv`|=w+C@+x=_04Qx}tJ^C?3rmt|{sB!rf;#XRZ!phDS#6 zrz091!kjs8gyeIFS}b)AWK#7@<|N{KU#z+3)p0%(Obfkl7~G3jrtjx&UL|?dc7U(# zx)r^u_XIH`qb_KEftp6uH3NHEWK$ z`{jFrjOwc%h52a@$;K74^iRWfPXy8e=8jXB^x=sL(Bn2h1cQUBIB-bCqaL^dAt57D zM^7QV4FnI(ANVgc-u|kHP%*qfp8@JOmHq~OdSFcu+RDk%$5QuXe6-sCp>zxj5fOtc^P?+z58oxc})&d+`p)~<57&8gN;iYmu~l8h|q`K1lTm$5Ro32cOrem$(T z^1*z?N`&BWZH60YE+ZqBptOBm;5t25tm5S3H__H*m4o(6`}xOZB5_6|V8VMOElQ zi$8RoTxp1t@EqnfK}5mQ%CM1Ypn3J0e$dW;mA!_yWHj={3lDJyjU|M28b+eX$jFGm zZ8!GTJ_}_OJ7r1=o%G%Kfp4pOL_`8dx;cKPP&(4lEo)SdLpq^|mY?n$v0va-R&Y+}bR{=ktV?n{F$0Yj5{^))i(yE&w{;J)n zw$-j0#oO0Qh^<(At7H`7wtTtR;a~Mi2AJ>EFL^diJf8p=sAfU)QI{AqQC+*c=n+bu z%qf#~Z*X-__H>xDxLwH`EB3r?)El+X9XD9@yowwV`4R4Sv3WY%c+O{^^CkPrK^nz)wsBYVJg*4-^}ok!_WIf*-csonk1DMsHdzg~pZY&zA^Pg4 zn(@_(FnJzCNYlGm34`MJhwQ4wV$tW`2XU+?zM?0oiHoW`)Z?479$ycb&rl}}k3adu zSRy*iN0-OEn9n?7zI?Lo@2%MJ;XQhhzKsnl)`s%=PH%6o8&ec4A2WEue|?cA)Bagu z#vSgno0-(zfuuf1YAA|?8{q1|cHdAmp?mT59eJ^?8+t+G4D;(QYESR7UH$|h-usDl z;1tDoq>-Wy@Q6aNw5cHM{Ig)G;zR(px*W!MnXs8swGr>y$!qAz1d4X|QD&B)``uzd z#{OF5SwKvcwP9RR5=n`u-%Rq|2Y(Z$De-(=^pG5734Z!cToDK-<8m8LCjI~Qf01s|$Bm3wl^wi}DRb?`~MZx9QCu14b?dT}HK z#Ibx(C%a8Pq7!KpQsZf7zph(i^eX3iVDa_3dwTh0)%y?BI({0l+C}8=U;iL#X~vNf z5Qr!$J8iJtPWmLgdnz5f=n7P)DPi>2U+WEv0l%1|rB&kRe@vV{@EZHpzAm)3UaVwng+;|9D{L)KQ0sX-Oym6rX8R*~-D)xSwjAlHt5 zX~GefkU(jY{$S?MA1AScgrts^Yp5I>a{<~nLxphIzWKJyH_4D=^Hxi|4J7L{HNW26 zWeGWrFHH;$#bscuJkGr?T{^H41!t|V%n8bn^OFg3Sw*Fw>zv{GWIC$Lox_mVSi;8o zK(0)`FF4s!)KX}C?kxF9U==lN^%2fJ-=->#)od*;jYUOcpI~bddgcR_>mHJD$ol?R zInC895Z*)M5_-l_YjZLDURuJlv1vMCsSq*tiFtJSgx#>R)J8hy?b&1AzrK@m2MG;< z?5jxplhc*M!{gDLncG;mblkiK{A>QNeT%YSJ_|cMRM9HZ`|k!Al7lAYEA9t0flUiq zU&}-K8*t<3Q{tQ-hKFbN+KSvR)0fZho$s@+AO_8Cp#QsgdVctsYzreXJbD}>cGPeM zGMt>8AowPKqqZs8b}h=X1fe3qH+eAmM(h4LmcahK3ij{bU*UM^{_khxqw9Cg7@_U1nb3b_8 z3n%HM`R^ImbkJwby>5p`Y(MYz>2YaL_3R$+IEnGcK>VPQ5h}!_*@rp;O`POJLQX!8 zb>+$x-3vsD`6P*D$N#vLE;67{z9fyNVQ_pjwQ!`dB}=umw|`r){x`hrp~W-K-%|r# zZ67~~?{2|3LOhcb`4QhUSyUXK2#VNmp#wIU%~D6iD6?SQ;Ul#$*EUZkoF?{fVjHUID>s7qFbWZ-K}L* zN2jO+HpFs)2M-@Cs=~vaU<<9U|C=>8|7=sfJ4phRo+c;1tz>R*9Va@(0px&l%j3)S zUmu@D4?KtnFYG?@K4JMd6{RmzlszKg0-R%nnL+pV{wS)9J=}SrnwsbTb!2rfmh+8y(zS{^B+qc*T3_}pBsr-CTUv6ZScN;h zesZZ}KFGG>$#>85Lfy9tx!T5X!JVkp$uL3&#yy7j>OiWB>R_u@m z*fsu5YWpp%*X(n&?RzeMwvo2%tKhh^`#!aG2%xfaQ^_zDS5mlZC{)G6cki5RX@Ai& zZ7?M{Mc#QIBUZ1dNCP)DTlcN7ZMcK!zQ)pbfxZ&u+~2sq6+W=+bc;3A#aPY8gR2$y zQLA+V{lop<9MU%NGA~yozL^WKfVXO*yn^MoOv|c-GvNeG;>30Bs?AfKl*?uMkCvZH z>wTAqhi{mGT4?xN+1!~Rt3uD4YZZ@SRWC0;9$e1L!q+buy;Y<^82Y?BsYsW{(%Sld z9UcB>SaJM+ePxD+b;?jBH1NgmGIaHh27A3YXuLZ63+7yhsUZK)ZJ5jm}G$@i+5 z=h1z!(bV}#s6qq@>k0PLSA5ngsjRN{xo_ux$BkbxzEiVhjxe}KLSM#q{rHXn_ND}0dQzvP1o}Q>As&&dI zTZ-jnUeYfgL$-Z*$1f%FeB%kxudJ}FFpEF!&YYL;9kDZQjz4{QyvK|6>!Z)552WXn z9>Q>anuVfv>GdwkJBupzEpYmwgkL`nUyExsfBE;q`iqPE>J|JtGFP}0hfgzEc%1C2 zdwHynW+f8VqF;SYF`T9x9+*disxJK{5&C7k%>_#lMD2trxv#HueE?0MpEc!Y%$ za0Plcmi8pkUuWKptx5=sjI$R_$*JxAx}%%!Az8*2dUU86zZR#P343zDpeM6Pci2BE zJ|*Q^k0&{GLFuZ&W6d8P>&MjlXt=&`1TOVW(u@?DeH$j0nB@I?6JN+ys5|VJ-5-eF zS1Z#;uVforbDW^!Gz${eLVbf4H7d+>#BGXolbms(t#*;1aCBHw%(^;4p2RXM&s&D8 z^Co#BUZbh+L9B11X~j5BL}VJ(bifuo=aN!%Z(hoi@UeH619`MRYy_6r5eaz$ZS>38 zY6~4V$Py|3ciZm4#uM~<{iINURvc&#p{C_?7jIi0fLul%f%J-pWKWYcJY865Lbw_g zO;>+hiNI3ze3__OumF!biRx>LfAF1sjgrLg)6MzKq~Uz%@-g*|BaRccap9s#xeB>> znS`J91dXO$YsiM>Sf01$?5-YadGAiqgS(W8Y}}^&vi?-|%N%6naT#-;3SItp5&9+7 z9+cs1@@k}b`6Dza^_ObQFV&>N^KKQN$``Mrc({|+a1Hsl#NR(%OCI5PGb0hVmhtL1 zXrzf5aE@2UtS!OGiaIL^Yq6G_c_Yk~AIvDw&O!(Ycc1}TB3rbw(cF*OS!Sa;Vns#E zML0I{^7G-i)sG5RP*jvy@))lBjjEvH#vl4%7T=hlhKwwGBst;4S;E3WH;VUt4piIM zEU7kOHa};L*Oz;#V;s&W&vOuWOW);soJ?OG@=E!CpHx!Yy!wN=X9~UD6nz@cg+#sj znymBh9SaXyXC;=WnuI5@cetMgg}SZdmXn`mUj5%|$F&LP=tbpwbP?d%-RWuBFAjZUnz;v*=Bz@Fw0CteGk{Pk^;Rb-La~VTK=mN6cA7J@@qBfgf-3s z?zk)(Pj$79+Ps;qmm{%>JAe0PT)t|)9F8F!{APG^YIMj^X>{B0B^c19S^nihZW#YG zQ$`i!sN?eQ*D>EeK2#iLYYc2{QnT!7xfaEc#CE&j+ci+w*7~!t0hyD^w+g80bc(*z z##ASK^-<+nXQ^Q5@la7+t`83X!5oqI40Aohr&mwdhA4HGNp&6*#<8P|n3wj3doa@x z`_V{(thAgpg!%t_^VQ_k{1>`1ii>~#|ET&7cr5q#|Hs}@*^w2=h>(%W)*_UOvWifY zl|61F5h7)l5GM^QloAPLD`b?09Tgd=Z2s@N^ZWk3zyI@kopU;!6Q29I@9T42@9RC_ zxFUAX1FoSaw_dF7c1)!wm;e8L#ao`NLjnBzD&B$pp3mG=d}f}7ng%+Y-7}SggM*o- z_9;nR%nTq(m$jYSBr3HA%-fgP%o{d?LSFyANQf|dsP}hg`oDkMbeils{(a@*JBEO& z%F+Z@Suoah4!!RZwqyt^DRn-wfJ6+UBR|4fdaN^YbFAPj~Yx zIZ~{xtq-XD^{M&q*KgQL9$f#vK*QQ`ffs8@McHKOW3eGRcE172WB*Xk7&`c~@^|%8CEJ<^S0j|Mv$h?=G7eV`Q9Ck@ffUJN|IHZue#oIH2s+v9JgTd9v@ofoAVz zknTZl;ZED($;`fSrzSGg_a|=+bN{jgw=7$dNe%&xQkM}(eGVC~Imfe|-fac{v>IDS zGJe70*#)L8TN+c8&aBfXuK1lWR)M`2cyv0tuiTKDE-Wt2m!6(}&#Xw4cCm17*frZ4a&Hr2m5vh+aW|aN z)fEWO$jHdFfX@uL$=G^sHkC?r)WE`)WWWJAce|f^jIJTA^Prl_(u*DuZ`<*ohx_k8 z2~nx@-7}Z|@@{aMB!&3+Qop`)p+IaeENNt5@Ct7eIW$_NWtDrG#lw~UYMsz=3D8tI z=+-RbLt7}>1m@%@1>D#uK;W_G~&0*PFF6_#-@W z&pWB{AM{`)NYagH85pt+QWbnBhCsW0Ul~}Q*@E;SXa|7DuS}moTalARN;Xq zecU%=&&l(m4scbe5GzJm3%N&^L-0eSCN2K*r6Irc*~cGDOKpg1>WbN3M^#l-&Ud&pb-33wOethnm+gQ?0EL+&!k2$7zIpCB&i=Od>x7FR22F(0W3lR{g+?>m->CD z&lClkhgbNxq8GCNQ&0Y<#4y?ZpNj&@TlLGAGzewUzJKTr$3|jfERH0W@1TDxh3NE9 z=~P$$ZqGlV@~Xbx7VG6u%`E7q$3Jkz?hfpwP*{28DSUkU1t2(i;xU3~*9@{>yZdSN3OdysNR+JH!MM%6dxZ*#ordNt6r)BJFM+jx&PH3d~q z-0^yH^0D+D?Z`YW$k=el^ODBN^WET9Z3MX{0+0Gh$WKT}!IcjVFf&2_s{HBGe&~<5X}{ewmBM2Ie*(@0AJ9U!)8Xpx-Rp01 z>(#4^+CmWx{4F;S57-T1+3O{L)UVYtVdAn10rfogPg~?t)3V5GV4-OZds?~nH zMYu|%)pI2FSdIZ-iRbIYn;)&BU{gWc4CDYV@%Cd$TMpCD5)+>lWluCa{AH;AZ)MC{ z+C~n;+OZpVU4n-9h9!@Hg%AR*qz1Ux_F!C;gslq;Qnwkfuj|=?cN-ZUU61SM1{03M zzNreQN#79sKwP(Wc!rjj`y≈XyuhczxEu#rfj# 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ð6U07~i^O+$Eo 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

-!&hNn{ba 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%0D&bDc)Y>-g8d&m2E!%wn73(3X)I;LWFLg{@a^B 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