Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Antoni Boucher 2015-05-31 15:59:46 -04:00
commit 1698c60124
26 changed files with 363 additions and 118 deletions

View File

@ -3,6 +3,7 @@ branch = true
omit = omit =
qutebrowser/__main__.py qutebrowser/__main__.py
*/__init__.py */__init__.py
qutebrowser/resources.py
[report] [report]
exclude_lines = exclude_lines =

View File

@ -32,6 +32,11 @@ Added
- New arguments `--basedir` and `--temp-basedir` (intended for debugging) to set a different base directory for all data, which allows multiple invocations. - New arguments `--basedir` and `--temp-basedir` (intended for debugging) to set a different base directory for all data, which allows multiple invocations.
- New argument `--no-err-windows` to suppress all error windows. - New argument `--no-err-windows` to suppress all error windows.
- New visual/caret mode (bound to `v`) to select text by keyboard. - New visual/caret mode (bound to `v`) to select text by keyboard.
- New setting `tabs -> mousewheel-tab-switching` to control mousewheel behavior on the tab bar.
- New arguments `--top-navigate` and `--bottom-navigate` (`-t`/`-b`) for `:scroll-page` to specify a navigation action (e.g. automatically go to the next page when arriving at the bottom).
- New flag `-d`/`--detach` for `:spawn` to detach the spawned process so it's not closed when qutebrowser is.
- New (hidden) command `:follow-selected` (bound to `Enter`/`Ctrl-Enter` by default) to follow the link which is currently selected (e.g. after searching via `/`).
- New setting `ui -> modal-js-dialog` to use the standard modal dialogs for javascript questions instead of using the statusbar.
Changed Changed
~~~~~~~ ~~~~~~~
@ -73,6 +78,7 @@ Fixed
- Various fixes for deprecated key bindings and auto-migrations. - Various fixes for deprecated key bindings and auto-migrations.
- Workaround for qutebrowser not starting when there are NUL-bytes in the history (because of a currently unknown bug) - Workaround for qutebrowser not starting when there are NUL-bytes in the history (because of a currently unknown bug)
- Fixed handling of keybindings containing Ctrl/Meta on OS X. - Fixed handling of keybindings containing Ctrl/Meta on OS X.
- Fixed crash when downloading an URL without filename (e.g. magnet links) via "Save as...".
https://github.com/The-Compiler/qutebrowser/releases/tag/v0.2.1[v0.2.1] https://github.com/The-Compiler/qutebrowser/releases/tag/v0.2.1[v0.2.1]
----------------------------------------------------------------------- -----------------------------------------------------------------------

View File

@ -86,7 +86,7 @@ Useful utilities
Checkers Checkers
~~~~~~~~ ~~~~~~~~
qutbebrowser uses http://tox.readthedocs.org/en/latest/[tox] to run its qutebrowser uses http://tox.readthedocs.org/en/latest/[tox] to run its
unittests and several linters/checkers. unittests and several linters/checkers.
Currently, the following tools will be invoked when you run `tox`: Currently, the following tools will be invoked when you run `tox`:

View File

@ -139,7 +139,9 @@ Contributors, sorted by the number of commits in descending order:
* Joel Torstensson * Joel Torstensson
* Claude * Claude
* Artur Shaik * Artur Shaik
* Antoni Boucher
* ZDarian * ZDarian
* Martin Tournoij
* Peter Vilim * Peter Vilim
* John ShaggyTwoDope Jenkins * John ShaggyTwoDope Jenkins
* Jimmy * Jimmy
@ -150,6 +152,7 @@ Contributors, sorted by the number of commits in descending order:
* Error 800 * Error 800
* Brian Jackson * Brian Jackson
* sbinix * sbinix
* Tobias Patzl
* Johannes Altmanninger * Johannes Altmanninger
* Samir Benmendil * Samir Benmendil
* Regina Hug * Regina Hug

View File

@ -692,6 +692,7 @@ How many steps to zoom out.
|<<drop-selection,drop-selection>>|Drop selection and keep selection mode enabled. |<<drop-selection,drop-selection>>|Drop selection and keep selection mode enabled.
|<<enter-mode,enter-mode>>|Enter a key mode. |<<enter-mode,enter-mode>>|Enter a key mode.
|<<follow-hint,follow-hint>>|Follow the currently selected hint. |<<follow-hint,follow-hint>>|Follow the currently selected hint.
|<<follow-selected,follow-selected>>|Follow the selected text.
|<<leave-mode,leave-mode>>|Leave the mode we're currently in. |<<leave-mode,leave-mode>>|Leave the mode we're currently in.
|<<message-error,message-error>>|Show an error message in the statusbar. |<<message-error,message-error>>|Show an error message in the statusbar.
|<<message-info,message-info>>|Show an info message in the statusbar. |<<message-info,message-info>>|Show an info message in the statusbar.
@ -774,6 +775,15 @@ Enter a key mode.
=== follow-hint === follow-hint
Follow the currently selected hint. Follow the currently selected hint.
[[follow-selected]]
=== follow-selected
Syntax: +:follow-selected [*--tab*]+
Follow the selected text.
==== optional arguments
* +*-t*+, +*--tab*+: Load the selected link in a new tab.
[[leave-mode]] [[leave-mode]]
=== leave-mode === leave-mode
Leave the mode we're currently in. Leave the mode we're currently in.
@ -1009,7 +1019,7 @@ multiplier
[[scroll-page]] [[scroll-page]]
=== scroll-page === scroll-page
Syntax: +:scroll-page 'x' 'y'+ Syntax: +:scroll-page [*--top-navigate* 'ACTION'] [*--bottom-navigate* 'ACTION'] 'x' 'y'+
Scroll the frame page-wise. Scroll the frame page-wise.
@ -1017,6 +1027,12 @@ Scroll the frame page-wise.
* +'x'+: How many pages to scroll to the right. * +'x'+: How many pages to scroll to the right.
* +'y'+: How many pages to scroll down. * +'y'+: How many pages to scroll down.
==== optional arguments
* +*-t*+, +*--top-navigate*+: :navigate action (prev, decrement) to run when scrolling up at the top of the page.
* +*-b*+, +*--bottom-navigate*+: :navigate action (next, increment) to run when scrolling down at the bottom of the page.
==== count ==== count
multiplier multiplier

View File

@ -45,6 +45,7 @@
|<<ui-hide-statusbar,hide-statusbar>>|Whether to hide the statusbar unless a message is shown. |<<ui-hide-statusbar,hide-statusbar>>|Whether to hide the statusbar unless a message is shown.
|<<ui-window-title-format,window-title-format>>|The format to use for the window title. The following placeholders are defined: |<<ui-window-title-format,window-title-format>>|The format to use for the window title. The following placeholders are defined:
|<<ui-hide-mouse-cursor,hide-mouse-cursor>>|Whether to hide the mouse cursor. |<<ui-hide-mouse-cursor,hide-mouse-cursor>>|Whether to hide the mouse cursor.
|<<ui-modal-js-dialog,modal-js-dialog>>|Use standard JavaScript modal dialog for alert() and confirm()
|============== |==============
.Quick reference for section ``network'' .Quick reference for section ``network''
@ -111,6 +112,7 @@
|<<tabs-indicator-space,indicator-space>>|Spacing between tab edge and indicator. |<<tabs-indicator-space,indicator-space>>|Spacing between tab edge and indicator.
|<<tabs-tabs-are-windows,tabs-are-windows>>|Whether to open windows instead of tabs. |<<tabs-tabs-are-windows,tabs-are-windows>>|Whether to open windows instead of tabs.
|<<tabs-title-format,title-format>>|The format to use for the tab title. The following placeholders are defined: |<<tabs-title-format,title-format>>|The format to use for the tab title. The following placeholders are defined:
|<<tabs-mousewheel-tab-switching,mousewheel-tab-switching>>|Switch between tabs using the mouse wheel.
|============== |==============
.Quick reference for section ``storage'' .Quick reference for section ``storage''
@ -593,6 +595,17 @@ Valid values:
Default: +pass:[false]+ Default: +pass:[false]+
[[ui-modal-js-dialog]]
=== modal-js-dialog
Use standard JavaScript modal dialog for alert() and confirm()
Valid values:
* +true+
* +false+
Default: +pass:[false]+
== network == network
Settings related to the network. Settings related to the network.
@ -1031,6 +1044,17 @@ The format to use for the tab title. The following placeholders are defined:
Default: +pass:[{index}: {title}]+ Default: +pass:[{index}: {title}]+
[[tabs-mousewheel-tab-switching]]
=== mousewheel-tab-switching
Switch between tabs using the mouse wheel.
Valid values:
* +true+
* +false+
Default: +pass:[true]+
== storage == storage
Settings related to cache and storage. Settings related to cache and storage.

View File

@ -11,6 +11,7 @@ What to do now
* View the http://qutebrowser.org/img/cheatsheet-big.png[key binding cheatsheet] * View the http://qutebrowser.org/img/cheatsheet-big.png[key binding cheatsheet]
to make yourself familiar with the key bindings: + to make yourself familiar with the key bindings: +
image:http://qutebrowser.org/img/cheatsheet-small.png["qutebrowser key binding cheatsheet",link="http://qutebrowser.org/img/cheatsheet-big.png"] image:http://qutebrowser.org/img/cheatsheet-small.png["qutebrowser key binding cheatsheet",link="http://qutebrowser.org/img/cheatsheet-big.png"]
* Run `:adblock-update` to download adblock lists and activate adblocking.
* If you just cloned the repository, you'll need to run * If you just cloned the repository, you'll need to run
`scripts/asciidoc2html.py` to generate the documentation. `scripts/asciidoc2html.py` to generate the documentation.
* Go to the link:qute://settings[settings page] to set up qutebrowser the way you want it. * Go to the link:qute://settings[settings page] to set up qutebrowser the way you want it.

View File

@ -71,7 +71,7 @@ def run(args):
sys.exit(usertypes.Exit.ok) sys.exit(usertypes.Exit.ok)
if args.temp_basedir: if args.temp_basedir:
args.basedir = tempfile.mkdtemp() args.basedir = tempfile.mkdtemp(prefix='qutebrowser-basedir-')
quitter = Quitter(args) quitter = Quitter(args)
objreg.register('quitter', quitter) objreg.register('quitter', quitter)

View File

@ -25,7 +25,9 @@ import shlex
import subprocess import subprocess
import posixpath import posixpath
import functools import functools
import xml.etree.ElementTree
from PyQt5.QtWebKit import QWebSettings
from PyQt5.QtWidgets import QApplication, QTabBar from PyQt5.QtWidgets import QApplication, QTabBar
from PyQt5.QtCore import Qt, QUrl, QEvent from PyQt5.QtCore import Qt, QUrl, QEvent
from PyQt5.QtGui import QClipboard, QKeyEvent from PyQt5.QtGui import QClipboard, QKeyEvent
@ -647,14 +649,37 @@ class CommandDispatcher:
@cmdutils.register(instance='command-dispatcher', hide=True, @cmdutils.register(instance='command-dispatcher', hide=True,
scope='window', count='count') scope='window', count='count')
def scroll_page(self, x: {'type': float}, y: {'type': float}, count=1): def scroll_page(self, x: {'type': float}, y: {'type': float}, *,
top_navigate: {'type': ('prev', 'decrement'),
'metavar': 'ACTION'}=None,
bottom_navigate: {'type': ('next', 'increment'),
'metavar': 'ACTION'}=None,
count=1):
"""Scroll the frame page-wise. """Scroll the frame page-wise.
Args: Args:
x: How many pages to scroll to the right. x: How many pages to scroll to the right.
y: How many pages to scroll down. y: How many pages to scroll down.
bottom_navigate: :navigate action (next, increment) to run when
scrolling down at the bottom of the page.
top_navigate: :navigate action (prev, decrement) to run when
scrolling up at the top of the page.
count: multiplier count: multiplier
""" """
frame = self._current_widget().page().currentFrame()
if not frame.url().isValid():
# See https://github.com/The-Compiler/qutebrowser/issues/701
return
if (bottom_navigate is not None and
frame.scrollPosition().y() >=
frame.scrollBarMaximum(Qt.Vertical)):
self.navigate(bottom_navigate)
return
elif top_navigate is not None and frame.scrollPosition().y() == 0:
self.navigate(top_navigate)
return
mult_x = count * x mult_x = count * x
mult_y = count * y mult_y = count * y
if mult_y.is_integer(): if mult_y.is_integer():
@ -667,7 +692,6 @@ class CommandDispatcher:
mult_y = 0 mult_y = 0
if mult_x == 0 and mult_y == 0: if mult_x == 0 and mult_y == 0:
return return
frame = self._current_widget().page().currentFrame()
size = frame.geometry() size = frame.geometry()
dx = mult_x * size.width() dx = mult_x * size.width()
dy = mult_y * size.height() dy = mult_y * size.height()
@ -1010,6 +1034,39 @@ class CommandDispatcher:
""" """
self._open(QUrl(url), tab, bg, window) self._open(QUrl(url), tab, bg, window)
@cmdutils.register(instance='command-dispatcher', hide=True,
scope='window')
def follow_selected(self, tab=False):
"""Follow the selected text.
Args:
tab: Load the selected link in a new tab.
"""
widget = self._current_widget()
page = widget.page()
if not page.hasSelection():
return
if QWebSettings.globalSettings().testAttribute(
QWebSettings.JavascriptEnabled):
if tab:
page.open_target = usertypes.ClickTarget.tab
page.currentFrame().evaluateJavaScript(
'window.getSelection().anchorNode.parentNode.click()')
else:
try:
selected_element = xml.etree.ElementTree.fromstring(
'<html>' + widget.selectedHtml() + '</html>').find('a')
except xml.etree.ElementTree.ParseError:
raise cmdexc.CommandError('Could not parse selected element!')
if selected_element is not None:
try:
url = selected_element.attrib['href']
except KeyError:
raise cmdexc.CommandError('Anchor element without href!')
url = self._current_url().resolved(QUrl(url))
self._open(url, tab)
@cmdutils.register(instance='command-dispatcher', name='inspector', @cmdutils.register(instance='command-dispatcher', name='inspector',
scope='window') scope='window')
def toggle_inspector(self): def toggle_inspector(self):

View File

@ -686,8 +686,11 @@ class DownloadManager(QAbstractListModel):
if fileobj is not None or filename is not None: if fileobj is not None or filename is not None:
return self.fetch_request(request, page, fileobj, filename, return self.fetch_request(request, page, fileobj, filename,
auto_remove, suggested_fn) auto_remove, suggested_fn)
encoding = sys.getfilesystemencoding() if suggested_fn is None:
suggested_fn = utils.force_encoding(suggested_fn, encoding) suggested_fn = 'qutebrowser-download'
else:
encoding = sys.getfilesystemencoding()
suggested_fn = utils.force_encoding(suggested_fn, encoding)
q = self._prepare_question() q = self._prepare_question()
q.default = _path_suggestion(suggested_fn) q.default = _path_suggestion(suggested_fn)
message_bridge = objreg.get('message-bridge', scope='window', message_bridge = objreg.get('message-bridge', scope='window',

View File

@ -478,17 +478,23 @@ class BrowserPage(QWebPage):
return super().extension(ext, opt, out) return super().extension(ext, opt, out)
return handler(opt, out) return handler(opt, out)
def javaScriptAlert(self, _frame, msg): def javaScriptAlert(self, frame, msg):
"""Override javaScriptAlert to use the statusbar.""" """Override javaScriptAlert to use the statusbar."""
log.js.debug("alert: {}".format(msg)) log.js.debug("alert: {}".format(msg))
if config.get('ui', 'modal-js-dialog'):
return super().javaScriptAlert(frame, msg)
if (self._is_shutting_down or if (self._is_shutting_down or
config.get('content', 'ignore-javascript-alert')): config.get('content', 'ignore-javascript-alert')):
return return
self._ask("[js alert] {}".format(msg), usertypes.PromptMode.alert) self._ask("[js alert] {}".format(msg), usertypes.PromptMode.alert)
def javaScriptConfirm(self, _frame, msg): def javaScriptConfirm(self, frame, msg):
"""Override javaScriptConfirm to use the statusbar.""" """Override javaScriptConfirm to use the statusbar."""
log.js.debug("confirm: {}".format(msg)) log.js.debug("confirm: {}".format(msg))
if config.get('ui', 'modal-js-dialog'):
return super().javaScriptConfirm(frame, msg)
if self._is_shutting_down: if self._is_shutting_down:
return False return False
ans = self._ask("[js confirm] {}".format(msg), ans = self._ask("[js confirm] {}".format(msg),

View File

@ -487,12 +487,25 @@ class WebView(QWebView):
old_scroll_pos = self.scroll_pos old_scroll_pos = self.scroll_pos
flags = QWebPage.FindFlags(flags) flags = QWebPage.FindFlags(flags)
found = self.findText(text, flags) found = self.findText(text, flags)
if not found and not flags & QWebPage.HighlightAllOccurrences and text: backward = flags & QWebPage.FindBackward
message.error(self.win_id, "Text '{}' not found on "
"page!".format(text), immediately=True)
else:
backward = int(flags) & QWebPage.FindBackward
if not found and not flags & QWebPage.HighlightAllOccurrences and text:
# User disabled wrapping; but findText() just returns False. If we
# have a selection, we know there's a match *somewhere* on the page
if (not flags & QWebPage.FindWrapsAroundDocument and
self.hasSelection()):
if not backward:
message.warning(self.win_id, "Search hit BOTTOM without "
"match for: {}".format(text),
immediately=True)
else:
message.warning(self.win_id, "Search hit TOP without "
"match for: {}".format(text),
immediately=True)
else:
message.error(self.win_id, "Text '{}' not found on "
"page!".format(text), immediately=True)
else:
def check_scroll_pos(): def check_scroll_pos():
"""Check if the scroll position got smaller and show info.""" """Check if the scroll position got smaller and show info."""
if not backward and self.scroll_pos < old_scroll_pos: if not backward and self.scroll_pos < old_scroll_pos:

View File

@ -61,7 +61,8 @@ class Command:
""" """
AnnotationInfo = collections.namedtuple('AnnotationInfo', AnnotationInfo = collections.namedtuple('AnnotationInfo',
['kwargs', 'type', 'flag', 'hide']) ['kwargs', 'type', 'flag', 'hide',
'metavar'])
def __init__(self, *, handler, name, instance=None, maxsplit=None, def __init__(self, *, handler, name, instance=None, maxsplit=None,
hide=False, completion=None, modes=None, not_modes=None, hide=False, completion=None, modes=None, not_modes=None,
@ -257,10 +258,10 @@ class Command:
pass pass
if isinstance(typ, tuple): if isinstance(typ, tuple):
pass kwargs['metavar'] = annotation_info.metavar or param.name
elif utils.is_enum(typ): elif utils.is_enum(typ):
kwargs['choices'] = [e.name.replace('_', '-') for e in typ] kwargs['choices'] = [e.name.replace('_', '-') for e in typ]
kwargs['metavar'] = param.name kwargs['metavar'] = annotation_info.metavar or param.name
elif typ is bool: elif typ is bool:
kwargs['action'] = 'store_true' kwargs['action'] = 'store_true'
elif typ is not None: elif typ is not None:
@ -287,7 +288,7 @@ class Command:
A list of args. A list of args.
""" """
args = [] args = []
name = param.name.rstrip('_') name = param.name.rstrip('_').replace('_', '-')
shortname = annotation_info.flag or name[0] shortname = annotation_info.flag or name[0]
if len(shortname) != 1: if len(shortname) != 1:
raise ValueError("Flag '{}' of parameter {} (command {}) must be " raise ValueError("Flag '{}' of parameter {} (command {}) must be "
@ -322,11 +323,12 @@ class Command:
flag: The short name/flag if overridden. flag: The short name/flag if overridden.
name: The long name if overridden. name: The long name if overridden.
""" """
info = {'kwargs': {}, 'type': None, 'flag': None, 'hide': False} info = {'kwargs': {}, 'type': None, 'flag': None, 'hide': False,
'metavar': None}
if param.annotation is not inspect.Parameter.empty: if param.annotation is not inspect.Parameter.empty:
log.commands.vdebug("Parsing annotation {}".format( log.commands.vdebug("Parsing annotation {}".format(
param.annotation)) param.annotation))
for field in ('type', 'flag', 'name', 'hide'): for field in ('type', 'flag', 'name', 'hide', 'metavar'):
if field in param.annotation: if field in param.annotation:
info[field] = param.annotation[field] info[field] = param.annotation[field]
if 'nargs' in param.annotation: if 'nargs' in param.annotation:

View File

@ -305,6 +305,10 @@ def data(readonly=False):
SettingValue(typ.Bool(), 'false'), SettingValue(typ.Bool(), 'false'),
"Whether to hide the mouse cursor."), "Whether to hide the mouse cursor."),
('modal-js-dialog',
SettingValue(typ.Bool(), 'false'),
"Use standard JavaScript modal dialog for alert() and confirm()"),
readonly=readonly readonly=readonly
)), )),
@ -522,6 +526,10 @@ def data(readonly=False):
"* `{index}`: The index of this tab.\n" "* `{index}`: The index of this tab.\n"
"* `{id}`: The internal tab ID of this tab."), "* `{id}`: The internal tab ID of this tab."),
('mousewheel-tab-switching',
SettingValue(typ.Bool(), 'true'),
"Switch between tabs using the mouse wheel."),
readonly=readonly readonly=readonly
)), )),
@ -1236,6 +1244,8 @@ KEY_DATA = collections.OrderedDict([
('stop', ['<Ctrl-s>']), ('stop', ['<Ctrl-s>']),
('print', ['<Ctrl-Alt-p>']), ('print', ['<Ctrl-Alt-p>']),
('open qute:settings', ['Ss']), ('open qute:settings', ['Ss']),
('follow-selected', ['<Return>']),
('follow-selected -t', ['<Ctrl-Return>']),
])), ])),
('insert', collections.OrderedDict([ ('insert', collections.OrderedDict([

View File

@ -577,3 +577,14 @@ class TabbedBrowser(tabwidget.TabWidget):
""" """
super().resizeEvent(e) super().resizeEvent(e)
self.resized.emit(self.geometry()) self.resized.emit(self.geometry())
def wheelEvent(self, e):
"""Override wheelEvent of QWidget to forward it to the focused tab.
Args:
e: The QWheelEvent
"""
if self._now_focused is not None:
self._now_focused.wheelEvent(e)
else:
e.ignore()

View File

@ -480,6 +480,19 @@ class TabBar(QTabBar):
new_idx = super().insertTab(idx, icon, '') new_idx = super().insertTab(idx, icon, '')
self.set_page_title(new_idx, text) self.set_page_title(new_idx, text)
def wheelEvent(self, e):
"""Override wheelEvent to make the action configurable.
Args:
e: The QWheelEvent
"""
if config.get('tabs', 'mousewheel-tab-switching'):
super().wheelEvent(e)
else:
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=self._win_id)
tabbed_browser.wheelEvent(e)
class TabBarStyle(QCommonStyle): class TabBarStyle(QCommonStyle):

View File

@ -122,7 +122,8 @@ class ExternalEditor(QObject):
raise ValueError("Already editing a file!") raise ValueError("Already editing a file!")
self._text = text self._text = text
try: try:
self._oshandle, self._filename = tempfile.mkstemp(text=True) self._oshandle, self._filename = tempfile.mkstemp(
text=True, prefix='qutebrowser-editor-')
if text: if text:
encoding = config.get('general', 'editor-encoding') encoding = config.get('general', 'editor-encoding')
with open(self._filename, 'w', encoding=encoding) as f: with open(self._filename, 'w', encoding=encoding) as f:

View File

@ -269,8 +269,6 @@ def qt_message_handler(msg_type, context, msg):
# https://bugreports.qt-project.org/browse/QTBUG-30298 # https://bugreports.qt-project.org/browse/QTBUG-30298
"QNetworkReplyImplPrivate::error: Internal problem, this method must " "QNetworkReplyImplPrivate::error: Internal problem, this method must "
"only be called once.", "only be called once.",
# Not much information about this, but it seems harmless
'QXcbWindow: Unhandled client message: "_GTK_LOAD_ICONTHEMES"',
# Sometimes indicates missing text, but most of the time harmless # Sometimes indicates missing text, but most of the time harmless
"load glyph failed ", "load glyph failed ",
# Harmless, see https://bugreports.qt-project.org/browse/QTBUG-42479 # Harmless, see https://bugreports.qt-project.org/browse/QTBUG-42479
@ -282,7 +280,11 @@ def qt_message_handler(msg_type, context, msg):
# Hopefully harmless # Hopefully harmless
'"Method "GetAll" with signature "s" on interface ' '"Method "GetAll" with signature "s" on interface '
'"org.freedesktop.DBus.Properties" doesn\'t exist', '"org.freedesktop.DBus.Properties" doesn\'t exist',
'WOFF support requires QtWebKit to be built with zlib support.' 'WOFF support requires QtWebKit to be built with zlib support.',
# Weird Enlightment/GTK X extensions
'QXcbWindow: Unhandled client message: "_E_',
'QXcbWindow: Unhandled client message: "_ECORE_',
'QXcbWindow: Unhandled client message: "_GTK_',
) )
if any(msg.strip().startswith(pattern) for pattern in suppressed_msgs): if any(msg.strip().startswith(pattern) for pattern in suppressed_msgs):
level = logging.DEBUG level = logging.DEBUG

View File

@ -131,7 +131,7 @@ def ensure_valid(obj):
def ensure_not_null(obj): def ensure_not_null(obj):
"""Ensure a Qt object with an .isNull() method is not null.""" """Ensure a Qt object with an .isNull() method is not null."""
if obj.isNull(): if obj.isNull():
raise QtValueError(obj) raise QtValueError(obj, null=True)
def check_qdatastream(stream): def check_qdatastream(stream):
@ -180,7 +180,7 @@ def deserialize_stream(stream, obj):
def savefile_open(filename, binary=False, encoding='utf-8'): def savefile_open(filename, binary=False, encoding='utf-8'):
"""Context manager to easily use a QSaveFile.""" """Context manager to easily use a QSaveFile."""
f = QSaveFile(filename) f = QSaveFile(filename)
new_f = None cancelled = False
try: try:
ok = f.open(QIODevice.WriteOnly) ok = f.open(QIODevice.WriteOnly)
if not ok: if not ok:
@ -192,13 +192,14 @@ def savefile_open(filename, binary=False, encoding='utf-8'):
yield new_f yield new_f
except: except:
f.cancelWriting() f.cancelWriting()
cancelled = True
raise raise
else:
new_f.flush()
finally: finally:
if new_f is not None:
new_f.flush()
commit_ok = f.commit() commit_ok = f.commit()
if not commit_ok: if not commit_ok and not cancelled:
raise OSError(f.errorString()) raise OSError("Commit failed!")
@contextlib.contextmanager @contextlib.contextmanager
@ -221,27 +222,58 @@ class PyQIODevice(io.BufferedIOBase):
"""Wrapper for a QIODevice which provides a python interface. """Wrapper for a QIODevice which provides a python interface.
Attributes: Attributes:
_dev: The underlying QIODevice. dev: The underlying QIODevice.
""" """
# pylint: disable=missing-docstring # pylint: disable=missing-docstring
def __init__(self, dev): def __init__(self, dev):
self._dev = dev self.dev = dev
def __len__(self): def __len__(self):
return self._dev.size() return self.dev.size()
def _check_open(self): def _check_open(self):
"""Check if the device is open, raise OSError if not.""" """Check if the device is open, raise ValueError if not."""
if not self._dev.isOpen(): if not self.dev.isOpen():
raise OSError("IO operation on closed device!") raise ValueError("IO operation on closed device!")
def _check_random(self): def _check_random(self):
"""Check if the device supports random access, raise OSError if not.""" """Check if the device supports random access, raise OSError if not."""
if not self.seekable(): if not self.seekable():
raise OSError("Random access not allowed!") raise OSError("Random access not allowed!")
def _check_readable(self):
"""Check if the device is readable, raise OSError if not."""
if not self.dev.isReadable():
raise OSError("Trying to read unreadable file!")
def _check_writable(self):
"""Check if the device is writable, raise OSError if not."""
if not self.writable():
raise OSError("Trying to write to unwritable file!")
def open(self, mode):
"""Open the underlying device and ensure opening succeeded.
Raises OSError if opening failed.
Args:
mode: QIODevice::OpenMode flags.
Return:
A contextlib.closing() object so this can be used as
contextmanager.
"""
ok = self.dev.open(mode)
if not ok:
raise OSError(self.dev.errorString())
return contextlib.closing(self)
def close(self):
"""Close the underlying device."""
self.dev.close()
def fileno(self): def fileno(self):
raise io.UnsupportedOperation raise io.UnsupportedOperation
@ -249,85 +281,102 @@ class PyQIODevice(io.BufferedIOBase):
self._check_open() self._check_open()
self._check_random() self._check_random()
if whence == io.SEEK_SET: if whence == io.SEEK_SET:
ok = self._dev.seek(offset) ok = self.dev.seek(offset)
elif whence == io.SEEK_CUR: elif whence == io.SEEK_CUR:
ok = self._dev.seek(self.tell() + offset) ok = self.dev.seek(self.tell() + offset)
elif whence == io.SEEK_END: elif whence == io.SEEK_END:
ok = self._dev.seek(len(self) + offset) ok = self.dev.seek(len(self) + offset)
else: else:
raise io.UnsupportedOperation("whence = {} is not " raise io.UnsupportedOperation("whence = {} is not "
"supported!".format(whence)) "supported!".format(whence))
if not ok: if not ok:
raise OSError(self._dev.errorString()) raise OSError("seek failed!")
def truncate(self, size=None): # pylint: disable=unused-argument def truncate(self, size=None): # pylint: disable=unused-argument
raise io.UnsupportedOperation raise io.UnsupportedOperation
def close(self):
self._dev.close()
@property @property
def closed(self): def closed(self):
return not self._dev.isOpen() return not self.dev.isOpen()
def flush(self): def flush(self):
self._check_open() self._check_open()
self._dev.waitForBytesWritten(-1) self.dev.waitForBytesWritten(-1)
def isatty(self): def isatty(self):
self._check_open() self._check_open()
return False return False
def readable(self): def readable(self):
return self._dev.isReadable() return self.dev.isReadable()
def readline(self, size=-1): def readline(self, size=-1):
self._check_open() self._check_open()
if size == -1: self._check_readable()
size = 0
return self._dev.readLine(size) if size < 0:
qt_size = 0 # no maximum size
elif size == 0:
return QByteArray()
else:
qt_size = size + 1 # Qt also counts the NUL byte
if self.dev.canReadLine():
buf = self.dev.readLine(qt_size)
else:
if size < 0:
buf = self.dev.readAll()
else:
buf = self.dev.read(size)
if buf is None:
raise OSError(self.dev.errorString())
return buf
def seekable(self): def seekable(self):
return not self._dev.isSequential() return not self.dev.isSequential()
def tell(self): def tell(self):
self._check_open() self._check_open()
self._check_random() self._check_random()
return self._dev.pos() return self.dev.pos()
def writable(self): def writable(self):
return self._dev.isWritable() return self.dev.isWritable()
def readinto(self, b):
self._check_open()
return self._dev.read(b, len(b))
def write(self, b): def write(self, b):
self._check_open() self._check_open()
num = self._dev.write(b) self._check_writable()
num = self.dev.write(b)
if num == -1 or num < len(b): if num == -1 or num < len(b):
raise OSError(self._dev.errorString()) raise OSError(self.dev.errorString())
return num return num
def read(self, size): def read(self, size=-1):
self._check_open() self._check_open()
buf = bytes() self._check_readable()
num = self._dev.read(buf, size) if size < 0:
if num == -1: buf = self.dev.readAll()
raise OSError(self._dev.errorString()) else:
return num buf = self.dev.read(size)
if buf is None:
raise OSError(self.dev.errorString())
return buf
class QtValueError(ValueError): class QtValueError(ValueError):
"""Exception which gets raised by ensure_valid.""" """Exception which gets raised by ensure_valid."""
def __init__(self, obj): def __init__(self, obj, null=False):
try: try:
self.reason = obj.errorString() self.reason = obj.errorString()
except AttributeError: except AttributeError:
self.reason = None self.reason = None
err = "{} is not valid".format(obj) if null:
err = "{} is null".format(obj)
else:
err = "{} is not valid".format(obj)
if self.reason: if self.reason:
err += ": {}".format(self.reason) err += ": {}".format(self.reason)
super().__init__(err) super().__init__(err)

View File

@ -94,6 +94,8 @@ def _is_url_naive(urlstr):
True if the URL really is a URL, False otherwise. True if the URL really is a URL, False otherwise.
""" """
url = qurl_from_user_input(urlstr) url = qurl_from_user_input(urlstr)
assert url.isValid()
if not utils.raises(ValueError, ipaddress.ip_address, urlstr): if not utils.raises(ValueError, ipaddress.ip_address, urlstr):
# Valid IPv4/IPv6 address # Valid IPv4/IPv6 address
return True return True
@ -104,9 +106,7 @@ def _is_url_naive(urlstr):
if not QHostAddress(urlstr).isNull(): if not QHostAddress(urlstr).isNull():
return False return False
if not url.isValid(): if '.' in url.host():
return False
elif '.' in url.host():
return True return True
else: else:
return False return False
@ -122,9 +122,7 @@ def _is_url_dns(urlstr):
True if the URL really is a URL, False otherwise. True if the URL really is a URL, False otherwise.
""" """
url = qurl_from_user_input(urlstr) url = qurl_from_user_input(urlstr)
if not url.isValid(): assert url.isValid()
log.url.debug("Invalid URL -> False")
return False
if (utils.raises(ValueError, ipaddress.ip_address, urlstr) and if (utils.raises(ValueError, ipaddress.ip_address, urlstr) and
not QHostAddress(urlstr).isNull()): not QHostAddress(urlstr).isNull()):
@ -246,16 +244,13 @@ def is_url(urlstr):
return False return False
if not qurl_userinput.isValid(): if not qurl_userinput.isValid():
# This will also catch URLs containing spaces.
return False return False
if _has_explicit_scheme(qurl): if _has_explicit_scheme(qurl):
# URLs with explicit schemes are always URLs # URLs with explicit schemes are always URLs
log.url.debug("Contains explicit scheme") log.url.debug("Contains explicit scheme")
url = True url = True
elif ' ' in urlstr:
# A URL will never contain a space
log.url.debug("Contains space -> no URL")
url = False
elif qurl_userinput.host() in ('localhost', '127.0.0.1', '::1'): elif qurl_userinput.host() in ('localhost', '127.0.0.1', '::1'):
log.url.debug("Is localhost.") log.url.debug("Is localhost.")
url = True url = True
@ -274,7 +269,7 @@ def is_url(urlstr):
else: else:
raise ValueError("Invalid autosearch value") raise ValueError("Invalid autosearch value")
log.url.debug("url = {}".format(url)) log.url.debug("url = {}".format(url))
return url and qurl_userinput.isValid() return url
def qurl_from_user_input(urlstr): def qurl_from_user_input(urlstr):

View File

@ -130,7 +130,7 @@ def _module_versions():
try: try:
import sipconfig # pylint: disable=import-error,unused-variable import sipconfig # pylint: disable=import-error,unused-variable
except ImportError: except ImportError:
pass lines.append('SIP: ?')
else: else:
try: try:
lines.append('SIP: {}'.format( lines.append('SIP: {}'.format(

View File

@ -28,6 +28,7 @@ import sys
import glob import glob
import subprocess import subprocess
import platform import platform
import filecmp
class Error(Exception): class Error(Exception):
@ -58,6 +59,22 @@ def get_ignored_files(directory, files):
return filtered return filtered
def needs_update(source, dest):
"""Check if a file to be linked/copied needs to be updated."""
if os.path.islink(dest):
# No need to delete a link and relink -> skip this
return False
elif os.path.isdir(dest):
diffs = filecmp.dircmp(source, dest)
ignored = get_ignored_files(source, diffs.left_only)
has_new_files = set(ignored) != set(diffs.left_only)
return (has_new_files or diffs.right_only or
diffs.common_funny or diffs.diff_files or
diffs.funny_files)
else:
return not filecmp.cmp(source, dest)
def link_pyqt(sys_path, venv_path): def link_pyqt(sys_path, venv_path):
"""Symlink the systemwide PyQt/sip into the venv. """Symlink the systemwide PyQt/sip into the venv.
@ -70,28 +87,47 @@ def link_pyqt(sys_path, venv_path):
if not globbed_sip: if not globbed_sip:
raise Error("Did not find sip in {}!".format(sys_path)) raise Error("Did not find sip in {}!".format(sys_path))
files = ['PyQt5'] files = [('PyQt5', True), ('sipconfig.py', False)]
files += [os.path.basename(e) for e in globbed_sip] files += [(os.path.basename(e), True) for e in globbed_sip]
for fn in files: for fn, required in files:
source = os.path.join(sys_path, fn) source = os.path.join(sys_path, fn)
dest = os.path.join(venv_path, fn) dest = os.path.join(venv_path, fn)
if not os.path.exists(source): if not os.path.exists(source):
raise FileNotFoundError(source) if required:
raise FileNotFoundError(source)
else:
continue
if os.path.exists(dest): if os.path.exists(dest):
if os.path.isdir(dest) and not os.path.islink(dest): if needs_update(source, dest):
shutil.rmtree(dest) remove(dest)
else: else:
os.unlink(dest) continue
if os.name == 'nt':
if os.path.isdir(source): copy_or_link(source, dest)
shutil.copytree(source, dest, ignore=get_ignored_files,
copy_function=verbose_copy)
else: def copy_or_link(source, dest):
print('{} -> {}'.format(source, dest)) """Copy or symlink source to dest."""
shutil.copy(source, dest) if os.name == 'nt':
if os.path.isdir(source):
shutil.copytree(source, dest, ignore=get_ignored_files,
copy_function=verbose_copy)
else: else:
print('{} -> {}'.format(source, dest)) print('{} -> {}'.format(source, dest))
os.symlink(source, dest) shutil.copy(source, dest)
else:
print('{} -> {}'.format(source, dest))
os.symlink(source, dest)
def remove(filename):
"""Remove a given filename, regardless of whether it's a file or dir."""
if os.path.isdir(filename):
shutil.rmtree(filename)
else:
os.unlink(filename)
def get_python_lib(executable, venv=False): def get_python_lib(executable, venv=False):

View File

@ -184,7 +184,7 @@ def _get_command_doc_args(cmd, parser):
yield "* +'{}'+: {}".format(name, parser.arg_descs[arg]) yield "* +'{}'+: {}".format(name, parser.arg_descs[arg])
except KeyError as e: except KeyError as e:
raise KeyError("No description for arg {} of command " raise KeyError("No description for arg {} of command "
"'{}'!".format(e, cmd.name)) "'{}'!".format(e, cmd.name)) from e
if cmd.opt_args: if cmd.opt_args:
yield "" yield ""
@ -193,9 +193,9 @@ def _get_command_doc_args(cmd, parser):
try: try:
yield '* +*{}*+, +*{}*+: {}'.format(short_flag, long_flag, yield '* +*{}*+, +*{}*+: {}'.format(short_flag, long_flag,
parser.arg_descs[arg]) parser.arg_descs[arg])
except KeyError: except KeyError as e:
raise KeyError("No description for arg {} of command " raise KeyError("No description for arg {} of command "
"'{}'!".format(e, cmd.name)) "'{}'!".format(e, cmd.name)) from e
def _get_command_doc_count(cmd, parser): def _get_command_doc_count(cmd, parser):
@ -213,9 +213,9 @@ def _get_command_doc_count(cmd, parser):
yield "==== count" yield "==== count"
try: try:
yield parser.arg_descs[cmd.count_arg] yield parser.arg_descs[cmd.count_arg]
except KeyError: except KeyError as e:
raise KeyError("No description for count arg {!r} of command " raise KeyError("No description for count arg {!r} of command "
"{!r}!".format(cmd.count_arg, cmd.name)) "{!r}!".format(cmd.count_arg, cmd.name)) from e
def _get_command_doc_notes(cmd): def _get_command_doc_notes(cmd):

View File

@ -157,14 +157,6 @@ class TestConfigParser:
self.cfg.get('general', 'bar') # pylint: disable=bad-config-call self.cfg.get('general', 'bar') # pylint: disable=bad-config-call
def keyconfig_deprecated_test_cases():
"""Generator yielding test cases (command, rgx) for TestKeyConfigParser."""
for sect in configdata.KEY_DATA.values():
for command in sect:
for rgx, _repl in configdata.CHANGED_KEY_COMMANDS:
yield (command, rgx)
class TestKeyConfigParser: class TestKeyConfigParser:
"""Test config.parsers.keyconf.KeyConfigParser.""" """Test config.parsers.keyconf.KeyConfigParser."""
@ -185,10 +177,13 @@ class TestKeyConfigParser:
with pytest.raises(keyconf.KeyConfigError): with pytest.raises(keyconf.KeyConfigError):
kcp._read_command(cmdline_test.cmd) kcp._read_command(cmdline_test.cmd)
@pytest.mark.parametrize('command, rgx', keyconfig_deprecated_test_cases()) @pytest.mark.parametrize('rgx', [rgx for rgx, _repl
def test_default_config_no_deprecated(self, command, rgx): in configdata.CHANGED_KEY_COMMANDS])
def test_default_config_no_deprecated(self, rgx):
"""Make sure the default config contains no deprecated commands.""" """Make sure the default config contains no deprecated commands."""
assert rgx.match(command) is None for sect in configdata.KEY_DATA.values():
for command in sect:
assert rgx.match(command) is None
@pytest.mark.parametrize( @pytest.mark.parametrize(
'old, new_expected', 'old, new_expected',

View File

@ -72,7 +72,7 @@ class LineParserWrapper:
return True return True
class TestableAppendLineParser(LineParserWrapper, class AppendLineParserTestable(LineParserWrapper,
lineparsermod.AppendLineParser): lineparsermod.AppendLineParser):
"""Wrapper over AppendLineParser to make it testable.""" """Wrapper over AppendLineParser to make it testable."""
@ -80,14 +80,14 @@ class TestableAppendLineParser(LineParserWrapper,
pass pass
class TestableLineParser(LineParserWrapper, lineparsermod.LineParser): class LineParserTestable(LineParserWrapper, lineparsermod.LineParser):
"""Wrapper over LineParser to make it testable.""" """Wrapper over LineParser to make it testable."""
pass pass
class TestableLimitLineParser(LineParserWrapper, class LimitLineParserTestable(LineParserWrapper,
lineparsermod.LimitLineParser): lineparsermod.LimitLineParser):
"""Wrapper over LimitLineParser to make it testable.""" """Wrapper over LimitLineParser to make it testable."""
@ -137,7 +137,7 @@ class TestAppendLineParser:
@pytest.fixture @pytest.fixture
def lineparser(self): def lineparser(self):
"""Fixture to get an AppendLineParser for tests.""" """Fixture to get an AppendLineParser for tests."""
lp = TestableAppendLineParser('this really', 'does not matter') lp = AppendLineParserTestable('this really', 'does not matter')
lp.new_data = self.BASE_DATA lp.new_data = self.BASE_DATA
lp.save() lp.save()
return lp return lp
@ -178,7 +178,7 @@ class TestAppendLineParser:
def test_get_recent_none(self): def test_get_recent_none(self):
"""Test get_recent with no data.""" """Test get_recent with no data."""
linep = TestableAppendLineParser('this really', 'does not matter') linep = AppendLineParserTestable('this really', 'does not matter')
assert linep.get_recent() == [] assert linep.get_recent() == []
def test_get_recent_little(self, lineparser): def test_get_recent_little(self, lineparser):

View File

@ -19,17 +19,18 @@ usedevelop = true
setenv = QT_QPA_PLATFORM_PLUGIN_PATH={envdir}/Lib/site-packages/PyQt5/plugins/platforms setenv = QT_QPA_PLATFORM_PLUGIN_PATH={envdir}/Lib/site-packages/PyQt5/plugins/platforms
passenv = DISPLAY XAUTHORITY HOME passenv = DISPLAY XAUTHORITY HOME
deps = deps =
-r{toxinidir}/requirements.txt
py==1.4.27 py==1.4.27
pytest==2.7.1 pytest==2.7.1
pytest-capturelog==0.7 pytest-capturelog==0.7
pytest-qt==1.3.0 pytest-qt==1.3.0
pytest-mock==0.5 pytest-mock==0.5
pytest-html==1.2 pytest-html==1.3.1
# We don't use {[testenv:mkvenv]commands} here because that seems to be broken # We don't use {[testenv:mkvenv]commands} here because that seems to be broken
# on Ubuntu Trusty. # on Ubuntu Trusty.
commands = commands =
{envpython} scripts/link_pyqt.py --tox {envdir} {envpython} scripts/link_pyqt.py --tox {envdir}
{envpython} -m py.test --strict {posargs} {envpython} -m py.test --strict -rfEsw {posargs}
[testenv:coverage] [testenv:coverage]
passenv = DISPLAY XAUTHORITY HOME passenv = DISPLAY XAUTHORITY HOME
@ -40,7 +41,7 @@ deps =
cov-core==1.15.0 cov-core==1.15.0
commands = commands =
{[testenv:mkvenv]commands} {[testenv:mkvenv]commands}
{envpython} -m py.test --strict --cov qutebrowser --cov-report term --cov-report html {posargs} {envpython} -m py.test --strict -rfEswx -v --cov qutebrowser --cov-report term --cov-report html {posargs}
[testenv:misc] [testenv:misc]
commands = commands =
@ -96,7 +97,7 @@ commands =
[testenv:check-manifest] [testenv:check-manifest]
skip_install = true skip_install = true
deps = deps =
check-manifest==0.24 check-manifest==0.25
commands = commands =
{[testenv:mkvenv]commands} {[testenv:mkvenv]commands}
{envdir}/bin/check-manifest --ignore 'qutebrowser/git-commit-id,qutebrowser/html/doc,qutebrowser/html/doc/*,*/__pycache__' {envdir}/bin/check-manifest --ignore 'qutebrowser/git-commit-id,qutebrowser/html/doc,qutebrowser/html/doc/*,*/__pycache__'