From 6b99ad95d33b7941c41340b108d2f528289d1987 Mon Sep 17 00:00:00 2001 From: Penaz91 Date: Mon, 31 Jul 2017 21:10:09 +0200 Subject: [PATCH 001/186] Proposed patch for #2858 --- qutebrowser/browser/browsertab.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index b94172118..154a1b42d 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -706,7 +706,9 @@ class AbstractTab(QWidget): def _handle_auto_insert_mode(self, ok): """Handle auto-insert-mode after loading finished.""" - if not config.get('input', 'auto-insert-mode') or not ok: + # Checks if the tab is in foreground first, then eventually sets the mode + foreground = self is objreg.get('tabbed-browser', scope='window', window=self.win_id).currentWidget() + if not config.get('input', 'auto-insert-mode') or not foreground or not ok: return cur_mode = self._mode_manager.mode From 506ee571b1015b2210673b9fd47e442cfbbeebf1 Mon Sep 17 00:00:00 2001 From: Ian Walker Date: Fri, 15 Sep 2017 08:36:59 +0900 Subject: [PATCH 002/186] Add handler for proxyAuthenticationRequired() --- qutebrowser/browser/webengine/webview.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index fd6fc99cb..8a69b4e35 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -24,6 +24,7 @@ import functools from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, PYQT_VERSION from PyQt5.QtGui import QPalette from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage +from PyQt5.QtNetwork import QAuthenticator from qutebrowser.browser import shared from qutebrowser.browser.webengine import certificateerror, webenginesettings @@ -133,6 +134,8 @@ class WebEnginePage(QWebEnginePage): self._is_shutting_down = False self.featurePermissionRequested.connect( self._on_feature_permission_requested) + self.proxyAuthenticationRequired.connect( + self._on_proxy_authentication_required) self._theme_color = theme_color self._set_bg_color() objreg.get('config').changed.connect(self._set_bg_color) @@ -144,6 +147,11 @@ class WebEnginePage(QWebEnginePage): col = self._theme_color self.setBackgroundColor(col) + @pyqtSlot(QUrl, 'QAuthenticator*', 'QString') + def _on_proxy_authentication_required(self, url, authenticator, proxyHost): + log.webview.debug("Proxy authentication required for URL: %s"%url.toString()) + shared.authentication_required(url, authenticator, [self.shutting_down, self.loadStarted]) + @pyqtSlot(QUrl, 'QWebEnginePage::Feature') def _on_feature_permission_requested(self, url, feature): """Ask the user for approval for geolocation/media/etc..""" From 9face7567ca9fe0a8970509ddf9b3bd83a39ac56 Mon Sep 17 00:00:00 2001 From: Ian Walker Date: Sat, 16 Sep 2017 17:01:18 +0900 Subject: [PATCH 003/186] Removed QAuthenticator import --- qutebrowser/browser/webengine/webview.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index 8a69b4e35..12f658be8 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -24,7 +24,6 @@ import functools from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, PYQT_VERSION from PyQt5.QtGui import QPalette from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage -from PyQt5.QtNetwork import QAuthenticator from qutebrowser.browser import shared from qutebrowser.browser.webengine import certificateerror, webenginesettings @@ -149,7 +148,6 @@ class WebEnginePage(QWebEnginePage): @pyqtSlot(QUrl, 'QAuthenticator*', 'QString') def _on_proxy_authentication_required(self, url, authenticator, proxyHost): - log.webview.debug("Proxy authentication required for URL: %s"%url.toString()) shared.authentication_required(url, authenticator, [self.shutting_down, self.loadStarted]) @pyqtSlot(QUrl, 'QWebEnginePage::Feature') From eaa1bdcddb42b35dafb6436440e71b2f43254996 Mon Sep 17 00:00:00 2001 From: Ian Walker Date: Sat, 16 Sep 2017 17:13:16 +0900 Subject: [PATCH 004/186] Show error page when user cancels proxy authentication dialog --- qutebrowser/browser/webengine/webview.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index 12f658be8..ee28ab2da 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -148,7 +148,15 @@ class WebEnginePage(QWebEnginePage): @pyqtSlot(QUrl, 'QAuthenticator*', 'QString') def _on_proxy_authentication_required(self, url, authenticator, proxyHost): - shared.authentication_required(url, authenticator, [self.shutting_down, self.loadStarted]) + answer = shared.authentication_required(url, authenticator, [self.shutting_down, self.loadStarted]) + if answer is None: + authenticator.setUser(None) + authenticator.setPassword(None) + 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.setHtml(error_page) @pyqtSlot(QUrl, 'QWebEnginePage::Feature') def _on_feature_permission_requested(self, url, feature): From 9867200c380db8e1d4044fe8bf406410eb821edb Mon Sep 17 00:00:00 2001 From: Ian Walker Date: Mon, 18 Sep 2017 15:55:44 +0900 Subject: [PATCH 005/186] Change username/password prompt for proxyAuthenticationRequired Update webview.py to more closely follow the webkit/networkmanager.py on_proxy_authentication_required(). --- qutebrowser/browser/webengine/webview.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index ee28ab2da..c99271494 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -20,6 +20,7 @@ """The main browser widget for QtWebEngine.""" import functools +import html from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, PYQT_VERSION from PyQt5.QtGui import QPalette @@ -147,16 +148,18 @@ class WebEnginePage(QWebEnginePage): self.setBackgroundColor(col) @pyqtSlot(QUrl, 'QAuthenticator*', 'QString') - def _on_proxy_authentication_required(self, url, authenticator, proxyHost): - answer = shared.authentication_required(url, authenticator, [self.shutting_down, self.loadStarted]) - if answer is None: - authenticator.setUser(None) - authenticator.setPassword(None) - 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.setHtml(error_page) + def _on_proxy_authentication_required(self, url, authenticator, + proxy_host): + """Called when a proxy needs authentication.""" + msg = "{} requires a username and password.".format( + html.escape(proxy_host)) + answer = message.ask( + title="Proxy authentication required", text=msg, + mode=usertypes.PromptMode.user_pwd, + abort_on=[self.loadStarted, self.shutting_down]) + if answer is not None: + authenticator.setUser(answer.user) + authenticator.setPassword(answer.password) @pyqtSlot(QUrl, 'QWebEnginePage::Feature') def _on_feature_permission_requested(self, url, feature): From a3456c41e43d351e0b1fd44a1bb1013d8ab515b7 Mon Sep 17 00:00:00 2001 From: Ian Walker Date: Wed, 20 Sep 2017 12:51:38 +0900 Subject: [PATCH 006/186] Mark url argument as unused --- qutebrowser/browser/webengine/webview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index c99271494..665d3f80a 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -148,7 +148,7 @@ class WebEnginePage(QWebEnginePage): self.setBackgroundColor(col) @pyqtSlot(QUrl, 'QAuthenticator*', 'QString') - def _on_proxy_authentication_required(self, url, authenticator, + def _on_proxy_authentication_required(self, _url, authenticator, proxy_host): """Called when a proxy needs authentication.""" msg = "{} requires a username and password.".format( From ccba76f7577901dfb54579d5b0cf78bdc360b038 Mon Sep 17 00:00:00 2001 From: Penaz91 Date: Wed, 20 Sep 2017 13:31:44 +0200 Subject: [PATCH 007/186] Fix for Issue #2879 --- qutebrowser/browser/browsertab.py | 2 +- qutebrowser/mainwindow/tabbedbrowser.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 0b21b6b05..38219101f 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -753,7 +753,7 @@ class AbstractTab(QWidget): self.load_finished.emit(ok) if not self.title(): self.title_changed.emit(self.url().toDisplayString()) - self._handle_auto_insert_mode(ok) + # self._handle_auto_insert_mode(ok) @pyqtSlot() def _on_history_trigger(self): diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 13865ba90..16fb3c924 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -689,6 +689,7 @@ class TabbedBrowser(tabwidget.TabWidget): self._update_tab_title(idx) if idx == self.currentIndex(): self._update_window_title() + tab._handle_auto_insert_mode(ok) @pyqtSlot() def on_scroll_pos_changed(self): From 7f03b0d0d57f36a222a5cd39787f97489fa3bc76 Mon Sep 17 00:00:00 2001 From: Penaz91 Date: Wed, 20 Sep 2017 13:37:40 +0200 Subject: [PATCH 008/186] Deleted a commented-out line --- qutebrowser/browser/browsertab.py | 1 - 1 file changed, 1 deletion(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 38219101f..a1c298282 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -753,7 +753,6 @@ class AbstractTab(QWidget): self.load_finished.emit(ok) if not self.title(): self.title_changed.emit(self.url().toDisplayString()) - # self._handle_auto_insert_mode(ok) @pyqtSlot() def _on_history_trigger(self): From 6132a3d7ca97b2346b74ab35b9038d627c966257 Mon Sep 17 00:00:00 2001 From: Penaz91 Date: Wed, 20 Sep 2017 15:52:07 +0200 Subject: [PATCH 009/186] Made _handle_auto_insert_mode public --- qutebrowser/browser/browsertab.py | 2 +- qutebrowser/mainwindow/tabbedbrowser.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index a1c298282..34a28756e 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -716,7 +716,7 @@ class AbstractTab(QWidget): self._set_load_status(usertypes.LoadStatus.loading) self.load_started.emit() - def _handle_auto_insert_mode(self, ok): + 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: return diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 16fb3c924..200107321 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -689,7 +689,7 @@ class TabbedBrowser(tabwidget.TabWidget): self._update_tab_title(idx) if idx == self.currentIndex(): self._update_window_title() - tab._handle_auto_insert_mode(ok) + tab.handle_auto_insert_mode(ok) @pyqtSlot() def on_scroll_pos_changed(self): From b46f11607587f4a335420d647b2c3ba2d701d025 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Sat, 22 Jul 2017 16:19:39 -0700 Subject: [PATCH 010/186] Switch pinned tabs to use their title width Previously, their width was determined by a config setting Closes #2845 --- qutebrowser/mainwindow/tabwidget.py | 58 ++++++++++++++++------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 111be2931..c1d2ec82c 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -435,15 +435,17 @@ class TabBar(QTabBar): return super().mousePressEvent(e) - def minimumTabSizeHint(self, index): + def minimumTabSizeHint(self, index, elipsis=True): """Set the minimum tab size to indicator/icon/... text. Args: index: The index of the tab to get a size hint for. - + elipsis: Whether to use elipsis to calculate width + instead of the tab's text. Return: - A QSize. + A QSize of the smallest tab size we can make. """ + text = '\u2026' if elipsis else self.tabText(index) icon = self.tabIcon(index) padding = config.val.tabs.padding padding_h = padding.left + padding.right @@ -457,12 +459,31 @@ class TabBar(QTabBar): padding_h += self.style().pixelMetric( PixelMetrics.icon_padding, None, self) height = self.fontMetrics().height() + padding_v - width = (self.fontMetrics().width('\u2026') + icon_size.width() + + width = (self.fontMetrics().width(text) + icon_size.width() + padding_h + config.val.tabs.width.indicator) return QSize(width, height) - def tabSizeHint(self, index): - """Override tabSizeHint so all tabs are the same size. + def _tabTotalWidthPinned(self): + """Get the current total width of pinned tabs. + + This width is calculated assuming no shortening due to elipsis.""" + return sum( + # Get the width (int) from minimum size + map(lambda tab: tab.width(), + # Get the minimum size of tabs, no elipsis + map(functools.partial(self.minimumTabSizeHint, elipsis=False), + # Get only pinned tabs (from indexes) + filter(self._tabPinned, range(self.count()))))) + + def _tabPinned(self, index: int): + """Return True if tab is pinned.""" + try: + return self.tab_data(index, 'pinned') + except KeyError: + return False + + def tabSizeHint(self, index: int): + """Override tabSizeHint to customize qb's tab size. https://wiki.python.org/moin/PyQt/Customising%20tab%20bars @@ -490,30 +511,15 @@ class TabBar(QTabBar): # want to ensure it's valid in this special case. return QSize() else: - try: - pinned = self.tab_data(index, 'pinned') - except KeyError: - pinned = False - + pinned = self._tabPinned(index) no_pinned_count = self.count() - self.pinned_count - pinned_width = config.val.tabs.width.pinned * self.pinned_count + pinned_width = self._tabTotalWidthPinned() no_pinned_width = self.width() - pinned_width if pinned: - size = QSize(config.val.tabs.width.pinned, height) - qtutils.ensure_valid(size) - return size - - # If we *do* have enough space, tabs should occupy the whole window - # width. If there are pinned tabs their size will be subtracted - # from the total window width. - # During shutdown the self.count goes down, - # but the self.pinned_count not - this generates some odd behavior. - # To avoid this we compare self.count against self.pinned_count. - if self.pinned_count > 0 and self.count() > self.pinned_count: - pinned_width = config.val.tabs.width.pinned * self.pinned_count - no_pinned_width = self.width() - pinned_width - width = no_pinned_width / (self.count() - self.pinned_count) + # Give pinned tabs the minimum size they need to display their + # titles, let QT handle scaling it down if we get too small. + width = self.minimumTabSizeHint(index, elipsis=False).width() else: # Tabs should attempt to occupy the whole window width. If From da57d21f0c6fee825958ddbec6f0cb79f00c27ef Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Sat, 22 Jul 2017 16:20:27 -0700 Subject: [PATCH 011/186] Remove pinned-width from config --- qutebrowser/browser/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 5bb07f129..1822b2675 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -257,7 +257,7 @@ class CommandDispatcher: def tab_pin(self, count=None): """Pin/Unpin the current/[count]th tab. - Pinning a tab shrinks it to `tabs.width.pinned` size. + Pinning a tab shrinks it to the size of its title text. Attempting to close a pinned tab will cause a confirmation, unless --force is passed. From e49aa35c752ec9408ad7fde6db6a81564edd5175 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Fri, 4 Aug 2017 20:20:54 -0700 Subject: [PATCH 012/186] Remove pinned_width variables Now it calculates the number of pinned tabs directly, instead of keeping track of a variable. Potentially slower though. --- qutebrowser/mainwindow/tabbedbrowser.py | 4 ---- qutebrowser/mainwindow/tabwidget.py | 30 ++++++------------------- 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 13865ba90..7234b13b4 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -272,10 +272,6 @@ class TabbedBrowser(tabwidget.TabWidget): if last_close == 'ignore' and count == 1: return - # If we are removing a pinned tab, decrease count - if tab.data.pinned: - self.tabBar().pinned_count -= 1 - self._remove_tab(tab, add_undo=add_undo) if count == 1: # We just closed the last tab above. diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index c1d2ec82c..c4686ddc1 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -104,14 +104,6 @@ class TabWidget(QTabWidget): bar = self.tabBar() idx = self.indexOf(tab) - # Only modify pinned_count if we had a change - # always modify pinned_count if we are loading - if tab.data.pinned != pinned or loading: - if pinned: - bar.pinned_count += 1 - elif not pinned: - bar.pinned_count -= 1 - bar.set_tab_data(idx, 'pinned', pinned) tab.data.pinned = pinned self._update_tab_title(idx) @@ -310,7 +302,6 @@ class TabBar(QTabBar): self._on_show_switching_delay_changed() self.setAutoFillBackground(True) self._set_colors() - self.pinned_count = 0 QTimer.singleShot(0, self.maybe_hide) def __repr__(self): @@ -475,7 +466,11 @@ class TabBar(QTabBar): # Get only pinned tabs (from indexes) filter(self._tabPinned, range(self.count()))))) - def _tabPinned(self, index: int): + def _pinnedCount(self) -> int: + """Get the number of pinned tabs.""" + return len(list(filter(self._tabPinned, range(self.count())))) + + def _tabPinned(self, index: int) -> bool: """Return True if tab is pinned.""" try: return self.tab_data(index, 'pinned') @@ -512,7 +507,7 @@ class TabBar(QTabBar): return QSize() else: pinned = self._tabPinned(index) - no_pinned_count = self.count() - self.pinned_count + no_pinned_count = self.count() - self._pinnedCount() pinned_width = self._tabTotalWidthPinned() no_pinned_width = self.width() - pinned_width @@ -521,18 +516,7 @@ class TabBar(QTabBar): # titles, let QT handle scaling it down if we get too small. width = self.minimumTabSizeHint(index, elipsis=False).width() else: - - # Tabs should attempt to occupy the whole window width. If - # there are pinned tabs their size will be subtracted from the - # total window width. During shutdown the self.count goes - # down, but the self.pinned_count not - this generates some odd - # behavior. To avoid this we compare self.count against - # self.pinned_count. If we end up having too little space, we - # set the minimum size below. - if self.pinned_count > 0 and no_pinned_count > 0: - width = no_pinned_width / no_pinned_count - else: - width = self.width() / self.count() + width = no_pinned_width / no_pinned_count # If no_pinned_width is not divisible by no_pinned_count, add a # pixel to some tabs so that there is no ugly leftover space. From d5c2f2855ac87c96dd2e831dac492d7578d0d073 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Wed, 20 Sep 2017 20:48:48 -0400 Subject: [PATCH 013/186] Clean up pinned_tab width implementation Misc fixes from PR --- qutebrowser/mainwindow/tabwidget.py | 36 ++++++++++++----------------- qutebrowser/misc/sessions.py | 3 +-- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index c4686ddc1..366d22b76 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -92,14 +92,12 @@ class TabWidget(QTabWidget): bar.update(bar.tabRect(idx)) def set_tab_pinned(self, tab: QWidget, - pinned: bool, *, loading: bool = False) -> None: + pinned: bool) -> None: """Set the tab status as pinned. Args: tab: The tab to pin pinned: Pinned tab state to set. - loading: Whether to ignore current data state when - counting pinned_count. """ bar = self.tabBar() idx = self.indexOf(tab) @@ -426,17 +424,17 @@ class TabBar(QTabBar): return super().mousePressEvent(e) - def minimumTabSizeHint(self, index, elipsis=True): + def minimumTabSizeHint(self, index, ellipsis: bool = True): """Set the minimum tab size to indicator/icon/... text. Args: index: The index of the tab to get a size hint for. - elipsis: Whether to use elipsis to calculate width + ellipsis: Whether to use ellipsis to calculate width instead of the tab's text. Return: A QSize of the smallest tab size we can make. """ - text = '\u2026' if elipsis else self.tabText(index) + text = '\u2026' if ellipsis else self.tabText(index) icon = self.tabIcon(index) padding = config.val.tabs.padding padding_h = padding.left + padding.right @@ -454,23 +452,19 @@ class TabBar(QTabBar): padding_h + config.val.tabs.width.indicator) return QSize(width, height) - def _tabTotalWidthPinned(self): + def _tab_total_width_pinned(self): """Get the current total width of pinned tabs. - This width is calculated assuming no shortening due to elipsis.""" - return sum( - # Get the width (int) from minimum size - map(lambda tab: tab.width(), - # Get the minimum size of tabs, no elipsis - map(functools.partial(self.minimumTabSizeHint, elipsis=False), - # Get only pinned tabs (from indexes) - filter(self._tabPinned, range(self.count()))))) + This width is calculated assuming no shortening due to ellipsis.""" + return sum(self.minimumTabSizeHint(idx, ellipsis=False).width() + for idx in range(self.count()) + if self._tab_pinned(idx)) def _pinnedCount(self) -> int: """Get the number of pinned tabs.""" - return len(list(filter(self._tabPinned, range(self.count())))) + return sum(self._tab_pinned(idx) for idx in range(self.count())) - def _tabPinned(self, index: int) -> bool: + def _tab_pinned(self, index: int) -> bool: """Return True if tab is pinned.""" try: return self.tab_data(index, 'pinned') @@ -506,15 +500,15 @@ class TabBar(QTabBar): # want to ensure it's valid in this special case. return QSize() else: - pinned = self._tabPinned(index) + pinned = self._tab_pinned(index) no_pinned_count = self.count() - self._pinnedCount() - pinned_width = self._tabTotalWidthPinned() + pinned_width = self._tab_total_width_pinned() no_pinned_width = self.width() - pinned_width if pinned: # Give pinned tabs the minimum size they need to display their - # titles, let QT handle scaling it down if we get too small. - width = self.minimumTabSizeHint(index, elipsis=False).width() + # titles, let Qt handle scaling it down if we get too small. + width = self.minimumTabSizeHint(index, ellipsis=False).width() else: width = no_pinned_width / no_pinned_count diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index 7c42b231b..064d8c9e9 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -393,8 +393,7 @@ 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, loading=True) + tabbed_browser.set_tab_pinned(new_tab, new_tab.data.pinned) if tab_to_focus is not None: tabbed_browser.setCurrentIndex(tab_to_focus) if win.get('active', False): From 7cad8f41f2d80806f15f4e3983996fcf5324ebcd Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 16:29:40 +0200 Subject: [PATCH 014/186] Remove unknown YAML data from config I considered introducing another list of deleted options (or a "deleted: True" in configdata.yml), similar to what we had with the old config. However, let's take the easier route and just delete everything we don't know from configdata.yml. If someone edits it by hand, it's their fault :P See #2772, #2847 --- qutebrowser/config/configfiles.py | 8 +++++++- tests/unit/config/test_config.py | 9 ++------- tests/unit/config/test_configfiles.py | 3 +++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index e26a03454..a155726a8 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -30,7 +30,7 @@ import yaml from PyQt5.QtCore import QSettings import qutebrowser -from qutebrowser.config import configexc, config +from qutebrowser.config import configexc, config, configdata from qutebrowser.utils import standarddir, utils, qtutils @@ -153,6 +153,12 @@ class YamlConfig: "'global' object is not a dict") raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) + # Delete unknown values + # (e.g. options which were removed from configdata.yml) + for name in list(global_obj): + if name not in configdata.DATA: + del global_obj[name] + self._values = global_obj self._dirty = False diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 6dff3c30a..36dc90c10 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -932,14 +932,9 @@ def test_early_init(init_patch, config_tmpdir, caplog, fake_args, expected_errors.append( "Errors occurred while reading config.py:\n" " While setting 'foo': No option 'foo'") - if invalid_yaml and (load_autoconfig or not config_py): + if invalid_yaml == '42' and (load_autoconfig or not config_py): error = "Errors occurred while reading autoconfig.yml:\n" - if invalid_yaml == '42': - error += " While loading data: Toplevel object is not a dict" - elif invalid_yaml == 'unknown': - error += " Error: No option 'colors.foobar'" - else: - assert False, invalid_yaml + error += " While loading data: Toplevel object is not a dict" expected_errors.append(error) actual_errors = [str(err) for err in config._init_errors] diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 219b92395..fdfda4033 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -57,6 +57,8 @@ def test_state_config(fake_save_manager, data_tmpdir, @pytest.mark.parametrize('old_config', [ None, 'global:\n colors.hints.fg: magenta', + # Unknown key + 'global:\n hello: world', ]) @pytest.mark.parametrize('insert', [True, False]) def test_yaml_config(fake_save_manager, config_tmpdir, old_config, insert): @@ -91,6 +93,7 @@ def test_yaml_config(fake_save_manager, config_tmpdir, old_config, insert): assert ' colors.hints.fg: magenta' in lines if insert: assert ' tabs.show: never' in lines + assert ' hello:' not in lines @pytest.mark.parametrize('old_config', [ From 2f7cbfa1ee5a5c618d0266ec8a62506d61270eb5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 17:42:30 +0200 Subject: [PATCH 015/186] Make sure the changelog is in releases [ci skip] --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index ec906aaf4..a3ae1ee28 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -27,6 +27,7 @@ exclude scripts/asciidoc2html.py exclude doc/notes recursive-exclude doc *.asciidoc include doc/qutebrowser.1.asciidoc +include doc/changelog.asciidoc prune tests prune qutebrowser/3rdparty prune misc/requirements From f821fb793a6fb271ca043ebbb29ec06c509fb4d7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 19:37:22 +0200 Subject: [PATCH 016/186] Initialize configdata in test_configfiles --- tests/unit/config/test_configfiles.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index fdfda4033..c7f611f44 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -22,12 +22,19 @@ import os import pytest -from qutebrowser.config import config, configfiles, configexc +from qutebrowser.config import config, configfiles, configexc, configdata from qutebrowser.utils import utils from PyQt5.QtCore import QSettings +@pytest.fixture(autouse=True) +def configdata_init(): + """Initialize configdata if needed.""" + if configdata.DATA is None: + configdata.init() + + @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'), From 3e0d49a4b32bd586e8bcc80c18df01ea92dabbe3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 19:57:54 +0200 Subject: [PATCH 017/186] Add TestYaml class to test_configfiles --- tests/unit/config/test_configfiles.py | 236 +++++++++++++------------- 1 file changed, 118 insertions(+), 118 deletions(-) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index c7f611f44..f1b7271f6 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -61,142 +61,142 @@ def test_state_config(fake_save_manager, data_tmpdir, assert statefile.read_text('utf-8') == new_data -@pytest.mark.parametrize('old_config', [ - None, - 'global:\n colors.hints.fg: magenta', - # Unknown key - 'global:\n hello: world', -]) -@pytest.mark.parametrize('insert', [True, False]) -def test_yaml_config(fake_save_manager, config_tmpdir, old_config, insert): - autoconfig = config_tmpdir / 'autoconfig.yml' - if old_config is not None: - autoconfig.write_text(old_config, 'utf-8') +class TestYaml: - yaml = configfiles.YamlConfig() - yaml.load() + @pytest.mark.parametrize('old_config', [ + None, + 'global:\n colors.hints.fg: magenta', + # Unknown key + 'global:\n hello: world', + ]) + @pytest.mark.parametrize('insert', [True, False]) + def test_yaml_config(self, fake_save_manager, config_tmpdir, + old_config, insert): + autoconfig = config_tmpdir / 'autoconfig.yml' + if old_config is not None: + autoconfig.write_text(old_config, 'utf-8') - if insert: - yaml['tabs.show'] = 'never' - - yaml._save() - - if not insert and old_config is None: - lines = [] - else: - text = autoconfig.read_text('utf-8') - lines = text.splitlines() + yaml = configfiles.YamlConfig() + yaml.load() if insert: - assert lines[0].startswith('# DO NOT edit this file by hand,') - assert 'config_version: {}'.format(yaml.VERSION) in lines + yaml['tabs.show'] = 'never' - assert 'global:' in lines + yaml._save() - print(lines) + if not insert and old_config is None: + lines = [] + else: + text = autoconfig.read_text('utf-8') + lines = text.splitlines() - # WORKAROUND for https://github.com/PyCQA/pylint/issues/574 - if 'magenta' in (old_config or ''): # pylint: disable=superfluous-parens - assert ' colors.hints.fg: magenta' in lines - if insert: - assert ' tabs.show: never' in lines - assert ' hello:' not in lines + 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 -@pytest.mark.parametrize('old_config', [ - None, - 'global:\n colors.hints.fg: magenta', -]) -@pytest.mark.parametrize('key, value', [ - ('colors.hints.fg', 'green'), - ('colors.hints.bg', None), - ('confirm_quit', True), - ('confirm_quit', False), -]) -def test_yaml_config_changed(fake_save_manager, config_tmpdir, old_config, - key, value): - autoconfig = config_tmpdir / 'autoconfig.yml' - if old_config is not None: - autoconfig.write_text(old_config, 'utf-8') + print(lines) - yaml = configfiles.YamlConfig() - yaml.load() + # WORKAROUND for https://github.com/PyCQA/pylint/issues/574 + # pylint: disable=superfluous-parens + if 'magenta' in (old_config or ''): + assert ' colors.hints.fg: magenta' in lines + if insert: + assert ' tabs.show: never' in lines + assert ' hello:' not in lines - yaml[key] = value - assert key in yaml - assert yaml[key] == value + @pytest.mark.parametrize('old_config', [ + None, + 'global:\n colors.hints.fg: magenta', + ]) + @pytest.mark.parametrize('key, value', [ + ('colors.hints.fg', 'green'), + ('colors.hints.bg', None), + ('confirm_quit', True), + ('confirm_quit', False), + ]) + def test_changed(self, fake_save_manager, config_tmpdir, old_config, + key, value): + autoconfig = config_tmpdir / 'autoconfig.yml' + if old_config is not None: + autoconfig.write_text(old_config, 'utf-8') - yaml._save() - - yaml = configfiles.YamlConfig() - yaml.load() - - assert key in yaml - assert yaml[key] == value - - -@pytest.mark.parametrize('old_config', [ - None, - 'global:\n colors.hints.fg: magenta', -]) -def test_yaml_config_unchanged(fake_save_manager, config_tmpdir, old_config): - autoconfig = config_tmpdir / 'autoconfig.yml' - mtime = None - if old_config is not None: - autoconfig.write_text(old_config, 'utf-8') - mtime = autoconfig.stat().mtime - - yaml = configfiles.YamlConfig() - yaml.load() - yaml._save() - - if old_config is None: - assert not autoconfig.exists() - else: - assert autoconfig.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"), - ('foo: 42', 'While loading data', - "Toplevel object does not contain 'global' key"), - ('42', 'While loading data', "Toplevel object is not a dict"), -]) -def test_yaml_config_invalid(fake_save_manager, config_tmpdir, - line, text, exception): - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.write_text(line, 'utf-8', ensure=True) - - yaml = configfiles.YamlConfig() - - with pytest.raises(configexc.ConfigFileErrors) as excinfo: + yaml = configfiles.YamlConfig() yaml.load() - assert len(excinfo.value.errors) == 1 - error = excinfo.value.errors[0] - assert error.text == text - assert str(error.exception).splitlines()[0] == exception - assert error.traceback is None + yaml[key] = value + assert key in yaml + assert yaml[key] == value + yaml._save() -def test_yaml_oserror(fake_save_manager, config_tmpdir): - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.ensure() - autoconfig.chmod(0) - if os.access(str(autoconfig), os.R_OK): - # Docker container or similar - pytest.skip("File was still readable") - - yaml = configfiles.YamlConfig() - with pytest.raises(configexc.ConfigFileErrors) as excinfo: + yaml = configfiles.YamlConfig() yaml.load() - assert len(excinfo.value.errors) == 1 - error = excinfo.value.errors[0] - assert error.text == "While reading" - assert isinstance(error.exception, OSError) - assert error.traceback is None + assert key in yaml + assert yaml[key] == value + + @pytest.mark.parametrize('old_config', [ + None, + 'global:\n colors.hints.fg: magenta', + ]) + def test_unchanged(self, fake_save_manager, config_tmpdir, old_config): + autoconfig = config_tmpdir / 'autoconfig.yml' + mtime = None + if old_config is not None: + autoconfig.write_text(old_config, 'utf-8') + mtime = autoconfig.stat().mtime + + yaml = configfiles.YamlConfig() + yaml.load() + yaml._save() + + if old_config is None: + assert not autoconfig.exists() + else: + assert autoconfig.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"), + ('foo: 42', 'While loading data', + "Toplevel object does not contain 'global' key"), + ('42', 'While loading data', "Toplevel object is not a dict"), + ]) + def test_invalid(self, fake_save_manager, config_tmpdir, + line, text, exception): + autoconfig = config_tmpdir / 'autoconfig.yml' + autoconfig.write_text(line, 'utf-8', ensure=True) + + yaml = configfiles.YamlConfig() + + with pytest.raises(configexc.ConfigFileErrors) as excinfo: + yaml.load() + + assert len(excinfo.value.errors) == 1 + error = excinfo.value.errors[0] + assert error.text == text + assert str(error.exception).splitlines()[0] == exception + assert error.traceback is None + + def test_oserror(self, fake_save_manager, config_tmpdir): + autoconfig = config_tmpdir / 'autoconfig.yml' + autoconfig.ensure() + autoconfig.chmod(0) + if os.access(str(autoconfig), os.R_OK): + # Docker container or similar + pytest.skip("File was still readable") + + yaml = configfiles.YamlConfig() + 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" + assert isinstance(error.exception, OSError) + assert error.traceback is None class TestConfigPy: From 691cd2d09bbc4b89e4ba1db19390b62a3efbc1ff Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 20:19:02 +0200 Subject: [PATCH 018/186] More test_configfiles cleanups --- tests/unit/config/test_configfiles.py | 28 +++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index f1b7271f6..0453314bd 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -63,15 +63,14 @@ def test_state_config(fake_save_manager, data_tmpdir, class TestYaml: + pytestmark = pytest.mark.usefixtures('fake_save_manager') + @pytest.mark.parametrize('old_config', [ None, 'global:\n colors.hints.fg: magenta', - # Unknown key - 'global:\n hello: world', ]) @pytest.mark.parametrize('insert', [True, False]) - def test_yaml_config(self, fake_save_manager, config_tmpdir, - old_config, insert): + def test_yaml_config(self, config_tmpdir, old_config, insert): autoconfig = config_tmpdir / 'autoconfig.yml' if old_config is not None: autoconfig.write_text(old_config, 'utf-8') @@ -104,6 +103,17 @@ class TestYaml: assert ' colors.hints.fg: magenta' in lines if insert: assert ' tabs.show: never' in lines + + def test_unknown_key(self, config_tmpdir): + """An unknown setting should be deleted.""" + autoconfig = config_tmpdir / 'autoconfig.yml' + autoconfig.write_text('global:\n hello: world', encoding='utf-8') + + yaml = configfiles.YamlConfig() + yaml.load() + yaml._save() + + lines = autoconfig.read_text('utf-8').splitlines() assert ' hello:' not in lines @pytest.mark.parametrize('old_config', [ @@ -116,8 +126,7 @@ class TestYaml: ('confirm_quit', True), ('confirm_quit', False), ]) - def test_changed(self, fake_save_manager, config_tmpdir, old_config, - key, value): + def test_changed(self, config_tmpdir, old_config, key, value): autoconfig = config_tmpdir / 'autoconfig.yml' if old_config is not None: autoconfig.write_text(old_config, 'utf-8') @@ -141,7 +150,7 @@ class TestYaml: None, 'global:\n colors.hints.fg: magenta', ]) - def test_unchanged(self, fake_save_manager, config_tmpdir, old_config): + def test_unchanged(self, config_tmpdir, old_config): autoconfig = config_tmpdir / 'autoconfig.yml' mtime = None if old_config is not None: @@ -164,8 +173,7 @@ class TestYaml: "Toplevel object does not contain 'global' key"), ('42', 'While loading data', "Toplevel object is not a dict"), ]) - def test_invalid(self, fake_save_manager, config_tmpdir, - line, text, exception): + def test_invalid(self, config_tmpdir, line, text, exception): autoconfig = config_tmpdir / 'autoconfig.yml' autoconfig.write_text(line, 'utf-8', ensure=True) @@ -180,7 +188,7 @@ class TestYaml: assert str(error.exception).splitlines()[0] == exception assert error.traceback is None - def test_oserror(self, fake_save_manager, config_tmpdir): + def test_oserror(self, config_tmpdir): autoconfig = config_tmpdir / 'autoconfig.yml' autoconfig.ensure() autoconfig.chmod(0) From b1ddb9a6df26dbd28bef717945635316d34d5960 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 20:27:45 +0200 Subject: [PATCH 019/186] Remove confusing test That's not the behavior we actually have in the config anymore when using conf._yaml.load(). --- tests/unit/config/test_config.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 36dc90c10..bf423fede 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -619,11 +619,6 @@ class TestConfig: assert conf._yaml.loaded assert conf._values['content.plugins'] is True - def test_read_yaml_invalid(self, conf): - conf._yaml['foo.bar'] = True - with pytest.raises(configexc.NoOptionError): - conf.read_yaml() - def test_get_opt_valid(self, conf): assert conf.get_opt('tabs.show') == configdata.DATA['tabs.show'] From 32b2b3dfd96e8d0cd253d0788f77ca6d83a34c8c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 20:41:30 +0200 Subject: [PATCH 020/186] Add test for invalid value type in YAML file --- tests/unit/config/test_config.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index bf423fede..3f419e8d0 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -891,7 +891,8 @@ def init_patch(qapp, fake_save_manager, monkeypatch, config_tmpdir, @pytest.mark.parametrize('load_autoconfig', [True, False]) # noqa @pytest.mark.parametrize('config_py', [True, 'error', False]) -@pytest.mark.parametrize('invalid_yaml', ['42', 'unknown', False]) +@pytest.mark.parametrize('invalid_yaml', + ['42', 'unknown', 'wrong-type', False]) # pylint: disable=too-many-branches def test_early_init(init_patch, config_tmpdir, caplog, fake_args, load_autoconfig, config_py, invalid_yaml): @@ -900,14 +901,15 @@ def test_early_init(init_patch, config_tmpdir, caplog, fake_args, config_py_file = config_tmpdir / 'config.py' if invalid_yaml == '42': - autoconfig_file.write_text('42', 'utf-8', ensure=True) + text = '42' elif invalid_yaml == 'unknown': - autoconfig_file.write_text('global:\n colors.foobar: magenta\n', - 'utf-8', ensure=True) + text = 'global:\n colors.foobar: magenta\n' + elif invalid_yaml == 'wrong-type': + text = 'global:\n tabs.position: true\n' else: assert not invalid_yaml - autoconfig_file.write_text('global:\n colors.hints.fg: magenta\n', - 'utf-8', ensure=True) + text = 'global:\n colors.hints.fg: magenta\n' + autoconfig_file.write_text(text, 'utf-8', ensure=True) if config_py: config_py_lines = ['c.colors.hints.bg = "red"'] @@ -927,10 +929,15 @@ def test_early_init(init_patch, config_tmpdir, caplog, fake_args, expected_errors.append( "Errors occurred while reading config.py:\n" " While setting 'foo': No option 'foo'") - if invalid_yaml == '42' and (load_autoconfig or not config_py): + if load_autoconfig or not config_py: error = "Errors occurred while reading autoconfig.yml:\n" - error += " While loading data: Toplevel object is not a dict" - expected_errors.append(error) + if invalid_yaml == '42': + error += " While loading data: Toplevel object is not a dict" + expected_errors.append(error) + elif invalid_yaml == 'wrong-type': + error += (" Error: Invalid value 'True' - expected a value of " + "type str but got bool.") + expected_errors.append(error) actual_errors = [str(err) for err in config._init_errors] assert actual_errors == expected_errors From f97f42710091b375b5249a6466f1d7124ef0d15e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 21:50:33 +0200 Subject: [PATCH 021/186] Add an assertion for Completer._partition --- qutebrowser/completion/completer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 854231305..efd6b3490 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -154,6 +154,9 @@ class Completer(QObject): "partitioned: {} '{}' {}".format(prefix, center, postfix)) return prefix, center, postfix + # We should always return above + assert False, parts + @pyqtSlot(str) def on_selection_changed(self, text): """Change the completed part if a new item was selected. From 64b783d9c056045bcb5caa3d9ab8543b54fc4e18 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 22:28:51 +0200 Subject: [PATCH 022/186] Do not validate commands in the config and with :bind There are just way too many gotchas related to valid modes, aliases, and circular dependencies when validating aliases/bindings in the config. Let's just remove this and let invalid commands fail late, when they're actually used. --- qutebrowser/config/config.py | 15 +-------- qutebrowser/config/configtypes.py | 36 ++++++--------------- tests/unit/config/test_config.py | 46 ++++++++++++--------------- tests/unit/config/test_configfiles.py | 9 ++++++ tests/unit/config/test_configtypes.py | 27 ---------------- 5 files changed, 41 insertions(+), 92 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 1efc96856..e99498947 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -31,7 +31,7 @@ from qutebrowser.config import configdata, configexc, configtypes, configfiles from qutebrowser.utils import (utils, objreg, message, log, usertypes, jinja, qtutils) from qutebrowser.misc import objects, msgbox, earlyinit -from qutebrowser.commands import cmdexc, cmdutils, runners +from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.completion.models import configmodel # An easy way to access the config from other code via config.val.foo @@ -178,19 +178,6 @@ class KeyConfig: def bind(self, key, command, *, mode, force=False, save_yaml=False): """Add a new binding from key to command.""" key = self._prepare(key, mode) - - parser = runners.CommandParser() - try: - results = parser.parse_all(command) - except cmdexc.Error as e: - raise configexc.KeybindingError("Invalid command: {}".format(e)) - - for result in results: # pragma: no branch - try: - result.cmd.validate_mode(usertypes.KeyMode[mode]) - except cmdexc.PrerequisitesError as e: - raise configexc.KeybindingError(str(e)) - log.keyboard.vdebug("Adding binding {} -> {} in mode {}.".format( key, command, mode)) if key in self.get_bindings_for(mode) and not force: diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index c669ff426..aca9fffda 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -59,7 +59,7 @@ from PyQt5.QtCore import QUrl, Qt from PyQt5.QtGui import QColor, QFont from PyQt5.QtWidgets import QTabWidget, QTabBar -from qutebrowser.commands import cmdutils, runners, cmdexc +from qutebrowser.commands import cmdutils from qutebrowser.config import configexc from qutebrowser.utils import standarddir, utils, qtutils, urlutils @@ -773,33 +773,13 @@ class PercOrInt(_Numeric): class Command(BaseType): - """Base class for a command value with arguments.""" + """Base class for a command value with arguments. - # See to_py for details - unvalidated = False + // - def to_py(self, value): - self._basic_py_validation(value, str) - if not value: - return None - - # This requires some trickery, as runners.CommandParser uses - # conf.val.aliases, which in turn map to a command again, - # leading to an endless recursion. - # To fix that, we turn off validating other commands (alias values) - # while validating a command. - if not Command.unvalidated: - Command.unvalidated = True - try: - parser = runners.CommandParser() - try: - parser.parse_all(value) - except cmdexc.Error as e: - raise configexc.ValidationError(value, str(e)) - finally: - Command.unvalidated = False - - return value + Since validation is quite tricky here, we don't do so, and instead let + invalid commands (in bindings/aliases) fail when used. + """ def complete(self): out = [] @@ -807,6 +787,10 @@ class Command(BaseType): out.append((cmdname, obj.desc)) return out + def to_py(self, value): + self._basic_py_validation(value, str) + return value + class ColorSystem(MappingType): diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 3f419e8d0..4e8bd684b 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -182,17 +182,6 @@ class TestKeyConfig: config_stub.val.bindings.commands = {'normal': bindings} assert keyconf.get_reverse_bindings_for('normal') == expected - def test_bind_invalid_command(self, keyconf): - with pytest.raises(configexc.KeybindingError, - match='Invalid command: foobar'): - keyconf.bind('a', 'foobar', mode='normal') - - def test_bind_invalid_mode(self, keyconf): - with pytest.raises(configexc.KeybindingError, - match='completion-item-del: This command is only ' - 'allowed in command mode, not normal.'): - keyconf.bind('a', 'completion-item-del', mode='normal') - @pytest.mark.parametrize('force', [True, False]) @pytest.mark.parametrize('key', ['a', '', 'b']) def test_bind_duplicate(self, keyconf, config_stub, force, key): @@ -208,12 +197,15 @@ class TestKeyConfig: assert keyconf.get_command(key, 'normal') == 'nop' @pytest.mark.parametrize('mode', ['normal', 'caret']) - def test_bind(self, keyconf, config_stub, qtbot, no_bindings, mode): + @pytest.mark.parametrize('command', [ + 'message-info foo', + 'nop ;; wq', # https://github.com/qutebrowser/qutebrowser/issues/3002 + ]) + def test_bind(self, keyconf, config_stub, qtbot, no_bindings, + mode, command): config_stub.val.bindings.default = no_bindings config_stub.val.bindings.commands = no_bindings - command = 'message-info foo' - with qtbot.wait_signal(config_stub.changed): keyconf.bind('a', command, mode=mode) @@ -221,6 +213,16 @@ class TestKeyConfig: assert keyconf.get_bindings_for(mode)['a'] == command assert keyconf.get_command('a', mode) == command + def test_bind_mode_changing(self, keyconf, config_stub, no_bindings): + """Make sure we can bind to a command which changes the mode. + + https://github.com/qutebrowser/qutebrowser/issues/2989 + """ + config_stub.val.bindings.default = no_bindings + config_stub.val.bindings.commands = no_bindings + keyconf.bind('a', 'set-cmd-text :nop ;; rl-beginning-of-line', + mode='normal') + @pytest.mark.parametrize('key, normalized', [ ('a', 'a'), # default bindings ('b', 'b'), # custom bindings @@ -504,20 +506,14 @@ class TestBindConfigCommand: msg = message_mock.getmsg(usertypes.MessageLevel.info) assert msg.text == expected - @pytest.mark.parametrize('command, mode, expected', [ - ('foobar', 'normal', "bind: Invalid command: foobar"), - ('completion-item-del', 'normal', - "bind: completion-item-del: This command is only allowed in " - "command mode, not normal."), - ('nop', 'wrongmode', "bind: Invalid mode wrongmode!"), - ]) - def test_bind_invalid(self, commands, command, mode, expected): - """Run ':bind a foobar' / ':bind a completion-item-del'. + def test_bind_invalid_mode(self, commands): + """Run ':bind --mode=wrongmode nop'. Should show an error. """ - with pytest.raises(cmdexc.CommandError, match=expected): - commands.bind('a', command, mode=mode) + with pytest.raises(cmdexc.CommandError, + match='bind: Invalid mode wrongmode!'): + commands.bind('a', 'nop', mode='wrongmode') @pytest.mark.parametrize('force', [True, False]) @pytest.mark.parametrize('key', ['a', 'b', '']) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 0453314bd..975e9c46a 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -279,6 +279,15 @@ class TestConfigPy: expected = {mode: {',a': 'message-info foo'}} assert config.instance._values['bindings.commands'] == expected + def test_bind_freshly_defined_alias(self, confpy): + """Make sure we can bind to a new alias. + + https://github.com/qutebrowser/qutebrowser/issues/3001 + """ + confpy.write("c.aliases['foo'] = 'message-info foo'", + "config.bind(',f', 'foo')") + confpy.read() + @pytest.mark.parametrize('line, key, mode', [ ('config.unbind("o")', 'o', 'normal'), ('config.unbind("y", mode="prompt")', 'y', 'prompt'), diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 122a1c20c..daf785d9c 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -1072,37 +1072,10 @@ class TestCommand: monkeypatch.setattr(configtypes, 'cmdutils', cmd_utils) monkeypatch.setattr('qutebrowser.commands.runners.cmdutils', cmd_utils) - @pytest.fixture(autouse=True) - def patch_aliases(self, config_stub): - """Patch the aliases setting.""" - configtypes.Command.unvalidated = True - config_stub.val.aliases = {'alias': 'cmd1'} - configtypes.Command.unvalidated = False - @pytest.fixture def klass(self): return configtypes.Command - @pytest.mark.parametrize('val', ['cmd1', 'cmd2', 'cmd1 foo bar', - 'cmd2 baz fish', 'alias foo']) - def test_to_py_valid(self, patch_cmdutils, klass, val): - expected = None if not val else val - assert klass().to_py(val) == expected - - @pytest.mark.parametrize('val', ['cmd3', 'cmd3 foo bar', ' ']) - def test_to_py_invalid(self, patch_cmdutils, klass, val): - with pytest.raises(configexc.ValidationError): - klass().to_py(val) - - def test_cmdline(self, klass, cmdline_test): - """Test some commandlines from the cmdline_test fixture.""" - typ = klass() - if cmdline_test.valid: - typ.to_py(cmdline_test.cmd) - else: - with pytest.raises(configexc.ValidationError): - typ.to_py(cmdline_test.cmd) - def test_complete(self, patch_cmdutils, klass): """Test completion.""" items = klass().complete() From 1c76a51c1e53186a7a3679d54b74b03acd50fe68 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 22:31:11 +0200 Subject: [PATCH 023/186] Improve configtypes.Command docs --- doc/help/settings.asciidoc | 2 +- qutebrowser/config/configtypes.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 243e60023..3fdb32e3c 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -3166,7 +3166,7 @@ This setting is only available with the QtWebKit backend. When setting from a string, `1`, `yes`, `on` and `true` count as true, while `0`, `no`, `off` and `false` count as false (case-insensitive). |BoolAsk|Like `Bool`, but `ask` is allowed as additional value. |ColorSystem|The color system to use for color interpolation. -|Command|Base class for a command value with arguments. +|Command|A qutebrowser command with arguments. |ConfirmQuit|Whether to display a confirmation when the window is closed. |Dict|A dictionary of values. diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index aca9fffda..76fc61a8b 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -773,7 +773,7 @@ class PercOrInt(_Numeric): class Command(BaseType): - """Base class for a command value with arguments. + """A qutebrowser command with arguments. // From 599a5b96482e37b5a8f76bcb73420bb1da8dccce Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 22:47:46 +0200 Subject: [PATCH 024/186] Remove windows/pip instructions from earlyinit Windows: The instructions are outdated and not really relevant anymore with the standalone packages; pip: Let's recommend tox/virtualenv by just linking to the install docs. Closes #2998 --- qutebrowser/misc/earlyinit.py | 53 ++++++++--------------------------- 1 file changed, 11 insertions(+), 42 deletions(-) diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index 032dfc53b..e589cbeef 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -47,33 +47,25 @@ except ImportError: START_TIME = datetime.datetime.now() -def _missing_str(name, *, windows=None, pip=None, webengine=False): +def _missing_str(name, *, webengine=False): """Get an error string for missing packages. Args: name: The name of the package. - windows: String to be displayed for Windows. - pip: pypi package name. webengine: Whether this is checking the QtWebEngine package """ blocks = ["Fatal error: {} is required to run qutebrowser but " "could not be imported! Maybe it's not installed?".format(name), "The error encountered was:
%ERROR%"] lines = ['Please search for the python3 version of {} in your ' - 'distributions packages, or install it via pip.'.format(name)] + 'distributions packages, or see ' + 'https://github.com/qutebrowser/qutebrowser/blob/master/doc/install.asciidoc' + .format(name)] blocks.append('
'.join(lines)) if not webengine: lines = ['If you installed a qutebrowser package for your ' 'distribution, please report this as a bug.'] blocks.append('
'.join(lines)) - if windows is not None: - lines = ["On Windows:"] - lines += windows.splitlines() - blocks.append('
'.join(lines)) - if pip is not None: - lines = ["Using pip:"] - lines.append("pip3 install {}".format(pip)) - blocks.append('
'.join(lines)) return '

'.join(blocks) @@ -142,11 +134,7 @@ def check_pyqt_core(): try: import PyQt5.QtCore # pylint: disable=unused-variable except ImportError as e: - text = _missing_str('PyQt5', - windows="Use the installer by Riverbank computing " - "or the standalone qutebrowser exe.
" - "http://www.riverbankcomputing.co.uk/" - "software/pyqt/download5") + text = _missing_str('PyQt5') text = text.replace('', '') text = text.replace('', '') text = text.replace('
', '\n') @@ -239,31 +227,12 @@ def _check_modules(modules): def check_libraries(): """Check if all needed Python libraries are installed.""" modules = { - 'pkg_resources': - _missing_str("pkg_resources/setuptools", - windows="Run python -m ensurepip."), - 'pypeg2': - _missing_str("pypeg2", - pip="pypeg2"), - 'jinja2': - _missing_str("jinja2", - windows="Install from http://www.lfd.uci.edu/" - "~gohlke/pythonlibs/#jinja2 or via pip.", - pip="jinja2"), - 'pygments': - _missing_str("pygments", - windows="Install from http://www.lfd.uci.edu/" - "~gohlke/pythonlibs/#pygments or via pip.", - pip="pygments"), - 'yaml': - _missing_str("PyYAML", - windows="Use the installers at " - "http://pyyaml.org/download/pyyaml/ (py3.4) " - "or Install via pip.", - pip="PyYAML"), - 'attr': - _missing_str("attrs", - pip="attrs"), + 'pkg_resources': _missing_str("pkg_resources/setuptools"), + 'pypeg2': _missing_str("pypeg2"), + 'jinja2': _missing_str("jinja2"), + 'pygments': _missing_str("pygments"), + 'yaml': _missing_str("PyYAML"), + 'attr': _missing_str("attrs"), 'PyQt5.QtQml': _missing_str("PyQt5.QtQml"), 'PyQt5.QtSql': _missing_str("PyQt5.QtSql"), 'PyQt5.QtOpenGL': _missing_str("PyQt5.QtOpenGL"), From c74236dd96a05829b086f9d8853d1cba3eb606a2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 22:53:43 +0200 Subject: [PATCH 025/186] Move some data from setupcommon to setup.py We can't get rid of setupcommon entirely (it's needed by PyInstaller), but at least we can get the data back to setup.py. Fixes #2996 --- scripts/setupcommon.py | 68 +++--------------------------------------- setup.py | 57 ++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 65 deletions(-) diff --git a/scripts/setupcommon.py b/scripts/setupcommon.py index d5e13ec72..a2e4dfca9 100644 --- a/scripts/setupcommon.py +++ b/scripts/setupcommon.py @@ -18,11 +18,9 @@ # along with qutebrowser. If not, see . -"""Data used by setup.py and scripts/freeze.py.""" +"""Data used by setup.py and the PyInstaller qutebrowser.spec.""" import sys -import re -import ast import os import os.path import subprocess @@ -30,42 +28,16 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) if sys.hexversion >= 0x03000000: - _open = open + open_file = open else: import codecs - _open = codecs.open + open_file = codecs.open BASEDIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), os.path.pardir) -def read_file(name): - """Get the string contained in the file named name.""" - with _open(name, 'r', encoding='utf-8') as f: - return f.read() - - -def _get_constant(name): - """Read a __magic__ constant from qutebrowser/__init__.py. - - We don't import qutebrowser here because it can go wrong for multiple - reasons. Instead we use re/ast to get the value directly from the source - file. - - Args: - name: The name of the argument to get. - - Return: - The value of the argument. - """ - field_re = re.compile(r'__{}__\s+=\s+(.*)'.format(re.escape(name))) - path = os.path.join(BASEDIR, 'qutebrowser', '__init__.py') - line = field_re.search(read_file(path)).group(1) - value = ast.literal_eval(line) - return value - - def _git_str(): """Try to find out git version. @@ -95,37 +67,5 @@ def write_git_file(): if gitstr is None: gitstr = '' path = os.path.join(BASEDIR, 'qutebrowser', 'git-commit-id') - with _open(path, 'w', encoding='ascii') as f: + with open_file(path, 'w', encoding='ascii') as f: f.write(gitstr) - - -setupdata = { - 'name': 'qutebrowser', - 'version': '.'.join(str(e) for e in _get_constant('version_info')), - 'description': _get_constant('description'), - 'long_description': read_file('README.asciidoc'), - 'url': 'https://www.qutebrowser.org/', - 'requires': ['pypeg2', 'jinja2', 'pygments', 'PyYAML', 'attrs'], - 'author': _get_constant('author'), - 'author_email': _get_constant('email'), - 'license': _get_constant('license'), - 'classifiers': [ - 'Development Status :: 3 - Alpha', - 'Environment :: X11 Applications :: Qt', - 'Intended Audience :: End Users/Desktop', - 'License :: OSI Approved :: GNU General Public License v3 or later ' - '(GPLv3+)', - 'Natural Language :: English', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: Microsoft :: Windows :: Windows XP', - 'Operating System :: Microsoft :: Windows :: Windows 7', - 'Operating System :: POSIX :: Linux', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Topic :: Internet', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Internet :: WWW/HTTP :: Browsers', - ], - 'keywords': 'pyqt browser web qt webkit', -} diff --git a/setup.py b/setup.py index 7bfd968f6..c90b163ed 100755 --- a/setup.py +++ b/setup.py @@ -21,6 +21,8 @@ """setuptools installer script for qutebrowser.""" +import re +import ast import os import os.path @@ -35,6 +37,32 @@ except NameError: BASEDIR = None +def read_file(name): + """Get the string contained in the file named name.""" + with common.open_file(name, 'r', encoding='utf-8') as f: + return f.read() + + +def _get_constant(name): + """Read a __magic__ constant from qutebrowser/__init__.py. + + We don't import qutebrowser here because it can go wrong for multiple + reasons. Instead we use re/ast to get the value directly from the source + file. + + Args: + name: The name of the argument to get. + + Return: + The value of the argument. + """ + field_re = re.compile(r'__{}__\s+=\s+(.*)'.format(re.escape(name))) + path = os.path.join(BASEDIR, 'qutebrowser', '__init__.py') + line = field_re.search(read_file(path)).group(1) + value = ast.literal_eval(line) + return value + + try: common.write_git_file() setuptools.setup( @@ -45,7 +73,34 @@ try: test_suite='qutebrowser.test', zip_safe=True, install_requires=['pypeg2', 'jinja2', 'pygments', 'PyYAML', 'attrs'], - **common.setupdata + name='qutebrowser', + version='.'.join(str(e) for e in _get_constant('version_info')), + description=_get_constant('description'), + long_description=read_file('README.asciidoc'), + url='https://www.qutebrowser.org/', + requires=['pypeg2', 'jinja2', 'pygments', 'PyYAML', 'attrs'], + author=_get_constant('author'), + author_email=_get_constant('email'), + license=_get_constant('license'), + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Environment :: X11 Applications :: Qt', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: GNU General Public License v3 or later ' + '(GPLv3+)', + 'Natural Language :: English', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: Microsoft :: Windows :: Windows XP', + 'Operating System :: Microsoft :: Windows :: Windows 7', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Topic :: Internet', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Browsers', + ], + keywords='pyqt browser web qt webkit', ) finally: if BASEDIR is not None: From 3f18a5ada7c585faaaa1c8f9a175a2a8950e1a73 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 22:57:29 +0200 Subject: [PATCH 026/186] Update metainfo in setup.py --- setup.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index c90b163ed..712383839 100755 --- a/setup.py +++ b/setup.py @@ -70,7 +70,6 @@ try: include_package_data=True, entry_points={'gui_scripts': ['qutebrowser = qutebrowser.qutebrowser:main']}, - test_suite='qutebrowser.test', zip_safe=True, install_requires=['pypeg2', 'jinja2', 'pygments', 'PyYAML', 'attrs'], name='qutebrowser', @@ -78,21 +77,20 @@ try: description=_get_constant('description'), long_description=read_file('README.asciidoc'), url='https://www.qutebrowser.org/', - requires=['pypeg2', 'jinja2', 'pygments', 'PyYAML', 'attrs'], author=_get_constant('author'), author_email=_get_constant('email'), license=_get_constant('license'), classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Environment :: X11 Applications :: Qt', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: GNU General Public License v3 or later ' '(GPLv3+)', 'Natural Language :: English', 'Operating System :: Microsoft :: Windows', - 'Operating System :: Microsoft :: Windows :: Windows XP', - 'Operating System :: Microsoft :: Windows :: Windows 7', 'Operating System :: POSIX :: Linux', + 'Operating System :: MacOS', + 'Operating System :: POSIX :: BSD', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', @@ -100,7 +98,7 @@ try: 'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP :: Browsers', ], - keywords='pyqt browser web qt webkit', + keywords='pyqt browser web qt webkit qtwebkit qtwebengine', ) finally: if BASEDIR is not None: From cd9fe57d84c102213bffbcfb8bbab2f0270e33eb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 23:03:02 +0200 Subject: [PATCH 027/186] build_release: Also run asciidoc2html on Linux --- scripts/dev/build_release.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index a51c8f2b3..0019752c2 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -354,6 +354,7 @@ def main(): import github3 # pylint: disable=unused-variable read_github_token() + run_asciidoc2html(args) if utils.is_windows: if sys.maxsize > 2**32: # WORKAROUND @@ -363,10 +364,8 @@ def main(): print("See http://bugs.python.org/issue24493 and ") print("https://github.com/pypa/virtualenv/issues/774") sys.exit(1) - run_asciidoc2html(args) artifacts = build_windows() elif utils.is_mac: - run_asciidoc2html(args) artifacts = build_mac() else: artifacts = build_sdist() From f4017eb5b6428071aceaa5f4023d8e59bf9c7fa8 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 23:24:22 +0200 Subject: [PATCH 028/186] Ignore more Python warnings when importing in earlyinit With a17c4767d6de3259b295ed7c15e81f8279df165a we moved the first time pkg_resources is imported to earlyinit.py, which means less warnings were suppressed. Fixes #2990 --- qutebrowser/misc/earlyinit.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index e589cbeef..c2ab454ac 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -218,7 +218,14 @@ def _check_modules(modules): 'Flags not at the start of the expression'] with log.ignore_py_warnings( category=DeprecationWarning, - message=r'({})'.format('|'.join(messages))): + message=r'({})'.format('|'.join(messages)) + ), log.ignore_py_warnings( + category=PendingDeprecationWarning, + module='imp' + ), log.ignore_py_warnings( + category=ImportWarning, + message=r'Not importing directory .*: missing __init__' + ): importlib.import_module(name) except ImportError as e: _die(text, e) From c652b0f96c3771fddd15f8a680cf2048d4ebdff5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 21 Sep 2017 23:59:16 +0200 Subject: [PATCH 029/186] Remove old monkeypatch --- tests/unit/commands/test_runners.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/commands/test_runners.py b/tests/unit/commands/test_runners.py index 5e9aa3dfc..4e97c2385 100644 --- a/tests/unit/commands/test_runners.py +++ b/tests/unit/commands/test_runners.py @@ -47,7 +47,6 @@ class TestCommandParser: if not cmdline_test.cmd: pytest.skip("Empty command") - monkeypatch.setattr(configtypes.Command, 'unvalidated', True) config_stub.val.aliases = {'alias_name': cmdline_test.cmd} parser = runners.CommandParser() From a2952e13a896fffbb16bc31549d8919bbd4f40e8 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Fri, 15 Sep 2017 20:43:17 -0400 Subject: [PATCH 030/186] Add qutebrowser config directory to python path This is done so config.py can import other python files in the config directory. For example, config.py can 'import theme' which would load a theme.py. The previous path is restored at the end of this function, to avoid tainting qutebrowser's path --- qutebrowser/config/configfiles.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index a155726a8..155a2c99f 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -21,6 +21,7 @@ import types import os.path +import sys import textwrap import traceback import configparser @@ -222,6 +223,12 @@ def read_config_py(filename=None): if not os.path.exists(filename): return api + # Add config directory to python path, so config.py can import other files + # in logical places + old_path = sys.path.copy() + if standarddir.config() not in sys.path: + sys.path.insert(0, standarddir.config()) + container = config.ConfigContainer(config.instance, configapi=api) basename = os.path.basename(filename) @@ -256,6 +263,9 @@ def read_config_py(filename=None): "Unhandled exception", exception=e, traceback=traceback.format_exc())) + # Restore previous path, to protect qutebrowser's imports + sys.path = old_path + api.finalize() return api From 0332dce458e249e73efd61cdf556d60f1062406c Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Sat, 16 Sep 2017 01:07:13 -0400 Subject: [PATCH 031/186] Get config path from config.py location, rather than standarddir --- qutebrowser/config/configfiles.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 155a2c99f..450b7b644 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -226,8 +226,9 @@ def read_config_py(filename=None): # Add config directory to python path, so config.py can import other files # in logical places old_path = sys.path.copy() - if standarddir.config() not in sys.path: - sys.path.insert(0, standarddir.config()) + config_dir = os.path.dirname(filename) + if config_dir not in sys.path: + sys.path.insert(0, config_dir) container = config.ConfigContainer(config.instance, configapi=api) basename = os.path.basename(filename) From 333c0d848b9564ed000b66774b63e3014277f451 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Sat, 16 Sep 2017 23:22:19 -0400 Subject: [PATCH 032/186] Restructure save/load of state to be more extensible Also save/load sys.modules as well - This is a little rough, but I can't find a better way... --- qutebrowser/config/configfiles.py | 33 +++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 450b7b644..57d6e851b 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -225,10 +225,10 @@ def read_config_py(filename=None): # Add config directory to python path, so config.py can import other files # in logical places - old_path = sys.path.copy() + old_state = _pre_config_save() config_dir = os.path.dirname(filename) if config_dir not in sys.path: - sys.path.insert(0, config_dir) + sys.path = [config_dir] + sys.path container = config.ConfigContainer(config.instance, configapi=api) basename = os.path.basename(filename) @@ -238,6 +238,20 @@ def read_config_py(filename=None): module.c = container module.__file__ = filename + try: + _run_python_config_helper(filename, basename, api, module) + except: + _post_config_load(old_state) + raise + + # Restore previous path, to protect qutebrowser's imports + _post_config_load(old_state) + + api.finalize() + return api + + +def _run_python_config_helper(filename, basename, api, module): try: with open(filename, mode='rb') as f: source = f.read() @@ -264,11 +278,18 @@ def read_config_py(filename=None): "Unhandled exception", exception=e, traceback=traceback.format_exc())) - # Restore previous path, to protect qutebrowser's imports - sys.path = old_path - api.finalize() - return api +def _pre_config_save(): + old_path = sys.path + old_modules = sys.modules.copy() + return (old_path, old_modules) + + +def _post_config_load(save_tuple): + sys.path = save_tuple[0] + for module in set(sys.modules).difference(save_tuple[1]): + del sys.modules[module] + pass def init(): From 7ddde334dacaa21661f41de893864319b2245b8a Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Sun, 17 Sep 2017 01:18:20 -0400 Subject: [PATCH 033/186] Add tests for module/path isolation --- qutebrowser/config/configfiles.py | 1 - tests/unit/config/test_configfiles.py | 138 ++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 57d6e851b..91dafe172 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -289,7 +289,6 @@ def _post_config_load(save_tuple): sys.path = save_tuple[0] for module in set(sys.modules).difference(save_tuple[1]): del sys.modules[module] - pass def init(): diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 975e9c46a..0e782efd7 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -207,6 +207,144 @@ class TestYaml: assert error.traceback is None +class TestConfigPyModules: + + """Test for ConfigPy Modules.""" + + pytestmark = pytest.mark.usefixtures('config_stub', 'key_config_stub') + _old_sys_path = sys.path.copy() + + class ConfPy: + + """Helper class to get a confpy fixture.""" + + def __init__(self, tmpdir): + self._confpy = tmpdir / 'config.py' + self.filename = str(self._confpy) + + def write(self, *lines): + text = '\n'.join(lines) + self._confpy.write_text(text, 'utf-8', ensure=True) + + class QbModulePy: + + """Helper class to get a QbModulePy fixture.""" + + def __init__(self, tmpdir): + self._qbmodulepy = tmpdir / 'qbmodule.py' + self.filename = str(self._qbmodulepy) + + def write(self, *lines): + text = '\n'.join(lines) + self._qbmodulepy.write_text(text, 'utf-8', ensure=True) + + @pytest.fixture + def confpy(self, tmpdir): + return self.ConfPy(tmpdir) + + @pytest.fixture + def qbmodulepy(self, tmpdir): + return self.QbModulePy(tmpdir) + + def setup_method(self, method): + # If we plan to add tests that modify modules themselves, that should + # be saved as well + TestConfigPyModules._old_sys_path = sys.path.copy() + + def teardown_method(self, method): + # Restore path to save the rest of the tests + sys.path = TestConfigPyModules._old_sys_path + + def test_bind_in_module(self, confpy, qbmodulepy): + qbmodulepy.write("""def run(config): + config.bind(",a", "message-info foo", mode="normal")""") + confpy.write("""import qbmodule +qbmodule.run(config)""") + api = configfiles.read_config_py(confpy.filename) + expected = {'normal': {',a': 'message-info foo'}} + assert len(api.errors) == 0 + assert config.instance._values['bindings.commands'] == expected + + def test_clear_path(self, confpy, qbmodulepy, tmpdir): + qbmodulepy.write("""def run(config): + config.bind(",a", "message-info foo", mode="normal")""") + confpy.write("""import qbmodule +qbmodule.run(config)""") + api = configfiles.read_config_py(confpy.filename) + assert len(api.errors) == 0 + assert tmpdir not in sys.path + + def test_clear_modules(self, confpy, qbmodulepy): + qbmodulepy.write("""def run(config): + config.bind(",a", "message-info foo", mode="normal")""") + confpy.write("""import qbmodule +qbmodule.run(config)""") + api = configfiles.read_config_py(confpy.filename) + assert len(api.errors) == 0 + assert "qbmodule" not in sys.modules.keys() + + def test_clear_modules_on_err(self, confpy, qbmodulepy): + qbmodulepy.write("""def run(config): + 1/0""") + confpy.write("""import qbmodule +qbmodule.run(config)""") + api = configfiles.read_config_py(confpy.filename) + + assert len(api.errors) == 1 + error = api.errors[0] + assert error.text == "Unhandled exception" + assert isinstance(error.exception, ZeroDivisionError) + + tblines = error.traceback.strip().splitlines() + assert tblines[0] == "Traceback (most recent call last):" + assert tblines[-1] == "ZeroDivisionError: division by zero" + assert " 1/0" in tblines + assert "qbmodule" not in sys.modules.keys() + + def test_clear_path_on_err(self, confpy, qbmodulepy, tmpdir): + qbmodulepy.write("""def run(config): + 1/0""") + confpy.write("""import qbmodule +qbmodule.run(config)""") + api = configfiles.read_config_py(confpy.filename) + + assert len(api.errors) == 1 + error = api.errors[0] + assert error.text == "Unhandled exception" + assert isinstance(error.exception, ZeroDivisionError) + + tblines = error.traceback.strip().splitlines() + assert tblines[0] == "Traceback (most recent call last):" + assert tblines[-1] == "ZeroDivisionError: division by zero" + assert " 1/0" in tblines + assert tmpdir not in sys.path + + def test_fail_on_nonexistent_module(self, confpy, qbmodulepy, tmpdir): + qbmodulepy.write("""def run(config): + pass""") + confpy.write("""import foobar +foobar.run(config)""") + api = configfiles.read_config_py(confpy.filename) + + assert len(api.errors) == 1 + error = api.errors[0] + assert error.text == "Unhandled exception" + assert isinstance(error.exception, ImportError) + + tblines = error.traceback.strip().splitlines() + assert tblines[0] == "Traceback (most recent call last):" + assert tblines[-1].endswith("Error: No module named 'foobar'") + + def test_no_double_if_path_exists(self, confpy, qbmodulepy, tmpdir): + sys.path.insert(0, tmpdir) + confpy.write("""import sys +if sys.path[1:].count(sys.path[0]) != 0: + raise Exception('Path not expected')""") + api = configfiles.read_config_py(confpy.filename) + assert len(api.errors) == 0 + assert sys.path.count(tmpdir) == 1 + + class TestConfigPy: """Tests for ConfigAPI and read_config_py().""" From 4e22b4666d79b6b7f4f9b56aff12d72dae42217d Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Wed, 20 Sep 2017 21:26:56 -0400 Subject: [PATCH 034/186] Convert save-restore of sys to a context-manager Also improve and simplify tests for save/load of sys.module and sys.path --- qutebrowser/config/configfiles.py | 53 +++++------ tests/unit/config/test_configfiles.py | 130 ++++++-------------------- 2 files changed, 54 insertions(+), 129 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 91dafe172..561eec0eb 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -223,13 +223,6 @@ def read_config_py(filename=None): if not os.path.exists(filename): return api - # Add config directory to python path, so config.py can import other files - # in logical places - old_state = _pre_config_save() - config_dir = os.path.dirname(filename) - if config_dir not in sys.path: - sys.path = [config_dir] + sys.path - container = config.ConfigContainer(config.instance, configapi=api) basename = os.path.basename(filename) @@ -238,20 +231,6 @@ def read_config_py(filename=None): module.c = container module.__file__ = filename - try: - _run_python_config_helper(filename, basename, api, module) - except: - _post_config_load(old_state) - raise - - # Restore previous path, to protect qutebrowser's imports - _post_config_load(old_state) - - api.finalize() - return api - - -def _run_python_config_helper(filename, basename, api, module): try: with open(filename, mode='rb') as f: source = f.read() @@ -268,27 +247,41 @@ def _run_python_config_helper(filename, basename, api, module): raise configexc.ConfigFileErrors(basename, [desc]) except SyntaxError as e: desc = configexc.ConfigErrorDesc("Syntax Error", e, - traceback=traceback.format_exc()) + traceback=traceback.format_exc()) raise configexc.ConfigFileErrors(basename, [desc]) try: - exec(code, module.__dict__) + # Save and restore sys variables + with saved_sys_properties(): + # Add config directory to python path, so config.py can import + # other files in logical places + config_dir = os.path.dirname(filename) + if config_dir not in sys.path: + sys.path = [config_dir] + sys.path + + exec(code, module.__dict__) except Exception as e: api.errors.append(configexc.ConfigErrorDesc( "Unhandled exception", exception=e, traceback=traceback.format_exc())) + api.finalize() + return api -def _pre_config_save(): +@contextlib.contextmanager +def saved_sys_properties(): + """Save various sys properties such as sys.path and sys.modules.""" old_path = sys.path old_modules = sys.modules.copy() - return (old_path, old_modules) - -def _post_config_load(save_tuple): - sys.path = save_tuple[0] - for module in set(sys.modules).difference(save_tuple[1]): - del sys.modules[module] + try: + yield + except: + raise + finally: + sys.path = old_path + for module in set(sys.modules).difference(old_modules): + del sys.modules[module] def init(): diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 0e782efd7..2d8d4cd94 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -209,121 +209,50 @@ class TestYaml: class TestConfigPyModules: - """Test for ConfigPy Modules.""" - pytestmark = pytest.mark.usefixtures('config_stub', 'key_config_stub') - _old_sys_path = sys.path.copy() - - class ConfPy: - - """Helper class to get a confpy fixture.""" - - def __init__(self, tmpdir): - self._confpy = tmpdir / 'config.py' - self.filename = str(self._confpy) - - def write(self, *lines): - text = '\n'.join(lines) - self._confpy.write_text(text, 'utf-8', ensure=True) - - class QbModulePy: - - """Helper class to get a QbModulePy fixture.""" - - def __init__(self, tmpdir): - self._qbmodulepy = tmpdir / 'qbmodule.py' - self.filename = str(self._qbmodulepy) - - def write(self, *lines): - text = '\n'.join(lines) - self._qbmodulepy.write_text(text, 'utf-8', ensure=True) @pytest.fixture def confpy(self, tmpdir): - return self.ConfPy(tmpdir) + return TestConfigPy.ConfPy(tmpdir) @pytest.fixture def qbmodulepy(self, tmpdir): - return self.QbModulePy(tmpdir) + return TestConfigPy.ConfPy(tmpdir, filename="qbmodule.py") - def setup_method(self, method): - # If we plan to add tests that modify modules themselves, that should - # be saved as well - TestConfigPyModules._old_sys_path = sys.path.copy() + @pytest.fixture(autouse=True) + def restore_sys_path(self): + old_path = sys.path.copy() + yield + sys.path = old_path - def teardown_method(self, method): - # Restore path to save the rest of the tests - sys.path = TestConfigPyModules._old_sys_path - - def test_bind_in_module(self, confpy, qbmodulepy): - qbmodulepy.write("""def run(config): - config.bind(",a", "message-info foo", mode="normal")""") - confpy.write("""import qbmodule -qbmodule.run(config)""") - api = configfiles.read_config_py(confpy.filename) + def test_bind_in_module(self, confpy, qbmodulepy, tmpdir): + qbmodulepy.write('def run(config):', + ' config.bind(",a", "message-info foo", mode="normal")') + confpy.write_qbmodule() + confpy.read() expected = {'normal': {',a': 'message-info foo'}} - assert len(api.errors) == 0 assert config.instance._values['bindings.commands'] == expected - - def test_clear_path(self, confpy, qbmodulepy, tmpdir): - qbmodulepy.write("""def run(config): - config.bind(",a", "message-info foo", mode="normal")""") - confpy.write("""import qbmodule -qbmodule.run(config)""") - api = configfiles.read_config_py(confpy.filename) - assert len(api.errors) == 0 + assert "qbmodule" not in sys.modules.keys() assert tmpdir not in sys.path - def test_clear_modules(self, confpy, qbmodulepy): - qbmodulepy.write("""def run(config): - config.bind(",a", "message-info foo", mode="normal")""") - confpy.write("""import qbmodule -qbmodule.run(config)""") - api = configfiles.read_config_py(confpy.filename) - assert len(api.errors) == 0 - assert "qbmodule" not in sys.modules.keys() - - def test_clear_modules_on_err(self, confpy, qbmodulepy): - qbmodulepy.write("""def run(config): - 1/0""") - confpy.write("""import qbmodule -qbmodule.run(config)""") + def test_restore_sys_on_err(self, confpy, qbmodulepy, tmpdir): + confpy.write_qbmodule() + qbmodulepy.write('def run(config):', + ' 1/0') api = configfiles.read_config_py(confpy.filename) assert len(api.errors) == 1 error = api.errors[0] assert error.text == "Unhandled exception" assert isinstance(error.exception, ZeroDivisionError) - - tblines = error.traceback.strip().splitlines() - assert tblines[0] == "Traceback (most recent call last):" - assert tblines[-1] == "ZeroDivisionError: division by zero" - assert " 1/0" in tblines assert "qbmodule" not in sys.modules.keys() - - def test_clear_path_on_err(self, confpy, qbmodulepy, tmpdir): - qbmodulepy.write("""def run(config): - 1/0""") - confpy.write("""import qbmodule -qbmodule.run(config)""") - api = configfiles.read_config_py(confpy.filename) - - assert len(api.errors) == 1 - error = api.errors[0] - assert error.text == "Unhandled exception" - assert isinstance(error.exception, ZeroDivisionError) - - tblines = error.traceback.strip().splitlines() - assert tblines[0] == "Traceback (most recent call last):" - assert tblines[-1] == "ZeroDivisionError: division by zero" - assert " 1/0" in tblines assert tmpdir not in sys.path def test_fail_on_nonexistent_module(self, confpy, qbmodulepy, tmpdir): - qbmodulepy.write("""def run(config): - pass""") - confpy.write("""import foobar -foobar.run(config)""") + qbmodulepy.write('def run(config):', + ' pass') + confpy.write('import foobar', + 'foobar.run(config)') api = configfiles.read_config_py(confpy.filename) assert len(api.errors) == 1 @@ -337,11 +266,10 @@ foobar.run(config)""") def test_no_double_if_path_exists(self, confpy, qbmodulepy, tmpdir): sys.path.insert(0, tmpdir) - confpy.write("""import sys -if sys.path[1:].count(sys.path[0]) != 0: - raise Exception('Path not expected')""") - api = configfiles.read_config_py(confpy.filename) - assert len(api.errors) == 0 + confpy.write('import sys', + 'if sys.path[0] in sys.path[1:]:', + ' raise Exception("Path not expected")') + confpy.read() assert sys.path.count(tmpdir) == 1 @@ -355,8 +283,8 @@ class TestConfigPy: """Helper class to get a confpy fixture.""" - def __init__(self, tmpdir): - self._confpy = tmpdir / 'config.py' + def __init__(self, tmpdir, filename: str = "config.py"): + self._confpy = tmpdir / filename self.filename = str(self._confpy) def write(self, *lines): @@ -368,6 +296,10 @@ class TestConfigPy: api = configfiles.read_config_py(self.filename) assert not api.errors + def write_qbmodule(self): + self.write('import qbmodule', + 'qbmodule.run(config)') + @pytest.fixture def confpy(self, tmpdir): return self.ConfPy(tmpdir) From 43ce10efc34188825421f1db94b60117b46b57b3 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Thu, 21 Sep 2017 10:17:25 -0400 Subject: [PATCH 035/186] Simplify and reorganize configfile tests Also make save/load of sys.path a little more robust --- qutebrowser/config/configfiles.py | 9 +++-- tests/unit/config/test_configfiles.py | 50 ++++++++++++++------------- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 561eec0eb..2ee973730 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -247,7 +247,7 @@ def read_config_py(filename=None): raise configexc.ConfigFileErrors(basename, [desc]) except SyntaxError as e: desc = configexc.ConfigErrorDesc("Syntax Error", e, - traceback=traceback.format_exc()) + traceback=traceback.format_exc()) raise configexc.ConfigFileErrors(basename, [desc]) try: @@ -257,13 +257,14 @@ def read_config_py(filename=None): # other files in logical places config_dir = os.path.dirname(filename) if config_dir not in sys.path: - sys.path = [config_dir] + sys.path + sys.path.insert(0, config_dir) exec(code, module.__dict__) except Exception as e: api.errors.append(configexc.ConfigErrorDesc( "Unhandled exception", exception=e, traceback=traceback.format_exc())) + api.finalize() return api @@ -271,13 +272,11 @@ def read_config_py(filename=None): @contextlib.contextmanager def saved_sys_properties(): """Save various sys properties such as sys.path and sys.modules.""" - old_path = sys.path + old_path = sys.path.copy() old_modules = sys.modules.copy() try: yield - except: - raise finally: sys.path = old_path for module in set(sys.modules).difference(old_modules): diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 2d8d4cd94..bf214fed3 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -19,6 +19,7 @@ """Tests for qutebrowser.config.configfiles.""" import os +import sys import pytest @@ -207,17 +208,39 @@ class TestYaml: assert error.traceback is None +class ConfPy: + + """Helper class to get a confpy fixture.""" + + def __init__(self, tmpdir, filename: str = "config.py"): + self._file = tmpdir / filename + self.filename = str(self._file) + + def write(self, *lines): + text = '\n'.join(lines) + self._file.write_text(text, 'utf-8', ensure=True) + + def read(self): + """Read the config.py via configfiles and check for errors.""" + api = configfiles.read_config_py(self.filename) + assert not api.errors + + def write_qbmodule(self): + self.write('import qbmodule', + 'qbmodule.run(config)') + + class TestConfigPyModules: pytestmark = pytest.mark.usefixtures('config_stub', 'key_config_stub') @pytest.fixture def confpy(self, tmpdir): - return TestConfigPy.ConfPy(tmpdir) + return ConfPy(tmpdir) @pytest.fixture def qbmodulepy(self, tmpdir): - return TestConfigPy.ConfPy(tmpdir, filename="qbmodule.py") + return ConfPy(tmpdir, filename="qbmodule.py") @pytest.fixture(autouse=True) def restore_sys_path(self): @@ -279,30 +302,9 @@ class TestConfigPy: pytestmark = pytest.mark.usefixtures('config_stub', 'key_config_stub') - class ConfPy: - - """Helper class to get a confpy fixture.""" - - def __init__(self, tmpdir, filename: str = "config.py"): - self._confpy = tmpdir / filename - self.filename = str(self._confpy) - - def write(self, *lines): - text = '\n'.join(lines) - self._confpy.write_text(text, 'utf-8', ensure=True) - - def read(self): - """Read the config.py via configfiles and check for errors.""" - api = configfiles.read_config_py(self.filename) - assert not api.errors - - def write_qbmodule(self): - self.write('import qbmodule', - 'qbmodule.run(config)') - @pytest.fixture def confpy(self, tmpdir): - return self.ConfPy(tmpdir) + return ConfPy(tmpdir) @pytest.mark.parametrize('line', [ 'c.colors.hints.bg = "red"', From 10016ae240863d68949681f789a84d129698b7a1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 08:23:06 +0200 Subject: [PATCH 036/186] Remove unused import --- tests/unit/commands/test_runners.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/commands/test_runners.py b/tests/unit/commands/test_runners.py index 4e97c2385..75558b390 100644 --- a/tests/unit/commands/test_runners.py +++ b/tests/unit/commands/test_runners.py @@ -22,7 +22,6 @@ import pytest from qutebrowser.commands import runners, cmdexc -from qutebrowser.config import configtypes class TestCommandParser: From 1dbd156c2f34bd9b2bdba47bbae0083493d2d0a1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 08:43:07 +0200 Subject: [PATCH 037/186] Simplify some config.py tests --- tests/unit/config/test_configfiles.py | 54 ++++++++++++--------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index bf214fed3..4f1aba9d4 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -220,10 +220,11 @@ class ConfPy: text = '\n'.join(lines) self._file.write_text(text, 'utf-8', ensure=True) - def read(self): + def read(self, error=False): """Read the config.py via configfiles and check for errors.""" api = configfiles.read_config_py(self.filename) - assert not api.errors + assert len(api.errors) == (1 if error else 0) + return api def write_qbmodule(self): self.write('import qbmodule', @@ -262,10 +263,9 @@ class TestConfigPyModules: confpy.write_qbmodule() qbmodulepy.write('def run(config):', ' 1/0') - api = configfiles.read_config_py(confpy.filename) - - assert len(api.errors) == 1 + api = confpy.read(error=True) error = api.errors[0] + assert error.text == "Unhandled exception" assert isinstance(error.exception, ZeroDivisionError) assert "qbmodule" not in sys.modules.keys() @@ -276,10 +276,10 @@ class TestConfigPyModules: ' pass') confpy.write('import foobar', 'foobar.run(config)') - api = configfiles.read_config_py(confpy.filename) - assert len(api.errors) == 1 + api = confpy.read(error=True) error = api.errors[0] + assert error.text == "Unhandled exception" assert isinstance(error.exception, ImportError) @@ -306,6 +306,13 @@ class TestConfigPy: def confpy(self, tmpdir): return ConfPy(tmpdir) + def test_assertions(self, confpy): + """Make sure assertions in config.py work for these tests.""" + confpy.write('assert False') + api = confpy.read(error=True) + error = api.errors[0] + assert isinstance(error.exception, AssertionError) + @pytest.mark.parametrize('line', [ 'c.colors.hints.bg = "red"', 'config.set("colors.hints.bg", "red")', @@ -321,25 +328,15 @@ class TestConfigPy: 'config.get("colors.hints.fg")', ]) def test_get(self, confpy, set_first, get_line): - """Test whether getting options works correctly. - - We test this by doing the following: - - Set colors.hints.fg to some value (inside the config.py with - set_first, outside of it otherwise). - - In the config.py, read .fg and set .bg to the same value. - - Verify that .bg has been set correctly. - """ + """Test whether getting options works correctly.""" # pylint: disable=bad-config-option config.val.colors.hints.fg = 'green' if set_first: confpy.write('c.colors.hints.fg = "red"', - 'c.colors.hints.bg = {}'.format(get_line)) - expected = 'red' + 'assert {} == "red"'.format(get_line)) else: - confpy.write('c.colors.hints.bg = {}'.format(get_line)) - expected = 'green' + confpy.write('assert {} == "green"'.format(get_line)) confpy.read() - assert config.instance._values['colors.hints.bg'] == expected @pytest.mark.parametrize('line, mode', [ ('config.bind(",a", "message-info foo")', 'normal'), @@ -430,12 +427,11 @@ class TestConfigPy: def test_unhandled_exception(self, confpy): confpy.write("config.load_autoconfig = False", "1/0") - api = configfiles.read_config_py(confpy.filename) + + api = confpy.read(error=True) + error = api.errors[0] assert not api.load_autoconfig - - assert len(api.errors) == 1 - error = api.errors[0] assert error.text == "Unhandled exception" assert isinstance(error.exception, ZeroDivisionError) @@ -447,9 +443,9 @@ class TestConfigPy: def test_config_val(self, confpy): """Using config.val should not work in config.py files.""" confpy.write("config.val.colors.hints.bg = 'red'") - api = configfiles.read_config_py(confpy.filename) - assert len(api.errors) == 1 + api = confpy.read(error=True) error = api.errors[0] + assert error.text == "Unhandled exception" assert isinstance(error.exception, AttributeError) message = "'ConfigAPI' object has no attribute 'val'" @@ -458,12 +454,10 @@ class TestConfigPy: @pytest.mark.parametrize('line', ["c.foo = 42", "config.set('foo', 42)"]) def test_config_error(self, confpy, line): confpy.write(line, "config.load_autoconfig = False") - api = configfiles.read_config_py(confpy.filename) + api = confpy.read(error=True) + error = api.errors[0] assert not api.load_autoconfig - - assert len(api.errors) == 1 - error = api.errors[0] assert error.text == "While setting 'foo'" assert isinstance(error.exception, configexc.NoOptionError) assert str(error.exception) == "No option 'foo'" From ebf378a945f99eec7e07e96e6f593c1e16949b87 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 08:58:41 +0200 Subject: [PATCH 038/186] Add docs about importing modules in config.py --- doc/help/configuring.asciidoc | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index dd2612045..3425fc924 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -211,6 +211,20 @@ doing: config.load_autoconfig = False ---- +Importing other modules +~~~~~~~~~~~~~~~~~~~~~~~ + +You can import any module from the +https://docs.python.org/3/library/index.html[Python standard library] (e.g. +`import os.path`), as well as any module installed in the environment +qutebrowser is run with. + +If you have an `utils.py` file in your qutebrowser config folder, you can import +that via `import utils` as well. + +While it's in some cases possible to import code from the qutebrowser +installation, doing so is unsupported and discouraged. + Handling errors ~~~~~~~~~~~~~~~ From 9b22480b07e50bd3138ee3fe6f858448659151ee Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 09:09:45 +0200 Subject: [PATCH 039/186] Raise config.py errors happening in tests --- qutebrowser/config/configfiles.py | 11 +++++++++-- tests/unit/config/test_configfiles.py | 7 +++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 2ee973730..55c6d7e3a 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -214,8 +214,13 @@ class ConfigAPI: self._keyconfig.unbind(key, mode=mode) -def read_config_py(filename=None): - """Read a config.py file.""" +def read_config_py(filename=None, raising=False): + """Read a config.py file. + + Arguments; + raising: Raise exceptions happening in config.py. + This is needed during tests to use pytest's inspection. + """ api = ConfigAPI(config.instance, config.key_instance) if filename is None: @@ -261,6 +266,8 @@ def read_config_py(filename=None): exec(code, module.__dict__) except Exception as e: + if raising: + raise api.errors.append(configexc.ConfigErrorDesc( "Unhandled exception", exception=e, traceback=traceback.format_exc())) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 4f1aba9d4..5661c3ff9 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -222,7 +222,7 @@ class ConfPy: def read(self, error=False): """Read the config.py via configfiles and check for errors.""" - api = configfiles.read_config_py(self.filename) + api = configfiles.read_config_py(self.filename, raising=not error) assert len(api.errors) == (1 if error else 0) return api @@ -309,9 +309,8 @@ class TestConfigPy: def test_assertions(self, confpy): """Make sure assertions in config.py work for these tests.""" confpy.write('assert False') - api = confpy.read(error=True) - error = api.errors[0] - assert isinstance(error.exception, AssertionError) + with pytest.raises(AssertionError): + confpy.read() # no errors=True so it gets raised @pytest.mark.parametrize('line', [ 'c.colors.hints.bg = "red"', From 7f8ae531aa43350c22b19d1389bac014f4fb3a28 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 09:57:06 +0200 Subject: [PATCH 040/186] Add config.configdir and config.datadir to config API. Fixes #1419 --- doc/help/configuring.asciidoc | 17 +++++++++++++++++ qutebrowser/config/configfiles.py | 5 +++++ tests/unit/config/test_configfiles.py | 19 ++++++++++++++----- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 3425fc924..23368a5ff 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -225,6 +225,23 @@ that via `import utils` as well. While it's in some cases possible to import code from the qutebrowser installation, doing so is unsupported and discouraged. +Getting the config directory +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you need to get the qutebrowser config directory, you can do so by reading +`config.configdir`. Similarily, you can get the qutebrowser data directory via +`config.datadir`. + +This gives you a https://docs.python.org/3/library/pathlib.html[`pathlib.Path` +object], on which you can use `/` to add more directory parts, or `str(...)` to +get a string: + +.config.py: +[source,python] +---- +print(str(config.configdir / 'config.py') +---- + Handling errors ~~~~~~~~~~~~~~~ diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 55c6d7e3a..622280ee7 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -19,6 +19,7 @@ """Configuration files residing on disk.""" +import pathlib import types import os.path import sys @@ -177,6 +178,8 @@ class ConfigAPI: _keyconfig: The KeyConfig object. load_autoconfig: Whether autoconfig.yml should be loaded. errors: Errors which occurred while setting options. + configdir: The qutebrowser config directory, as pathlib.Path. + datadir: The qutebrowser data directory, as pathlib.Path. """ def __init__(self, conf, keyconfig): @@ -184,6 +187,8 @@ class ConfigAPI: self._keyconfig = keyconfig self.load_autoconfig = True self.errors = [] + self.configdir = pathlib.Path(standarddir.config()) + self.datadir = pathlib.Path(standarddir.data()) @contextlib.contextmanager def _handle_error(self, action, name): diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 5661c3ff9..ed5d2b882 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -236,7 +236,7 @@ class TestConfigPyModules: pytestmark = pytest.mark.usefixtures('config_stub', 'key_config_stub') @pytest.fixture - def confpy(self, tmpdir): + def confpy(self, tmpdir, config_tmpdir, data_tmpdir): return ConfPy(tmpdir) @pytest.fixture @@ -303,7 +303,7 @@ class TestConfigPy: pytestmark = pytest.mark.usefixtures('config_stub', 'key_config_stub') @pytest.fixture - def confpy(self, tmpdir): + def confpy(self, tmpdir, config_tmpdir, data_tmpdir): return ConfPy(tmpdir) def test_assertions(self, confpy): @@ -312,6 +312,14 @@ class TestConfigPy: with pytest.raises(AssertionError): confpy.read() # no errors=True so it gets raised + @pytest.mark.parametrize('what', ['configdir', 'datadir']) + def test_getting_dirs(self, confpy, what): + confpy.write('import pathlib', + 'directory = config.{}'.format(what), + 'assert isinstance(directory, pathlib.Path)', + 'assert directory.exists()') + confpy.read() + @pytest.mark.parametrize('line', [ 'c.colors.hints.bg = "red"', 'config.set("colors.hints.bg", "red")', @@ -373,17 +381,18 @@ class TestConfigPy: assert config.instance._values['aliases']['foo'] == 'message-info foo' assert config.instance._values['aliases']['bar'] == 'message-info bar' - def test_reading_default_location(self, config_tmpdir): + def test_reading_default_location(self, config_tmpdir, data_tmpdir): (config_tmpdir / 'config.py').write_text( 'c.colors.hints.bg = "red"', 'utf-8') configfiles.read_config_py() assert config.instance._values['colors.hints.bg'] == 'red' - def test_reading_missing_default_location(self, config_tmpdir): + def test_reading_missing_default_location(self, config_tmpdir, + data_tmpdir): assert not (config_tmpdir / 'config.py').exists() configfiles.read_config_py() # Should not crash - def test_oserror(self, tmpdir): + def test_oserror(self, tmpdir, data_tmpdir, config_tmpdir): with pytest.raises(configexc.ConfigFileErrors) as excinfo: configfiles.read_config_py(str(tmpdir / 'foo')) From 43ab27634fd4c993ceb2923c26de71e0ae1367d4 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 11:07:54 +0200 Subject: [PATCH 041/186] Fix vulture --- scripts/dev/run_vulture.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py index 3e88da43b..ab93ab842 100755 --- a/scripts/dev/run_vulture.py +++ b/scripts/dev/run_vulture.py @@ -108,6 +108,8 @@ def whitelist_generator(): yield 'qutebrowser.config.configexc.ConfigErrorDesc.traceback' yield 'qutebrowser.config.configfiles.ConfigAPI.load_autoconfig' yield 'types.ModuleType.c' # configfiles:read_config_py + for name in ['configdir', 'datadir']: + yield 'qutebrowser.config.configfiles.ConfigAPI.' + name yield 'include_aliases' From d9a32684054b8c5f7b7cf7bd38294d655ed76744 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 11:33:42 +0200 Subject: [PATCH 042/186] Explain relationship between 'c' and 'config.set' better [ci skip] --- doc/help/configuring.asciidoc | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 23368a5ff..38bc8ede7 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -88,7 +88,9 @@ Two global objects are pre-defined when running `config.py`: `c` and `config`. Changing settings ~~~~~~~~~~~~~~~~~ -`c` is a shorthand object to easily set settings like this: +While you can set settings using the `config.set()` method (which is explained +in the next section), it's easier to use the `c` shorthand object to easily set +settings like this: .config.py: [source,python] @@ -136,6 +138,8 @@ If you want to set settings based on their name as a string, use the .config.py: [source,python] ---- +# Equivalent to: +# c.content.javascript.enabled = False config.set('content.javascript.enabled', False) ---- @@ -143,6 +147,8 @@ To read a setting, use the `config.get` method: [source,python] ---- +# Equivalent to: +# color = c.colors.completion.fg color = config.get('colors.completion.fg') ---- From 501764d1cbbf09058234bf0f0a94d6c863fb8500 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 13:18:27 +0200 Subject: [PATCH 043/186] Fix documented default values for falsey non-strings Fixes #3015. --- doc/help/settings.asciidoc | 70 +++++++++++++-------------- qutebrowser/config/configtypes.py | 5 +- tests/unit/config/test_configtypes.py | 6 ++- 3 files changed, 42 insertions(+), 39 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 3fdb32e3c..47524daa3 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -284,7 +284,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[backend]] === backend @@ -1342,7 +1342,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[completion.timestamp_format]] === completion.timestamp_format @@ -1402,7 +1402,7 @@ For more information about the feature, please refer to: http://webkit.org/blog/ Type: <> -Default: empty +Default: +pass:[0]+ This setting is only available with the QtWebKit backend. @@ -1466,7 +1466,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ This setting is only available with the QtWebKit backend. @@ -1497,7 +1497,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ This setting is only available with the QtWebKit backend. @@ -1628,7 +1628,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.images]] === content.images @@ -1668,7 +1668,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.javascript.can_close_tabs]] === content.javascript.can_close_tabs @@ -1681,7 +1681,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ This setting is only available with the QtWebKit backend. @@ -1696,7 +1696,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.javascript.enabled]] === content.javascript.enabled @@ -1737,7 +1737,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.javascript.prompt]] === content.javascript.prompt @@ -1776,7 +1776,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.local_storage]] === content.local_storage @@ -1842,7 +1842,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ This setting is only available with the QtWebKit backend. @@ -1857,7 +1857,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.print_element_backgrounds]] === content.print_element_backgrounds @@ -1885,7 +1885,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.proxy]] === content.proxy @@ -1963,7 +1963,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[downloads.location.directory]] === downloads.location.directory @@ -2243,7 +2243,7 @@ The hard minimum font size. Type: <> -Default: empty +Default: +pass:[0]+ [[fonts.web.size.minimum_logical]] === fonts.web.size.minimum_logical @@ -2274,7 +2274,7 @@ A timeout (in milliseconds) to ignore normal-mode key bindings after a successfu Type: <> -Default: empty +Default: +pass:[0]+ [[hints.border]] === hints.border @@ -2404,7 +2404,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[history_gap_interval]] === history_gap_interval @@ -2467,7 +2467,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[input.insert_mode.plugins]] === input.insert_mode.plugins @@ -2480,7 +2480,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[input.links_included_in_focus_chain]] === input.links_included_in_focus_chain @@ -2516,7 +2516,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[input.spatial_navigation]] === input.spatial_navigation @@ -2530,7 +2530,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[keyhint.blacklist]] === keyhint.blacklist @@ -2569,7 +2569,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[new_instance_open_target]] === new_instance_open_target @@ -2646,7 +2646,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[scrolling.smooth]] === scrolling.smooth @@ -2660,7 +2660,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[session_default_name]] === session_default_name @@ -2682,7 +2682,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[statusbar.padding]] === statusbar.padding @@ -2693,8 +2693,8 @@ Type: <> Default: - +pass:[bottom]+: +pass:[1]+ -- +pass:[left]+: empty -- +pass:[right]+: empty +- +pass:[left]+: +pass:[0]+ +- +pass:[right]+: +pass:[0]+ - +pass:[top]+: +pass:[1]+ [[statusbar.position]] @@ -2721,7 +2721,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[tabs.close_mouse_button]] === tabs.close_mouse_button @@ -2768,7 +2768,7 @@ Type: <> Default: - +pass:[bottom]+: +pass:[2]+ -- +pass:[left]+: empty +- +pass:[left]+: +pass:[0]+ - +pass:[right]+: +pass:[4]+ - +pass:[top]+: +pass:[2]+ @@ -2839,10 +2839,10 @@ Type: <> Default: -- +pass:[bottom]+: empty +- +pass:[bottom]+: +pass:[0]+ - +pass:[left]+: +pass:[5]+ - +pass:[right]+: +pass:[5]+ -- +pass:[top]+: empty +- +pass:[top]+: +pass:[0]+ [[tabs.position]] === tabs.position @@ -2907,7 +2907,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[tabs.title.alignment]] === tabs.title.alignment @@ -3078,7 +3078,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[window.title_format]] === window.title_format @@ -3152,7 +3152,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ This setting is only available with the QtWebKit backend. diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 76fc61a8b..154925734 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -257,9 +257,10 @@ class BaseType: This currently uses asciidoc syntax. """ utils.unused(indent) # only needed for Dict/List - if not value: + str_value = self.to_str(value) + if str_value == '': return 'empty' - return '+pass:[{}]+'.format(html.escape(self.to_str(value))) + return '+pass:[{}]+'.format(html.escape(str_value)) def complete(self): """Return a list of possible values for completion. diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index daf785d9c..55a321307 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -718,8 +718,10 @@ class TestBool: def test_to_str(self, klass, val, expected): assert klass().to_str(val) == expected - def test_to_doc(self, klass): - assert klass().to_doc(True) == '+pass:[true]+' + @pytest.mark.parametrize('value, expected', [(True, '+pass:[true]+'), + (False, '+pass:[false]+')]) + def test_to_doc(self, klass, value, expected): + assert klass().to_doc(value) == expected class TestBoolAsk: From 69d19e49df83355872b918293527067efd1ecc34 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 13:20:18 +0200 Subject: [PATCH 044/186] Fix flake8 --- scripts/dev/run_vulture.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py index ab93ab842..1c0b2224b 100755 --- a/scripts/dev/run_vulture.py +++ b/scripts/dev/run_vulture.py @@ -41,7 +41,7 @@ from qutebrowser.browser import qutescheme from qutebrowser.config import configtypes -def whitelist_generator(): +def whitelist_generator(): # noqa """Generator which yields lines to add to a vulture whitelist.""" # qutebrowser commands for cmd in cmdutils.cmd_dict.values(): From d1a4a028cd1d1b2ec8c6e87f4b80e31799170d8e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 13:24:26 +0200 Subject: [PATCH 045/186] Use more idiomatic comparison --- qutebrowser/config/configtypes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 154925734..32b1fc872 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -258,7 +258,7 @@ class BaseType: """ utils.unused(indent) # only needed for Dict/List str_value = self.to_str(value) - if str_value == '': + if not str_value: return 'empty' return '+pass:[{}]+'.format(html.escape(str_value)) From d5a1fff637c5261443562e2078707489f93d7f2b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 14:08:06 +0200 Subject: [PATCH 046/186] Move init stuff from config.py to configinit.py Closes #2997 --- qutebrowser/app.py | 9 +- qutebrowser/config/config.py | 124 +------------- qutebrowser/config/configinit.py | 144 +++++++++++++++++ tests/unit/config/test_config.py | 216 +------------------------ tests/unit/config/test_configinit.py | 234 +++++++++++++++++++++++++++ 5 files changed, 391 insertions(+), 336 deletions(-) create mode 100644 qutebrowser/config/configinit.py create mode 100644 tests/unit/config/test_configinit.py diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 5bca765eb..1f938c57e 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -43,7 +43,8 @@ import qutebrowser import qutebrowser.resources from qutebrowser.completion.models import miscmodels from qutebrowser.commands import cmdutils, runners, cmdexc -from qutebrowser.config import config, websettings, configexc, configfiles +from qutebrowser.config import (config, websettings, configexc, configfiles, + configinit) from qutebrowser.browser import (urlmarks, adblock, history, browsertab, downloads) from qutebrowser.browser.network import proxy @@ -77,7 +78,7 @@ def run(args): standarddir.init(args) log.init.debug("Initializing config...") - config.early_init(args) + configinit.early_init(args) global qApp qApp = Application(args) @@ -393,7 +394,7 @@ def _init_modules(args, crash_handler): log.init.debug("Initializing save manager...") save_manager = savemanager.SaveManager(qApp) objreg.register('save-manager', save_manager) - config.late_init(save_manager) + configinit.late_init(save_manager) log.init.debug("Initializing network...") networkmanager.init() @@ -762,7 +763,7 @@ class Application(QApplication): """ self._last_focus_object = None - qt_args = config.qt_args(args) + qt_args = configinit.qt_args(args) log.init.debug("Qt arguments: {}, based on {}".format(qt_args, args)) super().__init__(qt_args) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index e99498947..0365eb049 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -19,18 +19,15 @@ """Configuration storage and config-related utilities.""" -import sys import copy import contextlib import functools from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl -from PyQt5.QtWidgets import QMessageBox from qutebrowser.config import configdata, configexc, configtypes, configfiles -from qutebrowser.utils import (utils, objreg, message, log, usertypes, jinja, - qtutils) -from qutebrowser.misc import objects, msgbox, earlyinit +from qutebrowser.utils import utils, objreg, message, log, jinja, qtutils +from qutebrowser.misc import objects from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.completion.models import configmodel @@ -40,9 +37,7 @@ instance = None key_instance = None # Keeping track of all change filters to validate them later. -_change_filters = [] -# Errors which happened during init, so we can show a message box. -_init_errors = [] +change_filters = [] class change_filter: # pylint: disable=invalid-name @@ -68,7 +63,7 @@ class change_filter: # pylint: disable=invalid-name """ self._option = option self._function = function - _change_filters.append(self) + change_filters.append(self) def validate(self): """Make sure the configured option or prefix exists. @@ -634,114 +629,3 @@ class StyleSheetObserver(QObject): self._obj.setStyleSheet(qss) if update: instance.changed.connect(self._update_stylesheet) - - -def early_init(args): - """Initialize the part of the config which works without a QApplication.""" - configdata.init() - - yaml_config = configfiles.YamlConfig() - - global val, instance, key_instance - instance = Config(yaml_config=yaml_config) - val = ConfigContainer(instance) - key_instance = KeyConfig(instance) - - for cf in _change_filters: - cf.validate() - - configtypes.Font.monospace_fonts = val.fonts.monospace - - config_commands = ConfigCommands(instance, key_instance) - objreg.register('config-commands', config_commands) - - config_api = None - - try: - config_api = configfiles.read_config_py() - # Raised here so we get the config_api back. - if config_api.errors: - raise configexc.ConfigFileErrors('config.py', config_api.errors) - except configexc.ConfigFileErrors as e: - log.config.exception("Error while loading config.py") - _init_errors.append(e) - - try: - if getattr(config_api, 'load_autoconfig', True): - try: - instance.read_yaml() - except configexc.ConfigFileErrors as e: - raise # caught in outer block - except configexc.Error as e: - desc = configexc.ConfigErrorDesc("Error", e) - raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - except configexc.ConfigFileErrors as e: - log.config.exception("Error while loading config.py") - _init_errors.append(e) - - configfiles.init() - - objects.backend = get_backend(args) - earlyinit.init_with_backend(objects.backend) - - -def get_backend(args): - """Find out what backend to use based on available libraries.""" - try: - import PyQt5.QtWebKit # pylint: disable=unused-variable - except ImportError: - webkit_available = False - else: - webkit_available = qtutils.is_new_qtwebkit() - - str_to_backend = { - 'webkit': usertypes.Backend.QtWebKit, - 'webengine': usertypes.Backend.QtWebEngine, - } - - if args.backend is not None: - return str_to_backend[args.backend] - elif val.backend != 'auto': - return str_to_backend[val.backend] - elif webkit_available: - return usertypes.Backend.QtWebKit - else: - return usertypes.Backend.QtWebEngine - - -def late_init(save_manager): - """Initialize the rest of the config after the QApplication is created.""" - global _init_errors - for err in _init_errors: - errbox = msgbox.msgbox(parent=None, - title="Error while reading config", - text=err.to_html(), - icon=QMessageBox.Warning, - plain_text=False) - errbox.exec_() - _init_errors = [] - - instance.init_save_manager(save_manager) - configfiles.state.init_save_manager(save_manager) - - -def qt_args(namespace): - """Get the Qt QApplication arguments based on an argparse namespace. - - Args: - namespace: The argparse namespace. - - Return: - The argv list to be passed to Qt. - """ - argv = [sys.argv[0]] - - if namespace.qt_flag is not None: - argv += ['--' + flag[0] for flag in namespace.qt_flag] - - if namespace.qt_arg is not None: - for name, value in namespace.qt_arg: - argv += ['--' + name, value] - - argv += ['--' + arg for arg in val.qt_args] - return argv diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py new file mode 100644 index 000000000..27a37b5e9 --- /dev/null +++ b/qutebrowser/config/configinit.py @@ -0,0 +1,144 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 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 . + +"""Initialization of the configuration.""" + +import sys + +from PyQt5.QtWidgets import QMessageBox + +from qutebrowser.config import (config, configdata, configfiles, configtypes, + configexc) +from qutebrowser.utils import objreg, qtutils, usertypes, log +from qutebrowser.misc import earlyinit, msgbox, objects + + +# Errors which happened during init, so we can show a message box. +_init_errors = [] + + +def early_init(args): + """Initialize the part of the config which works without a QApplication.""" + configdata.init() + + yaml_config = configfiles.YamlConfig() + + config.instance = config.Config(yaml_config=yaml_config) + config.val = config.ConfigContainer(config.instance) + config.key_instance = config.KeyConfig(config.instance) + + for cf in config.change_filters: + cf.validate() + + configtypes.Font.monospace_fonts = config.val.fonts.monospace + + config_commands = config.ConfigCommands(config.instance, + config.key_instance) + objreg.register('config-commands', config_commands) + + config_api = None + + try: + config_api = configfiles.read_config_py() + # Raised here so we get the config_api back. + if config_api.errors: + raise configexc.ConfigFileErrors('config.py', config_api.errors) + except configexc.ConfigFileErrors as e: + log.config.exception("Error while loading config.py") + _init_errors.append(e) + + try: + if getattr(config_api, 'load_autoconfig', True): + try: + config.instance.read_yaml() + except configexc.ConfigFileErrors as e: + raise # caught in outer block + except configexc.Error as e: + desc = configexc.ConfigErrorDesc("Error", e) + raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) + except configexc.ConfigFileErrors as e: + log.config.exception("Error while loading config.py") + _init_errors.append(e) + + configfiles.init() + + objects.backend = get_backend(args) + earlyinit.init_with_backend(objects.backend) + + +def get_backend(args): + """Find out what backend to use based on available libraries.""" + try: + import PyQt5.QtWebKit # pylint: disable=unused-variable + except ImportError: + webkit_available = False + else: + webkit_available = qtutils.is_new_qtwebkit() + + str_to_backend = { + 'webkit': usertypes.Backend.QtWebKit, + 'webengine': usertypes.Backend.QtWebEngine, + } + + if args.backend is not None: + return str_to_backend[args.backend] + elif config.val.backend != 'auto': + return str_to_backend[config.val.backend] + elif webkit_available: + return usertypes.Backend.QtWebKit + else: + return usertypes.Backend.QtWebEngine + + +def late_init(save_manager): + """Initialize the rest of the config after the QApplication is created.""" + global _init_errors + for err in _init_errors: + errbox = msgbox.msgbox(parent=None, + title="Error while reading config", + text=err.to_html(), + icon=QMessageBox.Warning, + plain_text=False) + errbox.exec_() + _init_errors = [] + + config.instance.init_save_manager(save_manager) + configfiles.state.init_save_manager(save_manager) + + +def qt_args(namespace): + """Get the Qt QApplication arguments based on an argparse namespace. + + Args: + namespace: The argparse namespace. + + Return: + The argv list to be passed to Qt. + """ + argv = [sys.argv[0]] + + if namespace.qt_flag is not None: + argv += ['--' + flag[0] for flag in namespace.qt_flag] + + if namespace.qt_arg is not None: + for name, value in namespace.qt_arg: + argv += ['--' + name, value] + + argv += ['--' + arg for arg in config.val.qt_args] + return argv diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 4e8bd684b..24c6814b5 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -18,19 +18,15 @@ """Tests for qutebrowser.config.config.""" -import sys import copy import types -import logging -import unittest.mock import pytest from PyQt5.QtCore import QObject, QUrl from PyQt5.QtGui import QColor -from qutebrowser import qutebrowser from qutebrowser.commands import cmdexc -from qutebrowser.config import config, configdata, configexc, configfiles +from qutebrowser.config import config, configdata, configexc from qutebrowser.utils import objreg, usertypes from qutebrowser.misc import objects @@ -52,8 +48,8 @@ class TestChangeFilter: @pytest.fixture(autouse=True) def cleanup_globals(self, monkeypatch): - """Make sure config._change_filters is cleaned up.""" - monkeypatch.setattr(config, '_change_filters', []) + """Make sure config.change_filters is cleaned up.""" + monkeypatch.setattr(config, 'change_filters', []) @pytest.mark.parametrize('option', ['foobar', 'tab', 'tabss', 'tabs.']) def test_unknown_option(self, option): @@ -65,7 +61,7 @@ class TestChangeFilter: def test_validate(self, option): cf = config.change_filter(option) cf.validate() - assert cf in config._change_filters + assert cf in config.change_filters @pytest.mark.parametrize('method', [True, False]) @pytest.mark.parametrize('option, changed, matches', [ @@ -864,207 +860,3 @@ def test_set_register_stylesheet(delete, stylesheet_param, update, qtbot, expected = 'yellow' assert obj.rendered_stylesheet == expected - - -@pytest.fixture -def init_patch(qapp, fake_save_manager, monkeypatch, config_tmpdir, - data_tmpdir): - monkeypatch.setattr(configdata, 'DATA', None) - monkeypatch.setattr(configfiles, 'state', None) - monkeypatch.setattr(config, 'instance', None) - monkeypatch.setattr(config, 'key_instance', None) - monkeypatch.setattr(config, '_change_filters', []) - monkeypatch.setattr(config, '_init_errors', []) - # Make sure we get no SSL warning - monkeypatch.setattr(config.earlyinit, 'check_backend_ssl_support', - lambda _backend: None) - yield - try: - objreg.delete('config-commands') - except KeyError: - pass - - -@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]) -# pylint: disable=too-many-branches -def test_early_init(init_patch, config_tmpdir, caplog, fake_args, - load_autoconfig, config_py, invalid_yaml): - # Prepare files - autoconfig_file = config_tmpdir / 'autoconfig.yml' - config_py_file = config_tmpdir / 'config.py' - - if invalid_yaml == '42': - text = '42' - elif invalid_yaml == 'unknown': - text = 'global:\n colors.foobar: magenta\n' - elif invalid_yaml == 'wrong-type': - text = 'global:\n tabs.position: true\n' - else: - assert not invalid_yaml - text = 'global:\n colors.hints.fg: magenta\n' - autoconfig_file.write_text(text, 'utf-8', ensure=True) - - if config_py: - config_py_lines = ['c.colors.hints.bg = "red"'] - if not load_autoconfig: - config_py_lines.append('config.load_autoconfig = False') - if config_py == 'error': - config_py_lines.append('c.foo = 42') - config_py_file.write_text('\n'.join(config_py_lines), - 'utf-8', ensure=True) - - with caplog.at_level(logging.ERROR): - config.early_init(fake_args) - - # Check error messages - expected_errors = [] - if config_py == 'error': - expected_errors.append( - "Errors occurred while reading config.py:\n" - " While setting 'foo': No option 'foo'") - if load_autoconfig or not config_py: - error = "Errors occurred while reading autoconfig.yml:\n" - if invalid_yaml == '42': - error += " While loading data: Toplevel object is not a dict" - expected_errors.append(error) - elif invalid_yaml == 'wrong-type': - error += (" Error: Invalid value 'True' - expected a value of " - "type str but got bool.") - expected_errors.append(error) - - actual_errors = [str(err) for err in config._init_errors] - assert actual_errors == expected_errors - - # Make sure things have been init'ed - objreg.get('config-commands') - assert isinstance(config.instance, config.Config) - assert isinstance(config.key_instance, config.KeyConfig) - - # Check config values - if config_py and load_autoconfig and not invalid_yaml: - assert config.instance._values == { - 'colors.hints.bg': 'red', - 'colors.hints.fg': 'magenta', - } - elif config_py: - assert config.instance._values == {'colors.hints.bg': 'red'} - elif invalid_yaml: - assert config.instance._values == {} - else: - assert config.instance._values == {'colors.hints.fg': 'magenta'} - - -def test_early_init_invalid_change_filter(init_patch, fake_args): - config.change_filter('foobar') - with pytest.raises(configexc.NoOptionError): - config.early_init(fake_args) - - -@pytest.mark.parametrize('errors', [True, False]) -def test_late_init(init_patch, monkeypatch, fake_save_manager, fake_args, - mocker, errors): - config.early_init(fake_args) - if errors: - err = configexc.ConfigErrorDesc("Error text", Exception("Exception")) - errs = configexc.ConfigFileErrors("config.py", [err]) - monkeypatch.setattr(config, '_init_errors', [errs]) - msgbox_mock = mocker.patch('qutebrowser.config.config.msgbox.msgbox', - autospec=True) - - config.late_init(fake_save_manager) - - fake_save_manager.add_saveable.assert_any_call( - 'state-config', unittest.mock.ANY) - fake_save_manager.add_saveable.assert_any_call( - 'yaml-config', unittest.mock.ANY) - if errors: - assert len(msgbox_mock.call_args_list) == 1 - _call_posargs, call_kwargs = msgbox_mock.call_args_list[0] - text = call_kwargs['text'].strip() - assert text.startswith('Errors occurred while reading config.py:') - assert 'Error text: Exception' in text - else: - assert not msgbox_mock.called - - -class TestQtArgs: - - @pytest.fixture - def parser(self, mocker): - """Fixture to provide an argparser. - - Monkey-patches .exit() of the argparser so it doesn't exit on errors. - """ - parser = qutebrowser.get_argparser() - mocker.patch.object(parser, 'exit', side_effect=Exception) - return parser - - @pytest.mark.parametrize('args, expected', [ - # No Qt arguments - (['--debug'], [sys.argv[0]]), - # Qt flag - (['--debug', '--qt-flag', 'reverse'], [sys.argv[0], '--reverse']), - # Qt argument with value - (['--qt-arg', 'stylesheet', 'foo'], - [sys.argv[0], '--stylesheet', 'foo']), - # --qt-arg given twice - (['--qt-arg', 'stylesheet', 'foo', '--qt-arg', 'geometry', 'bar'], - [sys.argv[0], '--stylesheet', 'foo', '--geometry', 'bar']), - # --qt-flag given twice - (['--qt-flag', 'foo', '--qt-flag', 'bar'], - [sys.argv[0], '--foo', '--bar']), - ]) - def test_qt_args(self, config_stub, args, expected, parser): - """Test commandline with no Qt arguments given.""" - parsed = parser.parse_args(args) - assert config.qt_args(parsed) == expected - - def test_qt_both(self, config_stub, parser): - """Test commandline with a Qt argument and flag.""" - args = parser.parse_args(['--qt-arg', 'stylesheet', 'foobar', - '--qt-flag', 'reverse']) - qt_args = config.qt_args(args) - assert qt_args[0] == sys.argv[0] - assert '--reverse' in qt_args - assert '--stylesheet' in qt_args - assert 'foobar' in qt_args - - def test_with_settings(self, config_stub, parser): - parsed = parser.parse_args(['--qt-flag', 'foo']) - config_stub.val.qt_args = ['bar'] - assert config.qt_args(parsed) == [sys.argv[0], '--foo', '--bar'] - - -@pytest.mark.parametrize('arg, confval, can_import, is_new_webkit, used', [ - # overridden by commandline arg - ('webkit', 'auto', False, False, usertypes.Backend.QtWebKit), - # overridden by config - (None, 'webkit', False, False, usertypes.Backend.QtWebKit), - # WebKit available but too old - (None, 'auto', True, False, usertypes.Backend.QtWebEngine), - # WebKit available and new - (None, 'auto', True, True, usertypes.Backend.QtWebKit), - # WebKit unavailable - (None, 'auto', False, False, usertypes.Backend.QtWebEngine), -]) -def test_get_backend(monkeypatch, fake_args, config_stub, - arg, confval, can_import, is_new_webkit, used): - real_import = __import__ - - def fake_import(name, *args, **kwargs): - if name != 'PyQt5.QtWebKit': - return real_import(name, *args, **kwargs) - if can_import: - return None - raise ImportError - - fake_args.backend = arg - config_stub.val.backend = confval - monkeypatch.setattr(config.qtutils, 'is_new_qtwebkit', - lambda: is_new_webkit) - monkeypatch.setattr('builtins.__import__', fake_import) - - assert config.get_backend(fake_args) == used diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py new file mode 100644 index 000000000..c5108d95f --- /dev/null +++ b/tests/unit/config/test_configinit.py @@ -0,0 +1,234 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: +# Copyright 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 . + +"""Tests for qutebrowser.config.configinit.""" + +import sys +import logging +import unittest.mock + +import pytest + +from qutebrowser import qutebrowser +from qutebrowser.config import (config, configdata, configexc, configfiles, + configinit) +from qutebrowser.utils import objreg, usertypes + + +@pytest.fixture +def init_patch(qapp, fake_save_manager, monkeypatch, config_tmpdir, + data_tmpdir): + monkeypatch.setattr(configdata, 'DATA', None) + monkeypatch.setattr(configfiles, 'state', None) + monkeypatch.setattr(config, 'instance', None) + monkeypatch.setattr(config, 'key_instance', None) + monkeypatch.setattr(config, 'change_filters', []) + monkeypatch.setattr(configinit, '_init_errors', []) + # Make sure we get no SSL warning + monkeypatch.setattr(configinit.earlyinit, 'check_backend_ssl_support', + lambda _backend: None) + yield + try: + objreg.delete('config-commands') + except KeyError: + pass + + +@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]) +# pylint: disable=too-many-branches +def test_early_init(init_patch, config_tmpdir, caplog, fake_args, + load_autoconfig, config_py, invalid_yaml): + # Prepare files + autoconfig_file = config_tmpdir / 'autoconfig.yml' + config_py_file = config_tmpdir / 'config.py' + + if invalid_yaml == '42': + text = '42' + elif invalid_yaml == 'unknown': + text = 'global:\n colors.foobar: magenta\n' + elif invalid_yaml == 'wrong-type': + text = 'global:\n tabs.position: true\n' + else: + assert not invalid_yaml + text = 'global:\n colors.hints.fg: magenta\n' + autoconfig_file.write_text(text, 'utf-8', ensure=True) + + if config_py: + config_py_lines = ['c.colors.hints.bg = "red"'] + if not load_autoconfig: + config_py_lines.append('config.load_autoconfig = False') + if config_py == 'error': + config_py_lines.append('c.foo = 42') + config_py_file.write_text('\n'.join(config_py_lines), + 'utf-8', ensure=True) + + with caplog.at_level(logging.ERROR): + configinit.early_init(fake_args) + + # Check error messages + expected_errors = [] + if config_py == 'error': + expected_errors.append( + "Errors occurred while reading config.py:\n" + " While setting 'foo': No option 'foo'") + if load_autoconfig or not config_py: + error = "Errors occurred while reading autoconfig.yml:\n" + if invalid_yaml == '42': + error += " While loading data: Toplevel object is not a dict" + expected_errors.append(error) + elif invalid_yaml == 'wrong-type': + error += (" Error: Invalid value 'True' - expected a value of " + "type str but got bool.") + expected_errors.append(error) + + actual_errors = [str(err) for err in configinit._init_errors] + assert actual_errors == expected_errors + + # Make sure things have been init'ed + objreg.get('config-commands') + assert isinstance(config.instance, config.Config) + assert isinstance(config.key_instance, config.KeyConfig) + + # Check config values + if config_py and load_autoconfig and not invalid_yaml: + assert config.instance._values == { + 'colors.hints.bg': 'red', + 'colors.hints.fg': 'magenta', + } + elif config_py: + assert config.instance._values == {'colors.hints.bg': 'red'} + elif invalid_yaml: + assert config.instance._values == {} + else: + assert config.instance._values == {'colors.hints.fg': 'magenta'} + + +def test_early_init_invalid_change_filter(init_patch, fake_args): + config.change_filter('foobar') + with pytest.raises(configexc.NoOptionError): + configinit.early_init(fake_args) + + +@pytest.mark.parametrize('errors', [True, False]) +def test_late_init(init_patch, monkeypatch, fake_save_manager, fake_args, + mocker, errors): + configinit.early_init(fake_args) + if errors: + err = configexc.ConfigErrorDesc("Error text", Exception("Exception")) + errs = configexc.ConfigFileErrors("config.py", [err]) + monkeypatch.setattr(configinit, '_init_errors', [errs]) + msgbox_mock = mocker.patch('qutebrowser.config.configinit.msgbox.msgbox', + autospec=True) + + configinit.late_init(fake_save_manager) + + fake_save_manager.add_saveable.assert_any_call( + 'state-config', unittest.mock.ANY) + fake_save_manager.add_saveable.assert_any_call( + 'yaml-config', unittest.mock.ANY) + if errors: + assert len(msgbox_mock.call_args_list) == 1 + _call_posargs, call_kwargs = msgbox_mock.call_args_list[0] + text = call_kwargs['text'].strip() + assert text.startswith('Errors occurred while reading config.py:') + assert 'Error text: Exception' in text + else: + assert not msgbox_mock.called + + +class TestQtArgs: + + @pytest.fixture + def parser(self, mocker): + """Fixture to provide an argparser. + + Monkey-patches .exit() of the argparser so it doesn't exit on errors. + """ + parser = qutebrowser.get_argparser() + mocker.patch.object(parser, 'exit', side_effect=Exception) + return parser + + @pytest.mark.parametrize('args, expected', [ + # No Qt arguments + (['--debug'], [sys.argv[0]]), + # Qt flag + (['--debug', '--qt-flag', 'reverse'], [sys.argv[0], '--reverse']), + # Qt argument with value + (['--qt-arg', 'stylesheet', 'foo'], + [sys.argv[0], '--stylesheet', 'foo']), + # --qt-arg given twice + (['--qt-arg', 'stylesheet', 'foo', '--qt-arg', 'geometry', 'bar'], + [sys.argv[0], '--stylesheet', 'foo', '--geometry', 'bar']), + # --qt-flag given twice + (['--qt-flag', 'foo', '--qt-flag', 'bar'], + [sys.argv[0], '--foo', '--bar']), + ]) + def test_qt_args(self, config_stub, args, expected, parser): + """Test commandline with no Qt arguments given.""" + parsed = parser.parse_args(args) + assert configinit.qt_args(parsed) == expected + + def test_qt_both(self, config_stub, parser): + """Test commandline with a Qt argument and flag.""" + args = parser.parse_args(['--qt-arg', 'stylesheet', 'foobar', + '--qt-flag', 'reverse']) + qt_args = configinit.qt_args(args) + assert qt_args[0] == sys.argv[0] + assert '--reverse' in qt_args + assert '--stylesheet' in qt_args + assert 'foobar' in qt_args + + def test_with_settings(self, config_stub, parser): + parsed = parser.parse_args(['--qt-flag', 'foo']) + config_stub.val.qt_args = ['bar'] + assert configinit.qt_args(parsed) == [sys.argv[0], '--foo', '--bar'] + + +@pytest.mark.parametrize('arg, confval, can_import, is_new_webkit, used', [ + # overridden by commandline arg + ('webkit', 'auto', False, False, usertypes.Backend.QtWebKit), + # overridden by config + (None, 'webkit', False, False, usertypes.Backend.QtWebKit), + # WebKit available but too old + (None, 'auto', True, False, usertypes.Backend.QtWebEngine), + # WebKit available and new + (None, 'auto', True, True, usertypes.Backend.QtWebKit), + # WebKit unavailable + (None, 'auto', False, False, usertypes.Backend.QtWebEngine), +]) +def test_get_backend(monkeypatch, fake_args, config_stub, + arg, confval, can_import, is_new_webkit, used): + real_import = __import__ + + def fake_import(name, *args, **kwargs): + if name != 'PyQt5.QtWebKit': + return real_import(name, *args, **kwargs) + if can_import: + return None + raise ImportError + + fake_args.backend = arg + config_stub.val.backend = confval + monkeypatch.setattr(config.qtutils, 'is_new_qtwebkit', + lambda: is_new_webkit) + monkeypatch.setattr('builtins.__import__', fake_import) + + assert configinit.get_backend(fake_args) == used From 7f4cba8bc23223dabb81d448187c61ff56a03a25 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 14:23:41 +0200 Subject: [PATCH 047/186] Improve load_autoconfig docs Closes #2993 --- doc/help/configuring.asciidoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 38bc8ede7..b68e0bedc 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -217,6 +217,9 @@ doing: config.load_autoconfig = False ---- +Note that the settings are still saved in `autoconfig.yml` that way, but then +not loaded on start. + Importing other modules ~~~~~~~~~~~~~~~~~~~~~~~ From 1e2015be651c24c4e37ccb0621f5969233afb10f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 15:28:45 +0200 Subject: [PATCH 048/186] Make bindings win over mappings Fixes #2995 --- doc/help/settings.asciidoc | 1 + qutebrowser/config/configdata.yml | 3 ++ qutebrowser/keyinput/basekeyparser.py | 49 +++++++++++++---------- tests/unit/keyinput/conftest.py | 7 ++++ tests/unit/keyinput/test_basekeyparser.py | 41 ++++++++++++++----- 5 files changed, 69 insertions(+), 32 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 47524daa3..620e4467c 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -627,6 +627,7 @@ Default: This setting can be used to map keys to other keys. When the key used as dictionary-key is pressed, the binding for the key used as dictionary-value is invoked instead. This is useful for global remappings of keys, for example to map Ctrl-[ to Escape. +Note that when a key is bound (via `bindings.default` or `bindings.commands`), the mapping is ignored. Type: <> diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index f0e5ae74d..9f67689b0 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -1903,6 +1903,9 @@ bindings.key_mappings: This is useful for global remappings of keys, for example to map Ctrl-[ to Escape. + Note that when a key is bound (via `bindings.default` or + `bindings.commands`), the mapping is ignored. + bindings.default: default: normal: diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 9eec4b28f..2f75cbdfe 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -122,37 +122,40 @@ class BaseKeyParser(QObject): self._debug_log("Ignoring only-modifier keyeevent.") return False - key_mappings = config.val.bindings.key_mappings - try: - binding = key_mappings['<{}>'.format(binding)][1:-1] - except KeyError: - pass + 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() + count, _command = self._split_count(self._keystring) self.execute(cmdstr, self.Type.special, count) self.clear_keystring() return True - def _split_count(self): + 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.match(r'^(\d*)(.*)', - self._keystring).groups() + (countstr, cmd_input) = re.match(r'^(\d*)(.*)', keystring).groups() count = int(countstr) if countstr else None if count == 0 and not cmd_input: - cmd_input = self._keystring + cmd_input = keystring count = None else: - cmd_input = self._keystring + cmd_input = keystring count = None return count, cmd_input @@ -183,18 +186,17 @@ class BaseKeyParser(QObject): self._debug_log("Ignoring, no text char") return self.Match.none - key_mappings = config.val.bindings.key_mappings - txt = key_mappings.get(txt, txt) - self._keystring += txt - - count, cmd_input = self._split_count() - - if not cmd_input: - # Only a count, no command yet, but we handled it - return self.Match.other - + count, cmd_input = self._split_count(self._keystring + txt) match, binding = self._match_key(cmd_input) + if match == self.Match.none: + 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) + match, binding = self._match_key(cmd_input) + self._keystring += txt if match == self.Match.definitive: self._debug_log("Definitive match for '{}'.".format( self._keystring)) @@ -207,6 +209,8 @@ class BaseKeyParser(QObject): self._debug_log("Giving up with '{}', no matches".format( self._keystring)) self.clear_keystring() + elif match == self.Match.other: + pass else: raise AssertionError("Invalid match value {!r}".format(match)) return match @@ -223,6 +227,9 @@ 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 (self.Match.other, None) # A (cmd_input, binding) tuple (k, v of bindings) or None. definitive_match = None partial_match = False diff --git a/tests/unit/keyinput/conftest.py b/tests/unit/keyinput/conftest.py index bdae15272..684e5792e 100644 --- a/tests/unit/keyinput/conftest.py +++ b/tests/unit/keyinput/conftest.py @@ -31,6 +31,12 @@ BINDINGS = {'prompt': {'': 'message-info ctrla', 'command': {'foo': 'message-info bar', '': 'message-info ctrlx'}, 'normal': {'a': 'message-info a', 'ba': 'message-info ba'}} +MAPPINGS = { + '': 'a', + '': '', + 'x': 'a', + 'b': 'a', +} @pytest.fixture @@ -38,3 +44,4 @@ def keyinput_bindings(config_stub, key_config_stub): """Register some test bindings.""" config_stub.val.bindings.default = {} config_stub.val.bindings.commands = dict(BINDINGS) + config_stub.val.bindings.key_mappings = dict(MAPPINGS) diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index e7131f92c..c4ce838da 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -91,8 +91,7 @@ class TestDebugLog: ]) def test_split_count(config_stub, input_key, supports_count, expected): kp = basekeyparser.BaseKeyParser(0, supports_count=supports_count) - kp._keystring = input_key - assert kp._split_count() == expected + assert kp._split_count(input_key) == expected @pytest.mark.usefixtures('keyinput_bindings') @@ -165,20 +164,14 @@ class TestSpecialKeys: keyparser._read_config('prompt') def test_valid_key(self, fake_keyevent_factory, keyparser): - if utils.is_mac: - modifier = Qt.MetaModifier - else: - modifier = Qt.ControlModifier + 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.special, None) def test_valid_key_count(self, fake_keyevent_factory, keyparser): - if utils.is_mac: - modifier = Qt.MetaModifier - else: - modifier = Qt.ControlModifier + 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_A, modifier, text='A')) keyparser.execute.assert_called_once_with( @@ -199,6 +192,22 @@ class TestSpecialKeys: keyparser.handle(fake_keyevent_factory(Qt.Key_A, 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', keyparser.Type.special, 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', keyparser.Type.special, None) + class TestKeyChain: @@ -230,7 +239,7 @@ 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_once_with( + keyparser.execute.assert_called_with( 'message-info ba', keyparser.Type.chain, None) assert keyparser._keystring == '' @@ -249,6 +258,16 @@ class TestKeyChain: handle_text((Qt.Key_C, 'c')) assert keyparser._keystring == '' + 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) + + 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')) + assert not keyparser.execute.called + class TestCount: From 5be44756e3632be40558ce93eeacc8d4d7dda466 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 17:13:45 +0200 Subject: [PATCH 049/186] Remove unused imports --- qutebrowser/config/config.py | 4 ++-- tests/unit/config/test_configinit.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 0365eb049..8d93a2344 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -25,8 +25,8 @@ import functools from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl -from qutebrowser.config import configdata, configexc, configtypes, configfiles -from qutebrowser.utils import utils, objreg, message, log, jinja, qtutils +from qutebrowser.config import configdata, configexc, configtypes +from qutebrowser.utils import utils, objreg, message, log, jinja from qutebrowser.misc import objects from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.completion.models import configmodel diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index c5108d95f..fef352584 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -227,7 +227,7 @@ def test_get_backend(monkeypatch, fake_args, config_stub, fake_args.backend = arg config_stub.val.backend = confval - monkeypatch.setattr(config.qtutils, 'is_new_qtwebkit', + monkeypatch.setattr(configinit.qtutils, 'is_new_qtwebkit', lambda: is_new_webkit) monkeypatch.setattr('builtins.__import__', fake_import) From e27c54a5c1a0fa06a627df99e34fafb176d92fc7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 19:49:52 +0200 Subject: [PATCH 050/186] Fix modeparser tests --- tests/unit/keyinput/test_modeparsers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py index 5010b8efa..4d2024eac 100644 --- a/tests/unit/keyinput/test_modeparsers.py +++ b/tests/unit/keyinput/test_modeparsers.py @@ -56,7 +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_once_with( + keyparser.execute.assert_called_with( 'message-info ba', keyparser.Type.chain, None) assert keyparser._keystring == '' From 4e46c34e5a93ad44abf5e579171f487c21bd8a7f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 19:58:38 +0200 Subject: [PATCH 051/186] Use .assert_not_called() for mocks --- tests/helpers/test_stubs.py | 12 ++++++------ tests/unit/completion/test_completionmodel.py | 4 ++-- tests/unit/completion/test_completionwidget.py | 2 +- tests/unit/config/test_configinit.py | 2 +- tests/unit/misc/test_ipc.py | 2 +- tests/unit/utils/test_standarddir.py | 12 ++++++------ 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/helpers/test_stubs.py b/tests/helpers/test_stubs.py index 10fa9e5db..a5c1d27fb 100644 --- a/tests/helpers/test_stubs.py +++ b/tests/helpers/test_stubs.py @@ -36,8 +36,8 @@ def test_timeout(timer): func2 = mock.Mock() timer.timeout.connect(func) timer.timeout.connect(func2) - assert not func.called - assert not func2.called + func.assert_not_called() + func2.assert_not_called() timer.timeout.emit() func.assert_called_once_with() func2.assert_called_once_with() @@ -49,7 +49,7 @@ def test_disconnect_all(timer): timer.timeout.connect(func) timer.timeout.disconnect() timer.timeout.emit() - assert not func.called + func.assert_not_called() def test_disconnect_one(timer): @@ -58,7 +58,7 @@ def test_disconnect_one(timer): timer.timeout.connect(func) timer.timeout.disconnect(func) timer.timeout.emit() - assert not func.called + func.assert_not_called() def test_disconnect_all_invalid(timer): @@ -74,8 +74,8 @@ def test_disconnect_one_invalid(timer): timer.timeout.connect(func1) with pytest.raises(TypeError): timer.timeout.disconnect(func2) - assert not func1.called - assert not func2.called + func1.assert_not_called() + func2.assert_not_called() timer.timeout.emit() func1.assert_called_once_with() diff --git a/tests/unit/completion/test_completionmodel.py b/tests/unit/completion/test_completionmodel.py index 9e73e533a..292349730 100644 --- a/tests/unit/completion/test_completionmodel.py +++ b/tests/unit/completion/test_completionmodel.py @@ -103,7 +103,7 @@ def test_delete_cur_item_no_func(): parent = model.index(0, 0) with pytest.raises(cmdexc.CommandError): model.delete_cur_item(model.index(0, 0, parent)) - assert not callback.called + callback.assert_not_called() def test_delete_cur_item_no_cat(): @@ -114,4 +114,4 @@ def test_delete_cur_item_no_cat(): model.rowsRemoved.connect(callback) with pytest.raises(qtutils.QtValueError): model.delete_cur_item(QModelIndex()) - assert not callback.called + callback.assert_not_called() diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index 7eb7fe2b5..22c71a2b7 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -242,7 +242,7 @@ def test_completion_item_del_no_selection(completionview): completionview.set_model(model) with pytest.raises(cmdexc.CommandError, match='No item selected!'): completionview.completion_item_del() - assert not func.called + func.assert_not_called() def test_resize_no_model(completionview, qtbot): diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index fef352584..767ffd6ca 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -151,7 +151,7 @@ def test_late_init(init_patch, monkeypatch, fake_save_manager, fake_args, assert text.startswith('Errors occurred while reading config.py:') assert 'Error text: Exception' in text else: - assert not msgbox_mock.called + msgbox_mock.assert_not_called() class TestQtArgs: diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py index d8024b207..874419511 100644 --- a/tests/unit/misc/test_ipc.py +++ b/tests/unit/misc/test_ipc.py @@ -380,7 +380,7 @@ class TestHandleConnection: monkeypatch.setattr(ipc_server._server, 'nextPendingConnection', m) ipc_server.ignored = True ipc_server.handle_connection() - assert not m.called + m.assert_not_called() def test_no_connection(self, ipc_server, caplog): ipc_server.handle_connection() diff --git a/tests/unit/utils/test_standarddir.py b/tests/unit/utils/test_standarddir.py index 3dcb282d2..20ea88645 100644 --- a/tests/unit/utils/test_standarddir.py +++ b/tests/unit/utils/test_standarddir.py @@ -494,17 +494,17 @@ def test_init(mocker, tmpdir, args_kind): assert standarddir._locations != {} if args_kind == 'normal': if utils.is_mac: - assert not m_windows.called + m_windows.assert_not_called() assert m_mac.called elif utils.is_windows: assert m_windows.called - assert not m_mac.called + m_mac.assert_not_called() else: - assert not m_windows.called - assert not m_mac.called + m_windows.assert_not_called() + m_mac.assert_not_called() else: - assert not m_windows.called - assert not m_mac.called + m_windows.assert_not_called() + m_mac.assert_not_called() @pytest.mark.linux From 459bbc3a6f577474cb10aa7c85915bb2b1d0e86c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 20:26:56 +0200 Subject: [PATCH 052/186] Add configinit to PERFECT_FILES --- scripts/dev/check_coverage.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index aa5072536..99bd1277d 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -141,6 +141,8 @@ PERFECT_FILES = [ 'config/configfiles.py'), ('tests/unit/config/test_configtypes.py', 'config/configtypes.py'), + ('tests/unit/config/test_configinit.py', + 'config/configinit.py'), ('tests/unit/utils/test_qtutils.py', 'utils/qtutils.py'), From e8ceeceac8c3603af8937de961bf536ad0755458 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 22:28:40 +0200 Subject: [PATCH 053/186] Fix mock check with Python 3.5 Looks like .assert_not_called() doesn't work on function mocks with 3.5. --- tests/unit/config/test_configinit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index 767ffd6ca..fef352584 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -151,7 +151,7 @@ def test_late_init(init_patch, monkeypatch, fake_save_manager, fake_args, assert text.startswith('Errors occurred while reading config.py:') assert 'Error text: Exception' in text else: - msgbox_mock.assert_not_called() + assert not msgbox_mock.called class TestQtArgs: From b8389e4496028fce178031fc79eee478f4d8e4c9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 22 Sep 2017 22:30:02 +0200 Subject: [PATCH 054/186] Revert "Fix NUL byte error handling on Python 3.4" This reverts commit a7d5a98cc4843437244e7633f622dd71be9f500e. Not needed anymore now that we dropped support. --- 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 622280ee7..c9720a6d8 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -251,7 +251,7 @@ def read_config_py(filename=None, raising=False): try: code = compile(source, filename, 'exec') - except (ValueError, TypeError) as e: + except ValueError as e: # source contains NUL bytes desc = configexc.ConfigErrorDesc("Error while compiling", e) raise configexc.ConfigFileErrors(basename, [desc]) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index ed5d2b882..6a58659fa 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -409,7 +409,7 @@ class TestConfigPy: assert len(excinfo.value.errors) == 1 error = excinfo.value.errors[0] - assert isinstance(error.exception, (TypeError, ValueError)) + assert isinstance(error.exception, ValueError) assert error.text == "Error while compiling" exception_text = 'source code string cannot contain null bytes' assert str(error.exception) == exception_text From e2e9bbacce1d77941ef35757d40ddbb6113cef29 Mon Sep 17 00:00:00 2001 From: Ian Walker Date: Sat, 23 Sep 2017 17:26:41 +0900 Subject: [PATCH 055/186] Move _on_proxy_authentication_required to WebEngineTab --- qutebrowser/browser/webengine/webenginetab.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 8d328f5e8..a4ad1f3be 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -22,6 +22,7 @@ import os import math import functools +import html import sip from PyQt5.QtCore import pyqtSlot, Qt, QEvent, QPoint, QUrl, QTimer @@ -37,7 +38,7 @@ from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory, webenginesettings) from qutebrowser.misc import miscwidgets from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils, - objreg, jinja, debug, version) + message, objreg, jinja, debug, version) _qute_scheme_handler = None @@ -682,6 +683,20 @@ class WebEngineTab(browsertab.AbstractTab): self.add_history_item.emit(url, requested_url, title) + @pyqtSlot(QUrl, 'QAuthenticator*', 'QString') + def _on_proxy_authentication_required(self, _url, authenticator, + proxy_host): + """Called when a proxy needs authentication.""" + msg = "{} requires a username and password.".format( + html.escape(proxy_host)) + answer = message.ask( + title="Proxy authentication required", text=msg, + mode=usertypes.PromptMode.user_pwd, + abort_on=[self.shutting_down, self.load_started]) + if answer is not None: + authenticator.setUser(answer.user) + authenticator.setPassword(answer.password) + @pyqtSlot(QUrl, 'QAuthenticator*') def _on_authentication_required(self, url, authenticator): # FIXME:qtwebengine support .netrc @@ -759,6 +774,8 @@ class WebEngineTab(browsertab.AbstractTab): page.loadFinished.connect(self._on_load_finished) page.certificate_error.connect(self._on_ssl_errors) page.authenticationRequired.connect(self._on_authentication_required) + page.proxyAuthenticationRequired.connect( + self._on_proxy_authentication_required) page.fullScreenRequested.connect(self._on_fullscreen_requested) page.contentsSizeChanged.connect(self.contents_size_changed) From 888a1b8c578bfd4f0481bafbc5bcdb67cd178f7c Mon Sep 17 00:00:00 2001 From: Ryan Roden-Corrent Date: Fri, 22 Sep 2017 08:30:41 -0400 Subject: [PATCH 056/186] Append multiple history backups on import. Previously, a successful import of the text history into sqlite would move 'history' to 'history.bak'. If history.bak already existed, this would overwrite it on unix and fail on windows. With this patch, the most recently imported history is appended to history.bak to avoid data loss. Resolves #3005. A few other options I considered: 1. os.replace: - fast, simple, no error on Windows - potential data loss 2. numbered backups (.bak.1, .bak.2, ...): - fast, no data loss, but more complex 3. append each line to the backup as it is read: - more efficient than current patch (no need to read history twice) - potentially duplicate data if backup fails --- qutebrowser/browser/history.py | 15 +++++++++++---- tests/unit/browser/test_history.py | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index c9609bc03..ca62d9e6c 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -252,10 +252,7 @@ class WebHistory(sql.SqlTable): except ValueError as ex: message.error('Failed to import history: {}'.format(ex)) else: - bakpath = path + '.bak' - message.info('History import complete. Moving {} to {}' - .format(path, bakpath)) - os.rename(path, bakpath) + self._write_backup(path) # delay to give message time to appear before locking down for import message.info('Converting {} to sqlite...'.format(path)) @@ -287,6 +284,16 @@ class WebHistory(sql.SqlTable): self.insert_batch(data) self.completion.insert_batch(completion_data, replace=True) + def _write_backup(self, path): + bak = path + '.bak' + message.info('History import complete. Appending {} to {}' + .format(path, bak)) + with open(path, 'r', encoding='utf-8') as infile: + with open(bak, 'a', encoding='utf-8') as outfile: + for line in infile: + outfile.write('\n' + line) + os.remove(path) + def _format_url(self, url): return url.toString(QUrl.RemovePassword | QUrl.FullyEncoded) diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py index 51157fccb..1454e259b 100644 --- a/tests/unit/browser/test_history.py +++ b/tests/unit/browser/test_history.py @@ -284,6 +284,22 @@ def test_import_txt(hist, data_tmpdir, monkeypatch, stubs): assert (data_tmpdir / 'history.bak').exists() +def test_import_txt_existing_backup(hist, data_tmpdir, monkeypatch, stubs): + monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer) + histfile = data_tmpdir / 'history' + bakfile = data_tmpdir / 'history.bak' + histfile.write('12345 http://example.com/ title') + bakfile.write('12346 http://qutebrowser.org/') + + hist.import_txt() + + assert list(hist) == [('http://example.com/', 'title', 12345, False)] + + assert not histfile.exists() + assert bakfile.read().split('\n') == ['12346 http://qutebrowser.org/', + '12345 http://example.com/ title'] + + @pytest.mark.parametrize('line', [ '', '#12345 http://example.com/commented', From 1784dc777d6a5c0ebd1cd616d1e88a72bceb2efc Mon Sep 17 00:00:00 2001 From: arza Date: Sat, 23 Sep 2017 22:24:17 +0300 Subject: [PATCH 057/186] Add table headers and widen input fields in qute://settings --- qutebrowser/html/settings.html | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/qutebrowser/html/settings.html b/qutebrowser/html/settings.html index 217e052af..b370c0d91 100644 --- a/qutebrowser/html/settings.html +++ b/qutebrowser/html/settings.html @@ -17,6 +17,9 @@ pre { margin: 2px; } th, td { border: 1px solid grey; padding: 0px 5px; } th { background: lightgrey; } th pre { color: grey; text-align: left; } +input { width: 98%; } +.setting { width: 75%; } +.value { width: 25%; text-align: center; } .noscript, .noscript-text { color:red; } .noscript-text { margin-bottom: 5cm; } .option_description { margin: .5ex 0; color: grey; font-size: 80%; font-style: italic; white-space: pre-line; } @@ -26,15 +29,19 @@ th pre { color: grey; text-align: left; }

{{ title }}

+ + + + {% for option in configdata.DATA.values() %} - -
SettingValue
{{ option.name }} (Current: {{ confget(option.name) | string |truncate(100) }}) + {{ option.name }} (Current: {{ confget(option.name) | string |truncate(100) }}) {% if option.description %}

{{ option.description|e }}

{% endif %}
+ Date: Sat, 23 Sep 2017 22:38:36 +0300 Subject: [PATCH 058/186] Remove extra backslashes in configdata.yml --- qutebrowser/config/configdata.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 9f67689b0..642afb634 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -562,7 +562,7 @@ content.xss_auditing: desc: >- Whether load requests should be monitored for cross-site scripting attempts. - Suspicious scripts will be blocked and reported in the inspector\'s + Suspicious scripts will be blocked and reported in the inspector's JavaScript console. Enabling this feature might have an impact on performance. @@ -917,7 +917,7 @@ keyhint.blacklist: name: String default: [] desc: >- - Keychains that shouldn\'t be shown in the keyhint dialog. + Keychains that shouldn't be shown in the keyhint dialog. Globs are supported, so `;*` will blacklist all keychains starting with `;`. Use `*` to disable keyhints. @@ -1734,7 +1734,7 @@ fonts.monospace: desc: >- Default monospace fonts. - Whenever "monospace" is used in a font setting, it\'s replaced with the + Whenever "monospace" is used in a font setting, it's replaced with the fonts listed here. fonts.completion.entry: From 40f0f75ad5706aa150fc01078f265f31d61a2a79 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 24 Sep 2017 19:42:39 +0200 Subject: [PATCH 059/186] Improve error message for duplicate keys in config.py --- qutebrowser/config/configfiles.py | 6 +++++- tests/unit/config/test_configfiles.py | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index c9720a6d8..62cd6088c 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -212,7 +212,11 @@ class ConfigAPI: def bind(self, key, command, mode='normal', *, force=False): with self._handle_error('binding', key): - self._keyconfig.bind(key, command, mode=mode, force=force) + try: + self._keyconfig.bind(key, command, mode=mode, force=force) + except configexc.DuplicateKeyError as e: + raise configexc.KeybindingError('{} - use force=True to ' + 'override!'.format(e)) def unbind(self, key, mode='normal'): with self._handle_error('unbinding', key): diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 6a58659fa..a2ed4099a 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -364,6 +364,15 @@ class TestConfigPy: "config.bind(',f', 'foo')") confpy.read() + def test_bind_duplicate_key(self, confpy): + """Make sure we get a nice error message on duplicate key bindings.""" + confpy.write("config.bind('H', 'message-info back')") + api = confpy.read(error=True) + error = api.errors[0] + + expected = "Duplicate key H - use force=True to override!" + assert str(error.exception) == expected + @pytest.mark.parametrize('line, key, mode', [ ('config.unbind("o")', 'o', 'normal'), ('config.unbind("y", mode="prompt")', 'y', 'prompt'), From d7273283ce00e23f22a8eabba3f467af81e42eb4 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 25 Sep 2017 06:55:17 +0200 Subject: [PATCH 060/186] Regenerate docs --- doc/help/settings.asciidoc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 620e4467c..c600e820b 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -201,7 +201,7 @@ |<>|Timeout (in milliseconds) for partially typed key bindings. |<>|Enable Opera-like mouse rocker gestures. |<>|Enable Spatial Navigation. -|<>|Keychains that shouldn\'t be shown in the keyhint dialog. +|<>|Keychains that shouldn't be shown in the keyhint dialog. |<>|Time from pressing a key to seeing the keyhint dialog (ms). |<>|Time (in ms) to show messages in the statusbar for. |<>|Show messages in unfocused windows. @@ -1955,7 +1955,7 @@ Default: +pass:[true]+ [[content.xss_auditing]] === content.xss_auditing Whether load requests should be monitored 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. +Suspicious scripts will be blocked and reported in the inspector's JavaScript console. Enabling this feature might have an impact on performance. Type: <> @@ -2144,7 +2144,7 @@ Default: +pass:[8pt monospace]+ [[fonts.monospace]] === fonts.monospace Default monospace fonts. -Whenever "monospace" is used in a font setting, it\'s replaced with the fonts listed here. +Whenever "monospace" is used in a font setting, it's replaced with the fonts listed here. Type: <> @@ -2535,7 +2535,7 @@ Default: +pass:[false]+ [[keyhint.blacklist]] === keyhint.blacklist -Keychains that shouldn\'t be shown in the keyhint dialog. +Keychains that shouldn't be shown in the keyhint dialog. Globs are supported, so `;*` will blacklist all keychains starting with `;`. Use `*` to disable keyhints. Type: <> From 8408d6ed9b735b51c3425f7c4cd3f164875999d8 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 25 Sep 2017 06:56:33 +0200 Subject: [PATCH 061/186] Fix emacs syntax highlighting in configdata.yml --- qutebrowser/config/configdata.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 642afb634..a3b0023b1 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -566,6 +566,8 @@ content.xss_auditing: JavaScript console. Enabling this feature might have an impact on performance. +# emacs: ' + ## completion completion.cmd_history_max_items: @@ -922,6 +924,8 @@ keyhint.blacklist: Globs are supported, so `;*` will blacklist all keychains starting with `;`. Use `*` to disable keyhints. +# emacs: ' + keyhint.delay: type: name: Int @@ -1737,6 +1741,8 @@ fonts.monospace: Whenever "monospace" is used in a font setting, it's replaced with the fonts listed here. +# emacs: ' + fonts.completion.entry: default: 8pt monospace type: Font From 78bddaefe61c8fefb79203a0d6383bb0c8c22684 Mon Sep 17 00:00:00 2001 From: Ian Walker Date: Mon, 25 Sep 2017 14:47:54 +0900 Subject: [PATCH 062/186] Move _on_proxy_authentication_required to WebEngineTab --- qutebrowser/browser/webengine/webview.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index 665d3f80a..fd6fc99cb 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -20,7 +20,6 @@ """The main browser widget for QtWebEngine.""" import functools -import html from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, PYQT_VERSION from PyQt5.QtGui import QPalette @@ -134,8 +133,6 @@ class WebEnginePage(QWebEnginePage): self._is_shutting_down = False self.featurePermissionRequested.connect( self._on_feature_permission_requested) - self.proxyAuthenticationRequired.connect( - self._on_proxy_authentication_required) self._theme_color = theme_color self._set_bg_color() objreg.get('config').changed.connect(self._set_bg_color) @@ -147,20 +144,6 @@ class WebEnginePage(QWebEnginePage): col = self._theme_color self.setBackgroundColor(col) - @pyqtSlot(QUrl, 'QAuthenticator*', 'QString') - def _on_proxy_authentication_required(self, _url, authenticator, - proxy_host): - """Called when a proxy needs authentication.""" - msg = "{} requires a username and password.".format( - html.escape(proxy_host)) - answer = message.ask( - title="Proxy authentication required", text=msg, - mode=usertypes.PromptMode.user_pwd, - abort_on=[self.loadStarted, self.shutting_down]) - if answer is not None: - authenticator.setUser(answer.user) - authenticator.setPassword(answer.password) - @pyqtSlot(QUrl, 'QWebEnginePage::Feature') def _on_feature_permission_requested(self, url, feature): """Ask the user for approval for geolocation/media/etc..""" From ad2bb454460af1185652c1a7409a2990661ea135 Mon Sep 17 00:00:00 2001 From: Ian Walker Date: Mon, 25 Sep 2017 14:48:54 +0900 Subject: [PATCH 063/186] Allow user to cancel proxy authentication request --- qutebrowser/browser/webengine/webenginetab.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index a4ad1f3be..43e682c07 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -22,7 +22,7 @@ import os import math import functools -import html +import html as html_utils import sip from PyQt5.QtCore import pyqtSlot, Qt, QEvent, QPoint, QUrl, QTimer @@ -684,11 +684,11 @@ class WebEngineTab(browsertab.AbstractTab): self.add_history_item.emit(url, requested_url, title) @pyqtSlot(QUrl, 'QAuthenticator*', 'QString') - def _on_proxy_authentication_required(self, _url, authenticator, + def _on_proxy_authentication_required(self, url, authenticator, proxy_host): """Called when a proxy needs authentication.""" msg = "{} requires a username and password.".format( - html.escape(proxy_host)) + html_utils.escape(proxy_host)) answer = message.ask( title="Proxy authentication required", text=msg, mode=usertypes.PromptMode.user_pwd, @@ -696,6 +696,18 @@ class WebEngineTab(browsertab.AbstractTab): if answer is not None: authenticator.setUser(answer.user) authenticator.setPassword(answer.password) + else: + try: + # pylint: disable=no-member, useless-suppression + sip.assign(authenticator, QAuthenticator()) + 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) @pyqtSlot(QUrl, 'QAuthenticator*') def _on_authentication_required(self, url, authenticator): From c7c198b9496e4a096ba326a6bf24ca5400459e3e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 25 Sep 2017 08:22:40 +0200 Subject: [PATCH 064/186] Stabilize hint test --- tests/end2end/features/hints.feature | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index 889e9e03e..8552586fa 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -362,6 +362,7 @@ Feature: Using hints And I set hints.mode to letter And I hint with args "--mode number all" And I press the key "s" + And I wait for "Filtering hints on 's'" in the log And I run :follow-hint 1 Then data/numbers/7.txt should be loaded From 3605b1b5105a97c8f234ddac44d7ac55817b7e02 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 25 Sep 2017 16:47:13 +0200 Subject: [PATCH 065/186] Update setuptools from 36.2.7 to 36.5.0 --- 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 c6894673f..9adee96a0 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==36.2.7 +setuptools==36.5.0 six==1.10.0 wheel==0.29.0 From 9ecc0d8ef7d31b747012376df8f2303eebaa68fa Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 25 Sep 2017 16:47:14 +0200 Subject: [PATCH 066/186] Update six from 1.10.0 to 1.11.0 --- 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 9adee96a0..238c6f7e6 100644 --- a/misc/requirements/requirements-pip.txt +++ b/misc/requirements/requirements-pip.txt @@ -4,5 +4,5 @@ appdirs==1.4.3 packaging==16.8 pyparsing==2.2.0 setuptools==36.5.0 -six==1.10.0 +six==1.11.0 wheel==0.29.0 From d23d53de1c9f4c28560652bbdb12590303bb2b71 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 25 Sep 2017 16:47:16 +0200 Subject: [PATCH 067/186] Update wheel from 0.29.0 to 0.30.0 --- 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 238c6f7e6..b8d75bcc1 100644 --- a/misc/requirements/requirements-pip.txt +++ b/misc/requirements/requirements-pip.txt @@ -5,4 +5,4 @@ packaging==16.8 pyparsing==2.2.0 setuptools==36.5.0 six==1.11.0 -wheel==0.29.0 +wheel==0.30.0 From 2a1f628e4e8195c1e047becd4fe5bb4719845af8 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 25 Sep 2017 16:47:17 +0200 Subject: [PATCH 068/186] Update hypothesis from 3.28.3 to 3.30.3 --- 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 a5fc30e64..8a58b7680 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.1 -hypothesis==3.28.3 +hypothesis==3.30.3 itsdangerous==0.24 # Jinja2==2.9.6 Mako==1.0.7 From 414dc29493430bc20a57155db643186ece390358 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 25 Sep 2017 16:47:19 +0200 Subject: [PATCH 069/186] Update parse-type from 0.3.4 to 0.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 8a58b7680..0d0d39f50 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -17,7 +17,7 @@ itsdangerous==0.24 Mako==1.0.7 # MarkupSafe==1.0 parse==1.8.2 -parse-type==0.3.4 +parse-type==0.4.1 py==1.4.34 py-cpuinfo==3.3.0 pytest==3.2.2 From 4d4eee15d6b45a5caf73d55842de43e4664b25ca Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 25 Sep 2017 16:47:20 +0200 Subject: [PATCH 070/186] Update pluggy from 0.4.0 to 0.5.2 --- misc/requirements/requirements-tox.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt index d83654c98..33dec30aa 100644 --- a/misc/requirements/requirements-tox.txt +++ b/misc/requirements/requirements-tox.txt @@ -1,6 +1,6 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -pluggy==0.4.0 +pluggy==0.5.2 py==1.4.34 tox==2.8.2 virtualenv==15.1.0 From 54eb23eab162fffed2bf9e16c986f2a41263beb8 Mon Sep 17 00:00:00 2001 From: Panagiotis Ktistakis Date: Mon, 25 Sep 2017 20:54:28 +0300 Subject: [PATCH 071/186] Fix the link to faq.html in help page --- doc/help/index.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/help/index.asciidoc b/doc/help/index.asciidoc index 4edea719e..e90d472b5 100644 --- a/doc/help/index.asciidoc +++ b/doc/help/index.asciidoc @@ -7,7 +7,7 @@ Documentation The following help pages are currently available: * link:../quickstart.html[Quick start guide] -* link:../doc.html[Frequently asked questions] +* link:../faq.html[Frequently asked questions] * link:../changelog.html[Change Log] * link:commands.html[Documentation of commands] * link:configuring.html[Configuring qutebrowser] From 5a080207ff859e8f72d6b5acef513fcfd5669528 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 25 Sep 2017 21:20:30 +0200 Subject: [PATCH 072/186] Bump up hypothesis deadline some more --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index db50afcd6..2ccf646dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,7 @@ import qutebrowser.app # To register commands # Set hypothesis settings hypothesis.settings.register_profile('default', - hypothesis.settings(deadline=400)) + hypothesis.settings(deadline=600)) hypothesis.settings.load_profile('default') From 6aed6bca93ab910cc1ceb7adf540fd77d8410a75 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 25 Sep 2017 19:32:06 +0200 Subject: [PATCH 073/186] Make loading autoconfig.yml opt-in when a config.py exists This lets the user control the precedence those files should have, and also simplifies the code quite a bit. Fixes #2975 --- doc/help/configuring.asciidoc | 17 ++++---- qutebrowser/config/configexc.py | 6 +++ qutebrowser/config/configfiles.py | 34 ++++++++++----- qutebrowser/config/configinit.py | 41 +++++++----------- tests/unit/config/test_configexc.py | 7 ++++ tests/unit/config/test_configfiles.py | 60 +++++++++++---------------- tests/unit/config/test_configinit.py | 30 ++++++++------ 7 files changed, 103 insertions(+), 92 deletions(-) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index b68e0bedc..afb6ec8e6 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -204,21 +204,22 @@ config.bind(',v', 'spawn mpv {url}') To suppress loading of any default keybindings, you can set `c.bindings.default = {}`. -Prevent loading `autoconfig.yml` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Loading `autoconfig.yml` +~~~~~~~~~~~~~~~~~~~~~~~~ -If you want all customization done via `:set`, `:bind` and `:unbind` to be -temporary, you can suppress loading `autoconfig.yml` in your `config.py` by -doing: +By default, all customization done via `:set`, `:bind` and `:unbind` is +temporary as soon as a `config.py` exists. The settings done that way are always +saved in the `autoconfig.yml` file, but you'll need to explicitly load it in +your `config.py` by doing: .config.py: [source,python] ---- -config.load_autoconfig = False +config.load_autoconfig() ---- -Note that the settings are still saved in `autoconfig.yml` that way, but then -not loaded on start. +If you do so at the top of your file, your `config.py` settings will take +precedence as they overwrite the settings done in `autoconfig.yml`. Importing other modules ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index 4d283d21f..0d20bb09d 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -94,6 +94,12 @@ class ConfigErrorDesc: def __str__(self): return '{}: {}'.format(self.text, self.exception) + def with_text(self, text): + """Get a new ConfigErrorDesc with the given text appended.""" + return self.__class__(text='{} ({})'.format(self.text, text), + exception=self.exception, + traceback=self.traceback) + class ConfigFileErrors(Error): diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 62cd6088c..a017d025d 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -176,7 +176,6 @@ class ConfigAPI: Attributes: _config: The main Config object to use. _keyconfig: The KeyConfig object. - load_autoconfig: Whether autoconfig.yml should be loaded. errors: Errors which occurred while setting options. configdir: The qutebrowser config directory, as pathlib.Path. datadir: The qutebrowser data directory, as pathlib.Path. @@ -185,7 +184,6 @@ class ConfigAPI: def __init__(self, conf, keyconfig): self._config = conf self._keyconfig = keyconfig - self.load_autoconfig = True self.errors = [] self.configdir = pathlib.Path(standarddir.config()) self.datadir = pathlib.Path(standarddir.data()) @@ -194,6 +192,10 @@ class ConfigAPI: def _handle_error(self, action, name): try: yield + except configexc.ConfigFileErrors as e: + for err in e.errors: + new_err = err.with_text(e.basename) + self.errors.append(new_err) except configexc.Error as e: text = "While {} '{}'".format(action, name) self.errors.append(configexc.ConfigErrorDesc(text, e)) @@ -202,6 +204,10 @@ class ConfigAPI: """Do work which needs to be done after reading config.py.""" self._config.update_mutables() + def load_autoconfig(self): + with self._handle_error('reading', 'autoconfig.yml'): + read_autoconfig() + def get(self, name): with self._handle_error('getting', name): return self._config.get_obj(name) @@ -223,20 +229,15 @@ class ConfigAPI: self._keyconfig.unbind(key, mode=mode) -def read_config_py(filename=None, raising=False): +def read_config_py(filename, raising=False): """Read a config.py file. Arguments; + filename: The name of the file to read. raising: Raise exceptions happening in config.py. This is needed during tests to use pytest's inspection. """ api = ConfigAPI(config.instance, config.key_instance) - - if filename is None: - filename = os.path.join(standarddir.config(), 'config.py') - if not os.path.exists(filename): - return api - container = config.ConfigContainer(config.instance, configapi=api) basename = os.path.basename(filename) @@ -282,7 +283,20 @@ def read_config_py(filename=None, raising=False): exception=e, traceback=traceback.format_exc())) api.finalize() - return api + + if api.errors: + raise configexc.ConfigFileErrors('config.py', api.errors) + + +def read_autoconfig(): + """Read the autoconfig.yml file.""" + try: + config.instance.read_yaml() + except configexc.ConfigFileErrors as e: + raise # caught in outer block + except configexc.Error as e: + desc = configexc.ConfigErrorDesc("Error", e) + raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) @contextlib.contextmanager diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index 27a37b5e9..a64438b83 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -19,18 +19,19 @@ """Initialization of the configuration.""" +import os.path import sys from PyQt5.QtWidgets import QMessageBox from qutebrowser.config import (config, configdata, configfiles, configtypes, configexc) -from qutebrowser.utils import objreg, qtutils, usertypes, log +from qutebrowser.utils import objreg, qtutils, usertypes, log, standarddir from qutebrowser.misc import earlyinit, msgbox, objects -# Errors which happened during init, so we can show a message box. -_init_errors = [] +# Error which happened during init, so we can show a message box. +_init_errors = None def early_init(args): @@ -52,29 +53,17 @@ def early_init(args): config.key_instance) objreg.register('config-commands', config_commands) - config_api = None + config_file = os.path.join(standarddir.config(), 'config.py') try: - config_api = configfiles.read_config_py() - # Raised here so we get the config_api back. - if config_api.errors: - raise configexc.ConfigFileErrors('config.py', config_api.errors) + if os.path.exists(config_file): + configfiles.read_config_py(config_file) + else: + configfiles.read_autoconfig() except configexc.ConfigFileErrors as e: - log.config.exception("Error while loading config.py") - _init_errors.append(e) - - try: - if getattr(config_api, 'load_autoconfig', True): - try: - config.instance.read_yaml() - except configexc.ConfigFileErrors as e: - raise # caught in outer block - except configexc.Error as e: - desc = configexc.ConfigErrorDesc("Error", e) - raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - except configexc.ConfigFileErrors as e: - log.config.exception("Error while loading config.py") - _init_errors.append(e) + log.config.exception("Error while loading {}".format(e.basename)) + global _init_errors + _init_errors = e configfiles.init() @@ -109,14 +98,14 @@ def get_backend(args): def late_init(save_manager): """Initialize the rest of the config after the QApplication is created.""" global _init_errors - for err in _init_errors: + if _init_errors is not None: errbox = msgbox.msgbox(parent=None, title="Error while reading config", - text=err.to_html(), + text=_init_errors.to_html(), icon=QMessageBox.Warning, plain_text=False) errbox.exec_() - _init_errors = [] + _init_errors = None config.instance.init_save_manager(save_manager) configfiles.state.init_save_manager(save_manager) diff --git a/tests/unit/config/test_configexc.py b/tests/unit/config/test_configexc.py index 8eaa21f05..024bbb1d0 100644 --- a/tests/unit/config/test_configexc.py +++ b/tests/unit/config/test_configexc.py @@ -49,6 +49,13 @@ def test_duplicate_key_error(): assert str(e) == "Duplicate key asdf" +def test_desc_with_text(): + """Test ConfigErrorDesc.with_text.""" + old = configexc.ConfigErrorDesc("Error text", Exception("Exception text")) + new = old.with_text("additional text") + assert str(new) == 'Error text (additional text): Exception text' + + @pytest.fixture def errors(): """Get a ConfigFileErrors object.""" diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index a2ed4099a..7ce29484a 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -222,9 +222,15 @@ class ConfPy: def read(self, error=False): """Read the config.py via configfiles and check for errors.""" - api = configfiles.read_config_py(self.filename, raising=not error) - assert len(api.errors) == (1 if error else 0) - return api + if error: + with pytest.raises(configexc.ConfigFileErrors) as excinfo: + configfiles.read_config_py(self.filename) + errors = excinfo.value.errors + assert len(errors) == 1 + return errors[0] + else: + configfiles.read_config_py(self.filename, raising=True) + return None def write_qbmodule(self): self.write('import qbmodule', @@ -263,8 +269,7 @@ class TestConfigPyModules: confpy.write_qbmodule() qbmodulepy.write('def run(config):', ' 1/0') - api = confpy.read(error=True) - error = api.errors[0] + error = confpy.read(error=True) assert error.text == "Unhandled exception" assert isinstance(error.exception, ZeroDivisionError) @@ -277,8 +282,7 @@ class TestConfigPyModules: confpy.write('import foobar', 'foobar.run(config)') - api = confpy.read(error=True) - error = api.errors[0] + error = confpy.read(error=True) assert error.text == "Unhandled exception" assert isinstance(error.exception, ImportError) @@ -367,8 +371,7 @@ class TestConfigPy: def test_bind_duplicate_key(self, confpy): """Make sure we get a nice error message on duplicate key bindings.""" confpy.write("config.bind('H', 'message-info back')") - api = confpy.read(error=True) - error = api.errors[0] + error = confpy.read(error=True) expected = "Duplicate key H - use force=True to override!" assert str(error.exception) == expected @@ -390,17 +393,6 @@ class TestConfigPy: assert config.instance._values['aliases']['foo'] == 'message-info foo' assert config.instance._values['aliases']['bar'] == 'message-info bar' - def test_reading_default_location(self, config_tmpdir, data_tmpdir): - (config_tmpdir / 'config.py').write_text( - 'c.colors.hints.bg = "red"', 'utf-8') - configfiles.read_config_py() - assert config.instance._values['colors.hints.bg'] == 'red' - - def test_reading_missing_default_location(self, config_tmpdir, - data_tmpdir): - assert not (config_tmpdir / 'config.py').exists() - configfiles.read_config_py() # Should not crash - def test_oserror(self, tmpdir, data_tmpdir, config_tmpdir): with pytest.raises(configexc.ConfigFileErrors) as excinfo: configfiles.read_config_py(str(tmpdir / 'foo')) @@ -443,12 +435,9 @@ class TestConfigPy: assert " ^" in tblines def test_unhandled_exception(self, confpy): - confpy.write("config.load_autoconfig = False", "1/0") + confpy.write("1/0") + error = confpy.read(error=True) - api = confpy.read(error=True) - error = api.errors[0] - - assert not api.load_autoconfig assert error.text == "Unhandled exception" assert isinstance(error.exception, ZeroDivisionError) @@ -460,8 +449,7 @@ class TestConfigPy: def test_config_val(self, confpy): """Using config.val should not work in config.py files.""" confpy.write("config.val.colors.hints.bg = 'red'") - api = confpy.read(error=True) - error = api.errors[0] + error = confpy.read(error=True) assert error.text == "Unhandled exception" assert isinstance(error.exception, AttributeError) @@ -470,11 +458,9 @@ class TestConfigPy: @pytest.mark.parametrize('line', ["c.foo = 42", "config.set('foo', 42)"]) def test_config_error(self, confpy, line): - confpy.write(line, "config.load_autoconfig = False") - api = confpy.read(error=True) - error = api.errors[0] + confpy.write(line) + error = confpy.read(error=True) - assert not api.load_autoconfig assert error.text == "While setting 'foo'" assert isinstance(error.exception, configexc.NoOptionError) assert str(error.exception) == "No option 'foo'" @@ -482,16 +468,20 @@ class TestConfigPy: def test_multiple_errors(self, confpy): confpy.write("c.foo = 42", "config.set('foo', 42)", "1/0") - api = configfiles.read_config_py(confpy.filename) - assert len(api.errors) == 3 - for error in api.errors[:2]: + with pytest.raises(configexc.ConfigFileErrors) as excinfo: + configfiles.read_config_py(confpy.filename) + + errors = excinfo.value.errors + assert len(errors) == 3 + + for error in errors[:2]: assert error.text == "While setting 'foo'" assert isinstance(error.exception, configexc.NoOptionError) assert str(error.exception) == "No option 'foo'" assert error.traceback is None - error = api.errors[2] + error = errors[2] assert error.text == "Unhandled exception" assert isinstance(error.exception, ZeroDivisionError) assert error.traceback is not None diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index fef352584..d13624c66 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -38,7 +38,7 @@ def init_patch(qapp, fake_save_manager, monkeypatch, config_tmpdir, monkeypatch.setattr(config, 'instance', None) monkeypatch.setattr(config, 'key_instance', None) monkeypatch.setattr(config, 'change_filters', []) - monkeypatch.setattr(configinit, '_init_errors', []) + monkeypatch.setattr(configinit, '_init_errors', None) # Make sure we get no SSL warning monkeypatch.setattr(configinit.earlyinit, 'check_backend_ssl_support', lambda _backend: None) @@ -73,8 +73,8 @@ def test_early_init(init_patch, config_tmpdir, caplog, fake_args, if config_py: config_py_lines = ['c.colors.hints.bg = "red"'] - if not load_autoconfig: - config_py_lines.append('config.load_autoconfig = False') + if load_autoconfig: + config_py_lines.append('config.load_autoconfig()') if config_py == 'error': config_py_lines.append('c.foo = 42') config_py_file.write_text('\n'.join(config_py_lines), @@ -85,21 +85,25 @@ def test_early_init(init_patch, config_tmpdir, caplog, fake_args, # Check error messages expected_errors = [] - if config_py == 'error': - expected_errors.append( - "Errors occurred while reading config.py:\n" - " While setting 'foo': No option 'foo'") + if load_autoconfig or not config_py: - error = "Errors occurred while reading autoconfig.yml:\n" + suffix = ' (autoconfig.yml)' if config_py else '' if invalid_yaml == '42': - error += " While loading data: Toplevel object is not a dict" + error = ("While loading data{}: Toplevel object is not a dict" + .format(suffix)) expected_errors.append(error) elif invalid_yaml == 'wrong-type': - error += (" Error: Invalid value 'True' - expected a value of " - "type str but got bool.") + error = ("Error{}: Invalid value 'True' - expected a value of " + "type str but got bool.".format(suffix)) expected_errors.append(error) + if config_py == 'error': + expected_errors.append("While setting 'foo': No option 'foo'") + + if configinit._init_errors is None: + actual_errors = [] + else: + actual_errors = [str(err) for err in configinit._init_errors.errors] - actual_errors = [str(err) for err in configinit._init_errors] assert actual_errors == expected_errors # Make sure things have been init'ed @@ -134,7 +138,7 @@ def test_late_init(init_patch, monkeypatch, fake_save_manager, fake_args, if errors: err = configexc.ConfigErrorDesc("Error text", Exception("Exception")) errs = configexc.ConfigFileErrors("config.py", [err]) - monkeypatch.setattr(configinit, '_init_errors', [errs]) + monkeypatch.setattr(configinit, '_init_errors', errs) msgbox_mock = mocker.patch('qutebrowser.config.configinit.msgbox.msgbox', autospec=True) From 1086e31f2898f6ac85b8a561e2cc7f9c72f7623b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 25 Sep 2017 20:09:43 +0200 Subject: [PATCH 074/186] Split up configinit tests --- tests/unit/config/test_configinit.py | 172 ++++++++++++++++----------- 1 file changed, 104 insertions(+), 68 deletions(-) diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index d13624c66..7ec371a6d 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -49,86 +49,122 @@ def init_patch(qapp, fake_save_manager, monkeypatch, config_tmpdir, pass -@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]) -# pylint: disable=too-many-branches -def test_early_init(init_patch, config_tmpdir, caplog, fake_args, - load_autoconfig, config_py, invalid_yaml): - # Prepare files - autoconfig_file = config_tmpdir / 'autoconfig.yml' - config_py_file = config_tmpdir / 'config.py' +class TestEarlyInit: - if invalid_yaml == '42': - text = '42' - elif invalid_yaml == 'unknown': - text = 'global:\n colors.foobar: magenta\n' - elif invalid_yaml == 'wrong-type': - text = 'global:\n tabs.position: true\n' - else: - assert not invalid_yaml - text = 'global:\n colors.hints.fg: magenta\n' - autoconfig_file.write_text(text, 'utf-8', ensure=True) + @pytest.mark.parametrize('config_py', [True, 'error', False]) + def test_config_py(self, init_patch, config_tmpdir, caplog, fake_args, + config_py): + """Test loading with only a config.py.""" + config_py_file = config_tmpdir / 'config.py' - if config_py: - config_py_lines = ['c.colors.hints.bg = "red"'] - if load_autoconfig: - config_py_lines.append('config.load_autoconfig()') + if config_py: + config_py_lines = ['c.colors.hints.bg = "red"'] + if config_py == 'error': + config_py_lines.append('c.foo = 42') + config_py_file.write_text('\n'.join(config_py_lines), + 'utf-8', ensure=True) + + with caplog.at_level(logging.ERROR): + configinit.early_init(fake_args) + + # Check error messages + expected_errors = [] if config_py == 'error': - config_py_lines.append('c.foo = 42') - config_py_file.write_text('\n'.join(config_py_lines), - 'utf-8', ensure=True) + expected_errors.append("While setting 'foo': No option 'foo'") - with caplog.at_level(logging.ERROR): - configinit.early_init(fake_args) + if configinit._init_errors is None: + actual_errors = [] + else: + actual_errors = [str(err) + for err in configinit._init_errors.errors] - # Check error messages - expected_errors = [] + assert actual_errors == expected_errors - if load_autoconfig or not config_py: - suffix = ' (autoconfig.yml)' if config_py else '' - if invalid_yaml == '42': - error = ("While loading data{}: Toplevel object is not a dict" - .format(suffix)) - expected_errors.append(error) - elif invalid_yaml == 'wrong-type': - error = ("Error{}: Invalid value 'True' - expected a value of " - "type str but got bool.".format(suffix)) - expected_errors.append(error) - if config_py == 'error': - expected_errors.append("While setting 'foo': No option 'foo'") + # Make sure things have been init'ed + objreg.get('config-commands') + assert isinstance(config.instance, config.Config) + assert isinstance(config.key_instance, config.KeyConfig) - if configinit._init_errors is None: - actual_errors = [] - else: - actual_errors = [str(err) for err in configinit._init_errors.errors] + # Check config values + if config_py: + assert config.instance._values == {'colors.hints.bg': 'red'} + else: + assert config.instance._values == {} - assert actual_errors == expected_errors + @pytest.mark.parametrize('load_autoconfig', [True, False]) + @pytest.mark.parametrize('config_py', [True, 'error', False]) + @pytest.mark.parametrize('invalid_yaml', ['42', 'unknown', 'wrong-type', + False]) + def test_autoconfig_yml(self, init_patch, config_tmpdir, caplog, fake_args, + load_autoconfig, config_py, invalid_yaml): + """Test interaction between config.py and autoconfig.yml.""" + # pylint: disable=too-many-locals,too-many-branches + # Prepare files + autoconfig_file = config_tmpdir / 'autoconfig.yml' + config_py_file = config_tmpdir / 'config.py' - # Make sure things have been init'ed - objreg.get('config-commands') - assert isinstance(config.instance, config.Config) - assert isinstance(config.key_instance, config.KeyConfig) - - # Check config values - if config_py and load_autoconfig and not invalid_yaml: - assert config.instance._values == { - 'colors.hints.bg': 'red', - 'colors.hints.fg': 'magenta', + yaml_text = { + '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', } - elif config_py: - assert config.instance._values == {'colors.hints.bg': 'red'} - elif invalid_yaml: - assert config.instance._values == {} - else: - assert config.instance._values == {'colors.hints.fg': 'magenta'} + autoconfig_file.write_text(yaml_text[invalid_yaml], 'utf-8', + ensure=True) + if config_py: + config_py_lines = ['c.colors.hints.bg = "red"'] + if load_autoconfig: + config_py_lines.append('config.load_autoconfig()') + if config_py == 'error': + config_py_lines.append('c.foo = 42') + config_py_file.write_text('\n'.join(config_py_lines), + 'utf-8', ensure=True) -def test_early_init_invalid_change_filter(init_patch, fake_args): - config.change_filter('foobar') - with pytest.raises(configexc.NoOptionError): - configinit.early_init(fake_args) + with caplog.at_level(logging.ERROR): + configinit.early_init(fake_args) + + # Check error messages + expected_errors = [] + + if load_autoconfig or not config_py: + suffix = ' (autoconfig.yml)' if config_py else '' + if invalid_yaml == '42': + error = ("While loading data{}: Toplevel object is not a dict" + .format(suffix)) + expected_errors.append(error) + elif invalid_yaml == 'wrong-type': + error = ("Error{}: Invalid value 'True' - expected a value of " + "type str but got bool.".format(suffix)) + expected_errors.append(error) + if config_py == 'error': + expected_errors.append("While setting 'foo': No option 'foo'") + + if configinit._init_errors is None: + actual_errors = [] + else: + actual_errors = [str(err) + for err in configinit._init_errors.errors] + + assert actual_errors == expected_errors + + # Check config values + if config_py and load_autoconfig and not invalid_yaml: + assert config.instance._values == { + 'colors.hints.bg': 'red', + 'colors.hints.fg': 'magenta', + } + elif config_py: + assert config.instance._values == {'colors.hints.bg': 'red'} + elif invalid_yaml: + assert config.instance._values == {} + else: + assert config.instance._values == {'colors.hints.fg': 'magenta'} + + def test_invalid_change_filter(self, init_patch, fake_args): + config.change_filter('foobar') + with pytest.raises(configexc.NoOptionError): + configinit.early_init(fake_args) @pytest.mark.parametrize('errors', [True, False]) From 38038df70379a7ae14e58bbf9641de0309ea26a7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 25 Sep 2017 21:10:52 +0200 Subject: [PATCH 075/186] Compare objects with :set with multiple values --- qutebrowser/config/config.py | 9 ++++++--- tests/unit/config/test_config.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 8d93a2344..c7826aef2 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -275,14 +275,17 @@ class ConfigCommands: # Use the next valid value from values, or the first if the current # value does not appear in the list - old_value = self._config.get_str(option) + old_value = self._config.get_obj(option, mutable=False) + opt = self._config.get_opt(option) + values = [opt.typ.from_str(val) for val in values] + try: - idx = values.index(str(old_value)) + idx = values.index(old_value) idx = (idx + 1) % len(values) value = values[idx] except ValueError: value = values[0] - self._config.set_str(option, value, save_yaml=not temp) + self._config.set_obj(option, value, save_yaml=not temp) @contextlib.contextmanager def _handle_config_error(self): diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 24c6814b5..949064bc9 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -439,6 +439,18 @@ class TestSetConfigCommand: assert config_stub.get(opt) == expected assert config_stub._yaml[opt] == expected + def test_cycling_different_representation(self, commands, config_stub): + """When using a different representation, cycling should work. + + For example, we use [foo] which is represented as ["foo"]. + """ + opt = 'qt_args' + config_stub.set_obj(opt, ['foo']) + commands.set(0, opt, '[foo]', '[bar]') + assert config_stub.get(opt) == ['bar'] + commands.set(0, opt, '[foo]', '[bar]') + assert config_stub.get(opt) == ['foo'] + class TestBindConfigCommand: From 38449e3e2bc87d01b3173ae2bbe0bc2792775f37 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 06:41:55 +0200 Subject: [PATCH 076/186] Make sure the autoconfig.yml is saved periodically Fixes #2982 --- qutebrowser/config/configfiles.py | 11 +++++++---- qutebrowser/config/configinit.py | 1 + tests/unit/config/test_configfiles.py | 6 ++++-- tests/unit/config/test_configinit.py | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index a017d025d..7eba211f6 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -29,7 +29,7 @@ import configparser import contextlib import yaml -from PyQt5.QtCore import QSettings +from PyQt5.QtCore import pyqtSignal, QObject, QSettings import qutebrowser from qutebrowser.config import configexc, config, configdata @@ -72,7 +72,7 @@ class StateConfig(configparser.ConfigParser): self.write(f) -class YamlConfig: +class YamlConfig(QObject): """A config stored on disk as YAML file. @@ -81,8 +81,10 @@ class YamlConfig: """ VERSION = 1 + changed = pyqtSignal() - def __init__(self): + def __init__(self, parent=None): + super().__init__(parent) self._filename = os.path.join(standarddir.config(auto=True), 'autoconfig.yml') self._values = {} @@ -94,12 +96,13 @@ class YamlConfig: We do this outside of __init__ because the config gets created before the save_manager exists. """ - save_manager.add_saveable('yaml-config', self._save) + save_manager.add_saveable('yaml-config', self._save, self.changed) def __getitem__(self, name): return self._values[name] def __setitem__(self, name, value): + self.changed.emit() self._dirty = True self._values[name] = value diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index a64438b83..3ec98a293 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -43,6 +43,7 @@ def early_init(args): config.instance = config.Config(yaml_config=yaml_config) config.val = config.ConfigContainer(config.instance) config.key_instance = config.KeyConfig(config.instance) + yaml_config.setParent(config.instance) for cf in config.change_filters: cf.validate() diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 7ce29484a..d987d7442 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -127,7 +127,7 @@ class TestYaml: ('confirm_quit', True), ('confirm_quit', False), ]) - def test_changed(self, config_tmpdir, old_config, key, value): + def test_changed(self, qtbot, config_tmpdir, old_config, key, value): autoconfig = config_tmpdir / 'autoconfig.yml' if old_config is not None: autoconfig.write_text(old_config, 'utf-8') @@ -135,7 +135,9 @@ class TestYaml: yaml = configfiles.YamlConfig() yaml.load() - yaml[key] = value + with qtbot.wait_signal(yaml.changed): + yaml[key] = value + assert key in yaml assert yaml[key] == value diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index 7ec371a6d..1332499de 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -183,7 +183,7 @@ def test_late_init(init_patch, monkeypatch, fake_save_manager, fake_args, fake_save_manager.add_saveable.assert_any_call( 'state-config', unittest.mock.ANY) fake_save_manager.add_saveable.assert_any_call( - 'yaml-config', unittest.mock.ANY) + 'yaml-config', unittest.mock.ANY, unittest.mock.ANY) if errors: assert len(msgbox_mock.call_args_list) == 1 _call_posargs, call_kwargs = msgbox_mock.call_args_list[0] From 6e226c688552e19216416146c53bb661dfde48bd Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 07:08:42 +0200 Subject: [PATCH 077/186] Add a recipes section to configuring.asciidoc Closes #2987 Closes #2969 Closes #3009 See #2975 --- doc/help/configuring.asciidoc | 103 ++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index afb6ec8e6..62b10ebf5 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -262,3 +262,106 @@ qutebrowser tries to display errors which are easy to understand even for people who are not used to writing Python. If you see a config error which you find confusing or you think qutebrowser could handle better, please https://github.com/qutebrowser/qutebrowser/issues[open an issue]! + +Recipes +~~~~~~~ + +Reading a YAML file +^^^^^^^^^^^^^^^^^^^ + +To read a YAML config like this: + +.config.yml: +---- +tabs.position: left +tabs.show: switching +---- + +You can use: + +.config.py: +[source,python] +---- +import yaml + +with (config.configdir / 'config.yml').open() as f: + yaml_data = yaml.load(f) + +for k, v in yaml_data.items(): + config.set(k, v) +---- + +Reading a nested YAML file +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To read a YAML file with nested values like this: + +.colors.yml: +---- +colors: + statusbar: + normal: + bg: lime + fg: black + url: + fg: red +---- + +You can use: + +.config.py: +[source,python] +---- +import yaml + +with (config.configdir / 'colors.yml').open() as f: + yaml_data = yaml.load(f) + +def dict_attrs(obj, path=''): + if isinstance(obj, dict): + for k, v in obj.items(): + yield from dict_attrs(v, '{}.{}'.format(path, k) if path else k) + else: + yield path, obj + +for k, v in dict_attrs(yaml_data): + config.set(k, v) +---- + +Note that this won't work for values which are dictionaries. + +Binding chained commands +^^^^^^^^^^^^^^^^^^^^^^^^ + +If you have a lot of chained comamnds you want to bind, you can write a helper +to do so: + +[source,python] +---- +def bind_chained(key, *commands, force=False): + config.bind(key, ' ;; '.join(commands), force=force) + +bind_chained('', 'clear-keychain', 'search', force=True) +---- + +Avoiding flake8 errors +^^^^^^^^^^^^^^^^^^^^^^ + +If you use an editor with flake8 integration which complains about `c` and `config` being undefined, you can use: + +[source,python] +---- +c = c # noqa: F821 +config = config # noqa: F821 +---- + +For type annotation support (note that those imports aren't guaranteed to be +stable across qutebrowser versions): + +[source,python] +---- +from qutebrowser.config.configfiles import ConfigAPI # noqa: F401 +from qutebrowser.config.config import ConfigContainer # noqa: F401 +config = config # type: ConfigAPI # noqa: F821 +c = c # type: ConfigContainer # noqa: F821 +---- From e766cf5ed117a0f270248d5ea5a653439eba8a94 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 07:12:38 +0200 Subject: [PATCH 078/186] build_release: print artifacts if not releasing --- scripts/dev/build_release.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index 0019752c2..55ee9a151 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -377,6 +377,11 @@ def main(): github_upload(artifacts, args.upload[0]) if upload_to_pypi: pypi_upload(artifacts) + else: + print() + scriptutils.print_title("Artifacts") + for artifact in artifacts: + print(artifact) if __name__ == '__main__': From 5a606304504b455ebccd6b93efc5a427da39a0ce Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 07:25:59 +0200 Subject: [PATCH 079/186] Don't use utils.is_* in build_release.py This partially reverts ef1c83862b8166066a860fbfac93a19ec26bb8c3 Otherwise, we'd have to have PyQt5 installed in the environment which runs build_release.py. --- scripts/dev/build_release.py | 53 ++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index 55ee9a151..013b68440 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -36,8 +36,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) import qutebrowser -from qutebrowser.utils import utils -from scripts import utils as scriptutils +from scripts import utils as utils # from scripts.dev import update_3rdparty @@ -71,7 +70,7 @@ def call_tox(toxenv, *args, python=sys.executable): def run_asciidoc2html(args): """Common buildsteps used for all OS'.""" - scriptutils.print_title("Running asciidoc2html.py") + utils.print_title("Running asciidoc2html.py") if args.asciidoc is not None: a2h_args = ['--asciidoc'] + args.asciidoc else: @@ -128,7 +127,7 @@ def patch_mac_app(): def build_mac(): """Build macOS .dmg/.app.""" - scriptutils.print_title("Cleaning up...") + utils.print_title("Cleaning up...") for f in ['wc.dmg', 'template.dmg']: try: os.remove(f) @@ -136,20 +135,20 @@ def build_mac(): pass for d in ['dist', 'build']: shutil.rmtree(d, ignore_errors=True) - scriptutils.print_title("Updating 3rdparty content") + utils.print_title("Updating 3rdparty content") # Currently disabled because QtWebEngine has no pdfjs support # update_3rdparty.run(ace=False, pdfjs=True, fancy_dmg=False) - scriptutils.print_title("Building .app via pyinstaller") + utils.print_title("Building .app via pyinstaller") call_tox('pyinstaller', '-r') - scriptutils.print_title("Patching .app") + utils.print_title("Patching .app") patch_mac_app() - scriptutils.print_title("Building .dmg") + utils.print_title("Building .dmg") subprocess.check_call(['make', '-f', 'scripts/dev/Makefile-dmg']) dmg_name = 'qutebrowser-{}.dmg'.format(qutebrowser.__version__) os.rename('qutebrowser.dmg', dmg_name) - scriptutils.print_title("Running smoke test") + utils.print_title("Running smoke test") try: with tempfile.TemporaryDirectory() as tmpdir: @@ -178,11 +177,11 @@ def patch_windows(out_dir): def build_windows(): """Build windows executables/setups.""" - scriptutils.print_title("Updating 3rdparty content") + utils.print_title("Updating 3rdparty content") # Currently disabled because QtWebEngine has no pdfjs support # update_3rdparty.run(ace=False, pdfjs=True, fancy_dmg=False) - scriptutils.print_title("Building Windows binaries") + utils.print_title("Building Windows binaries") parts = str(sys.version_info.major), str(sys.version_info.minor) ver = ''.join(parts) python_x86 = r'C:\Python{}-32\python.exe'.format(ver) @@ -195,19 +194,19 @@ def build_windows(): artifacts = [] - scriptutils.print_title("Running pyinstaller 32bit") + utils.print_title("Running pyinstaller 32bit") _maybe_remove(out_32) call_tox('pyinstaller', '-r', python=python_x86) shutil.move(out_pyinstaller, out_32) patch_windows(out_32) - scriptutils.print_title("Running pyinstaller 64bit") + utils.print_title("Running pyinstaller 64bit") _maybe_remove(out_64) call_tox('pyinstaller', '-r', python=python_x64) shutil.move(out_pyinstaller, out_64) patch_windows(out_64) - scriptutils.print_title("Building installers") + utils.print_title("Building installers") subprocess.check_call(['makensis.exe', '/DVERSION={}'.format(qutebrowser.__version__), 'misc/qutebrowser.nsi']) @@ -228,12 +227,12 @@ def build_windows(): 'Windows 64bit installer'), ] - scriptutils.print_title("Running 32bit smoke test") + utils.print_title("Running 32bit smoke test") smoke_test(os.path.join(out_32, 'qutebrowser.exe')) - scriptutils.print_title("Running 64bit smoke test") + utils.print_title("Running 64bit smoke test") smoke_test(os.path.join(out_64, 'qutebrowser.exe')) - scriptutils.print_title("Zipping 32bit standalone...") + utils.print_title("Zipping 32bit standalone...") name = 'qutebrowser-{}-windows-standalone-win32'.format( qutebrowser.__version__) shutil.make_archive(name, 'zip', 'dist', os.path.basename(out_32)) @@ -241,7 +240,7 @@ def build_windows(): 'application/zip', 'Windows 32bit standalone')) - scriptutils.print_title("Zipping 64bit standalone...") + utils.print_title("Zipping 64bit standalone...") name = 'qutebrowser-{}-windows-standalone-amd64'.format( qutebrowser.__version__) shutil.make_archive(name, 'zip', 'dist', os.path.basename(out_64)) @@ -254,7 +253,7 @@ def build_windows(): def build_sdist(): """Build an sdist and list the contents.""" - scriptutils.print_title("Building sdist") + utils.print_title("Building sdist") _maybe_remove('dist') @@ -277,10 +276,10 @@ def build_sdist(): assert '.pyc' not in by_ext - scriptutils.print_title("sdist contents") + utils.print_title("sdist contents") for ext, files in sorted(by_ext.items()): - scriptutils.print_subtitle(ext) + utils.print_subtitle(ext) print('\n'.join(files)) filename = 'qutebrowser-{}.tar.gz'.format(qutebrowser.__version__) @@ -309,7 +308,7 @@ def github_upload(artifacts, tag): tag: The name of the release tag """ import github3 - scriptutils.print_title("Uploading to github...") + utils.print_title("Uploading to github...") token = read_github_token() gh = github3.login(token=token) @@ -344,7 +343,7 @@ def main(): parser.add_argument('--upload', help="Tag to upload the release for", nargs=1, required=False, metavar='TAG') args = parser.parse_args() - scriptutils.change_cwd() + utils.change_cwd() upload_to_pypi = False @@ -355,7 +354,7 @@ def main(): read_github_token() run_asciidoc2html(args) - if utils.is_windows: + if os.name == 'nt': if sys.maxsize > 2**32: # WORKAROUND print("Due to a python/Windows bug, this script needs to be run ") @@ -365,21 +364,21 @@ def main(): print("https://github.com/pypa/virtualenv/issues/774") sys.exit(1) artifacts = build_windows() - elif utils.is_mac: + elif sys.platform == 'darwin': artifacts = build_mac() else: artifacts = build_sdist() upload_to_pypi = True if args.upload is not None: - scriptutils.print_title("Press enter to release...") + utils.print_title("Press enter to release...") input() github_upload(artifacts, args.upload[0]) if upload_to_pypi: pypi_upload(artifacts) else: print() - scriptutils.print_title("Artifacts") + utils.print_title("Artifacts") for artifact in artifacts: print(artifact) From e7dba338b510457b7cc6272df6c59c71ffef1b08 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 07:30:54 +0200 Subject: [PATCH 080/186] Pass %APPDATA% to pyinstaller env This hopefully helps with PyInstaller creating a ~ directory --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 83fb3d91d..d1ac9fc2f 100644 --- a/tox.ini +++ b/tox.ini @@ -260,6 +260,7 @@ commands = # WORKAROUND for https://github.com/tox-dev/tox/issues/503 install_command = pip install --exists-action w {opts} {packages} basepython = {env:PYTHON:python3} +passenv = APPDATA deps = -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-pyinstaller.txt From ff6df0c8cab4a2e1d04f17f1c550d33c4d299f37 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 07:31:45 +0200 Subject: [PATCH 081/186] Don't use utils.is_* in qutebrowser.spec Looks like PyInstaller doesn't like that. This partially reverts ef1c83862b8166066a860fbfac93a19ec26bb8c3. --- misc/qutebrowser.spec | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/misc/qutebrowser.spec b/misc/qutebrowser.spec index e9ccc1619..bcbd67405 100644 --- a/misc/qutebrowser.spec +++ b/misc/qutebrowser.spec @@ -5,7 +5,6 @@ import os sys.path.insert(0, os.getcwd()) from scripts import setupcommon -from qutebrowser import utils block_cipher = None @@ -31,9 +30,9 @@ def get_data_files(): setupcommon.write_git_file() -if utils.is_windows: +if os.name == 'nt': icon = 'icons/qutebrowser.ico' -elif utils.is_mac: +elif sys.platform == 'darwin': icon = 'icons/qutebrowser.icns' else: icon = None From dba631102a100cce1aaa5c6ea7c7b96ffee49035 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 08:41:07 +0200 Subject: [PATCH 082/186] Try to stabilize :window-only test --- tests/end2end/features/misc.feature | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 3d23033f5..97f15bf90 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -399,6 +399,7 @@ Feature: Various utility commands. When I open data/hello2.txt in a new tab And I open data/hello3.txt in a new window And I run :window-only + And I wait for "Closing window *" in the log Then the session should look like: windows: - tabs: From e32d311d8e5e1955b0d5cf231eb9a035574d592e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 08:47:27 +0200 Subject: [PATCH 083/186] Update changelog --- doc/changelog.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 0d07b47e7..3b4f2182a 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -57,6 +57,7 @@ Added - New `backend` setting to select the backend to use (auto/webengine/webkit). Together with the previous setting, this should make wrapper scripts unnecessary. +- Proxy authentication is now supported with the QtWebEngine backend. Changed ~~~~~~~ From 474bf8ad06427d15a85b885df870428e0aef2981 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 10:47:07 +0200 Subject: [PATCH 084/186] Remove unneeded as-import --- scripts/dev/build_release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index 013b68440..1e2bd5b6d 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -36,7 +36,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) import qutebrowser -from scripts import utils as utils +from scripts import utils # from scripts.dev import update_3rdparty From 6b5d34c7fb07f5fce6640f39ad700b1a483fe05d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 11:28:40 +0200 Subject: [PATCH 085/186] Fix updating of stylesheet when scrolling.bar is set Fixes #2981 --- 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 7b4ece0e8..43c7e7f29 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -173,7 +173,7 @@ def _set_http_headers(profile): def _update_settings(option): """Update global settings when qwebsettings changed.""" websettings.update_mappings(MAPPINGS, option) - if option in ['scrollbar.hide', 'content.user_stylesheets']: + if option in ['scrolling.bar', 'content.user_stylesheets']: _init_stylesheet(default_profile) _init_stylesheet(private_profile) elif option in ['content.headers.user_agent', From 17044387775eddd5e5c81c3a4d05933234034068 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 19:25:53 +0200 Subject: [PATCH 086/186] Reintroduce crash dialogs for QtWebKit --- qutebrowser/misc/crashdialog.py | 35 --------------------------------- qutebrowser/misc/crashsignal.py | 2 +- 2 files changed, 1 insertion(+), 36 deletions(-) diff --git a/qutebrowser/misc/crashdialog.py b/qutebrowser/misc/crashdialog.py index 9efb0b7f2..71b9c7ebf 100644 --- a/qutebrowser/misc/crashdialog.py +++ b/qutebrowser/misc/crashdialog.py @@ -65,41 +65,6 @@ def parse_fatal_stacktrace(text): return (m.group(1), m.group(3)) -def get_fatal_crash_dialog(debug, data): - """Get a fatal crash dialog based on a crash log. - - If the crash is a segfault in qt_mainloop and we're on an old Qt version - this is a simple error dialog which lets the user know they should upgrade - if possible. - - If it's anything else, it's a normal FatalCrashDialog with the possibility - to report the crash. - - Args: - debug: Whether the debug flag (--debug) was given. - data: The crash log data. - """ - ignored_frames = ['qt_mainloop', 'paintEvent'] - errtype, frame = parse_fatal_stacktrace(data) - - if (errtype == 'Segmentation fault' and - frame in ignored_frames and - objects.backend == usertypes.Backend.QtWebKit): - title = "qutebrowser was restarted after a fatal crash!" - text = ("qutebrowser was restarted after a fatal crash!
" - "Unfortunately, this crash occurred in Qt (the library " - "qutebrowser uses), and QtWebKit (the current backend) is not " - "maintained anymore.

Since I can't do much about " - "those crashes I disabled the crash reporter for this case, " - "but this will likely be resolved in the future with the new " - "QtWebEngine backend.") - box = QMessageBox(QMessageBox.Critical, title, text, QMessageBox.Ok) - box.setAttribute(Qt.WA_DeleteOnClose) - return box - else: - return FatalCrashDialog(debug, data) - - def _get_environment_vars(): """Gather environment variables for the crash info.""" masks = ('DESKTOP_SESSION', 'DE', 'QT_*', 'PYTHON*', 'LC_*', 'LANG', diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py index 9a1b88942..da47b6f43 100644 --- a/qutebrowser/misc/crashsignal.py +++ b/qutebrowser/misc/crashsignal.py @@ -94,7 +94,7 @@ class CrashHandler(QObject): if data: # Crashlog exists and has data in it, so something crashed # previously. - self._crash_dialog = crashdialog.get_fatal_crash_dialog( + self._crash_dialog = crashdialog.FatalCrashDialog( self._args.debug, data) self._crash_dialog.show() else: From 8e000dfe543459b3b0eaf7cc1acc0ea9957ba629 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 19:39:47 +0200 Subject: [PATCH 087/186] Make qute://configdiff usable with the new config too Closes #2983 --- doc/help/configuring.asciidoc | 5 +++-- qutebrowser/browser/qutescheme.py | 18 +++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 62b10ebf5..6104d8d6c 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -11,8 +11,9 @@ Migrating older configurations ------------------------------ qutebrowser does no automatic migration for the new configuration. However, -there's a special link:qute://configdiff[] page in qutebrowser, which will show -you the changes you did in your old configuration, compared to the old defaults. +there's a special link:qute://configdiff/old[configdiff] page in qutebrowser, +which will show you the changes you did in your old configuration, compared to +the old defaults. Other changes in default settings: diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 6bf27e7d8..b49520002 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -408,11 +408,15 @@ def qute_settings(url): @add_handler('configdiff') -def qute_configdiff(_url): +def qute_configdiff(url): """Handler for qute://configdiff.""" - try: - return 'text/html', configdiff.get_diff() - except OSError as e: - error = (b'Failed to read old config: ' + - str(e.strerror).encode('utf-8')) - return 'text/plain', error + if url.path() == '/old': + try: + return 'text/html', configdiff.get_diff() + except OSError as e: + error = (b'Failed to read old config: ' + + str(e.strerror).encode('utf-8')) + return 'text/plain', error + else: + data = config.instance.dump_userconfig().encode('utf-8') + return 'text/plain', data From 6af879887fdfee58e579b105bb023f35023f0513 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 20:12:21 +0200 Subject: [PATCH 088/186] Drop --relaxed-config --- doc/changelog.asciidoc | 2 +- doc/qutebrowser.1.asciidoc | 3 --- qutebrowser/qutebrowser.py | 2 -- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 3b4f2182a..0c2751b79 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -39,7 +39,7 @@ Breaking changes v0.9.0) is now not supported anymore. - Upgrading qutebrowser with a version older than v0.4.0 still running now won't work properly anymore. -- The `--harfbuzz` commandline argument got dropped. +- The `--harfbuzz` and `--relaxed-config` commandline arguments got dropped. Major changes ~~~~~~~~~~~~~ diff --git a/doc/qutebrowser.1.asciidoc b/doc/qutebrowser.1.asciidoc index fc70427f6..726511624 100644 --- a/doc/qutebrowser.1.asciidoc +++ b/doc/qutebrowser.1.asciidoc @@ -84,9 +84,6 @@ show it. *--force-color*:: Force colored logging -*--relaxed-config*:: - Silently remove unknown config options. - *--nowindow*:: Don't show the main window. diff --git a/qutebrowser/qutebrowser.py b/qutebrowser/qutebrowser.py index 1ae4ce192..a2066acd3 100644 --- a/qutebrowser/qutebrowser.py +++ b/qutebrowser/qutebrowser.py @@ -95,8 +95,6 @@ def get_argparser(): action='store_false', dest='color') debug.add_argument('--force-color', help="Force colored logging", action='store_true') - debug.add_argument('--relaxed-config', action='store_true', - help="Silently remove unknown config options.") debug.add_argument('--nowindow', action='store_true', help="Don't show " "the main window.") debug.add_argument('--temp-basedir', action='store_true', help="Use a " From b879f5e648d27624ca5648b314223299442cba86 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 21:28:01 +0200 Subject: [PATCH 089/186] Slightly re-style prompts See #2104 --- doc/help/settings.asciidoc | 13 +++++++++++-- qutebrowser/config/configdata.yml | 9 +++++++-- qutebrowser/mainwindow/prompt.py | 30 ++++++++++++++++++++++-------- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index c600e820b..b40d03237 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -55,6 +55,7 @@ |<>|Border color of an error message. |<>|Foreground color a warning message. |<>|Background color for prompts. +|<>|Border used around UI elements in prompts. |<>|Foreground color for prompts. |<>|Background color for the selected item in filename prompts. |<>|Background color of the statusbar in caret mode. @@ -967,7 +968,15 @@ Background color for prompts. Type: <> -Default: +pass:[darkblue]+ +Default: +pass:[dimgrey]+ + +[[colors.prompts.border]] +=== colors.prompts.border +Border used around UI elements in prompts. + +Type: <> + +Default: +pass:[1px solid gray]+ [[colors.prompts.fg]] === colors.prompts.fg @@ -983,7 +992,7 @@ Background color for the selected item in filename prompts. Type: <> -Default: +pass:[#308cc6]+ +Default: +pass:[grey]+ [[colors.statusbar.caret.bg]] === colors.statusbar.caret.bg diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index a3b0023b1..a4467ac08 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -1551,13 +1551,18 @@ colors.prompts.fg: type: QssColor desc: Foreground color for prompts. +colors.prompts.border: + default: 1px solid gray + type: String + desc: Border used around UI elements in prompts. + colors.prompts.bg: - default: darkblue + default: dimgrey type: QssColor desc: Background color for prompts. colors.prompts.selected.bg: - default: '#308cc6' + default: grey type: QssColor desc: Background color for the selected item in filename prompts. diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py index 4242ceb8e..1f4c47c78 100644 --- a/qutebrowser/mainwindow/prompt.py +++ b/qutebrowser/mainwindow/prompt.py @@ -28,7 +28,8 @@ import sip from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QTimer, QDir, QModelIndex, QItemSelectionModel, QObject, QEventLoop) from PyQt5.QtWidgets import (QWidget, QGridLayout, QVBoxLayout, QLineEdit, - QLabel, QFileSystemModel, QTreeView, QSizePolicy) + QLabel, QFileSystemModel, QTreeView, QSizePolicy, + QSpacerItem) from qutebrowser.browser import downloads from qutebrowser.config import config @@ -256,11 +257,21 @@ class PromptContainer(QWidget): background-color: {{ conf.colors.prompts.bg }}; } - QTreeView { - selection-background-color: {{ conf.colors.prompts.selected.bg }}; + QLineEdit { + border: {{ conf.colors.prompts.border }}; } - QTreeView::item:selected, QTreeView::item:selected:hover { + QTreeView { + selection-background-color: {{ conf.colors.prompts.selected.bg }}; + border: {{ conf.colors.prompts.border }}; + } + + QTreeView::branch { + background-color: {{ conf.colors.prompts.bg }}; + } + + QTreeView::item:selected, QTreeView::item:selected:hover, + QTreeView::branch:selected { background-color: {{ conf.colors.prompts.selected.bg }}; } """ @@ -433,7 +444,6 @@ class LineEdit(QLineEdit): super().__init__(parent) self.setStyleSheet(""" QLineEdit { - border: 1px solid grey; background-color: transparent; } """) @@ -511,6 +521,9 @@ class _BasePrompt(QWidget): self._key_grid.addWidget(key_label, i, 0) self._key_grid.addWidget(text_label, i, 1) + spacer = QSpacerItem(0, 0, QSizePolicy.Expanding) + self._key_grid.addItem(spacer, 0, 2) + self._vbox.addLayout(self._key_grid) def accept(self, value=None): @@ -559,8 +572,7 @@ class FilenamePrompt(_BasePrompt): def __init__(self, question, parent=None): super().__init__(question, parent) self._init_texts(question) - self._init_fileview() - self._set_fileview_root(question.default) + self._init_key_label() self._lineedit = LineEdit(self) if question.default: @@ -569,7 +581,9 @@ class FilenamePrompt(_BasePrompt): self._vbox.addWidget(self._lineedit) self.setFocusProxy(self._lineedit) - self._init_key_label() + + self._init_fileview() + self._set_fileview_root(question.default) if config.val.prompt.filebrowser: self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) From 2dfcf9c506ab349f6cd839c404f72c161280ebd4 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 26 Sep 2017 21:37:20 +0200 Subject: [PATCH 090/186] Remove unused imports --- qutebrowser/misc/crashdialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qutebrowser/misc/crashdialog.py b/qutebrowser/misc/crashdialog.py index 71b9c7ebf..49c17ccd4 100644 --- a/qutebrowser/misc/crashdialog.py +++ b/qutebrowser/misc/crashdialog.py @@ -32,12 +32,12 @@ import pkg_resources from PyQt5.QtCore import pyqtSlot, Qt, QSize from PyQt5.QtWidgets import (QDialog, QLabel, QTextEdit, QPushButton, QVBoxLayout, QHBoxLayout, QCheckBox, - QDialogButtonBox, QMessageBox, QApplication) + QDialogButtonBox, QApplication) import qutebrowser -from qutebrowser.utils import version, log, utils, objreg, usertypes +from qutebrowser.utils import version, log, utils, objreg from qutebrowser.misc import (miscwidgets, autoupdate, msgbox, httpclient, - pastebin, objects) + pastebin) from qutebrowser.config import config, configfiles From 59c6555537ca317640139f29af5783bd2946ce9b Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Tue, 26 Sep 2017 18:06:23 -0400 Subject: [PATCH 091/186] Remove the tabs.width.pinned setting --- doc/help/commands.asciidoc | 2 +- doc/help/settings.asciidoc | 9 --------- qutebrowser/config/configdata.yml | 7 ------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 25c4c463f..6e1ffd647 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -919,7 +919,7 @@ Close all tabs except for the current one. === tab-pin Pin/Unpin the current/[count]th tab. -Pinning a tab shrinks it to `tabs.width.pinned` size. Attempting to close a pinned tab will cause a confirmation, unless --force is passed. +Pinning a tab shrinks it to the size of its title text. Attempting to close a pinned tab will cause a confirmation, unless --force is passed. ==== count The tab index to pin or unpin diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 243e60023..226c952b3 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -236,7 +236,6 @@ |<>|The format to use for the tab title for pinned tabs. The same placeholders like for `tabs.title.format` are defined. |<>|The width of the tab bar if it's vertical, in px or as percentage of the window. |<>|Width of the progress indicator (0 to disable). -|<>|The width for pinned tabs with a horizontal tabbar, in px. |<>|Whether to wrap when changing tabs. |<>|Whether to start a search when something else than a URL is entered. |<>|The page to open if :open -t/-b/-w is used without URL. @@ -2968,14 +2967,6 @@ Type: <> Default: +pass:[3]+ -[[tabs.width.pinned]] -=== tabs.width.pinned -The width for pinned tabs with a horizontal tabbar, in px. - -Type: <> - -Default: +pass:[43]+ - [[tabs.wrap]] === tabs.wrap Whether to wrap when changing tabs. diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index f0e5ae74d..721b63870 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -1174,13 +1174,6 @@ tabs.width.indicator: minval: 0 desc: Width of the progress indicator (0 to disable). -tabs.width.pinned: - default: 43 - type: - name: Int - minval: 10 - desc: The width for pinned tabs with a horizontal tabbar, in px. - tabs.wrap: default: true type: Bool From c694bff9029331c91e97b37501fa51ecc84e2d1c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 27 Sep 2017 08:21:03 +0200 Subject: [PATCH 092/186] Allow direct values for url.start_pages and content.user_stylesheets --- doc/help/settings.asciidoc | 9 +-- qutebrowser/config/configdata.py | 2 +- qutebrowser/config/configdata.yml | 6 +- qutebrowser/config/configtypes.py | 61 ++++++++++++++++ tests/unit/config/test_configtypes.py | 101 +++++++++++++++++++++++++- 5 files changed, 168 insertions(+), 11 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index b40d03237..616750c78 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -1944,7 +1944,7 @@ Default: +pass:[ask]+ === content.user_stylesheets A list of user stylesheet filenames to use. -Type: <> +Type: <> Default: empty @@ -3056,11 +3056,9 @@ Default: === url.start_pages The page(s) to open at the start. -Type: <> +Type: <> -Default: - -- +pass:[https://start.duckduckgo.com]+ +Default: +pass:[https://start.duckduckgo.com]+ [[url.yank_ignored_parameters]] === url.yank_ignored_parameters @@ -3199,6 +3197,7 @@ Lists with duplicate flags are invalid. Each item is checked against the valid v |List|A list of values. When setting from a string, pass a json-like list, e.g. `["one", "two"]`. +|ListOrValue|A list of values, or a single value. |NewTabPosition|How new tabs are positioned. |Padding|Setting for paddings around elements. |Perc|A percentage. diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 739086628..d58168ab3 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -93,7 +93,7 @@ def _parse_yaml_type(name, node): if typ is configtypes.Dict: kwargs['keytype'] = _parse_yaml_type(name, kwargs['keytype']) kwargs['valtype'] = _parse_yaml_type(name, kwargs['valtype']) - elif typ is configtypes.List: + elif typ is configtypes.List or typ is configtypes.ListOrValue: kwargs['valtype'] = _parse_yaml_type(name, kwargs['valtype']) except KeyError as e: _raise_invalid_node(name, str(e), node) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index a4467ac08..2055e518d 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -545,7 +545,7 @@ content.ssl_strict: content.user_stylesheets: type: - name: List + name: ListOrValue valtype: File none_ok: True default: null @@ -1240,9 +1240,9 @@ url.searchengines: url.start_pages: type: - name: List + name: ListOrValue valtype: FuzzyUrl - default: ["https://start.duckduckgo.com"] + default: "https://start.duckduckgo.com" desc: The page(s) to open at the start. url.yank_ignored_parameters: diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 32b1fc872..499b0c994 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -476,6 +476,67 @@ class List(BaseType): return '\n'.join(lines) +class ListOrValue(BaseType): + + """A list of values, or a single value. + + // + + Internally, the value is stored as either a value (of valtype), or a list. + to_py() then ensures that it's always a list. + """ + + _show_valtype = True + + def __init__(self, valtype, none_ok=False, *args, **kwargs): + super().__init__(none_ok) + assert not isinstance(valtype, (List, ListOrValue)), valtype + self.listtype = List(valtype, none_ok=none_ok, *args, **kwargs) + self.valtype = valtype + + def get_name(self): + return self.listtype.get_name() + ' or ' + self.valtype.get_name() + + def get_valid_values(self): + return self.valtype.get_valid_values() + + def from_str(self, value): + try: + return self.listtype.from_str(value) + except configexc.ValidationError: + return self.valtype.from_str(value) + + def to_py(self, value): + try: + return [self.valtype.to_py(value)] + except configexc.ValidationError: + return self.listtype.to_py(value) + + def to_str(self, value): + if value is None: + return '' + + if isinstance(value, list): + if len(value) == 1: + return self.valtype.to_str(value[0]) + else: + return self.listtype.to_str(value) + else: + return self.valtype.to_str(value) + + def to_doc(self, value): + if value is None: + return 'empty' + + if isinstance(value, list): + if len(value) == 1: + return self.valtype.to_doc(value[0]) + else: + return self.listtype.to_doc(value) + else: + return self.valtype.to_doc(value) + + class FlagList(List): """A list of flags. diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 55a321307..5030f2c51 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -193,7 +193,8 @@ class TestAll: if member in [configtypes.BaseType, configtypes.MappingType, configtypes._Numeric]: pass - elif member is configtypes.List: + elif (member is configtypes.List or + member is configtypes.ListOrValue): yield functools.partial(member, valtype=configtypes.Int()) yield functools.partial(member, valtype=configtypes.Url()) elif member is configtypes.Dict: @@ -240,6 +241,9 @@ class TestAll: configtypes.PercOrInt, # ditto ]: return + if (isinstance(typ, configtypes.ListOrValue) and + isinstance(typ.valtype, configtypes.Int)): + return assert converted == s @@ -250,7 +254,7 @@ class TestAll: to_py_expected = configtypes.PaddingValues(None, None, None, None) elif isinstance(typ, configtypes.Dict): to_py_expected = {} - elif isinstance(typ, configtypes.List): + elif isinstance(typ, (configtypes.List, configtypes.ListOrValue)): to_py_expected = [] else: to_py_expected = None @@ -670,6 +674,99 @@ class TestFlagList: assert klass().complete() is None +class TestListOrValue: + + @pytest.fixture + def klass(self): + return configtypes.ListOrValue + + @pytest.fixture + def strtype(self): + return configtypes.String() + + @pytest.mark.parametrize('val, expected', [ + ('["foo"]', ['foo']), + ('["foo", "bar"]', ['foo', 'bar']), + ('foo', 'foo'), + ]) + def test_from_str(self, klass, strtype, val, expected): + assert klass(strtype).from_str(val) == expected + + def test_from_str_invalid(self, klass): + valtype = configtypes.String(minlen=10) + with pytest.raises(configexc.ValidationError): + klass(valtype).from_str('123') + + @pytest.mark.parametrize('val, expected', [ + (['foo'], ['foo']), + ('foo', ['foo']), + ]) + def test_to_py_valid(self, klass, strtype, val, expected): + assert klass(strtype).to_py(val) == expected + + @pytest.mark.parametrize('val', [[42], ['\U00010000']]) + def test_to_py_invalid(self, klass, strtype, val): + with pytest.raises(configexc.ValidationError): + klass(strtype).to_py(val) + + @pytest.mark.parametrize('val', [None, ['foo', 'bar'], 'abcd']) + def test_to_py_length(self, strtype, klass, val): + klass(strtype, none_ok=True, length=2).to_py(val) + + @pytest.mark.parametrize('val', [['a'], ['a', 'b'], ['a', 'b', 'c', 'd']]) + def test_wrong_length(self, strtype, klass, val): + with pytest.raises(configexc.ValidationError, + match='Exactly 3 values need to be set!'): + klass(strtype, length=3).to_py(val) + + def test_get_name(self, strtype, klass): + assert klass(strtype).get_name() == 'List of String or String' + + def test_get_valid_values(self, klass): + valid_values = configtypes.ValidValues('foo', 'bar', 'baz') + valtype = configtypes.String(valid_values=valid_values) + assert klass(valtype).get_valid_values() == valid_values + + def test_to_str(self, strtype, klass): + assert klass(strtype).to_str(["a", True]) == '["a", true]' + + @hypothesis.given(val=strategies.lists(strategies.just('foo'))) + def test_hypothesis(self, strtype, klass, val): + typ = klass(strtype, none_ok=True) + try: + converted = typ.to_py(val) + except configexc.ValidationError: + pass + else: + expected = converted if converted else [] + assert typ.to_py(typ.from_str(typ.to_str(converted))) == expected + + @hypothesis.given(val=strategies.lists(strategies.just('foo'))) + def test_hypothesis_text(self, strtype, klass, val): + typ = klass(strtype) + text = json.dumps(val) + try: + typ.to_str(typ.from_str(text)) + except configexc.ValidationError: + pass + + @pytest.mark.parametrize('val, expected', [ + # simple list + (['foo', 'bar'], '\n\n- +pass:[foo]+\n- +pass:[bar]+'), + # only one value + (['foo'], '+pass:[foo]+'), + # value without list + ('foo', '+pass:[foo]+'), + # empty + ([], 'empty'), + (None, 'empty'), + ]) + def test_to_doc(self, klass, strtype, val, expected): + doc = klass(strtype).to_doc(val) + print(doc) + assert doc == expected + + class TestBool: TESTS = { From 9607f3de59259652d43cbeff1ccda4baeb4aacb8 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 27 Sep 2017 08:25:52 +0200 Subject: [PATCH 093/186] Improve type documentation of settings Use .get_name() for the docs --- doc/help/settings.asciidoc | 20 ++++++++++---------- qutebrowser/config/configtypes.py | 4 +++- scripts/dev/src2asciidoc.py | 3 +-- tests/unit/config/test_configtypes.py | 2 +- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 616750c78..e08869903 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -1605,7 +1605,7 @@ The file can be in one of the following formats: `hosts` (with any extension). -Type: <> +Type: <> Default: @@ -1621,7 +1621,7 @@ List of domains that should always be loaded, despite being ad-blocked. Domains may contain * and ? wildcards and are otherwise required to exactly match the requested domain. Local domains are always exempt from hostblocking. -Type: <> +Type: <> Default: @@ -1944,7 +1944,7 @@ Default: +pass:[ask]+ === content.user_stylesheets A list of user stylesheet filenames to use. -Type: <> +Type: <> Default: empty @@ -2364,7 +2364,7 @@ Default: +pass:[letter]+ === hints.next_regexes A comma-separated list of regexes to use for 'next' links. -Type: <> +Type: <> Default: @@ -2379,7 +2379,7 @@ Default: === hints.prev_regexes A comma-separated list of regexes to use for 'prev' links. -Type: <> +Type: <> Default: @@ -2547,7 +2547,7 @@ Default: +pass:[false]+ Keychains that shouldn't be shown in the keyhint dialog. Globs are supported, so `;*` will blacklist all keychains starting with `;`. Use `*` to disable keyhints. -Type: <> +Type: <> Default: empty @@ -2641,7 +2641,7 @@ Default: +pass:[8]+ Additional arguments to pass to Qt, without leading `--`. With QtWebEngine, some Chromium arguments (see https://peter.sh/experiments/chromium-command-line-switches/ for a list) will work. -Type: <> +Type: <> Default: empty @@ -3056,7 +3056,7 @@ Default: === url.start_pages The page(s) to open at the start. -Type: <> +Type: <> Default: +pass:[https://start.duckduckgo.com]+ @@ -3064,7 +3064,7 @@ Default: +pass:[https://start.duckduckgo.com]+ === url.yank_ignored_parameters The URL parameters to strip with `:yank url`. -Type: <> +Type: <> Default: @@ -3120,7 +3120,7 @@ Default: +pass:[100%]+ === zoom.levels The available zoom levels. -Type: <> +Type: <> Default: diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 499b0c994..c9b177d3e 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -495,7 +495,7 @@ class ListOrValue(BaseType): self.valtype = valtype def get_name(self): - return self.listtype.get_name() + ' or ' + self.valtype.get_name() + return self.listtype.get_name() + ', or ' + self.valtype.get_name() def get_valid_values(self): return self.valtype.get_valid_values() @@ -1306,6 +1306,8 @@ class ShellCommand(List): placeholder: If there should be a placeholder. """ + _show_valtype = False + def __init__(self, placeholder=False, none_ok=False): super().__init__(valtype=String(), none_ok=none_ok) self.placeholder = placeholder diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py index 9bfd346b2..817b44a00 100755 --- a/scripts/dev/src2asciidoc.py +++ b/scripts/dev/src2asciidoc.py @@ -416,8 +416,7 @@ def _generate_setting_option(f, opt): f.write("=== {}".format(opt.name) + "\n") f.write(opt.description + "\n") f.write("\n") - f.write('Type: <>\n'.format( - typ=opt.typ.__class__.__name__)) + f.write('Type: <>\n'.format(typ=opt.typ.get_name())) f.write("\n") valid_values = opt.typ.get_valid_values() diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 5030f2c51..1c6ed7f08 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -720,7 +720,7 @@ class TestListOrValue: klass(strtype, length=3).to_py(val) def test_get_name(self, strtype, klass): - assert klass(strtype).get_name() == 'List of String or String' + assert klass(strtype).get_name() == 'List of String, or String' def test_get_valid_values(self, klass): valid_values = configtypes.ValidValues('foo', 'bar', 'baz') From fef1a6524752c18e63a33bda02d823cbe5de2519 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 27 Sep 2017 10:37:42 +0200 Subject: [PATCH 094/186] Fix ListOrValue.to_doc signature --- qutebrowser/config/configtypes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index c9b177d3e..bc5db6d94 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -524,17 +524,17 @@ class ListOrValue(BaseType): else: return self.valtype.to_str(value) - def to_doc(self, value): + def to_doc(self, value, indent=0): if value is None: return 'empty' if isinstance(value, list): if len(value) == 1: - return self.valtype.to_doc(value[0]) + return self.valtype.to_doc(value[0], indent) else: - return self.listtype.to_doc(value) + return self.listtype.to_doc(value, indent) else: - return self.valtype.to_doc(value) + return self.valtype.to_doc(value, indent) class FlagList(List): From 6573888dc678039139f0b05a720cddc4e804f23e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 27 Sep 2017 11:10:25 +0200 Subject: [PATCH 095/186] Fix :bind completion with invalid commands Now that Command doesn't validate things anymore, we can't rely on parsing to work. --- qutebrowser/completion/models/configmodel.py | 10 ++++++--- tests/unit/completion/test_models.py | 23 +++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index 78ec53338..7b6f7d3b6 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -21,7 +21,7 @@ from qutebrowser.config import configdata, configexc from qutebrowser.completion.models import completionmodel, listcategory, util -from qutebrowser.commands import runners +from qutebrowser.commands import runners, cmdexc def option(*, info): @@ -72,8 +72,12 @@ def bind(key, *, info): if cmd_text: parser = runners.CommandParser() - cmd = parser.parse(cmd_text).cmd - data = [(cmd_text, cmd.desc, key)] + try: + cmd = parser.parse(cmd_text).cmd + except cmdexc.NoSuchCommandError: + data = [(cmd_text, 'Invalid command!', key)] + else: + data = [(cmd_text, cmd.desc, key)] model.add_category(listcategory.ListCategory("Current", data)) cmdlist = util.get_cmd_completions(info, include_hidden=True, diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 4e65254c9..30241b1b3 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -119,6 +119,7 @@ def configdata_stub(monkeypatch, configdata_init): 'normal': collections.OrderedDict([ ('', 'quit'), ('ZQ', 'quit'), + ('I', 'invalid'), ]) }, backends=[], @@ -538,7 +539,8 @@ 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"}}'), ('bindings.default', 'Default keybindings', '{"normal": {"": "quit"}}'), ] @@ -573,6 +575,25 @@ def test_bind_completion(qtmodeltester, cmdutils_stub, config_stub, }) +def test_bind_completion_invalid(cmdutils_stub, config_stub, key_config_stub, + configdata_stub, info): + """Test command completion with an invalid command bound.""" + model = configmodel.bind('I', info=info) + model.set_pattern('') + + _check_completions(model, { + "Current": [ + ('invalid', 'Invalid command!', 'I'), + ], + "Commands": [ + ('open', 'open a url', ''), + ('q', "Alias for 'quit'", ''), + ('quit', 'quit qutebrowser', 'ZQ, '), + ('scroll', 'Scroll the current tab in the given direction.', '') + ], + }) + + def test_bind_completion_no_current(qtmodeltester, cmdutils_stub, config_stub, key_config_stub, configdata_stub, info): """Test keybinding completion with no current binding.""" From fac322058ec54969c55934c0712ff3c06c58ca95 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 27 Sep 2017 11:46:51 +0200 Subject: [PATCH 096/186] Improve crashdialog result codes --- qutebrowser/misc/crashdialog.py | 10 +++++++--- qutebrowser/misc/crashsignal.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/qutebrowser/misc/crashdialog.py b/qutebrowser/misc/crashdialog.py index 49c17ccd4..60c83d36b 100644 --- a/qutebrowser/misc/crashdialog.py +++ b/qutebrowser/misc/crashdialog.py @@ -35,12 +35,16 @@ from PyQt5.QtWidgets import (QDialog, QLabel, QTextEdit, QPushButton, QDialogButtonBox, QApplication) import qutebrowser -from qutebrowser.utils import version, log, utils, objreg +from qutebrowser.utils import version, log, utils, objreg, usertypes from qutebrowser.misc import (miscwidgets, autoupdate, msgbox, httpclient, pastebin) from qutebrowser.config import config, configfiles +Result = usertypes.enum('Result', ['restore', 'no_restore'], is_int=True, + start=QDialog.Accepted + 1) + + def parse_fatal_stacktrace(text): """Get useful information from a fatal faulthandler stacktrace. @@ -443,9 +447,9 @@ class ExceptionCrashDialog(_CrashDialog): def finish(self): self._save_contact_info() if self._chk_restore.isChecked(): - self.accept() + self.done(Result.restore) else: - self.reject() + self.done(Result.no_restore) class FatalCrashDialog(_CrashDialog): diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py index da47b6f43..186bd9103 100644 --- a/qutebrowser/misc/crashsignal.py +++ b/qutebrowser/misc/crashsignal.py @@ -258,7 +258,7 @@ class CrashHandler(QObject): self._args.debug, info.pages, info.cmd_history, exc, info.objects) ret = self._crash_dialog.exec_() - if ret == QDialog.Accepted: # restore + if ret == crashdialog.Result.restore: self._quitter.restart(info.pages) # We might risk a segfault here, but that's better than continuing to From e1f38293835a3f9e2874abeeb9e6c3ad741b7dfa Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 27 Sep 2017 23:16:40 +0200 Subject: [PATCH 097/186] Cache the completion delegate stylesheet We removed various caches in b5eac744b5a24263d1b595e52e48907687b2d268 but the completion delegate stylesheet gets rendered a lot, causing things to slow down. The rendering takes around 1ms, but it gets done ~10k times with a simple profiling run, so that adds up quickly. We don't use a functools.lru_cache here as the stylesheet template never changes. Thanks a lot to gilbertw1 for tracking this down! See #2812 - there's probably more possible, but this should fix the performance regression some people saw with the new config. --- qutebrowser/app.py | 4 +++ qutebrowser/completion/completiondelegate.py | 34 +++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 1f938c57e..85f0e9410 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -41,6 +41,7 @@ except ImportError: import qutebrowser import qutebrowser.resources +from qutebrowser.completion import completiondelegate from qutebrowser.completion.models import miscmodels from qutebrowser.commands import cmdutils, runners, cmdexc from qutebrowser.config import (config, websettings, configexc, configfiles, @@ -414,6 +415,9 @@ def _init_modules(args, crash_handler): pre_text='Error initializing SQL') sys.exit(usertypes.Exit.err_init) + log.init.debug("Initializing completion...") + completiondelegate.init() + log.init.debug("Initializing command history...") cmdhistory.init() diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py index c59d81022..8248b3745 100644 --- a/qutebrowser/completion/completiondelegate.py +++ b/qutebrowser/completion/completiondelegate.py @@ -34,6 +34,9 @@ from qutebrowser.config import config from qutebrowser.utils import qtutils, jinja +_cached_stylesheet = None + + class CompletionItemDelegate(QStyledItemDelegate): """Delegate used by CompletionView to draw individual items. @@ -189,14 +192,8 @@ class CompletionItemDelegate(QStyledItemDelegate): self._doc.setDefaultTextOption(text_option) self._doc.setDocumentMargin(2) - stylesheet = """ - .highlight { - color: {{ conf.colors.completion.match.fg }}; - } - """ - with jinja.environment.no_autoescape(): - template = jinja.environment.from_string(stylesheet) - self._doc.setDefaultStyleSheet(template.render(conf=config.val)) + assert _cached_stylesheet is not None + self._doc.setDefaultStyleSheet(_cached_stylesheet) if index.parent().isValid(): view = self.parent() @@ -283,3 +280,24 @@ class CompletionItemDelegate(QStyledItemDelegate): self._draw_focus_rect() self._painter.restore() + + +@config.change_filter('colors.completion.match.fg', function=True) +def _update_stylesheet(): + """Update the cached stylesheet.""" + stylesheet = """ + .highlight { + color: {{ conf.colors.completion.match.fg }}; + } + """ + with jinja.environment.no_autoescape(): + template = jinja.environment.from_string(stylesheet) + + global _cached_stylesheet + _cached_stylesheet = template.render(conf=config.val) + + +def init(): + """Initialize the cached stylesheet.""" + _update_stylesheet() + config.instance.changed.connect(_update_stylesheet) From bb8d41cedcf4552785482cbce52433965538701d Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Wed, 27 Sep 2017 19:33:59 -0400 Subject: [PATCH 098/186] Add indicator padding to minimumTabSizeHint Previously, indicator_padding was not taken into account, causing problems when using a indicator_padding too small Also removed icon padding to width calculation (seemed to be overestimating) --- qutebrowser/mainwindow/tabwidget.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 366d22b76..9d1ebd945 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -435,9 +435,14 @@ class TabBar(QTabBar): A QSize of the smallest tab size we can make. """ text = '\u2026' if ellipsis else self.tabText(index) + # Don't ever shorten if text is shorter than the elipsis + text_width = min(self.fontMetrics().width(text), + self.fontMetrics().width(self.tabText(index))) icon = self.tabIcon(index) padding = config.val.tabs.padding + indicator_padding = config.val.tabs.indicator_padding padding_h = padding.left + padding.right + padding_h += indicator_padding.left + indicator_padding.right padding_v = padding.top + padding.bottom if icon.isNull(): icon_size = QSize(0, 0) @@ -445,10 +450,8 @@ class TabBar(QTabBar): extent = self.style().pixelMetric(QStyle.PM_TabBarIconSize, None, self) icon_size = icon.actualSize(QSize(extent, extent)) - padding_h += self.style().pixelMetric( - PixelMetrics.icon_padding, None, self) height = self.fontMetrics().height() + padding_v - width = (self.fontMetrics().width(text) + icon_size.width() + + width = (text_width + icon_size.width() + padding_h + config.val.tabs.width.indicator) return QSize(width, height) From ca4a9975591c0f39d3f977bee5812563fbbe5885 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 27 Sep 2017 10:33:16 +0200 Subject: [PATCH 099/186] Update settings for QtWebEngine by default See #2335 --- doc/help/settings.asciidoc | 11 +++++------ qutebrowser/config/configdata.yml | 19 +++++++++---------- qutebrowser/config/configinit.py | 13 +------------ 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index e08869903..36159b2db 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -291,18 +291,17 @@ Default: +pass:[false]+ === backend The backend to use to display websites. qutebrowser supports two different web rendering engines / backends, QtWebKit and QtWebEngine. -QtWebKit is based on WebKit (similar to Safari). It was discontinued by the Qt project with Qt 5.6, but picked up as a well maintained fork: https://github.com/annulen/webkit/wiki - qutebrowser only supports the fork. -QtWebEngine is Qt's official successor to QtWebKit and based on the Chromium project. It's slightly more resource hungry that QtWebKit and has a couple of missing features in qutebrowser, but is generally the preferred choice. +QtWebKit was discontinued by the Qt project with Qt 5.6, but picked up as a well maintained fork: https://github.com/annulen/webkit/wiki - qutebrowser only supports the fork. +QtWebEngine is Qt's official successor to QtWebKit. It's slightly more resource hungry that QtWebKit and has a couple of missing features in qutebrowser, but is generally the preferred choice. Type: <> Valid values: - * +auto+: Automatically select either QtWebEngine or QtWebKit - * +webkit+: Force QtWebKit - * +webengine+: Force QtWebEngine + * +webengine+: Use QtWebEngine (based on Chromium) + * +webkit+: Use QtWebKit (based on WebKit, similar to Safari) -Default: +pass:[auto]+ +Default: +pass:[webengine]+ [[bindings.commands]] === bindings.commands diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 2055e518d..c597fb188 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -105,23 +105,22 @@ backend: type: name: String valid_values: - - auto: Automatically select either QtWebEngine or QtWebKit - - webkit: Force QtWebKit - - webengine: Force QtWebEngine - default: auto + - webengine: Use QtWebEngine (based on Chromium) + - webkit: Use QtWebKit (based on WebKit, similar to Safari) + default: webengine desc: >- The backend to use to display websites. qutebrowser supports two different web rendering engines / backends, QtWebKit and QtWebEngine. - QtWebKit is based on WebKit (similar to Safari). It was discontinued by the - Qt project with Qt 5.6, but picked up as a well maintained fork: - https://github.com/annulen/webkit/wiki - qutebrowser only supports the fork. + QtWebKit was discontinued by the Qt project with Qt 5.6, but picked up as a + well maintained fork: https://github.com/annulen/webkit/wiki - qutebrowser + only supports the fork. - QtWebEngine is Qt's official successor to QtWebKit and based on the Chromium - project. It's slightly more resource hungry that QtWebKit and has a couple - of missing features in qutebrowser, but is generally the preferred choice. + QtWebEngine is Qt's official successor to QtWebKit. It's slightly more + resource hungry that QtWebKit and has a couple of missing features in + qutebrowser, but is generally the preferred choice. ## auto_save diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index 3ec98a293..aa622850c 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -74,13 +74,6 @@ def early_init(args): def get_backend(args): """Find out what backend to use based on available libraries.""" - try: - import PyQt5.QtWebKit # pylint: disable=unused-variable - except ImportError: - webkit_available = False - else: - webkit_available = qtutils.is_new_qtwebkit() - str_to_backend = { 'webkit': usertypes.Backend.QtWebKit, 'webengine': usertypes.Backend.QtWebEngine, @@ -88,12 +81,8 @@ def get_backend(args): if args.backend is not None: return str_to_backend[args.backend] - elif config.val.backend != 'auto': - return str_to_backend[config.val.backend] - elif webkit_available: - return usertypes.Backend.QtWebKit else: - return usertypes.Backend.QtWebEngine + return str_to_backend[config.val.backend] def late_init(save_manager): From 093f34183c1b2a51c6c6e38d20c1c9e19a3ae909 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 27 Sep 2017 22:39:49 +0200 Subject: [PATCH 100/186] Add improved checks for Nouveau/Wayland for QtWebEngine Closes #2368 Closes #2932 See #2335 --- doc/help/settings.asciidoc | 17 ++ qutebrowser/app.py | 12 +- qutebrowser/browser/webengine/webenginetab.py | 12 +- qutebrowser/config/configdata.yml | 9 + qutebrowser/misc/backendproblem.py | 182 ++++++++++++++++++ 5 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 qutebrowser/misc/backendproblem.py diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 36159b2db..c2a7f55fb 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -179,6 +179,7 @@ |<>|The default font size for fixed-pitch text. |<>|The hard minimum font size. |<>|The minimum logical font size that is applied when zooming out. +|<>|Force software rendering for QtWebEngine. |<>|Controls when a hint can be automatically followed without pressing Enter. |<>|A timeout (in milliseconds) to ignore normal-mode key bindings after a successful auto-follow. |<>|CSS border value for hints. @@ -2262,6 +2263,22 @@ Type: <> Default: +pass:[6]+ +[[force_software_rendering]] +=== force_software_rendering +Force software rendering for QtWebEngine. +This is needed for QtWebEngine to work with Nouveau drivers. + +Type: <> + +Valid values: + + * +true+ + * +false+ + +Default: +pass:[false]+ + +This setting is only available with the QtWebEngine backend. + [[hints.auto_follow]] === hints.auto_follow Controls when a hint can be automatically followed without pressing Enter. diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 85f0e9410..b8649b7fb 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -54,7 +54,8 @@ from qutebrowser.browser.webkit.network import networkmanager from qutebrowser.keyinput import macros from qutebrowser.mainwindow import mainwindow, prompt from qutebrowser.misc import (readline, ipc, savemanager, sessions, - crashsignal, earlyinit, sql, cmdhistory) + crashsignal, earlyinit, sql, cmdhistory, + backendproblem) from qutebrowser.utils import (log, version, message, utils, urlutils, objreg, usertypes, standarddir, error) # pylint: disable=unused-import @@ -389,14 +390,17 @@ def _init_modules(args, crash_handler): crash_handler: The CrashHandler instance. """ # pylint: disable=too-many-statements - log.init.debug("Initializing prompts...") - prompt.init() - log.init.debug("Initializing save manager...") save_manager = savemanager.SaveManager(qApp) objreg.register('save-manager', save_manager) configinit.late_init(save_manager) + log.init.debug("Checking backend requirements...") + backendproblem.init() + + log.init.debug("Initializing prompts...") + prompt.init() + log.init.debug("Initializing network...") networkmanager.init() diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 5c2533422..707ea10c3 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -38,7 +38,7 @@ from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory, webenginesettings) from qutebrowser.misc import miscwidgets from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils, - message, objreg, jinja, debug, version) + message, objreg, jinja, debug) _qute_scheme_handler = None @@ -50,16 +50,8 @@ def init(): # won't work... # https://www.riverbankcomputing.com/pipermail/pyqt/2016-September/038075.html global _qute_scheme_handler + app = QApplication.instance() - - software_rendering = (os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1' or - 'QT_XCB_FORCE_SOFTWARE_OPENGL' in os.environ) - if version.opengl_vendor() == 'nouveau' and not software_rendering: - # FIXME:qtwebengine display something more sophisticated here - raise browsertab.WebTabError( - "QtWebEngine is not supported with Nouveau graphics (unless " - "QT_XCB_FORCE_SOFTWARE_OPENGL is set as environment variable).") - log.init.debug("Initializing qute://* handler...") _qute_scheme_handler = webenginequtescheme.QuteSchemeHandler(parent=app) _qute_scheme_handler.install(webenginesettings.default_profile) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index c597fb188..9a3574282 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -101,6 +101,15 @@ qt_args: https://peter.sh/experiments/chromium-command-line-switches/ for a list) will work. +force_software_rendering: + type: Bool + default: false + backend: QtWebEngine + desc: >- + Force software rendering for QtWebEngine. + + This is needed for QtWebEngine to work with Nouveau drivers. + backend: type: name: String diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py new file mode 100644 index 000000000..15d27212f --- /dev/null +++ b/qutebrowser/misc/backendproblem.py @@ -0,0 +1,182 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 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 . + +"""Dialogs shown when there was a problem with a backend choice.""" + +import os +import sys +import functools + +import attr +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import (QApplication, QDialog, QPushButton, QHBoxLayout, + QVBoxLayout, QLabel) + +from qutebrowser.config import config +from qutebrowser.utils import usertypes, objreg, version +from qutebrowser.misc import objects + + +_Result = usertypes.enum('_Result', ['quit', 'restart'], is_int=True, + start=QDialog.Accepted + 1) + + +@attr.s +class _Button: + + """A button passed to BackendProblemDialog.""" + + text = attr.ib() + setting = attr.ib() + value = attr.ib() + default = attr.ib(default=False) + + +class _Dialog(QDialog): + + """A dialog which gets shown if there are issues with the backend.""" + + def __init__(self, because, text, backend, buttons=None, parent=None): + super().__init__(parent) + vbox = QVBoxLayout(self) + + other_backend = { + usertypes.Backend.QtWebKit: usertypes.Backend.QtWebEngine, + usertypes.Backend.QtWebEngine: usertypes.Backend.QtWebKit, + }[backend] + other_setting = other_backend.name.lower()[2:] + + label = QLabel( + "Failed to start with the {backend} backend!" + "

qutebrowser tried to start with the {backend} backend but " + "failed because {because}.

{text}" + "

Forcing the {other_backend.name} backend

" + "

This forces usage of the {other_backend.name} backend. " + "This sets the backend = '{other_setting}' setting " + "(if you have a config.py file, you'll need to set " + "this manually).

".format( + backend=backend.name, because=because, text=text, + other_backend=other_backend, other_setting=other_setting), + wordWrap=True) + label.setTextFormat(Qt.RichText) + vbox.addWidget(label) + + hbox = QHBoxLayout() + buttons = [] if buttons is None else buttons + + quit_button = QPushButton("Quit") + quit_button.clicked.connect(lambda: self.done(_Result.quit)) + hbox.addWidget(quit_button) + + backend_button = QPushButton("Force {} backend".format( + other_backend.name)) + backend_button.clicked.connect(functools.partial( + self._change_setting, 'backend', other_setting)) + hbox.addWidget(backend_button) + + for button in buttons: + btn = QPushButton(button.text, default=button.default) + btn.clicked.connect(functools.partial( + self._change_setting, button.setting, button.value)) + hbox.addWidget(btn) + + vbox.addLayout(hbox) + + def _change_setting(self, setting, value): + """Change the given setting and restart.""" + config.instance.set_obj(setting, value, save_yaml=True) + self.done(_Result.restart) + + +def _show_dialog(*args, **kwargs): + """Show a dialog for a backend problem.""" + dialog = _Dialog(*args, **kwargs) + + status = dialog.exec_() + + if status == _Result.quit: + sys.exit(usertypes.Exit.err_init) + elif status == _Result.restart: + # FIXME pass --backend webengine + quitter = objreg.get('quitter') + quitter.restart() + sys.exit(usertypes.Exit.err_init) + + +def _handle_nouveau_graphics(): + force_sw_var = 'QT_XCB_FORCE_SOFTWARE_OPENGL' + + if version.opengl_vendor() != 'nouveau': + return + + if (os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1' or + force_sw_var in os.environ): + return + + if config.force_software_rendering: + os.environ[force_sw_var] = '1' + return + + button = _Button("Force software rendering", 'force_software_rendering', + True) + _show_dialog( + backend=usertypes.Backend.QtWebEngine, + because="you're using Nouveau graphics", + text="

There are two ways to fix this:

" + "

Forcing software rendering

" + "

This allows you to use the newer QtWebEngine backend (based " + "on Chromium) but could have noticable performance impact " + "(depending on your hardware). " + "This sets the force_software_rendering = True setting " + "(if you have a config.py file, you'll need to set this " + "manually).

", + buttons=[button], + ) + + # Should never be reached + assert False + + +def _handle_wayland(): + if QApplication.instance().platformName() not in ['wayland', 'wayland-egl']: + return + if os.environ.get('DISPLAY'): + # When DISPLAY is set but with the wayland/wayland-egl platform plugin, + # QtWebEngine will do the right hting. + return + + _show_dialog( + backend=usertypes.Backend.QtWebEngine, + because="you're using Wayland", + text="

There are two ways to fix this:

" + "

Set up XWayland

" + "

This allows you to use the newer QtWebEngine backend (based " + "on Chromium). " + ) + + # Should never be reached + assert False + + +def init(): + if objects.backend == usertypes.Backend.QtWebEngine: + _handle_wayland() + _handle_nouveau_graphics() + else: + assert objects.backend == usertypes.Backend.QtWebKit, objects.backend From fa902c5d82fd6366289fe8e9d13d72ba4dfc8a85 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 27 Sep 2017 23:04:18 +0200 Subject: [PATCH 101/186] Improve error dialogs when QtWebKit/QtWebEngine was not found --- qutebrowser/misc/backendproblem.py | 113 +++++++++++++++++++++++++++-- qutebrowser/misc/earlyinit.py | 31 -------- qutebrowser/misc/savemanager.py | 5 ++ 3 files changed, 111 insertions(+), 38 deletions(-) diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index 15d27212f..caa21f97d 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -22,15 +22,16 @@ import os import sys import functools +import html import attr from PyQt5.QtCore import Qt from PyQt5.QtWidgets import (QApplication, QDialog, QPushButton, QHBoxLayout, - QVBoxLayout, QLabel) + QVBoxLayout, QLabel, QMessageBox) from qutebrowser.config import config -from qutebrowser.utils import usertypes, objreg, version -from qutebrowser.misc import objects +from qutebrowser.utils import usertypes, objreg, version, qtutils +from qutebrowser.misc import objects, msgbox _Result = usertypes.enum('_Result', ['quit', 'restart'], is_int=True, @@ -67,8 +68,8 @@ class _Dialog(QDialog): "

qutebrowser tried to start with the {backend} backend but " "failed because {because}.

{text}" "

Forcing the {other_backend.name} backend

" - "

This forces usage of the {other_backend.name} backend. " - "This sets the backend = '{other_setting}' setting " + "

This forces usage of the {other_backend.name} backend by " + "setting the backend = '{other_setting}' option " "(if you have a config.py file, you'll need to set " "this manually).

".format( backend=backend.name, because=because, text=text, @@ -101,6 +102,8 @@ class _Dialog(QDialog): def _change_setting(self, setting, value): """Change the given setting and restart.""" config.instance.set_obj(setting, value, save_yaml=True) + save_manager = objreg.get('save-manager') + save_manager.save_all(is_exit=True) self.done(_Result.restart) @@ -110,13 +113,15 @@ def _show_dialog(*args, **kwargs): status = dialog.exec_() - if status == _Result.quit: + if status in [_Result.quit, QDialog.Rejected]: sys.exit(usertypes.Exit.err_init) elif status == _Result.restart: # FIXME pass --backend webengine quitter = objreg.get('quitter') quitter.restart() sys.exit(usertypes.Exit.err_init) + else: + assert False, status def _handle_nouveau_graphics(): @@ -143,7 +148,7 @@ def _handle_nouveau_graphics(): "

This allows you to use the newer QtWebEngine backend (based " "on Chromium) but could have noticable performance impact " "(depending on your hardware). " - "This sets the force_software_rendering = True setting " + "This sets the force_software_rendering = True option " "(if you have a config.py file, you'll need to set this " "manually).

", buttons=[button], @@ -174,7 +179,101 @@ def _handle_wayland(): assert False +@attr.s +class BackendImports: + + """Whether backend modules could be imported.""" + + webkit_available = attr.ib(default=None) + webengine_available = attr.ib(default=None) + webkit_error = attr.ib(default=None) + webengine_error = attr.ib(default=None) + + +def _try_import_backends(): + """Check whether backends can be imported and return BackendImports.""" + results = BackendImports() + + try: + from PyQt5 import QtWebKit + from PyQt5 import QtWebKitWidgets + except ImportError as e: + results.webkit_available = False + results.webkit_error = str(e) + else: + if qtutils.is_new_qtwebkit(): + results.webkit_available = True + else: + results.webkit_available = False + results.webkit_error = "Unsupported legacy QtWebKit found" + + try: + from PyQt5 import QtWebEngineWidgets + except ImportError as e: + results.webengine_available = False + results.webengine_error = str(e) + else: + results.webengine_available = True + + assert results.webkit_available is not None + assert results.webengine_available is not None + if not results.webkit_available: + assert results.webkit_error is not None + if not results.webengine_available: + assert results.webengine_error is not None + + return results + + +def _check_backend_modules(): + """Check for the modules needed for QtWebKit/QtWebEngine.""" + imports = _try_import_backends() + + if imports.webkit_available and imports.webengine_available: + return + elif not imports.webkit_available and not imports.webengine_available: + text = ("

qutebrowser needs QtWebKit or QtWebEngine, but neither " + "could be imported!

" + "

The errors encountered were:

    " + "
  • QtWebKit: {webkit_error}" + "
  • QtWebEngine: {webengine_error}" + "

".format( + webkit_error=html.escape(imports.webkit_error), + webengine_error=html.escape(imports.webengine_error))) + errbox = msgbox.msgbox(parent=None, + title="No backend library found!", + text=text, + icon=QMessageBox.Critical, + plain_text=False) + errbox.exec_() + sys.exit(usertypes.Exit.err_init) + elif objects.backend == usertypes.Backend.QtWebKit: + if imports.webkit_available: + return + assert imports.webengine_available + _show_dialog( + backend=usertypes.Backend.QtWebKit, + because="QtWebKit could not be imported", + text="

The error encountered was:
{}

".format( + html.escape(imports.webkit_error)) + ) + elif objects.backend == usertypes.Backend.QtWebEngine: + if imports.webengine_available: + return + assert imports.webkit_available + _show_dialog( + backend=usertypes.Backend.QtWebEngine, + because="QtWebEngine could not be imported", + text="

The error encountered was:
{}

".format( + html.escape(imports.webengine_error)) + ) + + # Should never be reached + assert False + + def init(): + _check_backend_modules() if objects.backend == usertypes.Backend.QtWebEngine: _handle_wayland() _handle_nouveau_graphics() diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index c2ab454ac..f481f4dba 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -247,35 +247,6 @@ def check_libraries(): _check_modules(modules) -def check_backend_libraries(backend): - """Make sure the libraries needed by the given backend are available. - - Args: - backend: The backend as usertypes.Backend member. - """ - from qutebrowser.utils import usertypes - if backend == usertypes.Backend.QtWebEngine: - modules = { - 'PyQt5.QtWebEngineWidgets': - _missing_str("QtWebEngine", webengine=True), - } - else: - assert backend == usertypes.Backend.QtWebKit, backend - modules = { - 'PyQt5.QtWebKit': _missing_str("PyQt5.QtWebKit"), - 'PyQt5.QtWebKitWidgets': _missing_str("PyQt5.QtWebKitWidgets"), - } - _check_modules(modules) - - -def check_new_webkit(backend): - """Make sure we use QtWebEngine or a new QtWebKit.""" - from qutebrowser.utils import usertypes, qtutils - if backend == usertypes.Backend.QtWebKit and not qtutils.is_new_qtwebkit(): - _die("qutebrowser does not support legacy QtWebKit versions anymore, " - "see the installation docs for details.") - - def remove_inputhook(): """Remove the PyQt input hook. @@ -338,6 +309,4 @@ def init_with_backend(backend): """ assert not isinstance(backend, str), backend assert backend is not None - check_backend_libraries(backend) check_backend_ssl_support(backend) - check_new_webkit(backend) diff --git a/qutebrowser/misc/savemanager.py b/qutebrowser/misc/savemanager.py index ddda5325b..02001902c 100644 --- a/qutebrowser/misc/savemanager.py +++ b/qutebrowser/misc/savemanager.py @@ -164,6 +164,11 @@ class SaveManager(QObject): self.saveables[name].save(is_exit=is_exit, explicit=explicit, silent=silent, force=force) + def save_all(self, *args, **kwargs): + """Save all saveables.""" + for saveable in self.saveables: + self.save(saveable, *args, **kwargs) + @pyqtSlot() def autosave(self): """Slot used when the configs are auto-saved.""" From defcf5394a035e8606359c8d6b6a8abc60014cd6 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 08:11:26 +0200 Subject: [PATCH 102/186] Move SSL backend checking to backendproblem.py --- qutebrowser/config/configinit.py | 1 - qutebrowser/misc/backendproblem.py | 31 +++++++++++++++++++++++++++++- qutebrowser/misc/earlyinit.py | 28 --------------------------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index aa622850c..1b36fd675 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -69,7 +69,6 @@ def early_init(args): configfiles.init() objects.backend = get_backend(args) - earlyinit.init_with_backend(objects.backend) def get_backend(args): diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index caa21f97d..fdd13346c 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -28,9 +28,10 @@ import attr from PyQt5.QtCore import Qt from PyQt5.QtWidgets import (QApplication, QDialog, QPushButton, QHBoxLayout, QVBoxLayout, QLabel, QMessageBox) +from PyQt5.QtNetwork import QSslSocket from qutebrowser.config import config -from qutebrowser.utils import usertypes, objreg, version, qtutils +from qutebrowser.utils import usertypes, objreg, version, qtutils, log from qutebrowser.misc import objects, msgbox @@ -225,6 +226,32 @@ def _try_import_backends(): return results +def _handle_ssl_support(fatal=False): + """Check for full SSL availability. + + If "fatal" is given, show an error and exit. + """ + text = ("Could not initialize QtNetwork SSL support. If you use " + "OpenSSL 1.1 with a PyQt package from PyPI (e.g. on Archlinux " + "or Debian Stretch), you need to set LD_LIBRARY_PATH to the path " + "of OpenSSL 1.0. This only affects downloads.") + + if QSslSocket.supportsSsl(): + return + + if fatal: + errbox = msgbox.msgbox(parent=None, + title="SSL error", + text="Could not initialize SSL support.", + icon=QMessageBox.Critical, + plain_text=False) + errbox.exec_() + sys.exit(usertypes.Exit.err_init) + + assert not fatal + log.init.warning(text) + + def _check_backend_modules(): """Check for the modules needed for QtWebKit/QtWebEngine.""" imports = _try_import_backends() @@ -275,7 +302,9 @@ def _check_backend_modules(): def init(): _check_backend_modules() if objects.backend == usertypes.Backend.QtWebEngine: + _handle_ssl_support() _handle_wayland() _handle_nouveau_graphics() else: assert objects.backend == usertypes.Backend.QtWebKit, objects.backend + _handle_ssl_support(fatal=True) diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index f481f4dba..ca12cd901 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -187,23 +187,6 @@ def check_ssl_support(): _die("Fatal error: Your Qt is built without SSL support.") -def check_backend_ssl_support(backend): - """Check for full SSL availability when we know the backend.""" - from PyQt5.QtNetwork import QSslSocket - from qutebrowser.utils import log, usertypes - text = ("Could not initialize QtNetwork SSL support. If you use " - "OpenSSL 1.1 with a PyQt package from PyPI (e.g. on Archlinux " - "or Debian Stretch), you need to set LD_LIBRARY_PATH to the path " - "of OpenSSL 1.0. This only affects downloads.") - - if not QSslSocket.supportsSsl(): - if backend == usertypes.Backend.QtWebKit: - _die("Could not initialize SSL support.") - else: - assert backend == usertypes.Backend.QtWebEngine - log.init.warning(text) - - def _check_modules(modules): """Make sure the given modules are available.""" from qutebrowser.utils import log @@ -299,14 +282,3 @@ def early_init(args): remove_inputhook() check_ssl_support() check_optimize_flag() - - -def init_with_backend(backend): - """Do later stages of init when we know the backend. - - Args: - backend: The backend as usertypes.Backend member. - """ - assert not isinstance(backend, str), backend - assert backend is not None - check_backend_ssl_support(backend) From e5958e6061029eb4d357d3f013277764f500ffd7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 08:30:28 +0200 Subject: [PATCH 103/186] Override --backend argument from backend problem dialog --- qutebrowser/app.py | 11 ++++++++--- qutebrowser/misc/backendproblem.py | 26 +++++++++++++++++++------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index b8649b7fb..05420a9e4 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -513,12 +513,13 @@ class Quitter: with tokenize.open(os.path.join(dirpath, fn)) as f: compile(f.read(), fn, 'exec') - def _get_restart_args(self, pages=(), session=None): + def _get_restart_args(self, pages=(), session=None, override_args=None): """Get the current working directory and args to relaunch qutebrowser. Args: pages: The pages to re-open. session: The session to load, or None. + override_args: Argument overrides as a dict. Return: An (args, cwd) tuple. @@ -569,6 +570,9 @@ class Quitter: argdict['temp_basedir'] = False argdict['temp_basedir_restarted'] = True + if override_args is not None: + argdict.update(override_args) + # Dump the data data = json.dumps(argdict) args += ['--json-args', data] @@ -593,7 +597,7 @@ class Quitter: if ok: self.shutdown(restart=True) - def restart(self, pages=(), session=None): + def restart(self, pages=(), session=None, override_args=None): """Inner logic to restart qutebrowser. The "better" way to restart is to pass a session (_restart usually) as @@ -606,6 +610,7 @@ class Quitter: Args: pages: A list of URLs to open. session: The session to load, or None. + override_args: Argument overrides as a dict. Return: True if the restart succeeded, False otherwise. @@ -621,7 +626,7 @@ class Quitter: session_manager.save(session, with_private=True) # Open a new process and immediately shutdown the existing one try: - args, cwd = self._get_restart_args(pages, session) + args, cwd = self._get_restart_args(pages, session, override_args) if cwd is None: subprocess.Popen(args) else: diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index fdd13346c..806c32a36 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -35,8 +35,10 @@ from qutebrowser.utils import usertypes, objreg, version, qtutils, log from qutebrowser.misc import objects, msgbox -_Result = usertypes.enum('_Result', ['quit', 'restart'], is_int=True, - start=QDialog.Accepted + 1) +_Result = usertypes.enum( + '_Result', + ['quit', 'restart', 'restart_webkit', 'restart_webengine'], + is_int=True, start=QDialog.Accepted + 1) @attr.s @@ -105,7 +107,13 @@ class _Dialog(QDialog): config.instance.set_obj(setting, value, save_yaml=True) save_manager = objreg.get('save-manager') save_manager.save_all(is_exit=True) - self.done(_Result.restart) + + if setting == 'backend' and value == 'webkit': + self.done(_Result.restart_webkit) + elif setting == 'backend' and value == 'webengine': + self.done(_Result.restart_webengine) + else: + self.done(_Result.restart) def _show_dialog(*args, **kwargs): @@ -113,17 +121,21 @@ def _show_dialog(*args, **kwargs): dialog = _Dialog(*args, **kwargs) status = dialog.exec_() + quitter = objreg.get('quitter') if status in [_Result.quit, QDialog.Rejected]: - sys.exit(usertypes.Exit.err_init) + pass + elif status == _Result.restart_webkit: + quitter.restart(override_args={'backend': 'webkit'}) + elif status == _Result.restart_webengine: + quitter.restart(override_args={'backend': 'webengine'}) elif status == _Result.restart: - # FIXME pass --backend webengine - quitter = objreg.get('quitter') quitter.restart() - sys.exit(usertypes.Exit.err_init) else: assert False, status + sys.exit(usertypes.Exit.err_init) + def _handle_nouveau_graphics(): force_sw_var = 'QT_XCB_FORCE_SOFTWARE_OPENGL' From ce0622e38a544037507ddf8eddf2607bbc912f20 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 08:40:48 +0200 Subject: [PATCH 104/186] Document how initialization roughly works --- qutebrowser/app.py | 20 +++++++++++++++++++- qutebrowser/qutebrowser.py | 16 +++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 05420a9e4..c0dc80619 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -17,7 +17,25 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Initialization of qutebrowser and application-wide things.""" +"""Initialization of qutebrowser and application-wide things. + +The run() function will get called once early initialization (in +qutebrowser.py/earlyinit.py) is done. See the qutebrowser.py docstring for +details about early initialization. + +As we need to access the config before the QApplication is created, we +initialize everything the config needs before the QApplication is created, and +then leave it in a partially initialized state (no saving, no config errors +shown yet). + +We then set up the QApplication object and initialize a few more low-level +things. + +After that, init() and _init_modules() take over and initialize the rest. + +After all initialization is done, the qt_mainloop() function is called, which +blocks and spins the Qt mainloop. +""" import os import sys diff --git a/qutebrowser/qutebrowser.py b/qutebrowser/qutebrowser.py index a2066acd3..8c80dfc22 100644 --- a/qutebrowser/qutebrowser.py +++ b/qutebrowser/qutebrowser.py @@ -17,7 +17,21 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Early initialization and main entry point.""" +"""Early initialization and main entry point. + +qutebrowser's initialization process roughly looks like this: + +- This file gets imported, either via the setuptools entry point or + __main__.py. +- At import time, we check for the correct Python version and show an error if + it's too old. +- The main() function in this file gets invoked +- Argument parsing takes place +- earlyinit.early_init() gets invoked to do various low-level initialization and + checks whether all dependencies are met. +- app.run() gets called, which takes over. + See the docstring of app.py for details. +""" import sys import json From b906c862bb56da81304d8afdff34feb49f254c3b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 08:52:32 +0200 Subject: [PATCH 105/186] Remove ipc-server from objreg --- qutebrowser/app.py | 8 +++++++- qutebrowser/misc/crashsignal.py | 4 ++-- qutebrowser/misc/ipc.py | 8 ++++++-- tests/unit/misc/test_ipc.py | 20 ++++---------------- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index c0dc80619..00c8f98d8 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -638,10 +638,16 @@ class Quitter: log.destroy.debug("sys.path: {}".format(sys.path)) log.destroy.debug("sys.argv: {}".format(sys.argv)) log.destroy.debug("frozen: {}".format(hasattr(sys, 'frozen'))) + # Save the session if one is given. if session is not None: session_manager = objreg.get('session-manager') session_manager.save(session, with_private=True) + + # Make sure we're not accepting a connection from the new process before + # we fully exited. + ipc.server.shutdown() + # Open a new process and immediately shutdown the existing one try: args, cwd = self._get_restart_args(pages, session, override_args) @@ -732,7 +738,7 @@ class Quitter: QApplication.closeAllWindows() # Shut down IPC try: - objreg.get('ipc-server').shutdown() + ipc.server.shutdown() except KeyError: pass # Save everything diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py index 186bd9103..9899cfcd3 100644 --- a/qutebrowser/misc/crashsignal.py +++ b/qutebrowser/misc/crashsignal.py @@ -39,7 +39,7 @@ from PyQt5.QtCore import (pyqtSlot, qInstallMessageHandler, QObject, from PyQt5.QtWidgets import QApplication, QDialog from qutebrowser.commands import cmdutils -from qutebrowser.misc import earlyinit, crashdialog +from qutebrowser.misc import earlyinit, crashdialog, ipc from qutebrowser.utils import usertypes, standarddir, log, objreg, debug, utils @@ -236,7 +236,7 @@ class CrashHandler(QObject): info = self._get_exception_info() try: - objreg.get('ipc-server').ignored = True + ipc.server.ignored = True except Exception: log.destroy.exception("Error while ignoring ipc") diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py index e308ac8a0..c8c9d83b7 100644 --- a/qutebrowser/misc/ipc.py +++ b/qutebrowser/misc/ipc.py @@ -30,7 +30,7 @@ from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, Qt from PyQt5.QtNetwork import QLocalSocket, QLocalServer, QAbstractSocket import qutebrowser -from qutebrowser.utils import log, usertypes, error, objreg, standarddir, utils +from qutebrowser.utils import log, usertypes, error, standarddir, utils CONNECT_TIMEOUT = 100 # timeout for connecting/disconnecting @@ -40,6 +40,10 @@ ATIME_INTERVAL = 60 * 60 * 3 * 1000 # 3 hours PROTOCOL_VERSION = 1 +# The ipc server instance +server = None + + def _get_socketname_windows(basedir): """Get a socketname to use for Windows.""" parts = ['qutebrowser', getpass.getuser()] @@ -482,6 +486,7 @@ def send_or_listen(args): The IPCServer instance if no running instance was detected. None if an instance was running and received our request. """ + global server socketname = _get_socketname(args.basedir) try: try: @@ -492,7 +497,6 @@ def send_or_listen(args): log.init.debug("Starting IPC server...") server = IPCServer(socketname) server.listen() - objreg.register('ipc-server', server) return server except AddressInUseError as e: # This could be a race condition... diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py index 874419511..b515535bb 100644 --- a/tests/unit/misc/test_ipc.py +++ b/tests/unit/misc/test_ipc.py @@ -34,7 +34,7 @@ from PyQt5.QtTest import QSignalSpy import qutebrowser from qutebrowser.misc import ipc -from qutebrowser.utils import objreg, standarddir, utils +from qutebrowser.utils import standarddir, utils from helpers import stubs @@ -45,12 +45,8 @@ pytestmark = pytest.mark.usefixtures('qapp') def shutdown_server(): """If ipc.send_or_listen was called, make sure to shut server down.""" yield - try: - server = objreg.get('ipc-server') - except KeyError: - pass - else: - server.shutdown() + if ipc.server is not None: + ipc.server.shutdown() @pytest.fixture @@ -609,13 +605,6 @@ class TestSendOrListen: return self.Args(no_err_windows=True, basedir='/basedir/for/testing', command=['test'], target=None) - @pytest.fixture(autouse=True) - def cleanup(self): - try: - objreg.delete('ipc-server') - except KeyError: - pass - @pytest.fixture def qlocalserver_mock(self, mocker): m = mocker.patch('qutebrowser.misc.ipc.QLocalServer', autospec=True) @@ -639,8 +628,7 @@ class TestSendOrListen: assert isinstance(ret_server, ipc.IPCServer) msgs = [e.message for e in caplog.records] assert "Starting IPC server..." in msgs - objreg_server = objreg.get('ipc-server') - assert objreg_server is ret_server + assert ret_server is ipc.server with qtbot.waitSignal(ret_server.got_args): ret_client = ipc.send_or_listen(args) From c77cff3fcbd0f0f86c2cac3e81af111185d1a2c4 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 08:56:05 +0200 Subject: [PATCH 106/186] Also fail with DISPLAY with wayland platform plugin QtWebEngine spews errors at us, and while it seems to work with Weston for some reason (despite errors logged), it doesn't with sway. --- qutebrowser/misc/backendproblem.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index 806c32a36..7f3703be9 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -174,10 +174,6 @@ def _handle_nouveau_graphics(): def _handle_wayland(): if QApplication.instance().platformName() not in ['wayland', 'wayland-egl']: return - if os.environ.get('DISPLAY'): - # When DISPLAY is set but with the wayland/wayland-egl platform plugin, - # QtWebEngine will do the right hting. - return _show_dialog( backend=usertypes.Backend.QtWebEngine, From f077f52997702a64deba54abcbfbe193d5211959 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 08:57:30 +0200 Subject: [PATCH 107/186] Add asserts for the backend --- qutebrowser/misc/backendproblem.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index 7f3703be9..bfe2f7cb0 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -138,6 +138,7 @@ def _show_dialog(*args, **kwargs): def _handle_nouveau_graphics(): + assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend force_sw_var = 'QT_XCB_FORCE_SOFTWARE_OPENGL' if version.opengl_vendor() != 'nouveau': @@ -172,6 +173,8 @@ def _handle_nouveau_graphics(): def _handle_wayland(): + assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend + if QApplication.instance().platformName() not in ['wayland', 'wayland-egl']: return From dfa65a0bfe5f355ecc6a3ec84ab6a53f8423b2b1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 09:29:00 +0200 Subject: [PATCH 108/186] Remove requirements badge Now that we use pyup weekly they'll be outdated most of the time, and it's not really an useful metric for users anyways. --- README.asciidoc | 1 - 1 file changed, 1 deletion(-) diff --git a/README.asciidoc b/README.asciidoc index a7094b971..09317c0cd 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -11,7 +11,6 @@ image:icons/qutebrowser-64x64.png[qutebrowser logo] *A keyboard-driven, vim-like image:https://img.shields.io/pypi/l/qutebrowser.svg?style=flat["license badge",link="https://github.com/qutebrowser/qutebrowser/blob/master/LICENSE"] image:https://img.shields.io/pypi/v/qutebrowser.svg?style=flat["version badge",link="https://pypi.python.org/pypi/qutebrowser/"] -image:https://requires.io/github/qutebrowser/qutebrowser/requirements.svg?branch=master["requirements badge",link="https://requires.io/github/qutebrowser/qutebrowser/requirements/?branch=master"] image:https://travis-ci.org/qutebrowser/qutebrowser.svg?branch=master["Build Status", link="https://travis-ci.org/qutebrowser/qutebrowser"] image:https://ci.appveyor.com/api/projects/status/5pyauww2k68bbow2/branch/master?svg=true["AppVeyor build status", link="https://ci.appveyor.com/project/qutebrowser/qutebrowser"] image:https://codecov.io/github/qutebrowser/qutebrowser/coverage.svg?branch=master["coverage badge",link="https://codecov.io/github/qutebrowser/qutebrowser?branch=master"] From 35beb84e856bacd9e16f8c0dd7d334bebfcd6a60 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 09:32:23 +0200 Subject: [PATCH 109/186] Fix tests and lint --- qutebrowser/app.py | 4 ++-- qutebrowser/browser/webengine/webenginetab.py | 1 - qutebrowser/config/configinit.py | 4 ++-- qutebrowser/misc/backendproblem.py | 6 +++-- qutebrowser/misc/crashsignal.py | 2 +- qutebrowser/misc/ipc.py | 8 +++---- qutebrowser/qutebrowser.py | 4 ++-- tests/unit/config/test_configinit.py | 23 ++++--------------- 8 files changed, 20 insertions(+), 32 deletions(-) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 00c8f98d8..04eff1925 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -644,8 +644,8 @@ class Quitter: session_manager = objreg.get('session-manager') session_manager.save(session, with_private=True) - # Make sure we're not accepting a connection from the new process before - # we fully exited. + # Make sure we're not accepting a connection from the new process + # before we fully exited. ipc.server.shutdown() # Open a new process and immediately shutdown the existing one diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 707ea10c3..4de4bf26f 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -19,7 +19,6 @@ """Wrapper over a QWebEngineView.""" -import os import math import functools import html as html_utils diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index 1b36fd675..a046395c3 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -26,8 +26,8 @@ from PyQt5.QtWidgets import QMessageBox from qutebrowser.config import (config, configdata, configfiles, configtypes, configexc) -from qutebrowser.utils import objreg, qtutils, usertypes, log, standarddir -from qutebrowser.misc import earlyinit, msgbox, objects +from qutebrowser.utils import objreg, usertypes, log, standarddir +from qutebrowser.misc import msgbox, objects # Error which happened during init, so we can show a message box. diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index bfe2f7cb0..6bc10ba79 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -148,7 +148,7 @@ def _handle_nouveau_graphics(): force_sw_var in os.environ): return - if config.force_software_rendering: + if config.val.force_software_rendering: os.environ[force_sw_var] = '1' return @@ -175,7 +175,8 @@ def _handle_nouveau_graphics(): def _handle_wayland(): assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend - if QApplication.instance().platformName() not in ['wayland', 'wayland-egl']: + platform = QApplication.instance().platformName() + if platform not in ['wayland', 'wayland-egl']: return _show_dialog( @@ -204,6 +205,7 @@ class BackendImports: def _try_import_backends(): """Check whether backends can be imported and return BackendImports.""" + # pylint: disable=unused-variable results = BackendImports() try: diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py index 9899cfcd3..b90eae829 100644 --- a/qutebrowser/misc/crashsignal.py +++ b/qutebrowser/misc/crashsignal.py @@ -36,7 +36,7 @@ except ImportError: import attr from PyQt5.QtCore import (pyqtSlot, qInstallMessageHandler, QObject, QSocketNotifier, QTimer, QUrl) -from PyQt5.QtWidgets import QApplication, QDialog +from PyQt5.QtWidgets import QApplication from qutebrowser.commands import cmdutils from qutebrowser.misc import earlyinit, crashdialog, ipc diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py index c8c9d83b7..c9f982365 100644 --- a/qutebrowser/misc/ipc.py +++ b/qutebrowser/misc/ipc.py @@ -113,15 +113,15 @@ class ListenError(Error): message: The error message. """ - def __init__(self, server): + def __init__(self, local_server): """Constructor. Args: - server: The QLocalServer which has the error set. + local_server: The QLocalServer which has the error set. """ super().__init__() - self.code = server.serverError() - self.message = server.errorString() + self.code = local_server.serverError() + self.message = local_server.errorString() def __str__(self): return "Error while listening to IPC server: {} (error {})".format( diff --git a/qutebrowser/qutebrowser.py b/qutebrowser/qutebrowser.py index 8c80dfc22..1b1cfb013 100644 --- a/qutebrowser/qutebrowser.py +++ b/qutebrowser/qutebrowser.py @@ -27,8 +27,8 @@ qutebrowser's initialization process roughly looks like this: it's too old. - The main() function in this file gets invoked - Argument parsing takes place -- earlyinit.early_init() gets invoked to do various low-level initialization and - checks whether all dependencies are met. +- earlyinit.early_init() gets invoked to do various low-level initialization + and checks whether all dependencies are met. - app.run() gets called, which takes over. See the docstring of app.py for details. """ diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index 1332499de..02b87ebda 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -39,9 +39,6 @@ def init_patch(qapp, fake_save_manager, monkeypatch, config_tmpdir, monkeypatch.setattr(config, 'key_instance', None) monkeypatch.setattr(config, 'change_filters', []) monkeypatch.setattr(configinit, '_init_errors', None) - # Make sure we get no SSL warning - monkeypatch.setattr(configinit.earlyinit, 'check_backend_ssl_support', - lambda _backend: None) yield try: objreg.delete('config-commands') @@ -242,33 +239,23 @@ class TestQtArgs: assert configinit.qt_args(parsed) == [sys.argv[0], '--foo', '--bar'] -@pytest.mark.parametrize('arg, confval, can_import, is_new_webkit, used', [ +@pytest.mark.parametrize('arg, confval, used', [ # overridden by commandline arg - ('webkit', 'auto', False, False, usertypes.Backend.QtWebKit), - # overridden by config - (None, 'webkit', False, False, usertypes.Backend.QtWebKit), - # WebKit available but too old - (None, 'auto', True, False, usertypes.Backend.QtWebEngine), - # WebKit available and new - (None, 'auto', True, True, usertypes.Backend.QtWebKit), - # WebKit unavailable - (None, 'auto', False, False, usertypes.Backend.QtWebEngine), + ('webkit', 'webengine', usertypes.Backend.QtWebKit), + # set in config + (None, 'webkit', usertypes.Backend.QtWebKit), ]) def test_get_backend(monkeypatch, fake_args, config_stub, - arg, confval, can_import, is_new_webkit, used): + arg, confval, used): real_import = __import__ def fake_import(name, *args, **kwargs): if name != 'PyQt5.QtWebKit': return real_import(name, *args, **kwargs) - if can_import: - return None raise ImportError fake_args.backend = arg config_stub.val.backend = confval - monkeypatch.setattr(configinit.qtutils, 'is_new_qtwebkit', - lambda: is_new_webkit) monkeypatch.setattr('builtins.__import__', fake_import) assert configinit.get_backend(fake_args) == used From 6770a474c4801e7f9305903e49e650225ce971a1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 09:52:56 +0200 Subject: [PATCH 110/186] Force software rendering earlier We need to do this before a QApplication exists --- qutebrowser/config/configinit.py | 4 ++++ qutebrowser/misc/backendproblem.py | 7 +------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index a046395c3..6b4553626 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -70,6 +70,10 @@ def early_init(args): objects.backend = get_backend(args) + if (objects.backend == usertypes.Backend.QtWebEngine and + config.val.force_software_rendering): + os.environ['QT_XCB_FORCE_SOFTWARE_OPENGL'] = '1' + def get_backend(args): """Find out what backend to use based on available libraries.""" diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index 6bc10ba79..3cd9e5abd 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -139,17 +139,12 @@ def _show_dialog(*args, **kwargs): def _handle_nouveau_graphics(): assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend - force_sw_var = 'QT_XCB_FORCE_SOFTWARE_OPENGL' if version.opengl_vendor() != 'nouveau': return if (os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1' or - force_sw_var in os.environ): - return - - if config.val.force_software_rendering: - os.environ[force_sw_var] = '1' + 'QT_XCB_FORCE_SOFTWARE_OPENGL' in os.environ): return button = _Button("Force software rendering", 'force_software_rendering', From 45c6ffe9919748fcfd20f88d99a41b13d88ff149 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 10:04:47 +0200 Subject: [PATCH 111/186] Add a test for force_software_rendering --- tests/end2end/test_invocations.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index 03626dc14..aa28c91f7 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -319,3 +319,14 @@ def test_qute_settings_persistence(short_tmpdir, request, quteproc_new): quteproc_new.start(args) assert quteproc_new.get_setting('ignore_case') == 'always' + + +@pytest.mark.no_xvfb +def test_force_software_rendering(request, quteproc_new): + """Make sure we can force software rendering with -s.""" + args = (_base_args(request.config) + + ['--temp-basedir', '-s', 'force_software_rendering', 'true']) + quteproc_new.start(args) + quteproc_new.open_path('chrome://gpu') + message = 'Canvas: Software only, hardware acceleration unavailable' + assert message in quteproc_new.get_content() From 865fc2e0dee5ed8cf2df66a87ab8f3d068bd4dc0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 10:10:14 +0200 Subject: [PATCH 112/186] Handle -s argument earlier This makes sure we can e.g. set software_rendering via -s --- qutebrowser/app.py | 6 ------ qutebrowser/config/configinit.py | 8 +++++++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 04eff1925..b3e299a80 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -207,12 +207,6 @@ def _init_icon(): def _process_args(args): """Open startpage etc. and process commandline args.""" - for opt, val in args.temp_settings: - try: - config.instance.set_str(opt, val) - except configexc.Error as e: - message.error("set: {} - {}".format(e.__class__.__name__, e)) - if not args.override_restore: _load_session(args.session) session_manager = objreg.get('session-manager') diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index 6b4553626..2bbed56e5 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -26,7 +26,7 @@ from PyQt5.QtWidgets import QMessageBox from qutebrowser.config import (config, configdata, configfiles, configtypes, configexc) -from qutebrowser.utils import objreg, usertypes, log, standarddir +from qutebrowser.utils import objreg, usertypes, log, standarddir, message from qutebrowser.misc import msgbox, objects @@ -70,6 +70,12 @@ def early_init(args): objects.backend = get_backend(args) + for opt, val in args.temp_settings: + try: + config.instance.set_str(opt, val) + except configexc.Error as e: + message.error("set: {} - {}".format(e.__class__.__name__, e)) + if (objects.backend == usertypes.Backend.QtWebEngine and config.val.force_software_rendering): os.environ['QT_XCB_FORCE_SOFTWARE_OPENGL'] = '1' From 3be0a78819cf358e932605e66be684162b4e2ec9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 11:05:53 +0200 Subject: [PATCH 113/186] Fix configinit tests --- tests/unit/config/test_configinit.py | 61 ++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index 02b87ebda..811ef80b9 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -18,6 +18,7 @@ """Tests for qutebrowser.config.configinit.""" +import os import sys import logging import unittest.mock @@ -33,7 +34,6 @@ from qutebrowser.utils import objreg, usertypes @pytest.fixture def init_patch(qapp, fake_save_manager, monkeypatch, config_tmpdir, data_tmpdir): - monkeypatch.setattr(configdata, 'DATA', None) monkeypatch.setattr(configfiles, 'state', None) monkeypatch.setattr(config, 'instance', None) monkeypatch.setattr(config, 'key_instance', None) @@ -46,10 +46,17 @@ def init_patch(qapp, fake_save_manager, monkeypatch, config_tmpdir, pass +@pytest.fixture +def args(fake_args): + """Arguments needed for the config to init.""" + fake_args.temp_settings = [] + return fake_args + + class TestEarlyInit: @pytest.mark.parametrize('config_py', [True, 'error', False]) - def test_config_py(self, init_patch, config_tmpdir, caplog, fake_args, + def test_config_py(self, init_patch, config_tmpdir, caplog, args, config_py): """Test loading with only a config.py.""" config_py_file = config_tmpdir / 'config.py' @@ -62,7 +69,7 @@ class TestEarlyInit: 'utf-8', ensure=True) with caplog.at_level(logging.ERROR): - configinit.early_init(fake_args) + configinit.early_init(args) # Check error messages expected_errors = [] @@ -92,7 +99,7 @@ class TestEarlyInit: @pytest.mark.parametrize('config_py', [True, 'error', False]) @pytest.mark.parametrize('invalid_yaml', ['42', 'unknown', 'wrong-type', False]) - def test_autoconfig_yml(self, init_patch, config_tmpdir, caplog, fake_args, + 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.""" # pylint: disable=too-many-locals,too-many-branches @@ -119,7 +126,7 @@ class TestEarlyInit: 'utf-8', ensure=True) with caplog.at_level(logging.ERROR): - configinit.early_init(fake_args) + configinit.early_init(args) # Check error messages expected_errors = [] @@ -158,16 +165,46 @@ class TestEarlyInit: else: assert config.instance._values == {'colors.hints.fg': 'magenta'} - def test_invalid_change_filter(self, init_patch, fake_args): + def test_invalid_change_filter(self, init_patch, args): config.change_filter('foobar') with pytest.raises(configexc.NoOptionError): - configinit.early_init(fake_args) + configinit.early_init(args) + + 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' + + def test_temp_settings_invalid(self, caplog, init_patch, message_mock, + args): + """Invalid temp settings should show an error.""" + args.temp_settings = [('foo', 'bar')] + + with caplog.at_level(logging.ERROR): + configinit.early_init(args) + + 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 + + def test_force_software_rendering(self, monkeypatch, init_patch, args): + """Setting force_software_rendering should set the environment var.""" + envvar = 'QT_XCB_FORCE_SOFTWARE_OPENGL' + monkeypatch.setattr(configinit.objects, 'backend', + usertypes.Backend.QtWebEngine) + monkeypatch.delenv(envvar, raising=False) + args.temp_settings = [('force_software_rendering', 'true')] + + configinit.early_init(args) + + assert os.environ[envvar] == '1' @pytest.mark.parametrize('errors', [True, False]) -def test_late_init(init_patch, monkeypatch, fake_save_manager, fake_args, +def test_late_init(init_patch, monkeypatch, fake_save_manager, args, mocker, errors): - configinit.early_init(fake_args) + configinit.early_init(args) if errors: err = configexc.ConfigErrorDesc("Error text", Exception("Exception")) errs = configexc.ConfigFileErrors("config.py", [err]) @@ -245,7 +282,7 @@ class TestQtArgs: # set in config (None, 'webkit', usertypes.Backend.QtWebKit), ]) -def test_get_backend(monkeypatch, fake_args, config_stub, +def test_get_backend(monkeypatch, args, config_stub, arg, confval, used): real_import = __import__ @@ -254,8 +291,8 @@ def test_get_backend(monkeypatch, fake_args, config_stub, return real_import(name, *args, **kwargs) raise ImportError - fake_args.backend = arg + args.backend = arg config_stub.val.backend = confval monkeypatch.setattr('builtins.__import__', fake_import) - assert configinit.get_backend(fake_args) == used + assert configinit.get_backend(args) == used From 6c25e966214d2224fb7c8491ed2e50c3bf880175 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 11:38:52 +0200 Subject: [PATCH 114/186] Remove unused imports --- qutebrowser/app.py | 3 +-- tests/unit/config/test_configinit.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index b3e299a80..d2efb21e5 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -62,8 +62,7 @@ import qutebrowser.resources from qutebrowser.completion import completiondelegate from qutebrowser.completion.models import miscmodels from qutebrowser.commands import cmdutils, runners, cmdexc -from qutebrowser.config import (config, websettings, configexc, configfiles, - configinit) +from qutebrowser.config import config, websettings, configfiles, configinit from qutebrowser.browser import (urlmarks, adblock, history, browsertab, downloads) from qutebrowser.browser.network import proxy diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index 811ef80b9..92a5308c6 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -26,8 +26,7 @@ import unittest.mock import pytest from qutebrowser import qutebrowser -from qutebrowser.config import (config, configdata, configexc, configfiles, - configinit) +from qutebrowser.config import config, configexc, configfiles, configinit from qutebrowser.utils import objreg, usertypes From 6496442503c07c10ba4a7c61094f4f2fd949aaeb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 11:42:02 +0200 Subject: [PATCH 115/186] Skip test_force_software_rendering on CI We can't be sure we have hardware acceleration there --- tests/end2end/test_invocations.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index aa28c91f7..5ebd5568f 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -322,6 +322,7 @@ def test_qute_settings_persistence(short_tmpdir, request, quteproc_new): @pytest.mark.no_xvfb +@pytest.mark.no_ci def test_force_software_rendering(request, quteproc_new): """Make sure we can force software rendering with -s.""" args = (_base_args(request.config) + From 45db0eaccb888108561d7943ebd9fa199211dee6 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 11:44:21 +0200 Subject: [PATCH 116/186] Really force QtWebEngine for test_force_software_rendering init --- tests/unit/config/test_configinit.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index 92a5308c6..4a8c05dae 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -194,6 +194,7 @@ class TestEarlyInit: usertypes.Backend.QtWebEngine) monkeypatch.delenv(envvar, raising=False) args.temp_settings = [('force_software_rendering', 'true')] + args.backend = 'webengine' configinit.early_init(args) From 4b9bbaa04d528085aad43e3347a8d2405cdd1295 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 17:30:53 +0200 Subject: [PATCH 117/186] Skip test_force_software_rendering with QtWebKit --- tests/end2end/test_invocations.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index 5ebd5568f..ecb824f95 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -325,6 +325,9 @@ def test_qute_settings_persistence(short_tmpdir, request, quteproc_new): @pytest.mark.no_ci def test_force_software_rendering(request, quteproc_new): """Make sure we can force software rendering with -s.""" + if not request.config.webengine: + pytest.skip("Only runs with QtWebEngine") + args = (_base_args(request.config) + ['--temp-basedir', '-s', 'force_software_rendering', 'true']) quteproc_new.start(args) From 9d963d55f5dfc46a2a7f35c2922a46f8a16fee01 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 17:42:21 +0200 Subject: [PATCH 118/186] Fix :debug-cache-stats with QtWebEngine When we use --backend webengine, the QtWebKit stuff might be importable, but the history still isn't initialized because of that. --- qutebrowser/misc/utilcmds.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index 4b6909344..5c85aae10 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -171,12 +171,15 @@ def debug_cache_stats(): prefix_info = configdata.is_valid_prefix.cache_info() # pylint: disable=protected-access render_stylesheet_info = config._render_stylesheet.cache_info() + + history_info = None try: from PyQt5.QtWebKit import QWebHistoryInterface interface = QWebHistoryInterface.defaultInterface() - history_info = interface.historyContains.cache_info() + if interface is not None: + history_info = interface.historyContains.cache_info() except ImportError: - history_info = None + pass log.misc.debug('is_valid_prefix: {}'.format(prefix_info)) log.misc.debug('_render_stylesheet: {}'.format(render_stylesheet_info)) From 02bcec37f41e9b8d0324c8473fe8c271478b5708 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 19:20:29 +0200 Subject: [PATCH 119/186] Darken default prompt color a bit more --- doc/help/settings.asciidoc | 2 +- qutebrowser/config/configdata.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index c2a7f55fb..32230c7e9 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -968,7 +968,7 @@ Background color for prompts. Type: <> -Default: +pass:[dimgrey]+ +Default: empty [[colors.prompts.border]] === colors.prompts.border diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 9a3574282..5d7999dd3 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -1565,7 +1565,7 @@ colors.prompts.border: desc: Border used around UI elements in prompts. colors.prompts.bg: - default: dimgrey + default: #444444 type: QssColor desc: Background color for prompts. From 5c181a23abff59d240e2bd89a17557192ede9292 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 21:24:12 +0200 Subject: [PATCH 120/186] Fix default prompt color The former value was interpreted as a comment in the YAML... --- doc/help/settings.asciidoc | 2 +- qutebrowser/config/configdata.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 32230c7e9..ebf569c99 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -968,7 +968,7 @@ Background color for prompts. Type: <> -Default: empty +Default: +pass:[#444444]+ [[colors.prompts.border]] === colors.prompts.border diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 5d7999dd3..367a751a8 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -1565,7 +1565,7 @@ colors.prompts.border: desc: Border used around UI elements in prompts. colors.prompts.bg: - default: #444444 + default: '#444444' type: QssColor desc: Background color for prompts. From 322d97c3fadac85c629b02c923b7b48df4f23521 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 21:30:32 +0200 Subject: [PATCH 121/186] Only show warning message stack with --debug --- qutebrowser/utils/log.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index fa0208ea7..98f14a454 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -39,6 +39,7 @@ except ImportError: colorama = None _log_inited = False +_args = None COLORS = ['black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white'] COLOR_ESCAPES = {color: '\033[{}m'.format(i) @@ -189,8 +190,9 @@ def init_log(args): logging.captureWarnings(True) _init_py_warnings() QtCore.qInstallMessageHandler(qt_message_handler) - global _log_inited + global _log_inited, _args _log_inited = True + _args = args def _init_py_warnings(): @@ -442,7 +444,11 @@ def qt_message_handler(msg_type, context, msg): msg += ("\n\nOn Archlinux, this should fix the problem:\n" " pacman -S libxkbcommon-x11") faulthandler.disable() - stack = ''.join(traceback.format_stack()) + + if _args.debug: + stack = ''.join(traceback.format_stack()) + else: + stack = None record = qt.makeRecord(name, level, context.file, context.line, msg, None, None, func, sinfo=stack) qt.handle(record) From 0b5af757ecd555b5a9e89d2e70e23cf6638b8791 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 28 Sep 2017 21:51:07 +0200 Subject: [PATCH 122/186] Clarify settings which need a restart [ci skip] --- doc/help/settings.asciidoc | 4 +++- qutebrowser/config/configdata.yml | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index ebf569c99..95360b6ab 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -294,6 +294,7 @@ The backend to use to display websites. qutebrowser supports two different web rendering engines / backends, QtWebKit and QtWebEngine. QtWebKit was discontinued by the Qt project with Qt 5.6, but picked up as a well maintained fork: https://github.com/annulen/webkit/wiki - qutebrowser only supports the fork. QtWebEngine is Qt's official successor to QtWebKit. It's slightly more resource hungry that QtWebKit and has a couple of missing features in qutebrowser, but is generally the preferred choice. +This setting requires a restart. Type: <> @@ -2266,7 +2267,7 @@ Default: +pass:[6]+ [[force_software_rendering]] === force_software_rendering Force software rendering for QtWebEngine. -This is needed for QtWebEngine to work with Nouveau drivers. +This is needed for QtWebEngine to work with Nouveau drivers. This setting requires a restart. Type: <> @@ -2656,6 +2657,7 @@ Default: +pass:[8]+ === qt_args Additional arguments to pass to Qt, without leading `--`. With QtWebEngine, some Chromium arguments (see https://peter.sh/experiments/chromium-command-line-switches/ for a list) will work. +This setting requires a restart. Type: <> diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 367a751a8..8e931a7c7 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -101,6 +101,8 @@ qt_args: https://peter.sh/experiments/chromium-command-line-switches/ for a list) will work. + This setting requires a restart. + force_software_rendering: type: Bool default: false @@ -109,6 +111,7 @@ force_software_rendering: Force software rendering for QtWebEngine. This is needed for QtWebEngine to work with Nouveau drivers. + This setting requires a restart. backend: type: @@ -131,6 +134,8 @@ backend: resource hungry that QtWebKit and has a couple of missing features in qutebrowser, but is generally the preferred choice. + This setting requires a restart. + ## auto_save auto_save.interval: From dca962ca037cf8adea3512464402463fb651a998 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Sep 2017 13:38:38 +0200 Subject: [PATCH 123/186] Make userscripts work on both Python 2 and 3 --- misc/userscripts/readability | 2 +- misc/userscripts/ripbang | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/misc/userscripts/readability b/misc/userscripts/readability index 639e3a111..a5425dbac 100755 --- a/misc/userscripts/readability +++ b/misc/userscripts/readability @@ -1,4 +1,4 @@ -#!/usr/bin/env python2 +#!/usr/bin/env python # # Executes python-readability on current page and opens the summary as new tab. # diff --git a/misc/userscripts/ripbang b/misc/userscripts/ripbang index 4b418443d..b35ff7777 100755 --- a/misc/userscripts/ripbang +++ b/misc/userscripts/ripbang @@ -1,4 +1,4 @@ -#!/usr/bin/env python2 +#!/usr/bin/env python # # Adds DuckDuckGo bang as searchengine. # @@ -8,14 +8,21 @@ # Example: # :spawn --userscript ripbang amazon maps # -import os, re, requests, sys, urllib + +from __future__ import print_function +import os, re, requests, sys + +try: + from urllib.parse import unquote +except ImportError: + from urllib import unquote for argument in sys.argv[1:]: bang = '!' + argument r = requests.get('https://duckduckgo.com/', params={'q': bang + ' SEARCHTEXT'}) - searchengine = urllib.unquote(re.search("url=[^']+", r.text).group(0)) + searchengine = unquote(re.search("url=[^']+", r.text).group(0)) searchengine = searchengine.replace('url=', '') searchengine = searchengine.replace('/l/?kh=-1&uddg=', '') searchengine = searchengine.replace('SEARCHTEXT', '{}') @@ -24,4 +31,4 @@ for argument in sys.argv[1:]: with open(os.environ['QUTE_FIFO'], 'w') as fifo: fifo.write('set searchengines %s %s' % (bang, searchengine)) else: - print '%s %s' % (bang, searchengine) + print('%s %s' % (bang, searchengine)) From 1a381bf0a58e4bd8e2dd15b22fcdcd7d2f13a152 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 29 Sep 2017 22:29:18 +0200 Subject: [PATCH 124/186] eslint: Report unused disables --- qutebrowser/javascript/position_caret.js | 4 ---- scripts/dev/ci/travis_run.sh | 2 +- tox.ini | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/qutebrowser/javascript/position_caret.js b/qutebrowser/javascript/position_caret.js index 4f6c32380..c2df1cf1d 100644 --- a/qutebrowser/javascript/position_caret.js +++ b/qutebrowser/javascript/position_caret.js @@ -18,8 +18,6 @@ * along with qutebrowser. If not, see . */ -/* eslint-disable max-len */ - /** * Snippet to position caret at top of the page when caret mode is enabled. * Some code was borrowed from: @@ -28,8 +26,6 @@ * https://github.com/1995eaton/chromium-vim/blob/master/content_scripts/visual.js */ -/* eslint-enable max-len */ - "use strict"; (function() { diff --git a/scripts/dev/ci/travis_run.sh b/scripts/dev/ci/travis_run.sh index a1f498b62..46a64bc9c 100644 --- a/scripts/dev/ci/travis_run.sh +++ b/scripts/dev/ci/travis_run.sh @@ -5,7 +5,7 @@ if [[ $DOCKER ]]; then elif [[ $TESTENV == eslint ]]; then # Can't run this via tox as we can't easily install tox in the javascript travis env cd qutebrowser/javascript || exit 1 - eslint --color . + eslint --color --report-unused-disable-directives . else args=() [[ $TRAVIS_OS_NAME == osx ]] && args=('--qute-bdd-webengine' '--no-xvfb') diff --git a/tox.ini b/tox.ini index d1ac9fc2f..704e93744 100644 --- a/tox.ini +++ b/tox.ini @@ -274,4 +274,4 @@ commands = deps = whitelist_externals = eslint changedir = {toxinidir}/qutebrowser/javascript -commands = eslint --color . +commands = eslint --color --report-unused-disable-directives . From ba0632369649d177ba058fca95617e53e9fefd59 Mon Sep 17 00:00:00 2001 From: Bryan Gilbert Date: Sat, 30 Sep 2017 09:02:17 -0400 Subject: [PATCH 125/186] fix example config.py alias creation example --- doc/help/configuring.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 6104d8d6c..a689e0603 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -113,7 +113,7 @@ accepted values depend on the type of the option. Commonly used are: - Dictionaries: * `c.headers.custom = {'X-Hello': 'World', 'X-Awesome': 'yes'}` to override any other values in the dictionary. - * `c.aliases['foo'] = ':message-info foo'` to add a single value. + * `c.aliases['foo'] = 'message-info foo'` to add a single value. - Lists: * `c.url.start_pages = ["https://www.qutebrowser.org/"]` to override any previous elements. From e0ff95d62a4e68c2eaef1da0e40dee17e635bcbb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 30 Sep 2017 22:54:49 +0200 Subject: [PATCH 126/186] Remove outdated note from quickstart docs [ci skip] --- doc/quickstart.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/quickstart.asciidoc b/doc/quickstart.asciidoc index 4881cca62..6188b6a54 100644 --- a/doc/quickstart.asciidoc +++ b/doc/quickstart.asciidoc @@ -31,7 +31,7 @@ image:http://qutebrowser.org/img/cheatsheet-small.png["qutebrowser key binding c * Run `:adblock-update` to download adblock lists and activate adblocking. * If you just cloned the repository, you'll need to run `scripts/asciidoc2html.py` to generate the documentation. -* Go to the link:qute://settings[settings page] to set up qutebrowser the way you want it. (Currently not available with the QtWebEngine backend and on the macOS build - use the `:set` command instead) +* Go to the link:qute://settings[settings page] to set up qutebrowser the way you want it. * Subscribe to https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[the mailinglist] or https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser-announce[the announce-only mailinglist]. From 1b88fec7f0d8bbfb98dd972c1adf7cccf8f0c591 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 30 Sep 2017 23:23:24 +0200 Subject: [PATCH 127/186] Fix key chain in configuring docs [ci skip] --- doc/help/configuring.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index a689e0603..573e0d2a3 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -58,7 +58,7 @@ 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 link:commands.html#unbind[`:unbind`] commands: -- Binding the key chain "`,`, `v`" to the `:spawn mpv {url}` command: +- Binding the key chain `,v` to the `:spawn mpv {url}` command: `:bind ,v spawn mpv {url}` - Unbinding the same key chain: `:unbind ,v` - Changing an existing binding: `bind --force ,v message-info foo`. Without From 0fbd9144322f770abad72429e0f224138a2ede5d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 1 Oct 2017 20:14:49 +0200 Subject: [PATCH 128/186] Fix completion for empty config values If we have an empty string in the completion, that already gets completed as ''. If we return "", we'd have '""' in the completion. Fixes #3027 --- 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 7b6f7d3b6..ffa9bf4db 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -44,7 +44,7 @@ def value(optname, *_values, info): model = completionmodel.CompletionModel(column_widths=(30, 70, 0)) try: - current = info.config.get_str(optname) or '""' + current = info.config.get_str(optname) except configexc.NoOptionError: return None From a273baf8a065ce5a5480f8d0e11a2596d6e1996b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sun, 1 Oct 2017 21:20:30 +0200 Subject: [PATCH 129/186] Make sure :bind/unbind works properly when bindings.commands is None To make this work, we should never return None when trying to get bindings to modify. Fixes #3026 --- qutebrowser/config/config.py | 2 +- qutebrowser/config/configtypes.py | 19 ++++++++++++++++ tests/unit/config/test_config.py | 8 +++++++ tests/unit/config/test_configfiles.py | 7 ++++++ tests/unit/config/test_configtypes.py | 31 +++++++++++++++++++++++++++ 5 files changed, 66 insertions(+), 1 deletion(-) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index c7826aef2..891e56bf5 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -384,7 +384,7 @@ class Config(QObject): raise configexc.BackendError(objects.backend) opt.typ.to_py(value) # for validation - self._values[opt.name] = value + self._values[opt.name] = opt.typ.from_obj(value) self.changed.emit(opt.name) log.config.debug("Config option changed: {} = {}".format( diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index bc5db6d94..afe9eb372 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -227,6 +227,10 @@ class BaseType: return None return value + def from_obj(self, value): + """Get the setting value from a config.py/YAML object.""" + return value + def to_py(self, value): """Get the setting value from a Python value. @@ -441,6 +445,11 @@ class List(BaseType): self.to_py(yaml_val) return yaml_val + def from_obj(self, value): + if value is None: + return [] + return value + def to_py(self, value): self._basic_py_validation(value, list) if not value: @@ -506,6 +515,11 @@ class ListOrValue(BaseType): except configexc.ValidationError: return self.valtype.from_str(value) + def from_obj(self, value): + if value is None: + return [] + return value + def to_py(self, value): try: return [self.valtype.to_py(value)] @@ -1176,6 +1190,11 @@ class Dict(BaseType): self.to_py(yaml_val) return yaml_val + def from_obj(self, value): + if value is None: + return {} + return value + def _fill_fixed_keys(self, value): """Fill missing fixed keys with a None-value.""" if self.fixed_keys is None: diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 949064bc9..43af839ba 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -547,6 +547,14 @@ class TestBindConfigCommand: commands.bind(key, 'message-info foo', mode='normal') assert keyconf.get_command(key, 'normal') == 'nop' + def test_bind_none(self, commands, config_stub): + config_stub.val.bindings.commands = None + commands.bind(',x', 'nop') + + def test_unbind_none(self, commands, config_stub): + config_stub.val.bindings.commands = None + commands.unbind('H') + @pytest.mark.parametrize('key, normalized', [ ('a', 'a'), # default bindings ('b', 'b'), # custom bindings diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index d987d7442..6274b7d50 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -378,6 +378,13 @@ class TestConfigPy: expected = "Duplicate key H - use force=True to override!" assert str(error.exception) == 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 + @pytest.mark.parametrize('line, key, mode', [ ('config.unbind("o")', 'o', 'normal'), ('config.unbind("y", mode="prompt")', 'y', 'prompt'), diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 1c6ed7f08..9b5e23263 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -370,6 +370,10 @@ class TestBaseType: def test_to_doc(self, klass, value, expected): assert klass().to_doc(value) == expected + @pytest.mark.parametrize('obj', [42, '', None, 'foo']) + def test_from_obj(self, klass, obj): + assert klass(none_ok=True).from_obj(obj) == obj + class MappingSubclass(configtypes.MappingType): @@ -550,6 +554,14 @@ class TestList: with pytest.raises(configexc.ValidationError): klass().from_str(val) + @pytest.mark.parametrize('obj, expected', [ + ([1], [1]), + ([], []), + (None, []), + ]) + def test_from_obj(self, klass, obj, expected): + assert klass(none_ok_outer=True).from_obj(obj) == expected + @pytest.mark.parametrize('val', [['foo'], ['foo', 'bar']]) def test_to_py_valid(self, klass, val): assert klass().to_py(val) == val @@ -713,6 +725,15 @@ class TestListOrValue: def test_to_py_length(self, strtype, klass, val): klass(strtype, none_ok=True, length=2).to_py(val) + @pytest.mark.parametrize('obj, expected', [ + (['a'], ['a']), + ([], []), + (None, []), + ]) + def test_from_obj(self, klass, obj, expected): + typ = klass(none_ok=True, valtype=configtypes.String()) + assert typ.from_obj(obj) == expected + @pytest.mark.parametrize('val', [['a'], ['a', 'b'], ['a', 'b', 'c', 'd']]) def test_wrong_length(self, strtype, klass, val): with pytest.raises(configexc.ValidationError, @@ -1533,6 +1554,16 @@ class TestDict: valtype=configtypes.Int()) assert typ.from_str('{"answer": 42}') == {"answer": 42} + @pytest.mark.parametrize('obj, expected', [ + ({'a': 'b'}, {'a': 'b'}), + ({}, {}), + (None, {}), + ]) + def test_from_obj(self, klass, obj, expected): + d = klass(keytype=configtypes.String(), valtype=configtypes.String(), + none_ok=True) + assert d.from_obj(obj) == expected + @pytest.mark.parametrize('keytype, valtype, val', [ (configtypes.String(), configtypes.String(), {'hello': 'world'}), (configtypes.String(), configtypes.Int(), {'hello': 42}), From 32d529b54e8416522ab999eae263b33a3538eeb1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 2 Oct 2017 06:24:29 +0200 Subject: [PATCH 130/186] Fix typo in configuring docs [ci skip] --- doc/help/configuring.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 573e0d2a3..f195db230 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -334,7 +334,7 @@ Note that this won't work for values which are dictionaries. Binding chained commands ^^^^^^^^^^^^^^^^^^^^^^^^ -If you have a lot of chained comamnds you want to bind, you can write a helper +If you have a lot of chained commands you want to bind, you can write a helper to do so: [source,python] From a8fc5617075ebc4aff5dbdae7f804f36eb7fc9cb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 2 Oct 2017 07:06:05 +0200 Subject: [PATCH 131/186] Split config commands off to their own file. --- qutebrowser/config/config.py | 150 +--------- qutebrowser/config/configcommands.py | 171 +++++++++++ qutebrowser/config/configinit.py | 6 +- scripts/dev/check_coverage.py | 2 + tests/unit/config/conftest.py | 29 ++ tests/unit/config/test_config.py | 347 +--------------------- tests/unit/config/test_configcommands.py | 360 +++++++++++++++++++++++ 7 files changed, 570 insertions(+), 495 deletions(-) create mode 100644 qutebrowser/config/configcommands.py create mode 100644 tests/unit/config/conftest.py create mode 100644 tests/unit/config/test_configcommands.py diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 891e56bf5..7107564af 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -23,13 +23,11 @@ import copy import contextlib import functools -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject -from qutebrowser.config import configdata, configexc, configtypes -from qutebrowser.utils import utils, objreg, message, log, jinja +from qutebrowser.config import configdata, configexc +from qutebrowser.utils import utils, log, jinja from qutebrowser.misc import objects -from qutebrowser.commands import cmdexc, cmdutils -from qutebrowser.completion.models import configmodel # An easy way to access the config from other code via config.val.foo val = None @@ -205,148 +203,6 @@ class KeyConfig: self._config.update_mutables(save_yaml=save_yaml) -class ConfigCommands: - - """qutebrowser commands related to the configuration.""" - - def __init__(self, config, keyconfig): - self._config = config - self._keyconfig = keyconfig - - @cmdutils.register(instance='config-commands', star_args_optional=True) - @cmdutils.argument('option', completion=configmodel.option) - @cmdutils.argument('values', completion=configmodel.value) - @cmdutils.argument('win_id', win_id=True) - def set(self, win_id, option=None, *values, temp=False, print_=False): - """Set an option. - - If the option name ends with '?', the value of the option is shown - instead. - - If the option name ends with '!' and it is a boolean value, toggle it. - - Args: - option: The name of the option. - values: The value to set, or the values to cycle through. - temp: Set value temporarily until qutebrowser is closed. - print_: Print the value after setting. - """ - if option is None: - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=win_id) - tabbed_browser.openurl(QUrl('qute://settings'), newtab=False) - return - - if option.endswith('?') and option != '?': - self._print_value(option[:-1]) - return - - with self._handle_config_error(): - if option.endswith('!') and option != '!' and not values: - # Handle inversion as special cases of the cycle code path - option = option[:-1] - opt = self._config.get_opt(option) - if isinstance(opt.typ, configtypes.Bool): - values = ['false', 'true'] - else: - raise cmdexc.CommandError( - "set: Can't toggle non-bool setting {}".format(option)) - elif not values: - raise cmdexc.CommandError("set: The following arguments " - "are required: value") - self._set_next(option, values, temp=temp) - - if print_: - self._print_value(option) - - def _print_value(self, option): - """Print the value of the given option.""" - with self._handle_config_error(): - value = self._config.get_str(option) - message.info("{} = {}".format(option, value)) - - def _set_next(self, option, values, *, temp): - """Set the next value out of a list of values.""" - if len(values) == 1: - # If we have only one value, just set it directly (avoid - # breaking stuff like aliases or other pseudo-settings) - self._config.set_str(option, values[0], save_yaml=not temp) - return - - # Use the next valid value from values, or the first if the current - # value does not appear in the list - old_value = self._config.get_obj(option, mutable=False) - opt = self._config.get_opt(option) - values = [opt.typ.from_str(val) for val in values] - - try: - idx = values.index(old_value) - idx = (idx + 1) % len(values) - value = values[idx] - except ValueError: - value = values[0] - self._config.set_obj(option, value, save_yaml=not temp) - - @contextlib.contextmanager - def _handle_config_error(self): - """Catch errors in set_command and raise CommandError.""" - try: - yield - except configexc.Error as e: - raise cmdexc.CommandError("set: {}".format(e)) - - @cmdutils.register(instance='config-commands', maxsplit=1, - no_cmd_split=True, no_replace_variables=True) - @cmdutils.argument('command', completion=configmodel.bind) - def bind(self, key, command=None, *, mode='normal', force=False): - """Bind a key to a command. - - Args: - key: The keychain or special key (inside `<...>`) to bind. - command: The command to execute, with optional args, or None to - print the current binding. - mode: A comma-separated list of modes to bind the key in - (default: `normal`). See `:help bindings.commands` for the - available modes. - force: Rebind the key if it is already bound. - """ - if command is None: - 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) - cmd = self._keyconfig.get_command(key, mode) - if cmd is None: - message.info("{} is unbound in {} mode".format(key, mode)) - else: - message.info("{} is bound to '{}' in {} mode".format( - key, cmd, mode)) - return - - try: - self._keyconfig.bind(key, command, mode=mode, force=force, - save_yaml=True) - except configexc.DuplicateKeyError as e: - raise cmdexc.CommandError("bind: {} - use --force to override!" - .format(e)) - except configexc.KeybindingError as e: - raise cmdexc.CommandError("bind: {}".format(e)) - - @cmdutils.register(instance='config-commands') - def unbind(self, key, *, mode='normal'): - """Unbind a keychain. - - Args: - key: The keychain or special key (inside <...>) to unbind. - mode: A mode to unbind the key in (default: `normal`). - See `:help bindings.commands` for the available modes. - """ - try: - self._keyconfig.unbind(key, mode=mode, save_yaml=True) - except configexc.KeybindingError as e: - raise cmdexc.CommandError('unbind: {}'.format(e)) - - class Config(QObject): """Main config object. diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py new file mode 100644 index 000000000..866f29c74 --- /dev/null +++ b/qutebrowser/config/configcommands.py @@ -0,0 +1,171 @@ +# 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 . + +"""Commands related to the configuration.""" + +import contextlib + +from PyQt5.QtCore import QUrl + +from qutebrowser.commands import cmdexc, cmdutils +from qutebrowser.completion.models import configmodel +from qutebrowser.utils import objreg, utils, message +from qutebrowser.config import configtypes, configexc + + +class ConfigCommands: + + """qutebrowser commands related to the configuration.""" + + def __init__(self, config, keyconfig): + self._config = config + self._keyconfig = keyconfig + + @cmdutils.register(instance='config-commands', star_args_optional=True) + @cmdutils.argument('option', completion=configmodel.option) + @cmdutils.argument('values', completion=configmodel.value) + @cmdutils.argument('win_id', win_id=True) + def set(self, win_id, option=None, *values, temp=False, print_=False): + """Set an option. + + If the option name ends with '?', the value of the option is shown + instead. + + If the option name ends with '!' and it is a boolean value, toggle it. + + Args: + option: The name of the option. + values: The value to set, or the values to cycle through. + temp: Set value temporarily until qutebrowser is closed. + print_: Print the value after setting. + """ + if option is None: + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + tabbed_browser.openurl(QUrl('qute://settings'), newtab=False) + return + + if option.endswith('?') and option != '?': + self._print_value(option[:-1]) + return + + with self._handle_config_error(): + if option.endswith('!') and option != '!' and not values: + # Handle inversion as special cases of the cycle code path + option = option[:-1] + opt = self._config.get_opt(option) + if isinstance(opt.typ, configtypes.Bool): + values = ['false', 'true'] + else: + raise cmdexc.CommandError( + "set: Can't toggle non-bool setting {}".format(option)) + elif not values: + raise cmdexc.CommandError("set: The following arguments " + "are required: value") + self._set_next(option, values, temp=temp) + + if print_: + self._print_value(option) + + def _print_value(self, option): + """Print the value of the given option.""" + with self._handle_config_error(): + value = self._config.get_str(option) + message.info("{} = {}".format(option, value)) + + def _set_next(self, option, values, *, temp): + """Set the next value out of a list of values.""" + if len(values) == 1: + # If we have only one value, just set it directly (avoid + # breaking stuff like aliases or other pseudo-settings) + self._config.set_str(option, values[0], save_yaml=not temp) + return + + # Use the next valid value from values, or the first if the current + # value does not appear in the list + old_value = self._config.get_obj(option, mutable=False) + opt = self._config.get_opt(option) + values = [opt.typ.from_str(val) for val in values] + + try: + idx = values.index(old_value) + idx = (idx + 1) % len(values) + value = values[idx] + except ValueError: + value = values[0] + self._config.set_obj(option, value, save_yaml=not temp) + + @contextlib.contextmanager + def _handle_config_error(self): + """Catch errors in set_command and raise CommandError.""" + try: + yield + except configexc.Error as e: + raise cmdexc.CommandError("set: {}".format(e)) + + @cmdutils.register(instance='config-commands', maxsplit=1, + no_cmd_split=True, no_replace_variables=True) + @cmdutils.argument('command', completion=configmodel.bind) + def bind(self, key, command=None, *, mode='normal', force=False): + """Bind a key to a command. + + Args: + key: The keychain or special key (inside `<...>`) to bind. + command: The command to execute, with optional args, or None to + print the current binding. + mode: A comma-separated list of modes to bind the key in + (default: `normal`). See `:help bindings.commands` for the + available modes. + force: Rebind the key if it is already bound. + """ + if command is None: + 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) + cmd = self._keyconfig.get_command(key, mode) + if cmd is None: + message.info("{} is unbound in {} mode".format(key, mode)) + else: + message.info("{} is bound to '{}' in {} mode".format( + key, cmd, mode)) + return + + try: + self._keyconfig.bind(key, command, mode=mode, force=force, + save_yaml=True) + except configexc.DuplicateKeyError as e: + raise cmdexc.CommandError("bind: {} - use --force to override!" + .format(e)) + except configexc.KeybindingError as e: + raise cmdexc.CommandError("bind: {}".format(e)) + + @cmdutils.register(instance='config-commands') + def unbind(self, key, *, mode='normal'): + """Unbind a keychain. + + Args: + key: The keychain or special key (inside <...>) to unbind. + mode: A mode to unbind the key in (default: `normal`). + See `:help bindings.commands` for the available modes. + """ + try: + self._keyconfig.unbind(key, mode=mode, save_yaml=True) + except configexc.KeybindingError as e: + raise cmdexc.CommandError('unbind: {}'.format(e)) diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index 2bbed56e5..209e6428d 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -25,7 +25,7 @@ import sys from PyQt5.QtWidgets import QMessageBox from qutebrowser.config import (config, configdata, configfiles, configtypes, - configexc) + configexc, configcommands) from qutebrowser.utils import objreg, usertypes, log, standarddir, message from qutebrowser.misc import msgbox, objects @@ -50,8 +50,8 @@ def early_init(args): configtypes.Font.monospace_fonts = config.val.fonts.monospace - config_commands = config.ConfigCommands(config.instance, - config.key_instance) + config_commands = configcommands.ConfigCommands( + config.instance, config.key_instance) objreg.register('config-commands', config_commands) config_file = os.path.join(standarddir.config(), 'config.py') diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 99bd1277d..708870371 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -143,6 +143,8 @@ PERFECT_FILES = [ 'config/configtypes.py'), ('tests/unit/config/test_configinit.py', 'config/configinit.py'), + ('tests/unit/config/test_configcommands.py', + 'config/configcommands.py'), ('tests/unit/utils/test_qtutils.py', 'utils/qtutils.py'), diff --git a/tests/unit/config/conftest.py b/tests/unit/config/conftest.py new file mode 100644 index 000000000..89767b786 --- /dev/null +++ b/tests/unit/config/conftest.py @@ -0,0 +1,29 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: +# Copyright 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 . + +"""Fixtures needed in various config test files.""" + +import pytest + +from qutebrowser.config import config + + +@pytest.fixture +def keyconf(config_stub): + config_stub.val.aliases = {} + return config.KeyConfig(config_stub) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 43af839ba..24a0965d0 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -22,12 +22,11 @@ import copy import types import pytest -from PyQt5.QtCore import QObject, QUrl +from PyQt5.QtCore import QObject from PyQt5.QtGui import QColor -from qutebrowser.commands import cmdexc from qutebrowser.config import config, configdata, configexc -from qutebrowser.utils import objreg, usertypes +from qutebrowser.utils import usertypes from qutebrowser.misc import objects @@ -38,12 +37,6 @@ def configdata_init(): configdata.init() -@pytest.fixture -def keyconf(config_stub): - config_stub.val.aliases = {} - return config.KeyConfig(config_stub) - - class TestChangeFilter: @pytest.fixture(autouse=True) @@ -262,342 +255,6 @@ class TestKeyConfig: keyconf.unbind('foobar', mode='normal') -class TestSetConfigCommand: - - """Tests for :set.""" - - @pytest.fixture - def commands(self, config_stub, keyconf): - return config.ConfigCommands(config_stub, keyconf) - - @pytest.fixture - def tabbed_browser(self, stubs, win_registry): - tb = stubs.TabbedBrowserStub() - objreg.register('tabbed-browser', tb, scope='window', window=0) - yield tb - objreg.delete('tabbed-browser', scope='window', window=0) - - def test_set_no_args(self, commands, tabbed_browser): - """Run ':set'. - - Should open qute://settings.""" - commands.set(win_id=0) - assert tabbed_browser.opened_url == QUrl('qute://settings') - - def test_get(self, config_stub, commands, message_mock): - """Run ':set url.auto_search?'. - - Should show the value. - """ - config_stub.val.url.auto_search = 'never' - commands.set(win_id=0, option='url.auto_search?') - msg = message_mock.getmsg(usertypes.MessageLevel.info) - assert msg.text == 'url.auto_search = never' - - @pytest.mark.parametrize('temp', [True, False]) - @pytest.mark.parametrize('option, old_value, inp, new_value', [ - ('url.auto_search', 'naive', 'dns', 'dns'), - # https://github.com/qutebrowser/qutebrowser/issues/2962 - ('editor.command', ['gvim', '-f', '{}'], '[emacs, "{}"]', - ['emacs', '{}']), - ]) - def test_set_simple(self, monkeypatch, commands, config_stub, - temp, option, old_value, inp, new_value): - """Run ':set [-t] option value'. - - Should set the setting accordingly. - """ - monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit) - assert config_stub.get(option) == old_value - - 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 - - @pytest.mark.parametrize('temp', [True, False]) - def test_set_temp_override(self, commands, config_stub, temp): - """Invoking :set twice. - - :set url.auto_search dns - :set -t url.auto_search never - - Should set the setting accordingly. - """ - assert config_stub.val.url.auto_search == 'naive' - commands.set(0, 'url.auto_search', 'dns') - 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' - - def test_set_print(self, config_stub, commands, message_mock): - """Run ':set -p url.auto_search never'. - - 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.url.auto_search == 'dns' - msg = message_mock.getmsg(usertypes.MessageLevel.info) - assert msg.text == 'url.auto_search = dns' - - def test_set_toggle(self, commands, config_stub): - """Run ':set auto_save.session!'. - - Should toggle the value. - """ - assert not config_stub.val.auto_save.session - commands.set(0, 'auto_save.session!') - assert config_stub.val.auto_save.session - assert config_stub._yaml['auto_save.session'] - - def test_set_toggle_nonbool(self, commands, config_stub): - """Run ':set url.auto_search!'. - - Should show an error - """ - assert config_stub.val.url.auto_search == 'naive' - with pytest.raises(cmdexc.CommandError, match="set: Can't toggle " - "non-bool setting url.auto_search"): - commands.set(0, 'url.auto_search!') - assert config_stub.val.url.auto_search == 'naive' - - def test_set_toggle_print(self, commands, config_stub, message_mock): - """Run ':set -p auto_save.session!'. - - Should toggle the value and show the new value. - """ - commands.set(0, 'auto_save.session!', print_=True) - msg = message_mock.getmsg(usertypes.MessageLevel.info) - assert msg.text == 'auto_save.session = true' - - def test_set_invalid_option(self, commands): - """Run ':set foo bar'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, match="set: No option 'foo'"): - commands.set(0, 'foo', 'bar') - - def test_set_invalid_value(self, commands): - """Run ':set auto_save.session blah'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, - match="set: Invalid value 'blah' - must be a " - "boolean!"): - commands.set(0, 'auto_save.session', 'blah') - - def test_set_wrong_backend(self, commands, monkeypatch): - monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine) - with pytest.raises(cmdexc.CommandError, - match="set: This setting is not available with the " - "QtWebEngine backend!"): - commands.set(0, 'content.cookies.accept', 'all') - - @pytest.mark.parametrize('option', ['?', '!', 'url.auto_search']) - def test_empty(self, commands, option): - """Run ':set ?' / ':set !' / ':set url.auto_search'. - - Should show an error. - See https://github.com/qutebrowser/qutebrowser/issues/1109 - """ - with pytest.raises(cmdexc.CommandError, - match="set: The following arguments are required: " - "value"): - commands.set(win_id=0, option=option) - - @pytest.mark.parametrize('suffix', '?!') - def test_invalid(self, commands, suffix): - """Run ':set foo?' / ':set foo!'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, match="set: No option 'foo'"): - commands.set(win_id=0, option='foo' + suffix) - - @pytest.mark.parametrize('initial, expected', [ - # Normal cycling - ('magenta', 'blue'), - # Through the end of the list - ('yellow', 'green'), - # Value which is not in the list - ('red', 'green'), - ]) - def test_cycling(self, commands, config_stub, initial, expected): - """Run ':set' with multiple values.""" - opt = 'colors.statusbar.normal.bg' - config_stub.set_obj(opt, initial) - commands.set(0, opt, 'green', 'magenta', 'blue', 'yellow') - assert config_stub.get(opt) == expected - assert config_stub._yaml[opt] == expected - - def test_cycling_different_representation(self, commands, config_stub): - """When using a different representation, cycling should work. - - For example, we use [foo] which is represented as ["foo"]. - """ - opt = 'qt_args' - config_stub.set_obj(opt, ['foo']) - commands.set(0, opt, '[foo]', '[bar]') - assert config_stub.get(opt) == ['bar'] - commands.set(0, opt, '[foo]', '[bar]') - assert config_stub.get(opt) == ['foo'] - - -class TestBindConfigCommand: - - """Tests for :bind and :unbind.""" - - @pytest.fixture - def commands(self, config_stub, keyconf): - return config.ConfigCommands(config_stub, keyconf) - - @pytest.fixture - def no_bindings(self): - """Get a dict with no bindings.""" - return {'normal': {}} - - @pytest.mark.parametrize('command', ['nop', 'nope']) - def test_bind(self, commands, config_stub, no_bindings, keyconf, command): - """Simple :bind test (and aliases).""" - config_stub.val.aliases = {'nope': 'nop'} - config_stub.val.bindings.default = no_bindings - config_stub.val.bindings.commands = no_bindings - - commands.bind('a', command) - assert keyconf.get_command('a', 'normal') == command - yaml_bindings = config_stub._yaml['bindings.commands']['normal'] - assert yaml_bindings['a'] == command - - @pytest.mark.parametrize('key, mode, expected', [ - # Simple - ('a', 'normal', "a is bound to 'message-info a' in normal mode"), - # Alias - ('b', 'normal', "b is bound to 'mib' in normal mode"), - # Custom binding - ('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"), - # unbound - ('x', 'normal', "x is unbound in normal mode"), - # non-default mode - ('x', 'caret', "x is bound to 'nop' in caret mode"), - ]) - def test_bind_print(self, commands, config_stub, message_mock, - key, mode, expected): - """Run ':bind key'. - - Should print the binding. - """ - config_stub.val.aliases = {'mib': 'message-info b'} - config_stub.val.bindings.default = { - 'normal': {'a': 'message-info a', - 'b': 'mib', - '': 'message-info C-x'}, - 'caret': {'x': 'nop'} - } - config_stub.val.bindings.commands = { - 'normal': {'c': 'message-info c'} - } - - commands.bind(key, mode=mode) - - msg = message_mock.getmsg(usertypes.MessageLevel.info) - assert msg.text == expected - - def test_bind_invalid_mode(self, commands): - """Run ':bind --mode=wrongmode nop'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, - match='bind: Invalid mode wrongmode!'): - commands.bind('a', 'nop', mode='wrongmode') - - @pytest.mark.parametrize('force', [True, False]) - @pytest.mark.parametrize('key', ['a', 'b', '']) - def test_bind_duplicate(self, commands, config_stub, keyconf, force, key): - """Run ':bind' with a key which already has been bound.'. - - Also tests for https://github.com/qutebrowser/qutebrowser/issues/1544 - """ - config_stub.val.bindings.default = { - 'normal': {'a': 'nop', '': 'nop'} - } - config_stub.val.bindings.commands = { - 'normal': {'b': 'nop'}, - } - - if force: - commands.bind(key, 'message-info foo', mode='normal', force=True) - assert keyconf.get_command(key, 'normal') == 'message-info foo' - else: - with pytest.raises(cmdexc.CommandError, - match="bind: Duplicate key .* - use --force to " - "override"): - commands.bind(key, 'message-info foo', mode='normal') - assert keyconf.get_command(key, 'normal') == 'nop' - - def test_bind_none(self, commands, config_stub): - config_stub.val.bindings.commands = None - commands.bind(',x', 'nop') - - def test_unbind_none(self, commands, config_stub): - config_stub.val.bindings.commands = None - commands.unbind('H') - - @pytest.mark.parametrize('key, normalized', [ - ('a', 'a'), # default bindings - ('b', 'b'), # custom bindings - ('c', 'c'), # :bind then :unbind - ('', '') # normalized special binding - ]) - def test_unbind(self, commands, keyconf, config_stub, key, normalized): - config_stub.val.bindings.default = { - 'normal': {'a': 'nop', '': 'nop'}, - 'caret': {'a': 'nop', '': 'nop'}, - } - config_stub.val.bindings.commands = { - 'normal': {'b': 'nop'}, - 'caret': {'b': 'nop'}, - } - if key == 'c': - # Test :bind and :unbind - commands.bind(key, 'nop') - - commands.unbind(key) - assert keyconf.get_command(key, 'normal') is None - - yaml_bindings = config_stub._yaml['bindings.commands']['normal'] - if key in 'bc': - # Custom binding - assert normalized not in yaml_bindings - else: - assert yaml_bindings[normalized] is None - - @pytest.mark.parametrize('key, mode, expected', [ - ('foobar', 'normal', - "unbind: Can't find binding 'foobar' in normal mode"), - ('x', 'wrongmode', "unbind: 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) - - class TestConfig: @pytest.fixture diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py new file mode 100644 index 000000000..a3862e027 --- /dev/null +++ b/tests/unit/config/test_configcommands.py @@ -0,0 +1,360 @@ +# 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 . + +"""Tests for qutebrowser.config.configcommands.""" + +import pytest +from PyQt5.QtCore import QUrl + +from qutebrowser.config import configcommands +from qutebrowser.commands import cmdexc +from qutebrowser.utils import objreg, usertypes +from qutebrowser.misc import objects + + +@pytest.fixture +def commands(config_stub, keyconf): + return configcommands.ConfigCommands(config_stub, keyconf) + + +class TestSetConfigCommand: + + """Tests for :set.""" + + @pytest.fixture + def tabbed_browser(self, stubs, win_registry): + tb = stubs.TabbedBrowserStub() + objreg.register('tabbed-browser', tb, scope='window', window=0) + yield tb + objreg.delete('tabbed-browser', scope='window', window=0) + + def test_set_no_args(self, commands, tabbed_browser): + """Run ':set'. + + Should open qute://settings.""" + commands.set(win_id=0) + assert tabbed_browser.opened_url == QUrl('qute://settings') + + def test_get(self, config_stub, commands, message_mock): + """Run ':set url.auto_search?'. + + Should show the value. + """ + config_stub.val.url.auto_search = 'never' + commands.set(win_id=0, option='url.auto_search?') + msg = message_mock.getmsg(usertypes.MessageLevel.info) + assert msg.text == 'url.auto_search = never' + + @pytest.mark.parametrize('temp', [True, False]) + @pytest.mark.parametrize('option, old_value, inp, new_value', [ + ('url.auto_search', 'naive', 'dns', 'dns'), + # https://github.com/qutebrowser/qutebrowser/issues/2962 + ('editor.command', ['gvim', '-f', '{}'], '[emacs, "{}"]', + ['emacs', '{}']), + ]) + def test_set_simple(self, monkeypatch, commands, config_stub, + temp, option, old_value, inp, new_value): + """Run ':set [-t] option value'. + + Should set the setting accordingly. + """ + monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit) + assert config_stub.get(option) == old_value + + 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 + + @pytest.mark.parametrize('temp', [True, False]) + def test_set_temp_override(self, commands, config_stub, temp): + """Invoking :set twice. + + :set url.auto_search dns + :set -t url.auto_search never + + Should set the setting accordingly. + """ + assert config_stub.val.url.auto_search == 'naive' + commands.set(0, 'url.auto_search', 'dns') + 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' + + def test_set_print(self, config_stub, commands, message_mock): + """Run ':set -p url.auto_search never'. + + 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.url.auto_search == 'dns' + msg = message_mock.getmsg(usertypes.MessageLevel.info) + assert msg.text == 'url.auto_search = dns' + + def test_set_toggle(self, commands, config_stub): + """Run ':set auto_save.session!'. + + Should toggle the value. + """ + assert not config_stub.val.auto_save.session + commands.set(0, 'auto_save.session!') + assert config_stub.val.auto_save.session + assert config_stub._yaml['auto_save.session'] + + def test_set_toggle_nonbool(self, commands, config_stub): + """Run ':set url.auto_search!'. + + Should show an error + """ + assert config_stub.val.url.auto_search == 'naive' + with pytest.raises(cmdexc.CommandError, match="set: Can't toggle " + "non-bool setting url.auto_search"): + commands.set(0, 'url.auto_search!') + assert config_stub.val.url.auto_search == 'naive' + + def test_set_toggle_print(self, commands, config_stub, message_mock): + """Run ':set -p auto_save.session!'. + + Should toggle the value and show the new value. + """ + commands.set(0, 'auto_save.session!', print_=True) + msg = message_mock.getmsg(usertypes.MessageLevel.info) + assert msg.text == 'auto_save.session = true' + + def test_set_invalid_option(self, commands): + """Run ':set foo bar'. + + Should show an error. + """ + with pytest.raises(cmdexc.CommandError, match="set: No option 'foo'"): + commands.set(0, 'foo', 'bar') + + def test_set_invalid_value(self, commands): + """Run ':set auto_save.session blah'. + + Should show an error. + """ + with pytest.raises(cmdexc.CommandError, + match="set: Invalid value 'blah' - must be a " + "boolean!"): + commands.set(0, 'auto_save.session', 'blah') + + def test_set_wrong_backend(self, commands, monkeypatch): + monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine) + with pytest.raises(cmdexc.CommandError, + match="set: This setting is not available with the " + "QtWebEngine backend!"): + commands.set(0, 'content.cookies.accept', 'all') + + @pytest.mark.parametrize('option', ['?', '!', 'url.auto_search']) + def test_empty(self, commands, option): + """Run ':set ?' / ':set !' / ':set url.auto_search'. + + Should show an error. + See https://github.com/qutebrowser/qutebrowser/issues/1109 + """ + with pytest.raises(cmdexc.CommandError, + match="set: The following arguments are required: " + "value"): + commands.set(win_id=0, option=option) + + @pytest.mark.parametrize('suffix', '?!') + def test_invalid(self, commands, suffix): + """Run ':set foo?' / ':set foo!'. + + Should show an error. + """ + with pytest.raises(cmdexc.CommandError, match="set: No option 'foo'"): + commands.set(win_id=0, option='foo' + suffix) + + @pytest.mark.parametrize('initial, expected', [ + # Normal cycling + ('magenta', 'blue'), + # Through the end of the list + ('yellow', 'green'), + # Value which is not in the list + ('red', 'green'), + ]) + def test_cycling(self, commands, config_stub, initial, expected): + """Run ':set' with multiple values.""" + opt = 'colors.statusbar.normal.bg' + config_stub.set_obj(opt, initial) + commands.set(0, opt, 'green', 'magenta', 'blue', 'yellow') + assert config_stub.get(opt) == expected + assert config_stub._yaml[opt] == expected + + def test_cycling_different_representation(self, commands, config_stub): + """When using a different representation, cycling should work. + + For example, we use [foo] which is represented as ["foo"]. + """ + opt = 'qt_args' + config_stub.set_obj(opt, ['foo']) + commands.set(0, opt, '[foo]', '[bar]') + assert config_stub.get(opt) == ['bar'] + commands.set(0, opt, '[foo]', '[bar]') + assert config_stub.get(opt) == ['foo'] + + +class TestBindConfigCommand: + + """Tests for :bind and :unbind.""" + + @pytest.fixture + def no_bindings(self): + """Get a dict with no bindings.""" + return {'normal': {}} + + @pytest.mark.parametrize('command', ['nop', 'nope']) + def test_bind(self, commands, config_stub, no_bindings, keyconf, command): + """Simple :bind test (and aliases).""" + config_stub.val.aliases = {'nope': 'nop'} + config_stub.val.bindings.default = no_bindings + config_stub.val.bindings.commands = no_bindings + + commands.bind('a', command) + assert keyconf.get_command('a', 'normal') == command + yaml_bindings = config_stub._yaml['bindings.commands']['normal'] + assert yaml_bindings['a'] == command + + @pytest.mark.parametrize('key, mode, expected', [ + # Simple + ('a', 'normal', "a is bound to 'message-info a' in normal mode"), + # Alias + ('b', 'normal', "b is bound to 'mib' in normal mode"), + # Custom binding + ('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"), + # unbound + ('x', 'normal', "x is unbound in normal mode"), + # non-default mode + ('x', 'caret', "x is bound to 'nop' in caret mode"), + ]) + def test_bind_print(self, commands, config_stub, message_mock, + key, mode, expected): + """Run ':bind key'. + + Should print the binding. + """ + config_stub.val.aliases = {'mib': 'message-info b'} + config_stub.val.bindings.default = { + 'normal': {'a': 'message-info a', + 'b': 'mib', + '': 'message-info C-x'}, + 'caret': {'x': 'nop'} + } + config_stub.val.bindings.commands = { + 'normal': {'c': 'message-info c'} + } + + commands.bind(key, mode=mode) + + msg = message_mock.getmsg(usertypes.MessageLevel.info) + assert msg.text == expected + + def test_bind_invalid_mode(self, commands): + """Run ':bind --mode=wrongmode nop'. + + Should show an error. + """ + with pytest.raises(cmdexc.CommandError, + match='bind: Invalid mode wrongmode!'): + commands.bind('a', 'nop', mode='wrongmode') + + @pytest.mark.parametrize('force', [True, False]) + @pytest.mark.parametrize('key', ['a', 'b', '']) + def test_bind_duplicate(self, commands, config_stub, keyconf, force, key): + """Run ':bind' with a key which already has been bound.'. + + Also tests for https://github.com/qutebrowser/qutebrowser/issues/1544 + """ + config_stub.val.bindings.default = { + 'normal': {'a': 'nop', '': 'nop'} + } + config_stub.val.bindings.commands = { + 'normal': {'b': 'nop'}, + } + + if force: + commands.bind(key, 'message-info foo', mode='normal', force=True) + assert keyconf.get_command(key, 'normal') == 'message-info foo' + else: + with pytest.raises(cmdexc.CommandError, + match="bind: Duplicate key .* - use --force to " + "override"): + commands.bind(key, 'message-info foo', mode='normal') + assert keyconf.get_command(key, 'normal') == 'nop' + + def test_bind_none(self, commands, config_stub): + config_stub.val.bindings.commands = None + commands.bind(',x', 'nop') + + def test_unbind_none(self, commands, config_stub): + config_stub.val.bindings.commands = None + commands.unbind('H') + + @pytest.mark.parametrize('key, normalized', [ + ('a', 'a'), # default bindings + ('b', 'b'), # custom bindings + ('c', 'c'), # :bind then :unbind + ('', '') # normalized special binding + ]) + def test_unbind(self, commands, keyconf, config_stub, key, normalized): + config_stub.val.bindings.default = { + 'normal': {'a': 'nop', '': 'nop'}, + 'caret': {'a': 'nop', '': 'nop'}, + } + config_stub.val.bindings.commands = { + 'normal': {'b': 'nop'}, + 'caret': {'b': 'nop'}, + } + if key == 'c': + # Test :bind and :unbind + commands.bind(key, 'nop') + + commands.unbind(key) + assert keyconf.get_command(key, 'normal') is None + + yaml_bindings = config_stub._yaml['bindings.commands']['normal'] + if key in 'bc': + # Custom binding + assert normalized not in yaml_bindings + else: + assert yaml_bindings[normalized] is None + + @pytest.mark.parametrize('key, mode, expected', [ + ('foobar', 'normal', + "unbind: Can't find binding 'foobar' in normal mode"), + ('x', 'wrongmode', "unbind: 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 fbf9817dcb29b799120b9721c577e027719d3945 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 2 Oct 2017 07:15:00 +0200 Subject: [PATCH 132/186] Rename test classes --- tests/unit/config/test_configcommands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index a3862e027..ea8c1d153 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -32,7 +32,7 @@ def commands(config_stub, keyconf): return configcommands.ConfigCommands(config_stub, keyconf) -class TestSetConfigCommand: +class TestSet: """Tests for :set.""" @@ -218,7 +218,7 @@ class TestSetConfigCommand: assert config_stub.get(opt) == ['foo'] -class TestBindConfigCommand: +class TestBind: """Tests for :bind and :unbind.""" From 14dacbaa927bd519f1f23b052d164241722f2926 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 2 Oct 2017 07:49:31 +0200 Subject: [PATCH 133/186] Fix typo --- qutebrowser/mainwindow/tabwidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 9d1ebd945..c5566f877 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -435,7 +435,7 @@ class TabBar(QTabBar): A QSize of the smallest tab size we can make. """ text = '\u2026' if ellipsis else self.tabText(index) - # Don't ever shorten if text is shorter than the elipsis + # Don't ever shorten if text is shorter than the ellipsis text_width = min(self.fontMetrics().width(text), self.fontMetrics().width(self.tabText(index))) icon = self.tabIcon(index) From 9c1b604cb15104430c4e9fcc7bfdeab3bfb42990 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 2 Oct 2017 07:51:58 +0200 Subject: [PATCH 134/186] Update changelog --- doc/changelog.asciidoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 0c2751b79..7d217f205 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -67,6 +67,8 @@ Changed - When there are multiple messages shown, the timeout is increased. - `:search` now only clears the search if one was displayed before, so pressing `` doesn't un-focus inputs anymore. +- Pinned tabs now adjust to their text's width, so the `tabs.width.pinned` + setting got removed. Fixes ~~~~~ From 506b1cdbc140637a1654deae025e7c2d24eb4917 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 2 Oct 2017 08:52:12 +0200 Subject: [PATCH 135/186] Improve input.insert_mode.auto_load tests This also adds a test for #2858 (also see #2879) --- tests/end2end/test_insert_mode.py | 37 +++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/tests/end2end/test_insert_mode.py b/tests/end2end/test_insert_mode.py index a0a1ef033..746fde50f 100644 --- a/tests/end2end/test_insert_mode.py +++ b/tests/end2end/test_insert_mode.py @@ -22,23 +22,18 @@ import pytest -@pytest.mark.parametrize(['file_name', 'elem_id', 'source', 'input_text', - 'auto_insert'], [ - ('textarea.html', 'qute-textarea', 'clipboard', 'qutebrowser', 'false'), - ('textarea.html', 'qute-textarea', 'keypress', 'superqutebrowser', - 'false'), - ('input.html', 'qute-input', 'clipboard', 'amazingqutebrowser', 'false'), - ('input.html', 'qute-input', 'keypress', 'awesomequtebrowser', 'false'), - ('autofocus.html', 'qute-input-autofocus', 'keypress', 'cutebrowser', - 'true'), +@pytest.mark.parametrize(['file_name', 'elem_id', 'source', 'input_text'], [ + ('textarea.html', 'qute-textarea', 'clipboard', 'qutebrowser'), + ('textarea.html', 'qute-textarea', 'keypress', 'superqutebrowser'), + ('input.html', 'qute-input', 'clipboard', 'amazingqutebrowser'), + ('input.html', 'qute-input', 'keypress', 'awesomequtebrowser'), + ('autofocus.html', 'qute-input-autofocus', 'keypress', 'cutebrowser'), ]) @pytest.mark.parametrize('zoom', [100, 125, 250]) -def test_insert_mode(file_name, elem_id, source, input_text, auto_insert, zoom, +def test_insert_mode(file_name, elem_id, source, input_text, zoom, quteproc, request): url_path = 'data/insert_mode_settings/html/{}'.format(file_name) quteproc.open_path(url_path) - - quteproc.set_setting('input.insert_mode.auto_load', auto_insert) quteproc.send_cmd(':zoom {}'.format(zoom)) quteproc.send_cmd(':click-element --force-event id {}'.format(elem_id)) @@ -57,6 +52,24 @@ def test_insert_mode(file_name, elem_id, source, input_text, auto_insert, zoom, quteproc.send_cmd(':leave-mode') +@pytest.mark.parametrize('auto_load, background, insert_mode', [ + (False, False, False), # auto_load disabled + (True, False, True), # enabled and foreground tab + (True, True, False), # background tab +]) +def test_auto_load(quteproc, auto_load, background, insert_mode): + quteproc.set_setting('input.insert_mode.auto_load', str(auto_load)) + url_path = 'data/insert_mode_settings/html/autofocus.html' + quteproc.open_path(url_path, new_bg_tab=background) + + log_message = 'Entering mode KeyMode.insert (reason: *)' + if insert_mode: + quteproc.wait_for(message=log_message) + quteproc.send_cmd(':leave-mode') + else: + quteproc.ensure_not_logged(message=log_message) + + def test_auto_leave_insert_mode(quteproc): url_path = 'data/insert_mode_settings/html/autofocus.html' quteproc.open_path(url_path) From eacdbe132ed55f52413a4cb548bab49dc6489c0c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 2 Oct 2017 09:06:11 +0200 Subject: [PATCH 136/186] Update changelog [ci skip] --- doc/changelog.asciidoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 7d217f205..9ec0a403f 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -75,6 +75,8 @@ Fixes - Exiting fullscreen via `:fullscreen` or buttons on a page now restores the correct previous window state (maximized/fullscreen). +- When `input.insert_mode.auto_load` is set, background tabs now don't enter + insert mode anymore. v0.11.1 (unreleased) -------------------- From 5af8a95c829e7a39a87da420e03526fcc15a7cf3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 2 Oct 2017 09:40:35 +0200 Subject: [PATCH 137/186] Refactor SQL error handling This renames SqlException to SqlError (to be more consistent with how Python names exceptions), and adds an utility function which logs a few more useful details about errors. See #3004 --- qutebrowser/app.py | 2 +- qutebrowser/misc/sql.py | 53 ++++++++++++++++++++++++++----------- tests/unit/misc/test_sql.py | 4 +-- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index d2efb21e5..b2654e2ba 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -425,7 +425,7 @@ def _init_modules(args, crash_handler): log.init.debug("Initializing sql...") try: sql.init(os.path.join(standarddir.data(), 'history.sqlite')) - except sql.SqlException as e: + except sql.SqlError as e: error.handle_fatal_exc(e, args, 'Error initializing SQL', pre_text='Error initializing SQL') sys.exit(usertypes.Exit.err_init) diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py index a288df475..375d0464d 100644 --- a/qutebrowser/misc/sql.py +++ b/qutebrowser/misc/sql.py @@ -22,12 +22,12 @@ import collections from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtSql import QSqlDatabase, QSqlQuery +from PyQt5.QtSql import QSqlDatabase, QSqlQuery, QSqlError -from qutebrowser.utils import log +from qutebrowser.utils import log, debug -class SqlException(Exception): +class SqlError(Exception): """Raised on an error interacting with the SQL database.""" @@ -38,12 +38,14 @@ def init(db_path): """Initialize the SQL database connection.""" database = QSqlDatabase.addDatabase('QSQLITE') if not database.isValid(): - raise SqlException('Failed to add database. ' + raise SqlError('Failed to add database. ' 'Are sqlite and Qt sqlite support installed?') database.setDatabaseName(db_path) if not database.open(): - raise SqlException("Failed to open sqlite database at {}: {}" - .format(db_path, database.lastError().text())) + error = database.lastError() + _log_error(error) + raise SqlError("Failed to open sqlite database at {}: {}" + .format(db_path, error.text())) def close(): @@ -60,10 +62,32 @@ def version(): close() return ver return Query("select sqlite_version()").run().value() - except SqlException as e: + except SqlError as e: return 'UNAVAILABLE ({})'.format(e) +def _log_error(error): + """Log informations about a SQL error to the debug log.""" + log.sql.debug("SQL error:") + log.sql.debug("type: {}".format(debug.qenum_key(QSqlError, error.type()))) + log.sql.debug("database text: {}".format(error.databaseText())) + log.sql.debug("driver text: {}".format(error.driverText())) + log.sql.debug("error code: {}".format(error.nativeErrorCode())) + + +def _handle_query_error(what, query, error): + """Handle a sqlite error. + + Arguments: + what: What we were doing when the error happened. + query: The query which was executed. + error: The QSqlError object. + """ + _log_error(error) + msg = 'Failed to {} query "{}": "{}"'.format(what, query, error.text()) + raise SqlError(msg) + + class Query(QSqlQuery): """A prepared SQL Query.""" @@ -79,13 +103,12 @@ class Query(QSqlQuery): super().__init__(QSqlDatabase.database()) log.sql.debug('Preparing SQL query: "{}"'.format(querystr)) if not self.prepare(querystr): - raise SqlException('Failed to prepare query "{}": "{}"'.format( - querystr, self.lastError().text())) + _handle_query_error('prepare', querystr, self.lastError()) self.setForwardOnly(forward_only) def __iter__(self): if not self.isActive(): - raise SqlException("Cannot iterate inactive query") + raise SqlError("Cannot iterate inactive query") rec = self.record() fields = [rec.fieldName(i) for i in range(rec.count())] rowtype = collections.namedtuple('ResultRow', fields) @@ -101,14 +124,13 @@ class Query(QSqlQuery): self.bindValue(':{}'.format(key), val) log.sql.debug('query bindings: {}'.format(self.boundValues())) if not self.exec_(): - raise SqlException('Failed to exec query "{}": "{}"'.format( - self.lastQuery(), self.lastError().text())) + _handle_query_error('exec', self.lastQuery(), self.lastError()) return self def value(self): """Return the result of a single-value query (e.g. an EXISTS).""" if not self.next(): - raise SqlException("No result for single-result query") + raise SqlError("No result for single-result query") return self.record().value(0) @@ -128,7 +150,7 @@ class SqlTable(QObject): def __init__(self, name, fields, constraints=None, parent=None): """Create a new table in the sql database. - Raises SqlException if the table already exists. + Raises SqlError if the table already exists. Args: name: Name of the table. @@ -228,8 +250,7 @@ class SqlTable(QObject): db = QSqlDatabase.database() db.transaction() if not q.execBatch(): - raise SqlException('Failed to exec query "{}": "{}"'.format( - q.lastQuery(), q.lastError().text())) + _handle_query_error('exec', q.lastQuery(), q.lastError()) db.commit() self.changed.emit() diff --git a/tests/unit/misc/test_sql.py b/tests/unit/misc/test_sql.py index 8997afc3b..953c8c498 100644 --- a/tests/unit/misc/test_sql.py +++ b/tests/unit/misc/test_sql.py @@ -49,7 +49,7 @@ def test_insert_replace(qtbot): table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=True) assert list(table) == [('one', 11, True)] - with pytest.raises(sql.SqlException): + with pytest.raises(sql.SqlError): table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=False) @@ -85,7 +85,7 @@ def test_insert_batch_replace(qtbot): ('one', 11, True), ('nine', 19, True)] - with pytest.raises(sql.SqlException): + with pytest.raises(sql.SqlError): table.insert_batch({'name': ['one', 'nine'], 'val': [11, 19], 'lucky': [True, True]}) From 138ce60c1d532eaf406d92ae334066a47641c887 Mon Sep 17 00:00:00 2001 From: Michael Hoang Date: Sat, 23 Sep 2017 18:26:53 +1000 Subject: [PATCH 138/186] Add count support to buffer command --- doc/help/commands.asciidoc | 5 ++++- qutebrowser/browser/commands.py | 39 ++++++++++++++++++++------------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 6e1ffd647..7ca7fe822 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -188,11 +188,14 @@ Syntax: +:buffer 'index'+ Select tab by index or url/title best match. -Focuses window if necessary. +Focuses window if necessary when index is given. If both index and count are given, use count. ==== positional arguments * +'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. + [[close]] === close diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 11efccc11..1c03a39f8 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -1011,29 +1011,38 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('index', completion=miscmodels.buffer) - def buffer(self, index): + @cmdutils.argument('count', count=True) + def buffer(self, index=None, count=None): """Select tab by index or url/title best match. - Focuses window if necessary. + Focuses window if necessary when index is given. If both index and count + are given, use count. 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. """ - index_parts = index.split('/', 1) + if count is not None: + index_parts = [count] + elif index is None: + raise cmdexc.CommandError("buffer: Either a count or the argument " + "index must be specified.") + else: + index_parts = index.split('/', 1) - try: - for part in index_parts: - int(part) - except ValueError: - model = miscmodels.buffer() - model.set_pattern(index) - if model.count() > 0: - index = model.data(model.first_item()) - index_parts = index.split('/', 1) - else: - raise cmdexc.CommandError( - "No matching tab for: {}".format(index)) + try: + for part in index_parts: + int(part) + except ValueError: + model = miscmodels.buffer() + model.set_pattern(index) + if model.count() > 0: + index = model.data(model.first_item()) + index_parts = index.split('/', 1) + else: + raise cmdexc.CommandError( + "No matching tab for: {}".format(index)) if len(index_parts) == 2: win_id = int(index_parts[0]) From 8ae0bd2797e8f40ed34d18846c471c37c4dacddf Mon Sep 17 00:00:00 2001 From: Michael Hoang Date: Mon, 2 Oct 2017 23:43:49 +1100 Subject: [PATCH 139/186] Update :buffer tests for count support --- tests/end2end/features/tabs.feature | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index bcf7f15f0..02954dbcf 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -897,9 +897,9 @@ Feature: Tab management # :buffer - Scenario: :buffer without args + Scenario: :buffer without args or count When I run :buffer - Then the error "buffer: The following arguments are required: index" should be shown + Then the error "buffer: Either a count or the argument index must be specified." should be shown Scenario: :buffer with a matching title When I open data/title.html From 64e0313090a655354dfdebfbaddb19d954d71cb2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 2 Oct 2017 20:08:02 +0200 Subject: [PATCH 140/186] Fix commas in settings docs Otherwise, asciidoc interprets it as a third parameter to the xref... Fixes #3046 --- doc/help/settings.asciidoc | 4 ++-- scripts/dev/src2asciidoc.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 864ffcf2f..642aec436 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -1944,7 +1944,7 @@ Default: +pass:[ask]+ === content.user_stylesheets A list of user stylesheet filenames to use. -Type: <> +Type: <> Default: empty @@ -3065,7 +3065,7 @@ Default: === url.start_pages The page(s) to open at the start. -Type: <> +Type: <> Default: +pass:[https://start.duckduckgo.com]+ diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py index 817b44a00..f12d6601b 100755 --- a/scripts/dev/src2asciidoc.py +++ b/scripts/dev/src2asciidoc.py @@ -416,7 +416,8 @@ def _generate_setting_option(f, opt): f.write("=== {}".format(opt.name) + "\n") f.write(opt.description + "\n") f.write("\n") - f.write('Type: <>\n'.format(typ=opt.typ.get_name())) + typ = opt.typ.get_name().replace(',', ',') + f.write('Type: <>\n'.format(typ=typ)) f.write("\n") valid_values = opt.typ.get_valid_values() From 81993a70a2ad4187b8b6a1feec5cf7449e2b685b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 07:09:32 +0200 Subject: [PATCH 141/186] Split off :config-cycle from :set Before, we allowed :set to take multiple values, which often lead to confusing error messages when a user forgot to quote the value. Now, we instead have a dedicated :config-cycle command for that. See #1840, #2794 --- doc/help/commands.asciidoc | 19 +++++- qutebrowser/config/configcommands.py | 76 ++++++++++++++---------- tests/unit/config/test_configcommands.py | 13 ++-- 3 files changed, 69 insertions(+), 39 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 6e1ffd647..7e340e9ca 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -31,6 +31,7 @@ It is possible to run or bind multiple commands by separating them with `;;`. |<>|Load a bookmark. |<>|Select tab by index or url/title best match. |<>|Close the current window. +|<>|Cycle an option between multiple values. |<>|Download a given URL, or current page if no URL given. |<>|Cancel the last/[count]th download. |<>|Remove all finished downloads from the list. @@ -198,6 +199,20 @@ Focuses window if necessary. === close Close the current window. +[[config-cycle]] +=== config-cycle +Syntax: +:config-cycle [*--temp*] [*--print*] 'option' 'values' ['values' ...]+ + +Cycle an option between multiple values. + +==== positional arguments +* +'option'+: The name of the option. +* +'values'+: The values to cycle through. + +==== optional arguments +* +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed. +* +*-p*+, +*--print*+: Print the value after setting. + [[download]] === download Syntax: +:download [*--mhtml*] [*--dest* 'dest'] ['url'] ['dest-old']+ @@ -773,7 +788,7 @@ Save a session. [[set]] === set -Syntax: +:set [*--temp*] [*--print*] ['option'] ['values' ['values' ...]]+ +Syntax: +:set [*--temp*] [*--print*] ['option'] ['value']+ Set an option. @@ -781,7 +796,7 @@ If the option name ends with '?', the value of the option is shown instead. If t ==== positional arguments * +'option'+: The name of the option. -* +'values'+: The value to set, or the values to cycle through. +* +'value'+: The value to set. ==== optional arguments * +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed. diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 866f29c74..e5cde8900 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -37,11 +37,11 @@ class ConfigCommands: self._config = config self._keyconfig = keyconfig - @cmdutils.register(instance='config-commands', star_args_optional=True) + @cmdutils.register(instance='config-commands') @cmdutils.argument('option', completion=configmodel.option) - @cmdutils.argument('values', completion=configmodel.value) + @cmdutils.argument('value', completion=configmodel.value) @cmdutils.argument('win_id', win_id=True) - def set(self, win_id, option=None, *values, temp=False, print_=False): + def set(self, win_id, option=None, value=None, temp=False, print_=False): """Set an option. If the option name ends with '?', the value of the option is shown @@ -51,7 +51,7 @@ class ConfigCommands: Args: option: The name of the option. - values: The value to set, or the values to cycle through. + value: The value to set. temp: Set value temporarily until qutebrowser is closed. print_: Print the value after setting. """ @@ -66,19 +66,51 @@ class ConfigCommands: return with self._handle_config_error(): - if option.endswith('!') and option != '!' and not values: - # Handle inversion as special cases of the cycle code path + if option.endswith('!') and option != '!' and value is None: option = option[:-1] opt = self._config.get_opt(option) - if isinstance(opt.typ, configtypes.Bool): - values = ['false', 'true'] - else: + if not isinstance(opt.typ, configtypes.Bool): raise cmdexc.CommandError( "set: Can't toggle non-bool setting {}".format(option)) - elif not values: + old_value = self._config.get_obj(option) + self._config.set_obj(option, not old_value, save_yaml=not temp) + elif value is None: raise cmdexc.CommandError("set: The following arguments " "are required: value") - self._set_next(option, values, temp=temp) + else: + self._config.set_str(option, value, save_yaml=not temp) + + if print_: + self._print_value(option) + + @cmdutils.register(instance='config-commands') + @cmdutils.argument('option', completion=configmodel.option) + @cmdutils.argument('values', completion=configmodel.value) + def config_cycle(self, option, *values, temp=False, print_=False): + """Cycle an option between multiple values. + + Args: + option: The name of the option. + values: The values to cycle through. + temp: Set value temporarily until qutebrowser is closed. + print_: Print the value after setting. + """ + if len(values) < 2: + raise configexc.CommandError("Need at least two values") + with self._handle_config_error(): + # Use the next valid value from values, or the first if the current + # value does not appear in the list + old_value = self._config.get_obj(option, mutable=False) + opt = self._config.get_opt(option) + values = [opt.typ.from_str(val) for val in values] + + try: + idx = values.index(old_value) + idx = (idx + 1) % len(values) + value = values[idx] + except ValueError: + value = values[0] + self._config.set_obj(option, value, save_yaml=not temp) if print_: self._print_value(option) @@ -89,28 +121,6 @@ class ConfigCommands: value = self._config.get_str(option) message.info("{} = {}".format(option, value)) - def _set_next(self, option, values, *, temp): - """Set the next value out of a list of values.""" - if len(values) == 1: - # If we have only one value, just set it directly (avoid - # breaking stuff like aliases or other pseudo-settings) - self._config.set_str(option, values[0], save_yaml=not temp) - return - - # Use the next valid value from values, or the first if the current - # value does not appear in the list - old_value = self._config.get_obj(option, mutable=False) - opt = self._config.get_opt(option) - values = [opt.typ.from_str(val) for val in values] - - try: - idx = values.index(old_value) - idx = (idx + 1) % len(values) - value = values[idx] - except ValueError: - value = values[0] - self._config.set_obj(option, value, save_yaml=not temp) - @contextlib.contextmanager def _handle_config_error(self): """Catch errors in set_command and raise CommandError.""" diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index ea8c1d153..3a50100ce 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -189,6 +189,11 @@ class TestSet: with pytest.raises(cmdexc.CommandError, match="set: No option 'foo'"): commands.set(win_id=0, option='foo' + suffix) + +class TestCycle: + + """Test :config-cycle.""" + @pytest.mark.parametrize('initial, expected', [ # Normal cycling ('magenta', 'blue'), @@ -201,20 +206,20 @@ class TestSet: """Run ':set' with multiple values.""" opt = 'colors.statusbar.normal.bg' config_stub.set_obj(opt, initial) - commands.set(0, opt, 'green', 'magenta', 'blue', 'yellow') + commands.config_cycle(opt, 'green', 'magenta', 'blue', 'yellow') assert config_stub.get(opt) == expected assert config_stub._yaml[opt] == expected - def test_cycling_different_representation(self, commands, config_stub): + def test_different_representation(self, commands, config_stub): """When using a different representation, cycling should work. For example, we use [foo] which is represented as ["foo"]. """ opt = 'qt_args' config_stub.set_obj(opt, ['foo']) - commands.set(0, opt, '[foo]', '[bar]') + commands.config_cycle(opt, '[foo]', '[bar]') assert config_stub.get(opt) == ['bar'] - commands.set(0, opt, '[foo]', '[bar]') + commands.config_cycle(opt, '[foo]', '[bar]') assert config_stub.get(opt) == ['foo'] From f533e3b75183c084270df3d6c2b61bc4b46b3764 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 07:29:44 +0200 Subject: [PATCH 142/186] Move config toggling to :config-cycle This removes :set option! and allows :config-cycle option instead. --- doc/help/commands.asciidoc | 2 +- qutebrowser/config/configcommands.py | 43 +++++++------- tests/unit/config/test_configcommands.py | 73 +++++++++++++----------- 3 files changed, 61 insertions(+), 57 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 7e340e9ca..a0edf609d 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -792,7 +792,7 @@ Syntax: +:set [*--temp*] [*--print*] ['option'] ['value']+ Set an option. -If the option name ends with '?', the value of the option is shown instead. If the option name ends with '!' and it is a boolean value, toggle it. +If the option name ends with '?', the value of the option is shown instead. ==== positional arguments * +'option'+: The name of the option. diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index e5cde8900..afbbf48fe 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -47,8 +47,6 @@ class ConfigCommands: If the option name ends with '?', the value of the option is shown instead. - If the option name ends with '!' and it is a boolean value, toggle it. - Args: option: The name of the option. value: The value to set. @@ -66,15 +64,7 @@ class ConfigCommands: return with self._handle_config_error(): - if option.endswith('!') and option != '!' and value is None: - option = option[:-1] - opt = self._config.get_opt(option) - if not isinstance(opt.typ, configtypes.Bool): - raise cmdexc.CommandError( - "set: Can't toggle non-bool setting {}".format(option)) - old_value = self._config.get_obj(option) - self._config.set_obj(option, not old_value, save_yaml=not temp) - elif value is None: + if value is None: raise cmdexc.CommandError("set: The following arguments " "are required: value") else: @@ -95,21 +85,30 @@ class ConfigCommands: temp: Set value temporarily until qutebrowser is closed. print_: Print the value after setting. """ - if len(values) < 2: - raise configexc.CommandError("Need at least two values") with self._handle_config_error(): - # Use the next valid value from values, or the first if the current - # value does not appear in the list - old_value = self._config.get_obj(option, mutable=False) opt = self._config.get_opt(option) + old_value = self._config.get_obj(option, mutable=False) + + if not values and isinstance(opt.typ, configtypes.Bool): + values = ['true', 'false'] + + if len(values) < 2: + raise cmdexc.CommandError("Need at least two values for " + "non-boolean settings.") + + # Use the next valid value from values, or the first if the current + # value does not appear in the list + with self._handle_config_error(): values = [opt.typ.from_str(val) for val in values] - try: - idx = values.index(old_value) - idx = (idx + 1) % len(values) - value = values[idx] - except ValueError: - value = values[0] + try: + idx = values.index(old_value) + idx = (idx + 1) % len(values) + value = values[idx] + except ValueError: + value = values[0] + + with self._handle_config_error(): self._config.set_obj(option, value, save_yaml=not temp) if print_: diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 3a50100ce..1c88d1750 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -113,36 +113,6 @@ class TestSet: msg = message_mock.getmsg(usertypes.MessageLevel.info) assert msg.text == 'url.auto_search = dns' - def test_set_toggle(self, commands, config_stub): - """Run ':set auto_save.session!'. - - Should toggle the value. - """ - assert not config_stub.val.auto_save.session - commands.set(0, 'auto_save.session!') - assert config_stub.val.auto_save.session - assert config_stub._yaml['auto_save.session'] - - def test_set_toggle_nonbool(self, commands, config_stub): - """Run ':set url.auto_search!'. - - Should show an error - """ - assert config_stub.val.url.auto_search == 'naive' - with pytest.raises(cmdexc.CommandError, match="set: Can't toggle " - "non-bool setting url.auto_search"): - commands.set(0, 'url.auto_search!') - assert config_stub.val.url.auto_search == 'naive' - - def test_set_toggle_print(self, commands, config_stub, message_mock): - """Run ':set -p auto_save.session!'. - - Should toggle the value and show the new value. - """ - commands.set(0, 'auto_save.session!', print_=True) - msg = message_mock.getmsg(usertypes.MessageLevel.info) - assert msg.text == 'auto_save.session = true' - def test_set_invalid_option(self, commands): """Run ':set foo bar'. @@ -180,14 +150,13 @@ class TestSet: "value"): commands.set(win_id=0, option=option) - @pytest.mark.parametrize('suffix', '?!') - def test_invalid(self, commands, suffix): - """Run ':set foo?' / ':set foo!'. + def test_invalid(self, commands): + """Run ':set foo?'. Should show an error. """ with pytest.raises(cmdexc.CommandError, match="set: No option 'foo'"): - commands.set(win_id=0, option='foo' + suffix) + commands.set(win_id=0, option='foo?') class TestCycle: @@ -222,6 +191,42 @@ class TestCycle: commands.config_cycle(opt, '[foo]', '[bar]') assert config_stub.get(opt) == ['foo'] + def test_toggle(self, commands, config_stub): + """Run ':config-cycle auto_save.session'. + + Should toggle the value. + """ + 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'] + + @pytest.mark.parametrize('args', [ + ['url.auto_search'], ['url.auto_search', 'foo'] + ]) + def test_toggle_nonbool(self, commands, config_stub, args): + """Run :config-cycle without a bool and 0/1 value. + + :config-cycle url.auto_search + :config-cycle url.auto_search foo + + Should show an error. + """ + assert config_stub.val.url.auto_search == 'naive' + with pytest.raises(cmdexc.CommandError, match="Need at least " + "two values for non-boolean settings."): + commands.config_cycle(*args) + assert config_stub.val.url.auto_search == 'naive' + + def test_set_toggle_print(self, commands, config_stub, message_mock): + """Run ':config-cycle -p auto_save.session'. + + Should toggle the value and show the new value. + """ + commands.config_cycle('auto_save.session', print_=True) + msg = message_mock.getmsg(usertypes.MessageLevel.info) + assert msg.text == 'auto_save.session = true' + class TestBind: From 4ed60efa8090bc3a4e5126cf6d7704299395e2e4 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 07:35:07 +0200 Subject: [PATCH 143/186] Add missing qapp fixture --- tests/unit/config/test_configcommands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 1c88d1750..a839810cc 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -37,7 +37,7 @@ class TestSet: """Tests for :set.""" @pytest.fixture - def tabbed_browser(self, stubs, win_registry): + def tabbed_browser(self, qapp, stubs, win_registry): tb = stubs.TabbedBrowserStub() objreg.register('tabbed-browser', tb, scope='window', window=0) yield tb From 368e9a5cf9795261d2c7bd79f0fbfd82fd794426 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 07:39:24 +0200 Subject: [PATCH 144/186] Update changelog --- doc/changelog.asciidoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 9ec0a403f..e03350dc4 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -40,6 +40,8 @@ Breaking changes - Upgrading qutebrowser with a version older than v0.4.0 still running now won't work properly anymore. - The `--harfbuzz` and `--relaxed-config` commandline arguments got dropped. +- `:set` now doesn't support toggling/cycling values anymore, that functionality + got moved to `:config-cycle`. Major changes ~~~~~~~~~~~~~ @@ -58,6 +60,7 @@ Added Together with the previous setting, this should make wrapper scripts unnecessary. - Proxy authentication is now supported with the QtWebEngine backend. +- New `:config-cycle` command to cycle an option between multiple values. Changed ~~~~~~~ From 58bef6ba970eba7ce32c8089932bb8ec7d6374f2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 10:13:16 +0200 Subject: [PATCH 145/186] Regenerate docs --- doc/help/commands.asciidoc | 4 ++-- qutebrowser/browser/commands.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 7ca7fe822..6e8610b8b 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -184,7 +184,7 @@ Load a bookmark. [[buffer]] === buffer -Syntax: +:buffer 'index'+ +Syntax: +:buffer ['index']+ Select tab by index or url/title best match. @@ -193,10 +193,10 @@ Focuses window if necessary when index is given. If both index and count are giv ==== positional arguments * +'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. - [[close]] === close Close the current window. diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 1c03a39f8..24e7935fb 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -1015,8 +1015,8 @@ class CommandDispatcher: 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 count - are given, use count. + Focuses window if necessary when index is given. If both index and + count are given, use count. Args: index: The [win_id/]index of the tab to focus. Or a substring From b06a38ce7ed85e9673d858fec80af18e68a08df8 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 09:12:50 +0200 Subject: [PATCH 146/186] Reorder methods (cherry picked from commit ba9bd292dbc43bf0ad382a1ef060c87ee651b5d7) --- qutebrowser/config/configcommands.py | 110 +++++++++++++-------------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index afbbf48fe..83e79f57d 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -37,6 +37,20 @@ class ConfigCommands: self._config = config self._keyconfig = keyconfig + @contextlib.contextmanager + def _handle_config_error(self): + """Catch errors in set_command and raise CommandError.""" + try: + yield + except configexc.Error as e: + raise cmdexc.CommandError("set: {}".format(e)) + + def _print_value(self, option): + """Print the value of the given option.""" + with self._handle_config_error(): + value = self._config.get_str(option) + message.info("{} = {}".format(option, value)) + @cmdutils.register(instance='config-commands') @cmdutils.argument('option', completion=configmodel.option) @cmdutils.argument('value', completion=configmodel.value) @@ -73,61 +87,6 @@ class ConfigCommands: if print_: self._print_value(option) - @cmdutils.register(instance='config-commands') - @cmdutils.argument('option', completion=configmodel.option) - @cmdutils.argument('values', completion=configmodel.value) - def config_cycle(self, option, *values, temp=False, print_=False): - """Cycle an option between multiple values. - - Args: - option: The name of the option. - values: The values to cycle through. - temp: Set value temporarily until qutebrowser is closed. - print_: Print the value after setting. - """ - with self._handle_config_error(): - opt = self._config.get_opt(option) - old_value = self._config.get_obj(option, mutable=False) - - if not values and isinstance(opt.typ, configtypes.Bool): - values = ['true', 'false'] - - if len(values) < 2: - raise cmdexc.CommandError("Need at least two values for " - "non-boolean settings.") - - # Use the next valid value from values, or the first if the current - # value does not appear in the list - with self._handle_config_error(): - values = [opt.typ.from_str(val) for val in values] - - try: - idx = values.index(old_value) - idx = (idx + 1) % len(values) - value = values[idx] - except ValueError: - value = values[0] - - with self._handle_config_error(): - self._config.set_obj(option, value, save_yaml=not temp) - - if print_: - self._print_value(option) - - def _print_value(self, option): - """Print the value of the given option.""" - with self._handle_config_error(): - value = self._config.get_str(option) - message.info("{} = {}".format(option, value)) - - @contextlib.contextmanager - def _handle_config_error(self): - """Catch errors in set_command and raise CommandError.""" - try: - yield - except configexc.Error as e: - raise cmdexc.CommandError("set: {}".format(e)) - @cmdutils.register(instance='config-commands', maxsplit=1, no_cmd_split=True, no_replace_variables=True) @cmdutils.argument('command', completion=configmodel.bind) @@ -178,3 +137,44 @@ class ConfigCommands: self._keyconfig.unbind(key, mode=mode, save_yaml=True) except configexc.KeybindingError as e: raise cmdexc.CommandError('unbind: {}'.format(e)) + + @cmdutils.register(instance='config-commands') + @cmdutils.argument('option', completion=configmodel.option) + @cmdutils.argument('values', completion=configmodel.value) + def config_cycle(self, option, *values, temp=False, print_=False): + """Cycle an option between multiple values. + + Args: + option: The name of the option. + values: The values to cycle through. + temp: Set value temporarily until qutebrowser is closed. + print_: Print the value after setting. + """ + with self._handle_config_error(): + opt = self._config.get_opt(option) + old_value = self._config.get_obj(option, mutable=False) + + if not values and isinstance(opt.typ, configtypes.Bool): + values = ['true', 'false'] + + if len(values) < 2: + raise cmdexc.CommandError("Need at least two values for " + "non-boolean settings.") + + # Use the next valid value from values, or the first if the current + # value does not appear in the list + with self._handle_config_error(): + values = [opt.typ.from_str(val) for val in values] + + try: + idx = values.index(old_value) + idx = (idx + 1) % len(values) + value = values[idx] + except ValueError: + value = values[0] + + with self._handle_config_error(): + self._config.set_obj(option, value, save_yaml=not temp) + + if print_: + self._print_value(option) From 1603b15cfd3981f67ec1275ce101a7f32b434ffe Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 07:49:27 +0200 Subject: [PATCH 147/186] Default to NOT NULL for table constraints Ideally, we'd update all existing tables to add the new constraints, but sqlite doesn't offer an easy way to do so: https://www.sqlite.org/lang_altertable.html Since that migration really isn't worth the effort, we only set the constraint for new tables... --- qutebrowser/misc/sql.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py index 375d0464d..b4829bd50 100644 --- a/qutebrowser/misc/sql.py +++ b/qutebrowser/misc/sql.py @@ -161,7 +161,8 @@ class SqlTable(QObject): self._name = name constraints = constraints or {} - column_defs = ['{} {}'.format(field, constraints.get(field, '')) + default = 'NOT NULL' + column_defs = ['{} {}'.format(field, constraints.get(field, default)) for field in fields] q = Query("CREATE TABLE IF NOT EXISTS {name} ({column_defs})" .format(name=name, column_defs=', '.join(column_defs))) From 31f49afdb2ee06b4c3d610c73ed89a3bc8ed4b64 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 07:50:21 +0200 Subject: [PATCH 148/186] Fix incorrect docstring --- qutebrowser/misc/sql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py index b4829bd50..bfb1d1e8b 100644 --- a/qutebrowser/misc/sql.py +++ b/qutebrowser/misc/sql.py @@ -150,7 +150,7 @@ class SqlTable(QObject): def __init__(self, name, fields, constraints=None, parent=None): """Create a new table in the sql database. - Raises SqlError if the table already exists. + Does nothing if the table already exists. Args: name: Name of the table. From 3772084cbf0c6f2319630b80070c728588ef5d96 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 10:26:55 +0200 Subject: [PATCH 149/186] Adjust test_histcategory for NOT NULL constraints --- tests/unit/completion/test_histcategory.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py index 8ae3bb1f4..c093513cb 100644 --- a/tests/unit/completion/test_histcategory.py +++ b/tests/unit/completion/test_histcategory.py @@ -140,20 +140,24 @@ def test_sorting(max_items, before, after, model_validator, hist, config_stub): def test_remove_rows(hist, model_validator): - hist.insert({'url': 'foo', 'title': 'Foo'}) - hist.insert({'url': 'bar', 'title': 'Bar'}) + hist.insert({'url': 'foo', 'title': 'Foo', 'last_atime': 0}) + hist.insert({'url': 'bar', 'title': 'Bar', 'last_atime': 0}) cat = histcategory.HistoryCategory() model_validator.set_model(cat) cat.set_pattern('') hist.delete('url', 'foo') cat.removeRows(0, 1) - model_validator.validate([('bar', 'Bar', '')]) + model_validator.validate([('bar', 'Bar', '1970-01-01')]) def test_remove_rows_fetch(hist): """removeRows should fetch enough data to make the current index valid.""" # we cannot use model_validator as it will fetch everything up front - hist.insert_batch({'url': [str(i) for i in range(300)]}) + hist.insert_batch({ + 'url': [str(i) for i in range(300)], + 'title': [str(i) for i in range(300)], + 'last_atime': [0] * 300, + }) cat = histcategory.HistoryCategory() cat.set_pattern('') From ed8c3f4aa2c4ab067a125bb682f3757c74948177 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 12:44:22 +0200 Subject: [PATCH 150/186] Add :config-clear and :config-unset See #2794 --- doc/changelog.asciidoc | 5 ++- doc/help/commands.asciidoc | 26 ++++++++++++ qutebrowser/config/config.py | 26 ++++++++++++ qutebrowser/config/configcommands.py | 25 ++++++++++++ qutebrowser/config/configfiles.py | 21 +++++++++- tests/helpers/stubs.py | 6 +++ tests/unit/config/test_config.py | 50 ++++++++++++++++++++++++ tests/unit/config/test_configcommands.py | 35 +++++++++++++++++ tests/unit/config/test_configfiles.py | 25 ++++++++++++ 9 files changed, 216 insertions(+), 3 deletions(-) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index e03350dc4..beae58f87 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -60,7 +60,10 @@ Added Together with the previous setting, this should make wrapper scripts unnecessary. - Proxy authentication is now supported with the QtWebEngine backend. -- New `:config-cycle` command to cycle an option between multiple values. +- New config commands: + - `:config-cycle` to cycle an option between multiple values. + - `:config-unset` to remove a configured option + - `:config-clear` to remove all configured options Changed ~~~~~~~ diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 13ff65695..d7a6cc503 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -31,7 +31,9 @@ It is possible to run or bind multiple commands by separating them with `;;`. |<>|Load a bookmark. |<>|Select tab by index or url/title best match. |<>|Close the current window. +|<>|Set all settings back to their default. |<>|Cycle an option between multiple values. +|<>|Unset an option. |<>|Download a given URL, or current page if no URL given. |<>|Cancel the last/[count]th download. |<>|Remove all finished downloads from the list. @@ -202,6 +204,16 @@ The tab index to focus, starting with 1. === close Close the current window. +[[config-clear]] +=== config-clear +Syntax: +:config-clear [*--save*]+ + +Set all settings back to their default. + +==== optional arguments +* +*-s*+, +*--save*+: If given, all configuration in autoconfig.yml is also removed. + + [[config-cycle]] === config-cycle Syntax: +:config-cycle [*--temp*] [*--print*] 'option' 'values' ['values' ...]+ @@ -216,6 +228,20 @@ Cycle an option between multiple values. * +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed. * +*-p*+, +*--print*+: Print the value after setting. +[[config-unset]] +=== config-unset +Syntax: +:config-unset [*--temp*] 'option'+ + +Unset an option. + +This sets an option back to its default and removes it from autoconfig.yml. + +==== positional arguments +* +'option'+: The name of the option. + +==== optional arguments +* +*-t*+, +*--temp*+: Don't touch autoconfig.yml. + [[download]] === download Syntax: +:download [*--mhtml*] [*--dest* 'dest'] ['url'] ['dest-old']+ diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 7107564af..7f2d1ab41 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -319,6 +319,32 @@ class Config(QObject): if save_yaml: self._yaml[name] = converted + def unset(self, name, *, save_yaml=False): + """Set the given setting back to its default.""" + self.get_opt(name) + try: + del self._values[name] + except KeyError: + return + self.changed.emit(name) + + if save_yaml: + self._yaml.unset(name) + + def clear(self, *, save_yaml=False): + """Clear all settings in the config. + + If save_yaml=True is given, also remove all customization from the YAML + file. + """ + old_values = self._values + self._values = {} + for name in old_values: + self.changed.emit(name) + + if save_yaml: + self._yaml.clear() + def update_mutables(self, *, save_yaml=False): """Update mutable settings if they changed. diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 83e79f57d..1e9fe31cd 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -178,3 +178,28 @@ class ConfigCommands: if print_: self._print_value(option) + + @cmdutils.register(instance='config-commands') + @cmdutils.argument('option', completion=configmodel.option) + def config_unset(self, option, temp=False): + """Unset an option. + + This sets an option back to its default and removes it from + autoconfig.yml. + + Args: + option: The name of the option. + temp: Don't touch autoconfig.yml. + """ + with self._handle_config_error(): + self._config.unset(option, save_yaml=not temp) + + @cmdutils.register(instance='config-commands') + def config_clear(self, save=False): + """Set all settings back to their default. + + Args: + save: If given, all configuration in autoconfig.yml is also + removed. + """ + self._config.clear(save_yaml=save) diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 7eba211f6..840983480 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -102,9 +102,8 @@ class YamlConfig(QObject): return self._values[name] def __setitem__(self, name, value): - self.changed.emit() - self._dirty = True self._values[name] = value + self._mark_changed() def __contains__(self, name): return name in self._values @@ -112,6 +111,11 @@ class YamlConfig(QObject): def __iter__(self): return iter(self._values.items()) + def _mark_changed(self): + """Mark the YAML config as changed.""" + self._dirty = True + self.changed.emit() + def _save(self): """Save the settings to the YAML file if they've changed.""" if not self._dirty: @@ -167,6 +171,19 @@ class YamlConfig(QObject): self._values = global_obj self._dirty = False + def unset(self, name): + """Remove the given option name if it's configured.""" + try: + del self._values[name] + except KeyError: + return + self._mark_changed() + + def clear(self): + """Clear all values from the YAML file.""" + self._values = [] + self._mark_changed() + class ConfigAPI: diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 0bab0ce96..74407037a 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -432,6 +432,12 @@ class FakeYamlConfig: 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): diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 24a0965d0..539b59dc2 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -279,6 +279,56 @@ class TestConfig: conf._set_value(opt, 'never') assert conf._values['tabs.show'] == 'never' + @pytest.mark.parametrize('save_yaml', [True, False]) + def test_unset(self, conf, qtbot, save_yaml): + name = 'tabs.show' + conf.set_obj(name, 'never', save_yaml=True) + assert conf.get(name) == 'never' + + with qtbot.wait_signal(conf.changed): + conf.unset(name, save_yaml=save_yaml) + + assert conf.get(name) == 'always' + if save_yaml: + assert name not in conf._yaml + else: + assert conf._yaml[name] == 'never' + + def test_unset_never_set(self, conf, qtbot): + name = 'tabs.show' + assert conf.get(name) == 'always' + + with qtbot.assert_not_emitted(conf.changed): + conf.unset(name) + + 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, 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 + + with qtbot.waitSignals([conf.changed, conf.changed]) as blocker: + conf.clear(save_yaml=save_yaml) + + options = [e.args[0] for e in blocker.all_signals_and_args] + assert options == [name1, name2] + + if save_yaml: + assert name1 not in conf._yaml + assert name2 not in conf._yaml + else: + assert conf._yaml[name1] == 'never' + assert conf._yaml[name2] is True + def test_read_yaml(self, conf): assert not conf._yaml.loaded conf._yaml['content.plugins'] = True diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index a839810cc..85e4d0543 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -228,6 +228,41 @@ class TestCycle: assert msg.text == 'auto_save.session = true' +class TestUnsetAndClear: + + """Test :config-unset and :config-clear.""" + + @pytest.mark.parametrize('temp', [True, False]) + def test_unset(self, commands, config_stub, 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 + + 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): + 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' + + class TestBind: """Tests for :bind and :unbind.""" diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 6274b7d50..ecb1e763f 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -209,6 +209,31 @@ class TestYaml: assert isinstance(error.exception, OSError) assert error.traceback is None + def test_unset(self, qtbot, config_tmpdir): + name = 'tabs.show' + yaml = configfiles.YamlConfig() + yaml[name] = 'never' + + with qtbot.wait_signal(yaml.changed): + yaml.unset(name) + + assert name not in yaml + + def test_unset_never_set(self, qtbot, config_tmpdir): + yaml = configfiles.YamlConfig() + with qtbot.assert_not_emitted(yaml.changed): + yaml.unset('tabs.show') + + def test_clear(self, qtbot, config_tmpdir): + name = 'tabs.show' + yaml = configfiles.YamlConfig() + yaml[name] = 'never' + + with qtbot.wait_signal(yaml.changed): + yaml.clear() + + assert name not in yaml + class ConfPy: From 9383452ab91626f8f8bdfaf4d97f4a270875ae65 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 13:05:01 +0200 Subject: [PATCH 151/186] Use a real YAML config for config tests --- tests/helpers/stubs.py | 3 --- tests/unit/config/test_config.py | 10 +++------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 74407037a..e68b7cc15 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -417,9 +417,6 @@ class FakeYamlConfig: self.loaded = False self._values = {} - def load(self): - self.loaded = True - def __contains__(self, item): return item in self._values diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 539b59dc2..f1bed1060 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -25,7 +25,7 @@ import pytest from PyQt5.QtCore import QObject from PyQt5.QtGui import QColor -from qutebrowser.config import config, configdata, configexc +from qutebrowser.config import config, configdata, configexc, configfiles from qutebrowser.utils import usertypes from qutebrowser.misc import objects @@ -258,8 +258,8 @@ class TestKeyConfig: class TestConfig: @pytest.fixture - def conf(self, stubs): - yaml_config = stubs.FakeYamlConfig() + def conf(self, config_tmpdir): + yaml_config = configfiles.YamlConfig() return config.Config(yaml_config) def test_set_value(self, qtbot, conf, caplog): @@ -330,12 +330,8 @@ class TestConfig: assert conf._yaml[name2] is True def test_read_yaml(self, conf): - assert not conf._yaml.loaded conf._yaml['content.plugins'] = True - conf.read_yaml() - - assert conf._yaml.loaded assert conf._values['content.plugins'] is True def test_get_opt_valid(self, conf): From 2f9d1875cdb5c255ef91a599d3138be7c503eb22 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 13:38:16 +0200 Subject: [PATCH 152/186] Clear BDD process settings between each test Fixes #1310 --- tests/end2end/fixtures/quteprocess.py | 8 +++++++- tests/end2end/fixtures/test_quteprocess.py | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index 73f885cfe..bc6208e36 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -494,7 +494,13 @@ class QuteProc(testprocess.Process): if skip_texts: pytest.skip(', '.join(skip_texts)) - def _after_start(self): + def before_test(self): + """Clear settings before every test.""" + super().before_test() + self.send_cmd(':config-clear') + self._init_settings() + + def _init_settings(self): """Adjust some qutebrowser settings after starting.""" settings = [ ('messages.timeout', '0'), diff --git a/tests/end2end/fixtures/test_quteprocess.py b/tests/end2end/fixtures/test_quteprocess.py index 39cbe598c..b537960f4 100644 --- a/tests/end2end/fixtures/test_quteprocess.py +++ b/tests/end2end/fixtures/test_quteprocess.py @@ -47,6 +47,9 @@ class FakeConfig: '--verbose': False, } + def __init__(self): + self.webengine = False + def getoption(self, name): return self.ARGS[name] From 9f10fa105c05ff6d15babe4abbdbad2282229f50 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 13:42:04 +0200 Subject: [PATCH 153/186] Merge adblock.feature into misc.feature --- tests/end2end/features/adblock.feature | 8 ------ tests/end2end/features/misc.feature | 5 ++++ tests/end2end/features/test_adblock_bdd.py | 31 ---------------------- tests/end2end/features/test_misc_bdd.py | 9 +++++++ 4 files changed, 14 insertions(+), 39 deletions(-) delete mode 100644 tests/end2end/features/adblock.feature delete mode 100644 tests/end2end/features/test_adblock_bdd.py diff --git a/tests/end2end/features/adblock.feature b/tests/end2end/features/adblock.feature deleted file mode 100644 index c400df25f..000000000 --- a/tests/end2end/features/adblock.feature +++ /dev/null @@ -1,8 +0,0 @@ -# vim: ft=cucumber fileencoding=utf-8 sts=4 sw=4 et: - -Feature: Ad blocking - - 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 diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 97f15bf90..9d8204b63 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -527,3 +527,8 @@ Feature: Various utility commands. And I wait for "Renderer process was killed" in the log And I open data/numbers/3.txt Then no crash should happen + + 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 diff --git a/tests/end2end/features/test_adblock_bdd.py b/tests/end2end/features/test_adblock_bdd.py deleted file mode 100644 index 780e55a59..000000000 --- a/tests/end2end/features/test_adblock_bdd.py +++ /dev/null @@ -1,31 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2016-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 json - -import pytest_bdd as bdd - -bdd.scenarios('adblock.feature') - - -@bdd.when(bdd.parsers.parse('I set up "{lists}" as block lists')) -def set_up_blocking(quteproc, lists, server): - url = 'http://localhost:{}/data/adblock/'.format(server.port) - urls = [url + item.strip() for item in lists.split(',')] - quteproc.set_setting('content.host_blocking.lists', json.dumps(urls)) diff --git a/tests/end2end/features/test_misc_bdd.py b/tests/end2end/features/test_misc_bdd.py index d8e2fd07e..19d94ce6a 100644 --- a/tests/end2end/features/test_misc_bdd.py +++ b/tests/end2end/features/test_misc_bdd.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import json + import pytest_bdd as bdd bdd.scenarios('misc.feature') @@ -26,3 +28,10 @@ def pdf_exists(quteproc, tmpdir, filename): path = tmpdir / filename data = path.read_binary() assert data.startswith(b'%PDF') + + +@bdd.when(bdd.parsers.parse('I set up "{lists}" as block lists')) +def set_up_blocking(quteproc, lists, server): + url = 'http://localhost:{}/data/adblock/'.format(server.port) + urls = [url + item.strip() for item in lists.split(',')] + quteproc.set_setting('content.host_blocking.lists', json.dumps(urls)) From 586c6e810f2e7c0c922d052f0e6bbbba22cf5607 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 13:58:53 +0200 Subject: [PATCH 154/186] Fix xfail check --- tests/end2end/fixtures/quteprocess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index bc6208e36..b8105841b 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -458,7 +458,7 @@ class QuteProc(testprocess.Process): __tracebackhide__ = (lambda e: e.errisinstance(testprocess.WaitForTimeout)) xfail = self.request.node.get_marker('xfail') - if xfail and xfail.args[0]: + if xfail and (not xfail.args or xfail.args[0]): kwargs['divisor'] = 10 else: kwargs['divisor'] = 1 From 999d70ae40cf52ee29b4fcb6ca15fc5df8d06187 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 14:12:29 +0200 Subject: [PATCH 155/186] Add missing config.py tests --- tests/unit/config/test_config.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index f1bed1060..03fa1dbae 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -20,6 +20,7 @@ import copy import types +import unittest.mock import pytest from PyQt5.QtCore import QObject @@ -262,6 +263,11 @@ class TestConfig: yaml_config = configfiles.YamlConfig() return config.Config(yaml_config) + def test_init_save_manager(self, conf, fake_save_manager): + conf.init_save_manager(fake_save_manager) + fake_save_manager.add_saveable.assert_called_once_with( + 'yaml-config', unittest.mock.ANY, unittest.mock.ANY) + def test_set_value(self, qtbot, conf, caplog): opt = conf.get_opt('tabs.show') with qtbot.wait_signal(conf.changed) as blocker: @@ -432,6 +438,19 @@ class TestConfig: assert not conf._mutables assert conf.get_obj(option) == new + def test_get_mutable_twice(self, conf): + """Get a mutable value twice.""" + option = 'content.headers.custom' + obj = conf.get_obj(option, mutable=True) + obj['X-Foo'] = 'fooval' + obj2 = conf.get_obj(option, mutable=True) + obj2['X-Bar'] = 'barval' + + conf.update_mutables() + + expected = {'X-Foo': 'fooval', 'X-Bar': 'barval'} + assert conf.get_obj(option) == expected + 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 From 0695cfccfcc597557421a9ec449ec2cc463436f0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 14:33:33 +0200 Subject: [PATCH 156/186] Improve some configfile tests --- tests/unit/config/test_configfiles.py | 47 +++++++++++++++------------ 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index ecb1e763f..fad982dd6 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -20,6 +20,7 @@ import os import sys +import unittest.mock import pytest @@ -50,6 +51,7 @@ def test_state_config(fake_save_manager, data_tmpdir, statefile.write_text(old_data, 'utf-8') state = configfiles.StateConfig() + state.init_save_manager(fake_save_manager) if insert: state['general']['newval'] = '23' @@ -60,23 +62,27 @@ def test_state_config(fake_save_manager, data_tmpdir, state._save() assert statefile.read_text('utf-8') == new_data + fake_save_manager.add_saveable('state-config', unittest.mock.ANY) class TestYaml: - pytestmark = pytest.mark.usefixtures('fake_save_manager') + pytestmark = pytest.mark.usefixtures('config_tmpdir') + + @pytest.fixture + def yaml(self): + return configfiles.YamlConfig() @pytest.mark.parametrize('old_config', [ None, 'global:\n colors.hints.fg: magenta', ]) @pytest.mark.parametrize('insert', [True, False]) - def test_yaml_config(self, config_tmpdir, old_config, insert): + def test_yaml_config(self, yaml, config_tmpdir, old_config, insert): autoconfig = config_tmpdir / 'autoconfig.yml' if old_config is not None: autoconfig.write_text(old_config, 'utf-8') - yaml = configfiles.YamlConfig() yaml.load() if insert: @@ -105,12 +111,16 @@ class TestYaml: if insert: assert ' tabs.show: never' in lines - def test_unknown_key(self, config_tmpdir): + 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): """An unknown setting should be deleted.""" autoconfig = config_tmpdir / 'autoconfig.yml' autoconfig.write_text('global:\n hello: world', encoding='utf-8') - yaml = configfiles.YamlConfig() yaml.load() yaml._save() @@ -127,12 +137,11 @@ class TestYaml: ('confirm_quit', True), ('confirm_quit', False), ]) - def test_changed(self, qtbot, config_tmpdir, old_config, key, value): + def test_changed(self, yaml, qtbot, config_tmpdir, old_config, key, value): autoconfig = config_tmpdir / 'autoconfig.yml' if old_config is not None: autoconfig.write_text(old_config, 'utf-8') - yaml = configfiles.YamlConfig() yaml.load() with qtbot.wait_signal(yaml.changed): @@ -149,18 +158,22 @@ class TestYaml: assert key in yaml assert yaml[key] == value + def test_iter(self, yaml): + yaml['foo'] = 23 + yaml['bar'] = 42 + assert list(iter(yaml)) == [('foo', 23), ('bar', 42)] + @pytest.mark.parametrize('old_config', [ None, 'global:\n colors.hints.fg: magenta', ]) - def test_unchanged(self, config_tmpdir, old_config): + def test_unchanged(self, yaml, config_tmpdir, old_config): autoconfig = config_tmpdir / 'autoconfig.yml' mtime = None if old_config is not None: autoconfig.write_text(old_config, 'utf-8') mtime = autoconfig.stat().mtime - yaml = configfiles.YamlConfig() yaml.load() yaml._save() @@ -176,12 +189,10 @@ class TestYaml: "Toplevel object does not contain 'global' key"), ('42', 'While loading data', "Toplevel object is not a dict"), ]) - def test_invalid(self, config_tmpdir, line, text, exception): + def test_invalid(self, yaml, config_tmpdir, line, text, exception): autoconfig = config_tmpdir / 'autoconfig.yml' autoconfig.write_text(line, 'utf-8', ensure=True) - yaml = configfiles.YamlConfig() - with pytest.raises(configexc.ConfigFileErrors) as excinfo: yaml.load() @@ -191,7 +202,7 @@ class TestYaml: assert str(error.exception).splitlines()[0] == exception assert error.traceback is None - def test_oserror(self, config_tmpdir): + def test_oserror(self, yaml, config_tmpdir): autoconfig = config_tmpdir / 'autoconfig.yml' autoconfig.ensure() autoconfig.chmod(0) @@ -199,7 +210,6 @@ class TestYaml: # Docker container or similar pytest.skip("File was still readable") - yaml = configfiles.YamlConfig() with pytest.raises(configexc.ConfigFileErrors) as excinfo: yaml.load() @@ -209,9 +219,8 @@ class TestYaml: assert isinstance(error.exception, OSError) assert error.traceback is None - def test_unset(self, qtbot, config_tmpdir): + def test_unset(self, yaml, qtbot, config_tmpdir): name = 'tabs.show' - yaml = configfiles.YamlConfig() yaml[name] = 'never' with qtbot.wait_signal(yaml.changed): @@ -219,14 +228,12 @@ class TestYaml: assert name not in yaml - def test_unset_never_set(self, qtbot, config_tmpdir): - yaml = configfiles.YamlConfig() + def test_unset_never_set(self, yaml, qtbot, config_tmpdir): with qtbot.assert_not_emitted(yaml.changed): yaml.unset('tabs.show') - def test_clear(self, qtbot, config_tmpdir): + def test_clear(self, yaml, qtbot, config_tmpdir): name = 'tabs.show' - yaml = configfiles.YamlConfig() yaml[name] = 'never' with qtbot.wait_signal(yaml.changed): From 8edaad51c3a09ecc19fb5c4e7c71806f3447829b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 16:28:11 +0200 Subject: [PATCH 157/186] Add a :config-source command See #2794 --- doc/help/commands.asciidoc | 14 +++++++++ qutebrowser/config/configcommands.py | 27 ++++++++++++++-- tests/unit/config/test_configcommands.py | 39 ++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index d7a6cc503..1d25727aa 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -33,6 +33,7 @@ It is possible to run or bind multiple commands by separating them with `;;`. |<>|Close the current window. |<>|Set all settings back to their default. |<>|Cycle an option between multiple values. +|<>|Read a config.py file. |<>|Unset an option. |<>|Download a given URL, or current page if no URL given. |<>|Cancel the last/[count]th download. @@ -228,6 +229,19 @@ Cycle an option between multiple values. * +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed. * +*-p*+, +*--print*+: Print the value after setting. +[[config-source]] +=== config-source +Syntax: +:config-source [*--clear*] ['filename']+ + +Read a config.py file. + +==== positional arguments +* +'filename'+: The file to load. If not given, loads the default config.py. + + +==== optional arguments +* +*-c*+, +*--clear*+: Clear current settings first. + [[config-unset]] === config-unset Syntax: +:config-unset [*--temp*] 'option'+ diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 1e9fe31cd..b63a959b7 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -19,14 +19,15 @@ """Commands related to the configuration.""" +import os.path import contextlib from PyQt5.QtCore import QUrl from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.completion.models import configmodel -from qutebrowser.utils import objreg, utils, message -from qutebrowser.config import configtypes, configexc +from qutebrowser.utils import objreg, utils, message, standarddir +from qutebrowser.config import configtypes, configexc, configfiles class ConfigCommands: @@ -203,3 +204,25 @@ class ConfigCommands: removed. """ self._config.clear(save_yaml=save) + + @cmdutils.register(instance='config-commands') + def config_source(self, filename=None, clear=False): + """Read a config.py file. + + Args: + filename: The file to load. If not given, loads the default + config.py. + clear: Clear current settings first. + """ + if filename is None: + filename = os.path.join(standarddir.config(), 'config.py') + else: + filename = os.path.expanduser(filename) + + if clear: + self.config_clear() + + try: + configfiles.read_config_py(filename) + except configexc.ConfigFileErrors as e: + raise cmdexc.CommandError(e) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 85e4d0543..33e68cf7e 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -263,6 +263,45 @@ class TestUnsetAndClear: assert config_stub._yaml[name] == 'never' +class TestSource: + + """Test :config-source.""" + + pytestmark = pytest.mark.usefixtures('config_tmpdir', 'data_tmpdir') + + @pytest.mark.parametrize('use_default_dir', [True, False]) + @pytest.mark.parametrize('clear', [True, False]) + def test_config_source(self, tmpdir, commands, config_stub, config_tmpdir, + use_default_dir, clear): + assert config_stub.val.content.javascript.enabled + config_stub.val.ignore_case = 'always' + + if use_default_dir: + pyfile = config_tmpdir / 'config.py' + arg = None + else: + pyfile = tmpdir / 'sourced.py' + arg = str(pyfile) + pyfile.write_text('c.content.javascript.enabled = False\n', + encoding='utf-8') + + commands.config_source(arg, clear=clear) + + assert not config_stub.val.content.javascript.enabled + assert config_stub.val.ignore_case == ('smart' if clear else 'always') + + def test_errors(self, commands, config_tmpdir): + pyfile = config_tmpdir / 'config.py' + pyfile.write_text('c.foo = 42', encoding='utf-8') + + with pytest.raises(cmdexc.CommandError) as excinfo: + commands.config_source() + + expected = ("Errors occurred while reading config.py:\n" + " While setting 'foo': No option 'foo'") + assert str(excinfo.value) == expected + + class TestBind: """Tests for :bind and :unbind.""" From 8506e1f4f216e992f96440cea933a05db6caf412 Mon Sep 17 00:00:00 2001 From: Michael Hoang Date: Sat, 23 Sep 2017 18:40:42 +1000 Subject: [PATCH 158/186] Add arg to run when count given for :set-cmd-text --- doc/help/commands.asciidoc | 7 ++++++- qutebrowser/mainwindow/mainwindow.py | 3 ++- qutebrowser/mainwindow/statusbar/command.py | 18 +++++++++++++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 1d25727aa..d98d61a4a 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -847,7 +847,7 @@ If the option name ends with '?', the value of the option is shown instead. [[set-cmd-text]] === set-cmd-text -Syntax: +:set-cmd-text [*--space*] [*--append*] 'text'+ +Syntax: +:set-cmd-text [*--space*] [*--append*] [*--run-on-count*] 'text'+ Preset the statusbar to some text. @@ -857,6 +857,11 @@ Preset the statusbar to some text. ==== optional arguments * +*-s*+, +*--space*+: If given, a space is added to the end. * +*-a*+, +*--append*+: If given, the text is appended to the current text. +* +*-r*+, +*--run-on-count*+: If given with a count, the command is run with the given count rather than setting the command text. + + +==== count +The count if given. ==== note * This command does not split arguments after the last argument and handles quotes literally. diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 373a9030a..7e24c0f8d 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -437,7 +437,8 @@ class MainWindow(QWidget): # commands keyparsers[usertypes.KeyMode.normal].keystring_updated.connect( status.keystring.setText) - cmd.got_cmd.connect(self._commandrunner.run_safely) + cmd.got_cmd[str].connect(self._commandrunner.run_safely) + cmd.got_cmd[str, int].connect(self._commandrunner.run_safely) cmd.returnPressed.connect(tabs.on_cmd_return_pressed) # key hint popup diff --git a/qutebrowser/mainwindow/statusbar/command.py b/qutebrowser/mainwindow/statusbar/command.py index 3647d9859..b3d7a50e6 100644 --- a/qutebrowser/mainwindow/statusbar/command.py +++ b/qutebrowser/mainwindow/statusbar/command.py @@ -38,7 +38,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): Signals: got_cmd: Emitted when a command is triggered by the user. - arg: The command string. + arg: The command string and also potentially the count. clear_completion_selection: Emitted before the completion widget is hidden. hide_completion: Emitted when the completion widget should be hidden. @@ -47,7 +47,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): hide_cmd: Emitted when command input can be hidden. """ - got_cmd = pyqtSignal(str) + got_cmd = pyqtSignal([str], [str, int]) clear_completion_selection = pyqtSignal() hide_completion = pyqtSignal() update_completion = pyqtSignal() @@ -91,7 +91,9 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): @cmdutils.register(instance='status-command', name='set-cmd-text', scope='window', maxsplit=0) - def set_cmd_text_command(self, text, space=False, append=False): + @cmdutils.argument('count', count=True) + def set_cmd_text_command(self, text, count=None, space=False, append=False, + run_on_count=False): """Preset the statusbar to some text. // @@ -101,8 +103,11 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): Args: text: The commandline to set. + count: The count if given. space: If given, a space is added to the end. append: If given, the text is appended to the current text. + run_on_count: If given with a count, the command is run with the + given count rather than setting the command text. """ if space: text += ' ' @@ -114,7 +119,10 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): if not text or text[0] not in modeparsers.STARTCHARS: raise cmdexc.CommandError( "Invalid command text '{}'.".format(text)) - self.set_cmd_text(text) + if run_on_count and count is not None: + self.got_cmd[str, int].emit(text, count) + else: + self.set_cmd_text(text) @cmdutils.register(instance='status-command', hide=True, modes=[usertypes.KeyMode.command], scope='window') @@ -156,7 +164,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): text = self.text() self.history.append(text) modeman.leave(self._win_id, usertypes.KeyMode.command, 'cmd accept') - self.got_cmd.emit(prefixes[text[0]] + text[1:]) + self.got_cmd[str].emit(prefixes[text[0]] + text[1:]) @pyqtSlot(usertypes.KeyMode) def on_mode_left(self, mode): From c8c61993697ee34ae3831ff48422bca16036e228 Mon Sep 17 00:00:00 2001 From: Michael Hoang Date: Wed, 4 Oct 2017 02:30:23 +1100 Subject: [PATCH 159/186] Add tests for :set-cmd-text --run-on-count --- tests/end2end/features/misc.feature | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 9d8204b63..8af20806e 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -47,6 +47,14 @@ Feature: Various utility commands. When I run :set-cmd-text foo Then the error "Invalid command text 'foo'." should be shown + Scenario: :set-cmd-text with run on count flag and no count + When I run :set-cmd-text --run-on-count :message-info "Hello World" + Then "message:info:86 Hello World" should not be logged + + Scenario: :set-cmd-text with run on count flag and a count + When I run :set-cmd-text --run-on-count :message-info "Hello World" with count 1 + Then the message "Hello World" should be shown + ## :jseval Scenario: :jseval From 555930791f6570cc6e14c47411a3e6685ade398c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 18:19:09 +0200 Subject: [PATCH 160/186] Refactor ExternalEditor to be able to edit an existing file --- qutebrowser/misc/editor.py | 36 +++++++++++++++++++++++----------- tests/unit/misc/test_editor.py | 26 ++++++++++++++++-------- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py index 524588b54..3686e028f 100644 --- a/qutebrowser/misc/editor.py +++ b/qutebrowser/misc/editor.py @@ -35,8 +35,9 @@ class ExternalEditor(QObject): Attributes: _text: The current text before the editor is opened. - _file: The file handle as tempfile.NamedTemporaryFile. Note that this - handle will be closed after the initial file has been created. + _filename: The name of the file to be edited. + _remove_file: Whether the file should be removed when the editor is + closed. _proc: The GUIProcess of the editor. """ @@ -44,18 +45,20 @@ class ExternalEditor(QObject): def __init__(self, parent=None): super().__init__(parent) - self._text = None - self._file = None + self._filename = None self._proc = None + self._remove_file = None def _cleanup(self): """Clean up temporary files after the editor closed.""" - if self._file is None: + assert self._remove_file is not None + if self._filename is None or not self._remove_file: # Could not create initial file. return + try: if self._proc.exit_status() != QProcess.CrashExit: - os.remove(self._file.name) + os.remove(self._filename) except OSError as e: # NOTE: Do not replace this with "raise CommandError" as it's # executed async. @@ -77,7 +80,7 @@ class ExternalEditor(QObject): return encoding = config.val.editor.encoding try: - with open(self._file.name, 'r', encoding=encoding) as f: + with open(self._filename, 'r', encoding=encoding) as f: text = f.read() except OSError as e: # NOTE: Do not replace this with "raise CommandError" as it's @@ -99,9 +102,8 @@ class ExternalEditor(QObject): Args: text: The initial text to edit. """ - if self._text is not None: + if self._filename is not None: raise ValueError("Already editing a file!") - self._text = text try: # Close while the external process is running, as otherwise systems # with exclusive write access (e.g. Windows) may fail to update @@ -113,15 +115,27 @@ class ExternalEditor(QObject): delete=False) as fobj: if text: fobj.write(text) - self._file = fobj + self._filename = fobj.name except OSError as e: message.error("Failed to create initial file: {}".format(e)) return + + self._remove_file = True + self._start_editor() + + def edit_file(self, filename): + """Edit the file with the given filename.""" + self._filename = filename + self._remove_file = False + self._start_editor() + + def _start_editor(self): + """Start the editor with the file opened as self._filename.""" self._proc = guiprocess.GUIProcess(what='editor', parent=self) self._proc.finished.connect(self.on_proc_closed) self._proc.error.connect(self.on_proc_error) editor = config.val.editor.command executable = editor[0] - args = [arg.replace('{}', self._file.name) for arg in editor[1:]] + args = [arg.replace('{}', self._filename) for arg in editor[1:]] log.procs.debug("Calling \"{}\" with args {}".format(executable, args)) self._proc.start(executable, args) diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py index 9ca4fdcbb..e6902852e 100644 --- a/tests/unit/misc/test_editor.py +++ b/tests/unit/misc/test_editor.py @@ -59,14 +59,14 @@ class TestArg: config_stub.val.editor.command = ['bin', 'foo', '{}', 'bar'] editor.edit("") editor._proc._proc.start.assert_called_with( - "bin", ["foo", editor._file.name, "bar"]) + "bin", ["foo", editor._filename, "bar"]) def test_placeholder_inline(self, config_stub, editor): """Test starting editor with placeholder arg inside of another arg.""" config_stub.val.editor.command = ['bin', 'foo{}', 'bar'] editor.edit("") editor._proc._proc.start.assert_called_with( - "bin", ["foo" + editor._file.name, "bar"]) + "bin", ["foo" + editor._filename, "bar"]) class TestFileHandling: @@ -76,16 +76,26 @@ class TestFileHandling: def test_ok(self, editor): """Test file handling when closing with an exit status == 0.""" editor.edit("") - filename = editor._file.name + filename = editor._filename assert os.path.exists(filename) assert os.path.basename(filename).startswith('qutebrowser-editor-') editor._proc.finished.emit(0, QProcess.NormalExit) assert not os.path.exists(filename) + def test_existing_file(self, editor, tmpdir): + """Test editing an existing file.""" + path = tmpdir / 'foo.txt' + path.ensure() + + editor.edit_file(str(path)) + editor._proc.finished.emit(0, QProcess.NormalExit) + + assert path.exists() + def test_error(self, editor): """Test file handling when closing with an exit status != 0.""" editor.edit("") - filename = editor._file.name + filename = editor._filename assert os.path.exists(filename) editor._proc._proc.exitStatus = mock.Mock( @@ -99,7 +109,7 @@ class TestFileHandling: def test_crash(self, editor): """Test file handling when closing with a crash.""" editor.edit("") - filename = editor._file.name + filename = editor._filename assert os.path.exists(filename) editor._proc._proc.exitStatus = mock.Mock( @@ -114,7 +124,7 @@ class TestFileHandling: def test_unreadable(self, message_mock, editor, caplog): """Test file handling when closing with an unreadable file.""" editor.edit("") - filename = editor._file.name + filename = editor._filename assert os.path.exists(filename) os.chmod(filename, 0o077) if os.access(filename, os.R_OK): @@ -160,10 +170,10 @@ def test_modify(editor, initial_text, edited_text): """Test if inputs get modified correctly.""" editor.edit(initial_text) - with open(editor._file.name, 'r', encoding='utf-8') as f: + with open(editor._filename, 'r', encoding='utf-8') as f: assert f.read() == initial_text - with open(editor._file.name, 'w', encoding='utf-8') as f: + with open(editor._filename, 'w', encoding='utf-8') as f: f.write(edited_text) editor._proc.finished.emit(0, QProcess.NormalExit) From 727580d1f43dfda5689cc1ceaf470ae5379b450f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 18:54:40 +0200 Subject: [PATCH 161/186] Add a :config-edit command See #2794 --- doc/help/commands.asciidoc | 10 +++++ qutebrowser/config/configcommands.py | 25 ++++++++++++ tests/unit/config/test_configcommands.py | 51 +++++++++++++++++++++++- tests/unit/misc/test_editor.py | 1 + 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 1d25727aa..2e2e585b0 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -33,6 +33,7 @@ It is possible to run or bind multiple commands by separating them with `;;`. |<>|Close the current window. |<>|Set all settings back to their default. |<>|Cycle an option between multiple values. +|<>|Open the config.py file in the editor. |<>|Read a config.py file. |<>|Unset an option. |<>|Download a given URL, or current page if no URL given. @@ -229,6 +230,15 @@ Cycle an option between multiple values. * +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed. * +*-p*+, +*--print*+: Print the value after setting. +[[config-edit]] +=== config-edit +Syntax: +:config-edit [*--no-source*]+ + +Open the config.py file in the editor. + +==== optional arguments +* +*-n*+, +*--no-source*+: Don't re-source the config file after editing. + [[config-source]] === config-source Syntax: +:config-source [*--clear*] ['filename']+ diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index b63a959b7..12611cd27 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -28,6 +28,7 @@ from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.completion.models import configmodel from qutebrowser.utils import objreg, utils, message, standarddir from qutebrowser.config import configtypes, configexc, configfiles +from qutebrowser.misc import editor class ConfigCommands: @@ -226,3 +227,27 @@ class ConfigCommands: configfiles.read_config_py(filename) except configexc.ConfigFileErrors as e: raise cmdexc.CommandError(e) + + @cmdutils.register(instance='config-commands') + def config_edit(self, no_source=False): + """Open the config.py file in the editor. + + Args: + no_source: Don't re-source the config file after editing. + """ + def on_editing_finished(): + """Source the new config when editing finished. + + This can't use cmdexc.CommandError as it's run async. + """ + try: + configfiles.read_config_py(filename) + except configexc.ConfigFileErrors as e: + message.error(str(e)) + + ed = editor.ExternalEditor(self._config) + if not no_source: + ed.editing_finished.connect(on_editing_finished) + + filename = os.path.join(standarddir.config(), 'config.py') + ed.edit_file(filename) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 33e68cf7e..b3836f634 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -18,8 +18,10 @@ """Tests for qutebrowser.config.configcommands.""" +import logging + import pytest -from PyQt5.QtCore import QUrl +from PyQt5.QtCore import QUrl, QProcess from qutebrowser.config import configcommands from qutebrowser.commands import cmdexc @@ -302,6 +304,53 @@ class TestSource: assert str(excinfo.value) == expected +class TestEdit: + + """Tests for :config-edit.""" + + def test_no_source(self, commands, mocker, config_tmpdir): + mock = mocker.patch('qutebrowser.config.configcommands.editor.' + 'ExternalEditor._start_editor', autospec=True) + commands.config_edit(no_source=True) + mock.assert_called_once() + + @pytest.fixture + def patch_editor(self, mocker, config_tmpdir, data_tmpdir): + """Write a config.py file.""" + def do_patch(text): + def _write_file(editor_self): + with open(editor_self._filename, 'w', encoding='utf-8') as f: + f.write(text) + editor_self.on_proc_closed(0, QProcess.NormalExit) + + return mocker.patch('qutebrowser.config.configcommands.editor.' + 'ExternalEditor._start_editor', autospec=True, + side_effect=_write_file) + + return do_patch + + def test_with_sourcing(self, commands, config_stub, patch_editor): + assert config_stub.val.content.javascript.enabled + mock = patch_editor('c.content.javascript.enabled = False') + + commands.config_edit() + + mock.assert_called_once() + assert not config_stub.val.content.javascript.enabled + + def test_error(self, commands, config_stub, patch_editor, message_mock, + caplog): + patch_editor('c.foo = 42') + + with caplog.at_level(logging.ERROR): + commands.config_edit() + + msg = message_mock.getmsg() + expected = ("Errors occurred while reading config.py:\n" + " While setting 'foo': No option 'foo'") + assert msg.text == expected + + class TestBind: """Tests for :bind and :unbind.""" diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py index e6902852e..26a92476a 100644 --- a/tests/unit/misc/test_editor.py +++ b/tests/unit/misc/test_editor.py @@ -43,6 +43,7 @@ def editor(caplog): ed.editing_finished = mock.Mock() yield ed with caplog.at_level(logging.ERROR): + ed._remove_file = True ed._cleanup() From 22088d9f7bf6912f4a0b66d317dd99fce9cde636 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 19:03:03 +0200 Subject: [PATCH 162/186] Remove --force for :bind and config.bind(...) Turns out --force is just in the way for most people, and at least for default bindings it's easy to reset them. Also, it makes :config-source fail when config.py contains keybindings. Closes #3049 --- doc/help/commands.asciidoc | 3 +-- doc/help/configuring.asciidoc | 15 +++------------ qutebrowser/config/config.py | 4 +--- qutebrowser/config/configcommands.py | 9 ++------- qutebrowser/config/configexc.py | 8 -------- qutebrowser/config/configfiles.py | 8 ++------ tests/end2end/features/hints.feature | 4 ++-- tests/unit/completion/test_completer.py | 2 +- tests/unit/config/test_config.py | 12 +++--------- tests/unit/config/test_configcommands.py | 14 +++----------- tests/unit/config/test_configexc.py | 6 ------ tests/unit/config/test_configfiles.py | 9 ++++----- 12 files changed, 22 insertions(+), 72 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 2e2e585b0..71929d2aa 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -120,7 +120,7 @@ How many pages to go back. [[bind]] === bind -Syntax: +:bind [*--mode* 'mode'] [*--force*] 'key' ['command']+ +Syntax: +:bind [*--mode* 'mode'] 'key' ['command']+ Bind a key to a command. @@ -133,7 +133,6 @@ Bind a key to a command. * +*-m*+, +*--mode*+: A comma-separated list of modes to bind the key in (default: `normal`). See `:help bindings.commands` for the available modes. -* +*-f*+, +*--force*+: Rebind the key if it is already bound. ==== note * This command does not split arguments after the last argument and handles quotes literally. diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index f195db230..e296eaac4 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -61,8 +61,6 @@ link:commands.html#unbind[`:unbind`] commands: - Binding the key chain `,v` to the `:spawn mpv {url}` command: `:bind ,v spawn mpv {url}` - Unbinding the same key chain: `:unbind ,v` -- Changing an existing binding: `bind --force ,v message-info foo`. Without - `--force`, qutebrowser will show an error because `,v` is already bound. Key chains starting with a comma are ideal for custom bindings, as the comma key will never be used in a default keybinding. @@ -179,13 +177,6 @@ To bind a key in a mode other than `'normal'`, add a `mode` argument: config.bind('', 'prompt-yes', mode='prompt') ---- -If the key is already bound, `force=True` needs to be given to rebind it: - -[source,python] ----- -config.bind('', 'message-info foo', force=True) ----- - To unbind a key (either a key which has been bound before, or a default binding): [source,python] @@ -339,10 +330,10 @@ to do so: [source,python] ---- -def bind_chained(key, *commands, force=False): - config.bind(key, ' ;; '.join(commands), force=force) +def bind_chained(key, *commands): + config.bind(key, ' ;; '.join(commands)) -bind_chained('', 'clear-keychain', 'search', force=True) +bind_chained('', 'clear-keychain', 'search') ---- Avoiding flake8 errors diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 7f2d1ab41..d6dbd1e86 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -168,13 +168,11 @@ class KeyConfig: bindings = self.get_bindings_for(mode) return bindings.get(key, None) - def bind(self, key, command, *, mode, force=False, save_yaml=False): + def bind(self, key, command, *, mode, save_yaml=False): """Add a new binding from key to command.""" key = self._prepare(key, mode) log.keyboard.vdebug("Adding binding {} -> {} in mode {}.".format( key, command, mode)) - if key in self.get_bindings_for(mode) and not force: - raise configexc.DuplicateKeyError(key) bindings = self._config.get_obj('bindings.commands') if mode not in bindings: diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 12611cd27..e496f693b 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -92,7 +92,7 @@ class ConfigCommands: @cmdutils.register(instance='config-commands', maxsplit=1, no_cmd_split=True, no_replace_variables=True) @cmdutils.argument('command', completion=configmodel.bind) - def bind(self, key, command=None, *, mode='normal', force=False): + def bind(self, key, command=None, *, mode='normal'): """Bind a key to a command. Args: @@ -102,7 +102,6 @@ class ConfigCommands: mode: A comma-separated list of modes to bind the key in (default: `normal`). See `:help bindings.commands` for the available modes. - force: Rebind the key if it is already bound. """ if command is None: if utils.is_special_key(key): @@ -118,11 +117,7 @@ class ConfigCommands: return try: - self._keyconfig.bind(key, command, mode=mode, force=force, - save_yaml=True) - except configexc.DuplicateKeyError as e: - raise cmdexc.CommandError("bind: {} - use --force to override!" - .format(e)) + self._keyconfig.bind(key, command, mode=mode, save_yaml=True) except configexc.KeybindingError as e: raise cmdexc.CommandError("bind: {}".format(e)) diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index 0d20bb09d..7b2d0aa04 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -59,14 +59,6 @@ class KeybindingError(Error): """Raised for issues with keybindings.""" -class DuplicateKeyError(KeybindingError): - - """Raised when there was a duplicate key.""" - - def __init__(self, key): - super().__init__("Duplicate key {}".format(key)) - - class NoOptionError(Error): """Raised when an option was not found.""" diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 840983480..3a411f658 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -236,13 +236,9 @@ class ConfigAPI: with self._handle_error('setting', name): self._config.set_obj(name, value) - def bind(self, key, command, mode='normal', *, force=False): + def bind(self, key, command, mode='normal'): with self._handle_error('binding', key): - try: - self._keyconfig.bind(key, command, mode=mode, force=force) - except configexc.DuplicateKeyError as e: - raise configexc.KeybindingError('{} - use force=True to ' - 'override!'.format(e)) + self._keyconfig.bind(key, command, mode=mode) def unbind(self, key, mode='normal'): with self._handle_error('unbinding', key): diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index 8552586fa..07db63a5f 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -246,7 +246,7 @@ Feature: Using hints Scenario: Ignoring key presses after auto-following hints When I set hints.auto_follow_timeout to 1000 And I set hints.mode to number - And I run :bind --force , message-error "This error message was triggered via a keybinding which should have been inhibited" + And I run :bind , message-error "This error message was triggered via a keybinding which should have been inhibited" And I open data/hints/html/simple.html And I hint with args "all" And I press the key "f" @@ -259,7 +259,7 @@ Feature: Using hints Scenario: Turning off auto_follow_timeout When I set hints.auto_follow_timeout to 0 And I set hints.mode to number - And I run :bind --force , message-info "Keypress worked!" + And I run :bind , message-info "Keypress worked!" And I open data/hints/html/simple.html And I hint with args "all" And I press the key "f" diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py index 8914a956b..8575cf02d 100644 --- a/tests/unit/completion/test_completer.py +++ b/tests/unit/completion/test_completer.py @@ -120,7 +120,7 @@ def cmdutils_patch(monkeypatch, stubs, miscmodels_patch): @cmdutils.argument('win_id', win_id=True) @cmdutils.argument('command', completion=miscmodels_patch.command) - def bind(key, win_id, command=None, *, mode='normal', force=False): + def bind(key, win_id, command=None, *, mode='normal'): """docstring.""" pass diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 03fa1dbae..a0877cc23 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -172,19 +172,13 @@ class TestKeyConfig: config_stub.val.bindings.commands = {'normal': bindings} assert keyconf.get_reverse_bindings_for('normal') == expected - @pytest.mark.parametrize('force', [True, False]) @pytest.mark.parametrize('key', ['a', '', 'b']) - def test_bind_duplicate(self, keyconf, config_stub, force, key): + def test_bind_duplicate(self, keyconf, config_stub, key): config_stub.val.bindings.default = {'normal': {'a': 'nop', '': 'nop'}} config_stub.val.bindings.commands = {'normal': {'b': 'nop'}} - if force: - keyconf.bind(key, 'message-info foo', mode='normal', force=True) - assert keyconf.get_command(key, 'normal') == 'message-info foo' - else: - with pytest.raises(configexc.DuplicateKeyError): - keyconf.bind(key, 'message-info foo', mode='normal') - assert keyconf.get_command(key, 'normal') == 'nop' + keyconf.bind(key, 'message-info foo', mode='normal') + assert keyconf.get_command(key, 'normal') == 'message-info foo' @pytest.mark.parametrize('mode', ['normal', 'caret']) @pytest.mark.parametrize('command', [ diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index b3836f634..0f12dd843 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -418,9 +418,8 @@ class TestBind: match='bind: Invalid mode wrongmode!'): commands.bind('a', 'nop', mode='wrongmode') - @pytest.mark.parametrize('force', [True, False]) @pytest.mark.parametrize('key', ['a', 'b', '']) - def test_bind_duplicate(self, commands, config_stub, keyconf, force, key): + def test_bind_duplicate(self, commands, config_stub, keyconf, key): """Run ':bind' with a key which already has been bound.'. Also tests for https://github.com/qutebrowser/qutebrowser/issues/1544 @@ -432,15 +431,8 @@ class TestBind: 'normal': {'b': 'nop'}, } - if force: - commands.bind(key, 'message-info foo', mode='normal', force=True) - assert keyconf.get_command(key, 'normal') == 'message-info foo' - else: - with pytest.raises(cmdexc.CommandError, - match="bind: Duplicate key .* - use --force to " - "override"): - commands.bind(key, 'message-info foo', mode='normal') - assert keyconf.get_command(key, 'normal') == 'nop' + commands.bind(key, 'message-info foo', mode='normal') + assert keyconf.get_command(key, 'normal') == 'message-info foo' def test_bind_none(self, commands, config_stub): config_stub.val.bindings.commands = None diff --git a/tests/unit/config/test_configexc.py b/tests/unit/config/test_configexc.py index 024bbb1d0..38a74bcef 100644 --- a/tests/unit/config/test_configexc.py +++ b/tests/unit/config/test_configexc.py @@ -43,12 +43,6 @@ def test_backend_error(): assert str(e) == "This setting is not available with the QtWebKit backend!" -def test_duplicate_key_error(): - e = configexc.DuplicateKeyError('asdf') - assert isinstance(e, configexc.KeybindingError) - assert str(e) == "Duplicate key asdf" - - def test_desc_with_text(): """Test ConfigErrorDesc.with_text.""" old = configexc.ConfigErrorDesc("Error text", Exception("Exception text")) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index fad982dd6..99098f26e 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -403,12 +403,11 @@ class TestConfigPy: confpy.read() def test_bind_duplicate_key(self, confpy): - """Make sure we get a nice error message on duplicate key bindings.""" + """Make sure overriding a keybinding works.""" confpy.write("config.bind('H', 'message-info back')") - error = confpy.read(error=True) - - expected = "Duplicate key H - use force=True to override!" - assert str(error.exception) == expected + confpy.read() + expected = {'normal': {'H': 'message-info back'}} + assert config.instance._values['bindings.commands'] == expected def test_bind_none(self, confpy): confpy.write("c.bindings.commands = None", From 3907d1e032a3eb4dbe7f9b311a3fe13ac154fb2e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 19:12:24 +0200 Subject: [PATCH 163/186] Update changelog --- doc/changelog.asciidoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index beae58f87..960481372 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -64,6 +64,8 @@ Added - `:config-cycle` to cycle an option between multiple values. - `:config-unset` to remove a configured option - `:config-clear` to remove all configured options + - `:config-source` to (re-)read a `config.py` file + - `:config-edit` to open the `config.py` file in an editor Changed ~~~~~~~ From 22adcfba754b9cf837cb5f57ceafd74888e188b2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 19:28:41 +0200 Subject: [PATCH 164/186] Stop using mocks in test_editor --- tests/unit/misc/test_editor.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py index 26a92476a..d97a39f7b 100644 --- a/tests/unit/misc/test_editor.py +++ b/tests/unit/misc/test_editor.py @@ -22,7 +22,6 @@ import os import os.path import logging -from unittest import mock from PyQt5.QtCore import QProcess import pytest @@ -40,7 +39,6 @@ def patch_things(config_stub, monkeypatch, stubs): @pytest.fixture def editor(caplog): ed = editormod.ExternalEditor() - ed.editing_finished = mock.Mock() yield ed with caplog.at_level(logging.ERROR): ed._remove_file = True @@ -99,8 +97,7 @@ class TestFileHandling: filename = editor._filename assert os.path.exists(filename) - editor._proc._proc.exitStatus = mock.Mock( - return_value=QProcess.CrashExit) + editor._proc._proc.exitStatus = lambda: QProcess.CrashExit editor._proc.finished.emit(1, QProcess.NormalExit) assert os.path.exists(filename) @@ -113,8 +110,7 @@ class TestFileHandling: filename = editor._filename assert os.path.exists(filename) - editor._proc._proc.exitStatus = mock.Mock( - return_value=QProcess.CrashExit) + editor._proc._proc.exitStatus = lambda: QProcess.CrashExit editor._proc.error.emit(QProcess.Crashed) editor._proc.finished.emit(0, QProcess.CrashExit) @@ -167,7 +163,7 @@ class TestFileHandling: ('Hällö Wörld', 'Überprüfung'), ('\u2603', '\u2601') # Unicode snowman -> cloud ]) -def test_modify(editor, initial_text, edited_text): +def test_modify(qtbot, editor, initial_text, edited_text): """Test if inputs get modified correctly.""" editor.edit(initial_text) @@ -177,5 +173,7 @@ def test_modify(editor, initial_text, edited_text): with open(editor._filename, 'w', encoding='utf-8') as f: f.write(edited_text) - editor._proc.finished.emit(0, QProcess.NormalExit) - editor.editing_finished.emit.assert_called_with(edited_text) + with qtbot.wait_signal(editor.editing_finished) as blocker: + editor._proc.finished.emit(0, QProcess.NormalExit) + + assert blocker.args == [edited_text] From 6c2958b6469ee45c6c8134d7a3bfaeacd5f78870 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 20:49:16 +0200 Subject: [PATCH 165/186] Set star_args_optional for :config-cycle --- doc/help/commands.asciidoc | 2 +- qutebrowser/config/configcommands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 71929d2aa..2d42606f0 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -217,7 +217,7 @@ Set all settings back to their default. [[config-cycle]] === config-cycle -Syntax: +:config-cycle [*--temp*] [*--print*] 'option' 'values' ['values' ...]+ +Syntax: +:config-cycle [*--temp*] [*--print*] 'option' ['values' ['values' ...]]+ Cycle an option between multiple values. diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index e496f693b..0eb93387e 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -135,7 +135,7 @@ class ConfigCommands: except configexc.KeybindingError as e: raise cmdexc.CommandError('unbind: {}'.format(e)) - @cmdutils.register(instance='config-commands') + @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): From 16d369d98c3baf1add759dced0849a6784f3e8c1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 21:13:56 +0200 Subject: [PATCH 166/186] bdd: Include captured log when subprocess didn't start Fixes #3052 --- tests/end2end/fixtures/testprocess.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/end2end/fixtures/testprocess.py b/tests/end2end/fixtures/testprocess.py index 60874e622..a4b136193 100644 --- a/tests/end2end/fixtures/testprocess.py +++ b/tests/end2end/fixtures/testprocess.py @@ -242,7 +242,8 @@ class Process(QObject): self._after_start() return - raise WaitForTimeout("Timed out while waiting for process start.") + raise WaitForTimeout("Timed out while waiting for process start.\n" + + _render_log(self.captured_log)) def _start(self, args, env): """Actually start the process.""" From d70bdb5552f222922f945495145c4c967ade34bb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 21:28:55 +0200 Subject: [PATCH 167/186] Honour --no-err-windows in more places Fixes #3053 --- qutebrowser/misc/backendproblem.py | 49 ++++++++++++++++++++---------- qutebrowser/misc/msgbox.py | 16 ++++++++++ tests/unit/misc/test_msgbox.py | 14 +++++++++ 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index 3cd9e5abd..d975e29cf 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -52,22 +52,20 @@ class _Button: default = attr.ib(default=False) -class _Dialog(QDialog): +def _other_backend(backend): + """Get the other backend enum/setting for a given backend.""" + other_backend = { + usertypes.Backend.QtWebKit: usertypes.Backend.QtWebEngine, + usertypes.Backend.QtWebEngine: usertypes.Backend.QtWebKit, + }[backend] + other_setting = other_backend.name.lower()[2:] + return (other_backend, other_setting) - """A dialog which gets shown if there are issues with the backend.""" - def __init__(self, because, text, backend, buttons=None, parent=None): - super().__init__(parent) - vbox = QVBoxLayout(self) - - other_backend = { - usertypes.Backend.QtWebKit: usertypes.Backend.QtWebEngine, - usertypes.Backend.QtWebEngine: usertypes.Backend.QtWebKit, - }[backend] - other_setting = other_backend.name.lower()[2:] - - label = QLabel( - "Failed to start with the {backend} backend!" +def _error_text(because, text, backend): + """Get an error text for the given information.""" + other_backend, other_setting = _other_backend(backend) + return ("Failed to start with the {backend} backend!" "

qutebrowser tried to start with the {backend} backend but " "failed because {because}.

{text}" "

Forcing the {other_backend.name} backend

" @@ -76,8 +74,21 @@ class _Dialog(QDialog): "(if you have a config.py file, you'll need to set " "this manually).

".format( backend=backend.name, because=because, text=text, - other_backend=other_backend, other_setting=other_setting), - wordWrap=True) + other_backend=other_backend, other_setting=other_setting)) + + +class _Dialog(QDialog): + + """A dialog which gets shown if there are issues with the backend.""" + + def __init__(self, because, text, backend, buttons=None, parent=None): + super().__init__(parent) + vbox = QVBoxLayout(self) + + other_backend, other_setting = _other_backend(backend) + text = _error_text(because, text, backend) + + label = QLabel(text, wordWrap=True) label.setTextFormat(Qt.RichText) vbox.addWidget(label) @@ -118,6 +129,12 @@ class _Dialog(QDialog): def _show_dialog(*args, **kwargs): """Show a dialog for a backend problem.""" + cmd_args = objreg.get('args') + if cmd_args.no_err_windows: + text = _error_text(*args, **kwargs) + print(text, file=sys.stderr) + sys.exit(usertypes.Exit.err_init) + dialog = _Dialog(*args, **kwargs) status = dialog.exec_() diff --git a/qutebrowser/misc/msgbox.py b/qutebrowser/misc/msgbox.py index e4e77330a..459ab8b61 100644 --- a/qutebrowser/misc/msgbox.py +++ b/qutebrowser/misc/msgbox.py @@ -19,10 +19,21 @@ """Convenience functions to show message boxes.""" +import sys from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMessageBox +from qutebrowser.utils import objreg + + +class DummyBox: + + """A dummy QMessageBox returned when --no-err-windows is used.""" + + def exec_(self): + pass + def msgbox(parent, title, text, *, icon, buttons=QMessageBox.Ok, on_finished=None, plain_text=None): @@ -40,6 +51,11 @@ def msgbox(parent, title, text, *, icon, buttons=QMessageBox.Ok, Return: A new QMessageBox. """ + args = objreg.get('args') + if args.no_err_windows: + print('Message box: {}; {}'.format(title, text), file=sys.stderr) + return DummyBox() + box = QMessageBox(parent) box.setAttribute(Qt.WA_DeleteOnClose) box.setIcon(icon) diff --git a/tests/unit/misc/test_msgbox.py b/tests/unit/misc/test_msgbox.py index 5f0058dea..cab72c251 100644 --- a/tests/unit/misc/test_msgbox.py +++ b/tests/unit/misc/test_msgbox.py @@ -27,6 +27,11 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMessageBox, QWidget +@pytest.fixture(autouse=True) +def patch_args(fake_args): + fake_args.no_err_windows = False + + def test_attributes(qtbot): """Test basic QMessageBox attributes.""" title = 'title' @@ -85,3 +90,12 @@ def test_information(qtbot): assert box.windowTitle() == 'foo' assert box.text() == 'bar' assert box.icon() == QMessageBox.Information + + +def test_no_err_windows(fake_args, capsys): + fake_args.no_err_windows = True + box = msgbox.information(parent=None, title='foo', text='bar') + box.exec_() # should do nothing + out, err = capsys.readouterr() + assert not out + assert err == 'Message box: foo; bar\n' From 7f28097f558ccca779d402850ed9f7c8358bca15 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 22:17:29 +0200 Subject: [PATCH 168/186] Be explicit about constraints instead --- qutebrowser/browser/history.py | 9 ++++++++- qutebrowser/misc/sql.py | 3 +-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index ca62d9e6c..2c4c1f226 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -40,7 +40,10 @@ class CompletionHistory(sql.SqlTable): def __init__(self, parent=None): super().__init__("CompletionHistory", ['url', 'title', 'last_atime'], - constraints={'url': 'PRIMARY KEY'}, parent=parent) + constraints={'url': 'PRIMARY KEY', + 'title': 'NOT NULL', + 'last_atime': 'NOT NULL'}, + parent=parent) self.create_index('CompletionHistoryAtimeIndex', 'last_atime') @@ -50,6 +53,10 @@ class WebHistory(sql.SqlTable): def __init__(self, parent=None): super().__init__("History", ['url', 'title', 'atime', 'redirect'], + constraints={'url': 'NOT NULL', + 'title': 'NOT NULL', + 'atime': 'NOT NULL', + 'redirect': 'NOT NULL'}, parent=parent) self.completion = CompletionHistory(parent=self) if sql.Query('pragma user_version').run().value() < _USER_VERSION: diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py index bfb1d1e8b..24e2035e6 100644 --- a/qutebrowser/misc/sql.py +++ b/qutebrowser/misc/sql.py @@ -161,8 +161,7 @@ class SqlTable(QObject): self._name = name constraints = constraints or {} - default = 'NOT NULL' - column_defs = ['{} {}'.format(field, constraints.get(field, default)) + column_defs = ['{} {}'.format(field, constraints.get(field, '')) for field in fields] q = Query("CREATE TABLE IF NOT EXISTS {name} ({column_defs})" .format(name=name, column_defs=', '.join(column_defs))) From feaccb3083b9f97d3f413f70ccf14d6d01dcacfb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 3 Oct 2017 22:44:21 +0200 Subject: [PATCH 169/186] Rename :scroll-perc to :scroll-to-perc Closes #2819 --- doc/changelog.asciidoc | 1 + doc/help/commands.asciidoc | 34 +++++----- doc/help/settings.asciidoc | 4 +- qutebrowser/browser/commands.py | 2 +- qutebrowser/config/configdata.yml | 4 +- tests/end2end/features/marks.feature | 4 +- tests/end2end/features/qutescheme.feature | 4 +- tests/end2end/features/scroll.feature | 80 +++++++++++------------ 8 files changed, 67 insertions(+), 66 deletions(-) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 960481372..d94b011ab 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -42,6 +42,7 @@ Breaking changes - The `--harfbuzz` and `--relaxed-config` commandline arguments got dropped. - `:set` now doesn't support toggling/cycling values anymore, that functionality got moved to `:config-cycle`. +- `:scroll-perc` got renamed to `:scroll-to-perc`. Major changes ~~~~~~~~~~~~~ diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 2d42606f0..3056da700 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -1135,8 +1135,8 @@ How many steps to zoom out. |<>|Run a command with the given count. |<>|Scroll the current tab in the given direction. |<>|Scroll the frame page-wise. -|<>|Scroll to a specific percentage of the page. |<>|Scroll the current tab by 'count * dx/dy' pixels. +|<>|Scroll to a specific percentage of the page. |<>|Continue the search to the ([count]th) next term. |<>|Continue the search to the ([count]th) previous term. |<>|Set a mark at the current scroll position in the current tab. @@ -1556,9 +1556,22 @@ Scroll the frame page-wise. ==== count multiplier -[[scroll-perc]] -=== scroll-perc -Syntax: +:scroll-perc [*--horizontal*] ['perc']+ +[[scroll-px]] +=== scroll-px +Syntax: +:scroll-px 'dx' 'dy'+ + +Scroll the current tab by 'count * dx/dy' pixels. + +==== positional arguments +* +'dx'+: How much to scroll in x-direction. +* +'dy'+: How much to scroll in y-direction. + +==== count +multiplier + +[[scroll-to-perc]] +=== scroll-to-perc +Syntax: +:scroll-to-perc [*--horizontal*] ['perc']+ Scroll to a specific percentage of the page. @@ -1573,19 +1586,6 @@ The percentage can be given either as argument or as count. If no percentage is ==== count Percentage to scroll. -[[scroll-px]] -=== scroll-px -Syntax: +:scroll-px 'dx' 'dy'+ - -Scroll the current tab by 'count * dx/dy' pixels. - -==== positional arguments -* +'dx'+: How much to scroll in x-direction. -* +'dy'+: How much to scroll in y-direction. - -==== count -multiplier - [[search-next]] === search-next Continue the search to the ([count]th) next term. diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 642aec436..7aee1d66e 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -507,7 +507,7 @@ Default: * +pass:[B]+: +pass:[set-cmd-text -s :quickmark-load -t]+ * +pass:[D]+: +pass:[tab-close -o]+ * +pass:[F]+: +pass:[hint all tab]+ -* +pass:[G]+: +pass:[scroll-perc]+ +* +pass:[G]+: +pass:[scroll-to-perc]+ * +pass:[H]+: +pass:[back]+ * +pass:[J]+: +pass:[tab-next]+ * +pass:[K]+: +pass:[tab-prev]+ @@ -542,7 +542,7 @@ Default: * +pass:[gb]+: +pass:[set-cmd-text -s :bookmark-load]+ * +pass:[gd]+: +pass:[download]+ * +pass:[gf]+: +pass:[view-source]+ -* +pass:[gg]+: +pass:[scroll-perc 0]+ +* +pass:[gg]+: +pass:[scroll-to-perc 0]+ * +pass:[gl]+: +pass:[tab-move -]+ * +pass:[gm]+: +pass:[tab-move]+ * +pass:[go]+: +pass:[set-cmd-text :open {url:pretty}]+ diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 24e7935fb..c0c06c8ec 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -688,7 +688,7 @@ class CommandDispatcher: scope='window') @cmdutils.argument('count', count=True) @cmdutils.argument('horizontal', flag='x') - def scroll_perc(self, perc: float = None, horizontal=False, count=None): + def scroll_to_perc(self, perc: float = None, horizontal=False, count=None): """Scroll to a specific percentage of the page. The percentage can be given either as argument or as count. diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index b907a2d75..56b58f8e1 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -1991,8 +1991,8 @@ bindings.default: l: scroll right u: undo : undo - gg: scroll-perc 0 - G: scroll-perc + gg: scroll-to-perc 0 + G: scroll-to-perc n: search-next N: search-prev i: enter-mode insert diff --git a/tests/end2end/features/marks.feature b/tests/end2end/features/marks.feature index 31ddd034d..605bd3971 100644 --- a/tests/end2end/features/marks.feature +++ b/tests/end2end/features/marks.feature @@ -21,7 +21,7 @@ Feature: Setting positional marks Scenario: Jumping back after jumping to a particular percentage When I run :scroll-px 10 20 And I wait until the scroll position changed to 10/20 - And I run :scroll-perc 100 + And I run :scroll-to-perc 100 And I wait until the scroll position changed And I run :jump-mark "'" And I wait until the scroll position changed to 10/20 @@ -116,7 +116,7 @@ Feature: Setting positional marks Scenario: Hovering a hint does not set the ' mark When I run :scroll-px 30 20 And I wait until the scroll position changed to 30/20 - And I run :scroll-perc 0 + And I run :scroll-to-perc 0 And I wait until the scroll position changed And I hint with args "links hover" and follow s And I run :jump-mark "'" diff --git a/tests/end2end/features/qutescheme.feature b/tests/end2end/features/qutescheme.feature index 10ef9932f..c60e18f3a 100644 --- a/tests/end2end/features/qutescheme.feature +++ b/tests/end2end/features/qutescheme.feature @@ -77,7 +77,7 @@ Feature: Special qute:// pages When I set ignore_case to never And I open qute://settings # scroll to the right - the table does not fit in the default screen - And I run :scroll-perc -x 100 + And I run :scroll-to-perc -x 100 And I run :jseval document.getElementById('input-ignore_case').value = '' And I run :click-element id input-ignore_case And I wait for "Entering mode KeyMode.insert *" in the log @@ -91,7 +91,7 @@ Feature: Special qute:// pages Scenario: Focusing input fields in qute://settings and entering invalid value When I open qute://settings # scroll to the right - the table does not fit in the default screen - And I run :scroll-perc -x 100 + And I run :scroll-to-perc -x 100 And I run :jseval document.getElementById('input-ignore_case').value = '' And I run :click-element id input-ignore_case And I wait for "Entering mode KeyMode.insert *" in the log diff --git a/tests/end2end/features/scroll.feature b/tests/end2end/features/scroll.feature index d5e339f1a..e77b57dc8 100644 --- a/tests/end2end/features/scroll.feature +++ b/tests/end2end/features/scroll.feature @@ -156,86 +156,86 @@ Feature: Scrolling And I run :scroll down Then the page should not be scrolled - ## :scroll-perc + ## :scroll-to-perc - Scenario: Scrolling to bottom with :scroll-perc - When I run :scroll-perc 100 + Scenario: Scrolling to bottom with :scroll-to-perc + When I run :scroll-to-perc 100 Then the page should be scrolled vertically - Scenario: Scrolling to bottom and to top with :scroll-perc - When I run :scroll-perc 100 + Scenario: Scrolling to bottom and to top with :scroll-to-perc + When I run :scroll-to-perc 100 And I wait until the scroll position changed - And I run :scroll-perc 0 + And I run :scroll-to-perc 0 And I wait until the scroll position changed to 0/0 Then the page should not be scrolled - Scenario: Scrolling to middle with :scroll-perc - When I run :scroll-perc 50 + Scenario: Scrolling to middle with :scroll-to-perc + When I run :scroll-to-perc 50 Then the page should be scrolled vertically - Scenario: Scrolling to middle with :scroll-perc (float) - When I run :scroll-perc 50.5 + Scenario: Scrolling to middle with :scroll-to-perc (float) + When I run :scroll-to-perc 50.5 Then the page should be scrolled vertically - Scenario: Scrolling to middle and to top with :scroll-perc - When I run :scroll-perc 50 + Scenario: Scrolling to middle and to top with :scroll-to-perc + When I run :scroll-to-perc 50 And I wait until the scroll position changed - And I run :scroll-perc 0 + And I run :scroll-to-perc 0 And I wait until the scroll position changed to 0/0 Then the page should not be scrolled - Scenario: Scrolling to right with :scroll-perc - When I run :scroll-perc --horizontal 100 + Scenario: Scrolling to right with :scroll-to-perc + When I run :scroll-to-perc --horizontal 100 Then the page should be scrolled horizontally - Scenario: Scrolling to right and to left with :scroll-perc - When I run :scroll-perc --horizontal 100 + Scenario: Scrolling to right and to left with :scroll-to-perc + When I run :scroll-to-perc --horizontal 100 And I wait until the scroll position changed - And I run :scroll-perc --horizontal 0 + And I run :scroll-to-perc --horizontal 0 And I wait until the scroll position changed to 0/0 Then the page should not be scrolled - Scenario: Scrolling to middle (horizontally) with :scroll-perc - When I run :scroll-perc --horizontal 50 + Scenario: Scrolling to middle (horizontally) with :scroll-to-perc + When I run :scroll-to-perc --horizontal 50 Then the page should be scrolled horizontally - Scenario: Scrolling to middle and to left with :scroll-perc - When I run :scroll-perc --horizontal 50 + Scenario: Scrolling to middle and to left with :scroll-to-perc + When I run :scroll-to-perc --horizontal 50 And I wait until the scroll position changed - And I run :scroll-perc --horizontal 0 + And I run :scroll-to-perc --horizontal 0 And I wait until the scroll position changed to 0/0 Then the page should not be scrolled - Scenario: :scroll-perc without argument - When I run :scroll-perc + Scenario: :scroll-to-perc without argument + When I run :scroll-to-perc Then the page should be scrolled vertically - Scenario: :scroll-perc without argument and --horizontal - When I run :scroll-perc --horizontal + Scenario: :scroll-to-perc without argument and --horizontal + When I run :scroll-to-perc --horizontal Then the page should be scrolled horizontally - Scenario: :scroll-perc with count - When I run :scroll-perc with count 50 + Scenario: :scroll-to-perc with count + When I run :scroll-to-perc with count 50 Then the page should be scrolled vertically @qtwebengine_skip: Causes memory leak... - Scenario: :scroll-perc with a very big value - When I run :scroll-perc 99999999999 + Scenario: :scroll-to-perc with a very big value + When I run :scroll-to-perc 99999999999 Then no crash should happen - Scenario: :scroll-perc on a page without scrolling + Scenario: :scroll-to-perc on a page without scrolling When I open data/hello.txt - And I run :scroll-perc 20 + And I run :scroll-to-perc 20 Then the page should not be scrolled - Scenario: :scroll-perc with count and argument - When I run :scroll-perc 0 with count 50 + Scenario: :scroll-to-perc with count and argument + When I run :scroll-to-perc 0 with count 50 Then the page should be scrolled vertically # https://github.com/qutebrowser/qutebrowser/issues/1821 - Scenario: :scroll-perc without doctype + Scenario: :scroll-to-perc without doctype When I open data/scroll/no_doctype.html - And I run :scroll-perc 100 + And I run :scroll-to-perc 100 Then the page should be scrolled vertically ## :scroll-page @@ -280,14 +280,14 @@ Feature: Scrolling Then the page should not be scrolled Scenario: :scroll-page with --bottom-navigate - When I run :scroll-perc 100 + When I run :scroll-to-perc 100 And I wait until the scroll position changed And I run :scroll-page --bottom-navigate next 0 1 Then data/hello2.txt should be loaded Scenario: :scroll-page with --bottom-navigate and zoom When I run :zoom 200 - And I run :scroll-perc 100 + And I run :scroll-to-perc 100 And I wait until the scroll position changed And I run :scroll-page --bottom-navigate next 0 1 Then data/hello2.txt should be loaded @@ -317,7 +317,7 @@ Feature: Scrolling Scenario: Relative scroll position with a position:absolute page When I open data/scroll/position_absolute.html - And I run :scroll-perc 100 + And I run :scroll-to-perc 100 And I wait until the scroll position changed And I run :scroll-page --bottom-navigate next 0 1 Then data/hello2.txt should be loaded From 5d787c84ea27c24e558301875d565395ad028123 Mon Sep 17 00:00:00 2001 From: Ryan Roden-Corrent Date: Mon, 2 Oct 2017 11:44:02 -0400 Subject: [PATCH 170/186] Show keyhint even with count prefix. The keyhintwidget was not showing up when a keychain was prefixed with a count. For example, 'g' would show a keyhint but '5g' would not. Now keyhints are shown even when a count is given. Resolves #3045. --- qutebrowser/misc/keyhintwidget.py | 2 ++ tests/unit/misc/test_keyhints.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 63b8f017c..ad2ea84a2 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -26,6 +26,7 @@ It is intended to help discoverability of keybindings. import html import fnmatch +import re from PyQt5.QtWidgets import QLabel, QSizePolicy from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt @@ -85,6 +86,7 @@ class KeyHintView(QLabel): Args: prefix: The current partial keystring. """ + _, prefix = re.match(r'^(\d*)(.*)', prefix).groups() if not prefix: self._show_timer.stop() self.hide() diff --git a/tests/unit/misc/test_keyhints.py b/tests/unit/misc/test_keyhints.py index c40958c86..9c5c735a2 100644 --- a/tests/unit/misc/test_keyhints.py +++ b/tests/unit/misc/test_keyhints.py @@ -92,6 +92,18 @@ def test_suggestions(keyhint, config_stub): ('a', 'yellow', 'c', 'message-info cmd-ac')) +def test_suggestions_with_count(keyhint, config_stub): + """Test that keyhints are shown based on a prefix.""" + bindings = {'normal': {'aa': 'message-info cmd-aa'}} + config_stub.val.bindings.default = bindings + config_stub.val.bindings.commands = bindings + + keyhint.update_keyhint('normal', '2a') + assert keyhint.text() == expected_text( + ('a', 'yellow', 'a', 'message-info cmd-aa'), + ) + + def test_special_bindings(keyhint, config_stub): """Ensure a prefix of '<' doesn't suggest special keys.""" bindings = {'normal': { From 4a9e22163b232038a0ad729fdded9c2281a45505 Mon Sep 17 00:00:00 2001 From: Ryan Roden-Corrent Date: Mon, 2 Oct 2017 12:01:53 -0400 Subject: [PATCH 171/186] Filter keyhints based on count prefix. If a count prefix is given, only hint commands that can take a count. --- qutebrowser/commands/command.py | 4 ++++ qutebrowser/misc/keyhintwidget.py | 11 +++++++++-- tests/helpers/stubs.py | 1 + tests/unit/misc/test_keyhints.py | 13 +++++++++---- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index fd9b2382f..9baa4efe7 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -515,3 +515,7 @@ class Command: raise cmdexc.PrerequisitesError( "{}: This command is only allowed in {} mode, not {}.".format( self.name, mode_names, mode.name)) + + def takes_count(self): + """Return true iff this command can take a count argument.""" + return any(arg.count for arg in self._qute_args) diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index ad2ea84a2..5626ce4ae 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -33,6 +33,7 @@ from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt from qutebrowser.config import config from qutebrowser.utils import utils, usertypes +from qutebrowser.commands import cmdutils class KeyHintView(QLabel): @@ -86,7 +87,7 @@ class KeyHintView(QLabel): Args: prefix: The current partial keystring. """ - _, prefix = re.match(r'^(\d*)(.*)', prefix).groups() + countstr, prefix = re.match(r'^(\d*)(.*)', prefix).groups() if not prefix: self._show_timer.stop() self.hide() @@ -96,11 +97,17 @@ class KeyHintView(QLabel): return any(fnmatch.fnmatchcase(keychain, glob) for glob in config.val.keyhint.blacklist) + def takes_count(cmdstr): + cmdname = cmdstr.split(' ')[0] + cmd = cmdutils.cmd_dict.get(cmdname) + return cmd and cmd.takes_count() + 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)] + not blacklisted(k) and + (takes_count(v) or not countstr)] if not bindings: self._show_timer.stop() diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 0bab0ce96..1d8d547fc 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -336,6 +336,7 @@ class FakeCommand: deprecated = attr.ib(False) completion = attr.ib(None) maxsplit = attr.ib(None) + takes_count = attr.ib(lambda: False) class FakeTimer(QObject): diff --git a/tests/unit/misc/test_keyhints.py b/tests/unit/misc/test_keyhints.py index 9c5c735a2..3e94f1271 100644 --- a/tests/unit/misc/test_keyhints.py +++ b/tests/unit/misc/test_keyhints.py @@ -92,15 +92,20 @@ def test_suggestions(keyhint, config_stub): ('a', 'yellow', 'c', 'message-info cmd-ac')) -def test_suggestions_with_count(keyhint, config_stub): - """Test that keyhints are shown based on a prefix.""" - bindings = {'normal': {'aa': 'message-info cmd-aa'}} +def test_suggestions_with_count(keyhint, config_stub, monkeypatch, stubs): + """Test that a count prefix filters out commands that take no count.""" + monkeypatch.setattr('qutebrowser.commands.cmdutils.cmd_dict', { + 'foo': stubs.FakeCommand(name='foo', takes_count=lambda: False), + 'bar': stubs.FakeCommand(name='bar', takes_count=lambda: True), + }) + + bindings = {'normal': {'aa': 'foo', 'ab': 'bar'}} config_stub.val.bindings.default = bindings config_stub.val.bindings.commands = bindings keyhint.update_keyhint('normal', '2a') assert keyhint.text() == expected_text( - ('a', 'yellow', 'a', 'message-info cmd-aa'), + ('a', 'yellow', 'b', 'bar'), ) From 8c660d1bf4e885489f1a96539af2ea6d1068046e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 06:22:51 +0200 Subject: [PATCH 172/186] Add a :version command --- doc/changelog.asciidoc | 1 + doc/help/commands.asciidoc | 5 +++++ qutebrowser/misc/utilcmds.py | 9 +++++++++ tests/unit/misc/test_utilcmds.py | 16 +++++++++++++++- 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index d94b011ab..cedd7ff70 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -67,6 +67,7 @@ Added - `:config-clear` to remove all configured options - `:config-source` to (re-)read a `config.py` file - `:config-edit` to open the `config.py` file in an editor +- New `:version` command which opens `qute://version`. Changed ~~~~~~~ diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 3056da700..5ba7fae89 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -91,6 +91,7 @@ It is possible to run or bind multiple commands by separating them with `;;`. |<>|Switch to the previous tab, or switch [count] tabs back. |<>|Unbind a keychain. |<>|Re-open a closed tab. +|<>|Show version information. |<>|Show the source of the current page in a new tab. |<>|Close all windows except for the current one. |<>|Yank something to the clipboard or primary selection. @@ -1015,6 +1016,10 @@ Unbind a keychain. === undo Re-open a closed tab. +[[version]] +=== version +Show version information. + [[view-source]] === view-source Show the source of the current page in a new tab. diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index 5c85aae10..bf1e94586 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -342,3 +342,12 @@ def window_only(current_win_id): def nop(): """Do nothing.""" return + + +@cmdutils.register() +@cmdutils.argument('win_id', win_id=True) +def version(win_id): + """Show version information.""" + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + tabbed_browser.openurl(QUrl('qute://version'), newtab=True) diff --git a/tests/unit/misc/test_utilcmds.py b/tests/unit/misc/test_utilcmds.py index dfb99115d..9c679774a 100644 --- a/tests/unit/misc/test_utilcmds.py +++ b/tests/unit/misc/test_utilcmds.py @@ -25,10 +25,11 @@ import signal import time import pytest +from PyQt5.QtCore import QUrl from qutebrowser.misc import utilcmds from qutebrowser.commands import cmdexc -from qutebrowser.utils import utils +from qutebrowser.utils import utils, objreg @contextlib.contextmanager @@ -142,3 +143,16 @@ def test_window_only(mocker, monkeypatch): assert not test_windows[0].closed assert not test_windows[1].closed assert test_windows[2].closed + + +@pytest.fixture +def tabbed_browser(stubs, win_registry): + tb = stubs.TabbedBrowserStub() + objreg.register('tabbed-browser', tb, scope='window', window=0) + yield tb + objreg.delete('tabbed-browser', scope='window', window=0) + + +def test_version(tabbed_browser): + utilcmds.version(win_id=0) + assert tabbed_browser.opened_url == QUrl('qute://version') From 7cbb2b079f1ff6ace68517f379a74982457e3caa Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 06:30:39 +0200 Subject: [PATCH 173/186] Use existing tabbed_browser_stubs fixture in tests --- tests/unit/browser/test_signalfilter.py | 36 +++++++++---------- tests/unit/commands/test_argparser.py | 11 ++---- tests/unit/config/test_configcommands.py | 11 ++---- .../mainwindow/statusbar/test_backforward.py | 8 ++--- tests/unit/misc/test_sessions.py | 15 ++------ 5 files changed, 27 insertions(+), 54 deletions(-) diff --git a/tests/unit/browser/test_signalfilter.py b/tests/unit/browser/test_signalfilter.py index 9d191e7c6..66b6bd57c 100644 --- a/tests/unit/browser/test_signalfilter.py +++ b/tests/unit/browser/test_signalfilter.py @@ -66,18 +66,11 @@ def objects(): return Objects(signal_filter=signal_filter, signaller=signaller) -@pytest.fixture -def tabbed_browser(stubs, win_registry): - tb = stubs.TabbedBrowserStub() - objreg.register('tabbed-browser', tb, scope='window', window=0) - yield tb - objreg.delete('tabbed-browser', scope='window', window=0) - - @pytest.mark.parametrize('index_of, emitted', [(0, True), (1, False)]) -def test_filtering(objects, tabbed_browser, index_of, emitted): - tabbed_browser.current_index = 0 - tabbed_browser.index_of = index_of +def test_filtering(objects, tabbed_browser_stubs, index_of, emitted): + browser = tabbed_browser_stubs[0] + browser.current_index = 0 + browser.index_of = index_of objects.signaller.signal.emit('foo') if emitted: assert objects.signaller.filtered_signal_arg == 'foo' @@ -86,9 +79,10 @@ def test_filtering(objects, tabbed_browser, index_of, emitted): @pytest.mark.parametrize('index_of, verb', [(0, 'emitting'), (1, 'ignoring')]) -def test_logging(caplog, objects, tabbed_browser, index_of, verb): - tabbed_browser.current_index = 0 - tabbed_browser.index_of = index_of +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 with caplog.at_level(logging.DEBUG, logger='signals'): objects.signaller.signal.emit('foo') @@ -99,9 +93,10 @@ def test_logging(caplog, objects, tabbed_browser, index_of, verb): @pytest.mark.parametrize('index_of', [0, 1]) -def test_no_logging(caplog, objects, tabbed_browser, index_of): - tabbed_browser.current_index = 0 - tabbed_browser.index_of = index_of +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 with caplog.at_level(logging.DEBUG, logger='signals'): objects.signaller.link_hovered.emit('foo') @@ -109,9 +104,10 @@ def test_no_logging(caplog, objects, tabbed_browser, index_of): assert not caplog.records -def test_runtime_error(objects, tabbed_browser): +def test_runtime_error(objects, tabbed_browser_stubs): """Test that there's no crash if indexOf() raises RuntimeError.""" - tabbed_browser.current_index = 0 - tabbed_browser.index_of = RuntimeError + browser = tabbed_browser_stubs[0] + browser.current_index = 0 + browser.index_of = RuntimeError objects.signaller.signal.emit('foo') assert objects.signaller.filtered_signal_arg is None diff --git a/tests/unit/commands/test_argparser.py b/tests/unit/commands/test_argparser.py index b44fd5dce..2ca6adede 100644 --- a/tests/unit/commands/test_argparser.py +++ b/tests/unit/commands/test_argparser.py @@ -37,13 +37,6 @@ class TestArgumentParser: def parser(self): return argparser.ArgumentParser('foo') - @pytest.fixture - def tabbed_browser(self, stubs, win_registry): - tb = stubs.TabbedBrowserStub() - objreg.register('tabbed-browser', tb, scope='window', window=0) - yield tb - objreg.delete('tabbed-browser', scope='window', window=0) - def test_name(self, parser): assert parser.name == 'foo' @@ -60,14 +53,14 @@ class TestArgumentParser: match="Unrecognized arguments: --foo"): parser.parse_args(['--foo']) - def test_help(self, parser, tabbed_browser): + def test_help(self, parser, tabbed_browser_stubs): parser.add_argument('--help', action=argparser.HelpAction, nargs=0) with pytest.raises(argparser.ArgumentParserExit): parser.parse_args(['--help']) expected_url = QUrl('qute://help/commands.html#foo') - assert tabbed_browser.opened_url == expected_url + assert tabbed_browser_stubs[1].opened_url == expected_url @pytest.mark.parametrize('types, value, expected', [ diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 0f12dd843..a0b646893 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -38,19 +38,12 @@ class TestSet: """Tests for :set.""" - @pytest.fixture - def tabbed_browser(self, qapp, stubs, win_registry): - tb = stubs.TabbedBrowserStub() - objreg.register('tabbed-browser', tb, scope='window', window=0) - yield tb - objreg.delete('tabbed-browser', scope='window', window=0) - - def test_set_no_args(self, commands, tabbed_browser): + def test_set_no_args(self, commands, tabbed_browser_stubs): """Run ':set'. Should open qute://settings.""" commands.set(win_id=0) - assert tabbed_browser.opened_url == QUrl('qute://settings') + assert tabbed_browser_stubs[0].opened_url == QUrl('qute://settings') def test_get(self, config_stub, commands, message_mock): """Run ':set url.auto_search?'. diff --git a/tests/unit/mainwindow/statusbar/test_backforward.py b/tests/unit/mainwindow/statusbar/test_backforward.py index f2dec3d3f..6a3df1947 100644 --- a/tests/unit/mainwindow/statusbar/test_backforward.py +++ b/tests/unit/mainwindow/statusbar/test_backforward.py @@ -37,12 +37,12 @@ def backforward_widget(qtbot): (False, True, '[>]'), (True, True, '[<>]'), ]) -def test_backforward_widget(backforward_widget, stubs, +def test_backforward_widget(backforward_widget, tabbed_browser_stubs, fake_web_tab, can_go_back, can_go_forward, expected_text): """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 = stubs.TabbedBrowserStub() + tabbed_browser = tabbed_browser_stubs[0] tabbed_browser.current_index = 1 tabbed_browser.tabs = [tab] backforward_widget.on_tab_cur_url_changed(tabbed_browser) @@ -58,10 +58,10 @@ def test_backforward_widget(backforward_widget, stubs, assert not backforward_widget.isVisible() -def test_none_tab(backforward_widget, stubs, fake_web_tab): +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 = stubs.TabbedBrowserStub() + tabbed_browser = tabbed_browser_stubs[0] tabbed_browser.current_index = 1 tabbed_browser.tabs = [tab] backforward_widget.on_tab_cur_url_changed(tabbed_browser) diff --git a/tests/unit/misc/test_sessions.py b/tests/unit/misc/test_sessions.py index 3a387d0dc..771430d5b 100644 --- a/tests/unit/misc/test_sessions.py +++ b/tests/unit/misc/test_sessions.py @@ -138,20 +138,12 @@ class FakeMainWindow(QObject): @pytest.fixture -def fake_window(win_registry, stubs, monkeypatch, qtbot): +def fake_window(tabbed_browser_stubs): """Fixture which provides a fake main windows with a tabbedbrowser.""" win0 = FakeMainWindow(b'fake-geometry-0', win_id=0) objreg.register('main-window', win0, scope='window', window=0) - - webview = QWebView() - qtbot.add_widget(webview) - browser = stubs.TabbedBrowserStub([webview]) - objreg.register('tabbed-browser', browser, scope='window', window=0) - yield - objreg.delete('main-window', scope='window', window=0) - objreg.delete('tabbed-browser', scope='window', window=0) class TestSaveAll: @@ -192,13 +184,12 @@ class TestSave: return state @pytest.fixture - def fake_history(self, win_registry, stubs, monkeypatch, webview): + def fake_history(self, stubs, tabbed_browser_stubs, monkeypatch, webview): """Fixture which provides a window with a fake history.""" win = FakeMainWindow(b'fake-geometry-0', win_id=0) objreg.register('main-window', win, scope='window', window=0) - browser = stubs.TabbedBrowserStub([webview]) - objreg.register('tabbed-browser', browser, scope='window', window=0) + browser = tabbed_browser_stubs[0] qapp = stubs.FakeQApplication(active_window=win) monkeypatch.setattr(sessions, 'QApplication', qapp) From ae4d5153b923ead3a5724c5bcc60c5cde882f7b3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 06:45:27 +0200 Subject: [PATCH 174/186] Add missing docstring --- qutebrowser/misc/keyhintwidget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 5626ce4ae..ce1f324e4 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -98,6 +98,7 @@ class KeyHintView(QLabel): for glob in config.val.keyhint.blacklist) def takes_count(cmdstr): + """Return true iff this command can take a count argument.""" cmdname = cmdstr.split(' ')[0] cmd = cmdutils.cmd_dict.get(cmdname) return cmd and cmd.takes_count() From 9bba3ddf0d21b4053af47e8414335bd74cb8bb3b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 06:46:14 +0200 Subject: [PATCH 175/186] Update changelog --- doc/changelog.asciidoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index cedd7ff70..4733435bb 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -87,6 +87,8 @@ Fixes restores the correct previous window state (maximized/fullscreen). - When `input.insert_mode.auto_load` is set, background tabs now don't enter insert mode anymore. +- The keybinding help widget now works correctly when using keybindings with a + count. v0.11.1 (unreleased) -------------------- From 208b4d1cbc71dadc3355c79c97159d4f4ce030f6 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 08:47:10 +0200 Subject: [PATCH 176/186] Make configfiles.YamlConfig iteration deterministic --- 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 3a411f658..e1c6f720c 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -109,7 +109,7 @@ class YamlConfig(QObject): return name in self._values def __iter__(self): - return iter(self._values.items()) + return iter(sorted(self._values.items())) def _mark_changed(self): """Mark the YAML config as changed.""" diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 99098f26e..baa53408e 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -161,7 +161,7 @@ class TestYaml: def test_iter(self, yaml): yaml['foo'] = 23 yaml['bar'] = 42 - assert list(iter(yaml)) == [('foo', 23), ('bar', 42)] + assert list(iter(yaml)) == [('bar', 42), ('foo', 23)] @pytest.mark.parametrize('old_config', [ None, From 37fa7a0d3e9803c84e38611c7205930618655196 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 08:47:33 +0200 Subject: [PATCH 177/186] Fix casing in test name --- 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 baa53408e..6233369f7 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -111,7 +111,7 @@ class TestYaml: if insert: assert ' tabs.show: never' in lines - def test_init_save_Manager(self, yaml, fake_save_manager): + 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) From 969b8f3200104bfce891135a486ed4e66f86d85d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 08:55:33 +0200 Subject: [PATCH 178/186] Fix test_configcommands on Python 3.5 looks like assert_called_once() was introduced in 3.6 --- tests/unit/config/test_configcommands.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index a0b646893..c4b188fb8 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 unittest.mock import pytest from PyQt5.QtCore import QUrl, QProcess @@ -305,7 +306,7 @@ class TestEdit: mock = mocker.patch('qutebrowser.config.configcommands.editor.' 'ExternalEditor._start_editor', autospec=True) commands.config_edit(no_source=True) - mock.assert_called_once() + mock.assert_called_once_with(unittest.mock.ANY) @pytest.fixture def patch_editor(self, mocker, config_tmpdir, data_tmpdir): @@ -328,7 +329,7 @@ class TestEdit: commands.config_edit() - mock.assert_called_once() + mock.assert_called_once_with(unittest.mock.any) assert not config_stub.val.content.javascript.enabled def test_error(self, commands, config_stub, patch_editor, message_mock, From 6037d44d0e509fac364f7be0cd045f94d6b0c539 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 08:56:32 +0200 Subject: [PATCH 179/186] Remove unused imports --- tests/unit/browser/test_signalfilter.py | 1 - tests/unit/commands/test_argparser.py | 2 +- tests/unit/config/test_configcommands.py | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/browser/test_signalfilter.py b/tests/unit/browser/test_signalfilter.py index 66b6bd57c..fb4ed474c 100644 --- a/tests/unit/browser/test_signalfilter.py +++ b/tests/unit/browser/test_signalfilter.py @@ -26,7 +26,6 @@ import pytest from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject from qutebrowser.browser import signalfilter -from qutebrowser.utils import objreg class Signaller(QObject): diff --git a/tests/unit/commands/test_argparser.py b/tests/unit/commands/test_argparser.py index 2ca6adede..4cd3e7ee1 100644 --- a/tests/unit/commands/test_argparser.py +++ b/tests/unit/commands/test_argparser.py @@ -25,7 +25,7 @@ import pytest from PyQt5.QtCore import QUrl from qutebrowser.commands import argparser, cmdexc -from qutebrowser.utils import usertypes, objreg +from qutebrowser.utils import usertypes Enum = usertypes.enum('Enum', ['foo', 'foo_bar']) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index c4b188fb8..9c6a9e460 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -26,7 +26,7 @@ from PyQt5.QtCore import QUrl, QProcess from qutebrowser.config import configcommands from qutebrowser.commands import cmdexc -from qutebrowser.utils import objreg, usertypes +from qutebrowser.utils import usertypes from qutebrowser.misc import objects @@ -329,7 +329,7 @@ class TestEdit: commands.config_edit() - mock.assert_called_once_with(unittest.mock.any) + mock.assert_called_once_with(unittest.mock.ANY) assert not config_stub.val.content.javascript.enabled def test_error(self, commands, config_stub, patch_editor, message_mock, From cc871389c9d9d00f5375de99cb0d583968f8e953 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 08:58:35 +0200 Subject: [PATCH 180/186] Increase pytest-faulthandler timeout a bit See #2777 --- pytest.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest.ini b/pytest.ini index b853c8ca8..c35328cd6 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -addopts = --strict -rfEw --faulthandler-timeout=70 --instafail --pythonwarnings error --benchmark-columns=Min,Max,Median +addopts = --strict -rfEw --faulthandler-timeout=90 --instafail --pythonwarnings error --benchmark-columns=Min,Max,Median testpaths = tests markers = gui: Tests using the GUI (e.g. spawning widgets) From 38270de12017f6e30cd781515638c1d53255991c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 09:01:31 +0200 Subject: [PATCH 181/186] Avoid configdata init in test_configinit See #2777 --- tests/unit/config/test_configinit.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index 4a8c05dae..3a1fe8d13 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -26,7 +26,8 @@ import unittest.mock import pytest from qutebrowser import qutebrowser -from qutebrowser.config import config, configexc, configfiles, configinit +from qutebrowser.config import (config, configexc, configfiles, configinit, + configdata) from qutebrowser.utils import objreg, usertypes @@ -52,6 +53,14 @@ def args(fake_args): return fake_args +@pytest.fixture(autouse=True) +def configdata_init(monkeypatch): + """Make sure configdata is init'ed and no test re-init's it.""" + if not configdata.DATA: + configdata.init() + monkeypatch.setattr(configdata, 'init', lambda: None) + + class TestEarlyInit: @pytest.mark.parametrize('config_py', [True, 'error', False]) From da4402e98c41201075a0ed9d24c3ece5ff8492b3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 09:02:34 +0200 Subject: [PATCH 182/186] Don't rely on order in test_clear --- tests/unit/config/test_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index a0877cc23..a46e8e3de 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -319,8 +319,8 @@ class TestConfig: with qtbot.waitSignals([conf.changed, conf.changed]) as blocker: conf.clear(save_yaml=save_yaml) - options = [e.args[0] for e in blocker.all_signals_and_args] - assert options == [name1, name2] + options = {e.args[0] for e in blocker.all_signals_and_args} + assert options == {name1, name2} if save_yaml: assert name1 not in conf._yaml From 3b689166f8cbdd456d9130791f51ff9b50a43f09 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 09:04:24 +0200 Subject: [PATCH 183/186] Skip flaky :buffer test entirely --- tests/end2end/features/tabs.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index 02954dbcf..b510a4f66 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -953,7 +953,7 @@ Feature: Tab management And I run :buffer "99/1" Then the error "There's no window with id 99!" should be shown - @qtwebengine_flaky + @skip # Too flaky Scenario: :buffer with matching window index Given I have a fresh instance When I open data/title.html From 96ff0c61ef93a549267b838a51c133925a598341 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 09:08:59 +0200 Subject: [PATCH 184/186] Try to stabilize :completion-item-del for :buffer test --- tests/end2end/features/completion.feature | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/end2end/features/completion.feature b/tests/end2end/features/completion.feature index d78d170c6..153c53d71 100644 --- a/tests/end2end/features/completion.feature +++ b/tests/end2end/features/completion.feature @@ -76,7 +76,9 @@ Feature: Using completion And I open data/hello2.txt in a new tab And I run :set-cmd-text -s :buffer And I run :completion-item-focus next + And I wait for "setting text = ':buffer 0/1', *" in the log And I run :completion-item-focus next + And I wait for "setting text = ':buffer 0/2', *" in the log And I run :completion-item-del Then the following tabs should be open: - data/hello.txt (active) From f18b730f24a27907c3d37715eb3e29edf8455f7f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 09:36:08 +0200 Subject: [PATCH 185/186] Update changelog --- doc/changelog.asciidoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 4733435bb..91b8efb66 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -79,6 +79,8 @@ Changed `` doesn't un-focus inputs anymore. - Pinned tabs now adjust to their text's width, so the `tabs.width.pinned` setting got removed. +- `:set-cmd-text` now has a `--run-on-count` argument to run the underlying + command directly if a count was given. Fixes ~~~~~ From bae49c9366f139f33a8009b1d1db2408c975e3c0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 4 Oct 2017 11:49:04 +0200 Subject: [PATCH 186/186] Update Windows install instructions --- doc/install.asciidoc | 3 --- 1 file changed, 3 deletions(-) diff --git a/doc/install.asciidoc b/doc/install.asciidoc index 08f80903f..07892c876 100644 --- a/doc/install.asciidoc +++ b/doc/install.asciidoc @@ -264,9 +264,6 @@ Manual install * Use the installer from http://www.python.org/downloads[python.org] to get Python 3 (be sure to install pip). -* Use the installer from -http://www.riverbankcomputing.com/software/pyqt/download5[Riverbank computing] -to get Qt and PyQt5. * Install https://testrun.org/tox/latest/index.html[tox] via https://pip.pypa.io/en/latest/[pip]: