diff --git a/.coveragerc b/.coveragerc index ff714c43d..16bebb0cc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,6 +3,7 @@ branch = true omit = qutebrowser/__main__.py */__init__.py + qutebrowser/resources.py [report] exclude_lines = diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index c18927953..96e6482ea 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -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 argument `--no-err-windows` to suppress all error windows. - 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 ~~~~~~~ @@ -73,6 +78,7 @@ Fixed - 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) - 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] ----------------------------------------------------------------------- diff --git a/CONTRIBUTING.asciidoc b/CONTRIBUTING.asciidoc index cf185c3fe..7b0fe6087 100644 --- a/CONTRIBUTING.asciidoc +++ b/CONTRIBUTING.asciidoc @@ -86,7 +86,7 @@ Useful utilities 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. Currently, the following tools will be invoked when you run `tox`: diff --git a/README.asciidoc b/README.asciidoc index 8742f11cb..63d803d8f 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -139,7 +139,9 @@ Contributors, sorted by the number of commits in descending order: * Joel Torstensson * Claude * Artur Shaik +* Antoni Boucher * ZDarian +* Martin Tournoij * Peter Vilim * John ShaggyTwoDope Jenkins * Jimmy @@ -150,6 +152,7 @@ Contributors, sorted by the number of commits in descending order: * Error 800 * Brian Jackson * sbinix +* Tobias Patzl * Johannes Altmanninger * Samir Benmendil * Regina Hug diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index c33bccaaf..6cc2ee9db 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -692,6 +692,7 @@ How many steps to zoom out. |<>|Drop selection and keep selection mode enabled. |<>|Enter a key mode. |<>|Follow the currently selected hint. +|<>|Follow the selected text. |<>|Leave the mode we're currently in. |<>|Show an error message in the statusbar. |<>|Show an info message in the statusbar. @@ -774,6 +775,15 @@ Enter a key mode. === follow-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 the mode we're currently in. @@ -1009,7 +1019,7 @@ multiplier [[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. @@ -1017,6 +1027,12 @@ Scroll the frame page-wise. * +'x'+: How many pages to scroll to the right. * +'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 multiplier diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 46539c3b7..073ad2d63 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -45,6 +45,7 @@ |<>|Whether to hide the statusbar unless a message is shown. |<>|The format to use for the window title. The following placeholders are defined: |<>|Whether to hide the mouse cursor. +|<>|Use standard JavaScript modal dialog for alert() and confirm() |============== .Quick reference for section ``network'' @@ -111,6 +112,7 @@ |<>|Spacing between tab edge and indicator. |<>|Whether to open windows instead of tabs. |<>|The format to use for the tab title. The following placeholders are defined: +|<>|Switch between tabs using the mouse wheel. |============== .Quick reference for section ``storage'' @@ -593,6 +595,17 @@ Valid values: 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 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}]+ +[[tabs-mousewheel-tab-switching]] +=== mousewheel-tab-switching +Switch between tabs using the mouse wheel. + +Valid values: + + * +true+ + * +false+ + +Default: +pass:[true]+ + == storage Settings related to cache and storage. diff --git a/doc/quickstart.asciidoc b/doc/quickstart.asciidoc index ac3be3a9c..e0b22d378 100644 --- a/doc/quickstart.asciidoc +++ b/doc/quickstart.asciidoc @@ -11,6 +11,7 @@ What to do now * View the http://qutebrowser.org/img/cheatsheet-big.png[key binding cheatsheet] 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"] +* Run `:adblock-update` to download adblock lists and activate adblocking. * If you just cloned the repository, you'll need to run `scripts/asciidoc2html.py` to generate the documentation. * Go to the link:qute://settings[settings page] to set up qutebrowser the way you want it. diff --git a/qutebrowser/app.py b/qutebrowser/app.py index b755b0239..40764b098 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -71,7 +71,7 @@ def run(args): sys.exit(usertypes.Exit.ok) if args.temp_basedir: - args.basedir = tempfile.mkdtemp() + args.basedir = tempfile.mkdtemp(prefix='qutebrowser-basedir-') quitter = Quitter(args) objreg.register('quitter', quitter) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 9f5d5e210..239307560 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -25,7 +25,9 @@ import shlex import subprocess import posixpath import functools +import xml.etree.ElementTree +from PyQt5.QtWebKit import QWebSettings from PyQt5.QtWidgets import QApplication, QTabBar from PyQt5.QtCore import Qt, QUrl, QEvent from PyQt5.QtGui import QClipboard, QKeyEvent @@ -647,14 +649,37 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', hide=True, 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. Args: x: How many pages to scroll to the right. 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 """ + 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_y = count * y if mult_y.is_integer(): @@ -667,7 +692,6 @@ class CommandDispatcher: mult_y = 0 if mult_x == 0 and mult_y == 0: return - frame = self._current_widget().page().currentFrame() size = frame.geometry() dx = mult_x * size.width() dy = mult_y * size.height() @@ -1010,6 +1034,39 @@ class CommandDispatcher: """ 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( + '' + widget.selectedHtml() + '').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', scope='window') def toggle_inspector(self): diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index ab055cbf4..790459f6a 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -686,8 +686,11 @@ class DownloadManager(QAbstractListModel): if fileobj is not None or filename is not None: return self.fetch_request(request, page, fileobj, filename, auto_remove, suggested_fn) - encoding = sys.getfilesystemencoding() - suggested_fn = utils.force_encoding(suggested_fn, encoding) + if suggested_fn is None: + suggested_fn = 'qutebrowser-download' + else: + encoding = sys.getfilesystemencoding() + suggested_fn = utils.force_encoding(suggested_fn, encoding) q = self._prepare_question() q.default = _path_suggestion(suggested_fn) message_bridge = objreg.get('message-bridge', scope='window', diff --git a/qutebrowser/browser/webpage.py b/qutebrowser/browser/webpage.py index 005a6e300..8e430efcb 100644 --- a/qutebrowser/browser/webpage.py +++ b/qutebrowser/browser/webpage.py @@ -478,17 +478,23 @@ class BrowserPage(QWebPage): return super().extension(ext, opt, out) return handler(opt, out) - def javaScriptAlert(self, _frame, msg): + def javaScriptAlert(self, frame, msg): """Override javaScriptAlert to use the statusbar.""" log.js.debug("alert: {}".format(msg)) + if config.get('ui', 'modal-js-dialog'): + return super().javaScriptAlert(frame, msg) + if (self._is_shutting_down or config.get('content', 'ignore-javascript-alert')): return 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.""" log.js.debug("confirm: {}".format(msg)) + if config.get('ui', 'modal-js-dialog'): + return super().javaScriptConfirm(frame, msg) + if self._is_shutting_down: return False ans = self._ask("[js confirm] {}".format(msg), diff --git a/qutebrowser/browser/webview.py b/qutebrowser/browser/webview.py index a215a1245..06176064d 100644 --- a/qutebrowser/browser/webview.py +++ b/qutebrowser/browser/webview.py @@ -487,12 +487,25 @@ class WebView(QWebView): old_scroll_pos = self.scroll_pos flags = QWebPage.FindFlags(flags) found = self.findText(text, flags) - if not found and not flags & QWebPage.HighlightAllOccurrences and text: - message.error(self.win_id, "Text '{}' not found on " - "page!".format(text), immediately=True) - else: - backward = int(flags) & QWebPage.FindBackward + backward = 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(): """Check if the scroll position got smaller and show info.""" if not backward and self.scroll_pos < old_scroll_pos: diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index 269c17e06..d55597d9d 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -61,7 +61,8 @@ class Command: """ AnnotationInfo = collections.namedtuple('AnnotationInfo', - ['kwargs', 'type', 'flag', 'hide']) + ['kwargs', 'type', 'flag', 'hide', + 'metavar']) def __init__(self, *, handler, name, instance=None, maxsplit=None, hide=False, completion=None, modes=None, not_modes=None, @@ -257,10 +258,10 @@ class Command: pass if isinstance(typ, tuple): - pass + kwargs['metavar'] = annotation_info.metavar or param.name elif utils.is_enum(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: kwargs['action'] = 'store_true' elif typ is not None: @@ -287,7 +288,7 @@ class Command: A list of args. """ args = [] - name = param.name.rstrip('_') + name = param.name.rstrip('_').replace('_', '-') shortname = annotation_info.flag or name[0] if len(shortname) != 1: raise ValueError("Flag '{}' of parameter {} (command {}) must be " @@ -322,11 +323,12 @@ class Command: flag: The short name/flag 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: log.commands.vdebug("Parsing annotation {}".format( param.annotation)) - for field in ('type', 'flag', 'name', 'hide'): + for field in ('type', 'flag', 'name', 'hide', 'metavar'): if field in param.annotation: info[field] = param.annotation[field] if 'nargs' in param.annotation: diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index f6f038ee1..dbad5c66a 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -305,6 +305,10 @@ def data(readonly=False): SettingValue(typ.Bool(), 'false'), "Whether to hide the mouse cursor."), + ('modal-js-dialog', + SettingValue(typ.Bool(), 'false'), + "Use standard JavaScript modal dialog for alert() and confirm()"), + readonly=readonly )), @@ -522,6 +526,10 @@ def data(readonly=False): "* `{index}`: The index of this tab.\n" "* `{id}`: The internal tab ID of this tab."), + ('mousewheel-tab-switching', + SettingValue(typ.Bool(), 'true'), + "Switch between tabs using the mouse wheel."), + readonly=readonly )), @@ -1236,6 +1244,8 @@ KEY_DATA = collections.OrderedDict([ ('stop', ['']), ('print', ['']), ('open qute:settings', ['Ss']), + ('follow-selected', ['']), + ('follow-selected -t', ['']), ])), ('insert', collections.OrderedDict([ diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index c465c8ca4..ba5a5c725 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -577,3 +577,14 @@ class TabbedBrowser(tabwidget.TabWidget): """ super().resizeEvent(e) 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() diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index df44ebbba..bbbfdf045 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -480,6 +480,19 @@ class TabBar(QTabBar): new_idx = super().insertTab(idx, icon, '') 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): diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py index 81323bf5e..32e4100ca 100644 --- a/qutebrowser/misc/editor.py +++ b/qutebrowser/misc/editor.py @@ -122,7 +122,8 @@ class ExternalEditor(QObject): raise ValueError("Already editing a file!") self._text = text try: - self._oshandle, self._filename = tempfile.mkstemp(text=True) + self._oshandle, self._filename = tempfile.mkstemp( + text=True, prefix='qutebrowser-editor-') if text: encoding = config.get('general', 'editor-encoding') with open(self._filename, 'w', encoding=encoding) as f: diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index 92bdef222..d156c6be1 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -269,8 +269,6 @@ def qt_message_handler(msg_type, context, msg): # https://bugreports.qt-project.org/browse/QTBUG-30298 "QNetworkReplyImplPrivate::error: Internal problem, this method must " "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 "load glyph failed ", # Harmless, see https://bugreports.qt-project.org/browse/QTBUG-42479 @@ -282,7 +280,11 @@ def qt_message_handler(msg_type, context, msg): # Hopefully harmless '"Method "GetAll" with signature "s" on interface ' '"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): level = logging.DEBUG diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py index 939e1ed81..6573306ab 100644 --- a/qutebrowser/utils/qtutils.py +++ b/qutebrowser/utils/qtutils.py @@ -131,7 +131,7 @@ def ensure_valid(obj): def ensure_not_null(obj): """Ensure a Qt object with an .isNull() method is not null.""" if obj.isNull(): - raise QtValueError(obj) + raise QtValueError(obj, null=True) def check_qdatastream(stream): @@ -180,7 +180,7 @@ def deserialize_stream(stream, obj): def savefile_open(filename, binary=False, encoding='utf-8'): """Context manager to easily use a QSaveFile.""" f = QSaveFile(filename) - new_f = None + cancelled = False try: ok = f.open(QIODevice.WriteOnly) if not ok: @@ -192,13 +192,14 @@ def savefile_open(filename, binary=False, encoding='utf-8'): yield new_f except: f.cancelWriting() + cancelled = True raise + else: + new_f.flush() finally: - if new_f is not None: - new_f.flush() commit_ok = f.commit() - if not commit_ok: - raise OSError(f.errorString()) + if not commit_ok and not cancelled: + raise OSError("Commit failed!") @contextlib.contextmanager @@ -221,27 +222,58 @@ class PyQIODevice(io.BufferedIOBase): """Wrapper for a QIODevice which provides a python interface. Attributes: - _dev: The underlying QIODevice. + dev: The underlying QIODevice. """ # pylint: disable=missing-docstring def __init__(self, dev): - self._dev = dev + self.dev = dev def __len__(self): - return self._dev.size() + return self.dev.size() def _check_open(self): - """Check if the device is open, raise OSError if not.""" - if not self._dev.isOpen(): - raise OSError("IO operation on closed device!") + """Check if the device is open, raise ValueError if not.""" + if not self.dev.isOpen(): + raise ValueError("IO operation on closed device!") def _check_random(self): """Check if the device supports random access, raise OSError if not.""" if not self.seekable(): 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): raise io.UnsupportedOperation @@ -249,85 +281,102 @@ class PyQIODevice(io.BufferedIOBase): self._check_open() self._check_random() if whence == io.SEEK_SET: - ok = self._dev.seek(offset) + ok = self.dev.seek(offset) elif whence == io.SEEK_CUR: - ok = self._dev.seek(self.tell() + offset) + ok = self.dev.seek(self.tell() + offset) elif whence == io.SEEK_END: - ok = self._dev.seek(len(self) + offset) + ok = self.dev.seek(len(self) + offset) else: raise io.UnsupportedOperation("whence = {} is not " "supported!".format(whence)) if not ok: - raise OSError(self._dev.errorString()) + raise OSError("seek failed!") def truncate(self, size=None): # pylint: disable=unused-argument raise io.UnsupportedOperation - def close(self): - self._dev.close() - @property def closed(self): - return not self._dev.isOpen() + return not self.dev.isOpen() def flush(self): self._check_open() - self._dev.waitForBytesWritten(-1) + self.dev.waitForBytesWritten(-1) def isatty(self): self._check_open() return False def readable(self): - return self._dev.isReadable() + return self.dev.isReadable() def readline(self, size=-1): self._check_open() - if size == -1: - size = 0 - return self._dev.readLine(size) + self._check_readable() + + 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): - return not self._dev.isSequential() + return not self.dev.isSequential() def tell(self): self._check_open() self._check_random() - return self._dev.pos() + return self.dev.pos() def writable(self): - return self._dev.isWritable() - - def readinto(self, b): - self._check_open() - return self._dev.read(b, len(b)) + return self.dev.isWritable() def write(self, b): self._check_open() - num = self._dev.write(b) + self._check_writable() + num = self.dev.write(b) if num == -1 or num < len(b): - raise OSError(self._dev.errorString()) + raise OSError(self.dev.errorString()) return num - def read(self, size): + def read(self, size=-1): self._check_open() - buf = bytes() - num = self._dev.read(buf, size) - if num == -1: - raise OSError(self._dev.errorString()) - return num + self._check_readable() + if size < 0: + buf = self.dev.readAll() + else: + buf = self.dev.read(size) + if buf is None: + raise OSError(self.dev.errorString()) + return buf class QtValueError(ValueError): """Exception which gets raised by ensure_valid.""" - def __init__(self, obj): + def __init__(self, obj, null=False): try: self.reason = obj.errorString() except AttributeError: 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: err += ": {}".format(self.reason) super().__init__(err) diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index 3ed82b0db..143e7cfc5 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -94,6 +94,8 @@ def _is_url_naive(urlstr): True if the URL really is a URL, False otherwise. """ url = qurl_from_user_input(urlstr) + assert url.isValid() + if not utils.raises(ValueError, ipaddress.ip_address, urlstr): # Valid IPv4/IPv6 address return True @@ -104,9 +106,7 @@ def _is_url_naive(urlstr): if not QHostAddress(urlstr).isNull(): return False - if not url.isValid(): - return False - elif '.' in url.host(): + if '.' in url.host(): return True else: return False @@ -122,9 +122,7 @@ def _is_url_dns(urlstr): True if the URL really is a URL, False otherwise. """ url = qurl_from_user_input(urlstr) - if not url.isValid(): - log.url.debug("Invalid URL -> False") - return False + assert url.isValid() if (utils.raises(ValueError, ipaddress.ip_address, urlstr) and not QHostAddress(urlstr).isNull()): @@ -246,16 +244,13 @@ def is_url(urlstr): return False if not qurl_userinput.isValid(): + # This will also catch URLs containing spaces. return False if _has_explicit_scheme(qurl): # URLs with explicit schemes are always URLs log.url.debug("Contains explicit scheme") 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'): log.url.debug("Is localhost.") url = True @@ -274,7 +269,7 @@ def is_url(urlstr): else: raise ValueError("Invalid autosearch value") log.url.debug("url = {}".format(url)) - return url and qurl_userinput.isValid() + return url def qurl_from_user_input(urlstr): diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 6823685d7..827762af4 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -130,7 +130,7 @@ def _module_versions(): try: import sipconfig # pylint: disable=import-error,unused-variable except ImportError: - pass + lines.append('SIP: ?') else: try: lines.append('SIP: {}'.format( diff --git a/scripts/link_pyqt.py b/scripts/link_pyqt.py index ee1ebf2de..dbfeaed99 100644 --- a/scripts/link_pyqt.py +++ b/scripts/link_pyqt.py @@ -28,6 +28,7 @@ import sys import glob import subprocess import platform +import filecmp class Error(Exception): @@ -58,6 +59,22 @@ def get_ignored_files(directory, files): 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): """Symlink the systemwide PyQt/sip into the venv. @@ -70,28 +87,47 @@ def link_pyqt(sys_path, venv_path): if not globbed_sip: raise Error("Did not find sip in {}!".format(sys_path)) - files = ['PyQt5'] - files += [os.path.basename(e) for e in globbed_sip] - for fn in files: + files = [('PyQt5', True), ('sipconfig.py', False)] + files += [(os.path.basename(e), True) for e in globbed_sip] + for fn, required in files: source = os.path.join(sys_path, fn) dest = os.path.join(venv_path, fn) + if not os.path.exists(source): - raise FileNotFoundError(source) + if required: + raise FileNotFoundError(source) + else: + continue + if os.path.exists(dest): - if os.path.isdir(dest) and not os.path.islink(dest): - shutil.rmtree(dest) + if needs_update(source, dest): + remove(dest) else: - os.unlink(dest) - if os.name == 'nt': - if os.path.isdir(source): - shutil.copytree(source, dest, ignore=get_ignored_files, - copy_function=verbose_copy) - else: - print('{} -> {}'.format(source, dest)) - shutil.copy(source, dest) + continue + + copy_or_link(source, dest) + + +def copy_or_link(source, dest): + """Copy or symlink source to dest.""" + if os.name == 'nt': + if os.path.isdir(source): + shutil.copytree(source, dest, ignore=get_ignored_files, + copy_function=verbose_copy) else: 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): diff --git a/scripts/src2asciidoc.py b/scripts/src2asciidoc.py index d764b1c7e..31d82f6e8 100755 --- a/scripts/src2asciidoc.py +++ b/scripts/src2asciidoc.py @@ -184,7 +184,7 @@ def _get_command_doc_args(cmd, parser): yield "* +'{}'+: {}".format(name, parser.arg_descs[arg]) except KeyError as e: raise KeyError("No description for arg {} of command " - "'{}'!".format(e, cmd.name)) + "'{}'!".format(e, cmd.name)) from e if cmd.opt_args: yield "" @@ -193,9 +193,9 @@ def _get_command_doc_args(cmd, parser): try: yield '* +*{}*+, +*{}*+: {}'.format(short_flag, long_flag, parser.arg_descs[arg]) - except KeyError: + except KeyError as e: 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): @@ -213,9 +213,9 @@ def _get_command_doc_count(cmd, parser): yield "==== count" try: yield parser.arg_descs[cmd.count_arg] - except KeyError: + except KeyError as e: 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): diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 123b2a412..d5fab2ed1 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -157,14 +157,6 @@ class TestConfigParser: 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: """Test config.parsers.keyconf.KeyConfigParser.""" @@ -185,10 +177,13 @@ class TestKeyConfigParser: with pytest.raises(keyconf.KeyConfigError): kcp._read_command(cmdline_test.cmd) - @pytest.mark.parametrize('command, rgx', keyconfig_deprecated_test_cases()) - def test_default_config_no_deprecated(self, command, rgx): + @pytest.mark.parametrize('rgx', [rgx for rgx, _repl + in configdata.CHANGED_KEY_COMMANDS]) + def test_default_config_no_deprecated(self, rgx): """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( 'old, new_expected', diff --git a/tests/misc/test_lineparser.py b/tests/misc/test_lineparser.py index 613ef3d65..17ae8415b 100644 --- a/tests/misc/test_lineparser.py +++ b/tests/misc/test_lineparser.py @@ -72,7 +72,7 @@ class LineParserWrapper: return True -class TestableAppendLineParser(LineParserWrapper, +class AppendLineParserTestable(LineParserWrapper, lineparsermod.AppendLineParser): """Wrapper over AppendLineParser to make it testable.""" @@ -80,14 +80,14 @@ class TestableAppendLineParser(LineParserWrapper, pass -class TestableLineParser(LineParserWrapper, lineparsermod.LineParser): +class LineParserTestable(LineParserWrapper, lineparsermod.LineParser): """Wrapper over LineParser to make it testable.""" pass -class TestableLimitLineParser(LineParserWrapper, +class LimitLineParserTestable(LineParserWrapper, lineparsermod.LimitLineParser): """Wrapper over LimitLineParser to make it testable.""" @@ -137,7 +137,7 @@ class TestAppendLineParser: @pytest.fixture def lineparser(self): """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.save() return lp @@ -178,7 +178,7 @@ class TestAppendLineParser: def test_get_recent_none(self): """Test get_recent with no data.""" - linep = TestableAppendLineParser('this really', 'does not matter') + linep = AppendLineParserTestable('this really', 'does not matter') assert linep.get_recent() == [] def test_get_recent_little(self, lineparser): diff --git a/tox.ini b/tox.ini index cf1d596d6..f39af4ee3 100644 --- a/tox.ini +++ b/tox.ini @@ -19,17 +19,18 @@ usedevelop = true setenv = QT_QPA_PLATFORM_PLUGIN_PATH={envdir}/Lib/site-packages/PyQt5/plugins/platforms passenv = DISPLAY XAUTHORITY HOME deps = + -r{toxinidir}/requirements.txt py==1.4.27 pytest==2.7.1 pytest-capturelog==0.7 pytest-qt==1.3.0 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 # on Ubuntu Trusty. commands = {envpython} scripts/link_pyqt.py --tox {envdir} - {envpython} -m py.test --strict {posargs} + {envpython} -m py.test --strict -rfEsw {posargs} [testenv:coverage] passenv = DISPLAY XAUTHORITY HOME @@ -40,7 +41,7 @@ deps = cov-core==1.15.0 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] commands = @@ -96,7 +97,7 @@ commands = [testenv:check-manifest] skip_install = true deps = - check-manifest==0.24 + check-manifest==0.25 commands = {[testenv:mkvenv]commands} {envdir}/bin/check-manifest --ignore 'qutebrowser/git-commit-id,qutebrowser/html/doc,qutebrowser/html/doc/*,*/__pycache__'