Merge branch 'per-url'

This commit is contained in:
Florian Bruhin 2018-02-25 19:44:51 +01:00
commit 52b5492c6a
43 changed files with 2827 additions and 966 deletions

View File

@ -274,7 +274,8 @@ Set all settings back to their default.
[[config-cycle]]
=== config-cycle
Syntax: +:config-cycle [*--temp*] [*--print*] 'option' ['values' ['values' ...]]+
Syntax: +:config-cycle [*--pattern* 'pattern'] [*--temp*] [*--print*]
'option' ['values' ['values' ...]]+
Cycle an option between multiple values.
@ -283,6 +284,7 @@ Cycle an option between multiple values.
* +'values'+: The values to cycle through.
==== optional arguments
* +*-u*+, +*--pattern*+: The URL pattern to use.
* +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed.
* +*-p*+, +*--print*+: Print the value after setting.
@ -1110,7 +1112,7 @@ Save a session.
[[set]]
=== set
Syntax: +:set [*--temp*] [*--print*] ['option'] ['value']+
Syntax: +:set [*--temp*] [*--print*] [*--pattern* 'pattern'] ['option'] ['value']+
Set an option.
@ -1123,6 +1125,7 @@ If the option name ends with '?', the value of the option is shown instead. Usin
==== optional arguments
* +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed.
* +*-p*+, +*--print*+: Print the value after setting.
* +*-u*+, +*--pattern*+: The URL pattern to use.
[[set-cmd-text]]
=== set-cmd-text

View File

@ -63,6 +63,10 @@ customizable.
Using the link:commands.html#set[`:set`] command and command completion, you
can quickly set settings interactively, for example `:set tabs.position left`.
Some settings are also customizable for a given
https://developer.chrome.com/apps/match_patterns[URL pattern] by doing e.g.
`:set --pattern=*://example.com/ content.images false`.
To get more help about a setting, use e.g. `:help tabs.position`.
To bind and unbind keys, you can use the link:commands.html#bind[`:bind`] and
@ -147,7 +151,6 @@ prefix to preserve backslashes) or a Python regex object:
If you want to read a setting, you can use the `c` object to do so as well:
`c.colors.tabs.even.bg = c.colors.tabs.odd.bg`.
Using strings for setting names
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -171,6 +174,26 @@ To read a setting, use the `config.get` method:
color = config.get('colors.completion.fg')
----
Per-domain settings
~~~~~~~~~~~~~~~~~~~
Using `config.set`, some settings are also customizable for a given
https://developer.chrome.com/apps/match_patterns[URL pattern]:
[source,python]
----
config.set('content.images', False, '*://example.com/')
----
Alternatively, you can use `with config.pattern(...) as p:` to get a shortcut
similar to `c.` which is scoped to the given domain:
[source,python]
----
with config.pattern('*://example.com/') as p:
p.content.images = False
----
Binding keys
~~~~~~~~~~~~

View File

@ -582,8 +582,14 @@ Default:
* +pass:[sk]+: +pass:[set-cmd-text -s :bind]+
* +pass:[sl]+: +pass:[set-cmd-text -s :set -t]+
* +pass:[ss]+: +pass:[set-cmd-text -s :set]+
* +pass:[tSH]+: +pass:[config-cycle -p -u *://*.{url:host}/* content.javascript.enabled ;; reload]+
* +pass:[tSh]+: +pass:[config-cycle -p -u *://{url:host}/* content.javascript.enabled ;; reload]+
* +pass:[tSu]+: +pass:[config-cycle -p -u {url} content.javascript.enabled ;; reload]+
* +pass:[th]+: +pass:[back -t]+
* +pass:[tl]+: +pass:[forward -t]+
* +pass:[tsH]+: +pass:[config-cycle -p -t -u *://*.{url:host}/* content.javascript.enabled ;; reload]+
* +pass:[tsh]+: +pass:[config-cycle -p -t -u *://{url:host}/* content.javascript.enabled ;; reload]+
* +pass:[tsu]+: +pass:[config-cycle -p -t -u {url} content.javascript.enabled ;; reload]+
* +pass:[u]+: +pass:[undo]+
* +pass:[v]+: +pass:[enter-mode caret]+
* +pass:[wB]+: +pass:[set-cmd-text -s :bookmark-load -w]+
@ -1447,6 +1453,8 @@ Default:
Enable support for the HTML 5 web application cache feature.
An application cache acts like an HTTP cache in some sense. For documents that use the application cache via JavaScript, the loader engine will first ask the application cache for the contents, before hitting the network.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[true]+
@ -1524,6 +1532,8 @@ This setting is only available with the QtWebKit backend.
=== content.dns_prefetch
Try to pre-fetch DNS entries to speed up browsing.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[true]+
@ -1535,6 +1545,8 @@ This setting is only available with the QtWebKit backend.
Expand each subframe to its contents.
This will flatten all the frames to become one scrollable page.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[false]+
@ -1651,6 +1663,8 @@ Default:
=== content.hyperlink_auditing
Enable hyperlink auditing (`<a ping>`).
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[false]+
@ -1659,6 +1673,8 @@ Default: +pass:[false]+
=== content.images
Load images automatically in web pages.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[true]+
@ -1676,6 +1692,8 @@ Default: +pass:[true]+
Allow JavaScript to read from or write to the clipboard.
With QtWebEngine, writing the clipboard as response to a user interaction is always allowed.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[false]+
@ -1684,6 +1702,8 @@ Default: +pass:[false]+
=== content.javascript.can_close_tabs
Allow JavaScript to close tabs.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[false]+
@ -1694,6 +1714,8 @@ This setting is only available with the QtWebKit backend.
=== content.javascript.can_open_tabs_automatically
Allow JavaScript to open new tabs without user interaction.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[false]+
@ -1702,6 +1724,8 @@ Default: +pass:[false]+
=== content.javascript.enabled
Enable JavaScript.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[true]+
@ -1741,6 +1765,8 @@ Default: +pass:[true]+
=== content.local_content_can_access_file_urls
Allow locally loaded documents to access other local URLs.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[true]+
@ -1749,6 +1775,8 @@ Default: +pass:[true]+
=== content.local_content_can_access_remote_urls
Allow locally loaded documents to access remote URLs.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[false]+
@ -1757,6 +1785,8 @@ Default: +pass:[false]+
=== content.local_storage
Enable support for HTML 5 local storage and Web SQL.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[true]+
@ -1817,6 +1847,8 @@ This setting is only available with the QtWebKit backend.
=== content.plugins
Enable plugins in Web pages.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[false]+
@ -1825,6 +1857,8 @@ Default: +pass:[false]+
=== content.print_element_backgrounds
Draw the background color and images also when the page is printed.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[true]+
@ -1889,6 +1923,8 @@ Default: empty
=== content.webgl
Enable WebGL.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[true]+
@ -1906,6 +1942,8 @@ Default: +pass:[false]+
Monitor load requests for cross-site scripting attempts.
Suspicious scripts will be blocked and reported in the inspector's JavaScript console. Enabling this feature might have an impact on performance.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[false]+
@ -2379,6 +2417,8 @@ Default: +pass:[false]+
=== input.links_included_in_focus_chain
Include hyperlinks in the keyboard focus chain when tabbing.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[true]+
@ -2406,6 +2446,8 @@ Default: +pass:[false]+
Enable spatial navigation.
Spatial navigation consists in the ability to navigate between focusable elements in a Web page, such as hyperlinks and form controls, by using Left, Right, Up and Down arrow keys. For example, if the user presses the Right key, heuristics determine whether there is an element he might be trying to reach towards the right and which element he probably wants.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[false]+
@ -2550,6 +2592,8 @@ Default: +pass:[false]+
Enable smooth scrolling for web pages.
Note smooth scrolling does not work with the `:scroll-px` command.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[false]+
@ -3137,6 +3181,8 @@ Default: +pass:[512]+
=== zoom.text_only
Apply the zoom factor on a frame only to the text or to all content.
This setting supports URL patterns.
Type: <<types,Bool>>
Default: +pass:[false]+

View File

@ -30,7 +30,8 @@ from PyQt5.QtWidgets import QWidget, QApplication
from qutebrowser.keyinput import modeman
from qutebrowser.config import config
from qutebrowser.utils import utils, objreg, usertypes, log, qtutils
from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils,
urlutils, message)
from qutebrowser.misc import miscwidgets, objects
from qutebrowser.browser import mouse, hints
@ -94,6 +95,8 @@ class TabData:
keep_icon: Whether the (e.g. cloned) icon should not be cleared on page
load.
inspector: The QWebInspector used for this webview.
open_target: Where to open the next link.
Only used for QtWebKit.
override_target: Override for open_target for fake clicks (like hints).
Only used for QtWebKit.
pinned: Flag to pin the tab.
@ -104,6 +107,7 @@ class TabData:
keep_icon = attr.ib(False)
inspector = attr.ib(None)
open_target = attr.ib(usertypes.ClickTarget.normal)
override_target = attr.ib(None)
pinned = attr.ib(False)
fullscreen = attr.ib(False)
@ -612,6 +616,7 @@ class AbstractTab(QWidget):
process terminated.
arg 0: A TerminationStatus member.
arg 1: The exit code.
predicted_navigation: Emitted before we tell Qt to open a URL.
"""
window_close_requested = pyqtSignal()
@ -629,6 +634,7 @@ class AbstractTab(QWidget):
add_history_item = pyqtSignal(QUrl, QUrl, str) # url, requested url, title
fullscreen_requested = pyqtSignal(bool)
renderer_process_terminated = pyqtSignal(TerminationStatus, int)
predicted_navigation = pyqtSignal(QUrl)
def __init__(self, *, win_id, mode_manager, private, parent=None):
self.private = private
@ -659,6 +665,9 @@ class AbstractTab(QWidget):
objreg.register('hintmanager', hintmanager, scope='tab',
window=self.win_id, tab=self.tab_id)
self.predicted_navigation.connect(
lambda url: self.title_changed.emit(url.toDisplayString()))
def _set_widget(self, widget):
# pylint: disable=protected-access
self._widget = widget
@ -671,6 +680,7 @@ class AbstractTab(QWidget):
self.printing._widget = widget
self.action._widget = widget
self.elements._widget = widget
self.settings._settings = widget.settings()
self._install_event_filter()
self.zoom.set_default()
@ -719,6 +729,22 @@ class AbstractTab(QWidget):
self._set_load_status(usertypes.LoadStatus.loading)
self.load_started.emit()
@pyqtSlot(usertypes.NavigationRequest)
def _on_navigation_request(self, navigation):
"""Handle common acceptNavigationRequest code."""
log.webview.debug("navigation request: url {}, type {}, is_main_frame "
"{}".format(navigation.url.toDisplayString(),
navigation.navigation_type,
navigation.is_main_frame))
if (navigation.navigation_type == navigation.Type.link_clicked and
not navigation.url.isValid()):
msg = urlutils.get_errstring(navigation.url,
"Invalid link clicked")
message.error(msg)
self.data.open_target = usertypes.ClickTarget.normal
navigation.accepted = False
def handle_auto_insert_mode(self, ok):
"""Handle `input.insert_mode.auto_load` after loading finished."""
if not config.val.input.insert_mode.auto_load or not ok:
@ -790,7 +816,7 @@ class AbstractTab(QWidget):
def _openurl_prepare(self, url):
qtutils.ensure_valid(url)
self.title_changed.emit(url.toDisplayString())
self.predicted_navigation.emit(url)
def openurl(self, url):
raise NotImplementedError

View File

@ -17,9 +17,6 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
# We get various "abstract but not overridden" warnings
# pylint: disable=abstract-method
"""Bridge from QWebEngineSettings to our own settings.
Module attributes:
@ -44,116 +41,143 @@ from qutebrowser.utils import (utils, standarddir, javascript, qtutils,
default_profile = None
# The QWebEngineProfile used for private (off-the-record) windows
private_profile = None
# The global WebEngineSettings object
global_settings = None
class Base(websettings.Base):
class _SettingsWrapper:
"""Base settings class with appropriate _get_global_settings."""
"""Expose a QWebEngineSettings interface which acts on all profiles.
def _get_global_settings(self):
return [default_profile.settings(), private_profile.settings()]
class Attribute(Base, websettings.Attribute):
"""A setting set via QWebEngineSettings::setAttribute."""
ENUM_BASE = QWebEngineSettings
class Setter(Base, websettings.Setter):
"""A setting set via a QWebEngineSettings setter method."""
pass
class FontFamilySetter(Base, websettings.FontFamilySetter):
"""A setter for a font family.
Gets the default value from QFont.
For read operations, the default profile value is always used.
"""
def __init__(self, font):
# Mapping from WebEngineSettings::initDefaults in
# qtwebengine/src/core/web_engine_settings.cpp
font_to_qfont = {
QWebEngineSettings.StandardFont: QFont.Serif,
QWebEngineSettings.FixedFont: QFont.Monospace,
QWebEngineSettings.SerifFont: QFont.Serif,
QWebEngineSettings.SansSerifFont: QFont.SansSerif,
QWebEngineSettings.CursiveFont: QFont.Cursive,
QWebEngineSettings.FantasyFont: QFont.Fantasy,
def __init__(self):
self._settings = [default_profile.settings(),
private_profile.settings()]
def setAttribute(self, *args, **kwargs):
for settings in self._settings:
settings.setAttribute(*args, **kwargs)
def setFontFamily(self, *args, **kwargs):
for settings in self._settings:
settings.setFontFamily(*args, **kwargs)
def setFontSize(self, *args, **kwargs):
for settings in self._settings:
settings.setFontSize(*args, **kwargs)
def setDefaultTextEncoding(self, *args, **kwargs):
for settings in self._settings:
settings.setDefaultTextEncoding(*args, **kwargs)
def testAttribute(self, *args, **kwargs):
return self._settings[0].testAttribute(*args, **kwargs)
def fontSize(self, *args, **kwargs):
return self._settings[0].fontSize(*args, **kwargs)
def fontFamily(self, *args, **kwargs):
return self._settings[0].fontFamily(*args, **kwargs)
def defaultTextEncoding(self, *args, **kwargs):
return self._settings[0].defaultTextEncoding(*args, **kwargs)
class WebEngineSettings(websettings.AbstractSettings):
"""A wrapper for the config for QWebEngineSettings."""
_ATTRIBUTES = {
'content.xss_auditing':
[QWebEngineSettings.XSSAuditingEnabled],
'content.images':
[QWebEngineSettings.AutoLoadImages],
'content.javascript.enabled':
[QWebEngineSettings.JavascriptEnabled],
'content.javascript.can_open_tabs_automatically':
[QWebEngineSettings.JavascriptCanOpenWindows],
'content.javascript.can_access_clipboard':
[QWebEngineSettings.JavascriptCanAccessClipboard],
'content.plugins':
[QWebEngineSettings.PluginsEnabled],
'content.hyperlink_auditing':
[QWebEngineSettings.HyperlinkAuditingEnabled],
'content.local_content_can_access_remote_urls':
[QWebEngineSettings.LocalContentCanAccessRemoteUrls],
'content.local_content_can_access_file_urls':
[QWebEngineSettings.LocalContentCanAccessFileUrls],
'content.webgl':
[QWebEngineSettings.WebGLEnabled],
'content.local_storage':
[QWebEngineSettings.LocalStorageEnabled],
'input.spatial_navigation':
[QWebEngineSettings.SpatialNavigationEnabled],
'input.links_included_in_focus_chain':
[QWebEngineSettings.LinksIncludedInFocusChain],
'scrolling.smooth':
[QWebEngineSettings.ScrollAnimatorEnabled],
# Missing QtWebEngine attributes:
# - ScreenCaptureEnabled
# - Accelerated2dCanvasEnabled
# - AutoLoadIconsForPage
# - TouchIconsEnabled
# - FocusOnNavigationEnabled (5.8)
# - AllowRunningInsecureContent (5.8)
}
_FONT_SIZES = {
'fonts.web.size.minimum':
QWebEngineSettings.MinimumFontSize,
'fonts.web.size.minimum_logical':
QWebEngineSettings.MinimumLogicalFontSize,
'fonts.web.size.default':
QWebEngineSettings.DefaultFontSize,
'fonts.web.size.default_fixed':
QWebEngineSettings.DefaultFixedFontSize,
}
_FONT_FAMILIES = {
'fonts.web.family.standard': QWebEngineSettings.StandardFont,
'fonts.web.family.fixed': QWebEngineSettings.FixedFont,
'fonts.web.family.serif': QWebEngineSettings.SerifFont,
'fonts.web.family.sans_serif': QWebEngineSettings.SansSerifFont,
'fonts.web.family.cursive': QWebEngineSettings.CursiveFont,
'fonts.web.family.fantasy': QWebEngineSettings.FantasyFont,
# Missing QtWebEngine fonts:
# - PictographFont
}
# Mapping from WebEngineSettings::initDefaults in
# qtwebengine/src/core/web_engine_settings.cpp
_FONT_TO_QFONT = {
QWebEngineSettings.StandardFont: QFont.Serif,
QWebEngineSettings.FixedFont: QFont.Monospace,
QWebEngineSettings.SerifFont: QFont.Serif,
QWebEngineSettings.SansSerifFont: QFont.SansSerif,
QWebEngineSettings.CursiveFont: QFont.Cursive,
QWebEngineSettings.FantasyFont: QFont.Fantasy,
}
def __init__(self, settings):
super().__init__(settings)
# Attributes which don't exist in all Qt versions.
new_attributes = {
# Qt 5.8
'content.print_element_backgrounds': 'PrintElementBackgrounds',
}
super().__init__(setter=QWebEngineSettings.setFontFamily, font=font,
qfont=font_to_qfont[font])
for name, attribute in new_attributes.items():
try:
value = getattr(QWebEngineSettings, attribute)
except AttributeError:
continue
class DefaultProfileSetter(websettings.Base):
"""A setting set on the QWebEngineProfile."""
def __init__(self, setter, converter=None, default=websettings.UNSET):
super().__init__(default)
self._setter = setter
self._converter = converter
def __repr__(self):
return utils.get_repr(self, setter=self._setter, constructor=True)
def _set(self, value, settings=None):
if settings is not None:
raise ValueError("'settings' may not be set with "
"DefaultProfileSetters!")
setter = getattr(default_profile, self._setter)
if self._converter is not None:
value = self._converter(value)
setter(value)
class PersistentCookiePolicy(DefaultProfileSetter):
"""The content.cookies.store setting is different from other settings."""
def __init__(self):
super().__init__('setPersistentCookiesPolicy')
def _set(self, value, settings=None):
if settings is not None:
raise ValueError("'settings' may not be set with "
"PersistentCookiePolicy!")
setter = getattr(QWebEngineProfile.defaultProfile(), self._setter)
setter(
QWebEngineProfile.AllowPersistentCookies if value else
QWebEngineProfile.NoPersistentCookies
)
class DictionaryLanguageSetter(DefaultProfileSetter):
"""Sets paths to dictionary files based on language codes."""
def __init__(self):
super().__init__('setSpellCheckLanguages', default=[])
def _find_installed(self, code):
local_filename = spell.local_filename(code)
if not local_filename:
message.warning(
"Language {} is not installed - see scripts/dictcli.py "
"in qutebrowser's sources".format(code))
return local_filename
def _set(self, value, settings=None):
if settings is not None:
raise ValueError("'settings' may not be set with "
"DictionaryLanguageSetter!")
filenames = [self._find_installed(code) for code in value]
log.config.debug("Found dicts: {}".format(filenames))
super()._set([f for f in filenames if f], settings)
self._ATTRIBUTES[name] = [value]
def _init_stylesheet(profile):
@ -210,9 +234,47 @@ def _set_http_headers(profile):
profile.setHttpAcceptLanguage(accept_language)
def _set_http_cache_size(profile):
"""Initialize the HTTP cache size for the given profile."""
size = config.val.content.cache.size
if size is None:
size = 0
else:
size = qtutils.check_overflow(size, 'int', fatal=False)
# 0: automatically managed by QtWebEngine
profile.setHttpCacheMaximumSize(size)
def _set_persistent_cookie_policy(profile):
"""Set the HTTP Cookie size for the given profile."""
if config.val.content.cookies.store:
value = QWebEngineProfile.AllowPersistentCookies
else:
value = QWebEngineProfile.NoPersistentCookies
profile.setPersistentCookiesPolicy(value)
def _set_dictionary_language(profile):
filenames = []
for code in config.val.spellcheck.languages or []:
local_filename = spell.local_filename(code)
if not local_filename:
message.warning(
"Language {} is not installed - see scripts/dictcli.py "
"in qutebrowser's sources".format(code))
continue
filenames.append(local_filename)
log.config.debug("Found dicts: {}".format(filenames))
profile.setSpellCheckLanguages(filenames)
def _update_settings(option):
"""Update global settings when qwebsettings changed."""
websettings.update_mappings(MAPPINGS, option)
global_settings.update_setting(option)
if option in ['scrolling.bar', 'content.user_stylesheets']:
_init_stylesheet(default_profile)
_init_stylesheet(private_profile)
@ -221,27 +283,46 @@ def _update_settings(option):
'content.headers.accept_language']:
_set_http_headers(default_profile)
_set_http_headers(private_profile)
elif option == 'content.cache.size':
_set_http_cache_size(default_profile)
_set_http_cache_size(private_profile)
elif (option == 'content.cookies.store' and
# https://bugreports.qt.io/browse/QTBUG-58650
qtutils.version_check('5.9', compiled=False)):
_set_persistent_cookie_policy(default_profile)
# We're not touching the private profile's cookie policy.
elif option == 'spellcheck.languages' and qtutils.version_check('5.8'):
_set_dictionary_language(default_profile)
_set_dictionary_language(private_profile)
def _init_profile(profile):
"""Init the given profile."""
_init_stylesheet(profile)
_set_http_headers(profile)
_set_http_cache_size(profile)
profile.settings().setAttribute(
QWebEngineSettings.FullScreenSupportEnabled, True)
if qtutils.version_check('5.8'):
profile.setSpellCheckEnabled(True)
_set_dictionary_language(profile)
def _init_profiles():
"""Init the two used QWebEngineProfiles."""
global default_profile, private_profile
default_profile = QWebEngineProfile.defaultProfile()
default_profile.setCachePath(
os.path.join(standarddir.cache(), 'webengine'))
default_profile.setPersistentStoragePath(
os.path.join(standarddir.data(), 'webengine'))
_init_stylesheet(default_profile)
_set_http_headers(default_profile)
_init_profile(default_profile)
_set_persistent_cookie_policy(default_profile)
private_profile = QWebEngineProfile()
assert private_profile.isOffTheRecord()
_init_stylesheet(private_profile)
_set_http_headers(private_profile)
if qtutils.version_check('5.8'):
default_profile.setSpellCheckEnabled(True)
private_profile.setSpellCheckEnabled(True)
_init_profile(private_profile)
def inject_userscripts():
@ -287,111 +368,13 @@ def init(args):
os.environ['QTWEBENGINE_REMOTE_DEBUGGING'] = str(utils.random_port())
_init_profiles()
# We need to do this here as a WORKAROUND for
# https://bugreports.qt.io/browse/QTBUG-58650
if not qtutils.version_check('5.9', compiled=False):
PersistentCookiePolicy().set(config.val.content.cookies.store)
Attribute(QWebEngineSettings.FullScreenSupportEnabled).set(True)
websettings.init_mappings(MAPPINGS)
config.instance.changed.connect(_update_settings)
global global_settings
global_settings = WebEngineSettings(_SettingsWrapper())
global_settings.init_settings()
def shutdown():
# FIXME:qtwebengine do we need to do something for a clean shutdown here?
pass
# Missing QtWebEngine attributes:
# - ScreenCaptureEnabled
# - Accelerated2dCanvasEnabled
# - AutoLoadIconsForPage
# - TouchIconsEnabled
# - FocusOnNavigationEnabled (5.8)
# - AllowRunningInsecureContent (5.8)
#
# Missing QtWebEngine fonts:
# - PictographFont
MAPPINGS = {
'content.images':
Attribute(QWebEngineSettings.AutoLoadImages),
'content.javascript.enabled':
Attribute(QWebEngineSettings.JavascriptEnabled),
'content.javascript.can_open_tabs_automatically':
Attribute(QWebEngineSettings.JavascriptCanOpenWindows),
'content.javascript.can_access_clipboard':
Attribute(QWebEngineSettings.JavascriptCanAccessClipboard),
'content.plugins':
Attribute(QWebEngineSettings.PluginsEnabled),
'content.hyperlink_auditing':
Attribute(QWebEngineSettings.HyperlinkAuditingEnabled),
'content.local_content_can_access_remote_urls':
Attribute(QWebEngineSettings.LocalContentCanAccessRemoteUrls),
'content.local_content_can_access_file_urls':
Attribute(QWebEngineSettings.LocalContentCanAccessFileUrls),
'content.webgl':
Attribute(QWebEngineSettings.WebGLEnabled),
'content.local_storage':
Attribute(QWebEngineSettings.LocalStorageEnabled),
'content.cache.size':
# 0: automatically managed by QtWebEngine
DefaultProfileSetter('setHttpCacheMaximumSize', default=0,
converter=lambda val:
qtutils.check_overflow(val, 'int', fatal=False)),
'content.xss_auditing':
Attribute(QWebEngineSettings.XSSAuditingEnabled),
'content.default_encoding':
Setter(QWebEngineSettings.setDefaultTextEncoding),
'input.spatial_navigation':
Attribute(QWebEngineSettings.SpatialNavigationEnabled),
'input.links_included_in_focus_chain':
Attribute(QWebEngineSettings.LinksIncludedInFocusChain),
'fonts.web.family.standard':
FontFamilySetter(QWebEngineSettings.StandardFont),
'fonts.web.family.fixed':
FontFamilySetter(QWebEngineSettings.FixedFont),
'fonts.web.family.serif':
FontFamilySetter(QWebEngineSettings.SerifFont),
'fonts.web.family.sans_serif':
FontFamilySetter(QWebEngineSettings.SansSerifFont),
'fonts.web.family.cursive':
FontFamilySetter(QWebEngineSettings.CursiveFont),
'fonts.web.family.fantasy':
FontFamilySetter(QWebEngineSettings.FantasyFont),
'fonts.web.size.minimum':
Setter(QWebEngineSettings.setFontSize,
args=[QWebEngineSettings.MinimumFontSize]),
'fonts.web.size.minimum_logical':
Setter(QWebEngineSettings.setFontSize,
args=[QWebEngineSettings.MinimumLogicalFontSize]),
'fonts.web.size.default':
Setter(QWebEngineSettings.setFontSize,
args=[QWebEngineSettings.DefaultFontSize]),
'fonts.web.size.default_fixed':
Setter(QWebEngineSettings.setFontSize,
args=[QWebEngineSettings.DefaultFixedFontSize]),
'scrolling.smooth':
Attribute(QWebEngineSettings.ScrollAnimatorEnabled),
}
try:
MAPPINGS['content.print_element_backgrounds'] = Attribute(
QWebEngineSettings.PrintElementBackgrounds)
except AttributeError:
# Added in Qt 5.8
pass
if qtutils.version_check('5.8'):
MAPPINGS['spellcheck.languages'] = DictionaryLanguageSetter()
if qtutils.version_check('5.9', compiled=False):
# https://bugreports.qt.io/browse/QTBUG-58650
MAPPINGS['content.cookies.store'] = PersistentCookiePolicy()

View File

@ -22,11 +22,12 @@
import math
import functools
import sys
import re
import html as html_utils
import sip
from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QEvent, QPoint, QPointF,
QUrl)
QUrl, QTimer)
from PyQt5.QtGui import QKeyEvent
from PyQt5.QtNetwork import QAuthenticator
from PyQt5.QtWidgets import QApplication
@ -473,7 +474,8 @@ class WebEngineHistory(browsertab.AbstractHistory):
return self._history.itemAt(i)
def _go_to_item(self, item):
return self._history.goToItem(item)
self._tab.predicted_navigation.emit(item.url())
self._history.goToItem(item)
def serialize(self):
if not qtutils.version_check('5.9', compiled=False):
@ -607,12 +609,15 @@ class WebEngineTab(browsertab.AbstractTab):
self.printing = WebEnginePrinting()
self.elements = WebEngineElements(tab=self)
self.action = WebEngineAction(tab=self)
# We're assigning settings in _set_widget
self.settings = webenginesettings.WebEngineSettings(settings=None)
self._set_widget(widget)
self._connect_signals()
self.backend = usertypes.Backend.QtWebEngine
self._init_js()
self._child_event_filter = None
self._saved_zoom = None
self._reload_url = None
def _init_js(self):
js_code = '\n'.join([
@ -731,6 +736,16 @@ class WebEngineTab(browsertab.AbstractTab):
self.send_event(press_evt)
self.send_event(release_evt)
def _show_error_page(self, url, error):
"""Show an error page in the tab."""
log.misc.debug("Showing error page for {}".format(error))
url_string = url.toDisplayString()
error_page = jinja.render(
'error.html',
title="Error loading page: {}".format(url_string),
url=url_string, error=error)
self.set_html(error_page)
@pyqtSlot()
def _on_history_trigger(self):
try:
@ -779,13 +794,7 @@ class WebEngineTab(browsertab.AbstractTab):
sip.assign(authenticator, QAuthenticator())
# pylint: enable=no-member, useless-suppression
except AttributeError:
url_string = url.toDisplayString()
error_page = jinja.render(
'error.html',
title="Error loading page: {}".format(url_string),
url=url_string, error="Proxy authentication required",
icon='')
self.set_html(error_page)
self._show_error_page(url, "Proxy authentication required")
@pyqtSlot(QUrl, 'QAuthenticator*')
def _on_authentication_required(self, url, authenticator):
@ -805,12 +814,7 @@ class WebEngineTab(browsertab.AbstractTab):
except AttributeError:
# WORKAROUND for
# https://www.riverbankcomputing.com/pipermail/pyqt/2016-December/038400.html
url_string = url.toDisplayString()
error_page = jinja.render(
'error.html',
title="Error loading page: {}".format(url_string),
url=url_string, error="Authentication required")
self.set_html(error_page)
self._show_error_page(url, "Authentication required")
@pyqtSlot('QWebEngineFullScreenRequest')
def _on_fullscreen_requested(self, request):
@ -875,6 +879,54 @@ class WebEngineTab(browsertab.AbstractTab):
if not ok:
self._load_finished_fake.emit(False)
def _error_page_workaround(self, html):
"""Check if we're displaying a Chromium error page.
This gets only called if we got loadFinished(False) without JavaScript,
so we can display at least some error page.
WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66643
Needs to check the page content as a WORKAROUND for
https://bugreports.qt.io/browse/QTBUG-66661
"""
match = re.search(r'"errorCode":"([^"]*)"', html)
if match is None:
return
self._show_error_page(self.url(), error=match.group(1))
@pyqtSlot(bool)
def _on_load_finished(self, ok):
"""Display a static error page if JavaScript is disabled."""
super()._on_load_finished(ok)
js_enabled = self.settings.test_attribute('content.javascript.enabled')
if not ok and not js_enabled:
self.dump_async(self._error_page_workaround)
if ok and self._reload_url is not None:
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656
log.config.debug(
"Reloading {} because of config change".format(
self._reload_url.toDisplayString()))
QTimer.singleShot(100, lambda url=self._reload_url:
self.openurl(url))
self._reload_url = None
@pyqtSlot(QUrl)
def _on_predicted_navigation(self, url):
"""If we know we're going to visit an URL soon, change the settings."""
self.settings.update_for_url(url)
@pyqtSlot(usertypes.NavigationRequest)
def _on_navigation_request(self, navigation):
super()._on_navigation_request(navigation)
if navigation.accepted and navigation.is_main_frame:
changed = self.settings.update_for_url(navigation.url)
needs_reload = {'content.plugins', 'content.javascript.enabled'}
if (changed & needs_reload and navigation.navigation_type !=
navigation.Type.link_clicked):
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656
self._reload_url = navigation.url
def _connect_signals(self):
view = self._widget
page = view.page()
@ -889,6 +941,7 @@ class WebEngineTab(browsertab.AbstractTab):
self._on_proxy_authentication_required)
page.fullScreenRequested.connect(self._on_fullscreen_requested)
page.contentsSizeChanged.connect(self.contents_size_changed)
page.navigation_request.connect(self._on_navigation_request)
view.titleChanged.connect(self.title_changed)
view.urlChanged.connect(self._on_url_changed)
@ -909,5 +962,7 @@ class WebEngineTab(browsertab.AbstractTab):
page.loadFinished.connect(self._restore_zoom)
page.loadFinished.connect(self._on_load_finished)
self.predicted_navigation.connect(self._on_predicted_navigation)
def event_target(self):
return self._widget.focusProxy()

View File

@ -29,8 +29,7 @@ from PyQt5.QtWebEngineWidgets import (QWebEngineView, QWebEnginePage,
from qutebrowser.browser import shared
from qutebrowser.browser.webengine import certificateerror, webenginesettings
from qutebrowser.config import config
from qutebrowser.utils import (log, debug, usertypes, jinja, urlutils, message,
objreg, qtutils)
from qutebrowser.utils import log, debug, usertypes, jinja, objreg, qtutils
class WebEngineView(QWebEngineView):
@ -124,10 +123,12 @@ class WebEnginePage(QWebEnginePage):
Signals:
certificate_error: Emitted on certificate errors.
shutting_down: Emitted when the page is shutting down.
navigation_request: Emitted on acceptNavigationRequest.
"""
certificate_error = pyqtSignal()
shutting_down = pyqtSignal()
navigation_request = pyqtSignal(usertypes.NavigationRequest)
def __init__(self, *, theme_color, profile, parent=None):
super().__init__(profile, parent)
@ -288,21 +289,26 @@ class WebEnginePage(QWebEnginePage):
url: QUrl,
typ: QWebEnginePage.NavigationType,
is_main_frame: bool):
"""Override acceptNavigationRequest to handle clicked links.
This only show an error on invalid links - everything else is handled
in createWindow.
"""
log.webview.debug("navigation request: url {}, type {}, is_main_frame "
"{}".format(url.toDisplayString(),
debug.qenum_key(QWebEnginePage, typ),
is_main_frame))
if (typ == QWebEnginePage.NavigationTypeLinkClicked and
not url.isValid()):
msg = urlutils.get_errstring(url, "Invalid link clicked")
message.error(msg)
return False
return True
"""Override acceptNavigationRequest to forward it to the tab API."""
type_map = {
QWebEnginePage.NavigationTypeLinkClicked:
usertypes.NavigationRequest.Type.link_clicked,
QWebEnginePage.NavigationTypeTyped:
usertypes.NavigationRequest.Type.typed,
QWebEnginePage.NavigationTypeFormSubmitted:
usertypes.NavigationRequest.Type.form_submitted,
QWebEnginePage.NavigationTypeBackForward:
usertypes.NavigationRequest.Type.back_forward,
QWebEnginePage.NavigationTypeReload:
usertypes.NavigationRequest.Type.reloaded,
QWebEnginePage.NavigationTypeOther:
usertypes.NavigationRequest.Type.other,
}
navigation = usertypes.NavigationRequest(url=url,
navigation_type=type_map[typ],
is_main_frame=is_main_frame)
self.navigation_request.emit(navigation)
return navigation.accepted
@pyqtSlot('QUrl')
def _inject_userjs(self, url):

View File

@ -17,9 +17,6 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
# We get various "abstract but not overridden" warnings
# pylint: disable=abstract-method
"""Bridge from QWebSettings to our own settings.
Module attributes:
@ -37,85 +34,130 @@ from qutebrowser.utils import standarddir, urlutils
from qutebrowser.browser import shared
class Base(websettings.Base):
"""Base settings class with appropriate _get_global_settings."""
def _get_global_settings(self):
return [QWebSettings.globalSettings()]
# The global WebKitSettings object
global_settings = None
class Attribute(Base, websettings.Attribute):
class WebKitSettings(websettings.AbstractSettings):
"""A setting set via QWebSettings::setAttribute."""
"""A wrapper for the config for QWebSettings."""
ENUM_BASE = QWebSettings
_ATTRIBUTES = {
'content.images':
[QWebSettings.AutoLoadImages],
'content.javascript.enabled':
[QWebSettings.JavascriptEnabled],
'content.javascript.can_open_tabs_automatically':
[QWebSettings.JavascriptCanOpenWindows],
'content.javascript.can_close_tabs':
[QWebSettings.JavascriptCanCloseWindows],
'content.javascript.can_access_clipboard':
[QWebSettings.JavascriptCanAccessClipboard],
'content.plugins':
[QWebSettings.PluginsEnabled],
'content.webgl':
[QWebSettings.WebGLEnabled],
'content.hyperlink_auditing':
[QWebSettings.HyperlinkAuditingEnabled],
'content.local_content_can_access_remote_urls':
[QWebSettings.LocalContentCanAccessRemoteUrls],
'content.local_content_can_access_file_urls':
[QWebSettings.LocalContentCanAccessFileUrls],
'content.dns_prefetch':
[QWebSettings.DnsPrefetchEnabled],
'content.frame_flattening':
[QWebSettings.FrameFlatteningEnabled],
'content.cache.appcache':
[QWebSettings.OfflineWebApplicationCacheEnabled],
'content.local_storage':
[QWebSettings.LocalStorageEnabled,
QWebSettings.OfflineStorageDatabaseEnabled],
'content.developer_extras':
[QWebSettings.DeveloperExtrasEnabled],
'content.print_element_backgrounds':
[QWebSettings.PrintElementBackgrounds],
'content.xss_auditing':
[QWebSettings.XSSAuditingEnabled],
'input.spatial_navigation':
[QWebSettings.SpatialNavigationEnabled],
'input.links_included_in_focus_chain':
[QWebSettings.LinksIncludedInFocusChain],
'zoom.text_only':
[QWebSettings.ZoomTextOnly],
'scrolling.smooth':
[QWebSettings.ScrollAnimatorEnabled],
}
_FONT_SIZES = {
'fonts.web.size.minimum':
QWebSettings.MinimumFontSize,
'fonts.web.size.minimum_logical':
QWebSettings.MinimumLogicalFontSize,
'fonts.web.size.default':
QWebSettings.DefaultFontSize,
'fonts.web.size.default_fixed':
QWebSettings.DefaultFixedFontSize,
}
_FONT_FAMILIES = {
'fonts.web.family.standard': QWebSettings.StandardFont,
'fonts.web.family.fixed': QWebSettings.FixedFont,
'fonts.web.family.serif': QWebSettings.SerifFont,
'fonts.web.family.sans_serif': QWebSettings.SansSerifFont,
'fonts.web.family.cursive': QWebSettings.CursiveFont,
'fonts.web.family.fantasy': QWebSettings.FantasyFont,
}
# Mapping from QWebSettings::QWebSettings() in
# qtwebkit/Source/WebKit/qt/Api/qwebsettings.cpp
_FONT_TO_QFONT = {
QWebSettings.StandardFont: QFont.Serif,
QWebSettings.FixedFont: QFont.Monospace,
QWebSettings.SerifFont: QFont.Serif,
QWebSettings.SansSerifFont: QFont.SansSerif,
QWebSettings.CursiveFont: QFont.Cursive,
QWebSettings.FantasyFont: QFont.Fantasy,
}
class Setter(Base, websettings.Setter):
"""A setting set via a QWebSettings setter method."""
pass
def _set_user_stylesheet(settings):
"""Set the generated user-stylesheet."""
stylesheet = shared.get_user_stylesheet().encode('utf-8')
url = urlutils.data_url('text/css;charset=utf-8', stylesheet)
settings.setUserStyleSheetUrl(url)
class StaticSetter(Base, websettings.StaticSetter):
"""A setting set via a static QWebSettings setter method."""
pass
class FontFamilySetter(Base, websettings.FontFamilySetter):
"""A setter for a font family.
Gets the default value from QFont.
"""
def __init__(self, font):
# Mapping from QWebSettings::QWebSettings() in
# qtwebkit/Source/WebKit/qt/Api/qwebsettings.cpp
font_to_qfont = {
QWebSettings.StandardFont: QFont.Serif,
QWebSettings.FixedFont: QFont.Monospace,
QWebSettings.SerifFont: QFont.Serif,
QWebSettings.SansSerifFont: QFont.SansSerif,
QWebSettings.CursiveFont: QFont.Cursive,
QWebSettings.FantasyFont: QFont.Fantasy,
}
super().__init__(setter=QWebSettings.setFontFamily, font=font,
qfont=font_to_qfont[font])
class CookiePolicy(Base):
"""The ThirdPartyCookiePolicy setting is different from other settings."""
MAPPING = {
def _set_cookie_accept_policy(settings):
"""Update the content.cookies.accept setting."""
mapping = {
'all': QWebSettings.AlwaysAllowThirdPartyCookies,
'no-3rdparty': QWebSettings.AlwaysBlockThirdPartyCookies,
'never': QWebSettings.AlwaysBlockThirdPartyCookies,
'no-unknown-3rdparty': QWebSettings.AllowThirdPartyWithExistingCookies,
}
def _set(self, value, settings=None):
for obj in self._get_settings(settings):
obj.setThirdPartyCookiePolicy(self.MAPPING[value])
value = config.val.content.cookies.accept
settings.setThirdPartyCookiePolicy(mapping[value])
def _set_user_stylesheet():
"""Set the generated user-stylesheet."""
stylesheet = shared.get_user_stylesheet().encode('utf-8')
url = urlutils.data_url('text/css;charset=utf-8', stylesheet)
QWebSettings.globalSettings().setUserStyleSheetUrl(url)
def _set_cache_maximum_pages(settings):
"""Update the content.cache.maximum_pages setting."""
value = config.val.content.cache.maximum_pages
settings.setMaximumPagesInCache(value)
def _update_settings(option):
"""Update global settings when qwebsettings changed."""
global_settings.update_setting(option)
settings = QWebSettings.globalSettings()
if option in ['scrollbar.hide', 'content.user_stylesheets']:
_set_user_stylesheet()
websettings.update_mappings(MAPPINGS, option)
_set_user_stylesheet(settings)
elif option == 'content.cookies.accept':
_set_cookie_accept_policy(settings)
elif option == 'content.cache.maximum_pages':
_set_cache_maximum_pages(settings)
def init(_args):
@ -131,92 +173,20 @@ def init(_args):
QWebSettings.setOfflineStoragePath(
os.path.join(data_path, 'offline-storage'))
websettings.init_mappings(MAPPINGS)
_set_user_stylesheet()
settings = QWebSettings.globalSettings()
_set_user_stylesheet(settings)
_set_cookie_accept_policy(settings)
_set_cache_maximum_pages(settings)
config.instance.changed.connect(_update_settings)
global global_settings
global_settings = WebKitSettings(QWebSettings.globalSettings())
global_settings.init_settings()
def shutdown():
"""Disable storage so removing tmpdir will work."""
QWebSettings.setIconDatabasePath('')
QWebSettings.setOfflineWebApplicationCachePath('')
QWebSettings.globalSettings().setLocalStoragePath('')
MAPPINGS = {
'content.images':
Attribute(QWebSettings.AutoLoadImages),
'content.javascript.enabled':
Attribute(QWebSettings.JavascriptEnabled),
'content.javascript.can_open_tabs_automatically':
Attribute(QWebSettings.JavascriptCanOpenWindows),
'content.javascript.can_close_tabs':
Attribute(QWebSettings.JavascriptCanCloseWindows),
'content.javascript.can_access_clipboard':
Attribute(QWebSettings.JavascriptCanAccessClipboard),
'content.plugins':
Attribute(QWebSettings.PluginsEnabled),
'content.webgl':
Attribute(QWebSettings.WebGLEnabled),
'content.hyperlink_auditing':
Attribute(QWebSettings.HyperlinkAuditingEnabled),
'content.local_content_can_access_remote_urls':
Attribute(QWebSettings.LocalContentCanAccessRemoteUrls),
'content.local_content_can_access_file_urls':
Attribute(QWebSettings.LocalContentCanAccessFileUrls),
'content.cookies.accept':
CookiePolicy(),
'content.dns_prefetch':
Attribute(QWebSettings.DnsPrefetchEnabled),
'content.frame_flattening':
Attribute(QWebSettings.FrameFlatteningEnabled),
'content.cache.appcache':
Attribute(QWebSettings.OfflineWebApplicationCacheEnabled),
'content.local_storage':
Attribute(QWebSettings.LocalStorageEnabled,
QWebSettings.OfflineStorageDatabaseEnabled),
'content.cache.maximum_pages':
StaticSetter(QWebSettings.setMaximumPagesInCache),
'content.developer_extras':
Attribute(QWebSettings.DeveloperExtrasEnabled),
'content.print_element_backgrounds':
Attribute(QWebSettings.PrintElementBackgrounds),
'content.xss_auditing':
Attribute(QWebSettings.XSSAuditingEnabled),
'content.default_encoding':
Setter(QWebSettings.setDefaultTextEncoding),
# content.user_stylesheets is handled separately
'input.spatial_navigation':
Attribute(QWebSettings.SpatialNavigationEnabled),
'input.links_included_in_focus_chain':
Attribute(QWebSettings.LinksIncludedInFocusChain),
'fonts.web.family.standard':
FontFamilySetter(QWebSettings.StandardFont),
'fonts.web.family.fixed':
FontFamilySetter(QWebSettings.FixedFont),
'fonts.web.family.serif':
FontFamilySetter(QWebSettings.SerifFont),
'fonts.web.family.sans_serif':
FontFamilySetter(QWebSettings.SansSerifFont),
'fonts.web.family.cursive':
FontFamilySetter(QWebSettings.CursiveFont),
'fonts.web.family.fantasy':
FontFamilySetter(QWebSettings.FantasyFont),
'fonts.web.size.minimum':
Setter(QWebSettings.setFontSize, args=[QWebSettings.MinimumFontSize]),
'fonts.web.size.minimum_logical':
Setter(QWebSettings.setFontSize,
args=[QWebSettings.MinimumLogicalFontSize]),
'fonts.web.size.default':
Setter(QWebSettings.setFontSize, args=[QWebSettings.DefaultFontSize]),
'fonts.web.size.default_fixed':
Setter(QWebSettings.setFontSize,
args=[QWebSettings.DefaultFixedFontSize]),
'zoom.text_only':
Attribute(QWebSettings.ZoomTextOnly),
'scrolling.smooth':
Attribute(QWebSettings.ScrollAnimatorEnabled),
}

View File

@ -35,8 +35,9 @@ from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame
from PyQt5.QtWebKit import QWebSettings
from PyQt5.QtPrintSupport import QPrinter
from qutebrowser.browser import browsertab
from qutebrowser.browser.webkit import webview, tabhistory, webkitelem
from qutebrowser.browser import browsertab, shared
from qutebrowser.browser.webkit import (webview, tabhistory, webkitelem,
webkitsettings)
from qutebrowser.utils import qtutils, objreg, usertypes, utils, log, debug
@ -517,7 +518,8 @@ class WebKitHistory(browsertab.AbstractHistory):
return self._history.itemAt(i)
def _go_to_item(self, item):
return self._history.goToItem(item)
self._tab.predicted_navigation.emit(item.url())
self._history.goToItem(item)
def serialize(self):
return qtutils.serialize(self._history)
@ -644,6 +646,8 @@ class WebKitTab(browsertab.AbstractTab):
self.printing = WebKitPrinting()
self.elements = WebKitElements(tab=self)
self.action = WebKitAction(tab=self)
# We're assigning settings in _set_widget
self.settings = webkitsettings.WebKitSettings(settings=None)
self._set_widget(widget)
self._connect_signals()
self.backend = usertypes.Backend.QtWebKit
@ -761,6 +765,31 @@ class WebKitTab(browsertab.AbstractTab):
def _on_contents_size_changed(self, size):
self.contents_size_changed.emit(QSizeF(size))
@pyqtSlot(usertypes.NavigationRequest)
def _on_navigation_request(self, navigation):
super()._on_navigation_request(navigation)
if not navigation.accepted:
return
log.webview.debug("target {} override {}".format(
self.data.open_target, self.data.override_target))
if self.data.override_target is not None:
target = self.data.override_target
self.data.override_target = None
else:
target = self.data.open_target
if (navigation.navigation_type == navigation.Type.link_clicked and
target != usertypes.ClickTarget.normal):
tab = shared.get_tab(self.win_id, target)
tab.openurl(navigation.url)
self.data.open_target = usertypes.ClickTarget.normal
navigation.accepted = False
if navigation.is_main_frame:
self.settings.update_for_url(navigation.url)
def _connect_signals(self):
view = self._widget
page = view.page()
@ -779,6 +808,7 @@ class WebKitTab(browsertab.AbstractTab):
page.frameCreated.connect(self._on_frame_created)
frame.contentsSizeChanged.connect(self._on_contents_size_changed)
frame.initialLayoutCompleted.connect(self._on_history_trigger)
page.navigation_request.connect(self._on_navigation_request)
def event_target(self):
return self._widget

View File

@ -33,8 +33,7 @@ from qutebrowser.config import config
from qutebrowser.browser import pdfjs, shared
from qutebrowser.browser.webkit import http
from qutebrowser.browser.webkit.network import networkmanager
from qutebrowser.utils import (message, usertypes, log, jinja, objreg, debug,
urlutils)
from qutebrowser.utils import message, usertypes, log, jinja, objreg
class BrowserPage(QWebPage):
@ -54,10 +53,12 @@ class BrowserPage(QWebPage):
shutting_down: Emitted when the page is currently shutting down.
reloading: Emitted before a web page reloads.
arg: The URL which gets reloaded.
navigation_request: Emitted on acceptNavigationRequest.
"""
shutting_down = pyqtSignal()
reloading = pyqtSignal(QUrl)
navigation_request = pyqtSignal(usertypes.NavigationRequest)
def __init__(self, win_id, tab_id, tabdata, private, parent=None):
super().__init__(parent)
@ -70,7 +71,6 @@ class BrowserPage(QWebPage):
}
self._ignore_load_started = False
self.error_occurred = False
self.open_target = usertypes.ClickTarget.normal
self._networkmanager = networkmanager.NetworkManager(
win_id=win_id, tab_id=tab_id, private=private, parent=self)
self.setNetworkAccessManager(self._networkmanager)
@ -474,7 +474,7 @@ class BrowserPage(QWebPage):
source, line, msg)
def acceptNavigationRequest(self,
_frame: QWebFrame,
frame: QWebFrame,
request: QNetworkRequest,
typ: QWebPage.NavigationType):
"""Override acceptNavigationRequest to handle clicked links.
@ -486,36 +486,27 @@ class BrowserPage(QWebPage):
Checks if it should open it in a tab (middle-click or control) or not,
and then conditionally opens the URL here or in another tab/window.
"""
url = request.url()
log.webview.debug("navigation request: url {}, type {}, "
"target {} override {}".format(
url.toDisplayString(),
debug.qenum_key(QWebPage, typ),
self.open_target,
self._tabdata.override_target))
type_map = {
QWebPage.NavigationTypeLinkClicked:
usertypes.NavigationRequest.Type.link_clicked,
QWebPage.NavigationTypeFormSubmitted:
usertypes.NavigationRequest.Type.form_submitted,
QWebPage.NavigationTypeFormResubmitted:
usertypes.NavigationRequest.Type.form_resubmitted,
QWebPage.NavigationTypeBackOrForward:
usertypes.NavigationRequest.Type.back_forward,
QWebPage.NavigationTypeReload:
usertypes.NavigationRequest.Type.reloaded,
QWebPage.NavigationTypeOther:
usertypes.NavigationRequest.Type.other,
}
is_main_frame = frame is self.mainFrame()
navigation = usertypes.NavigationRequest(url=request.url(),
navigation_type=type_map[typ],
is_main_frame=is_main_frame)
if self._tabdata.override_target is not None:
target = self._tabdata.override_target
self._tabdata.override_target = None
else:
target = self.open_target
if navigation.navigation_type == navigation.Type.reloaded:
self.reloading.emit(navigation.url)
if typ == QWebPage.NavigationTypeReload:
self.reloading.emit(url)
return True
elif typ != QWebPage.NavigationTypeLinkClicked:
return True
if not url.isValid():
msg = urlutils.get_errstring(url, "Invalid link clicked")
message.error(msg)
self.open_target = usertypes.ClickTarget.normal
return False
if target == usertypes.ClickTarget.normal:
return True
tab = shared.get_tab(self._win_id, target)
tab.openurl(url)
self.open_target = usertypes.ClickTarget.normal
return False
self.navigation_request.emit(navigation)
return navigation.accepted

View File

@ -262,10 +262,10 @@ class WebView(QWebView):
target = usertypes.ClickTarget.tab_bg
else:
target = usertypes.ClickTarget.tab
self.page().open_target = target
self._tabdata.open_target = target
log.mouse.debug("Ctrl/Middle click, setting target: {}".format(
target))
else:
self.page().open_target = usertypes.ClickTarget.normal
self._tabdata.open_target = usertypes.ClickTarget.normal
log.mouse.debug("Normal click, setting normal target")
super().mousePressEvent(e)

View File

@ -63,6 +63,7 @@ def replace_variables(win_id, arglist):
QUrl.FullyEncoded | QUrl.RemovePassword),
'url:pretty': lambda: _current_url(tabbed_browser).toString(
QUrl.DecodeReserved | QUrl.RemovePassword),
'url:host': lambda: _current_url(tabbed_browser).host(),
'clipboard': utils.get_clipboard,
'primary': lambda: utils.get_clipboard(selection=True),
}

View File

@ -25,7 +25,7 @@ import functools
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject
from qutebrowser.config import configdata, configexc
from qutebrowser.config import configdata, configexc, configutils
from qutebrowser.utils import utils, log, jinja
from qutebrowser.misc import objects
@ -37,6 +37,9 @@ key_instance = None
# Keeping track of all change filters to validate them later.
change_filters = []
# Sentinel
UNSET = object()
class change_filter: # noqa: N801,N806 pylint: disable=invalid-name
@ -186,7 +189,7 @@ class KeyConfig:
log.keyboard.vdebug("Adding binding {} -> {} in mode {}.".format(
key, command, mode))
bindings = self._config.get_obj('bindings.commands')
bindings = self._config.get_mutable_obj('bindings.commands')
if mode not in bindings:
bindings[mode] = {}
bindings[mode][key] = command
@ -196,7 +199,7 @@ class KeyConfig:
"""Restore a default keybinding."""
key = self._prepare(key, mode)
bindings_commands = self._config.get_obj('bindings.commands')
bindings_commands = self._config.get_mutable_obj('bindings.commands')
try:
del bindings_commands[mode][key]
except KeyError:
@ -208,7 +211,7 @@ class KeyConfig:
"""Unbind the given key in the given mode."""
key = self._prepare(key, mode)
bindings_commands = self._config.get_obj('bindings.commands')
bindings_commands = self._config.get_mutable_obj('bindings.commands')
if val.bindings.commands[mode].get(key, None) is not None:
# In custom bindings -> remove it
@ -229,8 +232,12 @@ class Config(QObject):
"""Main config object.
Class attributes:
MUTABLE_TYPES: Types returned from the config which could potentially
be mutated.
Attributes:
_values: A dict mapping setting names to their values.
_values: A dict mapping setting names to configutils.Values objects.
_mutables: A dictionary of mutable objects to be checked for changes.
_yaml: A YamlConfig object or None.
@ -238,19 +245,25 @@ class Config(QObject):
changed: Emitted with the option name when an option changed.
"""
MUTABLE_TYPES = (dict, list)
changed = pyqtSignal(str)
def __init__(self, yaml_config, parent=None):
super().__init__(parent)
self.changed.connect(_render_stylesheet.cache_clear)
self._values = {}
self._mutables = {}
self._yaml = yaml_config
self._init_values()
def _init_values(self):
"""Populate the self._values dict."""
self._values = {}
for name, opt in configdata.DATA.items():
self._values[name] = configutils.Values(opt)
def __iter__(self):
"""Iterate over Option, value tuples."""
for name, value in sorted(self._values.items()):
yield (self.get_opt(name), value)
"""Iterate over configutils.Values items."""
yield from self._values.values()
def init_save_manager(self, save_manager):
"""Make sure the config gets saved properly.
@ -260,14 +273,15 @@ class Config(QObject):
"""
self._yaml.init_save_manager(save_manager)
def _set_value(self, opt, value):
def _set_value(self, opt, value, pattern=None):
"""Set the given option to the given value."""
if not isinstance(objects.backend, objects.NoBackend):
if objects.backend not in opt.backends:
raise configexc.BackendError(opt.name, objects.backend)
opt.typ.to_py(value) # for validation
self._values[opt.name] = opt.typ.from_obj(value)
self._values[opt.name].add(opt.typ.from_obj(value), pattern)
self.changed.emit(opt.name)
log.config.debug("Config option changed: {} = {}".format(
@ -276,8 +290,10 @@ class Config(QObject):
def read_yaml(self):
"""Read the YAML settings from self._yaml."""
self._yaml.load()
for name, value in self._yaml:
self._set_value(self.get_opt(name), value)
for values in self._yaml:
for scoped in values:
self._set_value(values.opt, scoped.value,
pattern=scoped.pattern)
def get_opt(self, name):
"""Get a configdata.Option object for the given setting."""
@ -290,53 +306,89 @@ class Config(QObject):
name, deleted=deleted, renamed=renamed)
raise exception from None
def get(self, name):
def get(self, name, url=None):
"""Get the given setting converted for Python code."""
opt = self.get_opt(name)
obj = self.get_obj(name, mutable=False)
obj = self.get_obj(name, url=url)
return opt.typ.to_py(obj)
def get_obj(self, name, *, mutable=True):
def _maybe_copy(self, value):
"""Copy the value if it could potentially be mutated."""
if isinstance(value, self.MUTABLE_TYPES):
# For mutable objects, create a copy so we don't accidentally
# mutate the config's internal value.
return copy.deepcopy(value)
else:
# Shouldn't be mutable (and thus hashable)
assert value.__hash__ is not None, value
return value
def get_obj(self, name, *, url=None):
"""Get the given setting as object (for YAML/config.py).
If mutable=True is set, watch the returned object for mutations.
Note that the returned values are not watched for mutation.
If a URL is given, return the value which should be used for that URL.
"""
opt = self.get_opt(name)
obj = None
self.get_opt(name) # To make sure it exists
value = self._values[name].get_for_url(url)
return self._maybe_copy(value)
def get_obj_for_pattern(self, name, *, pattern):
"""Get the given setting as object (for YAML/config.py).
This gets the overridden value for a given pattern, or
configutils.UNSET if no such override exists.
"""
self.get_opt(name) # To make sure it exists
value = self._values[name].get_for_pattern(pattern, fallback=False)
return self._maybe_copy(value)
def get_mutable_obj(self, name, *, pattern=None):
"""Get an object which can be mutated, e.g. in a config.py.
If a pattern is given, return the value for that pattern.
Note that it's impossible to get a mutable object for an URL as we
wouldn't know what pattern to apply.
"""
self.get_opt(name) # To make sure it exists
# If we allow mutation, there is a chance that prior mutations already
# entered the mutable dictionary and thus further copies are unneeded
# until update_mutables() is called
if name in self._mutables and mutable:
if name in self._mutables:
_copy, obj = self._mutables[name]
# Otherwise, we return a copy of the value stored internally, so the
# internal value can never be changed by mutating the object returned.
else:
obj = copy.deepcopy(self._values.get(name, opt.default))
# Then we watch the returned object for changes.
if isinstance(obj, (dict, list)):
if mutable:
self._mutables[name] = (copy.deepcopy(obj), obj)
else:
# Shouldn't be mutable (and thus hashable)
assert obj.__hash__ is not None, obj
return obj
return obj
def get_str(self, name):
"""Get the given setting as string."""
value = self._values[name].get_for_pattern(pattern)
copy_value = self._maybe_copy(value)
# Watch the returned object for changes if it's mutable.
if isinstance(copy_value, self.MUTABLE_TYPES):
self._mutables[name] = (value, copy_value) # old, new
return copy_value
def get_str(self, name, *, pattern=None):
"""Get the given setting as string.
If a pattern is given, get the setting for the given pattern or
configutils.UNSET.
"""
opt = self.get_opt(name)
value = self._values.get(name, opt.default)
values = self._values[name]
value = values.get_for_pattern(pattern)
return opt.typ.to_str(value)
def set_obj(self, name, value, *, save_yaml=False):
def set_obj(self, name, value, *, pattern=None, save_yaml=False):
"""Set the given setting from a YAML/config.py object.
If save_yaml=True is given, store the new value to YAML.
"""
self._set_value(self.get_opt(name), value)
self._set_value(self.get_opt(name), value, pattern=pattern)
if save_yaml:
self._yaml[name] = value
self._yaml.set_obj(name, value, pattern=pattern)
def set_str(self, name, value, *, save_yaml=False):
def set_str(self, name, value, *, pattern=None, save_yaml=False):
"""Set the given setting from a string.
If save_yaml=True is given, store the new value to YAML.
@ -346,21 +398,19 @@ class Config(QObject):
log.config.debug("Setting {} (type {}) to {!r} (converted from {!r})"
.format(name, opt.typ.__class__.__name__, converted,
value))
self._set_value(opt, converted)
self._set_value(opt, converted, pattern=pattern)
if save_yaml:
self._yaml[name] = converted
self._yaml.set_obj(name, converted, pattern=pattern)
def unset(self, name, *, save_yaml=False):
def unset(self, name, *, save_yaml=False, pattern=None):
"""Set the given setting back to its default."""
self.get_opt(name)
try:
del self._values[name]
except KeyError:
return
self.changed.emit(name)
self.get_opt(name) # To check whether it exists
changed = self._values[name].remove(pattern)
if changed:
self.changed.emit(name)
if save_yaml:
self._yaml.unset(name)
self._yaml.unset(name, pattern=pattern)
def clear(self, *, save_yaml=False):
"""Clear all settings in the config.
@ -368,10 +418,10 @@ class Config(QObject):
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)
for name, values in self._values.items():
if values:
values.clear()
self.changed.emit(name)
if save_yaml:
self._yaml.clear()
@ -397,13 +447,15 @@ class Config(QObject):
Return:
The changed config part as string.
"""
lines = []
for opt, value in self:
str_value = opt.typ.to_str(value)
lines.append('{} = {}'.format(opt.name, str_value))
if not lines:
lines = ['<Default configuration>']
return '\n'.join(lines)
blocks = []
for values in sorted(self, key=lambda v: v.opt.name):
if values:
blocks.append(str(values))
if not blocks:
return '<Default configuration>'
return '\n'.join(blocks)
class ConfigContainer:
@ -415,16 +467,21 @@ class ConfigContainer:
_prefix: The __getattr__ chain leading up to this object.
_configapi: If given, get values suitable for config.py and
add errors to the given ConfigAPI object.
_pattern: The URL pattern to be used.
"""
def __init__(self, config, configapi=None, prefix=''):
def __init__(self, config, configapi=None, prefix='', pattern=None):
self._config = config
self._prefix = prefix
self._configapi = configapi
self._pattern = pattern
if configapi is None and pattern is not None:
raise TypeError("Can't use pattern without configapi!")
def __repr__(self):
return utils.get_repr(self, constructor=True, config=self._config,
configapi=self._configapi, prefix=self._prefix)
configapi=self._configapi, prefix=self._prefix,
pattern=self._pattern)
@contextlib.contextmanager
def _handle_error(self, action, name):
@ -452,7 +509,7 @@ class ConfigContainer:
if configdata.is_valid_prefix(name):
return ConfigContainer(config=self._config,
configapi=self._configapi,
prefix=name)
prefix=name, pattern=self._pattern)
with self._handle_error('getting', name):
if self._configapi is None:
@ -460,7 +517,8 @@ class ConfigContainer:
return self._config.get(name)
else:
# access from config.py
return self._config.get_obj(name)
return self._config.get_mutable_obj(
name, pattern=self._pattern)
def __setattr__(self, attr, value):
"""Set the given option in the config."""
@ -470,7 +528,7 @@ class ConfigContainer:
name = self._join(attr)
with self._handle_error('setting', name):
self._config.set_obj(name, value)
self._config.set_obj(name, value, pattern=self._pattern)
def _join(self, attr):
"""Get the prefix joined with the given attribute."""

View File

@ -26,7 +26,7 @@ from PyQt5.QtCore import QUrl
from qutebrowser.commands import cmdexc, cmdutils
from qutebrowser.completion.models import configmodel
from qutebrowser.utils import objreg, utils, message, standarddir
from qutebrowser.utils import objreg, utils, message, standarddir, urlmatch
from qutebrowser.config import configtypes, configexc, configfiles, configdata
from qutebrowser.misc import editor
@ -47,17 +47,34 @@ class ConfigCommands:
except configexc.Error as e:
raise cmdexc.CommandError(str(e))
def _print_value(self, option):
def _parse_pattern(self, pattern):
"""Parse a pattern string argument to a pattern."""
if pattern is None:
return None
try:
return urlmatch.UrlPattern(pattern)
except urlmatch.ParseError as e:
raise cmdexc.CommandError("Error while parsing {}: {}"
.format(pattern, str(e)))
def _print_value(self, option, pattern):
"""Print the value of the given option."""
with self._handle_config_error():
value = self._config.get_str(option)
message.info("{} = {}".format(option, value))
value = self._config.get_str(option, pattern=pattern)
text = "{} = {}".format(option, value)
if pattern is not None:
text += " for {}".format(pattern)
message.info(text)
@cmdutils.register(instance='config-commands')
@cmdutils.argument('option', completion=configmodel.option)
@cmdutils.argument('value', completion=configmodel.value)
@cmdutils.argument('win_id', win_id=True)
def set(self, win_id, option=None, value=None, temp=False, print_=False):
@cmdutils.argument('pattern', flag='u')
def set(self, win_id, option=None, value=None, temp=False, print_=False,
*, pattern=None):
"""Set an option.
If the option name ends with '?', the value of the option is shown
@ -69,6 +86,7 @@ class ConfigCommands:
Args:
option: The name of the option.
value: The value to set.
pattern: The URL pattern to use.
temp: Set value temporarily until qutebrowser is closed.
print_: Print the value after setting.
"""
@ -82,8 +100,10 @@ class ConfigCommands:
raise cmdexc.CommandError("Toggling values was moved to the "
":config-cycle command")
pattern = self._parse_pattern(pattern)
if option.endswith('?') and option != '?':
self._print_value(option[:-1])
self._print_value(option[:-1], pattern=pattern)
return
with self._handle_config_error():
@ -91,10 +111,11 @@ class ConfigCommands:
raise cmdexc.CommandError("set: The following arguments "
"are required: value")
else:
self._config.set_str(option, value, save_yaml=not temp)
self._config.set_str(option, value, pattern=pattern,
save_yaml=not temp)
if print_:
self._print_value(option)
self._print_value(option, pattern=pattern)
@cmdutils.register(instance='config-commands', maxsplit=1,
no_cmd_split=True, no_replace_variables=True)
@ -161,18 +182,24 @@ class ConfigCommands:
@cmdutils.register(instance='config-commands', star_args_optional=True)
@cmdutils.argument('option', completion=configmodel.option)
@cmdutils.argument('values', completion=configmodel.value)
def config_cycle(self, option, *values, temp=False, print_=False):
@cmdutils.argument('pattern', flag='u')
def config_cycle(self, option, *values, pattern=None, temp=False,
print_=False):
"""Cycle an option between multiple values.
Args:
option: The name of the option.
values: The values to cycle through.
pattern: The URL pattern to use.
temp: Set value temporarily until qutebrowser is closed.
print_: Print the value after setting.
"""
pattern = self._parse_pattern(pattern)
with self._handle_config_error():
opt = self._config.get_opt(option)
old_value = self._config.get_obj(option, mutable=False)
old_value = self._config.get_obj_for_pattern(option,
pattern=pattern)
if not values and isinstance(opt.typ, configtypes.Bool):
values = ['true', 'false']
@ -194,10 +221,11 @@ class ConfigCommands:
value = values[0]
with self._handle_config_error():
self._config.set_obj(option, value, save_yaml=not temp)
self._config.set_obj(option, value, pattern=pattern,
save_yaml=not temp)
if print_:
self._print_value(option)
self._print_value(option, pattern=pattern)
@cmdutils.register(instance='config-commands')
@cmdutils.argument('option', completion=configmodel.customized_option)
@ -291,13 +319,16 @@ class ConfigCommands:
"overwrite!".format(filename))
if defaults:
options = [(opt, opt.default)
options = [(None, opt, opt.default)
for _name, opt in sorted(configdata.DATA.items())]
bindings = dict(configdata.DATA['bindings.default'].default)
commented = True
else:
options = list(self._config)
bindings = dict(self._config.get_obj('bindings.commands'))
options = []
for values in self._config:
for scoped in values:
options.append((scoped.pattern, values.opt, scoped.value))
bindings = dict(self._config.get_mutable_obj('bindings.commands'))
commented = False
writer = configfiles.ConfigPyWriter(options, bindings,

View File

@ -48,6 +48,7 @@ class Option:
backends = attr.ib()
raw_backends = attr.ib()
description = attr.ib()
supports_pattern = attr.ib(default=False)
restart = attr.ib(default=False)
@ -197,7 +198,8 @@ def _read_yaml(yaml_data):
migrations = Migrations()
data = utils.yaml_load(yaml_data)
keys = {'type', 'default', 'desc', 'backend', 'restart'}
keys = {'type', 'default', 'desc', 'backend', 'restart',
'supports_pattern'}
for name, option in data.items():
if set(option.keys()) == {'renamed'}:
@ -223,7 +225,9 @@ def _read_yaml(yaml_data):
backends=_parse_yaml_backends(name, backends),
raw_backends=backends if isinstance(backends, dict) else None,
description=option['desc'],
restart=option.get('restart', False))
restart=option.get('restart', False),
supports_pattern=option.get('supports_pattern', False),
)
# Make sure no key shadows another.
for key1 in parsed:

View File

@ -240,6 +240,7 @@ content.cache.appcache:
default: true
type: Bool
backend: QtWebKit
supports_pattern: true
desc: >-
Enable support for the HTML 5 web application cache feature.
@ -298,12 +299,14 @@ content.dns_prefetch:
default: true
type: Bool
backend: QtWebKit
supports_pattern: true
desc: Try to pre-fetch DNS entries to speed up browsing.
content.frame_flattening:
default: false
type: Bool
backend: QtWebKit
supports_pattern: true
desc: >-
Expand each subframe to its contents.
@ -459,12 +462,14 @@ content.host_blocking.whitelist:
content.hyperlink_auditing:
default: false
type: Bool
supports_pattern: true
desc: Enable hyperlink auditing (`<a ping>`).
content.images:
default: true
type: Bool
desc: Load images automatically in web pages.
supports_pattern: true
content.javascript.alert:
default: true
@ -474,6 +479,7 @@ content.javascript.alert:
content.javascript.can_access_clipboard:
default: false
type: Bool
supports_pattern: true
desc: >-
Allow JavaScript to read from or write to the clipboard.
@ -484,16 +490,19 @@ content.javascript.can_close_tabs:
default: false
type: Bool
backend: QtWebKit
supports_pattern: true
desc: Allow JavaScript to close tabs.
content.javascript.can_open_tabs_automatically:
default: false
type: Bool
supports_pattern: true
desc: Allow JavaScript to open new tabs without user interaction.
content.javascript.enabled:
default: true
type: Bool
supports_pattern: true
desc: Enable JavaScript.
content.javascript.log:
@ -536,16 +545,19 @@ content.javascript.prompt:
content.local_content_can_access_remote_urls:
default: false
type: Bool
supports_pattern: true
desc: Allow locally loaded documents to access remote URLs.
content.local_content_can_access_file_urls:
default: true
type: Bool
supports_pattern: true
desc: Allow locally loaded documents to access other local URLs.
content.local_storage:
default: true
type: Bool
supports_pattern: true
desc: Enable support for HTML 5 local storage and Web SQL.
content.media_capture:
@ -583,6 +595,7 @@ content.pdfjs:
content.plugins:
default: false
type: Bool
supports_pattern: true
desc: Enable plugins in Web pages.
content.print_element_backgrounds:
@ -591,6 +604,7 @@ content.print_element_backgrounds:
backend:
QtWebKit: true
QtWebEngine: Qt 5.8
supports_pattern: true
desc: >-
Draw the background color and images also when the page is printed.
@ -631,11 +645,13 @@ content.user_stylesheets:
content.webgl:
default: true
type: Bool
supports_pattern: true
desc: Enable WebGL.
content.xss_auditing:
type: Bool
default: false
supports_pattern: true
desc: >-
Monitor load requests for cross-site scripting attempts.
@ -978,6 +994,7 @@ input.insert_mode.plugins:
input.links_included_in_focus_chain:
default: true
type: Bool
supports_pattern: true
desc: Include hyperlinks in the keyboard focus chain when tabbing.
input.partial_timeout:
@ -1003,6 +1020,7 @@ input.rocker_gestures:
input.spatial_navigation:
default: false
type: Bool
supports_pattern: true
desc: >-
Enable spatial navigation.
@ -1083,6 +1101,7 @@ scrolling.bar:
scrolling.smooth:
type: Bool
default: false
supports_pattern: true
desc: >-
Enable smooth scrolling for web pages.
@ -1557,6 +1576,7 @@ zoom.text_only:
type: Bool
default: false
backend: QtWebKit
supports_pattern: true
desc: Apply the zoom factor on a frame only to the text or to all content.
## colors
@ -2309,6 +2329,12 @@ bindings.default:
<Ctrl-p>: tab-pin
q: record-macro
"@": run-macro
tsh: config-cycle -p -t -u *://{url:host}/* content.javascript.enabled ;; reload
tSh: config-cycle -p -u *://{url:host}/* content.javascript.enabled ;; reload
tsH: config-cycle -p -t -u *://*.{url:host}/* content.javascript.enabled ;; reload
tSH: config-cycle -p -u *://*.{url:host}/* content.javascript.enabled ;; reload
tsu: config-cycle -p -t -u {url} content.javascript.enabled ;; reload
tSu: config-cycle -p -u {url} content.javascript.enabled ;; reload
insert:
<Ctrl-E>: open-editor
<Shift-Ins>: insert-text {primary}

View File

@ -40,6 +40,15 @@ class BackendError(Error):
"backend!".format(name, backend.name))
class NoPatternError(Error):
"""Raised when the given setting does not support URL patterns."""
def __init__(self, name):
super().__init__("The {} setting does not support URL patterns!"
.format(name))
class ValidationError(Error):
"""Raised when a value for a config type was invalid.

View File

@ -32,8 +32,8 @@ import yaml
from PyQt5.QtCore import pyqtSignal, QObject, QSettings
import qutebrowser
from qutebrowser.config import configexc, config, configdata
from qutebrowser.utils import standarddir, utils, qtutils, log
from qutebrowser.config import configexc, config, configdata, configutils
from qutebrowser.utils import standarddir, utils, qtutils, log, urlmatch
# The StateConfig instance
@ -80,16 +80,19 @@ class YamlConfig(QObject):
VERSION: The current version number of the config file.
"""
VERSION = 1
VERSION = 2
changed = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._filename = os.path.join(standarddir.config(auto=True),
'autoconfig.yml')
self._values = {}
self._dirty = None
self._values = {}
for name, opt in configdata.DATA.items():
self._values[name] = configutils.Values(opt)
def init_save_manager(self, save_manager):
"""Make sure the config gets saved properly.
@ -98,18 +101,9 @@ class YamlConfig(QObject):
"""
save_manager.add_saveable('yaml-config', self._save, self.changed)
def __getitem__(self, name):
return self._values[name]
def __setitem__(self, name, value):
self._values[name] = value
self._mark_changed()
def __contains__(self, name):
return name in self._values
def __iter__(self):
return iter(sorted(self._values.items()))
"""Iterate over configutils.Values items."""
yield from self._values.values()
def _mark_changed(self):
"""Mark the YAML config as changed."""
@ -121,7 +115,17 @@ class YamlConfig(QObject):
if not self._dirty:
return
data = {'config_version': self.VERSION, 'global': self._values}
settings = {}
for name, values in sorted(self._values.items()):
if not values:
continue
settings[name] = {}
for scoped in values:
key = ('global' if scoped.pattern is None
else str(scoped.pattern))
settings[name][key] = scoped.value
data = {'config_version': self.VERSION, 'settings': settings}
with qtutils.savefile_open(self._filename) as f:
f.write(textwrap.dedent("""
# DO NOT edit this file by hand, qutebrowser will overwrite it.
@ -130,6 +134,29 @@ class YamlConfig(QObject):
""".lstrip('\n')))
utils.yaml_dump(data, f)
def _pop_object(self, yaml_data, key, typ):
"""Get a global object from the given data."""
if not isinstance(yaml_data, dict):
desc = configexc.ConfigErrorDesc("While loading data",
"Toplevel object is not a dict")
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
if key not in yaml_data:
desc = configexc.ConfigErrorDesc(
"While loading data",
"Toplevel object does not contain '{}' key".format(key))
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
data = yaml_data.pop(key)
if not isinstance(data, typ):
desc = configexc.ConfigErrorDesc(
"While loading data",
"'{}' object is not a {}".format(key, typ.__name__))
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
return data
def load(self):
"""Load configuration from the configured YAML file."""
try:
@ -144,76 +171,126 @@ class YamlConfig(QObject):
desc = configexc.ConfigErrorDesc("While parsing", e)
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
try:
global_obj = yaml_data['global']
except KeyError:
config_version = self._pop_object(yaml_data, 'config_version', int)
if config_version == 1:
settings = self._load_legacy_settings_object(yaml_data)
self._mark_changed()
elif config_version > self.VERSION:
desc = configexc.ConfigErrorDesc(
"While loading data",
"Toplevel object does not contain 'global' key")
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
except TypeError:
desc = configexc.ConfigErrorDesc("While loading data",
"Toplevel object is not a dict")
"While reading",
"Can't read config from incompatible newer version")
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
else:
settings = self._load_settings_object(yaml_data)
self._dirty = False
if not isinstance(global_obj, dict):
desc = configexc.ConfigErrorDesc(
"While loading data",
"'global' object is not a dict")
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
settings = self._handle_migrations(settings)
self._validate(settings)
self._build_values(settings)
self._values = global_obj
self._dirty = False
def _load_settings_object(self, yaml_data):
"""Load the settings from the settings: key."""
return self._pop_object(yaml_data, 'settings', dict)
self._handle_migrations()
self._validate()
def _load_legacy_settings_object(self, yaml_data):
data = self._pop_object(yaml_data, 'global', dict)
settings = {}
for name, value in data.items():
settings[name] = {'global': value}
return settings
def _handle_migrations(self):
def _build_values(self, settings):
"""Build up self._values from the values in the given dict."""
errors = []
for name, yaml_values in settings.items():
if not isinstance(yaml_values, dict):
errors.append(configexc.ConfigErrorDesc(
"While parsing {!r}".format(name), "value is not a dict"))
continue
values = configutils.Values(configdata.DATA[name])
if 'global' in yaml_values:
values.add(yaml_values.pop('global'))
for pattern, value in yaml_values.items():
if not isinstance(pattern, str):
errors.append(configexc.ConfigErrorDesc(
"While parsing {!r}".format(name),
"pattern is not of type string"))
continue
try:
urlpattern = urlmatch.UrlPattern(pattern)
except urlmatch.ParseError as e:
errors.append(configexc.ConfigErrorDesc(
"While parsing pattern {!r} for {!r}"
.format(pattern, name), e))
continue
values.add(value, urlpattern)
self._values[name] = values
if errors:
raise configexc.ConfigFileErrors('autoconfig.yml', errors)
def _handle_migrations(self, settings):
"""Migrate older configs to the newest format."""
# Simple renamed/deleted options
for name in list(self._values):
for name in list(settings):
if name in configdata.MIGRATIONS.renamed:
new_name = configdata.MIGRATIONS.renamed[name]
log.config.debug("Renaming {} to {}".format(name, new_name))
self._values[new_name] = self._values[name]
del self._values[name]
settings[new_name] = settings[name]
del settings[name]
self._mark_changed()
elif name in configdata.MIGRATIONS.deleted:
log.config.debug("Removing {}".format(name))
del self._values[name]
del settings[name]
self._mark_changed()
# tabs.persist_mode_on_change got merged into tabs.mode_on_change
old = 'tabs.persist_mode_on_change'
new = 'tabs.mode_on_change'
if old in self._values:
if self._values[old]:
self._values[new] = 'persist'
else:
self._values[new] = 'normal'
del self._values[old]
if old in settings:
settings[new] = {}
for scope, val in settings[old].items():
if val:
settings[new][scope] = 'persist'
else:
settings[new][scope] = 'normal'
del settings[old]
self._mark_changed()
def _validate(self):
return settings
def _validate(self, settings):
"""Make sure all settings exist."""
unknown = set(self._values) - set(configdata.DATA)
unknown = []
for name in settings:
if name not in configdata.DATA:
unknown.append(name)
if unknown:
errors = [configexc.ConfigErrorDesc("While loading options",
"Unknown option {}".format(e))
for e in sorted(unknown)]
raise configexc.ConfigFileErrors('autoconfig.yml', errors)
def unset(self, name):
"""Remove the given option name if it's configured."""
try:
del self._values[name]
except KeyError:
return
def set_obj(self, name, value, *, pattern=None):
"""Set the given setting to the given value."""
self._values[name].add(value, pattern)
self._mark_changed()
def unset(self, name, *, pattern=None):
"""Remove the given option name if it's configured."""
changed = self._values[name].remove(pattern)
if changed:
self._mark_changed()
def clear(self):
"""Clear all values from the YAML file."""
self._values = []
for values in self._values.values():
values.clear()
self._mark_changed()
@ -242,6 +319,7 @@ class ConfigAPI:
@contextlib.contextmanager
def _handle_error(self, action, name):
"""Catch config-related exceptions and save them in self.errors."""
try:
yield
except configexc.ConfigFileErrors as e:
@ -251,28 +329,38 @@ class ConfigAPI:
except configexc.Error as e:
text = "While {} '{}'".format(action, name)
self.errors.append(configexc.ConfigErrorDesc(text, e))
except urlmatch.ParseError as e:
text = "While {} '{}' and parsing pattern".format(action, name)
self.errors.append(configexc.ConfigErrorDesc(text, e))
def finalize(self):
"""Do work which needs to be done after reading config.py."""
self._config.update_mutables()
def load_autoconfig(self):
"""Load the autoconfig.yml file which is used for :set/:bind/etc."""
with self._handle_error('reading', 'autoconfig.yml'):
read_autoconfig()
def get(self, name):
def get(self, name, pattern=None):
"""Get a setting value from the config, optionally with a pattern."""
with self._handle_error('getting', name):
return self._config.get_obj(name)
urlpattern = urlmatch.UrlPattern(pattern) if pattern else None
return self._config.get_mutable_obj(name, pattern=urlpattern)
def set(self, name, value):
def set(self, name, value, pattern=None):
"""Set a setting value in the config, optionally with a pattern."""
with self._handle_error('setting', name):
self._config.set_obj(name, value)
urlpattern = urlmatch.UrlPattern(pattern) if pattern else None
self._config.set_obj(name, value, pattern=urlpattern)
def bind(self, key, command, mode='normal'):
"""Bind a key to a command, with an optional key mode."""
with self._handle_error('binding', key):
self._keyconfig.bind(key, command, mode=mode)
def unbind(self, key, mode='normal'):
"""Unbind a key from a command, with an optional key mode."""
with self._handle_error('unbinding', key):
self._keyconfig.unbind(key, mode=mode)
@ -286,6 +374,16 @@ class ConfigAPI:
except configexc.ConfigFileErrors as e:
self.errors += e.errors
@contextlib.contextmanager
def pattern(self, pattern):
"""Get a ConfigContainer for the given pattern."""
# We need to propagate the exception so we don't need to return
# something.
urlpattern = urlmatch.UrlPattern(pattern)
container = config.ConfigContainer(config=self._config, configapi=self,
pattern=urlpattern)
yield container
class ConfigPyWriter:
@ -344,7 +442,7 @@ class ConfigPyWriter:
def _gen_options(self):
"""Generate the options part of the config."""
for opt, value in self._options:
for pattern, opt, value in self._options:
if opt.name in ['bindings.commands', 'bindings.default']:
continue
@ -363,7 +461,11 @@ class ConfigPyWriter:
except KeyError:
yield self._line("# - {}".format(val))
yield self._line('c.{} = {!r}'.format(opt.name, value))
if pattern is None:
yield self._line('c.{} = {!r}'.format(opt.name, value))
else:
yield self._line('config.set({!r}, {!r}, {!r})'.format(
opt.name, value, str(pattern)))
yield ''
def _gen_bindings(self):

View File

@ -0,0 +1,186 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Utilities and data structures used by various config code."""
import attr
from qutebrowser.utils import utils
from qutebrowser.config import configexc
class _UnsetObject:
"""Sentinel object."""
__slots__ = ()
def __repr__(self):
return '<UNSET>'
UNSET = _UnsetObject()
@attr.s
class ScopedValue:
"""A configuration value which is valid for a UrlPattern.
Attributes:
value: The value itself.
pattern: The UrlPattern for the value, or None for global values.
"""
value = attr.ib()
pattern = attr.ib()
class Values:
"""A collection of values for a single setting.
Currently, this is a list and iterates through all possible ScopedValues to
find matching ones.
In the future, it should be possible to optimize this by doing
pre-selection based on hosts, by making this a dict mapping the
non-wildcard part of the host to a list of matching ScopedValues.
That way, when searching for a setting for sub.example.com, we only have to
check 'sub.example.com', 'example.com', '.com' and '' instead of checking
all ScopedValues for the given setting.
Attributes:
opt: The Option being customized.
"""
def __init__(self, opt, values=None):
self.opt = opt
self._values = values or []
def __repr__(self):
return utils.get_repr(self, opt=self.opt, values=self._values,
constructor=True)
def __str__(self):
"""Get the values as human-readable string."""
if not self:
return '{}: <unchanged>'.format(self.opt.name)
lines = []
for scoped in self._values:
str_value = self.opt.typ.to_str(scoped.value)
if scoped.pattern is None:
lines.append('{} = {}'.format(self.opt.name, str_value))
else:
lines.append('{}: {} = {}'.format(
scoped.pattern, self.opt.name, str_value))
return '\n'.join(lines)
def __iter__(self):
"""Yield ScopedValue elements.
This yields in "normal" order, i.e. global and then first-set settings
first.
"""
yield from self._values
def __bool__(self):
"""Check whether this value is customized."""
return bool(self._values)
def _check_pattern_support(self, arg):
"""Make sure patterns are supported if one was given."""
if arg is not None and not self.opt.supports_pattern:
raise configexc.NoPatternError(self.opt.name)
def add(self, value, pattern=None):
"""Add a value with the given pattern to the list of values."""
self._check_pattern_support(pattern)
self.remove(pattern)
scoped = ScopedValue(value, pattern)
self._values.append(scoped)
def remove(self, pattern=None):
"""Remove the value with the given pattern.
If a matching pattern was removed, True is returned.
If no matching pattern was found, False is returned.
"""
self._check_pattern_support(pattern)
old_len = len(self._values)
self._values = [v for v in self._values if v.pattern != pattern]
return old_len != len(self._values)
def clear(self):
"""Clear all customization for this value."""
self._values = []
def _get_fallback(self, fallback):
"""Get the fallback global/default value."""
for scoped in self._values:
if scoped.pattern is None:
return scoped.value
if fallback:
return self.opt.default
else:
return UNSET
def get_for_url(self, url=None, *, fallback=True):
"""Get a config value, falling back when needed.
This first tries to find a value matching the URL (if given).
If there's no match:
With fallback=True, the global/default setting is returned.
With fallback=False, UNSET is returned.
"""
self._check_pattern_support(url)
if url is not None:
for scoped in reversed(self._values):
if scoped.pattern is not None and scoped.pattern.matches(url):
return scoped.value
if not fallback:
return UNSET
return self._get_fallback(fallback)
def get_for_pattern(self, pattern, *, fallback=True):
"""Get a value only if it's been overridden for the given pattern.
This is useful when showing values to the user.
If there's no match:
With fallback=True, the global/default setting is returned.
With fallback=False, UNSET is returned.
"""
self._check_pattern_support(pattern)
if pattern is not None:
for scoped in reversed(self._values):
if scoped.pattern == pattern:
return scoped.value
if not fallback:
return UNSET
return self._get_fallback(fallback)

View File

@ -17,195 +17,150 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
# We get various "abstract but not overridden" warnings
# pylint: disable=abstract-method
"""Bridge from QWeb(Engine)Settings to our own settings."""
from PyQt5.QtGui import QFont
from qutebrowser.config import config
from qutebrowser.utils import log, utils, debug, usertypes
from qutebrowser.config import config, configutils
from qutebrowser.utils import log, usertypes
from qutebrowser.misc import objects
UNSET = object()
class Base:
class AbstractSettings:
"""Base class for QWeb(Engine)Settings wrappers."""
"""Abstract base class for settings set via QWeb(Engine)Settings."""
def __init__(self, default=UNSET):
self._default = default
_ATTRIBUTES = None
_FONT_SIZES = None
_FONT_FAMILIES = None
_FONT_TO_QFONT = None
def _get_global_settings(self):
"""Get a list of global QWeb(Engine)Settings to use."""
raise NotImplementedError
def __init__(self, settings):
self._settings = settings
def _get_settings(self, settings):
"""Get a list of QWeb(Engine)Settings objects to use.
def set_attribute(self, name, value):
"""Set the given QWebSettings/QWebEngineSettings attribute.
Args:
settings: The QWeb(Engine)Settings instance to use, or None to use
the global instance.
If the value is configutils.UNSET, the value is reset instead.
Return:
A list of QWeb(Engine)Settings objects. The first one should be
used for reading.
True if there was a change, False otherwise.
"""
if settings is None:
return self._get_global_settings()
else:
return [settings]
old_value = self.test_attribute(name)
def set(self, value, settings=None):
"""Set the value of this setting.
Args:
value: The value to set, or None to restore the default.
settings: The QWeb(Engine)Settings instance to use, or None to use
the global instance.
"""
if value is None:
self.set_default(settings=settings)
else:
self._set(value, settings=settings)
def set_default(self, settings=None):
"""Set the default value for this setting.
Not implemented for most settings.
"""
if self._default is UNSET:
raise ValueError("No default set for {!r}".format(self))
else:
self._set(self._default, settings=settings)
def _set(self, value, settings):
"""Inner function to set the value of this setting.
Must be overridden by subclasses.
Args:
value: The value to set.
settings: The QWeb(Engine)Settings instance to use, or None to use
the global instance.
"""
raise NotImplementedError
class Attribute(Base):
"""A setting set via QWeb(Engine)Settings::setAttribute.
Attributes:
self._attributes: A list of QWeb(Engine)Settings::WebAttribute members.
"""
ENUM_BASE = None
def __init__(self, *attributes, default=UNSET):
super().__init__(default=default)
self._attributes = list(attributes)
def __repr__(self):
attributes = [debug.qenum_key(self.ENUM_BASE, attr)
for attr in self._attributes]
return utils.get_repr(self, attributes=attributes, constructor=True)
def _set(self, value, settings=None):
for obj in self._get_settings(settings):
for attribute in self._attributes:
obj.setAttribute(attribute, value)
class Setter(Base):
"""A setting set via a QWeb(Engine)Settings setter method.
This will pass the QWeb(Engine)Settings instance ("self") as first argument
to the methods, so self._setter is the *unbound* method.
Attributes:
_setter: The unbound QWeb(Engine)Settings method to set this value.
_args: An iterable of the arguments to pass to the setter (before the
value).
_unpack: Whether to unpack args (True) or pass them directly (False).
"""
def __init__(self, setter, args=(), unpack=False, default=UNSET):
super().__init__(default=default)
self._setter = setter
self._args = args
self._unpack = unpack
def __repr__(self):
return utils.get_repr(self, setter=self._setter, args=self._args,
unpack=self._unpack, constructor=True)
def _set(self, value, settings=None):
for obj in self._get_settings(settings):
args = [obj]
args.extend(self._args)
if self._unpack:
args.extend(value)
for attribute in self._ATTRIBUTES[name]:
if value is configutils.UNSET:
self._settings.resetAttribute(attribute)
new_value = self.test_attribute(name)
else:
args.append(value)
self._setter(*args)
self._settings.setAttribute(attribute, value)
new_value = value
return old_value != new_value
class StaticSetter(Setter):
def test_attribute(self, name):
"""Get the value for the given attribute.
"""A setting set via a static QWeb(Engine)Settings method.
If the setting resolves to a list of attributes, only the first
attribute is tested.
"""
return self._settings.testAttribute(self._ATTRIBUTES[name][0])
self._setter is the *bound* method.
"""
def set_font_size(self, name, value):
"""Set the given QWebSettings/QWebEngineSettings font size.
def _set(self, value, settings=None):
if settings is not None:
raise ValueError("'settings' may not be set with StaticSetters!")
args = list(self._args)
if self._unpack:
args.extend(value)
else:
args.append(value)
self._setter(*args)
Return:
True if there was a change, False otherwise.
"""
assert value is not configutils.UNSET
family = self._FONT_SIZES[name]
old_value = self._settings.fontSize(family)
self._settings.setFontSize(family, value)
return old_value != value
def set_font_family(self, name, value):
"""Set the given QWebSettings/QWebEngineSettings font family.
class FontFamilySetter(Setter):
With None (the default), QFont is used to get the default font for the
family.
"""A setter for a font family.
Return:
True if there was a change, False otherwise.
"""
assert value is not configutils.UNSET
family = self._FONT_FAMILIES[name]
if value is None:
font = QFont()
font.setStyleHint(self._FONT_TO_QFONT[family])
value = font.defaultFamily()
Gets the default value from QFont.
"""
old_value = self._settings.fontFamily(family)
self._settings.setFontFamily(family, value)
def __init__(self, setter, font, qfont):
super().__init__(setter=setter, args=[font])
self._qfont = qfont
return value != old_value
def set_default(self, settings=None):
font = QFont()
font.setStyleHint(self._qfont)
value = font.defaultFamily()
self._set(value, settings=settings)
def set_default_text_encoding(self, encoding):
"""Set the default text encoding to use.
Return:
True if there was a change, False otherwise.
"""
assert encoding is not configutils.UNSET
old_value = self._settings.defaultTextEncoding()
self._settings.setDefaultTextEncoding(encoding)
return old_value != encoding
def init_mappings(mappings):
"""Initialize all settings based on a settings mapping."""
for option, mapping in mappings.items():
value = config.instance.get(option)
log.config.vdebug("Setting {} to {!r}".format(option, value))
mapping.set(value)
def _update_setting(self, setting, value):
"""Update the given setting/value.
Unknown settings are ignored.
def update_mappings(mappings, option):
"""Update global settings when QWeb(Engine)Settings changed."""
try:
mapping = mappings[option]
except KeyError:
return
value = config.instance.get(option)
mapping.set(value)
Return:
True if there was a change, False otherwise.
"""
if setting in self._ATTRIBUTES:
return self.set_attribute(setting, value)
elif setting in self._FONT_SIZES:
return self.set_font_size(setting, value)
elif setting in self._FONT_FAMILIES:
return self.set_font_family(setting, value)
elif setting == 'content.default_encoding':
return self.set_default_text_encoding(value)
return False
def update_setting(self, setting):
"""Update the given setting."""
value = config.instance.get(setting)
self._update_setting(setting, value)
def update_for_url(self, url):
"""Update settings customized for the given tab.
Return:
A set of settings which actually changed.
"""
changed_settings = set()
for values in config.instance:
if not values.opt.supports_pattern:
continue
value = values.get_for_url(url, fallback=False)
changed = self._update_setting(values.opt.name, value)
if changed:
log.config.debug("Changed for {}: {} = {}".format(
url.toDisplayString(), values.opt.name, value))
changed_settings.add(values.opt.name)
return changed_settings
def init_settings(self):
"""Set all supported settings correctly."""
for setting in (list(self._ATTRIBUTES) + list(self._FONT_SIZES) +
list(self._FONT_FAMILIES)):
self.update_setting(setting)
def init(args):

View File

@ -0,0 +1,277 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""A Chromium-like URL matching pattern.
See:
https://developer.chrome.com/apps/match_patterns
https://cs.chromium.org/chromium/src/extensions/common/url_pattern.cc
https://cs.chromium.org/chromium/src/extensions/common/url_pattern.h
"""
import ipaddress
import fnmatch
import urllib.parse
from qutebrowser.utils import utils, qtutils
class ParseError(Exception):
"""Raised when a pattern could not be parsed."""
class UrlPattern:
"""A Chromium-like URL matching pattern.
Class attributes:
DEFAULT_PORTS: The default ports used for schemes which support ports.
Attributes:
_pattern: The given pattern as string.
_match_all: Whether the pattern should match all URLs.
_match_subdomains: Whether the pattern should match subdomains of the
given host.
_scheme: The scheme to match to, or None to match any scheme.
Note that with Chromium, '*'/None only matches http/https and
not file/ftp. We deviate from that as per-URL settings aren't
security relevant.
_host: The host to match to, or None for any host.
_path: The path to match to, or None for any path.
_port: The port to match to as integer, or None for any port.
"""
DEFAULT_PORTS = {'https': 443, 'http': 80, 'ftp': 21}
def __init__(self, pattern):
# Make sure all attributes are initialized if we exit early.
self._pattern = pattern
self._match_all = False
self._match_subdomains = False
self._scheme = None
self._host = None
self._path = None
self._port = None
# > The special pattern <all_urls> matches any URL that starts with a
# > permitted scheme.
if pattern == '<all_urls>':
self._match_all = True
return
if '\0' in pattern:
raise ParseError("May not contain NUL byte")
pattern = self._fixup_pattern(pattern)
# We use urllib.parse instead of QUrl here because it can handle
# hosts with * in them.
try:
parsed = urllib.parse.urlparse(pattern)
except ValueError as e:
raise ParseError(str(e))
assert parsed is not None
self._init_scheme(parsed)
self._init_host(parsed)
self._init_path(parsed)
self._init_port(parsed)
def _to_tuple(self):
"""Get a pattern with information used for __eq__/__hash__."""
return (self._match_all, self._match_subdomains, self._scheme,
self._host, self._path, self._port)
def __hash__(self):
return hash(self._to_tuple())
def __eq__(self, other):
if not isinstance(other, UrlPattern):
return NotImplemented
# pylint: disable=protected-access
return self._to_tuple() == other._to_tuple()
def __repr__(self):
return utils.get_repr(self, pattern=self._pattern, constructor=True)
def __str__(self):
return self._pattern
def _fixup_pattern(self, pattern):
"""Make sure the given pattern is parseable by urllib.parse."""
if pattern.startswith('*:'): # Any scheme, but *:// is unparseable
pattern = 'any:' + pattern[2:]
# Chromium handles file://foo like file:///foo
# FIXME This doesn't actually strip the hostname correctly.
if (pattern.startswith('file://') and
not pattern.startswith('file:///')):
pattern = 'file:///' + pattern[len("file://"):]
return pattern
def _init_scheme(self, parsed):
if not parsed.scheme:
raise ParseError("No scheme given")
elif parsed.scheme == 'any':
self._scheme = None
return
self._scheme = parsed.scheme
def _init_path(self, parsed):
if self._scheme == 'about' and not parsed.path.strip():
raise ParseError("Pattern without path")
if parsed.path == '/*':
self._path = None
elif parsed.path == '':
# We want to make it possible to leave off a trailing slash.
self._path = '/'
else:
self._path = parsed.path
def _init_host(self, parsed):
"""Parse the host from the given URL.
Deviation from Chromium:
- http://:1234/ is not a valid URL because it has no host.
"""
if parsed.hostname is None or not parsed.hostname.strip():
if self._scheme not in ['about', 'file', 'data', 'javascript']:
raise ParseError("Pattern without host")
assert self._host is None
return
# FIXME what about multiple dots?
host_parts = parsed.hostname.rstrip('.').split('.')
if host_parts[0] == '*':
host_parts = host_parts[1:]
self._match_subdomains = True
if not host_parts:
self._host = None
return
self._host = '.'.join(host_parts)
if self._host.endswith('.*'):
# Special case to have a nicer error
raise ParseError("TLD wildcards are not implemented yet")
elif '*' in self._host:
# Only * or *.foo is allowed as host.
raise ParseError("Invalid host wildcard")
def _init_port(self, parsed):
"""Parse the port from the given URL.
Deviation from Chromium:
- We use None instead of "*" if there's no port filter.
"""
if parsed.netloc.endswith(':*'):
# We can't access parsed.port as it tries to run int()
self._port = None
elif parsed.netloc.endswith(':'):
raise ParseError("Invalid port: Port is empty")
else:
try:
self._port = parsed.port
except ValueError as e:
raise ParseError("Invalid port: {}".format(e))
if (self._scheme not in list(self.DEFAULT_PORTS) + [None] and
self._port is not None):
raise ParseError("Ports are unsupported with {} scheme".format(
self._scheme))
def _matches_scheme(self, scheme):
return self._scheme is None or self._scheme == scheme
def _matches_host(self, host):
# FIXME what about multiple dots?
host = host.rstrip('.')
# If we have no host in the match pattern, that means that we're
# matching all hosts, which means we have a match no matter what the
# test host is.
# Contrary to Chromium, we don't need to check for
# self._match_subdomains, as we want to return True here for e.g.
# file:// as well.
if self._host is None:
return True
# If the hosts are exactly equal, we have a match.
if host == self._host:
return True
# Otherwise, we can only match if our match pattern matches subdomains.
if not self._match_subdomains:
return False
# We don't do subdomain matching against IP addresses, so we can give
# up now if the test host is an IP address.
if not utils.raises(ValueError, ipaddress.ip_address, host):
return False
# Check if the test host is a subdomain of our host.
if len(host) <= (len(self._host) + 1):
return False
if not host.endswith(self._host):
return False
return host[len(host) - len(self._host) - 1] == '.'
def _matches_port(self, scheme, port):
if port == -1 and scheme in self.DEFAULT_PORTS:
port = self.DEFAULT_PORTS[scheme]
return self._port is None or self._port == port
def _matches_path(self, path):
if self._path is None:
return True
# Match 'google.com' with 'google.com/'
if path + '/*' == self._path:
return True
# FIXME Chromium seems to have a more optimized glob matching which
# doesn't rely on regexes. Do we need that too?
return fnmatch.fnmatchcase(path, self._path)
def matches(self, qurl):
"""Check if the pattern matches the given QUrl."""
qtutils.ensure_valid(qurl)
if self._match_all:
return True
if not self._matches_scheme(qurl.scheme()):
return False
# FIXME ignore for file:// like Chromium?
if not self._matches_host(qurl.host()):
return False
if not self._matches_port(qurl.scheme(), qurl.port()):
return False
if not self._matches_path(qurl.path()):
return False
return True

View File

@ -27,6 +27,7 @@ import operator
import collections.abc
import enum
import attr
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QTimer
from qutebrowser.utils import log, qtutils, utils
@ -394,3 +395,24 @@ class AbstractCertificateErrorWrapper:
def is_overridable(self):
raise NotImplementedError
@attr.s
class NavigationRequest:
"""A request to navigate to the given URL."""
Type = enum.Enum('Type', [
'link_clicked',
'typed', # QtWebEngine only
'form_submitted',
'form_resubmitted', # QtWebKit only
'back_forward',
'reloaded',
'other'
])
url = attr.ib()
navigation_type = attr.ib()
is_main_frame = attr.ib()
accepted = attr.ib(default=True)

View File

@ -143,6 +143,8 @@ PERFECT_FILES = [
'config/configinit.py'),
('tests/unit/config/test_configcommands.py',
'config/configcommands.py'),
('tests/unit/config/test_configutils.py',
'config/configutils.py'),
('tests/unit/utils/test_qtutils.py',
'utils/qtutils.py'),
@ -164,6 +166,8 @@ PERFECT_FILES = [
'utils/error.py'),
('tests/unit/utils/test_javascript.py',
'utils/javascript.py'),
('tests/unit/utils/test_urlmatch.py',
'utils/urlmatch.py'),
(None,
'completion/models/util.py'),

View File

@ -419,6 +419,8 @@ def _generate_setting_option(f, opt):
f.write(opt.description + "\n")
if opt.restart:
f.write("This setting requires a restart.\n")
if opt.supports_pattern:
f.write("\nThis setting supports URL patterns.\n")
f.write("\n")
typ = opt.typ.get_name().replace(',', '&#44;')
f.write('Type: <<types,{typ}>>\n'.format(typ=typ))

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript">
function check_enabled() {
const elem = document.getElementById("status");
elem.innerHTML = "enabled";
console.log("JavaScript is enabled");
}
</script>
</head>
<body onload="check_enabled()">
<p>JavaScript is <span id="status">disabled</span></p>
<noscript>noscript tag</noscript>
</body>
</html>

View File

@ -7,8 +7,10 @@
try {
localStorage.qute_test = "foo";
elem.innerHTML = "working";
console.log("local storage is working");
} catch (e) {
elem.innerHTML = "not working";
console.log("local storage is not working");
}
}
</script>

View File

@ -207,7 +207,7 @@ Feature: Using hints
Scenario: Using :follow-hint inside an iframe
When I open data/hints/iframe.html
And I hint with args "links normal" and follow a
Then "navigation request: url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, *" should be logged
Then "navigation request: url http://localhost:*/data/hello.txt, type Type.link_clicked, *" should be logged
Scenario: Using :follow-hint inside an iframe button
When I open data/hints/iframe_button.html
@ -228,12 +228,12 @@ Feature: Using hints
And I hint with args "all normal" and follow a
And I run :scroll bottom
And I hint with args "links normal" and follow a
Then "navigation request: url http://localhost:*/data/hello2.txt, type NavigationTypeLinkClicked, *" should be logged
Then "navigation request: url http://localhost:*/data/hello2.txt, type Type.link_clicked, *" should be logged
Scenario: Opening a link inside a specific iframe
When I open data/hints/iframe_target.html
And I hint with args "links normal" and follow a
Then "navigation request: url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, *" should be logged
Then "navigation request: url http://localhost:*/data/hello.txt, type Type.link_clicked, *" should be logged
Scenario: Opening a link with specific target frame in a new tab
When I open data/hints/iframe_target.html

View File

@ -151,3 +151,25 @@ Feature: Javascript stuff
And I run :greasemonkey-reload
And I open data/hints/iframe.html
Then the javascript message "Script is running on /data/hints/html/wrapped.html" should not be logged
Scenario: Per-URL localstorage setting
When I set content.local_storage to false
And I run :set -u http://localhost:*/data2/* content.local_storage true
And I open data/javascript/localstorage.html
And I wait for "[*] local storage is not working" in the log
And I open data2/javascript/localstorage.html
Then the javascript message "local storage is working" should be logged
Scenario: Per-URL JavaScript setting
When I set content.javascript.enabled to false
And I run :set -u http://localhost:*/data2/* content.javascript.enabled true
And I open data2/javascript/enabled.html
And I wait for "[*] JavaScript is enabled" in the log
And I open data/javascript/enabled.html
Then the page should contain the plaintext "JavaScript is disabled"
@qtwebkit_skip
Scenario: Error pages without JS enabled
When I set content.javascript.enabled to false
And I open 500 without waiting
Then "Showing error page for* 500" should be logged

View File

@ -250,7 +250,7 @@ Feature: Searching on a page
And I run :search follow
And I wait for "search found follow" in the log
And I run :follow-selected
Then "navigation request: url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, is_main_frame False" should be logged
Then "navigation request: url http://localhost:*/data/hello.txt, type Type.link_clicked, is_main_frame False" should be logged
@qtwebkit_skip: Not supported in qtwebkit
Scenario: Follow a tabbed searched link in an iframe

View File

@ -79,6 +79,7 @@ class Request(testprocess.Line):
'/cookies/set': [HTTPStatus.FOUND],
'/500-inline': [HTTPStatus.INTERNAL_SERVER_ERROR],
'/500': [HTTPStatus.INTERNAL_SERVER_ERROR],
}
for i in range(15):
path_to_statuses['/redirect/{}'.format(i)] = [HTTPStatus.FOUND]

View File

@ -49,6 +49,7 @@ def root():
@app.route('/data/<path:path>')
@app.route('/data2/<path:path>') # for per-URL settings
def send_data(path):
"""Send a given data file to qutebrowser.
@ -179,6 +180,14 @@ def internal_error_attachment():
return response
@app.route('/500')
def internal_error():
"""A normal 500 error."""
r = flask.make_response()
r.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
return r
@app.route('/cookies')
def view_cookies():
"""Show cookies."""

View File

@ -42,7 +42,8 @@ from PyQt5.QtNetwork import QNetworkCookieJar
import helpers.stubs as stubsmod
import helpers.utils
from qutebrowser.config import config, configdata, configtypes, configexc
from qutebrowser.config import (config, configdata, configtypes, configexc,
configfiles)
from qutebrowser.utils import objreg, standarddir
from qutebrowser.browser.webkit import cookies
from qutebrowser.misc import savemanager, sql
@ -193,9 +194,9 @@ def configdata_init():
@pytest.fixture
def config_stub(stubs, monkeypatch, configdata_init):
def config_stub(stubs, monkeypatch, configdata_init, config_tmpdir):
"""Fixture which provides a fake config object."""
yaml_config = stubs.FakeYamlConfig()
yaml_config = configfiles.YamlConfig()
conf = config.Config(yaml_config=yaml_config)
monkeypatch.setattr(config, 'instance', conf)

View File

@ -406,33 +406,6 @@ class InstaTimer(QObject):
fun()
class FakeYamlConfig:
"""Fake configfiles.YamlConfig object."""
def __init__(self):
self.loaded = False
self._values = {}
def __contains__(self, item):
return item in self._values
def __iter__(self):
return iter(self._values.items())
def __setitem__(self, key, value):
self._values[key] = value
def __getitem__(self, key):
return self._values[key]
def unset(self, name):
self._values.pop(name, None)
def clear(self):
self._values = []
class StatusBarCommandStub(QLineEdit):
"""Stub for the statusbar command prompt."""

View File

@ -32,7 +32,8 @@ def init_profiles(qapp, config_stub, cache_tmpdir, data_tmpdir):
def test_big_cache_size(config_stub):
"""Make sure a too big cache size is handled correctly."""
config_stub.val.content.cache.size = 2 ** 63 - 1
webenginesettings._update_settings('content.cache.size')
profile = webenginesettings.default_profile
size = webenginesettings.default_profile.httpCacheMaximumSize()
assert size == 2 ** 31 - 1
webenginesettings._set_http_cache_size(profile)
assert profile.httpCacheMaximumSize() == 2 ** 31 - 1

View File

@ -80,9 +80,9 @@ def cmdutils_stub(monkeypatch, stubs):
@pytest.fixture()
def configdata_stub(monkeypatch, configdata_init):
def configdata_stub(config_stub, monkeypatch, configdata_init):
"""Patch the configdata module to provide fake data."""
return monkeypatch.setattr(configdata, 'DATA', collections.OrderedDict([
monkeypatch.setattr(configdata, 'DATA', collections.OrderedDict([
('aliases', configdata.Option(
name='aliases',
description='Aliases for commands.',
@ -132,6 +132,7 @@ def configdata_stub(monkeypatch, configdata_init):
backends=[],
raw_backends=None)),
]))
config_stub._init_values()
@pytest.fixture

View File

@ -23,11 +23,11 @@ import types
import unittest.mock
import pytest
from PyQt5.QtCore import QObject
from PyQt5.QtCore import QObject, QUrl
from PyQt5.QtGui import QColor
from qutebrowser.config import config, configdata, configexc, configfiles
from qutebrowser.utils import usertypes
from qutebrowser.config import config, configdata, configexc, configutils
from qutebrowser.utils import usertypes, urlmatch
from qutebrowser.misc import objects
@ -303,9 +303,15 @@ class TestKeyConfig:
class TestConfig:
@pytest.fixture
def conf(self, config_tmpdir):
yaml_config = configfiles.YamlConfig()
return config.Config(yaml_config)
def conf(self, config_stub):
return config_stub
@pytest.fixture
def yaml_value(self, conf):
"""Fixture which provides a getter for a YAML value."""
def getter(option):
return conf._yaml._values[option].get_for_url(fallback=False)
return getter
def test_init_save_manager(self, conf, fake_save_manager):
conf.init_save_manager(fake_save_manager)
@ -327,10 +333,10 @@ class TestConfig:
monkeypatch.setattr(config.objects, 'backend', objects.NoBackend())
opt = conf.get_opt('tabs.show')
conf._set_value(opt, 'never')
assert conf._values['tabs.show'] == 'never'
assert conf.get_obj('tabs.show') == 'never'
@pytest.mark.parametrize('save_yaml', [True, False])
def test_unset(self, conf, qtbot, save_yaml):
def test_unset(self, conf, qtbot, yaml_value, save_yaml):
name = 'tabs.show'
conf.set_obj(name, 'never', save_yaml=True)
assert conf.get(name) == 'never'
@ -340,9 +346,9 @@ class TestConfig:
assert conf.get(name) == 'always'
if save_yaml:
assert name not in conf._yaml
assert yaml_value(name) is configutils.UNSET
else:
assert conf._yaml[name] == 'never'
assert yaml_value(name) == 'never'
def test_unset_never_set(self, conf, qtbot):
name = 'tabs.show'
@ -353,18 +359,14 @@ class TestConfig:
assert conf.get(name) == 'always'
def test_unset_unknown(self, conf):
with pytest.raises(configexc.NoOptionError):
conf.unset('tabs')
@pytest.mark.parametrize('save_yaml', [True, False])
def test_clear(self, conf, qtbot, save_yaml):
def test_clear(self, conf, qtbot, yaml_value, save_yaml):
name1 = 'tabs.show'
name2 = 'content.plugins'
conf.set_obj(name1, 'never', save_yaml=True)
conf.set_obj(name2, True, save_yaml=True)
assert conf._values[name1] == 'never'
assert conf._values[name2] is True
assert conf.get_obj(name1) == 'never'
assert conf.get_obj(name2) is True
with qtbot.waitSignals([conf.changed, conf.changed]) as blocker:
conf.clear(save_yaml=save_yaml)
@ -373,33 +375,52 @@ class TestConfig:
assert options == {name1, name2}
if save_yaml:
assert name1 not in conf._yaml
assert name2 not in conf._yaml
assert yaml_value(name1) is configutils.UNSET
assert yaml_value(name2) is configutils.UNSET
else:
assert conf._yaml[name1] == 'never'
assert conf._yaml[name2] is True
assert yaml_value(name1) == 'never'
assert yaml_value(name2) is True
def test_read_yaml(self, conf):
conf._yaml['content.plugins'] = True
def test_read_yaml(self, conf, yaml_value):
conf._yaml.set_obj('content.plugins', True)
conf.read_yaml()
assert conf._values['content.plugins'] is True
assert conf.get_obj('content.plugins') is True
def test_get_opt_valid(self, conf):
assert conf.get_opt('tabs.show') == configdata.DATA['tabs.show']
def test_get_opt_invalid(self, conf):
@pytest.mark.parametrize('code', [
lambda c: c.get_opt('tabs'),
lambda c: c.get('tabs'),
lambda c: c.get_obj('tabs'),
lambda c: c.get_obj_for_pattern('tabs', pattern=None),
lambda c: c.get_mutable_obj('tabs'),
lambda c: c.get_str('tabs'),
lambda c: c.set_obj('tabs', 42),
lambda c: c.set_str('tabs', '42'),
lambda c: c.unset('tabs'),
])
def test_no_option_error(self, conf, code):
with pytest.raises(configexc.NoOptionError):
conf.get_opt('tabs')
code(conf)
def test_get(self, conf):
"""Test conf.get() with a QColor (where get/get_obj is different)."""
assert conf.get('colors.completion.category.fg') == QColor('white')
def test_get_for_url(self, conf):
"""Test conf.get() with an URL/pattern."""
pattern = urlmatch.UrlPattern('*://example.com/')
name = 'content.javascript.enabled'
conf.set_obj(name, False, pattern=pattern)
assert conf.get(name, url=QUrl('https://example.com/')) is False
@pytest.mark.parametrize('value', [{}, {'normal': {'a': 'nop'}}])
def test_get_bindings(self, config_stub, conf, value):
"""Test conf.get() with bindings which have missing keys."""
config_stub.val.aliases = {}
conf._values['bindings.commands'] = value
conf.set_obj('bindings.commands', value)
assert conf.get('bindings.commands')['prompt'] == {}
def test_get_mutable(self, conf):
@ -415,7 +436,7 @@ class TestConfig:
'bindings.commands'])
@pytest.mark.parametrize('mutable', [True, False])
@pytest.mark.parametrize('mutated', [True, False])
def test_get_obj_mutable(self, conf, config_stub, qtbot, caplog,
def test_get_obj_mutable(self, conf, qtbot, caplog,
option, mutable, mutated):
"""Make sure mutables are handled correctly.
@ -432,7 +453,7 @@ class TestConfig:
(keyhint.blacklist).
"""
# Setting new value
obj = conf.get_obj(option, mutable=mutable)
obj = conf.get_mutable_obj(option) if mutable else conf.get_obj(option)
with qtbot.assert_not_emitted(conf.changed):
if option == 'content.headers.custom':
old = {}
@ -454,7 +475,6 @@ class TestConfig:
assert obj == new
else:
assert option == 'bindings.commands'
config_stub.val.aliases = {}
old = {}
new = {}
assert obj == old
@ -485,9 +505,9 @@ class TestConfig:
def test_get_mutable_twice(self, conf):
"""Get a mutable value twice."""
option = 'content.headers.custom'
obj = conf.get_obj(option, mutable=True)
obj = conf.get_mutable_obj(option)
obj['X-Foo'] = 'fooval'
obj2 = conf.get_obj(option, mutable=True)
obj2 = conf.get_mutable_obj(option)
obj2['X-Bar'] = 'barval'
conf.update_mutables()
@ -497,9 +517,32 @@ class TestConfig:
def test_get_obj_unknown_mutable(self, conf):
"""Make sure we don't have unknown mutable types."""
conf._values['aliases'] = set() # This would never happen
with pytest.raises(AssertionError):
conf.get_obj('aliases')
conf._maybe_copy(set())
def test_copy_non_mutable(self, conf, mocker):
"""Make sure no copies are done for non-mutable types."""
spy = mocker.spy(config.copy, 'deepcopy')
conf.get_mutable_obj('content.plugins')
assert not spy.called
def test_copy_mutable(self, conf, mocker):
"""Make sure mutable types are only copied once."""
spy = mocker.spy(config.copy, 'deepcopy')
conf.get_mutable_obj('bindings.commands')
spy.assert_called_once_with(mocker.ANY)
def test_get_obj_for_pattern(self, conf):
pattern = urlmatch.UrlPattern('*://example.com')
name = 'content.javascript.enabled'
conf.set_obj(name, False, pattern=pattern)
assert conf.get_obj_for_pattern(name, pattern=pattern) is False
def test_get_obj_for_pattern_no_match(self, conf):
pattern = urlmatch.UrlPattern('*://example.com')
name = 'content.javascript.enabled'
value = conf.get_obj_for_pattern(name, pattern=pattern)
assert value is configutils.UNSET
def test_get_str(self, conf):
assert conf.get_str('content.plugins') == 'false'
@ -509,16 +552,17 @@ class TestConfig:
('set_obj', True),
('set_str', 'true'),
])
def test_set_valid(self, conf, qtbot, save_yaml, method, value):
def test_set_valid(self, conf, qtbot, yaml_value,
save_yaml, method, value):
option = 'content.plugins'
meth = getattr(conf, method)
with qtbot.wait_signal(conf.changed):
meth(option, value, save_yaml=save_yaml)
assert conf._values[option] is True
assert conf.get_obj(option) is True
if save_yaml:
assert conf._yaml[option] is True
assert yaml_value(option) is True
else:
assert option not in conf._yaml
assert yaml_value(option) is configutils.UNSET
@pytest.mark.parametrize('method', ['set_obj', 'set_str'])
def test_set_invalid(self, conf, qtbot, method):
@ -526,7 +570,7 @@ class TestConfig:
with pytest.raises(configexc.ValidationError):
with qtbot.assert_not_emitted(conf.changed):
meth('content.plugins', '42')
assert 'content.plugins' not in conf._values
assert not conf._values['content.plugins']
@pytest.mark.parametrize('method', ['set_obj', 'set_str'])
def test_set_wrong_backend(self, conf, qtbot, monkeypatch, method):
@ -535,7 +579,15 @@ class TestConfig:
with pytest.raises(configexc.BackendError):
with qtbot.assert_not_emitted(conf.changed):
meth('content.cookies.accept', 'all')
assert 'content.cookies.accept' not in conf._values
assert not conf._values['content.cookies.accept']
@pytest.mark.parametrize('method', ['set_obj', 'set_str'])
def test_set_no_pattern(self, conf, method, qtbot):
meth = getattr(conf, method)
pattern = urlmatch.UrlPattern('https://www.example.com/')
with pytest.raises(configexc.NoPatternError):
with qtbot.assert_not_emitted(conf.changed):
meth('colors.statusbar.normal.bg', '#abcdef', pattern=pattern)
def test_dump_userconfig(self, conf):
conf.set_obj('content.plugins', True)
@ -581,7 +633,7 @@ class TestContainer:
def test_setattr_option(self, config_stub, container):
container.content.cookies.store = False
assert config_stub._values['content.cookies.store'] is False
assert config_stub.get_obj('content.cookies.store') is False
def test_confapi_errors(self, container):
configapi = types.SimpleNamespace(errors=[])
@ -593,6 +645,12 @@ class TestContainer:
assert error.text == "While getting 'tabs.foobar'"
assert str(error.exception) == "No option 'tabs.foobar'"
def test_pattern_no_configapi(self, config_stub):
pattern = urlmatch.UrlPattern('https://example.com/')
with pytest.raises(TypeError,
match="Can't use pattern without configapi!"):
config.ConfigContainer(config_stub, pattern=pattern)
class StyleObj(QObject):

View File

@ -24,9 +24,9 @@ import unittest.mock
import pytest
from PyQt5.QtCore import QUrl
from qutebrowser.config import configcommands
from qutebrowser.config import configcommands, configutils
from qutebrowser.commands import cmdexc
from qutebrowser.utils import usertypes
from qutebrowser.utils import usertypes, urlmatch
from qutebrowser.misc import objects
@ -35,6 +35,14 @@ def commands(config_stub, key_config_stub):
return configcommands.ConfigCommands(config_stub, key_config_stub)
@pytest.fixture
def yaml_value(config_stub):
"""Fixture which provides a getter for a YAML value."""
def getter(option):
return config_stub._yaml._values[option].get_for_url(fallback=False)
return getter
class TestSet:
"""Tests for :set."""
@ -64,7 +72,7 @@ class TestSet:
['gvim', '-f', '{file}', '-c', 'normal {line}G{column0}l'],
'[emacs, "{}"]', ['emacs', '{}']),
])
def test_set_simple(self, monkeypatch, commands, config_stub,
def test_set_simple(self, monkeypatch, commands, config_stub, yaml_value,
temp, option, old_value, inp, new_value):
"""Run ':set [-t] option value'.
@ -76,14 +84,38 @@ class TestSet:
commands.set(0, option, inp, temp=temp)
assert config_stub.get(option) == new_value
assert yaml_value(option) == (configutils.UNSET if temp else new_value)
if temp:
assert option not in config_stub._yaml
else:
assert config_stub._yaml[option] == new_value
def test_set_with_pattern(self, monkeypatch, commands, config_stub):
monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit)
option = 'content.javascript.enabled'
commands.set(0, option, 'false', pattern='*://example.com')
pattern = urlmatch.UrlPattern('*://example.com')
assert config_stub.get(option)
assert not config_stub.get_obj_for_pattern(option, pattern=pattern)
def test_set_invalid_pattern(self, monkeypatch, commands):
monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit)
option = 'content.javascript.enabled'
with pytest.raises(cmdexc.CommandError,
match='Error while parsing :/: No scheme given'):
commands.set(0, option, 'false', pattern=':/')
def test_set_no_pattern(self, monkeypatch, commands):
"""Run ':set --pattern=*://* colors.statusbar.normal.bg #abcdef.
Should show an error as patterns are unsupported.
"""
with pytest.raises(cmdexc.CommandError,
match='does not support URL patterns'):
commands.set(0, 'colors.statusbar.normal.bg', '#abcdef',
pattern='*://*')
@pytest.mark.parametrize('temp', [True, False])
def test_set_temp_override(self, commands, config_stub, temp):
def test_set_temp_override(self, commands, config_stub, yaml_value, temp):
"""Invoking :set twice.
:set url.auto_search dns
@ -96,19 +128,28 @@ class TestSet:
commands.set(0, 'url.auto_search', 'never', temp=True)
assert config_stub.val.url.auto_search == 'never'
assert config_stub._yaml['url.auto_search'] == 'dns'
assert yaml_value('url.auto_search') == 'dns'
def test_set_print(self, config_stub, commands, message_mock):
"""Run ':set -p url.auto_search never'.
@pytest.mark.parametrize('pattern', [None, '*://example.com'])
def test_set_print(self, config_stub, commands, message_mock, pattern):
"""Run ':set -p [-u *://example.com] content.javascript.enabled false'.
Should set show the value.
"""
assert config_stub.val.url.auto_search == 'naive'
commands.set(0, 'url.auto_search', 'dns', print_=True)
assert config_stub.val.content.javascript.enabled
commands.set(0, 'content.javascript.enabled', 'false', print_=True,
pattern=pattern)
assert config_stub.val.url.auto_search == 'dns'
value = config_stub.get_obj_for_pattern(
'content.javascript.enabled',
pattern=None if pattern is None else urlmatch.UrlPattern(pattern))
assert not value
expected = 'content.javascript.enabled = false'
if pattern is not None:
expected += ' for {}'.format(pattern)
msg = message_mock.getmsg(usertypes.MessageLevel.info)
assert msg.text == 'url.auto_search = dns'
assert msg.text == expected
def test_set_invalid_option(self, commands):
"""Run ':set foo bar'.
@ -177,13 +218,14 @@ class TestCycle:
# Value which is not in the list
('red', 'green'),
])
def test_cycling(self, commands, config_stub, initial, expected):
def test_cycling(self, commands, config_stub, yaml_value,
initial, expected):
"""Run ':set' with multiple values."""
opt = 'colors.statusbar.normal.bg'
config_stub.set_obj(opt, initial)
commands.config_cycle(opt, 'green', 'magenta', 'blue', 'yellow')
assert config_stub.get(opt) == expected
assert config_stub._yaml[opt] == expected
assert yaml_value(opt) == expected
def test_different_representation(self, commands, config_stub):
"""When using a different representation, cycling should work.
@ -205,7 +247,7 @@ class TestCycle:
assert not config_stub.val.auto_save.session
commands.config_cycle('auto_save.session')
assert config_stub.val.auto_save.session
assert config_stub._yaml['auto_save.session']
assert yaml_value('auto_save.session')
@pytest.mark.parametrize('args', [
['url.auto_search'], ['url.auto_search', 'foo']
@ -239,34 +281,28 @@ class TestUnsetAndClear:
"""Test :config-unset and :config-clear."""
@pytest.mark.parametrize('temp', [True, False])
def test_unset(self, commands, config_stub, temp):
def test_unset(self, commands, config_stub, yaml_value, temp):
name = 'tabs.show'
config_stub.set_obj(name, 'never', save_yaml=True)
commands.config_unset(name, temp=temp)
assert config_stub.get(name) == 'always'
if temp:
assert config_stub._yaml[name] == 'never'
else:
assert name not in config_stub._yaml
assert yaml_value(name) == ('never' if temp else configutils.UNSET)
def test_unset_unknown_option(self, commands):
with pytest.raises(cmdexc.CommandError, match="No option 'tabs'"):
commands.config_unset('tabs')
@pytest.mark.parametrize('save', [True, False])
def test_clear(self, commands, config_stub, save):
def test_clear(self, commands, config_stub, yaml_value, save):
name = 'tabs.show'
config_stub.set_obj(name, 'never', save_yaml=True)
commands.config_clear(save=save)
assert config_stub.get(name) == 'always'
if save:
assert name not in config_stub._yaml
else:
assert config_stub._yaml[name] == 'never'
assert yaml_value(name) == (configutils.UNSET if save else 'never')
class TestSource:
@ -453,7 +489,7 @@ class TestBind:
@pytest.mark.parametrize('command', ['nop', 'nope'])
def test_bind(self, commands, config_stub, no_bindings, key_config_stub,
command):
yaml_value, command):
"""Simple :bind test (and aliases)."""
config_stub.val.aliases = {'nope': 'nop'}
config_stub.val.bindings.default = no_bindings
@ -461,7 +497,7 @@ class TestBind:
commands.bind(0, 'a', command)
assert key_config_stub.get_command('a', 'normal') == command
yaml_bindings = config_stub._yaml['bindings.commands']['normal']
yaml_bindings = yaml_value('bindings.commands')['normal']
assert yaml_bindings['a'] == command
@pytest.mark.parametrize('key, mode, expected', [
@ -573,7 +609,7 @@ class TestBind:
('c', 'c'), # :bind then :unbind
('<Ctrl-X>', '<ctrl+x>') # normalized special binding
])
def test_unbind(self, commands, key_config_stub, config_stub,
def test_unbind(self, commands, key_config_stub, config_stub, yaml_value,
key, normalized):
config_stub.val.bindings.default = {
'normal': {'a': 'nop', '<ctrl+x>': 'nop'},
@ -590,7 +626,7 @@ class TestBind:
commands.unbind(key)
assert key_config_stub.get_command(key, 'normal') is None
yaml_bindings = config_stub._yaml['bindings.commands']['normal']
yaml_bindings = yaml_value('bindings.commands')['normal']
if key in 'bc':
# Custom binding
assert normalized not in yaml_bindings

View File

@ -54,6 +54,12 @@ def test_backend_error():
assert str(e) == expected
def test_no_pattern_error():
e = configexc.NoPatternError('foo')
expected = "The foo setting does not support URL patterns!"
assert str(e) == expected
def test_desc_with_text():
"""Test ConfigErrorDesc.with_text."""
old = configexc.ConfigErrorDesc("Error text", Exception("Exception text"))

View File

@ -28,7 +28,7 @@ from PyQt5.QtCore import QSettings
from qutebrowser.config import (config, configfiles, configexc, configdata,
configtypes)
from qutebrowser.utils import utils, usertypes
from qutebrowser.utils import utils, usertypes, urlmatch
@pytest.fixture(autouse=True)
@ -38,6 +38,42 @@ def configdata_init():
configdata.init()
class AutoConfigHelper:
"""A helper to easily create/validate autoconfig.yml files."""
def __init__(self, config_tmpdir):
self.fobj = config_tmpdir / 'autoconfig.yml'
def write_toplevel(self, data):
with self.fobj.open('w', encoding='utf-8') as f:
utils.yaml_dump(data, f)
def write(self, values):
data = {'config_version': 2, 'settings': values}
self.write_toplevel(data)
def write_raw(self, text):
self.fobj.write_text(text, encoding='utf-8', ensure=True)
def read_toplevel(self):
with self.fobj.open('r', encoding='utf-8') as f:
data = utils.yaml_load(f)
assert data['config_version'] == 2
return data
def read(self):
return self.read_toplevel()['settings']
def read_raw(self):
return self.fobj.read_text('utf-8')
@pytest.fixture
def autoconfig(config_tmpdir):
return AutoConfigHelper(config_tmpdir)
@pytest.mark.parametrize('old_data, insert, new_data', [
(None, False, '[general]\n\n[geometry]\n\n'),
('[general]\nfooled = true', False, '[general]\n\n[geometry]\n\n'),
@ -75,49 +111,58 @@ class TestYaml:
@pytest.mark.parametrize('old_config', [
None,
'global:\n colors.hints.fg: magenta',
# Only global
{'colors.hints.fg': {'global': 'magenta'}},
# Global and for pattern
{'content.javascript.enabled':
{'global': True, 'https://example.com/': False}},
# Only for pattern
{'content.images': {'https://example.com/': False}},
])
@pytest.mark.parametrize('insert', [True, False])
def test_yaml_config(self, yaml, config_tmpdir, old_config, insert):
autoconfig = config_tmpdir / 'autoconfig.yml'
def test_yaml_config(self, yaml, autoconfig, old_config, insert):
if old_config is not None:
autoconfig.write_text(old_config, 'utf-8')
autoconfig.write(old_config)
yaml.load()
if insert:
yaml['tabs.show'] = 'never'
yaml.set_obj('tabs.show', 'never')
yaml._save()
if not insert and old_config is None:
lines = []
else:
text = autoconfig.read_text('utf-8')
lines = text.splitlines()
data = autoconfig.read()
lines = autoconfig.read_raw().splitlines()
if insert:
assert lines[0].startswith('# DO NOT edit this file by hand,')
assert 'config_version: {}'.format(yaml.VERSION) in lines
assert 'global:' in lines
print(lines)
if 'magenta' in (old_config or ''):
assert ' colors.hints.fg: magenta' in lines
if old_config is None:
pass
elif 'colors.hints.fg' in old_config:
assert data['colors.hints.fg'] == {'global': 'magenta'}
elif 'content.javascript.enabled' in old_config:
expected = {'global': True, 'https://example.com/': False}
assert data['content.javascript.enabled'] == expected
elif 'content.images' in old_config:
assert data['content.images'] == {'https://example.com/': False}
if insert:
assert ' tabs.show: never' in lines
assert data['tabs.show'] == {'global': 'never'}
def test_init_save_manager(self, yaml, fake_save_manager):
yaml.init_save_manager(fake_save_manager)
fake_save_manager.add_saveable.assert_called_with(
'yaml-config', unittest.mock.ANY, unittest.mock.ANY)
def test_unknown_key(self, yaml, config_tmpdir):
def test_unknown_key(self, yaml, autoconfig):
"""An unknown setting should show an error."""
autoconfig = config_tmpdir / 'autoconfig.yml'
autoconfig.write_text('global:\n hello: world', encoding='utf-8')
autoconfig.write({'hello': {'global': 'world'}})
with pytest.raises(configexc.ConfigFileErrors) as excinfo:
yaml.load()
@ -127,10 +172,9 @@ class TestYaml:
assert error.text == "While loading options"
assert str(error.exception) == "Unknown option hello"
def test_multiple_unknown_keys(self, yaml, config_tmpdir):
def test_multiple_unknown_keys(self, yaml, autoconfig):
"""With multiple unknown settings, all should be shown."""
autoconfig = config_tmpdir / 'autoconfig.yml'
autoconfig.write_text('global:\n one: 1\n two: 2', encoding='utf-8')
autoconfig.write({'one': {'global': 1}, 'two': {'global': 2}})
with pytest.raises(configexc.ConfigFileErrors) as excinfo:
yaml.load()
@ -141,23 +185,21 @@ class TestYaml:
assert str(error1.exception) == "Unknown option one"
assert str(error2.exception) == "Unknown option two"
def test_deleted_key(self, monkeypatch, yaml, config_tmpdir):
def test_deleted_key(self, monkeypatch, yaml, autoconfig):
"""A key marked as deleted should be removed."""
autoconfig = config_tmpdir / 'autoconfig.yml'
autoconfig.write_text('global:\n hello: world', encoding='utf-8')
autoconfig.write({'hello': {'global': 'world'}})
monkeypatch.setattr(configdata.MIGRATIONS, 'deleted', ['hello'])
yaml.load()
yaml._save()
lines = autoconfig.read_text('utf-8').splitlines()
assert ' hello: world' not in lines
data = autoconfig.read()
assert not data
def test_renamed_key(self, monkeypatch, yaml, config_tmpdir):
def test_renamed_key(self, monkeypatch, yaml, autoconfig):
"""A key marked as renamed should be renamed properly."""
autoconfig = config_tmpdir / 'autoconfig.yml'
autoconfig.write_text('global:\n old: value', encoding='utf-8')
autoconfig.write({'old': {'global': 'value'}})
monkeypatch.setattr(configdata.MIGRATIONS, 'renamed',
{'old': 'tabs.show'})
@ -165,29 +207,25 @@ class TestYaml:
yaml.load()
yaml._save()
lines = autoconfig.read_text('utf-8').splitlines()
assert ' old: value' not in lines
assert ' tabs.show: value' in lines
data = autoconfig.read()
assert data == {'tabs.show': {'global': 'value'}}
@pytest.mark.parametrize('persist', [True, False])
def test_merge_persist(self, yaml, config_tmpdir, persist):
def test_merge_persist(self, yaml, autoconfig, persist):
"""Tests for migration of tabs.persist_mode_on_change."""
autoconfig = config_tmpdir / 'autoconfig.yml'
autoconfig.write_text('global:\n tabs.persist_mode_on_change: {}'.
format(persist), encoding='utf-8')
autoconfig.write({'tabs.persist_mode_on_change': {'global': persist}})
yaml.load()
yaml._save()
lines = autoconfig.read_text('utf-8').splitlines()
data = autoconfig.read()
assert 'tabs.persist_mode_on_change' not in data
mode = 'persist' if persist else 'normal'
assert ' tabs.persist_mode_on_change:' not in lines
assert ' tabs.mode_on_change: {}'.format(mode) in lines
assert data['tabs.mode_on_change']['global'] == mode
def test_renamed_key_unknown_target(self, monkeypatch, yaml,
config_tmpdir):
autoconfig):
"""A key marked as renamed with invalid name should raise an error."""
autoconfig = config_tmpdir / 'autoconfig.yml'
autoconfig.write_text('global:\n old: value', encoding='utf-8')
autoconfig.write({'old': {'global': 'value'}})
monkeypatch.setattr(configdata.MIGRATIONS, 'renamed',
{'old': 'new'})
@ -202,7 +240,7 @@ class TestYaml:
@pytest.mark.parametrize('old_config', [
None,
'global:\n colors.hints.fg: magenta',
{'colors.hints.fg': {'global': 'magenta'}},
])
@pytest.mark.parametrize('key, value', [
('colors.hints.fg', 'green'),
@ -210,61 +248,64 @@ class TestYaml:
('confirm_quit', True),
('confirm_quit', False),
])
def test_changed(self, yaml, qtbot, config_tmpdir, old_config, key, value):
autoconfig = config_tmpdir / 'autoconfig.yml'
def test_changed(self, yaml, qtbot, autoconfig,
old_config, key, value):
if old_config is not None:
autoconfig.write_text(old_config, 'utf-8')
autoconfig.write(old_config)
yaml.load()
with qtbot.wait_signal(yaml.changed):
yaml[key] = value
yaml.set_obj(key, value)
assert key in yaml
assert yaml[key] == value
assert yaml._values[key].get_for_url(fallback=False) == value
yaml._save()
yaml = configfiles.YamlConfig()
yaml.load()
assert key in yaml
assert yaml[key] == value
assert yaml._values[key].get_for_url(fallback=False) == value
def test_iter(self, yaml):
yaml['foo'] = 23
yaml['bar'] = 42
assert list(iter(yaml)) == [('bar', 42), ('foo', 23)]
assert list(iter(yaml)) == list(iter(yaml._values.values()))
@pytest.mark.parametrize('old_config', [
None,
'global:\n colors.hints.fg: magenta',
{'colors.hints.fg': {'global': 'magenta'}},
])
def test_unchanged(self, yaml, config_tmpdir, old_config):
autoconfig = config_tmpdir / 'autoconfig.yml'
def test_unchanged(self, yaml, autoconfig, old_config):
mtime = None
if old_config is not None:
autoconfig.write_text(old_config, 'utf-8')
mtime = autoconfig.stat().mtime
autoconfig.write(old_config)
mtime = autoconfig.fobj.stat().mtime
yaml.load()
yaml._save()
if old_config is None:
assert not autoconfig.exists()
assert not autoconfig.fobj.exists()
else:
assert autoconfig.stat().mtime == mtime
assert autoconfig.fobj.stat().mtime == mtime
@pytest.mark.parametrize('line, text, exception', [
('%', 'While parsing', 'while scanning a directive'),
('global: 42', 'While loading data', "'global' object is not a dict"),
('foo: 42', 'While loading data',
"Toplevel object does not contain 'global' key"),
('settings: 42\nconfig_version: 2',
'While loading data', "'settings' object is not a dict"),
('foo: 42\nconfig_version: 2', 'While loading data',
"Toplevel object does not contain 'settings' key"),
('settings: {}', 'While loading data',
"Toplevel object does not contain 'config_version' key"),
('42', 'While loading data', "Toplevel object is not a dict"),
('settings: {"content.images": 42}\nconfig_version: 2',
"While parsing 'content.images'", "value is not a dict"),
('settings: {"content.images": {"https://": true}}\nconfig_version: 2',
"While parsing pattern 'https://' for 'content.images'", "Pattern without host"),
('settings: {"content.images": {true: true}}\nconfig_version: 2',
"While parsing 'content.images'", "pattern is not of type string"),
])
def test_invalid(self, yaml, config_tmpdir, line, text, exception):
autoconfig = config_tmpdir / 'autoconfig.yml'
autoconfig.write_text(line, 'utf-8', ensure=True)
def test_invalid(self, yaml, autoconfig, line, text, exception):
autoconfig.write_raw(line)
with pytest.raises(configexc.ConfigFileErrors) as excinfo:
yaml.load()
@ -275,11 +316,44 @@ class TestYaml:
assert str(error.exception).splitlines()[0] == exception
assert error.traceback is None
def test_oserror(self, yaml, config_tmpdir):
autoconfig = config_tmpdir / 'autoconfig.yml'
autoconfig.ensure()
autoconfig.chmod(0)
if os.access(str(autoconfig), os.R_OK):
def test_legacy_migration(self, yaml, autoconfig, qtbot):
autoconfig.write_toplevel({
'config_version': 1,
'global': {'content.javascript.enabled': True},
})
with qtbot.wait_signal(yaml.changed):
yaml.load()
yaml._save()
data = autoconfig.read_toplevel()
assert data == {
'config_version': 2,
'settings': {
'content.javascript.enabled': {
'global': True,
}
}
}
def test_read_newer_version(self, yaml, autoconfig):
autoconfig.write_toplevel({
'config_version': 999,
'settings': {},
})
with pytest.raises(configexc.ConfigFileErrors) as excinfo:
yaml.load()
assert len(excinfo.value.errors) == 1
error = excinfo.value.errors[0]
assert error.text == "While reading"
msg = "Can't read config from incompatible newer version"
assert error.exception == msg
def test_oserror(self, yaml, autoconfig):
autoconfig.fobj.ensure()
autoconfig.fobj.chmod(0)
if os.access(str(autoconfig.fobj), os.R_OK):
# Docker container or similar
pytest.skip("File was still readable")
@ -292,22 +366,22 @@ class TestYaml:
assert isinstance(error.exception, OSError)
assert error.traceback is None
def test_unset(self, yaml, qtbot, config_tmpdir):
def test_unset(self, yaml, qtbot):
name = 'tabs.show'
yaml[name] = 'never'
yaml.set_obj(name, 'never')
with qtbot.wait_signal(yaml.changed):
yaml.unset(name)
assert name not in yaml
def test_unset_never_set(self, yaml, qtbot, config_tmpdir):
def test_unset_never_set(self, yaml, qtbot):
with qtbot.assert_not_emitted(yaml.changed):
yaml.unset('tabs.show')
def test_clear(self, yaml, qtbot, config_tmpdir):
def test_clear(self, yaml, qtbot):
name = 'tabs.show'
yaml[name] = 'never'
yaml.set_obj(name, 'never')
with qtbot.wait_signal(yaml.changed):
yaml.clear()
@ -370,7 +444,7 @@ class TestConfigPyModules:
confpy.write_qbmodule()
confpy.read()
expected = {'normal': {',a': 'message-info foo'}}
assert config.instance._values['bindings.commands'] == expected
assert config.instance.get_obj('bindings.commands') == expected
assert "qbmodule" not in sys.modules.keys()
assert tmpdir not in sys.path
@ -432,16 +506,41 @@ class TestConfigPy:
@pytest.mark.parametrize('line', [
'c.colors.hints.bg = "red"',
'config.set("colors.hints.bg", "red")',
'config.set("colors.hints.bg", "red", pattern=None)',
])
def test_set(self, confpy, line):
confpy.write(line)
confpy.read()
assert config.instance._values['colors.hints.bg'] == 'red'
assert config.instance.get_obj('colors.hints.bg') == 'red'
@pytest.mark.parametrize('template', [
"config.set({opt!r}, False, {pattern!r})",
"with config.pattern({pattern!r}) as p: p.{opt} = False",
])
def test_set_with_pattern(self, confpy, template):
option = 'content.javascript.enabled'
pattern = 'https://www.example.com/'
confpy.write(template.format(opt=option, pattern=pattern))
confpy.read()
assert config.instance.get_obj(option)
assert not config.instance.get_obj_for_pattern(
option, pattern=urlmatch.UrlPattern(pattern))
def test_set_context_manager_global(self, confpy):
"""When "with config.pattern" is used, "c." should still be global."""
option = 'content.javascript.enabled'
confpy.write('with config.pattern("https://www.example.com/") as p:'
' c.{} = False'.format(option))
confpy.read()
assert not config.instance.get_obj(option)
@pytest.mark.parametrize('set_first', [True, False])
@pytest.mark.parametrize('get_line', [
'c.colors.hints.fg',
'config.get("colors.hints.fg")',
'config.get("colors.hints.fg", pattern=None)',
])
def test_get(self, confpy, set_first, get_line):
"""Test whether getting options works correctly."""
@ -455,6 +554,24 @@ class TestConfigPy:
confpy.write('assert {} == "green"'.format(get_line))
confpy.read()
def test_get_with_pattern(self, confpy):
"""Test whether we get a matching value with a pattern."""
option = 'content.javascript.enabled'
pattern = 'https://www.example.com/'
config.instance.set_obj(option, False,
pattern=urlmatch.UrlPattern(pattern))
confpy.write('assert config.get({!r})'.format(option),
'assert not config.get({!r}, pattern={!r})'
.format(option, pattern))
confpy.read()
def test_get_with_pattern_no_match(self, confpy):
confpy.write(
'val = config.get("content.images", "https://www.example.com")',
'assert val is True',
)
confpy.read()
@pytest.mark.parametrize('line, mode', [
('config.bind(",a", "message-info foo")', 'normal'),
('config.bind(",a", "message-info foo", "prompt")', 'prompt'),
@ -463,7 +580,7 @@ class TestConfigPy:
confpy.write(line)
confpy.read()
expected = {mode: {',a': 'message-info foo'}}
assert config.instance._values['bindings.commands'] == expected
assert config.instance.get_obj('bindings.commands') == expected
def test_bind_freshly_defined_alias(self, confpy):
"""Make sure we can bind to a new alias.
@ -479,14 +596,14 @@ class TestConfigPy:
confpy.write("config.bind('H', 'message-info back')")
confpy.read()
expected = {'normal': {'H': 'message-info back'}}
assert config.instance._values['bindings.commands'] == expected
assert config.instance.get_obj('bindings.commands') == expected
def test_bind_none(self, confpy):
confpy.write("c.bindings.commands = None",
"config.bind(',x', 'nop')")
confpy.read()
expected = {'normal': {',x': 'nop'}}
assert config.instance._values['bindings.commands'] == expected
assert config.instance.get_obj('bindings.commands') == expected
@pytest.mark.parametrize('line, key, mode', [
('config.unbind("o")', 'o', 'normal'),
@ -496,14 +613,14 @@ class TestConfigPy:
confpy.write(line)
confpy.read()
expected = {mode: {key: None}}
assert config.instance._values['bindings.commands'] == expected
assert config.instance.get_obj('bindings.commands') == expected
def test_mutating(self, confpy):
confpy.write('c.aliases["foo"] = "message-info foo"',
'c.aliases["bar"] = "message-info bar"')
confpy.read()
assert config.instance._values['aliases']['foo'] == 'message-info foo'
assert config.instance._values['aliases']['bar'] == 'message-info bar'
assert config.instance.get_obj('aliases')['foo'] == 'message-info foo'
assert config.instance.get_obj('aliases')['bar'] == 'message-info bar'
@pytest.mark.parametrize('option, value', [
('content.user_stylesheets', 'style.css'),
@ -517,7 +634,7 @@ class TestConfigPy:
(config_tmpdir / 'style.css').ensure()
confpy.write('c.{}.append("{}")'.format(option, value))
confpy.read()
assert config.instance._values[option][-1] == value
assert config.instance.get_obj(option)[-1] == value
def test_oserror(self, tmpdir, data_tmpdir, config_tmpdir):
with pytest.raises(configexc.ConfigFileErrors) as excinfo:
@ -604,6 +721,22 @@ class TestConfigPy:
"'qt.args')")
assert str(error.exception) == expected
@pytest.mark.parametrize('line, text', [
('config.get("content.images", "://")',
"While getting 'content.images' and parsing pattern"),
('config.set("content.images", False, "://")',
"While setting 'content.images' and parsing pattern"),
('with config.pattern("://"): pass',
"Unhandled exception"),
])
def test_invalid_pattern(self, confpy, line, text):
confpy.write(line)
error = confpy.read(error=True)
assert error.text == text
assert isinstance(error.exception, urlmatch.ParseError)
assert str(error.exception) == "No scheme given"
def test_multiple_errors(self, confpy):
confpy.write("c.foo = 42", "config.set('foo', 42)", "1/0")
@ -638,7 +771,7 @@ class TestConfigPy:
confpy.write("config.source({!r})".format(arg))
confpy.read()
assert not config.instance._values['content.javascript.enabled']
assert not config.instance.get_obj('content.javascript.enabled')
def test_source_errors(self, tmpdir, confpy):
subfile = tmpdir / 'config' / 'subfile.py'
@ -682,7 +815,7 @@ class TestConfigPyWriter:
name='opt', typ=configtypes.Int(), default='def',
backends=[usertypes.Backend.QtWebEngine], raw_backends=None,
description=desc)
options = [(opt, 'val')]
options = [(None, opt, 'val')]
bindings = {'normal': {',x': 'message-info normal'},
'caret': {',y': 'message-info caret'}}
@ -714,8 +847,8 @@ class TestConfigPyWriter:
def test_binding_options_hidden(self):
opt1 = configdata.DATA['bindings.default']
opt2 = configdata.DATA['bindings.commands']
options = [(opt1, {'normal': {'x': 'message-info x'}}),
(opt2, {})]
options = [(None, opt1, {'normal': {'x': 'message-info x'}}),
(None, opt2, {})]
writer = configfiles.ConfigPyWriter(options, bindings={},
commented=False)
text = '\n'.join(writer._gen_lines())
@ -727,7 +860,7 @@ class TestConfigPyWriter:
name='opt', typ=configtypes.Int(), default='def',
backends=[usertypes.Backend.QtWebEngine], raw_backends=None,
description='Hello World')
options = [(opt, 'val')]
options = [(None, opt, 'val')]
bindings = {'normal': {',x': 'message-info normal'},
'caret': {',y': 'message-info caret'}}
@ -753,7 +886,7 @@ class TestConfigPyWriter:
backends=[usertypes.Backend.QtWebEngine], raw_backends=None,
description='All colors are beautiful!')
options = [(opt1, 'ask'), (opt2, 'rgb')]
options = [(None, opt1, 'ask'), (None, opt2, 'rgb')]
writer = configfiles.ConfigPyWriter(options, bindings={},
commented=False)
@ -794,6 +927,20 @@ class TestConfigPyWriter:
""").lstrip()
assert text == expected
def test_pattern(self):
opt = configdata.Option(
name='opt', typ=configtypes.BoolAsk(), default='ask',
backends=[usertypes.Backend.QtWebEngine], raw_backends=None,
description='Hello World')
options = [
(urlmatch.UrlPattern('https://www.example.com/'), opt, 'ask'),
]
writer = configfiles.ConfigPyWriter(options=options, bindings={},
commented=False)
text = '\n'.join(writer._gen_lines())
expected = "config.set('opt', 'ask', 'https://www.example.com/')"
assert expected in text
def test_write(self, tmpdir):
pyfile = tmpdir / 'config.py'
writer = configfiles.ConfigPyWriter(options=[], bindings={},
@ -804,7 +951,7 @@ class TestConfigPyWriter:
def test_defaults_work(self, confpy):
"""Get a config.py with default values and run it."""
options = [(opt, opt.default)
options = [(None, opt, opt.default)
for _name, opt in sorted(configdata.DATA.items())]
bindings = dict(configdata.DATA['bindings.default'].default)
writer = configfiles.ConfigPyWriter(options, bindings, commented=False)

View File

@ -100,14 +100,15 @@ class TestEarlyInit:
# Check config values
if config_py:
assert config.instance._values == {'colors.hints.bg': 'red'}
expected = 'colors.hints.bg = red'
else:
assert config.instance._values == {}
expected = '<Default configuration>'
assert config.instance.dump_userconfig() == expected
@pytest.mark.parametrize('load_autoconfig', [True, False]) # noqa
@pytest.mark.parametrize('config_py', [True, 'error', False])
@pytest.mark.parametrize('invalid_yaml', ['42', 'unknown', 'wrong-type',
False])
@pytest.mark.parametrize('invalid_yaml', ['42', 'list', 'unknown',
'wrong-type', False])
def test_autoconfig_yml(self, init_patch, config_tmpdir, caplog, args,
load_autoconfig, config_py, invalid_yaml):
"""Test interaction between config.py and autoconfig.yml."""
@ -115,14 +116,30 @@ class TestEarlyInit:
autoconfig_file = config_tmpdir / 'autoconfig.yml'
config_py_file = config_tmpdir / 'config.py'
yaml_text = {
yaml_lines = {
'42': '42',
'unknown': 'global:\n colors.foobar: magenta\n',
'wrong-type': 'global:\n tabs.position: true\n',
False: 'global:\n colors.hints.fg: magenta\n',
'list': '[1, 2]',
'unknown': [
'settings:',
' colors.foobar:',
' global: magenta',
'config_version: 2',
],
'wrong-type': [
'settings:',
' tabs.position:',
' global: true',
'config_version: 2',
],
False: [
'settings:',
' colors.hints.fg:',
' global: magenta',
'config_version: 2',
],
}
autoconfig_file.write_text(yaml_text[invalid_yaml], 'utf-8',
ensure=True)
text = '\n'.join(yaml_lines[invalid_yaml])
autoconfig_file.write_text(text, 'utf-8', ensure=True)
if config_py:
config_py_lines = ['c.colors.hints.bg = "red"']
@ -141,7 +158,7 @@ class TestEarlyInit:
if load_autoconfig or not config_py:
suffix = ' (autoconfig.yml)' if config_py else ''
if invalid_yaml == '42':
if invalid_yaml in ['42', 'list']:
error = ("While loading data{}: Toplevel object is not a dict"
.format(suffix))
expected_errors.append(error)
@ -165,17 +182,21 @@ class TestEarlyInit:
assert actual_errors == expected_errors
# Check config values
dump = config.instance.dump_userconfig()
if config_py and load_autoconfig and not invalid_yaml:
assert config.instance._values == {
'colors.hints.bg': 'red',
'colors.hints.fg': 'magenta',
}
expected = [
'colors.hints.bg = red',
'colors.hints.fg = magenta',
]
elif config_py:
assert config.instance._values == {'colors.hints.bg': 'red'}
expected = ['colors.hints.bg = red']
elif invalid_yaml:
assert config.instance._values == {}
expected = ['<Default configuration>']
else:
assert config.instance._values == {'colors.hints.fg': 'magenta'}
expected = ['colors.hints.fg = magenta']
assert dump == '\n'.join(expected)
def test_invalid_change_filter(self, init_patch, args):
config.change_filter('foobar')
@ -185,7 +206,7 @@ class TestEarlyInit:
def test_temp_settings_valid(self, init_patch, args):
args.temp_settings = [('colors.completion.fg', 'magenta')]
configinit.early_init(args)
assert config.instance._values['colors.completion.fg'] == 'magenta'
assert config.instance.get_obj('colors.completion.fg') == 'magenta'
def test_temp_settings_invalid(self, caplog, init_patch, message_mock,
args):
@ -198,7 +219,6 @@ class TestEarlyInit:
msg = message_mock.getmsg()
assert msg.level == usertypes.MessageLevel.error
assert msg.text == "set: NoOptionError - No option 'foo'"
assert 'colors.completion.fg' not in config.instance._values
@pytest.mark.parametrize('settings, size, family', [
# Only fonts.monospace customized
@ -220,8 +240,9 @@ class TestEarlyInit:
args.temp_settings = settings
elif method == 'auto':
autoconfig_file = config_tmpdir / 'autoconfig.yml'
lines = ["global:"] + [" {}: '{}'".format(k, v)
for k, v in settings]
lines = (["config_version: 2", "settings:"] +
[" {}:\n global:\n '{}'".format(k, v)
for k, v in settings])
autoconfig_file.write_text('\n'.join(lines), 'utf-8', ensure=True)
elif method == 'py':
config_py_file = config_tmpdir / 'config.py'

View File

@ -0,0 +1,210 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
import pytest
from PyQt5.QtCore import QUrl
from qutebrowser.config import configutils, configdata, configtypes
from qutebrowser.utils import urlmatch
def test_unset_object_identity():
assert configutils._UnsetObject() is not configutils._UnsetObject()
assert configutils.UNSET is configutils.UNSET
def test_unset_object_repr():
assert repr(configutils.UNSET) == '<UNSET>'
@pytest.fixture
def opt():
return configdata.Option(name='example.option', typ=configtypes.String(),
default='default value', backends=None,
raw_backends=None, description=None,
supports_pattern=True)
@pytest.fixture
def pattern():
return urlmatch.UrlPattern('*://www.example.com/')
@pytest.fixture
def other_pattern():
return urlmatch.UrlPattern('https://www.example.org/')
@pytest.fixture
def values(opt, pattern):
scoped_values = [configutils.ScopedValue('global value', None),
configutils.ScopedValue('example value', pattern)]
return configutils.Values(opt, scoped_values)
@pytest.fixture
def empty_values(opt):
return configutils.Values(opt)
def test_repr(opt, values):
expected = ("qutebrowser.config.configutils.Values(opt={!r}, "
"values=[ScopedValue(value='global value', pattern=None), "
"ScopedValue(value='example value', pattern=qutebrowser.utils."
"urlmatch.UrlPattern(pattern='*://www.example.com/'))])"
.format(opt))
assert repr(values) == expected
def test_str(values):
expected = [
'example.option = global value',
'*://www.example.com/: example.option = example value',
]
assert str(values) == '\n'.join(expected)
def test_str_empty(empty_values):
assert str(empty_values) == 'example.option: <unchanged>'
def test_bool(values, empty_values):
assert values
assert not empty_values
def test_iter(values):
assert list(iter(values)) == list(iter(values._values))
def test_add_existing(values):
values.add('new global value')
assert values.get_for_url() == 'new global value'
def test_add_new(values, other_pattern):
values.add('example.org value', other_pattern)
assert values.get_for_url() == 'global value'
example_com = QUrl('https://www.example.com/')
example_org = QUrl('https://www.example.org/')
assert values.get_for_url(example_com) == 'example value'
assert values.get_for_url(example_org) == 'example.org value'
def test_remove_existing(values, pattern):
removed = values.remove(pattern)
assert removed
url = QUrl('https://www.example.com/')
assert values.get_for_url(url) == 'global value'
def test_remove_non_existing(values, other_pattern):
removed = values.remove(other_pattern)
assert not removed
url = QUrl('https://www.example.com/')
assert values.get_for_url(url) == 'example value'
def test_clear(values):
assert values
values.clear()
assert not values
assert values.get_for_url(fallback=False) is configutils.UNSET
def test_get_matching(values):
url = QUrl('https://www.example.com/')
assert values.get_for_url(url, fallback=False) == 'example value'
def test_get_unset(empty_values):
assert empty_values.get_for_url(fallback=False) is configutils.UNSET
def test_get_no_global(empty_values, other_pattern):
empty_values.add('example.org value', pattern)
assert empty_values.get_for_url(fallback=False) is configutils.UNSET
def test_get_unset_fallback(empty_values):
assert empty_values.get_for_url() == 'default value'
def test_get_non_matching(values):
url = QUrl('https://www.example.ch/')
assert values.get_for_url(url, fallback=False) is configutils.UNSET
def test_get_non_matching_fallback(values):
url = QUrl('https://www.example.ch/')
assert values.get_for_url(url) == 'global value'
def test_get_multiple_matches(values):
"""With multiple matching pattern, the last added should win."""
all_pattern = urlmatch.UrlPattern('*://*/')
values.add('new value', all_pattern)
url = QUrl('https://www.example.com/')
assert values.get_for_url(url) == 'new value'
def test_get_matching_pattern(values, pattern):
assert values.get_for_pattern(pattern, fallback=False) == 'example value'
def test_get_pattern_none(values, pattern):
assert values.get_for_pattern(None, fallback=False) == 'global value'
def test_get_unset_pattern(empty_values, pattern):
value = empty_values.get_for_pattern(pattern, fallback=False)
assert value is configutils.UNSET
def test_get_no_global_pattern(empty_values, pattern, other_pattern):
empty_values.add('example.org value', other_pattern)
value = empty_values.get_for_pattern(pattern, fallback=False)
assert value is configutils.UNSET
def test_get_unset_fallback_pattern(empty_values, pattern):
assert empty_values.get_for_pattern(pattern) == 'default value'
def test_get_non_matching_pattern(values, other_pattern):
value = values.get_for_pattern(other_pattern, fallback=False)
assert value is configutils.UNSET
def test_get_non_matching_fallback_pattern(values, other_pattern):
assert values.get_for_pattern(other_pattern) == 'global value'
def test_get_equivalent_patterns(empty_values):
"""With multiple matching pattern, the last added should win."""
pat1 = urlmatch.UrlPattern('https://www.example.com/')
pat2 = urlmatch.UrlPattern('*://www.example.com/')
empty_values.add('pat1 value', pat1)
empty_values.add('pat2 value', pat2)
assert empty_values.get_for_pattern(pat1) == 'pat1 value'
assert empty_values.get_for_pattern(pat2) == 'pat2 value'

View File

@ -56,22 +56,25 @@ class TestFileCompletion:
def test_simple_completion(self, tmpdir, get_prompt, steps, where,
subfolder):
"""Simply trying to tab through items."""
testdir = tmpdir / 'test'
for directory in 'abc':
(tmpdir / directory).ensure(dir=True)
(testdir / directory).ensure(dir=True)
prompt = get_prompt(str(tmpdir) + os.sep)
prompt = get_prompt(str(testdir) + os.sep)
for _ in range(steps):
prompt.item_focus(where)
assert prompt._lineedit.text() == str(tmpdir / subfolder)
assert prompt._lineedit.text() == str(testdir / subfolder)
def test_backspacing_path(self, qtbot, tmpdir, get_prompt):
"""When we start deleting a path we want to see the subdir."""
for directory in ['bar', 'foo']:
(tmpdir / directory).ensure(dir=True)
testdir = tmpdir / 'test'
prompt = get_prompt(str(tmpdir / 'foo') + os.sep)
for directory in ['bar', 'foo']:
(testdir / directory).ensure(dir=True)
prompt = get_prompt(str(testdir / 'foo') + os.sep)
# Deleting /f[oo/]
with qtbot.wait_signal(prompt._file_model.directoryLoaded):
@ -81,7 +84,7 @@ class TestFileCompletion:
# We should now show / again, so tabbing twice gives us .. -> bar
prompt.item_focus('next')
prompt.item_focus('next')
assert prompt._lineedit.text() == str(tmpdir / 'bar')
assert prompt._lineedit.text() == str(testdir / 'bar')
@pytest.mark.linux
def test_root_path(self, get_prompt):

View File

@ -0,0 +1,544 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Tests for qutebrowser.utils.urlmatch.
The tests are mostly inspired by Chromium's:
https://cs.chromium.org/chromium/src/extensions/common/url_pattern_unittest.cc
Currently not tested:
- The match_effective_tld attribute as it doesn't exist yet.
- Nested filesystem:// URLs as we don't have those.
- Unicode matching because QUrl doesn't like those URLs.
- Any other features we don't need, such as .GetAsString() or set operations.
"""
import re
import sys
import string
import pytest
import hypothesis
import hypothesis.strategies as hst
from PyQt5.QtCore import QUrl
from qutebrowser.utils import urlmatch
@pytest.mark.parametrize('pattern, error', [
# Chromium: PARSE_ERROR_MISSING_SCHEME_SEPARATOR
("http", "No scheme given"),
("http:", "Pattern without host"),
("http:/", "Pattern without host"),
("about://", "Pattern without path"),
("http:/bar", "Pattern without host"),
# Chromium: PARSE_ERROR_EMPTY_HOST
("http://", "Pattern without host"),
("http:///", "Pattern without host"),
("http:// /", "Pattern without host"),
("http://:1234/", "Pattern without host"),
# Chromium: PARSE_ERROR_EMPTY_PATH
# We deviate from Chromium and allow this for ease of use
# ("http://bar", "..."),
# Chromium: PARSE_ERROR_INVALID_HOST
("http://\0www/", "May not contain NUL byte"),
# Chromium: PARSE_ERROR_INVALID_HOST_WILDCARD
("http://*foo/bar", "Invalid host wildcard"),
("http://foo.*.bar/baz", "Invalid host wildcard"),
("http://fo.*.ba:123/baz", "Invalid host wildcard"),
("http://foo.*/bar", "TLD wildcards are not implemented yet"),
# Chromium: PARSE_ERROR_INVALID_PORT
("http://foo:/", "Invalid port: Port is empty"),
("http://*.foo:/", "Invalid port: Port is empty"),
("http://foo:com/",
"Invalid port: invalid literal for int() with base 10: 'com'"),
pytest.param("http://foo:123456/",
"Invalid port: Port out of range 0-65535",
marks=pytest.mark.skipif(
sys.hexversion < 0x03060000,
reason="Doesn't show an error on Python 3.5")),
("http://foo:80:80/monkey",
"Invalid port: invalid literal for int() with base 10: '80:80'"),
("chrome://foo:1234/bar", "Ports are unsupported with chrome scheme"),
# Additional tests
("http://[", "Invalid IPv6 URL"),
])
def test_invalid_patterns(pattern, error):
with pytest.raises(urlmatch.ParseError, match=re.escape(error)):
urlmatch.UrlPattern(pattern)
@pytest.mark.parametrize('pattern, port', [
("http://foo:1234/", 1234),
("http://foo:1234/bar", 1234),
("http://*.foo:1234/", 1234),
("http://*.foo:1234/bar", 1234),
("http://*:1234/", 1234),
("http://*:*/", None),
("http://foo:*/", None),
("file://foo:1234/bar", None),
# Port-like strings in the path should not trigger a warning.
("http://*/:1234", None),
("http://*.foo/bar:1234", None),
("http://foo/bar:1234/path", None),
# We don't implement ALLOW_WILDCARD_FOR_EFFECTIVE_TLD yet.
# ("http://*.foo.*/:1234", None),
])
def test_port(pattern, port):
up = urlmatch.UrlPattern(pattern)
assert up._port == port
class TestMatchAllPagesForGivenScheme:
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("http://*/*")
def test_attrs(self, up):
assert up._scheme == 'http'
assert up._host is None
assert up._match_subdomains
assert not up._match_all
assert up._path is None
@pytest.mark.parametrize('url, expected', [
("http://google.com", True),
("http://yahoo.com", True),
("http://google.com/foo", True),
("https://google.com", False),
("http://74.125.127.100/search", True),
])
def test_urls(self, up, url, expected):
assert up.matches(QUrl(url)) == expected
class TestMatchAllDomains:
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("https://*/foo*")
def test_attrs(self, up):
assert up._scheme == 'https'
assert up._host is None
assert up._match_subdomains
assert not up._match_all
assert up._path == '/foo*'
@pytest.mark.parametrize('url, expected', [
("https://google.com/foo", True),
("https://google.com/foobar", True),
("http://google.com/foo", False),
("https://google.com/", False),
])
def test_urls(self, up, url, expected):
assert up.matches(QUrl(url)) == expected
class TestMatchSubdomains:
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("http://*.google.com/foo*bar")
def test_attrs(self, up):
assert up._scheme == 'http'
assert up._host == 'google.com'
assert up._match_subdomains
assert not up._match_all
assert up._path == '/foo*bar'
@pytest.mark.parametrize('url, expected', [
("http://google.com/foobar", True),
# FIXME The ?bar seems to be treated as path by GURL but as query by
# QUrl.
# ("http://www.google.com/foo?bar", True),
("http://monkey.images.google.com/foooobar", True),
("http://yahoo.com/foobar", False),
])
def test_urls(self, up, url, expected):
assert up.matches(QUrl(url)) == expected
class TestMatchGlobEscaping:
@pytest.fixture
def up(self):
return urlmatch.UrlPattern(r"file:///foo-bar\*baz")
def test_attrs(self, up):
assert up._scheme == 'file'
assert up._host is None
assert not up._match_subdomains
assert not up._match_all
assert up._path == r'/foo-bar\*baz'
@pytest.mark.parametrize('url, expected', [
# We use - instead of ? so it doesn't get treated as query
(r"file:///foo-bar\hellobaz", True),
(r"file:///fooXbar\hellobaz", False),
])
def test_urls(self, up, url, expected):
assert up.matches(QUrl(url)) == expected
class TestMatchIpAddresses:
@pytest.mark.parametrize('pattern, host, match_subdomains', [
("http://127.0.0.1/*", "127.0.0.1", False),
("http://*.0.0.1/*", "0.0.1", True),
])
def test_attrs(self, pattern, host, match_subdomains):
up = urlmatch.UrlPattern(pattern)
assert up._scheme == 'http'
assert up._host == host
assert up._match_subdomains == match_subdomains
assert not up._match_all
assert up._path is None
@pytest.mark.parametrize('pattern, expected', [
("http://127.0.0.1/*", True),
# No subdomain matching is done with IPs
("http://*.0.0.1/*", False),
])
def test_urls(self, pattern, expected):
up = urlmatch.UrlPattern(pattern)
assert up.matches(QUrl("http://127.0.0.1")) == expected
class TestMatchChromeUrls:
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("chrome://favicon/*")
def test_attrs(self, up):
assert up._scheme == 'chrome'
assert up._host == 'favicon'
assert not up._match_subdomains
assert not up._match_all
assert up._path is None
@pytest.mark.parametrize('url, expected', [
("chrome://favicon/http://google.com", True),
("chrome://favicon/https://google.com", True),
("chrome://history", False),
])
def test_urls(self, up, url, expected):
assert up.matches(QUrl(url)) == expected
class TestMatchAnything:
@pytest.fixture(params=['*://*/*', '*://*:*/*', '<all_urls>'])
def up(self, request):
return urlmatch.UrlPattern(request.param)
def test_attrs_common(self, up):
assert up._scheme is None
assert up._host is None
assert up._path is None
def test_attrs_wildcard(self):
up = urlmatch.UrlPattern('*://*/*')
assert up._match_subdomains
assert not up._match_all
def test_attrs_all(self):
up = urlmatch.UrlPattern('<all_urls>')
assert not up._match_subdomains
assert up._match_all
@pytest.mark.parametrize('url', [
"http://127.0.0.1",
# We deviate from Chromium as we allow other schemes as well
"chrome://favicon/http://google.com",
"file:///foo/bar",
"file://localhost/foo/bar",
"qute://version",
"about:blank",
"data:text/html;charset=utf-8,<html>asdf</html>",
])
def test_urls(self, up, url):
assert up.matches(QUrl(url))
@pytest.mark.parametrize('pattern, url, expected', [
("about:*", "about:blank", True),
("about:blank", "about:blank", True),
("about:*", "about:version", True),
("data:*", "data:monkey", True),
("javascript:*", "javascript:atemyhomework", True),
("data:*", "about:blank", False),
])
def test_special_schemes(pattern, url, expected):
assert urlmatch.UrlPattern(pattern).matches(QUrl(url)) == expected
class TestFileScheme:
@pytest.fixture(params=[
'file:///foo*',
'file://foo*',
# FIXME This doesn't pass all tests
pytest.param('file://localhost/foo*', marks=pytest.mark.skip(
reason="We're not handling this correctly in all cases"))
])
def up(self, request):
return urlmatch.UrlPattern(request.param)
def test_attrs(self, up):
assert up._scheme == 'file'
assert up._host is None
assert not up._match_subdomains
assert not up._match_all
assert up._path == '/foo*'
@pytest.mark.parametrize('url, expected', [
("file://foo", False),
("file://foobar", False),
("file:///foo", True),
("file:///foobar", True),
("file://localhost/foo", True),
])
def test_urls(self, up, url, expected):
assert up.matches(QUrl(url)) == expected
class TestMatchSpecificPort:
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("http://www.example.com:80/foo")
def test_attrs(self, up):
assert up._scheme == 'http'
assert up._host == 'www.example.com'
assert not up._match_subdomains
assert not up._match_all
assert up._path == '/foo'
assert up._port == 80
@pytest.mark.parametrize('url, expected', [
("http://www.example.com:80/foo", True),
("http://www.example.com/foo", True),
("http://www.example.com:8080/foo", False),
])
def test_urls(self, up, url, expected):
assert up.matches(QUrl(url)) == expected
class TestExplicitPortWildcard:
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("http://www.example.com:*/foo")
def test_attrs(self, up):
assert up._scheme == 'http'
assert up._host == 'www.example.com'
assert not up._match_subdomains
assert not up._match_all
assert up._path == '/foo'
assert up._port is None
@pytest.mark.parametrize('url, expected', [
("http://www.example.com:80/foo", True),
("http://www.example.com/foo", True),
("http://www.example.com:8080/foo", True),
])
def test_urls(self, up, url, expected):
assert up.matches(QUrl(url)) == expected
def test_ignore_missing_slashes():
pattern1 = urlmatch.UrlPattern("http://www.example.com/example")
pattern2 = urlmatch.UrlPattern("http://www.example.com/example/*")
url1 = QUrl('http://www.example.com/example')
url2 = QUrl('http://www.example.com/example/')
# Same patterns should match same URLs.
assert pattern1.matches(url1)
assert pattern2.matches(url1)
# The not terminated path should match the terminated pattern.
assert pattern2.matches(url1)
# The terminated path however should not match the unterminated pattern.
assert not pattern1.matches(url2)
def test_trailing_slash():
"""Contrary to Chromium, we allow to leave off a trailing slash."""
url = QUrl('http://www.example.com/')
pattern = urlmatch.UrlPattern('http://www.example.com')
assert pattern.matches(url)
@pytest.mark.parametrize('pattern', ['*://example.com/*',
'*://example.com./*'])
@pytest.mark.parametrize('url', ['http://example.com/',
'http://example.com./'])
def test_trailing_dot_domain(pattern, url):
"""Both patterns should match trailing dot and non trailing dot domains.
More information about this not obvious behavior can be found in [1].
RFC 1738 [2] specifies clearly that the <host> part of a URL is supposed to
contain a fully qualified domain name:
3.1. Common Internet Scheme Syntax
//<user>:<password>@<host>:<port>/<url-path>
host
The fully qualified domain name of a network host
[1] http://www.dns-sd.org./TrailingDotsInDomainNames.html
[2] http://www.ietf.org/rfc/rfc1738.txt
"""
assert urlmatch.UrlPattern(pattern).matches(QUrl(url))
def test_urlpattern_benchmark(benchmark):
url = QUrl('https://www.example.com/barfoobar')
def run():
up = urlmatch.UrlPattern('https://*.example.com/*foo*')
up.matches(url)
benchmark(run)
URL_TEXT = hst.text(alphabet=string.ascii_letters)
@hypothesis.given(pattern=hst.builds(
lambda *a: ''.join(a),
# Scheme
hst.one_of(hst.just('*'), hst.just('http'), hst.just('file')),
# Separator
hst.one_of(hst.just(':'), hst.just('://')),
# Host
hst.one_of(hst.just('*'),
hst.builds(lambda *a: ''.join(a), hst.just('*.'), URL_TEXT),
URL_TEXT),
# Port
hst.one_of(hst.just(''),
hst.builds(lambda *a: ''.join(a), hst.just(':'),
hst.integers(min_value=0,
max_value=65535).map(str))),
# Path
hst.one_of(hst.just(''),
hst.builds(lambda *a: ''.join(a), hst.just('/'), URL_TEXT))
))
def test_urlpattern_hypothesis(pattern):
try:
up = urlmatch.UrlPattern(pattern)
except urlmatch.ParseError:
return
up.matches(QUrl('https://www.example.com/'))
@pytest.mark.parametrize('text1, text2, equal', [
# schemes
("http://en.google.com/blah/*/foo",
"https://en.google.com/blah/*/foo",
False),
("https://en.google.com/blah/*/foo",
"https://en.google.com/blah/*/foo",
True),
("https://en.google.com/blah/*/foo",
"ftp://en.google.com/blah/*/foo",
False),
# subdomains
("https://en.google.com/blah/*/foo",
"https://fr.google.com/blah/*/foo",
False),
("https://www.google.com/blah/*/foo",
"https://*.google.com/blah/*/foo",
False),
("https://*.google.com/blah/*/foo",
"https://*.google.com/blah/*/foo",
True),
# domains
("http://en.example.com/blah/*/foo",
"http://en.google.com/blah/*/foo",
False),
# ports
("http://en.google.com:8000/blah/*/foo",
"http://en.google.com/blah/*/foo",
False),
("http://fr.google.com:8000/blah/*/foo",
"http://fr.google.com:8000/blah/*/foo",
True),
("http://en.google.com:8000/blah/*/foo",
"http://en.google.com:8080/blah/*/foo",
False),
# paths
("http://en.google.com/blah/*/foo",
"http://en.google.com/blah/*",
False),
("http://en.google.com/*",
"http://en.google.com/",
False),
("http://en.google.com/*",
"http://en.google.com/*",
True),
# all_urls
("<all_urls>",
"<all_urls>",
True),
("<all_urls>",
"http://*/*",
False)
])
def test_equal(text1, text2, equal):
pat1 = urlmatch.UrlPattern(text1)
pat2 = urlmatch.UrlPattern(text2)
assert (pat1 == pat2) == equal
assert (hash(pat1) == hash(pat2)) == equal
def test_equal_string():
assert urlmatch.UrlPattern("<all_urls>") != '<all_urls>'
def test_repr():
pat = urlmatch.UrlPattern('https://www.example.com/')
expected = ("qutebrowser.utils.urlmatch.UrlPattern("
"pattern='https://www.example.com/')")
assert repr(pat) == expected
def test_str():
text = 'https://www.example.com/'
pat = urlmatch.UrlPattern(text)
assert str(pat) == text