diff --git a/.appveyor.yml b/.appveyor.yml new file mode 100644 index 000000000..3ab4cda97 --- /dev/null +++ b/.appveyor.yml @@ -0,0 +1,18 @@ +shallow_clone: true +version: '{branch}-{build}' +cache: C:\Users\appveyor\pip\wheels +build: off +environment: + PYTHON: 'C:\Python34' + PYTHONUNBUFFERED: 1 + +install: + - C:\Python27\python -u scripts\ci_install.py + +test_script: + - C:\Python34\Scripts\tox -e smoke + - C:\Python34\Scripts\tox -e smoke-frozen + - C:\Python34\Scripts\tox -e unittests + - C:\Python34\Scripts\tox -e unittests-frozen + - C:\Python34\Scripts\tox -e pyflakes + - C:\Python34\Scripts\tox -e pylint diff --git a/.pylintrc b/.pylintrc index 2cc56909d..664eda267 100644 --- a/.pylintrc +++ b/.pylintrc @@ -4,7 +4,6 @@ ignore=resources.py extension-pkg-whitelist=PyQt5,sip load-plugins=pylint_checkers.config, - pylint_checkers.crlf, pylint_checkers.modeline, pylint_checkers.openencoding, pylint_checkers.settrace @@ -28,7 +27,8 @@ disable=no-self-use, broad-except, bare-except, eval-used, - exec-used + exec-used, + file-ignored [BASIC] module-rgx=(__)?[a-z][a-z0-9_]*(__)?$ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..686dbbdaa --- /dev/null +++ b/.travis.yml @@ -0,0 +1,28 @@ +dist: trusty + +os: + - linux + - osx + +# Not really, but this is here so we can do stuff by hand. +language: c + +install: + - python scripts/ci_install.py + +script: + - xvfb-run -s "-screen 0 640x480x16" tox -e unittests,smoke + - tox -e misc + - tox -e pep257 + - tox -e pyflakes + - tox -e pep8 + - tox -e mccabe + - tox -e pylint + - tox -e pyroma + - tox -e check-manifest + +# Travis bug - OS X builds get routed to Ubuntu Trusty if "dist: trusty" is +# given. +matrix: + allow_failures: + - os: osx diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 92129b3e1..feed0fed2 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -21,34 +21,44 @@ Added ~~~~~ - New commands `:message-info`, `:message-error` and `:message-warning` to show messages in the statusbar, e.g. from an userscript. -- There are now some example userscripts in `misc/userscripts`. - New command `:scroll-px` which replaces `:scroll` for pixel-exact scrolling. +- New command `:jseval` to run a javascript snippet on the current page. +- 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 (hidden) command `:clear-keychain` to clear a partially entered keychain (bound to `` by default, in addition to clearing search). - New setting `ui -> smooth-scrolling`. - New setting `content -> webgl` to enable/disable https://www.khronos.org/webgl/[WebGL]. - New setting `content -> css-regions` to enable/disable support for http://dev.w3.org/csswg/css-regions/[CSS Regions]. - New setting `content -> hyperlink-auditing` to enable/disable support for https://html.spec.whatwg.org/multipage/semantics.html#hyperlink-auditing[hyperlink auditing]. -- Support for Qt 5.5 and tox 2.0 +- New setting `tabs -> mousewheel-tab-switching` to control mousewheel behavior on the tab bar. - New arguments `--datadir` and `--cachedir` to set the data/cache location. - 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 flag `-v`/`--verbose` for `:spawn` to print informations when the process started/exited successfully. +- Many new color settings (foreground setting for every background setting). - New setting `ui -> modal-js-dialog` to use the standard modal dialogs for javascript questions instead of using the statusbar. - New setting `colors -> webpage.bg` to set the background color to use for websites which don't set one. +- New setting `completion -> auto-open` to only open the completion when tab is pressed (if set to false). +- New visual/caret mode (bound to `v`) to select text by keyboard. +- There are now some example userscripts in `misc/userscripts`. +- Support for Qt 5.5 and tox 2.0 Changed ~~~~~~~ -- `QUTE_HTML` and `QUTE_TEXT` for userscripts now don't store the contents directly, and instead contain a filename. -- `:spawn` now shows the command being executed in the statusbar, use `-q`/`--quiet` for the old behavior. +- *Breaking change for userscripts:* `QUTE_HTML` and `QUTE_TEXT` for userscripts now don't store the contents directly, and instead contain a filename. - The `content -> geolocation` and `notifications` settings now support a `true` value to always allow those. However, this is *not recommended*. - New bindings `` (rapid), `` (foreground) and `` (background) to switch hint modes while hinting. - `` and numpad-enter are now bound by default for bindings where `` was bound. - `:hint tab` and `F` now respect the `background-tabs` setting. To enforce a foreground tab (what `F` did before), use `:hint tab-fg` or `;f`. - `:scroll` now takes a direction argument (`up`/`down`/`left`/`right`/`top`/`bottom`/`page-up`/`page-down`) instead of two pixel arguments (`dx`/`dy`). The old form still works but is deprecated. +- The `ui -> user-stylesheet` setting now also takes file paths relative to the config directory. +- The `content -> cookies-accept` setting now has new `no-3rdparty` (default) and `no-unknown-3rdparty` values to block third-party cookies. The `default` value got renamed to `all`. +- Improved startup time by reading the webpage history while qutebrowser is open. +- The way `:spawn` splits its commandline has been changed slightly to allow commands with flags. +- The default for the `new-instance-open-target` setting has been changed to `tab`. +- Sessions now store zoom/scroll-position separately for each entry. Deprecated ~~~~~~~~~~ @@ -59,28 +69,28 @@ Removed ~~~~~~~ - The `--no-crash-dialog` argument which was intended for debugging only was removed as it's replaced by `--no-err-windows` which suppresses all error windows. +- Support for Qt installations without SSL support was dropped. Fixed ~~~~~ - Scrolling should now work more reliably on some pages where arrow keys worked but `hjkl` didn't. - Small improvements when checking if an input is an URL or not. - -v0.2.2 (unreleased) -------------------- - -Fixed -~~~~~ - +- Fixed wrong cursor position when completing the first item in the completion. +- Fixed exception when using search engines with {foo} in their name. +- Fixed a bug where the same title was shown for all tabs on some systems. +- Don't install the scripts package when installing qutebrowser. - Fixed searching for terms starting with a hyphen (e.g. `/-foo`) - Proxy authentication credentials are now remembered between different tabs. - Fixed updating of the tab title on pages without title. - Fixed AssertionError when closing many windows quickly. - 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 crash when downloading an URL without filename (e.g. magnet links) via "Save as...". - Fixed exception when starting qutebrowser with `:set` as argument. +- Fixed horrible completion performance when the `shrink` option was set. +- Sessions now store zoom/scroll-position correctly. https://github.com/The-Compiler/qutebrowser/releases/tag/v0.2.1[v0.2.1] ----------------------------------------------------------------------- diff --git a/CONTRIBUTING.asciidoc b/CONTRIBUTING.asciidoc index 1975a9d7c..558887a5b 100644 --- a/CONTRIBUTING.asciidoc +++ b/CONTRIBUTING.asciidoc @@ -153,7 +153,7 @@ Useful websites Some resources which might be handy: -* http://qt-project.org/doc/qt-5/classes.html[The Qt5 reference] +* http://doc.qt.io/qt-5/classes.html[The Qt5 reference] * https://docs.python.org/3/library/index.html[The Python reference] * http://httpbin.org/[httpbin, a test service for HTTP requests/responses] * http://requestb.in/[RequestBin, a service to inspect HTTP requests] @@ -211,8 +211,7 @@ Other Languages] (http://www.rfc-editor.org/errata_search.php?rfc=5646[Errata]) * http://www.w3.org/TR/CSS2/[Cascading Style Sheets Level 2 Revision 1 (CSS 2.1) Specification] -* http://qt-project.org/doc/qt-4.8/stylesheet-reference.html[Qt Style Sheets -Reference] +* http://doc.qt.io/qt-5/stylesheet-reference.html[Qt Style Sheets Reference] * http://mimesniff.spec.whatwg.org/[MIME Sniffing Standard] * http://spec.whatwg.org/[WHATWG specifications] * http://www.w3.org/html/wg/drafts/html/master/Overview.html[HTML 5.1 Nightly] @@ -238,9 +237,7 @@ There are some exceptions to that: * `QThread` is used instead of Python threads because it provides signals and slots. -* `QProcess` is used instead of Python's `subprocess` if certain actions (e.g. -cleanup) when the process finished are desired, as it provides signals for -that. +* `QProcess` is used instead of Python's `subprocess` * `QUrl` is used instead of storing URLs as string, see the <> section for details. @@ -295,8 +292,8 @@ All objects can be printed by starting with the `--debug` flag and using the The registry is mainly used for <> but also can be useful in places where using Qt's -http://qt-project.org/doc/qt-5/signalsandslots.html[signals and slots] -mechanism would be difficult. +http://doc.qt.io/qt-5/signalsandslots.html[signals and slots] mechanism would +be difficult. Logging ~~~~~~~ @@ -541,7 +538,7 @@ New Qt release * Run all tests and check nothing is broken. * Check the -https://bugreports.qt-project.org/issues/?jql=reporter%20%3D%20%22The%20Compiler%22%20ORDER%20BY%20fixVersion%20ASC[Qt bugtracker] +https://bugreports.qt.io/issues/?jql=reporter%20%3D%20%22The%20Compiler%22%20ORDER%20BY%20fixVersion%20ASC[Qt bugtracker] and make sure all bugs marked as resolved are actually fixed. * Update own PKGBUILDs based on upstream Archlinux updates and rebuild. * Update recommended Qt version in `README` diff --git a/FAQ.asciidoc b/FAQ.asciidoc index 16d02cfa0..886721a18 100644 --- a/FAQ.asciidoc +++ b/FAQ.asciidoc @@ -4,8 +4,8 @@ The Compiler [qanda] What is qutebrowser based on?:: - qutebrowser uses http://www.python.org/[Python], http://qt-project.org/[Qt] - and http://www.riverbankcomputing.com/software/pyqt/intro[PyQt]. + qutebrowser uses http://www.python.org/[Python], http://qt.io/[Qt] and + http://www.riverbankcomputing.com/software/pyqt/intro[PyQt]. + The concept of it is largely inspired by http://portix.bitbucket.org/dwb/[dwb] and http://www.vimperator.org/vimperator[Vimperator]. Many actions and @@ -15,7 +15,7 @@ Why another browser?:: It might be hard to believe, but I didn't find any browser which I was happy with, so I started to write my own. Also, I needed a project to get into writing GUI applications with Python and - link:http://qt-project.org/[Qt]/link:http://www.riverbankcomputing.com/software/pyqt/intro[PyQt]. + link:http://qt.io/[Qt]/link:http://www.riverbankcomputing.com/software/pyqt/intro[PyQt]. + Read the next few questions to find out why I was unhappy with existing software. @@ -32,12 +32,11 @@ API] seems to lack basic features like proxy support, and almost no projects seem to have started porting to WebKit2 (I only know of http://www.uzbl.org/[uzbl]). + -qutebrowser uses http://qt-project.org/[Qt] and -http://qt-project.org/wiki/QtWebKit[QtWebKit] instead, which suffers from far -less such crashes. It might switch to -http://qt-project.org/wiki/QtWebEngine[QtWebEngine] in the future, which is -based on Google's https://en.wikipedia.org/wiki/Blink_(layout_engine)[Blink] -rendering engine. +qutebrowser uses http://qt.io/[Qt] and http://wiki.qt.io/QtWebKit[QtWebKit] +instead, which suffers from far less such crashes. It might switch to +http://wiki.qt.io/QtWebEngine[QtWebEngine] in the future, which is based on +Google's https://en.wikipedia.org/wiki/Blink_(layout_engine)[Blink] rendering +engine. What's wrong with https://www.mozilla.org/en-US/firefox/new/[Firefox] and link:http://5digits.org/pentadactyl/[Pentadactyl]/link:http://www.vimperator.org/vimperator[Vimperator]?:: Firefox likes to break compatibility with addons on each upgrade, gets @@ -54,10 +53,10 @@ What's wrong with http://www.chromium.org/Home[Chromium] and https://vimium.gith Why Python?:: I enjoy writing Python since 2011, which made it one of the possible - choices. I wanted to use http://qt-project.org/[Qt] because of - http://qt-project.org/wiki/QtWebKit[QtWebKit] so I didn't have - http://qt-project.org/wiki/Category:LanguageBindings[many other choices]. I - don't like C++ and can't write it very well, so that wasn't an alternative. + choices. I wanted to use http://qt.io/[Qt] because of + http://wiki.qt.io/QtWebKit[QtWebKit] so I didn't have + http://wiki.qt.io/Category:LanguageBindings[many other choices]. I don't + like C++ and can't write it very well, so that wasn't an alternative. But isn't Python too slow for a browser?:: http://www.infoworld.com/d/application-development/van-rossum-python-not-too-slow-188715[No.] diff --git a/MANIFEST.in b/MANIFEST.in index 4092f81c5..3a3e21d60 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,12 +1,13 @@ global-exclude __pycache__ *.pyc *.pyo +recursive-include qutebrowser *.py recursive-include qutebrowser/html *.html recursive-include qutebrowser/test *.py recursive-include qutebrowser/javascript *.js graft icons -graft scripts/pylint_checkers graft doc/img graft misc +graft scripts include qutebrowser/utils/testfile include qutebrowser/git-commit-id include COPYING doc/* README.asciidoc CONTRIBUTING.asciidoc FAQ.asciidoc INSTALL.asciidoc CHANGELOG.asciidoc @@ -15,13 +16,15 @@ include requirements.txt include tox.ini include qutebrowser.py -exclude scripts/cleanup.py -exclude scripts/minimal_webkit_testbrowser.py -exclude scripts/run_profile.py -exclude scripts/src2asciidoc.sh -exclude scripts/gen_resources.sh -exclude scripts/quit_segfault_test.sh -exclude scripts/segfault_test.sh +include scripts/__init__.py +include scripts/hostblock_blame.py +include scripts/importer.py +include scripts/keytester.py +include scripts/link_pyqt.py +include scripts/minimal_webkit_testbrowser.py +include scripts/setupcommon.py +include scripts/utils.py + exclude doc/notes recursive-exclude doc *.asciidoc include doc/qutebrowser.1.asciidoc @@ -31,3 +34,6 @@ exclude .coveragerc exclude .pylintrc exclude .eslintrc exclude doc/help +exclude .appveyor.yml +exclude .travis.yml +exclude misc/appveyor_install.py diff --git a/README.asciidoc b/README.asciidoc index d42a895ef..b2194add8 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -68,7 +68,7 @@ Contributions / Bugs -------------------- You want to contribute to qutebrowser? Awesome! Please read -link:doc/CONTRIBUTING.asciidoc[the contribution guidelines] for details and +link:CONTRIBUTING.asciidoc[the contribution guidelines] for details and useful hints. If you found a bug or have a feature request, you can report it in several @@ -89,10 +89,10 @@ Requirements The following software and libraries are required to run qutebrowser: * http://www.python.org/[Python] 3.4 -* http://qt-project.org/[Qt] 5.2.0 or newer (5.4.1 recommended) +* http://qt.io/[Qt] 5.2.0 or newer (5.4.2 recommended) * QtWebKit * http://www.riverbankcomputing.com/software/pyqt/intro[PyQt] 5.2.0 or newer -(5.4.1 recommended) for Python 3 +(5.4.2 recommended) for Python 3 * https://pypi.python.org/pypi/setuptools/[pkg_resources/setuptools] * http://fdik.org/pyPEG/[pyPEG2] * http://jinja.pocoo.org/[jinja2] @@ -137,10 +137,12 @@ Contributors, sorted by the number of commits in descending order: * Bruno Oliveira * Raphael Pierzina * Joel Torstensson +* Martin Tournoij * Claude +* Lamar Pavel +* Austin Anderson * Artur Shaik * Antoni Boucher -* Martin Tournoij * ZDarian * Peter Vilim * John ShaggyTwoDope Jenkins @@ -159,6 +161,7 @@ Contributors, sorted by the number of commits in descending order: * Mathias Fussenegger * Larry Hynes * Fritz V155 Reichwald +* Franz Fellner * error800 * Thorsten Wißmann * Thiago Barroso Perrotta @@ -166,7 +169,6 @@ Contributors, sorted by the number of commits in descending order: * Helen Sherwood-Taylor * HalosGhost * Gregor Pohl -* Franz Fellner * Eivind Uggedal * Andreas Fischer // QUTE_AUTHORS_END @@ -220,7 +222,7 @@ Also, thanks to: * Everyone who had the patience to test qutebrowser before v0.1. * Everyone triaging/fixing my bugs in the -https://bugreports.qt-project.org/secure/Dashboard.jspa[Qt bugtracker] +https://bugreports.qt.io/secure/Dashboard.jspa[Qt bugtracker] * Everyone answering my questions on http://stackoverflow.com/[Stack Overflow] and in IRC. * All the projects which were a great help while developing qutebrowser. diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 6cc2ee9db..7fccc2117 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -20,6 +20,7 @@ |<>|Start hinting. |<>|Open main startpage in current tab. |<>|Toggle the web inspector. +|<>|Evaluate a JavaScript string. |<>|Execute a command after some time. |<>|Open typical prev/next links or navigate using the URL path. |<>|Open a URL in the current/[count]th tab. @@ -241,6 +242,22 @@ Open main startpage in current tab. === inspector Toggle the web inspector. +[[jseval]] +=== jseval +Syntax: +:jseval [*--quiet*] 'js-code'+ + +Evaluate a JavaScript string. + +==== positional arguments +* +'js-code'+: The string to evaluate. + +==== optional arguments +* +*-q*+, +*--quiet*+: Don't show resulting JS object. + +==== note +* This command does not split arguments after the last argument and handles quotes literally. +* With this command, +;;+ is interpreted literally instead of splitting off a second command. + [[later]] === later Syntax: +:later 'ms' 'command'+ @@ -512,18 +529,23 @@ Preset the statusbar to some text. [[spawn]] === spawn -Syntax: +:spawn [*--userscript*] [*--quiet*] 'args' ['args' ...]+ +Syntax: +:spawn [*--userscript*] [*--verbose*] [*--detach*] 'cmdline'+ Spawn a command in a shell. Note the {url} variable which gets replaced by the current URL might be useful here. ==== positional arguments -* +'args'+: The commandline to execute. +* +'cmdline'+: The commandline to execute. ==== optional arguments * +*-u*+, +*--userscript*+: Run the command as an userscript. -* +*-q*+, +*--quiet*+: Don't print the commandline being executed. +* +*-v*+, +*--verbose*+: Show notifications when the command started/exited. +* +*-d*+, +*--detach*+: Whether the command should be detached from qutebrowser. + +==== note +* This command does not split arguments after the last argument and handles quotes literally. +* With this command, +;;+ is interpreted literally instead of splitting off a second command. [[stop]] === stop @@ -642,13 +664,14 @@ Save open pages and quit. [[yank]] === yank -Syntax: +:yank [*--title*] [*--sel*]+ +Syntax: +:yank [*--title*] [*--sel*] [*--domain*]+ Yank the current URL/title to the clipboard or primary selection. ==== optional arguments * +*-t*+, +*--title*+: Yank the title instead of the URL. * +*-s*+, +*--sel*+: Use the primary selection instead of the clipboard. +* +*-d*+, +*--domain*+: Yank only the scheme, domain, and port number. [[zoom]] === zoom @@ -684,6 +707,7 @@ How many steps to zoom out. [options="header",width="75%",cols="25%,75%"] |============== |Command|Description +|<>|Clear the currently entered key chain. |<>|Execute the command currently in the commandline. |<>|Go forward in the commandline history. |<>|Go back in the commandline history. @@ -738,6 +762,10 @@ How many steps to zoom out. |<>|Toggle caret selection mode. |<>|Yank the selected text to the clipboard or primary selection. |============== +[[clear-keychain]] +=== clear-keychain +Clear the currently entered key chain. + [[command-accept]] === command-accept Execute the command currently in the commandline. diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index fc4802232..c6e9c6923 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -38,7 +38,7 @@ |<>|Whether to display javascript statusbar messages. |<>|Whether the zoom factor on a frame applies only to the text or to all content. |<>|Whether to expand each subframe to its contents. -|<>|User stylesheet to use (absolute filename or CSS string). Will expand environment variables. +|<>|User stylesheet to use (absolute filename, filename relative to the config directory or CSS string). Will expand environment variables. |<>|Set the CSS media type. |<>|Whether to enable smooth scrolling for webpages. |<>|Whether to remove finished downloads automatically. @@ -65,6 +65,7 @@ [options="header",width="75%",cols="25%,75%"] |============== |Setting|Description +|<>|Automatically open completion when typing. |<>|What to display in the download filename input. |<>|How to format timestamps (e.g. for history) |<>|Whether to show the autocompletion window. @@ -99,7 +100,7 @@ |<>|Which tab to select when the focused tab is removed. |<>|How new tabs are positioned. |<>|How new tabs opened explicitly are positioned. -|<>|Behaviour when the last tab is closed. +|<>|Behavior when the last tab is closed. |<>|Hide the tab bar if only one tab is open. |<>|Always hide the tab bar. |<>|Whether to wrap when changing tabs. @@ -149,7 +150,7 @@ |<>|Whether all javascript alerts should be ignored. |<>|Whether locally loaded documents are allowed to access remote urls. |<>|Whether locally loaded documents are allowed to access other local urls. -|<>|Whether to accept cookies. +|<>|Control which cookies to accept. |<>|Whether to store cookies. |<>|List of URLs of lists which contain hosts to block. |<>|Whether host blocking is enabled. @@ -187,13 +188,21 @@ |<>|Top border color of the completion widget category headers. |<>|Bottom border color of the selected completion item. |<>|Foreground color of the matched text in the completion. -|<>|Foreground color of the statusbar. |<>|Foreground color of the statusbar. +|<>|Foreground color of the statusbar. +|<>|Foreground color of the statusbar if there was an error. |<>|Background color of the statusbar if there was an error. +|<>|Foreground color of the statusbar if there is a warning. |<>|Background color of the statusbar if there is a warning. +|<>|Foreground color of the statusbar if there is a prompt. |<>|Background color of the statusbar if there is a prompt. +|<>|Foreground color of the statusbar in insert mode. |<>|Background color of the statusbar in insert mode. +|<>|Foreground color of the statusbar in command mode. +|<>|Background color of the statusbar in command mode. +|<>|Foreground color of the statusbar in caret mode. |<>|Background color of the statusbar in caret mode. +|<>|Foreground color of the statusbar in caret mode with a selection |<>|Background color of the statusbar in caret mode with a selection |<>|Background color of the progress bar. |<>|Default foreground color of the URL in the statusbar. @@ -202,10 +211,10 @@ |<>|Foreground color of the URL in the statusbar when there's a warning. |<>|Foreground color of the URL in the statusbar for hovered links. |<>|Foreground color of unselected odd tabs. -|<>|Foreground color of unselected even tabs. -|<>|Foreground color of selected tabs. |<>|Background color of unselected odd tabs. +|<>|Foreground color of unselected even tabs. |<>|Background color of unselected even tabs. +|<>|Foreground color of selected tabs. |<>|Background color of selected tabs. |<>|Background color of the tab bar. |<>|Color gradient start for the tab indicator. @@ -213,13 +222,16 @@ |<>|Color for the tab indicator on errors.. |<>|Color gradient interpolation system for the tab indicator. |<>|Font color for hints. -|<>|Font color for the matched part of hints. |<>|Background color for hints. -|<>|Foreground color for downloads. +|<>|Font color for the matched part of hints. |<>|Background color for the download bar. -|<>|Color gradient start for downloads. -|<>|Color gradient end for downloads. -|<>|Color gradient interpolation system for downloads. +|<>|Color gradient start for download text. +|<>|Color gradient start for download backgrounds. +|<>|Color gradient end for download text. +|<>|Color gradient stop for download backgrounds. +|<>|Color gradient interpolation system for download text. +|<>|Color gradient interpolation system for download backgrounds. +|<>|Foreground color for downloads with errors. |<>|Background color for downloads with errors. |<>|Background color for webpages if unset (or empty to use the theme's color) |============== @@ -407,7 +419,7 @@ Valid values: * +tab-bg-silent+: Open a new background tab in the existing window without activating the window. * +window+: Open in a new window. -Default: +pass:[window]+ +Default: +pass:[tab]+ [[general-log-javascript-console]] === log-javascript-console @@ -530,7 +542,7 @@ Default: +pass:[false]+ [[ui-user-stylesheet]] === user-stylesheet -User stylesheet to use (absolute filename or CSS string). Will expand environment variables. +User stylesheet to use (absolute filename, filename relative to the config directory or CSS string). Will expand environment variables. Default: +pass:[::-webkit-scrollbar { width: 0px; height: 0px; }]+ @@ -683,6 +695,17 @@ Default: +pass:[true]+ == completion Options related to completion and command history. +[[completion-auto-open]] +=== auto-open +Automatically open completion when typing. + +Valid values: + + * +true+ + * +false+ + +Default: +pass:[true]+ + [[completion-download-path-suggestion]] === download-path-suggestion What to display in the download filename input. @@ -911,7 +934,7 @@ Default: +pass:[last]+ [[tabs-last-close]] === last-close -Behaviour when the last tab is closed. +Behavior when the last tab is closed. Valid values: @@ -1316,14 +1339,16 @@ Default: +pass:[true]+ [[content-cookies-accept]] === cookies-accept -Whether to accept cookies. +Control which cookies to accept. Valid values: - * +default+: Default QtWebKit behavior. + * +all+: Accept all cookies. + * +no-3rdparty+: Accept cookies from the same origin only. + * +no-unknown-3rdparty+: Accept cookies from the same origin only, unless a cookie is already set for the domain. * +never+: Don't accept cookies at all. -Default: +pass:[default]+ +Default: +pass:[no-3rdparty]+ [[content-cookies-store]] === cookies-store @@ -1434,7 +1459,7 @@ Default: +pass:[true]+ === next-regexes A comma-separated list of regexes to use for 'next' links. -Default: +pass:[\bnext\b,\bmore\b,\bnewer\b,\b[>→≫]\b,\b(>>|»)\b]+ +Default: +pass:[\bnext\b,\bmore\b,\bnewer\b,\b[>→≫]\b,\b(>>|»)\b,\bcontinue\b]+ [[hints-prev-regexes]] === prev-regexes @@ -1461,7 +1486,9 @@ A value can be in one of the following format: * transparent (no color) * `rgb(r, g, b)` / `rgba(r, g, b, a)` (values 0-255 or percentages) * `hsv(h, s, v)` / `hsva(h, s, v, a)` (values 0-255, hue 0-359) - * A gradient as explained in http://qt-project.org/doc/qt-4.8/stylesheet-reference.html#list-of-property-types[the Qt documentation] under ``Gradient''. + * A gradient as explained in http://doc.qt.io/qt-5/stylesheet-reference.html#list-of-property-types[the Qt documentation] under ``Gradient''. + +A *.system value determines the color system to use for color interpolation between similarly-named *.start and *.stop entries, regardless of how they are defined in the options. Valid values are 'rgb', 'hsv', and 'hsl'. The `hints.*` values are a special case as they're real CSS colors, not Qt-CSS colors. There, for a gradient, you need to use `-webkit-gradient`, see https://www.webkit.org/blog/175/introducing-css-gradients/[the WebKit documentation]. @@ -1537,17 +1564,23 @@ Foreground color of the matched text in the completion. Default: +pass:[#ff4444]+ +[[colors-statusbar.fg]] +=== statusbar.fg +Foreground color of the statusbar. + +Default: +pass:[white]+ + [[colors-statusbar.bg]] === statusbar.bg Foreground color of the statusbar. Default: +pass:[black]+ -[[colors-statusbar.fg]] -=== statusbar.fg -Foreground color of the statusbar. +[[colors-statusbar.fg.error]] +=== statusbar.fg.error +Foreground color of the statusbar if there was an error. -Default: +pass:[white]+ +Default: +pass:[${statusbar.fg}]+ [[colors-statusbar.bg.error]] === statusbar.bg.error @@ -1555,30 +1588,72 @@ Background color of the statusbar if there was an error. Default: +pass:[red]+ +[[colors-statusbar.fg.warning]] +=== statusbar.fg.warning +Foreground color of the statusbar if there is a warning. + +Default: +pass:[${statusbar.fg}]+ + [[colors-statusbar.bg.warning]] === statusbar.bg.warning Background color of the statusbar if there is a warning. Default: +pass:[darkorange]+ +[[colors-statusbar.fg.prompt]] +=== statusbar.fg.prompt +Foreground color of the statusbar if there is a prompt. + +Default: +pass:[${statusbar.fg}]+ + [[colors-statusbar.bg.prompt]] === statusbar.bg.prompt Background color of the statusbar if there is a prompt. Default: +pass:[darkblue]+ +[[colors-statusbar.fg.insert]] +=== statusbar.fg.insert +Foreground color of the statusbar in insert mode. + +Default: +pass:[${statusbar.fg}]+ + [[colors-statusbar.bg.insert]] === statusbar.bg.insert Background color of the statusbar in insert mode. Default: +pass:[darkgreen]+ +[[colors-statusbar.fg.command]] +=== statusbar.fg.command +Foreground color of the statusbar in command mode. + +Default: +pass:[${statusbar.fg}]+ + +[[colors-statusbar.bg.command]] +=== statusbar.bg.command +Background color of the statusbar in command mode. + +Default: +pass:[${statusbar.bg}]+ + +[[colors-statusbar.fg.caret]] +=== statusbar.fg.caret +Foreground color of the statusbar in caret mode. + +Default: +pass:[${statusbar.fg}]+ + [[colors-statusbar.bg.caret]] === statusbar.bg.caret Background color of the statusbar in caret mode. Default: +pass:[purple]+ +[[colors-statusbar.fg.caret-selection]] +=== statusbar.fg.caret-selection +Foreground color of the statusbar in caret mode with a selection + +Default: +pass:[${statusbar.fg}]+ + [[colors-statusbar.bg.caret-selection]] === statusbar.bg.caret-selection Background color of the statusbar in caret mode with a selection @@ -1627,30 +1702,30 @@ Foreground color of unselected odd tabs. Default: +pass:[white]+ -[[colors-tabs.fg.even]] -=== tabs.fg.even -Foreground color of unselected even tabs. - -Default: +pass:[white]+ - -[[colors-tabs.fg.selected]] -=== tabs.fg.selected -Foreground color of selected tabs. - -Default: +pass:[white]+ - [[colors-tabs.bg.odd]] === tabs.bg.odd Background color of unselected odd tabs. Default: +pass:[grey]+ +[[colors-tabs.fg.even]] +=== tabs.fg.even +Foreground color of unselected even tabs. + +Default: +pass:[white]+ + [[colors-tabs.bg.even]] === tabs.bg.even Background color of unselected even tabs. Default: +pass:[darkgrey]+ +[[colors-tabs.fg.selected]] +=== tabs.fg.selected +Foreground color of selected tabs. + +Default: +pass:[white]+ + [[colors-tabs.bg.selected]] === tabs.bg.selected Background color of selected tabs. @@ -1699,23 +1774,17 @@ Font color for hints. Default: +pass:[black]+ -[[colors-hints.fg.match]] -=== hints.fg.match -Font color for the matched part of hints. - -Default: +pass:[green]+ - [[colors-hints.bg]] === hints.bg Background color for hints. Default: +pass:[-webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785), color-stop(100%,#FFC542))]+ -[[colors-downloads.fg]] -=== downloads.fg -Foreground color for downloads. +[[colors-hints.fg.match]] +=== hints.fg.match +Font color for the matched part of hints. -Default: +pass:[#ffffff]+ +Default: +pass:[green]+ [[colors-downloads.bg.bar]] === downloads.bg.bar @@ -1723,21 +1792,33 @@ Background color for the download bar. Default: +pass:[black]+ +[[colors-downloads.fg.start]] +=== downloads.fg.start +Color gradient start for download text. + +Default: +pass:[white]+ + [[colors-downloads.bg.start]] === downloads.bg.start -Color gradient start for downloads. +Color gradient start for download backgrounds. Default: +pass:[#0000aa]+ +[[colors-downloads.fg.stop]] +=== downloads.fg.stop +Color gradient end for download text. + +Default: +pass:[${downloads.fg.start}]+ + [[colors-downloads.bg.stop]] === downloads.bg.stop -Color gradient end for downloads. +Color gradient stop for download backgrounds. Default: +pass:[#00aa00]+ -[[colors-downloads.bg.system]] -=== downloads.bg.system -Color gradient interpolation system for downloads. +[[colors-downloads.fg.system]] +=== downloads.fg.system +Color gradient interpolation system for download text. Valid values: @@ -1747,6 +1828,24 @@ Valid values: Default: +pass:[rgb]+ +[[colors-downloads.bg.system]] +=== downloads.bg.system +Color gradient interpolation system for download backgrounds. + +Valid values: + + * +rgb+: Interpolate in the RGB color system. + * +hsv+: Interpolate in the HSV color system. + * +hsl+: Interpolate in the HSL color system. + +Default: +pass:[rgb]+ + +[[colors-downloads.fg.error]] +=== downloads.fg.error +Foreground color for downloads with errors. + +Default: +pass:[white]+ + [[colors-downloads.bg.error]] === downloads.bg.error Background color for downloads with errors. diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 2e8f7ea6a..20bf0f757 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -50,7 +50,7 @@ from qutebrowser.mainwindow import mainwindow from qutebrowser.misc import readline, ipc, savemanager, sessions, crashsignal from qutebrowser.misc import utilcmds # pylint: disable=unused-import from qutebrowser.utils import (log, version, message, utils, qtutils, urlutils, - objreg, usertypes, standarddir, error) + objreg, usertypes, standarddir, error, debug) # We import utilcmds to run the cmdutils.register decorators. @@ -61,7 +61,7 @@ def run(args): """Initialize everthing and run the application.""" # pylint: disable=too-many-statements if args.version: - print(version.version()) + print(version.version(short=True)) print() print() print(qutebrowser.__copyright__) @@ -148,7 +148,9 @@ def init(args, crash_handler): error.handle_fatal_exc(e, args, "Error while initializing!", pre_text="Error while initializing") sys.exit(usertypes.Exit.err_init) + QTimer.singleShot(0, functools.partial(_process_args, args)) + QTimer.singleShot(10, functools.partial(_init_late_modules, args)) log.init.debug("Initializing eventfilter...") event_filter = EventFilter(qApp) @@ -428,6 +430,23 @@ def _init_modules(args, crash_handler): objreg.get('config').changed.connect(_maybe_hide_mouse_cursor) +def _init_late_modules(args): + """Initialize modules which can be inited after the window is shown.""" + try: + log.init.debug("Reading web history...") + reader = objreg.get('web-history').async_read() + with debug.log_time(log.init, 'Reading history'): + while True: + QApplication.processEvents() + next(reader) + except StopIteration: + pass + except (OSError, UnicodeDecodeError) as e: + error.handle_fatal_exc(e, args, "Error while initializing!", + pre_text="Error while initializing") + sys.exit(usertypes.Exit.err_init) + + class Quitter: """Utility class to quit/restart the QApplication. diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 6585abed6..e29350d1b 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -22,7 +22,6 @@ import re import os import shlex -import subprocess import posixpath import functools import xml.etree.ElementTree @@ -37,14 +36,14 @@ import pygments import pygments.lexers import pygments.formatters -from qutebrowser.commands import userscripts, cmdexc, cmdutils +from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners from qutebrowser.config import config, configexc from qutebrowser.browser import webelem, inspector from qutebrowser.keyinput import modeman from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, objreg, utils) from qutebrowser.utils.usertypes import KeyMode -from qutebrowser.misc import editor +from qutebrowser.misc import editor, guiprocess class CommandDispatcher: @@ -696,19 +695,28 @@ class CommandDispatcher: frame.scroll(dx, dy) @cmdutils.register(instance='command-dispatcher', scope='window') - def yank(self, title=False, sel=False): + def yank(self, title=False, sel=False, domain=False): """Yank the current URL/title to the clipboard or primary selection. Args: sel: Use the primary selection instead of the clipboard. title: Yank the title instead of the URL. + domain: Yank only the scheme, domain, and port number. """ clipboard = QApplication.clipboard() if title: s = self._tabbed_browser.page_title(self._current_index()) + what = 'title' + elif domain: + port = self._current_url().port() + s = '{}://{}{}'.format(self._current_url().scheme(), + self._current_url().host(), + ':' + str(port) if port > -1 else '') + what = 'domain' else: s = self._current_url().toString( QUrl.FullyEncoded | QUrl.RemovePassword) + what = 'URL' if sel and clipboard.supportsSelection(): mode = QClipboard.Selection target = "primary selection" @@ -717,8 +725,8 @@ class CommandDispatcher: target = "clipboard" log.misc.debug("Yanking to {}: '{}'".format(target, s)) clipboard.setText(s, mode) - what = 'Title' if title else 'URL' - message.info(self._win_id, "{} yanked to {}".format(what, target)) + message.info(self._win_id, "Yanked {} to {}: {}".format( + what, target, s)) @cmdutils.register(instance='command-dispatcher', scope='window', count='count') @@ -729,7 +737,11 @@ class CommandDispatcher: count: How many steps to zoom in. """ tab = self._current_widget() - tab.zoom(count) + try: + perc = tab.zoom(count) + except ValueError as e: + raise cmdexc.CommandError(e) + message.info(self._win_id, "Zoom level: {}%".format(perc)) @cmdutils.register(instance='command-dispatcher', scope='window', count='count') @@ -740,7 +752,11 @@ class CommandDispatcher: count: How many steps to zoom out. """ tab = self._current_widget() - tab.zoom(-count) + try: + perc = tab.zoom(-count) + except ValueError as e: + raise cmdexc.CommandError(e) + message.info(self._win_id, "Zoom level: {}%".format(perc)) @cmdutils.register(instance='command-dispatcher', scope='window', count='count') @@ -760,7 +776,12 @@ class CommandDispatcher: except ValueError as e: raise cmdexc.CommandError(e) tab = self._current_widget() - tab.zoom_perc(level) + + try: + tab.zoom_perc(level) + except ValueError as e: + raise cmdexc.CommandError(e) + message.info(self._win_id, "Zoom level: {}%".format(level)) @cmdutils.register(instance='command-dispatcher', scope='window') def tab_only(self, left=False, right=False): @@ -913,38 +934,39 @@ class CommandDispatcher: self._tabbed_browser.setUpdatesEnabled(True) @cmdutils.register(instance='command-dispatcher', scope='window', - win_id='win_id') - def spawn(self, win_id, userscript=False, quiet=False, *args): + maxsplit=0) + def spawn(self, cmdline, userscript=False, verbose=False, detach=False): """Spawn a command in a shell. Note the {url} variable which gets replaced by the current URL might be useful here. - // - - We use subprocess rather than Qt's QProcess here because we really - don't care about the process anymore as soon as it's spawned. - Args: userscript: Run the command as an userscript. - quiet: Don't print the commandline being executed. - *args: The commandline to execute. + verbose: Show notifications when the command started/exited. + detach: Whether the command should be detached from qutebrowser. + cmdline: The commandline to execute. """ - log.procs.debug("Executing: {}, userscript={}".format( - args, userscript)) - if not quiet: - fake_cmdline = ' '.join(shlex.quote(arg) for arg in args) - message.info(win_id, 'Executing: ' + fake_cmdline) + try: + cmd, *args = shlex.split(cmdline) + except ValueError as e: + raise cmdexc.CommandError("Error while splitting command: " + "{}".format(e)) + + args = runners.replace_variables(self._win_id, args) + + log.procs.debug("Executing {} with args {}, userscript={}".format( + cmd, args, userscript)) if userscript: - cmd = args[0] - args = [] if not args else args[1:] - self.run_userscript(cmd, *args) + self.run_userscript(cmd, *args, verbose=verbose) else: - try: - subprocess.Popen(args) - except OSError as e: - raise cmdexc.CommandError("Error while spawning command: " - "{}".format(e)) + proc = guiprocess.GUIProcess(self._win_id, what='command', + verbose=verbose, + parent=self._tabbed_browser) + if detach: + proc.start_detached(cmd, args) + else: + proc.start(cmd, args) @cmdutils.register(instance='command-dispatcher', scope='window') def home(self): @@ -953,12 +975,13 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', deprecated='Use :spawn --userscript instead!') - def run_userscript(self, cmd, *args: {'nargs': '*'}): + def run_userscript(self, cmd, *args: {'nargs': '*'}, verbose=False): """Run an userscript given as argument. Args: cmd: The userscript to run. args: Arguments to pass to the userscript. + verbose: Show notifications when the command started/exited. """ cmd = os.path.expanduser(cmd) env = { @@ -986,7 +1009,8 @@ class CommandDispatcher: env['QUTE_URL'] = url.toString(QUrl.FullyEncoded) env.update(userscripts.store_source(mainframe)) - userscripts.run(cmd, *args, win_id=self._win_id, env=env) + userscripts.run(cmd, *args, win_id=self._win_id, env=env, + verbose=verbose) @cmdutils.register(instance='command-dispatcher', scope='window') def quickmark_save(self): @@ -1157,12 +1181,6 @@ class CommandDispatcher: The editor which should be launched can be configured via the `general -> editor` config option. - - // - - We use QProcess rather than subprocess here because it makes it a lot - easier to execute some code as soon as the process has been finished - and do everything async. """ frame = self._current_widget().page().currentFrame() try: @@ -1184,7 +1202,7 @@ class CommandDispatcher: def on_editing_finished(self, elem, text): """Write the editor text into the form field and clean up tempfile. - Callback for QProcess when the editor was closed. + Callback for GUIProcess when the editor was closed. Args: elem: The WebElementWrapper which was modified. @@ -1567,3 +1585,33 @@ class CommandDispatcher: view = self._current_widget() for _ in range(count): view.triggerPageAction(member) + + @cmdutils.register(instance='command-dispatcher', scope='window', + maxsplit=0, no_cmd_split=True) + def jseval(self, js_code, quiet=False): + """Evaluate a JavaScript string. + + Args: + js_code: The string to evaluate. + quiet: Don't show resulting JS object. + """ + frame = self._current_widget().page().mainFrame() + out = frame.evaluateJavaScript(js_code) + + if quiet: + return + + if out is None: + # Getting the actual error (if any) seems to be difficult. The + # error does end up in BrowserPage.javaScriptConsoleMessage(), but + # distinguishing between :jseval errors and errors from the webpage + # is not trivial... + message.info(self._win_id, 'No output or error') + else: + # The output can be a string, number, dict, array, etc. But *don't* + # output too much data, as this will make qutebrowser hang + out = str(out) + if len(out) > 5000: + message.info(self._win_id, out[:5000] + ' [...trimmed...]') + else: + message.info(self._win_id, out) diff --git a/qutebrowser/browser/cookies.py b/qutebrowser/browser/cookies.py index f6af98d57..0c6b8c036 100644 --- a/qutebrowser/browser/cookies.py +++ b/qutebrowser/browser/cookies.py @@ -84,7 +84,7 @@ class CookieJar(RAMCookieJar): def purge_old_cookies(self): """Purge expired cookies from the cookie jar.""" # Based on: - # http://qt-project.org/doc/qt-5/qtwebkitexamples-webkitwidgets-browser-cookiejar-cpp.html + # http://doc.qt.io/qt-5/qtwebkitexamples-webkitwidgets-browser-cookiejar-cpp.html now = QDateTime.currentDateTime() cookies = [c for c in self.allCookies() if c.isSessionCookie() or c.expirationDate() >= now] diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 790459f6a..3f8bf116c 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -148,7 +148,7 @@ class DownloadItemStats(QObject): @pyqtSlot(int, int) def on_download_progress(self, bytes_done, bytes_total): - """Upload local variables when the download progress changed. + """Update local variables when the download progress changed. Args: bytes_done: How many bytes are downloaded. @@ -158,7 +158,6 @@ class DownloadItemStats(QObject): bytes_total = None self.done = bytes_done self.total = bytes_total - self.updated.emit() class DownloadItem(QObject): @@ -356,12 +355,19 @@ class DownloadItem(QObject): if reply.error() != QNetworkReply.NoError: QTimer.singleShot(0, lambda: self.error.emit(reply.errorString())) - def bg_color(self): - """Background color to be shown.""" - start = config.get('colors', 'downloads.bg.start') - stop = config.get('colors', 'downloads.bg.stop') - system = config.get('colors', 'downloads.bg.system') - error = config.get('colors', 'downloads.bg.error') + def get_status_color(self, position): + """Choose an appropriate color for presenting the download's status. + + Args: + position: The color type requested, can be 'fg' or 'bg'. + """ + # pylint: disable=bad-config-call + # WORKAROUND for https://bitbucket.org/logilab/astroid/issue/104/ + assert position in ("fg", "bg") + start = config.get('colors', 'downloads.{}.start'.format(position)) + stop = config.get('colors', 'downloads.{}.stop'.format(position)) + system = config.get('colors', 'downloads.{}.system'.format(position)) + error = config.get('colors', 'downloads.{}.error'.format(position)) if self.error_msg is not None: assert not self.successful return error @@ -679,7 +685,7 @@ class DownloadManager(QAbstractListModel): if fileobj is not None and filename is not None: raise TypeError("Only one of fileobj/filename may be given!") # WORKAROUND for Qt corrupting data loaded from cache: - # https://bugreports.qt-project.org/browse/QTBUG-42757 + # https://bugreports.qt.io/browse/QTBUG-42757 request.setAttribute(QNetworkRequest.CacheLoadControlAttribute, QNetworkRequest.AlwaysNetwork) suggested_fn = urlutils.filename_from_url(request.url()) @@ -1023,9 +1029,9 @@ class DownloadManager(QAbstractListModel): if role == Qt.DisplayRole: data = str(item) elif role == Qt.ForegroundRole: - data = config.get('colors', 'downloads.fg') + data = item.get_status_color('fg') elif role == Qt.BackgroundRole: - data = item.bg_color() + data = item.get_status_color('bg') elif role == ModelRole.item: data = item elif role == Qt.ToolTipRole: diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index eef512f55..4f03a5774 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -21,7 +21,6 @@ import math import functools -import subprocess import collections from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl, @@ -36,6 +35,7 @@ from qutebrowser.keyinput import modeman, modeparsers from qutebrowser.browser import webelem from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners from qutebrowser.utils import usertypes, log, qtutils, message, objreg +from qutebrowser.misc import guiprocess ElemTuple = collections.namedtuple('ElemTuple', ['elem', 'label']) @@ -548,11 +548,9 @@ class HintManager(QObject): """ urlstr = url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) args = context.get_args(urlstr) - try: - subprocess.Popen(args) - except OSError as e: - msg = "Error while spawning command: {}".format(e) - message.error(self._win_id, msg, immediately=True) + cmd, *args = args + proc = guiprocess.GUIProcess(self._win_id, what='command', parent=self) + proc.start(cmd, args) def _resolve_url(self, elem, baseurl): """Resolve a URL and check if we want to keep it. diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index 2ffbf70c1..ed9c55e7f 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -67,23 +67,30 @@ class WebHistory(QWebHistoryInterface): _history_dict: An OrderedDict of URLs read from the on-disk history. _new_history: A list of HistoryEntry items of the current session. _saved_count: How many HistoryEntries have been written to disk. + _initial_read_started: Whether async_read was called. + _initial_read_done: Whether async_read has completed. + _temp_history: OrderedDict of temporary history entries before + async_read was called. Signals: - item_about_to_be_added: Emitted before a new HistoryEntry is added. - arg: The new HistoryEntry. + add_completion_item: Emitted before a new HistoryEntry is added. + arg: The new HistoryEntry. item_added: Emitted after a new HistoryEntry is added. arg: The new HistoryEntry. """ - item_about_to_be_added = pyqtSignal(HistoryEntry) + add_completion_item = pyqtSignal(HistoryEntry) item_added = pyqtSignal(HistoryEntry) + async_read_done = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) + self._initial_read_started = False + self._initial_read_done = False self._lineparser = lineparser.AppendLineParser( standarddir.data(), 'history', parent=self) self._history_dict = collections.OrderedDict() - self._read_history() + self._temp_history = collections.OrderedDict() self._new_history = [] self._saved_count = 0 objreg.get('save-manager').add_saveable( @@ -101,12 +108,21 @@ class WebHistory(QWebHistoryInterface): def __len__(self): return len(self._history_dict) - def _read_history(self): + def async_read(self): """Read the initial history.""" - if standarddir.data() is None: + if self._initial_read_started: + log.init.debug("Ignoring async_read() because reading is started.") return + self._initial_read_started = True + + if standarddir.data() is None: + self._initial_read_done = True + self.async_read_done.emit() + return + with self._lineparser.open(): for line in self._lineparser: + yield data = line.rstrip().split(maxsplit=1) if not data: # empty line @@ -128,8 +144,23 @@ class WebHistory(QWebHistoryInterface): # information about previous hits change the items in # old_urls to be lists or change HistoryEntry to have a # list of atimes. - self._history_dict[url] = HistoryEntry(atime, url) - self._history_dict.move_to_end(url) + entry = HistoryEntry(atime, url) + self._add_entry(entry) + + self._initial_read_done = True + self.async_read_done.emit() + + for url, entry in self._temp_history.items(): + self._new_history.append(entry) + self._add_entry(entry) + self.add_completion_item.emit(entry) + + def _add_entry(self, entry, target=None): + """Add an entry to self._history_dict or another given OrderedDict.""" + if target is None: + target = self._history_dict + target[entry.url_string] = entry + target.move_to_end(entry.url_string) def get_recent(self): """Get the most recent history entries.""" @@ -151,13 +182,16 @@ class WebHistory(QWebHistoryInterface): """ if not url_string: return - if not config.get('general', 'private-browsing'): - entry = HistoryEntry(time.time(), url_string) - self.item_about_to_be_added.emit(entry) + if config.get('general', 'private-browsing'): + return + entry = HistoryEntry(time.time(), url_string) + if self._initial_read_done: + self.add_completion_item.emit(entry) self._new_history.append(entry) - self._history_dict[url_string] = entry - self._history_dict.move_to_end(url_string) + self._add_entry(entry) self.item_added.emit(entry) + else: + self._add_entry(entry, target=self._temp_history) def historyContains(self, url_string): """Called by WebKit to determine if an URL is contained in the history. diff --git a/qutebrowser/browser/network/networkmanager.py b/qutebrowser/browser/network/networkmanager.py index 52dc4f017..7ebb80a2e 100644 --- a/qutebrowser/browser/network/networkmanager.py +++ b/qutebrowser/browser/network/networkmanager.py @@ -21,16 +21,9 @@ import collections -from PyQt5.QtCore import (pyqtSlot, pyqtSignal, PYQT_VERSION, QCoreApplication, - QUrl, QByteArray) -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QSslError - -try: - from PyQt5.QtNetwork import QSslSocket -except ImportError: - SSL_AVAILABLE = False -else: - SSL_AVAILABLE = QSslSocket.supportsSsl() +from PyQt5.QtCore import (pyqtSlot, pyqtSignal, PYQT_VERSION, QCoreApplication) +from PyQt5.QtNetwork import (QNetworkAccessManager, QNetworkReply, QSslError, + QSslSocket, QUrl, QByteArray) from qutebrowser.config import config from qutebrowser.utils import (message, log, usertypes, utils, objreg, qtutils, @@ -46,13 +39,12 @@ _proxy_auth_cache = {} def init(): """Disable insecure SSL ciphers on old Qt versions.""" - if SSL_AVAILABLE: - if not qtutils.version_check('5.3.0'): - # Disable weak SSL ciphers. - # See https://codereview.qt-project.org/#/c/75943/ - good_ciphers = [c for c in QSslSocket.supportedCiphers() - if c.usedBits() >= 128] - QSslSocket.setDefaultCiphers(good_ciphers) + if not qtutils.version_check('5.3.0'): + # Disable weak SSL ciphers. + # See https://codereview.qt-project.org/#/c/75943/ + good_ciphers = [c for c in QSslSocket.supportedCiphers() + if c.usedBits() >= 128] + QSslSocket.setDefaultCiphers(good_ciphers) class SslError(QSslError): @@ -107,10 +99,9 @@ class NetworkManager(QNetworkAccessManager): } self._set_cookiejar() self._set_cache() - if SSL_AVAILABLE: - self.sslErrors.connect(self.on_ssl_errors) - self._rejected_ssl_errors = collections.defaultdict(list) - self._accepted_ssl_errors = collections.defaultdict(list) + self.sslErrors.connect(self.on_ssl_errors) + self._rejected_ssl_errors = collections.defaultdict(list) + self._accepted_ssl_errors = collections.defaultdict(list) self.authenticationRequired.connect(self.on_authentication_required) self.proxyAuthenticationRequired.connect( self.on_proxy_authentication_required) @@ -181,76 +172,67 @@ class NetworkManager(QNetworkAccessManager): request.deleteLater() self.shutting_down.emit() - if SSL_AVAILABLE: # pragma: no mccabe - @pyqtSlot('QNetworkReply*', 'QList') - def on_ssl_errors(self, reply, errors): - """Decide if SSL errors should be ignored or not. + @pyqtSlot('QNetworkReply*', 'QList') + def on_ssl_errors(self, reply, errors): # pragma: no mccabe + """Decide if SSL errors should be ignored or not. - This slot is called on SSL/TLS errors by the self.sslErrors signal. + This slot is called on SSL/TLS errors by the self.sslErrors signal. - Args: - reply: The QNetworkReply that is encountering the errors. - errors: A list of errors. - """ - errors = [SslError(e) for e in errors] - ssl_strict = config.get('network', 'ssl-strict') - if ssl_strict == 'ask': - try: - host_tpl = urlutils.host_tuple(reply.url()) - except ValueError: - host_tpl = None - is_accepted = False - is_rejected = False - else: - is_accepted = set(errors).issubset( - self._accepted_ssl_errors[host_tpl]) - is_rejected = set(errors).issubset( - self._rejected_ssl_errors[host_tpl]) - if is_accepted: - reply.ignoreSslErrors() - elif is_rejected: - pass - else: - err_string = '\n'.join('- ' + err.errorString() for err in - errors) - answer = self._ask('SSL errors - continue?\n{}'.format( - err_string), mode=usertypes.PromptMode.yesno, - owner=reply) - if answer: - reply.ignoreSslErrors() - d = self._accepted_ssl_errors - else: - d = self._rejected_ssl_errors - if host_tpl is not None: - d[host_tpl] += errors - elif ssl_strict: + Args: + reply: The QNetworkReply that is encountering the errors. + errors: A list of errors. + """ + errors = [SslError(e) for e in errors] + ssl_strict = config.get('network', 'ssl-strict') + if ssl_strict == 'ask': + try: + host_tpl = urlutils.host_tuple(reply.url()) + except ValueError: + host_tpl = None + is_accepted = False + is_rejected = False + else: + is_accepted = set(errors).issubset( + self._accepted_ssl_errors[host_tpl]) + is_rejected = set(errors).issubset( + self._rejected_ssl_errors[host_tpl]) + if is_accepted: + reply.ignoreSslErrors() + elif is_rejected: pass else: - for err in errors: - # FIXME we might want to use warn here (non-fatal error) - # https://github.com/The-Compiler/qutebrowser/issues/114 - message.error(self._win_id, - 'SSL error: {}'.format(err.errorString())) - reply.ignoreSslErrors() + err_string = '\n'.join('- ' + err.errorString() for err in + errors) + answer = self._ask('SSL errors - continue?\n{}'.format( + err_string), mode=usertypes.PromptMode.yesno, + owner=reply) + if answer: + reply.ignoreSslErrors() + d = self._accepted_ssl_errors + else: + d = self._rejected_ssl_errors + if host_tpl is not None: + d[host_tpl] += errors + elif ssl_strict: + pass + else: + for err in errors: + # FIXME we might want to use warn here (non-fatal error) + # https://github.com/The-Compiler/qutebrowser/issues/114 + message.error(self._win_id, + 'SSL error: {}'.format(err.errorString())) + reply.ignoreSslErrors() - @pyqtSlot(QUrl) - def clear_rejected_ssl_errors(self, url): - """Clear the rejected SSL errors on a reload. + @pyqtSlot(QUrl) + def clear_rejected_ssl_errors(self, url): + """Clear the rejected SSL errors on a reload. - Args: - url: The URL to remove. - """ - try: - del self._rejected_ssl_errors[url] - except KeyError: - pass - else: - @pyqtSlot(QUrl) - def clear_rejected_ssl_errors(self, _url): - """Clear the rejected SSL errors on a reload. - - Does nothing because SSL is unavailable. - """ + Args: + url: The URL to remove. + """ + try: + del self._rejected_ssl_errors[url] + except KeyError: pass @pyqtSlot('QNetworkReply', 'QAuthenticator') @@ -334,11 +316,7 @@ class NetworkManager(QNetworkAccessManager): A QNetworkReply. """ scheme = req.url().scheme() - if scheme == 'https' and not SSL_AVAILABLE: - return networkreply.ErrorNetworkReply( - req, "SSL is not supported by the installed Qt library!", - QNetworkReply.ProtocolUnknownError, self) - elif scheme in self._scheme_handlers: + if scheme in self._scheme_handlers: return self._scheme_handlers[scheme].createRequest( op, req, outgoing_data) diff --git a/qutebrowser/browser/network/qutescheme.py b/qutebrowser/browser/network/qutescheme.py index 48b3dbd5f..7a9f3bf8d 100644 --- a/qutebrowser/browser/network/qutescheme.py +++ b/qutebrowser/browser/network/qutescheme.py @@ -34,7 +34,6 @@ import configparser from PyQt5.QtCore import pyqtSlot, QObject from PyQt5.QtNetwork import QNetworkReply -from PyQt5.QtWebKit import QWebSettings import qutebrowser from qutebrowser.browser.network import schemehandler, networkreply @@ -98,7 +97,7 @@ class JSBridge(QObject): def set(self, win_id, sectname, optname, value): """Slot to set a setting from qute:settings.""" # https://github.com/The-Compiler/qutebrowser/issues/727 - if (sectname, optname == 'content', 'allow-javascript' and + if ((sectname, optname) == ('content', 'allow-javascript') and value == 'false'): message.error(win_id, "Refusing to disable javascript via " "qute:settings as it needs javascript support.") @@ -160,7 +159,7 @@ def qute_help(win_id, request): url=request.url().toDisplayString(), error="This most likely means the documentation was not generated " "properly. If you are running qutebrowser from the git " - "repository, please run scripts/asciidoc2html.py." + "repository, please run scripts/asciidoc2html.py. " "If you're running a released version this is a bug, please " "use :report to report it.", icon='') @@ -179,18 +178,10 @@ def qute_help(win_id, request): def qute_settings(win_id, _request): """Handler for qute:settings. View/change qute configuration.""" - if not QWebSettings.globalSettings().testAttribute( - QWebSettings.JavascriptEnabled): - # https://github.com/The-Compiler/qutebrowser/issues/727 - template = jinja.env.get_template('pre.html') - html = template.render( - title='Failed to open qute:settings.', - content="qute:settings needs javascript enabled to work.") - else: - config_getter = functools.partial(objreg.get('config').get, raw=True) - html = jinja.env.get_template('settings.html').render( - win_id=win_id, title='settings', config=configdata, - confget=config_getter) + config_getter = functools.partial(objreg.get('config').get, raw=True) + html = jinja.env.get_template('settings.html').render( + win_id=win_id, title='settings', config=configdata, + confget=config_getter) return html.encode('UTF-8', errors='xmlcharrefreplace') diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index 59fea9897..263289e6b 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -312,7 +312,7 @@ def javascript_escape(text): def get_child_frames(startframe): """Get all children recursively of a given QWebFrame. - Loosly based on http://blog.nextgenetics.net/?e=64 + Loosely based on http://blog.nextgenetics.net/?e=64 Args: startframe: The QWebFrame to start with. diff --git a/qutebrowser/browser/webpage.py b/qutebrowser/browser/webpage.py index 8e430efcb..071e627d9 100644 --- a/qutebrowser/browser/webpage.py +++ b/qutebrowser/browser/webpage.py @@ -109,7 +109,7 @@ class BrowserPage(QWebPage): def _handle_errorpage(self, info, errpage): """Display an error page if needed. - Loosly based on Helpviewer/HelpBrowserWV.py from eric5 + Loosely based on Helpviewer/HelpBrowserWV.py from eric5 (line 260 @ 5d937eb378dd) Args: @@ -178,7 +178,7 @@ class BrowserPage(QWebPage): def _handle_multiple_files(self, info, files): """Handle uploading of multiple files. - Loosly based on Helpviewer/HelpBrowserWV.py from eric5. + Loosely based on Helpviewer/HelpBrowserWV.py from eric5. Args: info: The ChooseMultipleFilesExtensionOption instance. @@ -241,7 +241,7 @@ class BrowserPage(QWebPage): if cur_data is not None: frame = self.mainFrame() if 'zoom' in cur_data: - frame.setZoomFactor(cur_data['zoom']) + frame.page().view().zoom_perc(cur_data['zoom'] * 100) if ('scroll-pos' in cur_data and frame.scrollPosition() == QPoint(0, 0)): QTimer.singleShot(0, functools.partial( @@ -418,7 +418,7 @@ class BrowserPage(QWebPage): if data is None: return if 'zoom' in data: - frame.setZoomFactor(data['zoom']) + frame.page().view().zoom_perc(data['zoom'] * 100) if 'scroll-pos' in data and frame.scrollPosition() == QPoint(0, 0): frame.setScrollPosition(data['scroll-pos']) diff --git a/qutebrowser/browser/webview.py b/qutebrowser/browser/webview.py index 584c7f268..cfabee80c 100644 --- a/qutebrowser/browser/webview.py +++ b/qutebrowser/browser/webview.py @@ -33,7 +33,6 @@ from qutebrowser.config import config from qutebrowser.keyinput import modeman from qutebrowser.utils import message, log, usertypes, utils, qtutils, objreg from qutebrowser.browser import webpage, hints, webelem -from qutebrowser.commands import cmdexc LoadStatus = usertypes.enum('LoadStatus', ['none', 'success', 'error', 'warn', @@ -163,7 +162,7 @@ class WebView(QWebView): return utils.get_repr(self, tab_id=self.tab_id, url=url) def __del__(self): - # Explicitely releasing the page here seems to prevent some segfaults + # Explicitly releasing the page here seems to prevent some segfaults # when quitting. # Copied from: # https://code.google.com/p/webscraping/source/browse/webkit.py#325 @@ -369,9 +368,8 @@ class WebView(QWebView): if fuzzyval: self._zoom.fuzzyval = int(perc) if perc < 0: - raise cmdexc.CommandError("Can't zoom {}%!".format(perc)) + raise ValueError("Can't zoom {}%!".format(perc)) self.setZoomFactor(float(perc) / 100) - message.info(self.win_id, "Zoom level: {}%".format(perc)) self._default_zoom_changed = True def zoom(self, offset): @@ -379,9 +377,13 @@ class WebView(QWebView): Args: offset: The offset in the zoom level list. + + Return: + The new zoom percentage. """ level = self._zoom.getitem(offset) self.zoom_perc(level, fuzzyval=False) + return level @pyqtSlot('QUrl') def on_url_changed(self, url): @@ -460,15 +462,22 @@ class WebView(QWebView): elif mode == usertypes.KeyMode.caret: settings = self.settings() settings.setAttribute(QWebSettings.CaretBrowsingEnabled, True) - self.selection_enabled = False + self.selection_enabled = bool(self.page().selectedText()) if self.isVisible(): # Sometimes the caret isn't immediately visible, but unfocusing # and refocusing it fixes that. self.clearFocus() self.setFocus(Qt.OtherFocusReason) - self.page().currentFrame().evaluateJavaScript( - utils.read_file('javascript/position_caret.js')) + + # Move the caret to the first element in the viewport if there + # isn't any text which is already selected. + # + # Note: We can't use hasSelection() here, as that's always + # true in caret mode. + if not self.page().selectedText(): + self.page().currentFrame().evaluateJavaScript( + utils.read_file('javascript/position_caret.js')) @pyqtSlot(usertypes.KeyMode) def on_mode_left(self, mode): @@ -620,6 +629,7 @@ class WebView(QWebView): """Save a reference to the context menu so we can close it.""" menu = self.page().createStandardContextMenu() self.shutting_down.connect(menu.close) + modeman.instance(self.win_id).entered.connect(menu.close) menu.exec_(e.globalPos()) def wheelEvent(self, e): diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index 4bda15f8a..5135c07e0 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -29,6 +29,11 @@ from qutebrowser.utils import log, utils, message, docutils, objreg, usertypes from qutebrowser.utils import debug as debug_utils +def arg_name(name): + """Get the name an argument should have based on its Python name.""" + return name.rstrip('_').replace('_', '-') + + class Command: """Base skeleton for a command. @@ -257,10 +262,12 @@ class Command: except KeyError: pass + kwargs['dest'] = param.name + if isinstance(typ, tuple): kwargs['metavar'] = annotation_info.metavar or param.name elif utils.is_enum(typ): - kwargs['choices'] = [e.name.replace('_', '-') for e in typ] + kwargs['choices'] = [arg_name(e.name) for e in typ] kwargs['metavar'] = annotation_info.metavar or param.name elif typ is bool: kwargs['action'] = 'store_true' @@ -288,7 +295,7 @@ class Command: A list of args. """ args = [] - name = param.name.rstrip('_').replace('_', '-') + name = arg_name(param.name) shortname = annotation_info.flag or name[0] if len(shortname) != 1: raise ValueError("Flag '{}' of parameter {} (command {}) must be " @@ -304,7 +311,6 @@ class Command: if typ is not bool: self.flags_with_args += [short_flag, long_flag] else: - args.append(name) if not annotation_info.hide: self.pos_args.append((param.name, name)) return args @@ -408,17 +414,16 @@ class Command: raise TypeError("{}: invalid parameter type {} for argument " "{!r}!".format(self.name, param.kind, param.name)) - def _get_param_name_and_value(self, param): - """Get the converted name and value for an inspect.Parameter.""" - name = param.name.rstrip('_') - value = getattr(self.namespace, name) + def _get_param_value(self, param): + """Get the converted value for an inspect.Parameter.""" + value = getattr(self.namespace, param.name) if param.name in self._type_conv: # We convert enum types after getting the values from # argparse, because argparse's choices argument is # processed after type conversation, which is not what we # want. value = self._type_conv[param.name](value) - return name, value + return value def _get_call_args(self, win_id): """Get arguments for a function call. @@ -452,14 +457,14 @@ class Command: # Special case for win_id parameter. self._get_win_id_arg(win_id, param, args, kwargs) continue - name, value = self._get_param_name_and_value(param) + value = self._get_param_value(param) if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: args.append(value) elif param.kind == inspect.Parameter.VAR_POSITIONAL: if value is not None: args += value elif param.kind == inspect.Parameter.KEYWORD_ONLY: - kwargs[name] = value + kwargs[param.name] = value else: raise TypeError("{}: Invalid parameter type {} for argument " "'{}'!".format( diff --git a/qutebrowser/commands/userscripts.py b/qutebrowser/commands/userscripts.py index 76e9f94a6..136b23e57 100644 --- a/qutebrowser/commands/userscripts.py +++ b/qutebrowser/commands/userscripts.py @@ -23,12 +23,12 @@ import os import os.path import tempfile -from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QSocketNotifier, - QProcessEnvironment, QProcess) +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QSocketNotifier from qutebrowser.utils import message, log, objreg, standarddir from qutebrowser.commands import runners, cmdexc from qutebrowser.config import config +from qutebrowser.misc import guiprocess class _QtFIFOReader(QObject): @@ -70,13 +70,9 @@ class _BaseUserscriptRunner(QObject): Attributes: _filepath: The path of the file/FIFO which is being read. - _proc: The QProcess which is being executed. + _proc: The GUIProcess which is being executed. _win_id: The window ID this runner is associated with. - Class attributes: - PROCESS_MESSAGES: A mapping of QProcess::ProcessError members to - human-readable error strings. - Signals: got_cmd: Emitted when a new command arrived and should be executed. finished: Emitted when the userscript finished running. @@ -85,17 +81,6 @@ class _BaseUserscriptRunner(QObject): got_cmd = pyqtSignal(str) finished = pyqtSignal() - PROCESS_MESSAGES = { - QProcess.FailedToStart: "The process failed to start.", - QProcess.Crashed: "The process crashed.", - QProcess.Timedout: "The last waitFor...() function timed out.", - QProcess.WriteError: ("An error occurred when attempting to write to " - "the process."), - QProcess.ReadError: ("An error occurred when attempting to read from " - "the process."), - QProcess.UnknownError: "An unknown error occurred.", - } - def __init__(self, win_id, parent=None): super().__init__(parent) self._win_id = win_id @@ -103,22 +88,20 @@ class _BaseUserscriptRunner(QObject): self._proc = None self._env = None - def _run_process(self, cmd, *args, env): - """Start the given command via QProcess. + def _run_process(self, cmd, *args, env, verbose): + """Start the given command. Args: cmd: The command to be started. *args: The arguments to hand to the command env: A dictionary of environment variables to add. + verbose: Show notifications when the command started/exited. """ - self._env = env - self._proc = QProcess(self) - procenv = QProcessEnvironment.systemEnvironment() - procenv.insert('QUTE_FIFO', self._filepath) - if env is not None: - for k, v in env.items(): - procenv.insert(k, v) - self._proc.setProcessEnvironment(procenv) + self._env = {'QUTE_FIFO': self._filepath} + self._env.update(env) + self._proc = guiprocess.GUIProcess(self._win_id, 'userscript', + additional_env=self._env, + verbose=verbose, parent=self) self._proc.error.connect(self.on_proc_error) self._proc.finished.connect(self.on_proc_finished) self._proc.start(cmd, args) @@ -126,11 +109,10 @@ class _BaseUserscriptRunner(QObject): def _cleanup(self): """Clean up temporary files.""" tempfiles = [self._filepath] - if self._env is not None: - if 'QUTE_HTML' in self._env: - tempfiles.append(self._env['QUTE_HTML']) - if 'QUTE_TEXT' in self._env: - tempfiles.append(self._env['QUTE_TEXT']) + if 'QUTE_HTML' in self._env: + tempfiles.append(self._env['QUTE_HTML']) + if 'QUTE_TEXT' in self._env: + tempfiles.append(self._env['QUTE_TEXT']) for fn in tempfiles: log.procs.debug("Deleting temporary file {}.".format(fn)) try: @@ -145,7 +127,7 @@ class _BaseUserscriptRunner(QObject): self._proc = None self._env = None - def run(self, cmd, *args, env=None): + def run(self, cmd, *args, env=None, verbose=False): """Run the userscript given. Needs to be overridden by subclasses. @@ -154,6 +136,7 @@ class _BaseUserscriptRunner(QObject): cmd: The command to be started. *args: The arguments to hand to the command env: A dictionary of environment variables to add. + verbose: Show notifications when the command started/exited. """ raise NotImplementedError @@ -166,12 +149,7 @@ class _BaseUserscriptRunner(QObject): def on_proc_error(self, error): """Called when the process encountered an error.""" - msg = self.PROCESS_MESSAGES[error] - # NOTE: Do not replace this with "raise CommandError" as it's - # executed async. - message.error(self._win_id, - "Error while calling userscript: {}".format(msg)) - log.procs.debug("Userscript process error: {} - {}".format(error, msg)) + raise NotImplementedError class _POSIXUserscriptRunner(_BaseUserscriptRunner): @@ -188,7 +166,7 @@ class _POSIXUserscriptRunner(_BaseUserscriptRunner): super().__init__(win_id, parent) self._reader = None - def run(self, cmd, *args, env=None): + def run(self, cmd, *args, env=None, verbose=False): try: # tempfile.mktemp is deprecated and discouraged, but we use it here # to create a FIFO since the only other alternative would be to @@ -206,16 +184,14 @@ class _POSIXUserscriptRunner(_BaseUserscriptRunner): self._reader = _QtFIFOReader(self._filepath) self._reader.got_line.connect(self.got_cmd) - self._run_process(cmd, *args, env=env) + self._run_process(cmd, *args, env=env, verbose=verbose) def on_proc_finished(self): """Interrupt the reader when the process finished.""" - log.procs.debug("Userscript process finished.") self.finish() def on_proc_error(self, error): """Interrupt the reader when the process had an error.""" - super().on_proc_error(error) self.finish() def finish(self): @@ -260,7 +236,6 @@ class _WindowsUserscriptRunner(_BaseUserscriptRunner): def on_proc_finished(self): """Read back the commands when the process finished.""" - log.procs.debug("Userscript process finished.") try: with open(self._filepath, 'r', encoding='utf-8') as f: for line in f: @@ -272,18 +247,17 @@ class _WindowsUserscriptRunner(_BaseUserscriptRunner): def on_proc_error(self, error): """Clean up when the process had an error.""" - super().on_proc_error(error) self._cleanup() self.finished.emit() - def run(self, cmd, *args, env=None): + def run(self, cmd, *args, env=None, verbose=False): try: self._oshandle, self._filepath = tempfile.mkstemp(text=True) except OSError as e: message.error(self._win_id, "Error while creating tempfile: " "{}".format(e)) return - self._run_process(cmd, *args, env=env) + self._run_process(cmd, *args, env=env, verbose=verbose) class _DummyUserscriptRunner: @@ -299,8 +273,9 @@ class _DummyUserscriptRunner: finished = pyqtSignal() - def run(self, _cmd, *_args, _env=None): + def run(self, cmd, *args, env=None, verbose=False): """Print an error as userscripts are not supported.""" + # pylint: disable=unused-argument,unused-variable self.finished.emit() raise cmdexc.CommandError( "Userscripts are not supported on this platform!") @@ -347,7 +322,7 @@ def store_source(frame): return env -def run(cmd, *args, win_id, env): +def run(cmd, *args, win_id, env, verbose=False): """Convenience method to run an userscript. Args: @@ -355,6 +330,7 @@ def run(cmd, *args, win_id, env): *args: The arguments to pass to the userscript. win_id: The window id the userscript is executed in. env: A dictionary of variables to add to the process environment. + verbose: Show notifications when the command started/exited. """ tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) @@ -367,6 +343,6 @@ def run(cmd, *args, win_id, env): user_agent = config.get('network', 'user-agent') if user_agent is not None: env['QUTE_USER_AGENT'] = user_agent - runner.run(cmd, *args, env=env) + runner.run(cmd, *args, env=env, verbose=verbose) runner.finished.connect(commandrunner.deleteLater) runner.finished.connect(runner.deleteLater) diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 2fd1858ca..945f3636f 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -19,7 +19,7 @@ """Completer attached to a CompletionView.""" -from PyQt5.QtCore import pyqtSlot, QObject, QTimer +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QTimer from qutebrowser.config import config from qutebrowser.commands import cmdutils, runners @@ -40,14 +40,22 @@ class Completer(QObject): _last_cursor_pos: The old cursor position so we avoid double completion updates. _last_text: The old command text so we avoid double completion updates. + _signals_connected: Whether the signals are connected to update the + completion when the command widget requests that. + + Signals: + next_prev_item: Emitted to select the next/previous item in the + completion. + arg0: True for the previous item, False for the next. """ + next_prev_item = pyqtSignal(bool) + def __init__(self, cmd, win_id, parent=None): super().__init__(parent) self._win_id = win_id self._cmd = cmd - self._cmd.update_completion.connect(self.schedule_completion_update) - self._cmd.textEdited.connect(self.on_text_edited) + self._signals_connected = False self._ignore_change = False self._empty_item_idx = None self._timer = QTimer() @@ -58,9 +66,63 @@ class Completer(QObject): self._last_cursor_pos = None self._last_text = None + objreg.get('config').changed.connect(self.on_auto_open_changed) + self.handle_signal_connections() + self._cmd.clear_completion_selection.connect( + self.handle_signal_connections) + def __repr__(self): return utils.get_repr(self) + @config.change_filter('completion', 'auto-open') + def on_auto_open_changed(self): + self.handle_signal_connections() + + @pyqtSlot() + def handle_signal_connections(self): + self._connect_signals(config.get('completion', 'auto-open')) + + def _connect_signals(self, connect=True): + """Connect or disconnect the completion signals. + + Args: + connect: Whether to connect (True) or disconnect (False) the + signals. + + Return: + True if the signals were connected (connect=True and aren't + connected yet) - otherwise False. + """ + connections = [ + (self._cmd.update_completion, self.schedule_completion_update), + (self._cmd.textChanged, self.on_text_edited), + ] + + if connect and not self._signals_connected: + for sender, receiver in connections: + sender.connect(receiver) + self._signals_connected = True + return True + elif not connect: + for sender, receiver in connections: + try: + sender.disconnect(receiver) + except TypeError: + # Don't fail if not connected + pass + self._signals_connected = False + return False + + def _open_completion_if_needed(self): + """If auto-open is false, temporarily connect signals. + + Also opens the completion. + """ + if not config.get('completion', 'auto-open'): + connected = self._connect_signals(True) + if connected: + self.update_completion() + def _model(self): """Convienience method to get the current completion model.""" completion = objreg.get('completion', scope='window', @@ -272,7 +334,7 @@ class Completer(QObject): pattern = parts[self._cursor_part].strip() except IndexError: pattern = '' - self._model().set_pattern(pattern) + completion.set_pattern(pattern) log.completion.debug( "New completion for {}: {}, with pattern '{}'".format( @@ -328,7 +390,7 @@ class Completer(QObject): cursor_pos)) skip = 0 for i, part in enumerate(parts): - log.completion.vdebug("Checking part {}: {}".format(i, parts[i])) + log.completion.vdebug("Checking part {}: {!r}".format(i, parts[i])) if not part: skip += 1 continue @@ -350,7 +412,11 @@ class Completer(QObject): "Removing len({!r}) -> {} from cursor_pos -> {}".format( part, len(part), cursor_pos)) else: - self._cursor_part = i - skip + if i == 0: + # Initial `:` press without any text. + self._cursor_part = 0 + else: + self._cursor_part = i - skip if spaces: self._empty_item_idx = i - skip else: @@ -401,3 +467,17 @@ class Completer(QObject): # We also want to update the cursor part and emit update_completion # here, but that's already done for us by cursorPositionChanged # anyways, so we don't need to do it twice. + + @cmdutils.register(instance='completer', hide=True, + modes=[usertypes.KeyMode.command], scope='window') + def completion_item_prev(self): + """Select the previous completion item.""" + self._open_completion_if_needed() + self.next_prev_item.emit(True) + + @cmdutils.register(instance='completer', hide=True, + modes=[usertypes.KeyMode.command], scope='window') + def completion_item_next(self): + """Select the next completion item.""" + self._open_completion_if_needed() + self.next_prev_item.emit(False) diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py index 03caa5158..3fe7e00ba 100644 --- a/qutebrowser/completion/completiondelegate.py +++ b/qutebrowser/completion/completiondelegate.py @@ -145,7 +145,6 @@ class CompletionItemDelegate(QStyledItemDelegate): rect: The QRect to clip the drawing to. """ # We can't use drawContents because then the color would be ignored. - # See: https://qt-project.org/forums/viewthread/21492 clip = QRectF(0, 0, rect.width(), rect.height()) self._painter.save() if self._opt.state & QStyle.State_Selected: diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index aa2fd31da..a3bea931a 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -26,10 +26,9 @@ subclasses to provide completions. from PyQt5.QtWidgets import QStyle, QTreeView, QSizePolicy from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel -from qutebrowser.commands import cmdutils from qutebrowser.config import config, style from qutebrowser.completion import completiondelegate, completer -from qutebrowser.utils import usertypes, qtutils, objreg, utils +from qutebrowser.utils import qtutils, objreg, utils class CompletionView(QTreeView): @@ -96,12 +95,13 @@ class CompletionView(QTreeView): objreg.register('completion', self, scope='window', window=win_id) cmd = objreg.get('status-command', scope='window', window=win_id) completer_obj = completer.Completer(cmd, win_id, self) + completer_obj.next_prev_item.connect(self.on_next_prev_item) objreg.register('completer', completer_obj, scope='window', window=win_id) self.enabled = config.get('completion', 'show') objreg.get('config').changed.connect(self.set_enabled) # FIXME handle new aliases. - #objreg.get('config').changed.connect(self.init_command_completion) + # objreg.get('config').changed.connect(self.init_command_completion) self._delegate = completiondelegate.CompletionItemDelegate(self) self.setItemDelegate(self._delegate) @@ -168,12 +168,15 @@ class CompletionView(QTreeView): # Item is a real item, not a category header -> success return idx - def _next_prev_item(self, prev): + @pyqtSlot(bool) + def on_next_prev_item(self, prev): """Handle a tab press for the CompletionView. Select the previous/next item and write the new text to the statusbar. + Called from the Completer's next_prev_item signal. + Args: prev: True for prev item, False for next one. """ @@ -201,8 +204,17 @@ class CompletionView(QTreeView): for i in range(model.rowCount()): self.expand(model.index(i, 0)) self._resize_columns() - model.rowsRemoved.connect(self.maybe_resize_completion) - model.rowsInserted.connect(self.maybe_resize_completion) + self.maybe_resize_completion() + + def set_pattern(self, pattern): + """Set the completion pattern for the current model. + + Called from on_update_completion(). + + Args: + pattern: The filter pattern to set (what the user entered). + """ + self.model().set_pattern(pattern) self.maybe_resize_completion() @pyqtSlot() @@ -224,18 +236,6 @@ class CompletionView(QTreeView): selmod.clearSelection() selmod.clearCurrentIndex() - @cmdutils.register(instance='completion', hide=True, - modes=[usertypes.KeyMode.command], scope='window') - def completion_item_prev(self): - """Select the previous completion item.""" - self._next_prev_item(prev=True) - - @cmdutils.register(instance='completion', hide=True, - modes=[usertypes.KeyMode.command], scope='window') - def completion_item_next(self): - """Select the next completion item.""" - self._next_prev_item(prev=False) - def selectionChanged(self, selected, deselected): """Extend selectionChanged to call completers selection_changed.""" super().selectionChanged(selected, deselected) diff --git a/qutebrowser/completion/models/instances.py b/qutebrowser/completion/models/instances.py index 85998357f..e39ca1692 100644 --- a/qutebrowser/completion/models/instances.py +++ b/qutebrowser/completion/models/instances.py @@ -165,6 +165,11 @@ def init(): quickmark_manager.changed.connect( functools.partial(update, [usertypes.Completion.quickmark_by_url, usertypes.Completion.quickmark_by_name])) + session_manager = objreg.get('session-manager') session_manager.update_completion.connect( functools.partial(update, [usertypes.Completion.sessions])) + + history = objreg.get('web-history') + history.async_read_done.connect( + functools.partial(update, [usertypes.Completion.url])) diff --git a/qutebrowser/completion/models/urlmodel.py b/qutebrowser/completion/models/urlmodel.py index e8898ec85..1eb7e13d2 100644 --- a/qutebrowser/completion/models/urlmodel.py +++ b/qutebrowser/completion/models/urlmodel.py @@ -54,7 +54,7 @@ class UrlCompletionModel(base.BaseCompletionModel): history = utils.newest_slice(self._history, max_history) for entry in history: self._add_history_entry(entry) - self._history.item_about_to_be_added.connect( + self._history.add_completion_item.connect( self.on_history_item_added) objreg.get('config').changed.connect(self.reformat_timestamps) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index e195310ef..b735f8274 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -252,6 +252,25 @@ def init(parent=None): _init_misc() +def _get_value_transformer(old, new): + """Get a function which transforms a value for CHANGED_OPTIONS. + + Args: + old: The old value - if the supplied value doesn't match this, it's + returned untransformed. + new: The new value. + + Return: + A function which takes a value and transforms it. + """ + def transformer(val): + if val == old: + return new + else: + return val + return transformer + + class ConfigManager(QObject): """Configuration manager for qutebrowser. @@ -263,6 +282,10 @@ class ConfigManager(QObject): RENAMED_SECTIONS: A mapping of renamed sections, {'oldname': 'newname'} RENAMED_OPTIONS: A mapping of renamed options, {('section', 'oldname'): 'newname'} + CHANGED_OPTIONS: A mapping of arbitrarily changed options, + {('section', 'option'): callable}. + The callable takes the old value and returns the new + one. DELETED_OPTIONS: A (section, option) list of deleted options. Attributes: @@ -298,12 +321,17 @@ class ConfigManager(QObject): ('colors', 'tab.indicator.system'): 'tabs.indicator.system', ('tabs', 'auto-hide'): 'hide-auto', ('completion', 'history-length'): 'cmd-history-max-items', + ('colors', 'downloads.fg'): 'downloads.fg.start', } DELETED_OPTIONS = [ ('colors', 'tab.separator'), ('colors', 'tabs.separator'), ('colors', 'completion.item.bg'), ] + CHANGED_OPTIONS = { + ('content', 'cookies-accept'): + _get_value_transformer('default', 'no-3rdparty'), + } changed = pyqtSignal(str, str) style_changed = pyqtSignal(str, str) @@ -462,10 +490,15 @@ class ConfigManager(QObject): for k, v in cp[real_sectname].items(): if k.startswith(self.ESCAPE_CHAR): k = k[1:] + if (sectname, k) in self.DELETED_OPTIONS: return - elif (sectname, k) in self.RENAMED_OPTIONS: + if (sectname, k) in self.RENAMED_OPTIONS: k = self.RENAMED_OPTIONS[sectname, k] + if (sectname, k) in self.CHANGED_OPTIONS: + func = self.CHANGED_OPTIONS[(sectname, k)] + v = func(v) + try: self.set('conf', sectname, k, v, validate=False) except configexc.NoOptionError: diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 0e1cf9b9c..601bc8205 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -100,9 +100,13 @@ SECTION_DESC = { " * `rgb(r, g, b)` / `rgba(r, g, b, a)` (values 0-255 or " "percentages)\n" " * `hsv(h, s, v)` / `hsva(h, s, v, a)` (values 0-255, hue 0-359)\n" - " * A gradient as explained in http://qt-project.org/doc/qt-4.8/" + " * A gradient as explained in http://doc.qt.io/qt-5/" "stylesheet-reference.html#list-of-property-types[the Qt " "documentation] under ``Gradient''.\n\n" + "A *.system value determines the color system to use for color " + "interpolation between similarly-named *.start and *.stop entries, " + "regardless of how they are defined in the options. " + "Valid values are 'rgb', 'hsv', and 'hsl'.\n\n" "The `hints.*` values are a special case as they're real CSS " "colors, not Qt-CSS colors. There, for a gradient, you need to use " "`-webkit-gradient`, see https://www.webkit.org/blog/175/introducing-" @@ -204,7 +208,7 @@ def data(readonly=False): "be used."), ('new-instance-open-target', - SettingValue(typ.NewInstanceOpenTarget(), 'window'), + SettingValue(typ.NewInstanceOpenTarget(), 'tab'), "How to open links in an existing instance if a new one is " "launched."), @@ -269,8 +273,9 @@ def data(readonly=False): ('user-stylesheet', SettingValue(typ.UserStyleSheet(), '::-webkit-scrollbar { width: 0px; height: 0px; }'), - "User stylesheet to use (absolute filename or CSS string). Will " - "expand environment variables."), + "User stylesheet to use (absolute filename, filename relative to " + "the config directory or CSS string). Will expand environment " + "variables."), ('css-media-type', SettingValue(typ.String(none_ok=True), ''), @@ -351,6 +356,10 @@ def data(readonly=False): )), ('completion', sect.KeyValue( + ('auto-open', + SettingValue(typ.Bool(), 'true'), + "Automatically open completion when typing."), + ('download-path-suggestion', SettingValue(typ.DownloadPathSuggestion(), 'path'), "What to display in the download filename input."), @@ -468,7 +477,7 @@ def data(readonly=False): ('last-close', SettingValue(typ.LastClose(), 'ignore'), - "Behaviour when the last tab is closed."), + "Behavior when the last tab is closed."), ('hide-auto', SettingValue(typ.Bool(), 'false'), @@ -678,8 +687,8 @@ def data(readonly=False): "local urls."), ('cookies-accept', - SettingValue(typ.AcceptCookies(), 'default'), - "Whether to accept cookies."), + SettingValue(typ.AcceptCookies(), 'no-3rdparty'), + "Control which cookies to accept."), ('cookies-store', SettingValue(typ.Bool(), 'true'), @@ -744,7 +753,8 @@ def data(readonly=False): ('next-regexes', SettingValue(typ.RegexList(flags=re.IGNORECASE), - r'\bnext\b,\bmore\b,\bnewer\b,\b[>→≫]\b,\b(>>|»)\b'), + r'\bnext\b,\bmore\b,\bnewer\b,\b[>→≫]\b,\b(>>|»)\b,' + r'\bcontinue\b'), "A comma-separated list of regexes to use for 'next' links."), ('prev-regexes', @@ -820,34 +830,67 @@ def data(readonly=False): SettingValue(typ.QssColor(), '#ff4444'), "Foreground color of the matched text in the completion."), + ('statusbar.fg', + SettingValue(typ.QssColor(), 'white'), + "Foreground color of the statusbar."), + ('statusbar.bg', SettingValue(typ.QssColor(), 'black'), "Foreground color of the statusbar."), - ('statusbar.fg', - SettingValue(typ.QssColor(), 'white'), - "Foreground color of the statusbar."), + ('statusbar.fg.error', + SettingValue(typ.QssColor(), '${statusbar.fg}'), + "Foreground color of the statusbar if there was an error."), ('statusbar.bg.error', SettingValue(typ.QssColor(), 'red'), "Background color of the statusbar if there was an error."), + ('statusbar.fg.warning', + SettingValue(typ.QssColor(), '${statusbar.fg}'), + "Foreground color of the statusbar if there is a warning."), + ('statusbar.bg.warning', SettingValue(typ.QssColor(), 'darkorange'), "Background color of the statusbar if there is a warning."), + ('statusbar.fg.prompt', + SettingValue(typ.QssColor(), '${statusbar.fg}'), + "Foreground color of the statusbar if there is a prompt."), + ('statusbar.bg.prompt', SettingValue(typ.QssColor(), 'darkblue'), "Background color of the statusbar if there is a prompt."), + ('statusbar.fg.insert', + SettingValue(typ.QssColor(), '${statusbar.fg}'), + "Foreground color of the statusbar in insert mode."), + ('statusbar.bg.insert', SettingValue(typ.QssColor(), 'darkgreen'), "Background color of the statusbar in insert mode."), + ('statusbar.fg.command', + SettingValue(typ.QssColor(), '${statusbar.fg}'), + "Foreground color of the statusbar in command mode."), + + ('statusbar.bg.command', + SettingValue(typ.QssColor(), '${statusbar.bg}'), + "Background color of the statusbar in command mode."), + + ('statusbar.fg.caret', + SettingValue(typ.QssColor(), '${statusbar.fg}'), + "Foreground color of the statusbar in caret mode."), + ('statusbar.bg.caret', SettingValue(typ.QssColor(), 'purple'), "Background color of the statusbar in caret mode."), + ('statusbar.fg.caret-selection', + SettingValue(typ.QssColor(), '${statusbar.fg}'), + "Foreground color of the statusbar in caret mode with a " + "selection"), + ('statusbar.bg.caret-selection', SettingValue(typ.QssColor(), '#a12dff'), "Background color of the statusbar in caret mode with a " @@ -884,22 +927,22 @@ def data(readonly=False): SettingValue(typ.QtColor(), 'white'), "Foreground color of unselected odd tabs."), - ('tabs.fg.even', - SettingValue(typ.QtColor(), 'white'), - "Foreground color of unselected even tabs."), - - ('tabs.fg.selected', - SettingValue(typ.QtColor(), 'white'), - "Foreground color of selected tabs."), - ('tabs.bg.odd', SettingValue(typ.QtColor(), 'grey'), "Background color of unselected odd tabs."), + ('tabs.fg.even', + SettingValue(typ.QtColor(), 'white'), + "Foreground color of unselected even tabs."), + ('tabs.bg.even', SettingValue(typ.QtColor(), 'darkgrey'), "Background color of unselected even tabs."), + ('tabs.fg.selected', + SettingValue(typ.QtColor(), 'white'), + "Foreground color of selected tabs."), + ('tabs.bg.selected', SettingValue(typ.QtColor(), 'black'), "Background color of selected tabs."), @@ -928,10 +971,6 @@ def data(readonly=False): SettingValue(typ.CssColor(), 'black'), "Font color for hints."), - ('hints.fg.match', - SettingValue(typ.CssColor(), 'green'), - "Font color for the matched part of hints."), - ('hints.bg', SettingValue( typ.CssColor(), '-webkit-gradient(linear, left top, ' @@ -939,25 +978,41 @@ def data(readonly=False): 'color-stop(100%,#FFC542))'), "Background color for hints."), - ('downloads.fg', - SettingValue(typ.QtColor(), '#ffffff'), - "Foreground color for downloads."), + ('hints.fg.match', + SettingValue(typ.CssColor(), 'green'), + "Font color for the matched part of hints."), ('downloads.bg.bar', SettingValue(typ.QssColor(), 'black'), "Background color for the download bar."), + ('downloads.fg.start', + SettingValue(typ.QtColor(), 'white'), + "Color gradient start for download text."), + ('downloads.bg.start', SettingValue(typ.QtColor(), '#0000aa'), - "Color gradient start for downloads."), + "Color gradient start for download backgrounds."), + + ('downloads.fg.stop', + SettingValue(typ.QtColor(), '${downloads.fg.start}'), + "Color gradient end for download text."), ('downloads.bg.stop', SettingValue(typ.QtColor(), '#00aa00'), - "Color gradient end for downloads."), + "Color gradient stop for download backgrounds."), + + ('downloads.fg.system', + SettingValue(typ.ColorSystem(), 'rgb'), + "Color gradient interpolation system for download text."), ('downloads.bg.system', SettingValue(typ.ColorSystem(), 'rgb'), - "Color gradient interpolation system for downloads."), + "Color gradient interpolation system for download backgrounds."), + + ('downloads.fg.error', + SettingValue(typ.QtColor(), 'white'), + "Foreground color for downloads with errors."), ('downloads.bg.error', SettingValue(typ.QtColor(), 'red'), @@ -1147,7 +1202,7 @@ KEY_DATA = collections.OrderedDict([ ])), ('normal', collections.OrderedDict([ - ('search', ['']), + ('search ;; clear-keychain', ['']), ('set-cmd-text -s :open', ['o']), ('set-cmd-text :open {url}', ['go']), ('set-cmd-text -s :open -t', ['O']), @@ -1208,6 +1263,8 @@ KEY_DATA = collections.OrderedDict([ ('yank -s', ['yY']), ('yank -t', ['yt']), ('yank -ts', ['yT']), + ('yank -d', ['yd']), + ('yank -ds', ['yD']), ('paste', ['pp']), ('paste -s', ['pP']), ('paste -t', ['Pp']), @@ -1299,7 +1356,7 @@ KEY_DATA = collections.OrderedDict([ ('rl-unix-line-discard', ['']), ('rl-kill-line', ['']), ('rl-kill-word', ['']), - ('rl-unix-word-rubout', ['']), + ('rl-unix-word-rubout', ['', '']), ('rl-yank', ['']), ('rl-delete-char', ['']), ('rl-backward-delete-char', ['']), @@ -1342,8 +1399,8 @@ CHANGED_KEY_COMMANDS = [ (re.compile(r'^download-page$'), r'download'), (re.compile(r'^cancel-download$'), r'download-cancel'), - (re.compile(r'^search ""$'), r'search'), - (re.compile(r"^search ''$"), r'search'), + (re.compile(r"""^search (''|"")$"""), r'search ;; clear-keychain'), + (re.compile(r'^search$'), r'search ;; clear-keychain'), (re.compile(r"""^set-cmd-text ['"](.*) ['"]$"""), r'set-cmd-text -s \1'), (re.compile(r"""^set-cmd-text ['"](.*)['"]$"""), r'set-cmd-text \1'), diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index cb1bc744a..609747322 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -34,6 +34,7 @@ from PyQt5.QtWidgets import QTabWidget, QTabBar from qutebrowser.commands import cmdutils from qutebrowser.config import configexc +from qutebrowser.utils import standarddir SYSTEM_PROXY = object() # Return value for Proxy type @@ -798,6 +799,17 @@ class File(BaseType): typestr = 'file' + def transform(self, value): + if not value: + return None + value = os.path.expanduser(value) + value = os.path.expandvars(value) + if not os.path.isabs(value): + cfgdir = standarddir.config() + if cfgdir is not None: + return os.path.join(cfgdir, value) + return value + def validate(self, value): if not value: if self._none_ok: @@ -805,20 +817,26 @@ class File(BaseType): else: raise configexc.ValidationError(value, "may not be empty!") value = os.path.expanduser(value) + value = os.path.expandvars(value) try: - if not os.path.isfile(value): - raise configexc.ValidationError(value, "must be a valid file!") if not os.path.isabs(value): + cfgdir = standarddir.config() + if cfgdir is None: + raise configexc.ValidationError( + value, "must be an absolute path when not using a " + "config directory!") + elif not os.path.isfile(os.path.join(cfgdir, value)): + raise configexc.ValidationError( + value, "must be a valid path relative to the config " + "directory!") + else: + return + elif not os.path.isfile(value): raise configexc.ValidationError( - value, "must be an absolute path!") + value, "must be a valid file!") except UnicodeEncodeError as e: raise configexc.ValidationError(value, e) - def transform(self, value): - if not value: - return None - return os.path.expanduser(value) - class Directory(BaseType): @@ -1092,8 +1110,15 @@ class SearchEngineUrl(BaseType): return else: raise configexc.ValidationError(value, "may not be empty!") + if '{}' not in value: raise configexc.ValidationError(value, "must contain \"{}\"") + try: + value.format("") + except KeyError: + raise configexc.ValidationError( + value, "may not contain {...} (use {{ and }} for literal {/})") + url = QUrl(value.replace('{}', 'foobar')) if not url.isValid(): raise configexc.ValidationError(value, "invalid url, {}".format( @@ -1151,6 +1176,16 @@ class UserStyleSheet(File): def __init__(self): super().__init__(none_ok=True) + def transform(self, value): + if not value: + return None + path = super().transform(value) + if os.path.exists(path): + return QUrl.fromLocalFile(path) + else: + data = base64.b64encode(value.encode('utf-8')).decode('ascii') + return QUrl("data:text/css;charset=utf-8;base64,{}".format(data)) + def validate(self, value): if not value: if self._none_ok: @@ -1160,31 +1195,17 @@ class UserStyleSheet(File): value = os.path.expandvars(value) value = os.path.expanduser(value) try: - if not os.path.isabs(value): - # probably a CSS, so we don't handle it as filename. - # FIXME We just try if it is encodable, maybe we should - # validate CSS? - # https://github.com/The-Compiler/qutebrowser/issues/115 - try: + super().validate(value) + except configexc.ValidationError: + try: + if not os.path.isabs(value): + # probably a CSS, so we don't handle it as filename. + # FIXME We just try if it is encodable, maybe we should + # validate CSS? + # https://github.com/The-Compiler/qutebrowser/issues/115 value.encode('utf-8') - except UnicodeEncodeError as e: - raise configexc.ValidationError(value, str(e)) - return - elif not os.path.isfile(value): - raise configexc.ValidationError(value, "must be a valid file!") - except UnicodeEncodeError as e: - raise configexc.ValidationError(value, e) - - def transform(self, value): - path = os.path.expandvars(value) - path = os.path.expanduser(path) - if not value: - return None - elif os.path.isabs(path): - return QUrl.fromLocalFile(path) - else: - data = base64.b64encode(value.encode('utf-8')).decode('ascii') - return QUrl("data:text/css;charset=utf-8;base64,{}".format(data)) + except UnicodeEncodeError as e: + raise configexc.ValidationError(value, str(e)) class AutoSearch(BaseType): @@ -1312,7 +1333,7 @@ class SelectOnRemove(BaseType): class LastClose(BaseType): - """Behaviour when the last tab is closed.""" + """Behavior when the last tab is closed.""" valid_values = ValidValues(('ignore', "Don't do anything."), ('blank', "Load a blank page."), @@ -1323,9 +1344,14 @@ class LastClose(BaseType): class AcceptCookies(BaseType): - """Whether to accept a cookie.""" + """Control which cookies to accept.""" - valid_values = ValidValues(('default', "Default QtWebKit behavior."), + valid_values = ValidValues(('all', "Accept all cookies."), + ('no-3rdparty', "Accept cookies from the same" + " origin only."), + ('no-unknown-3rdparty', "Accept cookies from " + "the same origin only, unless a cookie is " + "already set for the domain."), ('never', "Don't accept cookies at all.")) diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 117abbb26..c3216aae2 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -238,6 +238,25 @@ class GlobalSetter(Setter): self._setter(*args) +class CookiePolicy(Base): + + """The ThirdPartyCookiePolicy setting is different from other settings.""" + + MAPPING = { + 'all': QWebSettings.AlwaysAllowThirdPartyCookies, + 'no-3rdparty': QWebSettings.AlwaysBlockThirdPartyCookies, + 'never': QWebSettings.AlwaysBlockThirdPartyCookies, + 'no-unknown-3rdparty': QWebSettings.AllowThirdPartyWithExistingCookies, + } + + def get(self, qws=None): + return config.get('content', 'cookies-accept') + + def _set(self, value, qws=None): + QWebSettings.globalSettings().setThirdPartyCookiePolicy( + self.MAPPING[value]) + + MAPPINGS = { 'content': { 'allow-images': @@ -264,6 +283,8 @@ MAPPINGS = { Attribute(QWebSettings.LocalContentCanAccessRemoteUrls), 'local-content-can-access-file-urls': Attribute(QWebSettings.LocalContentCanAccessFileUrls), + 'cookies-accept': + CookiePolicy(), }, 'network': { 'dns-prefetch': diff --git a/qutebrowser/html/settings.html b/qutebrowser/html/settings.html index c4fbdcc59..57a0502ba 100644 --- a/qutebrowser/html/settings.html +++ b/qutebrowser/html/settings.html @@ -14,10 +14,12 @@ pre { margin: 2px; } th, td { border: 1px solid grey; padding: 0px 5px; } th { background: lightgrey; } th pre { color: grey; text-align: left; } +.noscript, .noscript-text { color:red; } +.noscript-text { margin-bottom: 5cm; } {% endblock %} {% block content %} - +

{{ title }}

{% for section in config.DATA %} diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index b52a39824..56b9cfaac 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -23,7 +23,7 @@ import re import functools import unicodedata -from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QObject +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject from qutebrowser.config import config from qutebrowser.utils import usertypes, log, utils, objreg @@ -49,6 +49,8 @@ class BaseKeyParser(QObject): special: execute() was called via a special key binding do_log: Whether to log keypresses or not. + passthrough: Whether unbound keys should be passed through with this + handler. Attributes: bindings: Bound key bindings @@ -69,6 +71,7 @@ class BaseKeyParser(QObject): keystring_updated = pyqtSignal(str) do_log = True + passthrough = False Match = usertypes.enum('Match', ['partial', 'definitive', 'ambiguous', 'other', 'none']) @@ -162,12 +165,6 @@ class BaseKeyParser(QObject): key = e.key() self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) - if key == Qt.Key_Escape: - self._debug_log("Escape pressed, discarding '{}'.".format( - self._keystring)) - self._keystring = '' - return self.Match.none - if len(txt) == 1: category = unicodedata.category(txt) is_control_char = (category == 'Cc') @@ -198,7 +195,7 @@ class BaseKeyParser(QObject): self._keystring = '' self.execute(binding, self.Type.chain, count) elif match == self.Match.ambiguous: - self._debug_log("Ambigious match for '{}'.".format( + self._debug_log("Ambiguous match for '{}'.".format( self._keystring)) self._handle_ambiguous_match(binding, count) elif match == self.Match.partial: @@ -303,6 +300,7 @@ class BaseKeyParser(QObject): True if the event was handled, False otherwise. """ handled = self._handle_special_key(e) + if handled or not self._supports_chains: return handled match = self._handle_single_key(e) @@ -359,3 +357,9 @@ class BaseKeyParser(QObject): "defined!") if mode == self._modename: self.read_config() + + def clear_keystring(self): + """Clear the currently entered key sequence.""" + self._debug_log("discarding keystring '{}'.".format(self._keystring)) + self._keystring = '' + self.keystring_updated.emit(self._keystring) diff --git a/qutebrowser/keyinput/keyparser.py b/qutebrowser/keyinput/keyparser.py index bef364e66..46f179fdb 100644 --- a/qutebrowser/keyinput/keyparser.py +++ b/qutebrowser/keyinput/keyparser.py @@ -55,6 +55,7 @@ class PassthroughKeyParser(CommandKeyParser): """ do_log = False + passthrough = True def __init__(self, win_id, mode, parent=None, warn=True): """Constructor. diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index fc70ac76b..6906a8720 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -84,38 +84,30 @@ def init(win_id, parent): modeman.destroyed.connect( functools.partial(objreg.delete, 'keyparsers', scope='window', window=win_id)) - modeman.register(KM.normal, keyparsers[KM.normal].handle) - modeman.register(KM.hint, keyparsers[KM.hint].handle) - modeman.register(KM.insert, keyparsers[KM.insert].handle, passthrough=True) - modeman.register(KM.passthrough, keyparsers[KM.passthrough].handle, - passthrough=True) - modeman.register(KM.command, keyparsers[KM.command].handle, - passthrough=True) - modeman.register(KM.prompt, keyparsers[KM.prompt].handle, passthrough=True) - modeman.register(KM.yesno, keyparsers[KM.yesno].handle) - modeman.register(KM.caret, keyparsers[KM.caret].handle, passthrough=True) + for mode, parser in keyparsers.items(): + modeman.register(mode, parser) return modeman -def _get_modeman(win_id): +def instance(win_id): """Get a modemanager object.""" return objreg.get('mode-manager', scope='window', window=win_id) def enter(win_id, mode, reason=None, only_if_normal=False): """Enter the mode 'mode'.""" - _get_modeman(win_id).enter(mode, reason, only_if_normal) + instance(win_id).enter(mode, reason, only_if_normal) def leave(win_id, mode, reason=None): """Leave the mode 'mode'.""" - _get_modeman(win_id).leave(mode, reason) + instance(win_id).leave(mode, reason) def maybe_leave(win_id, mode, reason=None): """Convenience method to leave 'mode' without exceptions.""" try: - _get_modeman(win_id).leave(mode, reason) + instance(win_id).leave(mode, reason) except NotInModeError as e: # This is rather likely to happen, so we only log to debug log. log.modes.debug("{} (leave reason: {})".format(e, reason)) @@ -126,10 +118,9 @@ class ModeManager(QObject): """Manager for keyboard modes. Attributes: - passthrough: A list of modes in which to pass through events. mode: The mode we're currently in. _win_id: The window ID of this ModeManager - _handlers: A dictionary of modes and their handlers. + _parsers: A dictionary of modes and their keyparsers. _forward_unbound_keys: If we should forward unbound keys. _releaseevents_to_pass: A set of KeyEvents where the keyPressEvent was passed through, so the release event should as @@ -151,8 +142,7 @@ class ModeManager(QObject): def __init__(self, win_id, parent=None): super().__init__(parent) self._win_id = win_id - self._handlers = {} - self.passthrough = [] + self._parsers = {} self.mode = usertypes.KeyMode.normal self._releaseevents_to_pass = set() self._forward_unbound_keys = config.get( @@ -160,8 +150,7 @@ class ModeManager(QObject): objreg.get('config').changed.connect(self.set_forward_unbound_keys) def __repr__(self): - return utils.get_repr(self, mode=self.mode, - passthrough=self.passthrough) + return utils.get_repr(self, mode=self.mode) def _eventFilter_keypress(self, event): """Handle filtering of KeyPress events. @@ -173,11 +162,11 @@ class ModeManager(QObject): True if event should be filtered, False otherwise. """ curmode = self.mode - handler = self._handlers[curmode] + parser = self._parsers[curmode] if curmode != usertypes.KeyMode.insert: - log.modes.debug("got keypress in mode {} - calling handler " - "{}".format(curmode, utils.qualname(handler))) - handled = handler(event) if handler is not None else False + log.modes.debug("got keypress in mode {} - delegating to " + "{}".format(curmode, utils.qualname(parser))) + handled = parser.handle(event) is_non_alnum = bool(event.modifiers()) or not event.text().strip() focus_widget = QApplication.instance().focusWidget() @@ -187,7 +176,7 @@ class ModeManager(QObject): filter_this = True elif is_tab and not isinstance(focus_widget, QWebView): filter_this = True - elif (curmode in self.passthrough or + elif (parser.passthrough or self._forward_unbound_keys == 'all' or (self._forward_unbound_keys == 'auto' and is_non_alnum)): filter_this = False @@ -202,8 +191,8 @@ class ModeManager(QObject): "passthrough: {}, is_non_alnum: {}, is_tab {} --> " "filter: {} (focused: {!r})".format( handled, self._forward_unbound_keys, - curmode in self.passthrough, is_non_alnum, - is_tab, filter_this, focus_widget)) + parser.passthrough, is_non_alnum, is_tab, + filter_this, focus_widget)) return filter_this def _eventFilter_keyrelease(self, event): @@ -226,20 +215,16 @@ class ModeManager(QObject): log.modes.debug("filter: {}".format(filter_this)) return filter_this - def register(self, mode, handler, passthrough=False): + def register(self, mode, parser): """Register a new mode. Args: mode: The name of the mode. - handler: Handler for keyPressEvents. - passthrough: Whether to pass key bindings in this mode through to - the widgets. + parser: The KeyParser which should be used. """ - if not isinstance(mode, usertypes.KeyMode): - raise TypeError("Mode {} is no KeyMode member!".format(mode)) - self._handlers[mode] = handler - if passthrough: - self.passthrough.append(mode) + assert isinstance(mode, usertypes.KeyMode) + assert parser is not None + self._parsers[mode] = parser def enter(self, mode, reason=None, only_if_normal=False): """Enter a new mode. @@ -253,8 +238,8 @@ class ModeManager(QObject): raise TypeError("Mode {} is no KeyMode member!".format(mode)) log.modes.debug("Entering mode {}{}".format( mode, '' if reason is None else ' (reason: {})'.format(reason))) - if mode not in self._handlers: - raise ValueError("No handler for mode {}".format(mode)) + if mode not in self._parsers: + raise ValueError("No keyparser for mode {}".format(mode)) prompt_modes = (usertypes.KeyMode.prompt, usertypes.KeyMode.yesno) if self.mode == mode or (self.mode in prompt_modes and mode in prompt_modes): @@ -332,3 +317,8 @@ class ModeManager(QObject): return self._eventFilter_keypress(event) else: return self._eventFilter_keyrelease(event) + + @cmdutils.register(instance='mode-manager', scope='window', hide=True) + def clear_keychain(self): + """Clear the currently entered key chain.""" + self._parsers[self.mode].clear_keystring() diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 8d47de0c1..d16734ed0 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -224,6 +224,8 @@ class CaretKeyParser(keyparser.CommandKeyParser): """KeyParser for caret mode.""" + passthrough = True + def __init__(self, win_id, parent=None): super().__init__(win_id, parent, supports_count=True, supports_chains=True) diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 4bafd76e5..d9e58802a 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -120,14 +120,6 @@ class MainWindow(QWidget): window=self.win_id) self.setWindowTitle('qutebrowser') - if geometry is not None: - self._load_geometry(geometry) - elif self.win_id == 0: - self._load_state_geometry() - else: - self._set_default_geometry() - log.init.debug("Initial main window geometry: {}".format( - self.geometry())) self._vbox = QVBoxLayout(self) self._vbox.setContentsMargins(0, 0, 0, 0) self._vbox.setSpacing(0) @@ -165,6 +157,15 @@ class MainWindow(QWidget): log.init.debug("Initializing modes...") modeman.init(self.win_id, self) + if geometry is not None: + self._load_geometry(geometry) + elif self.win_id == 0: + self._load_state_geometry() + else: + self._set_default_geometry() + log.init.debug("Initial main window geometry: {}".format( + self.geometry())) + self._connect_signals() # When we're here the statusbar might not even really exist yet, so diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py index 5f633eaaa..fa16c24a4 100644 --- a/qutebrowser/mainwindow/statusbar/bar.py +++ b/qutebrowser/mainwindow/statusbar/bar.py @@ -78,6 +78,11 @@ class StatusBar(QWidget): For some reason we need to have this as class attribute so pyqtProperty works correctly. + _command_active: If we're currently in command mode. + + For some reason we need to have this as class + attribute so pyqtProperty works correctly. + _caret_mode: The current caret mode (off/on/selection). For some reason we need to have this as class attribute @@ -97,41 +102,68 @@ class StatusBar(QWidget): _severity = None _prompt_active = False _insert_active = False + _command_active = False _caret_mode = CaretMode.off STYLESHEET = """ - QWidget#StatusBar { + + QWidget#StatusBar, + QWidget#StatusBar QLabel, + QWidget#StatusBar QLineEdit { + {{ font['statusbar'] }} {{ color['statusbar.bg'] }} + {{ color['statusbar.fg'] }} } - QWidget#StatusBar[insert_active="true"] { - {{ color['statusbar.bg.insert'] }} - } - - QWidget#StatusBar[caret_mode="on"] { + QWidget#StatusBar[caret_mode="on"], + QWidget#StatusBar[caret_mode="on"] QLabel, + QWidget#StatusBar[caret_mode="on"] QLineEdit { + {{ color['statusbar.fg.caret'] }} {{ color['statusbar.bg.caret'] }} } - QWidget#StatusBar[caret_mode="selection"] { + QWidget#StatusBar[caret_mode="selection"], + QWidget#StatusBar[caret_mode="selection"] QLabel, + QWidget#StatusBar[caret_mode="selection"] QLineEdit { + {{ color['statusbar.fg.caret-selection'] }} {{ color['statusbar.bg.caret-selection'] }} } - QWidget#StatusBar[prompt_active="true"] { - {{ color['statusbar.bg.prompt'] }} - } - - QWidget#StatusBar[severity="error"] { + QWidget#StatusBar[severity="error"], + QWidget#StatusBar[severity="error"] QLabel, + QWidget#StatusBar[severity="error"] QLineEdit { + {{ color['statusbar.fg.error'] }} {{ color['statusbar.bg.error'] }} } - QWidget#StatusBar[severity="warning"] { + QWidget#StatusBar[severity="warning"], + QWidget#StatusBar[severity="warning"] QLabel, + QWidget#StatusBar[severity="warning"] QLineEdit { + {{ color['statusbar.fg.warning'] }} {{ color['statusbar.bg.warning'] }} } - QLabel, QLineEdit { - {{ color['statusbar.fg'] }} - {{ font['statusbar'] }} + QWidget#StatusBar[prompt_active="true"], + QWidget#StatusBar[prompt_active="true"] QLabel, + QWidget#StatusBar[prompt_active="true"] QLineEdit { + {{ color['statusbar.fg.prompt'] }} + {{ color['statusbar.bg.prompt'] }} } + + QWidget#StatusBar[insert_active="true"], + QWidget#StatusBar[insert_active="true"] QLabel, + QWidget#StatusBar[insert_active="true"] QLineEdit { + {{ color['statusbar.fg.insert'] }} + {{ color['statusbar.bg.insert'] }} + } + + QWidget#StatusBar[command_active="true"], + QWidget#StatusBar[command_active="true"] QLabel, + QWidget#StatusBar[command_active="true"] QLineEdit { + {{ color['statusbar.fg.command'] }} + {{ color['statusbar.bg.command'] }} + } + """ def __init__(self, win_id, parent=None): @@ -263,6 +295,11 @@ class StatusBar(QWidget): self._prompt_active = val self.setStyleSheet(style.get_stylesheet(self.STYLESHEET)) + @pyqtProperty(bool) + def command_active(self): + """Getter for self.command_active, so it can be used as Qt property.""" + return self._command_active + @pyqtProperty(bool) def insert_active(self): """Getter for self.insert_active, so it can be used as Qt property.""" @@ -274,7 +311,7 @@ class StatusBar(QWidget): return self._caret_mode.name def set_mode_active(self, mode, val): - """Setter for self.{insert,caret}_active. + """Setter for self.{insert,command,caret}_active. Re-set the stylesheet after setting the value, so everything gets updated by Qt properly. @@ -282,6 +319,9 @@ class StatusBar(QWidget): if mode == usertypes.KeyMode.insert: log.statusbar.debug("Setting insert_active to {}".format(val)) self._insert_active = val + if mode == usertypes.KeyMode.command: + log.statusbar.debug("Setting command_active to {}".format(val)) + self._command_active = val elif mode == usertypes.KeyMode.caret: webview = objreg.get('tabbed-browser', scope='window', window=self._win_id).currentWidget() @@ -469,24 +509,28 @@ class StatusBar(QWidget): @pyqtSlot(usertypes.KeyMode) def on_mode_entered(self, mode): """Mark certain modes in the commandline.""" - mode_manager = objreg.get('mode-manager', scope='window', - window=self._win_id) - if mode in mode_manager.passthrough: + keyparsers = objreg.get('keyparsers', scope='window', + window=self._win_id) + if keyparsers[mode].passthrough: self._set_mode_text(mode.name) - if mode in (usertypes.KeyMode.insert, usertypes.KeyMode.caret): + if mode in (usertypes.KeyMode.insert, + usertypes.KeyMode.command, + usertypes.KeyMode.caret): self.set_mode_active(mode, True) @pyqtSlot(usertypes.KeyMode, usertypes.KeyMode) def on_mode_left(self, old_mode, new_mode): """Clear marked mode.""" - mode_manager = objreg.get('mode-manager', scope='window', - window=self._win_id) - if old_mode in mode_manager.passthrough: - if new_mode in mode_manager.passthrough: + keyparsers = objreg.get('keyparsers', scope='window', + window=self._win_id) + if keyparsers[old_mode].passthrough: + if keyparsers[new_mode].passthrough: self._set_mode_text(new_mode.name) else: self.txt.set_text(self.txt.Text.normal, '') - if old_mode in (usertypes.KeyMode.insert, usertypes.KeyMode.caret): + if old_mode in (usertypes.KeyMode.insert, + usertypes.KeyMode.command, + usertypes.KeyMode.caret): self.set_mode_active(old_mode, False) @config.change_filter('ui', 'message-timeout') diff --git a/qutebrowser/mainwindow/statusbar/textbase.py b/qutebrowser/mainwindow/statusbar/textbase.py index 1006effe7..5a5954f85 100644 --- a/qutebrowser/mainwindow/statusbar/textbase.py +++ b/qutebrowser/mainwindow/statusbar/textbase.py @@ -70,7 +70,7 @@ class TextBase(QLabel): More info: http://stackoverflow.com/q/21890462/2085149 - https://bugreports.qt-project.org/browse/QTBUG-36945 + https://bugreports.qt.io/browse/QTBUG-36945 https://codereview.qt-project.org/#/c/79181/ Args: diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index d54b22d0c..7b4d84b7b 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -332,7 +332,7 @@ class TabbedBrowser(tabwidget.TabWidget): the default settings we handle it like Chromium does: - Tabs from clicked links etc. are to the right of the current. - - Explicitely opened tabs are at the very right. + - Explicitly opened tabs are at the very right. Return: The opened WebView instance. diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index bbbfdf045..a5612a8b5 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -391,9 +391,9 @@ class TabBar(QTabBar): def paintEvent(self, _e): """Override paintEvent to draw the tabs like we want to.""" p = QStylePainter(self) - tab = QStyleOptionTab() selected = self.currentIndex() for idx in range(self.count()): + tab = QStyleOptionTab() self.initStyleOption(tab, idx) if idx == selected: bg_color = config.get('colors', 'tabs.bg.selected') diff --git a/qutebrowser/misc/crashdialog.py b/qutebrowser/misc/crashdialog.py index dbb473b4e..64cccde83 100644 --- a/qutebrowser/misc/crashdialog.py +++ b/qutebrowser/misc/crashdialog.py @@ -24,9 +24,8 @@ import sys import html import getpass import traceback -import distutils.version # pylint: disable=no-name-in-module,import-error -# https://bitbucket.org/logilab/pylint/issue/73/ +import pkg_resources from PyQt5.QtCore import pyqtSlot, Qt, QSize, qVersion from PyQt5.QtWidgets import (QDialog, QLabel, QTextEdit, QPushButton, QVBoxLayout, QHBoxLayout, QCheckBox, @@ -328,8 +327,8 @@ class _CrashDialog(QDialog): """ # pylint: disable=no-member # https://bitbucket.org/logilab/pylint/issue/73/ - new_version = distutils.version.StrictVersion(newest) - cur_version = distutils.version.StrictVersion(qutebrowser.__version__) + new_version = pkg_resources.parse_version(newest) + cur_version = pkg_resources.parse_version(qutebrowser.__version__) lines = ['The report has been sent successfully. Thanks!'] if new_version > cur_version: lines.append("Note: The newest available version is v{}, " diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py index 2e336bc1e..2d3399a33 100644 --- a/qutebrowser/misc/crashsignal.py +++ b/qutebrowser/misc/crashsignal.py @@ -300,6 +300,7 @@ class SignalHandler(QObject): signal.SIGTERM, self.interrupt) if os.name == 'posix' and hasattr(signal, 'set_wakeup_fd'): + # pylint: disable=import-error,no-member import fcntl read_fd, write_fd = os.pipe() for fd in (read_fd, write_fd): diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index 3bc214389..ca8096b03 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -137,10 +137,10 @@ def fix_harfbuzz(args): - On Qt 5.2 (and probably earlier) the new engine probably has more crashes and is also experimental. - e.g. https://bugreports.qt-project.org/browse/QTBUG-36099 + e.g. https://bugreports.qt.io/browse/QTBUG-36099 - On Qt 5.3.0 there's a bug that affects a lot of websites: - https://bugreports.qt-project.org/browse/QTBUG-39278 + https://bugreports.qt.io/browse/QTBUG-39278 So the new engine will be more stable. - On Qt 5.3.1 this bug is fixed and the old engine will be the more stable @@ -213,6 +213,19 @@ def check_qt_version(): _die(text) +def check_ssl_support(): + """Check if SSL support is available.""" + try: + from PyQt5.QtNetwork import QSslSocket + except ImportError: + ok = False + else: + ok = QSslSocket.supportsSsl() + if not ok: + text = "Fatal error: Your Qt is built without SSL support." + _die(text) + + def check_libraries(): """Check if all needed Python libraries are installed.""" modules = { @@ -288,6 +301,7 @@ def earlyinit(args): # Now we can be sure QtCore is available, so we can print dialogs on # errors, so people only using the GUI notice them as well. check_qt_version() + check_ssl_support() remove_inputhook() check_libraries() init_log(args) diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py index 32e4100ca..b5a9e5995 100644 --- a/qutebrowser/misc/editor.py +++ b/qutebrowser/misc/editor.py @@ -22,10 +22,11 @@ import os import tempfile -from PyQt5.QtCore import pyqtSignal, QProcess, QObject +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QProcess from qutebrowser.config import config from qutebrowser.utils import message, log +from qutebrowser.misc import guiprocess class ExternalEditor(QObject): @@ -36,7 +37,7 @@ class ExternalEditor(QObject): _text: The current text before the editor is opened. _oshandle: The OS level handle to the tmpfile. _filehandle: The file handle to the tmpfile. - _proc: The QProcess of the editor. + _proc: The GUIProcess of the editor. _win_id: The window ID the ExternalEditor is associated with. """ @@ -69,15 +70,10 @@ class ExternalEditor(QObject): log.procs.debug("Editor closed") if exitstatus != QProcess.NormalExit: # No error/cleanup here, since we already handle this in - # on_proc_error + # on_proc_error. return try: if exitcode != 0: - # NOTE: Do not replace this with "raise CommandError" as it's - # executed async. - message.error( - self._win_id, "Editor did quit abnormally (status " - "{})!".format(exitcode)) return encoding = config.get('general', 'editor-encoding') try: @@ -94,22 +90,8 @@ class ExternalEditor(QObject): finally: self._cleanup() - def on_proc_error(self, error): - """Display an error message and clean up when editor crashed.""" - messages = { - QProcess.FailedToStart: "The process failed to start.", - QProcess.Crashed: "The process crashed.", - QProcess.Timedout: "The last waitFor...() function timed out.", - QProcess.WriteError: ("An error occurred when attempting to write " - "to the process."), - QProcess.ReadError: ("An error occurred when attempting to read " - "from the process."), - QProcess.UnknownError: "An unknown error occurred.", - } - # NOTE: Do not replace this with "raise CommandError" as it's - # executed async. - message.error(self._win_id, - "Error while calling editor: {}".format(messages[error])) + @pyqtSlot(QProcess.ProcessError) + def on_proc_error(self, _err): self._cleanup() def edit(self, text): @@ -132,7 +114,8 @@ class ExternalEditor(QObject): message.error(self._win_id, "Failed to create initial file: " "{}".format(e)) return - self._proc = QProcess(self) + self._proc = guiprocess.GUIProcess(self._win_id, what='editor', + parent=self) self._proc.finished.connect(self.on_proc_closed) self._proc.error.connect(self.on_proc_error) editor = config.get('general', 'editor') diff --git a/qutebrowser/misc/guiprocess.py b/qutebrowser/misc/guiprocess.py new file mode 100644 index 000000000..7db0e56f8 --- /dev/null +++ b/qutebrowser/misc/guiprocess.py @@ -0,0 +1,152 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2015 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""A QProcess which shows notifications in the GUI.""" + +import shlex + +from PyQt5.QtCore import (pyqtSlot, pyqtSignal, QObject, QProcess, + QProcessEnvironment) + +from qutebrowser.utils import message, log + +# A mapping of QProcess::ErrorCode's to human-readable strings. + +ERROR_STRINGS = { + QProcess.FailedToStart: "The process failed to start.", + QProcess.Crashed: "The process crashed.", + QProcess.Timedout: "The last waitFor...() function timed out.", + QProcess.WriteError: ("An error occurred when attempting to write to the " + "process."), + QProcess.ReadError: ("An error occurred when attempting to read from the " + "process."), + QProcess.UnknownError: "An unknown error occurred.", +} + + +class GUIProcess(QObject): + + """An external process which shows notifications in the GUI. + + Args: + cmd: The command which was started. + args: A list of arguments which gets passed. + _started: Whether the underlying process is started. + _proc: The underlying QProcess. + _win_id: The window ID this process is used in. + _what: What kind of thing is spawned (process/editor/userscript/...). + Used in messages. + _verbose: Whether to show more messages. + + Signals: + error/finished/started signals proxied from QProcess. + """ + + error = pyqtSignal(QProcess.ProcessError) + finished = pyqtSignal(int, QProcess.ExitStatus) + started = pyqtSignal() + + def __init__(self, win_id, what, *, verbose=False, additional_env=None, + parent=None): + super().__init__(parent) + self._win_id = win_id + self._what = what + self._verbose = verbose + self._started = False + self.cmd = None + self.args = None + + self._proc = QProcess(self) + self._proc.error.connect(self.on_error) + self._proc.error.connect(self.error) + self._proc.finished.connect(self.on_finished) + self._proc.finished.connect(self.finished) + self._proc.started.connect(self.on_started) + self._proc.started.connect(self.started) + + if additional_env is not None: + procenv = QProcessEnvironment.systemEnvironment() + for k, v in additional_env.items(): + procenv.insert(k, v) + self._proc.setProcessEnvironment(procenv) + + @pyqtSlot(QProcess.ProcessError) + def on_error(self, error): + """Show a message if there was an error while spawning.""" + msg = ERROR_STRINGS[error] + message.error(self._win_id, "Error while spawning {}: {}".format( + self._what, msg), immediately=True) + + @pyqtSlot(int, QProcess.ExitStatus) + def on_finished(self, code, status): + """Show a message when the process finished.""" + self._started = False + log.procs.debug("Process finished with code {}, status {}.".format( + code, status)) + if status == QProcess.CrashExit: + message.error(self._win_id, + "{} crashed!".format(self._what.capitalize()), + immediately=True) + elif status == QProcess.NormalExit and code == 0: + if self._verbose: + message.info(self._win_id, "{} exited successfully.".format( + self._what.capitalize())) + else: + assert status == QProcess.NormalExit + message.error(self._win_id, "{} exited with status {}.".format( + self._what.capitalize(), code)) + + @pyqtSlot() + def on_started(self): + """Called when the process started successfully.""" + log.procs.debug("Process started.") + assert not self._started + self._started = True + + def _pre_start(self, cmd, args): + """Prepare starting of a QProcess.""" + if self._started: + raise ValueError("Trying to start a running QProcess!") + self.cmd = cmd + self.args = args + if self._verbose: + fake_cmdline = ' '.join(shlex.quote(e) for e in [cmd] + list(args)) + message.info(self._win_id, 'Executing: ' + fake_cmdline) + + def start(self, cmd, args, mode=None): + """Convenience wrapper around QProcess::start.""" + log.procs.debug("Starting process.") + self._pre_start(cmd, args) + if mode is None: + self._proc.start(cmd, args) + else: + self._proc.start(cmd, args, mode) + + def start_detached(self, cmd, args, cwd=None): + """Convenience wrapper around QProcess::startDetached.""" + log.procs.debug("Starting detached.") + self._pre_start(cmd, args) + ok, _pid = self._proc.startDetached(cmd, args, cwd) + + if ok: + log.procs.debug("Process started.") + self._started = True + else: + message.error(self._win_id, "Error while spawning {}: {}.".format( + self._what, self._proc.error()), immediately=True) diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index bd75c634b..89fba7bcd 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -145,21 +145,23 @@ class SessionManager(QObject): if item.originalUrl() != item.url(): encoded = item.originalUrl().toEncoded() item_data['original-url'] = bytes(encoded).decode('ascii') - user_data = item.userData() + if history.currentItemIndex() == idx: item_data['active'] = True - if user_data is None: - pos = tab.page().mainFrame().scrollPosition() - data['zoom'] = tab.zoomFactor() - data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()} - data['history'].append(item_data) - if user_data is not None: + user_data = item.userData() + if history.currentItemIndex() == idx: + pos = tab.page().mainFrame().scrollPosition() + item_data['zoom'] = tab.zoomFactor() + item_data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()} + elif user_data is not None: if 'zoom' in user_data: - data['zoom'] = user_data['zoom'] + item_data['zoom'] = user_data['zoom'] if 'scroll-pos' in user_data: pos = user_data['scroll-pos'] - data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()} + item_data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()} + + data['history'].append(item_data) return data def _save_all(self): @@ -235,11 +237,25 @@ class SessionManager(QObject): entries = [] for histentry in data['history']: user_data = {} + if 'zoom' in data: + # The zoom was accidentally stored in 'data' instead of per-tab + # earlier. + # See https://github.com/The-Compiler/qutebrowser/issues/728 user_data['zoom'] = data['zoom'] + elif 'zoom' in histentry: + user_data['zoom'] = histentry['zoom'] + if 'scroll-pos' in data: + # The scroll position was accidentally stored in 'data' instead + # of per-tab earlier. + # See https://github.com/The-Compiler/qutebrowser/issues/728 pos = data['scroll-pos'] user_data['scroll-pos'] = QPoint(pos['x'], pos['y']) + elif 'scroll-pos' in histentry: + pos = histentry['scroll-pos'] + user_data['scroll-pos'] = QPoint(pos['x'], pos['y']) + active = histentry.get('active', False) url = QUrl.fromEncoded(histentry['url'].encode('ascii')) if 'original-url' in histentry: diff --git a/qutebrowser/utils/debug.py b/qutebrowser/utils/debug.py index 97a21c0e5..16aa53adf 100644 --- a/qutebrowser/utils/debug.py +++ b/qutebrowser/utils/debug.py @@ -230,10 +230,12 @@ def log_time(logger, action='operation'): action: A description of what's being done. """ started = datetime.datetime.now() - yield - finished = datetime.datetime.now() - delta = (finished - started).total_seconds() - logger.debug("{} took {} seconds.".format(action.capitalize(), delta)) + try: + yield + finally: + finished = datetime.datetime.now() + delta = (finished - started).total_seconds() + logger.debug("{} took {} seconds.".format(action.capitalize(), delta)) def _get_widgets(): diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index d156c6be1..bac158c3d 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -159,8 +159,10 @@ def init_log(args): def disable_qt_msghandler(): """Contextmanager which temporarily disables the Qt message handler.""" old_handler = QtCore.qInstallMessageHandler(None) - yield - QtCore.qInstallMessageHandler(old_handler) + try: + yield + finally: + QtCore.qInstallMessageHandler(old_handler) def _init_handlers(level, color, ram_capacity): @@ -259,23 +261,23 @@ def qt_message_handler(msg_type, context, msg): # suppressed_msgs is a list of regexes matching the message texts to hide. suppressed_msgs = ( # PNGs in Qt with broken color profile - # https://bugreports.qt-project.org/browse/QTBUG-39788 + # https://bugreports.qt.io/browse/QTBUG-39788 "libpng warning: iCCP: Not recognizing known sRGB profile that has " "been edited", # Hopefully harmless warning "OpenType support missing for script ", # Error if a QNetworkReply gets two different errors set. Harmless Qt # bug on some pages. - # https://bugreports.qt-project.org/browse/QTBUG-30298 + # https://bugreports.qt.io/browse/QTBUG-30298 "QNetworkReplyImplPrivate::error: Internal problem, this method must " "only be called once.", # Sometimes indicates missing text, but most of the time harmless "load glyph failed ", - # Harmless, see https://bugreports.qt-project.org/browse/QTBUG-42479 + # Harmless, see https://bugreports.qt.io/browse/QTBUG-42479 "content-type missing in HTTP POST, defaulting to " "application/x-www-form-urlencoded. Use QNetworkRequest::setHeader() " "to fix this problem.", - # https://bugreports.qt-project.org/browse/QTBUG-43118 + # https://bugreports.qt.io/browse/QTBUG-43118 "Using blocking call!", # Hopefully harmless '"Method "GetAll" with signature "s" on interface ' @@ -285,6 +287,8 @@ def qt_message_handler(msg_type, context, msg): 'QXcbWindow: Unhandled client message: "_E_', 'QXcbWindow: Unhandled client message: "_ECORE_', 'QXcbWindow: Unhandled client message: "_GTK_', + # Happens on AppVeyor CI + 'SetProcessDpiAwareness failed:', ) if any(msg.strip().startswith(pattern) for pattern in suppressed_msgs): level = logging.DEBUG @@ -317,8 +321,10 @@ def hide_qt_warning(pattern, logger='qt'): log_filter = QtWarningFilter(pattern) logger_obj = logging.getLogger(logger) logger_obj.addFilter(log_filter) - yield - logger_obj.removeFilter(log_filter) + try: + yield + finally: + logger_obj.removeFilter(log_filter) class QtWarningFilter(logging.Filter): @@ -377,7 +383,7 @@ class RAMHandler(logging.Handler): """Logging handler which keeps the messages in a deque in RAM. - Loosly based on logging.BufferingHandler which is unsuitable because it + Loosely based on logging.BufferingHandler which is unsuitable because it uses a simple list rather than a deque. Attributes: diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py index 6573306ab..8073b4bbb 100644 --- a/qutebrowser/utils/qtutils.py +++ b/qutebrowser/utils/qtutils.py @@ -31,10 +31,9 @@ import io import os import sys import operator -import distutils.version # pylint: disable=no-name-in-module,import-error -# https://bitbucket.org/logilab/pylint/issue/73/ import contextlib +import pkg_resources from PyQt5.QtCore import (qVersion, QEventLoop, QDataStream, QByteArray, QIODevice, QSaveFile) from PyQt5.QtWidgets import QApplication @@ -60,8 +59,8 @@ def version_check(version, op=operator.ge): """ # pylint: disable=no-member # https://bitbucket.org/logilab/pylint/issue/73/ - return op(distutils.version.StrictVersion(qVersion()), - distutils.version.StrictVersion(version)) + return op(pkg_resources.parse_version(qVersion()), + pkg_resources.parse_version(version)) def check_overflow(arg, ctype, fatal=True): diff --git a/qutebrowser/utils/standarddir.py b/qutebrowser/utils/standarddir.py index 9dd8c5752..bbe61c459 100644 --- a/qutebrowser/utils/standarddir.py +++ b/qutebrowser/utils/standarddir.py @@ -118,7 +118,7 @@ def _get(typ): Args: typ: A member of the QStandardPaths::StandardLocation enum, - see http://qt-project.org/doc/qt-5/qstandardpaths.html#StandardLocation-enum + see http://doc.qt.io/qt-5/qstandardpaths.html#StandardLocation-enum """ overridden, path = _from_args(typ, _args) if not overridden: @@ -127,7 +127,7 @@ def _get(typ): if (typ == QStandardPaths.ConfigLocation and path.split(os.sep)[-1] != appname): # WORKAROUND - see - # https://bugreports.qt-project.org/browse/QTBUG-38872 + # https://bugreports.qt.io/browse/QTBUG-38872 path = os.path.join(path, appname) if typ == QStandardPaths.DataLocation and os.name == 'nt': # Under windows, config/data might end up in the same directory. @@ -170,6 +170,7 @@ def _init_cachedir_tag(): f.write("# This file is a cache directory tag created by " "qutebrowser.\n") f.write("# For information about cache directory tags, see:\n") - f.write("# http://www.brynosaurus.com/cachedir/\n") + f.write("# http://www.brynosaurus.com/" # pragma: no branch + "cachedir/\n") except OSError: log.init.exception("Failed to create CACHEDIR.TAG") diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index fbe280199..168e7aabf 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -279,7 +279,7 @@ def qurl_from_user_input(urlstr): IPv6, so we first try to handle it as a valid IPv6, and if that fails we use QUrl.fromUserInput. - WORKAROUND - https://bugreports.qt-project.org/browse/QTBUG-41089 + WORKAROUND - https://bugreports.qt.io/browse/QTBUG-41089 FIXME - Maybe https://codereview.qt-project.org/#/c/93851/ has a better way to solve this? https://github.com/The-Compiler/qutebrowser/issues/109 diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 5d19ad515..13a2e1cfb 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -73,7 +73,7 @@ class NeighborList(collections.abc.Sequence): Args: items: The list of items to iterate in. _default: The initially selected value. - _mode: Behaviour when the first/last item is reached. + _mode: Behavior when the first/last item is reached. Modes.block: Stay on the selected item Modes.wrap: Wrap around to the other end Modes.exception: Raise an IndexError. @@ -242,7 +242,7 @@ Completion = enum('Completion', ['command', 'section', 'option', 'value', # Exit statuses for errors. Needs to be an int for sys.exit. Exit = enum('Exit', ['ok', 'reserved', 'exception', 'err_ipc', 'err_init', - 'err_config', 'err_key_config'], is_int=True) + 'err_config', 'err_key_config'], is_int=True, start=0) class Question(QObject): diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 920da944c..526a2a5e0 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -50,8 +50,6 @@ def elide(text, length): def compact_text(text, elidelength=None): """Remove leading whitespace and newlines from a text and maybe elide it. - FIXME: Add tests. - Args: text: The text to compact. elidelength: To how many chars to elide. @@ -105,12 +103,12 @@ def actute_warning(): try: if qtutils.version_check('5.3.0'): return - except ValueError: + except ValueError: # pragma: no cover pass try: with open('/usr/share/X11/locale/en_US.UTF-8/Compose', 'r', encoding='utf-8') as f: - for line in f: + for line in f: # pragma: no branch if '' in line: if sys.stdout is not None: sys.stdout.flush() @@ -118,7 +116,7 @@ def actute_warning(): "that is not a bug in qutebrowser! See " "https://bugs.freedesktop.org/show_bug.cgi?id=69476 " "for details.") - break + break # pragma: no branch except OSError: log.init.exception("Failed to read Compose file") @@ -242,7 +240,7 @@ def key_to_string(key): """ special_names_str = { # Some keys handled in a weird way by QKeySequence::toString. - # See https://bugreports.qt-project.org/browse/QTBUG-40030 + # See https://bugreports.qt.io/browse/QTBUG-40030 # Most are unlikely to be ever needed, but you never know ;) # For dead/combining keys, we return the corresponding non-combining # key, as that's easier to add to the config. @@ -290,6 +288,18 @@ def key_to_string(key): 'Key_TouchpadOn': 'Touchpad On', 'Key_TouchpadToggle': 'Touchpad toggle', 'Key_Yellow': 'Yellow', + 'Key_Alt': 'Alt', + 'Key_AltGr': 'AltGr', + 'Key_Control': 'Control', + 'Key_Direction_L': 'Direction L', + 'Key_Direction_R': 'Direction R', + 'Key_Hyper_L': 'Hyper L', + 'Key_Hyper_R': 'Hyper R', + 'Key_Meta': 'Meta', + 'Key_Shift': 'Shift', + 'Key_Super_L': 'Super L', + 'Key_Super_R': 'Super R', + 'Key_unknown': 'Unknown', } # We now build our real special_names dict from the string mapping above. # The reason we don't do this directly is that certain Qt versions don't @@ -428,11 +438,13 @@ def disabled_excepthook(): """Run code with the exception hook temporarily disabled.""" old_excepthook = sys.excepthook sys.excepthook = sys.__excepthook__ - yield - # If the code we did run did change sys.excepthook, we leave it - # unchanged. Otherwise, we reset it. - if sys.excepthook is sys.__excepthook__: - sys.excepthook = old_excepthook + try: + yield + finally: + # If the code we did run did change sys.excepthook, we leave it + # unchanged. Otherwise, we reset it. + if sys.excepthook is sys.__excepthook__: + sys.excepthook = old_excepthook class prevent_exceptions: # pylint: disable=invalid-name @@ -541,7 +553,7 @@ def qualname(obj): elif hasattr(obj, '__name__'): name = obj.__name__ else: - name = '' + name = repr(obj) if inspect.isclass(obj) or inspect.isfunction(obj): module = obj.__module__ diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 19dae311a..e0e966615 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -29,10 +29,8 @@ import collections from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR, qVersion from PyQt5.QtWebKit import qWebKitVersion -try: - from PyQt5.QtNetwork import QSslSocket -except ImportError: - QSslSocket = None +from PyQt5.QtNetwork import QSslSocket +from PyQt5.QtWidgets import QApplication import qutebrowser from qutebrowser.utils import log, utils @@ -114,7 +112,7 @@ def _release_info(): for fn in glob.glob("/etc/*-release"): try: with open(fn, 'r', encoding='utf-8') as f: - data.append((fn, ''.join(f.readlines()))) + data.append((fn, ''.join(f.readlines()))) # pragma: no branch except OSError: log.misc.exception("Error while reading {}.".format(fn)) return data @@ -186,8 +184,12 @@ def _os_info(): return lines -def version(): - """Return a string with various version informations.""" +def version(short=False): + """Return a string with various version informations. + + Args: + short: Return a shortened output. + """ lines = ["qutebrowser v{}".format(qutebrowser.__version__)] gitver = _git_str() if gitver is not None: @@ -199,20 +201,24 @@ def version(): 'Qt: {}, runtime: {}'.format(QT_VERSION_STR, qVersion()), 'PyQt: {}'.format(PYQT_VERSION_STR), ] - lines += _module_versions() - if QSslSocket is not None and QSslSocket.supportsSsl(): - ssl_version = QSslSocket.sslLibraryVersionString() - else: - ssl_version = 'unavailable' - lines += [ - 'Webkit: {}'.format(qWebKitVersion()), - 'Harfbuzz: {}'.format(os.environ.get('QT_HARFBUZZ', 'system')), - 'SSL: {}'.format(ssl_version), - '', - 'Frozen: {}'.format(hasattr(sys, 'frozen')), - 'Platform: {}, {}'.format(platform.platform(), - platform.architecture()[0]), - ] - lines += _os_info() + if not short: + style = QApplication.instance().style() + lines += [ + 'Style: {}'.format(style.metaObject().className()), + 'Desktop: {}'.format(os.environ.get('DESKTOP_SESSION')), + ] + + lines += _module_versions() + + lines += [ + 'Webkit: {}'.format(qWebKitVersion()), + 'Harfbuzz: {}'.format(os.environ.get('QT_HARFBUZZ', 'system')), + 'SSL: {}'.format(QSslSocket.sslLibraryVersionString()), + '', + 'Frozen: {}'.format(hasattr(sys, 'frozen')), + 'Platform: {}, {}'.format(platform.platform(), + platform.architecture()[0]), + ] + lines += _os_info() return '\n'.join(lines) diff --git a/scripts/build_release.py b/scripts/build_release.py index 9f051ff18..a37ecf426 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -46,6 +46,20 @@ def call_script(name, *args, python=sys.executable): subprocess.check_call([python, path] + list(args)) +def call_freeze(*args, python=sys.executable): + """Call freeze.py via tox. + + Args: + *args: The arguments to pass. + python: The python interpreter to use. + """ + env = os.environ.copy() + env['PYTHON'] = python + subprocess.check_call( + [sys.executable, '-m', 'tox', '-e', 'cxfreeze-windows'] + list(args), + env=env) + + def build_common(args): """Common buildsteps used for all OS'.""" utils.print_title("Running asciidoc2html.py") @@ -64,22 +78,33 @@ def _maybe_remove(path): pass +def smoke_test(executable): + """Try starting the given qutebrowser executable.""" + subprocess.check_call([executable, '--no-err-windows', '--nowindow', + '--temp-basedir', 'about:blank', ':later 500 quit']) + + def build_windows(): """Build windows executables/setups.""" parts = str(sys.version_info.major), str(sys.version_info.minor) ver = ''.join(parts) dotver = '.'.join(parts) - python_x86 = r'C:\Python{}_x32\python.exe'.format(ver) - python_x64 = r'C:\Python{}\python.exe'.format(ver) + python_x86 = r'C:\Python{}_x32'.format(ver) + python_x64 = r'C:\Python{}'.format(ver) utils.print_title("Running 32bit freeze.py build_exe") - call_script('freeze.py', 'build_exe', python=python_x86) - utils.print_title("Running 64bit freeze.py build_exe") - call_script('freeze.py', 'build_exe', python=python_x64) + call_freeze('build_exe', python=python_x86) utils.print_title("Running 32bit freeze.py bdist_msi") - call_script('freeze.py', 'bdist_msi', python=python_x86) + call_freeze('bdist_msi', python=python_x86) + utils.print_title("Running 64bit freeze.py build_exe") + call_freeze('build_exe', python=python_x64) utils.print_title("Running 64bit freeze.py bdist_msi") - call_script('freeze.py', 'bdist_msi', python=python_x64) + call_freeze('bdist_msi', python=python_x64) + + utils.print_title("Running 32bit smoke test") + smoke_test('build/exe.win32-{}/qutebrowser.exe'.format(dotver)) + utils.print_title("Running 64bit smoke test") + smoke_test('build/exe.win-amd64-{}/qutebrowser.exe'.format(dotver)) destdir = os.path.join('dist', 'zip') _maybe_remove(destdir) @@ -126,6 +151,14 @@ def main(): args = parser.parse_args() utils.change_cwd() if os.name == 'nt': + if sys.maxsize > 2**32: + # WORKAROUND + print("Due to a python/Windows bug, this script needs to be run ") + print("with a 32bit Python.") + print() + print("See http://bugs.python.org/issue24493 and ") + print("https://github.com/pypa/virtualenv/issues/774") + sys.exit(1) build_common(args) build_windows() else: diff --git a/scripts/ci_install.py b/scripts/ci_install.py new file mode 100644 index 000000000..4bd4698ad --- /dev/null +++ b/scripts/ci_install.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python2 +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2015 Florian Bruhin (The Compiler) + +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +# pylint: skip-file + +"""Install needed prerequisites on the AppVeyor/Travis CI. + +Note this file is written in python2 as this is more readily available on the +CI machines. +""" + +from __future__ import print_function + +import os +import sys +import subprocess +import urllib + +PYQT_VERSION = '5.4.2' + + +def apt_get(args): + subprocess.check_call(['sudo', 'apt-get', '-y', '-q'] + args) + + +def brew(args, silent=False): + if silent: + with open(os.devnull, 'w') as f: + subprocess.check_call(['brew'] + args, stdout=f) + else: + subprocess.check_call(['brew'] + args) + + +if 'APPVEYOR' in os.environ: + print("Getting PyQt5...") + urllib.urlretrieve( + ('http://sourceforge.net/projects/pyqt/files/PyQt5/PyQt-{v}/' + 'PyQt5-{v}-gpl-Py3.4-Qt{v}-x32.exe'.format(v=PYQT_VERSION)), + r'C:\install-PyQt5.exe') + + print("Installing PyQt5...") + subprocess.check_call([r'C:\install-PyQt5.exe', '/S']) + + print("Installing tox...") + subprocess.check_call([r'C:\Python34\Scripts\pip', 'install', 'tox']) + + print("Linking Python...") + with open(r'C:\Windows\system32\python3.bat', 'w') as f: + f.write(r'@C:\Python34\python %*') +elif os.environ.get('TRAVIS_OS_NAME', None) == 'linux': + print("apt-get update...") + apt_get(['update']) + + print("Installing packages...") + pkgs = 'python3-pyqt5 python3-pyqt5.qtwebkit python-tox python3-dev xvfb' + apt_get(['install'] + pkgs.split()) +elif os.environ.get('TRAVIS_OS_NAME', None) == 'osx': + print("brew update...") + brew(['update'], silent=True) + + print("Installing packages...") + brew(['install', 'python3', 'pyqt5']) + + print("Installing tox...") + subprocess.check_call(['sudo', 'pip3.4', 'install', 'tox']) + + os.system('ls -l /usr/local/bin/xvfb-run') + print("Creating xvfb-run stub...") + with open('/usr/local/bin/xvfb-run', 'w') as f: + # This will break when xvfb-run is called differently in .travis.yml, + # but I can't be bothered to do it in a nicer way. + f.write('#!/bin/bash\n') + f.write('shift 2\n') + f.write('exec "$@"\n') + os.system('sudo chmod 755 /usr/local/bin/xvfb-run') + os.system('ls -l /usr/local/bin/xvfb-run') +else: + def env(key): + return os.environ.get(key, None) + print("Unknown environment! (CI {}, APPVEYOR {}, TRAVIS {}, " + "TRAVIS_OS_NAME {})".format(env('CI'), env('APPVEYOR'), + env('TRAVIS'), env('TRAVIS_OS_NAME')), + file=sys.stderr) + sys.exit(1) diff --git a/scripts/freeze.py b/scripts/freeze.py index c5f13cdbb..6185634ec 100755 --- a/scripts/freeze.py +++ b/scripts/freeze.py @@ -47,20 +47,41 @@ def get_egl_path(): return os.path.join(distutils.sysconfig.get_python_lib(), r'PyQt5\libEGL.dll') -build_exe_options = { - 'include_files': [ - ('qutebrowser/html', 'html'), - ('qutebrowser/html/doc', 'html/doc'), - ('qutebrowser/git-commit-id', 'git-commit-id'), - ], - 'include_msvcr': True, - 'excludes': ['tkinter'], - 'packages': ['pygments'], -} -egl_path = get_egl_path() -if egl_path is not None: - build_exe_options['include_files'].append((egl_path, 'libEGL.dll')) +def get_build_exe_options(skip_html=False): + """Get the options passed as build_exe_options to cx_Freeze. + + If either skip_html or --qute-skip-html as argument is given, doesn't + freeze the documentation. + """ + if '--qute-skip-html' in sys.argv: + skip_html = True + sys.argv.remove('--qute-skip-html') + + include_files = [ + ('qutebrowser/javascript', 'javascript'), + ('qutebrowser/git-commit-id', 'git-commit-id'), + ('qutebrowser/utils/testfile', 'utils/testfile'), + ] + + if not skip_html: + include_files += [ + ('qutebrowser/html', 'html'), + ('qutebrowser/html/doc', 'html/doc'), + ] + + egl_path = get_egl_path() + if egl_path is not None: + include_files.append((egl_path, 'libEGL.dll')) + + return { + 'include_files': include_files, + 'include_msvcr': True, + 'includes': [], + 'excludes': ['tkinter'], + 'packages': ['pygments'], + } + bdist_msi_options = { # random GUID generated by uuid.uuid4() @@ -92,19 +113,21 @@ executable = cx.Executable('qutebrowser/__main__.py', base=base, icon=os.path.join(BASEDIR, 'icons', 'qutebrowser.ico')) -try: - setupcommon.write_git_file() - cx.setup( - executables=[executable], - options={ - 'build_exe': build_exe_options, - 'bdist_msi': bdist_msi_options, - 'bdist_mac': bdist_mac_options, - 'bdist_dmg': bdist_dmg_options, - }, - **setupcommon.setupdata - ) -finally: - path = os.path.join(BASEDIR, 'qutebrowser', 'git-commit-id') - if os.path.exists(path): - os.remove(path) + +if __name__ == '__main__': + try: + setupcommon.write_git_file() + cx.setup( + executables=[executable], + options={ + 'build_exe': get_build_exe_options(), + 'bdist_msi': bdist_msi_options, + 'bdist_mac': bdist_mac_options, + 'bdist_dmg': bdist_dmg_options, + }, + **setupcommon.setupdata + ) + finally: + path = os.path.join(BASEDIR, 'qutebrowser', 'git-commit-id') + if os.path.exists(path): + os.remove(path) diff --git a/scripts/freeze_tests.py b/scripts/freeze_tests.py new file mode 100755 index 000000000..7cd7022a9 --- /dev/null +++ b/scripts/freeze_tests.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2015 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""cx_Freeze script to freeze qutebrowser and its tests.""" + + +import os +import os.path +import sys +import contextlib + +import cx_Freeze as cx # pylint: disable=import-error +# cx_Freeze is hard to install (needs C extensions) so we don't check for it. +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +from scripts import setupcommon, freeze + + +@contextlib.contextmanager +def temp_git_commit_file(): + """Context manager to temporarily create a fake git-commit-id file.""" + basedir = os.path.join(os.path.dirname(os.path.realpath(__file__)), + os.path.pardir) + path = os.path.join(basedir, 'qutebrowser', 'git-commit-id') + with open(path, 'wb') as f: + f.write(b'fake-frozen-git-commit') + yield + os.remove(path) + + +def get_build_exe_options(): + """Get build_exe options with additional includes.""" + opts = freeze.get_build_exe_options(skip_html=True) + opts['includes'] += pytest.freeze_includes() # pylint: disable=no-member + opts['includes'] += ['unittest.mock', 'PyQt5.QtTest'] + opts['packages'].append('qutebrowser') + return opts + + +def main(): + """Main entry point.""" + with temp_git_commit_file(): + cx.setup( + executables=[cx.Executable('scripts/run_frozen_tests.py', + targetName='run-frozen-tests')], + options={'build_exe': get_build_exe_options()}, + **setupcommon.setupdata + ) + + +if __name__ == '__main__': + main() diff --git a/scripts/link_pyqt.py b/scripts/link_pyqt.py index dbfeaed99..20e8e3e21 100644 --- a/scripts/link_pyqt.py +++ b/scripts/link_pyqt.py @@ -40,7 +40,8 @@ class Error(Exception): def verbose_copy(src, dst, *, follow_symlinks=True): """Copy function for shutil.copytree which prints copied files.""" - print('{} -> {}'.format(src, dst)) + if '-v' in sys.argv: + print('{} -> {}'.format(src, dst)) shutil.copy(src, dst, follow_symlinks=follow_symlinks) @@ -112,6 +113,7 @@ def copy_or_link(source, dest): """Copy or symlink source to dest.""" if os.name == 'nt': if os.path.isdir(source): + print('{} -> {}'.format(source, dest)) shutil.copytree(source, dest, ignore=get_ignored_files, copy_function=verbose_copy) else: @@ -138,7 +140,10 @@ def get_python_lib(executable, venv=False): treatments for Windows/Ubuntu shouldn't take place. """ distribution = platform.linux_distribution(full_distribution_name=False) - if os.name == 'nt' and not venv: + if 'PYTHON' in os.environ and not venv: + # e.g. on AppVeyor + return os.path.join(os.environ['PYTHON'], 'Lib', 'site-packages') + elif os.name == 'nt' and not venv: # For some reason, we get an empty string from get_python_lib() on # Windows when running via tox, and sys.prefix is empty too... return os.path.join(os.path.dirname(executable), '..', 'Lib', diff --git a/scripts/misc_checks.py b/scripts/misc_checks.py index 4e2c185f5..dac0fe017 100644 --- a/scripts/misc_checks.py +++ b/scripts/misc_checks.py @@ -35,9 +35,13 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) from scripts import utils -def _py_files(target): +def _py_files(): """Iterate over all python files and yield filenames.""" - for (dirpath, _dirnames, filenames) in os.walk(target): + for (dirpath, _dirnames, filenames) in os.walk('.'): + parts = dirpath.split(os.sep) + if len(parts) >= 2 and parts[1].startswith('.'): + # ignore hidden dirs + continue for name in (e for e in filenames if e.endswith('.py')): yield os.path.join(dirpath, name) @@ -64,31 +68,32 @@ def check_git(): return status -def check_spelling(target): +def check_spelling(): """Check commonly misspelled words.""" # Words which I often misspell - words = {'behaviour', 'quitted', 'likelyhood', 'sucessfully', - 'occur[^r .]', 'seperator', 'explicitely', 'resetted', - 'auxillary', 'accidentaly', 'ambigious', 'loosly', - 'initialis', 'convienence', 'similiar', 'uncommited', - 'reproducable'} + words = {'[Bb]ehaviour', '[Qq]uitted', 'Ll]ikelyhood', '[Ss]ucessfully', + '[Oo]ccur[^r .]', '[Ss]eperator', '[Ee]xplicitely', '[Rr]esetted', + '[Aa]uxillary', '[Aa]ccidentaly', '[Aa]mbigious', '[Ll]oosly', + '[Ii]nitialis', '[Cc]onvienence', '[Ss]imiliar', '[Uu]ncommited', + '[Rr]eproducable'} # Words which look better when splitted, but might need some fine tuning. - words |= {'keystrings', 'webelements', 'mouseevent', 'keysequence', - 'normalmode', 'eventloops', 'sizehint', 'statemachine', - 'metaobject', 'logrecord', 'filetype'} + words |= {'[Kk]eystrings', '[Ww]ebelements', '[Mm]ouseevent', + '[Kk]eysequence', '[Nn]ormalmode', '[Ee]ventloops', + '[Ss]izehint', '[Ss]tatemachine', '[Mm]etaobject', + '[Ll]ogrecord', '[Ff]iletype'} seen = collections.defaultdict(list) try: ok = True - for fn in _py_files(target): + for fn in _py_files(): with tokenize.open(fn) as f: - if fn == os.path.join('scripts', 'misc_checks.py'): + if fn == os.path.join('.', 'scripts', 'misc_checks.py'): continue for line in f: for w in words: if re.search(w, line) and fn not in seen[w]: - print("Found '{}' in {}!".format(w, fn)) + print('Found "{}" in {}!'.format(w, fn)) seen[w].append(fn) ok = False print() @@ -98,11 +103,11 @@ def check_spelling(target): return None -def check_vcs_conflict(target): +def check_vcs_conflict(): """Check VCS conflict markers.""" try: ok = True - for fn in _py_files(target): + for fn in _py_files(): with tokenize.open(fn) as f: for line in f: if any(line.startswith(c * 7) for c in '<>=|'): @@ -120,25 +125,14 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument('checker', choices=('git', 'vcs', 'spelling'), help="Which checker to run.") - parser.add_argument('target', help="What to check", nargs='*') args = parser.parse_args() if args.checker == 'git': ok = check_git() - return 0 if ok else 1 elif args.checker == 'vcs': - is_ok = True - for target in args.target: - ok = check_vcs_conflict(target) - if not ok: - is_ok = False - return 0 if is_ok else 1 + ok = check_vcs_conflict() elif args.checker == 'spelling': - is_ok = True - for target in args.target: - ok = check_spelling(target) - if not ok: - is_ok = False - return 0 if is_ok else 1 + ok = check_spelling() + return 0 if ok else 1 if __name__ == '__main__': diff --git a/scripts/pylint_checkers/crlf.py b/scripts/run_frozen_tests.py similarity index 50% rename from scripts/pylint_checkers/crlf.py rename to scripts/run_frozen_tests.py index a77f8b9e0..dd70c0505 100644 --- a/scripts/pylint_checkers/crlf.py +++ b/scripts/run_frozen_tests.py @@ -1,6 +1,8 @@ -# Copyright 2014-2015 Florian Bruhin (The Compiler) +#!/usr/bin/env python3 # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: +# Copyright 2015 Florian Bruhin (The Compiler) +# # This file is part of qutebrowser. # # qutebrowser is free software: you can redistribute it and/or modify @@ -16,30 +18,16 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Checker for CRLF in files.""" +# pylint: disable=import-error,no-member -from pylint import interfaces, checkers +"""cx_Freeze script to run qutebrowser tests on the frozen executable.""" +import sys -class CrlfChecker(checkers.BaseChecker): +import pytest +import pytestqt.plugin +import pytest_mock +import pytest_capturelog - """Check for CRLF in files.""" - - __implements__ = interfaces.IRawChecker - - name = 'crlf' - msgs = {'W9001': ('Uses CRLFs', 'crlf', None)} - options = () - priority = -1 - - def process_module(self, node): - """Process the module.""" - for (lineno, line) in enumerate(node.file_stream): - if b'\r\n' in line: - self.add_message('crlf', line=lineno) - return - - -def register(linter): - """Register the checker.""" - linter.register_checker(CrlfChecker(linter)) +sys.exit(pytest.main(plugins=[pytestqt.plugin, pytest_mock, + pytest_capturelog])) diff --git a/scripts/segfault_test.py b/scripts/segfault_test.py index e2a374343..091b1b7e4 100755 --- a/scripts/segfault_test.py +++ b/scripts/segfault_test.py @@ -70,20 +70,20 @@ def main(): if len(sys.argv) < 2: # pages which previously caused problems pages = [ - # ANGLE, https://bugreports.qt-project.org/browse/QTBUG-39723 + # ANGLE, https://bugreports.qt.io/browse/QTBUG-39723 ('http://www.binpress.com/', False), ('http://david.li/flow/', False), ('https://imzdl.com/', False), # not reproducible - # https://bugreports.qt-project.org/browse/QTBUG-39847 + # https://bugreports.qt.io/browse/QTBUG-39847 ('http://www.20min.ch/', True), - # HarfBuzz, https://bugreports.qt-project.org/browse/QTBUG-39278 + # HarfBuzz, https://bugreports.qt.io/browse/QTBUG-39278 ('http://www.the-compiler.org/', True), ('http://phoronix.com', True), ('http://twitter.com', True), - # HarfBuzz #2, https://bugreports.qt-project.org/browse/QTBUG-36099 + # HarfBuzz #2, https://bugreports.qt.io/browse/QTBUG-36099 ('http://lenta.ru/', True), - # Unknown, https://bugreports.qt-project.org/browse/QTBUG-41360 + # Unknown, https://bugreports.qt.io/browse/QTBUG-41360 ('http://salt.readthedocs.org/en/latest/topics/pillar/', True), ] else: diff --git a/scripts/src2asciidoc.py b/scripts/src2asciidoc.py index 31d82f6e8..dddbc3a4f 100755 --- a/scripts/src2asciidoc.py +++ b/scripts/src2asciidoc.py @@ -37,7 +37,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) import qutebrowser.app from scripts import asciidoc2html, utils from qutebrowser import qutebrowser -from qutebrowser.commands import cmdutils +from qutebrowser.commands import cmdutils, command from qutebrowser.config import configdata from qutebrowser.utils import docutils @@ -54,6 +54,14 @@ class UsageFormatter(argparse.HelpFormatter): """Override _format_usage to not add the 'usage:' prefix.""" return super()._format_usage(usage, actions, groups, '') + def _get_default_metavar_for_optional(self, action): + """Do name transforming when getting metavar.""" + return command.arg_name(action.dest.upper()) + + def _get_default_metavar_for_positional(self, action): + """Do name transforming when getting metavar.""" + return command.arg_name(action.dest) + def _metavar_formatter(self, action, default_metavar): """Override _metavar_formatter to add asciidoc markup to metavars. diff --git a/setup.py b/setup.py index b62a75ba2..1cb99e8ab 100755 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ except NameError: try: common.write_git_file() setuptools.setup( - packages=setuptools.find_packages(exclude=['qutebrowser.test']), + packages=setuptools.find_packages(exclude=['scripts', 'scripts.*']), include_package_data=True, entry_points={'gui_scripts': ['qutebrowser = qutebrowser.qutebrowser:main']}, diff --git a/tests/config/test_config.py b/tests/config/test_config.py index d5fab2ed1..3fff0ea66 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -197,8 +197,10 @@ class TestKeyConfigParser: ('download-page', 'download'), ('cancel-download', 'download-cancel'), - ('search ""', 'search'), - ("search ''", 'search'), + ('search ""', 'search ;; clear-keychain'), + ("search ''", 'search ;; clear-keychain'), + ("search", 'search ;; clear-keychain'), + ("search ;; foobar", None), ('search "foo"', None), ('set-cmd-text "foo bar"', 'set-cmd-text foo bar'), diff --git a/tests/config/test_configtypes.py b/tests/config/test_configtypes.py index 871b6cf4d..edb0bb8cb 100644 --- a/tests/config/test_configtypes.py +++ b/tests/config/test_configtypes.py @@ -1362,6 +1362,7 @@ class TestFile: def test_validate_does_not_exist(self, os_path): """Test validate with a file which does not exist.""" os_path.expanduser.side_effect = lambda x: x + os_path.expandvars.side_effect = lambda x: x os_path.isfile.return_value = False with pytest.raises(configexc.ValidationError): self.t.validate('foobar') @@ -1369,26 +1370,50 @@ class TestFile: def test_validate_exists_abs(self, os_path): """Test validate with a file which does exist.""" os_path.expanduser.side_effect = lambda x: x + os_path.expandvars.side_effect = lambda x: x os_path.isfile.return_value = True os_path.isabs.return_value = True self.t.validate('foobar') - def test_validate_exists_not_abs(self, os_path): - """Test validate with a file which does exist but is not absolute.""" + def test_validate_exists_rel(self, os_path, monkeypatch): + """Test validate with a relative path to an existing file.""" + monkeypatch.setattr( + 'qutebrowser.config.configtypes.standarddir.config', + lambda: '/home/foo/.config/') os_path.expanduser.side_effect = lambda x: x + os_path.expandvars.side_effect = lambda x: x os_path.isfile.return_value = True os_path.isabs.return_value = False + self.t.validate('foobar') + os_path.join.assert_called_once_with('/home/foo/.config/', 'foobar') + + def test_validate_rel_config_none(self, os_path, monkeypatch): + """Test with a relative path and standarddir.config returning None.""" + monkeypatch.setattr( + 'qutebrowser.config.configtypes.standarddir.config', lambda: None) + os_path.isabs.return_value = False with pytest.raises(configexc.ValidationError): self.t.validate('foobar') def test_validate_expanduser(self, os_path): """Test if validate expands the user correctly.""" os_path.expanduser.side_effect = lambda x: x.replace('~', '/home/foo') + os_path.expandvars.side_effect = lambda x: x os_path.isfile.side_effect = lambda path: path == '/home/foo/foobar' os_path.isabs.return_value = True self.t.validate('~/foobar') os_path.expanduser.assert_called_once_with('~/foobar') + def test_validate_expandvars(self, os_path): + """Test if validate expands the environment vars correctly.""" + os_path.expanduser.side_effect = lambda x: x + os_path.expandvars.side_effect = lambda x: x.replace( + '$HOME', '/home/foo') + os_path.isfile.side_effect = lambda path: path == '/home/foo/foobar' + os_path.isabs.return_value = True + self.t.validate('$HOME/foobar') + os_path.expandvars.assert_called_once_with('$HOME/foobar') + def test_validate_invalid_encoding(self, os_path, unicode_encode_err): """Test validate with an invalid encoding, e.g. LC_ALL=C.""" os_path.isfile.side_effect = unicode_encode_err @@ -1399,6 +1424,7 @@ class TestFile: def test_transform(self, os_path): """Test transform.""" os_path.expanduser.side_effect = lambda x: x.replace('~', '/home/foo') + os_path.expandvars.side_effect = lambda x: x assert self.t.transform('~/foobar') == '/home/foo/foobar' os_path.expanduser.assert_called_once_with('~/foobar') @@ -1855,6 +1881,11 @@ class TestSearchEngineUrl: with pytest.raises(configexc.ValidationError): self.t.validate(':{}') + def test_validate_format_string(self): + """Test validate with a {foo} format string.""" + with pytest.raises(configexc.ValidationError): + self.t.validate('foo{bar}baz{}') + def test_transform_empty(self): """Test transform with an empty value.""" assert self.t.transform('') is None @@ -1930,13 +1961,21 @@ class TestUserStyleSheet: """Test transform with an empty value.""" assert self.t.transform('') is None - def test_transform_file(self): + def test_transform_file(self, os_path, mocker): """Test transform with a filename.""" + qurl = mocker.patch('qutebrowser.config.configtypes.QUrl', + autospec=True) + qurl.fromLocalFile.return_value = QUrl("file:///foo/bar") + os_path.exists.return_value = True path = os.path.join(os.path.sep, 'foo', 'bar') assert self.t.transform(path) == QUrl("file:///foo/bar") - def test_transform_file_expandvars(self, monkeypatch): + def test_transform_file_expandvars(self, os_path, monkeypatch, mocker): """Test transform with a filename (expandvars).""" + qurl = mocker.patch('qutebrowser.config.configtypes.QUrl', + autospec=True) + qurl.fromLocalFile.return_value = QUrl("file:///foo/bar") + os_path.exists.return_value = True monkeypatch.setenv('FOO', 'foo') path = os.path.join(os.path.sep, '$FOO', 'bar') assert self.t.transform(path) == QUrl("file:///foo/bar") diff --git a/tests/javascript/conftest.py b/tests/javascript/conftest.py index d97b38625..3bc65aff1 100644 --- a/tests/javascript/conftest.py +++ b/tests/javascript/conftest.py @@ -28,7 +28,7 @@ import jinja2 from PyQt5.QtWebKit import QWebSettings from PyQt5.QtWebKitWidgets import QWebView, QWebPage -import qutebrowser +from qutebrowser.utils import utils class TestWebPage(QWebPage): @@ -80,8 +80,10 @@ class JSTester: def scroll_anchor(self, name): """Scroll the main frame to the given anchor.""" page = self.webview.page() - with self._qtbot.waitSignal(page.scrollRequested): - page.mainFrame().scrollToAnchor(name) + old_pos = page.mainFrame().scrollPosition() + page.mainFrame().scrollToAnchor(name) + new_pos = page.mainFrame().scrollPosition() + assert old_pos != new_pos def load(self, path, **kwargs): """Load and display the given test data. @@ -92,7 +94,7 @@ class JSTester: **kwargs: Passed to jinja's template.render(). """ template = self._jinja_env.get_template(path) - with self._qtbot.waitSignal(self.webview.loadFinished): + with self._qtbot.waitSignal(self.webview.loadFinished, raising=True): self.webview.setHtml(template.render(**kwargs)) def run_file(self, filename): @@ -105,11 +107,7 @@ class JSTester: Return: The javascript return value. """ - base_path = os.path.join(os.path.dirname(qutebrowser.__file__), - 'javascript') - full_path = os.path.join(base_path, filename) - with open(full_path, 'r', encoding='utf-8') as f: - source = f.read() + source = utils.read_file(os.path.join('javascript', filename)) return self.run(source) def run(self, source): diff --git a/tests/mainwindow/statusbar/test_progress.py b/tests/mainwindow/statusbar/test_progress.py index a0d066808..07e93e0e5 100644 --- a/tests/mainwindow/statusbar/test_progress.py +++ b/tests/mainwindow/statusbar/test_progress.py @@ -44,9 +44,6 @@ def progress_widget(qtbot, monkeypatch, config_stub): return widget -@pytest.mark.xfail( - reason='Blacklisted because it could cause random segfaults - see ' - 'https://github.com/hackebrot/qutebrowser/issues/22', run=False) def test_load_started(progress_widget): """Ensure the Progress widget reacts properly when the page starts loading. diff --git a/tests/misc/test_editor.py b/tests/misc/test_editor.py index abc2eabb0..773769f80 100644 --- a/tests/misc/test_editor.py +++ b/tests/misc/test_editor.py @@ -42,8 +42,8 @@ class TestArg: @pytest.yield_fixture(autouse=True) def setup(self, monkeypatch, stubs): - monkeypatch.setattr('qutebrowser.misc.editor.QProcess', - stubs.FakeQProcess()) + monkeypatch.setattr('qutebrowser.misc.editor.guiprocess.QProcess', + stubs.fake_qprocess()) self.editor = editor.ExternalEditor(0) yield self.editor._cleanup() # pylint: disable=protected-access @@ -60,7 +60,7 @@ class TestArg: stubbed_config.data = { 'general': {'editor': ['bin'], 'editor-encoding': 'utf-8'}} self.editor.edit("") - self.editor._proc.start.assert_called_with("bin", []) + self.editor._proc._proc.start.assert_called_with("bin", []) def test_start_args(self, stubbed_config): """Test starting editor with static arguments.""" @@ -68,7 +68,7 @@ class TestArg: 'general': {'editor': ['bin', 'foo', 'bar'], 'editor-encoding': 'utf-8'}} self.editor.edit("") - self.editor._proc.start.assert_called_with("bin", ["foo", "bar"]) + self.editor._proc._proc.start.assert_called_with("bin", ["foo", "bar"]) def test_placeholder(self, stubbed_config): """Test starting editor with placeholder argument.""" @@ -77,7 +77,7 @@ class TestArg: 'editor-encoding': 'utf-8'}} self.editor.edit("") filename = self.editor._filename - self.editor._proc.start.assert_called_with( + self.editor._proc._proc.start.assert_called_with( "bin", ["foo", filename, "bar"]) def test_in_arg_placeholder(self, stubbed_config): @@ -86,7 +86,7 @@ class TestArg: 'general': {'editor': ['bin', 'foo{}bar'], 'editor-encoding': 'utf-8'}} self.editor.edit("") - self.editor._proc.start.assert_called_with("bin", ["foo{}bar"]) + self.editor._proc._proc.start.assert_called_with("bin", ["foo{}bar"]) class TestFileHandling: @@ -101,8 +101,8 @@ class TestFileHandling: def setup(self, monkeypatch, stubs, config_stub): monkeypatch.setattr('qutebrowser.misc.editor.message', stubs.MessageModule()) - monkeypatch.setattr('qutebrowser.misc.editor.QProcess', - stubs.FakeQProcess()) + monkeypatch.setattr('qutebrowser.misc.editor.guiprocess.QProcess', + stubs.fake_qprocess()) config_stub.data = {'general': {'editor': [''], 'editor-encoding': 'utf-8'}} monkeypatch.setattr('qutebrowser.misc.editor.config', config_stub) @@ -113,7 +113,7 @@ class TestFileHandling: self.editor.edit("") filename = self.editor._filename assert os.path.exists(filename) - self.editor.on_proc_closed(0, QProcess.NormalExit) + self.editor._proc.finished.emit(0, QProcess.NormalExit) assert not os.path.exists(filename) def test_file_handling_closed_error(self, caplog): @@ -122,7 +122,7 @@ class TestFileHandling: filename = self.editor._filename assert os.path.exists(filename) with caplog.atLevel(logging.ERROR): - self.editor.on_proc_closed(1, QProcess.NormalExit) + self.editor._proc.finished.emit(1, QProcess.NormalExit) assert len(caplog.records()) == 2 assert not os.path.exists(filename) @@ -132,9 +132,9 @@ class TestFileHandling: filename = self.editor._filename assert os.path.exists(filename) with caplog.atLevel(logging.ERROR): - self.editor.on_proc_error(QProcess.Crashed) + self.editor._proc.error.emit(QProcess.Crashed) assert len(caplog.records()) == 2 - self.editor.on_proc_closed(0, QProcess.CrashExit) + self.editor._proc.finished.emit(0, QProcess.CrashExit) assert not os.path.exists(filename) @@ -148,8 +148,8 @@ class TestModifyTests: @pytest.fixture(autouse=True) def setup(self, monkeypatch, stubs, config_stub): - monkeypatch.setattr('qutebrowser.misc.editor.QProcess', - stubs.FakeQProcess()) + monkeypatch.setattr('qutebrowser.misc.editor.guiprocess.QProcess', + stubs.fake_qprocess()) config_stub.data = {'general': {'editor': [''], 'editor-encoding': 'utf-8'}} monkeypatch.setattr('qutebrowser.misc.editor.config', config_stub) @@ -182,7 +182,7 @@ class TestModifyTests: self.editor.edit("") assert self._read() == "" self._write("Hello") - self.editor.on_proc_closed(0, QProcess.NormalExit) + self.editor._proc.finished.emit(0, QProcess.NormalExit) self.editor.editing_finished.emit.assert_called_with("Hello") def test_simple_input(self): @@ -190,7 +190,7 @@ class TestModifyTests: self.editor.edit("Hello") assert self._read() == "Hello" self._write("World") - self.editor.on_proc_closed(0, QProcess.NormalExit) + self.editor._proc.finished.emit(0, QProcess.NormalExit) self.editor.editing_finished.emit.assert_called_with("World") def test_umlaut(self): @@ -198,7 +198,7 @@ class TestModifyTests: self.editor.edit("Hällö Wörld") assert self._read() == "Hällö Wörld" self._write("Überprüfung") - self.editor.on_proc_closed(0, QProcess.NormalExit) + self.editor._proc.finished.emit(0, QProcess.NormalExit) self.editor.editing_finished.emit.assert_called_with("Überprüfung") def test_unicode(self): @@ -220,8 +220,8 @@ class TestErrorMessage: @pytest.yield_fixture(autouse=True) def setup(self, monkeypatch, stubs, config_stub): - monkeypatch.setattr('qutebrowser.misc.editor.QProcess', - stubs.FakeQProcess()) + monkeypatch.setattr('qutebrowser.misc.editor.guiprocess.QProcess', + stubs.fake_qprocess()) monkeypatch.setattr('qutebrowser.misc.editor.message', stubs.MessageModule()) config_stub.data = {'general': {'editor': [''], diff --git a/tests/misc/test_guiprocess.py b/tests/misc/test_guiprocess.py new file mode 100644 index 000000000..9c28ab9f0 --- /dev/null +++ b/tests/misc/test_guiprocess.py @@ -0,0 +1,133 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2015 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +# pylint: disable=protected-access + +"""Tests for qutebrowser.misc.guiprocess.""" + +import sys +import textwrap + +import pytest +from PyQt5.QtCore import QProcess + +from qutebrowser.misc import guiprocess + + +# FIXME check statusbar messages + + +no_frozen = pytest.mark.skipif( + getattr(sys, 'frozen', False), reason="Can't be executed when frozen.") + + +def _py_proc(code): + """Get a python executable and args list which executes the given code.""" + return (sys.executable, ['-c', textwrap.dedent(code.strip('\n'))]) + + +@pytest.fixture(autouse=True) +def mock_modules(monkeypatch, stubs): + monkeypatch.setattr('qutebrowser.misc.guiprocess.message', + stubs.MessageModule()) + + +@pytest.yield_fixture() +def proc(qtbot): + """A fixture providing a GUIProcess and cleaning it up after the test.""" + p = guiprocess.GUIProcess(0, 'test') + yield p + if p._proc.state() == QProcess.Running: + with qtbot.waitSignal(p.finished, timeout=2000) as blocker: + p._proc.terminate() + if not blocker.signal_triggered: + p._proc.kill() + + +@pytest.fixture() +def fake_proc(monkeypatch, stubs): + """A fixture providing a GUIProcess with a mocked QProcess.""" + p = guiprocess.GUIProcess(0, 'test') + monkeypatch.setattr(p, '_proc', stubs.fake_qprocess()) + return p + + +@no_frozen +def test_start(proc, qtbot): + """Test simply starting a process.""" + with qtbot.waitSignals([proc.started, proc.finished], raising=True, + timeout=10000): + argv = _py_proc("import sys; print('test'); sys.exit(0)") + proc.start(*argv) + + assert bytes(proc._proc.readAll()).rstrip() == b'test' + + +@pytest.mark.parametrize('argv', [ + no_frozen(_py_proc('import sys; sys.exit(0)')), + ('does_not', 'exist'), +]) +def test_start_detached(fake_proc, argv): + """Test starting a detached process.""" + fake_proc._proc.startDetached.return_value = (True, 0) + fake_proc.start_detached(*argv) + fake_proc._proc.startDetached.assert_called_with(*list(argv) + [None]) + + +@no_frozen +def test_double_start(qtbot, proc): + """Test starting a GUIProcess twice.""" + with qtbot.waitSignal(proc.started, raising=True, timeout=2000): + argv = _py_proc("import time; time.sleep(10)") + proc.start(*argv) + with pytest.raises(ValueError): + proc.start('', []) + + +@no_frozen +def test_double_start_finished(qtbot, proc): + """Test starting a GUIProcess twice (with the first call finished).""" + with qtbot.waitSignals([proc.started, proc.finished], raising=True, + timeout=2000): + argv = _py_proc("import sys; sys.exit(0)") + proc.start(*argv) + with qtbot.waitSignals([proc.started, proc.finished], raising=True, + timeout=2000): + argv = _py_proc("import sys; sys.exit(0)") + proc.start(*argv) + + +def test_cmd_args(proc): + """Test the cmd and args attributes.""" + cmd = 'does_not_exist' + args = ['arg1', 'arg2'] + proc.start(cmd, args) + assert (proc.cmd, proc.args) == (cmd, args) + + +def test_error(qtbot, proc): + """Test the process emitting an error.""" + with qtbot.waitSignal(proc.error, raising=True): + proc.start('this_does_not_exist_either', []) + + +@no_frozen +def test_exit_unsuccessful(qtbot, proc): + with qtbot.waitSignal(proc.finished, raising=True, timeout=2000): + proc.start(*_py_proc('import sys; sys.exit(0)')) diff --git a/tests/stubs.py b/tests/stubs.py index 9d7091581..4345a42f6 100644 --- a/tests/stubs.py +++ b/tests/stubs.py @@ -27,6 +27,7 @@ from unittest import mock from PyQt5.QtCore import pyqtSignal, QPoint, QProcess, QObject from PyQt5.QtNetwork import QNetworkRequest +from PyQt5.QtWidgets import QCommonStyle class FakeKeyEvent: @@ -72,8 +73,10 @@ class FakeQApplication: """Stub to insert as QApplication module.""" - def __init__(self): + def __init__(self, style=None): self.instance = mock.Mock(return_value=self) + self.style = mock.Mock(spec=QCommonStyle) + self.style().metaObject().className.return_value = style class FakeUrl: @@ -147,22 +150,13 @@ class FakeNetworkReply: self.headers[key] = value -class FakeQProcess(mock.Mock): - - """QProcess stub. - - Gets some enum values from the real QProcess. - """ - - NormalExit = QProcess.NormalExit - CrashExit = QProcess.CrashExit - - FailedToStart = QProcess.FailedToStart - Crashed = QProcess.Crashed - Timedout = QProcess.Timedout - WriteError = QProcess.WriteError - ReadError = QProcess.ReadError - UnknownError = QProcess.UnknownError +def fake_qprocess(): + """Factory for a QProcess mock which has the QProcess enum values.""" + m = mock.Mock(spec=QProcess) + for attr in ['NormalExit', 'CrashExit', 'FailedToStart', 'Crashed', + 'Timedout', 'WriteError', 'ReadError', 'UnknownError']: + setattr(m, attr, getattr(QProcess, attr)) + return m class FakeSignal: @@ -267,14 +261,16 @@ class MessageModule: """A drop-in replacement for qutebrowser.utils.message.""" - def error(self, _win_id, message, _immediately=False): + # pylint: disable=unused-argument + + def error(self, _win_id, message, immediately=False): """Log an error to the message logger.""" logging.getLogger('message').error(message) - def warning(self, _win_id, message, _immediately=False): + def warning(self, _win_id, message, immediately=False): """Log a warning to the message logger.""" logging.getLogger('message').warning(message) - def info(self, _win_id, message, _immediately=True): + def info(self, _win_id, message, immediately=True): """Log an info message to the message logger.""" logging.getLogger('message').info(message) diff --git a/tests/utils/test_log.py b/tests/utils/test_log.py index a09a354ee..03575ea5b 100644 --- a/tests/utils/test_log.py +++ b/tests/utils/test_log.py @@ -27,7 +27,6 @@ import itertools import sys import pytest -from PyQt5.QtCore import qWarning from qutebrowser.utils import log @@ -214,7 +213,7 @@ class TestInitLog: @pytest.fixture def args(self): - """Fixture providing an argparse namespace.""" + """Fixture providing an argparse namespace for init_log.""" return argparse.Namespace(debug=True, loglevel=logging.DEBUG, color=True, loglines=10, logfilter="") @@ -230,33 +229,37 @@ class TestHideQtWarning: """Tests for hide_qt_warning/QtWarningFilter.""" - def test_unfiltered(self, caplog): + @pytest.fixture() + def logger(self): + return logging.getLogger('qt-tests') + + def test_unfiltered(self, logger, caplog): """Test a message which is not filtered.""" with log.hide_qt_warning("World", logger='qt-tests'): with caplog.atLevel(logging.WARNING, logger='qt-tests'): - qWarning("Hello World") + logger.warning("Hello World") assert len(caplog.records()) == 1 record = caplog.records()[0] assert record.levelname == 'WARNING' assert record.message == "Hello World" - def test_filtered_exact(self, caplog): + def test_filtered_exact(self, logger, caplog): """Test a message which is filtered (exact match).""" with log.hide_qt_warning("Hello", logger='qt-tests'): with caplog.atLevel(logging.WARNING, logger='qt-tests'): - qWarning("Hello") + logger.warning("Hello") assert not caplog.records() - def test_filtered_start(self, caplog): + def test_filtered_start(self, logger, caplog): """Test a message which is filtered (match at line start).""" with log.hide_qt_warning("Hello", logger='qt-tests'): with caplog.atLevel(logging.WARNING, logger='qt-tests'): - qWarning("Hello World") + logger.warning("Hello World") assert not caplog.records() - def test_filtered_whitespace(self, caplog): + def test_filtered_whitespace(self, logger, caplog): """Test a message which is filtered (match with whitespace).""" with log.hide_qt_warning("Hello", logger='qt-tests'): with caplog.atLevel(logging.WARNING, logger='qt-tests'): - qWarning(" Hello World ") + logger.warning(" Hello World ") assert not caplog.records() diff --git a/tests/utils/test_qtutils.py b/tests/utils/test_qtutils.py index 85a99c2f4..aaec77870 100644 --- a/tests/utils/test_qtutils.py +++ b/tests/utils/test_qtutils.py @@ -19,15 +19,51 @@ """Tests for qutebrowser.utils.qtutils.""" +import io +import os import sys +import operator +import os.path +try: + from test import test_file # pylint: disable=no-name-in-module +except ImportError: + # Debian patches Python to remove the tests... + test_file = None import pytest +import unittest +import unittest.mock +from PyQt5.QtCore import (QDataStream, QPoint, QUrl, QByteArray, QIODevice, + QTimer, QBuffer, QFile, QProcess) +from PyQt5.QtWidgets import QApplication from qutebrowser import qutebrowser from qutebrowser.utils import qtutils import overflow_test_cases +@pytest.mark.parametrize('qversion, version, op, expected', [ + ('5.4.0', '5.4.0', operator.ge, True), + ('5.4.0', '5.4.0', operator.eq, True), + ('5.4.0', '5.4', operator.eq, True), + ('5.4.1', '5.4', operator.ge, True), + ('5.3.2', '5.4', operator.ge, False), + ('5.3.0', '5.3.2', operator.ge, False), +]) +def test_version_check(monkeypatch, qversion, version, op, expected): + """Test for version_check(). + + Args: + monkeypatch: The pytest monkeypatch fixture. + qversion: The version to set as fake qVersion(). + version: The version to compare with. + op: The operator to use when comparing. + expected: The expected result. + """ + monkeypatch.setattr('qutebrowser.utils.qtutils.qVersion', lambda: qversion) + assert qtutils.version_check(version, op) == expected + + class TestCheckOverflow: """Test check_overflow.""" @@ -68,20 +104,18 @@ class TestGetQtArgs: mocker.patch.object(parser, 'exit', side_effect=Exception) return parser - def test_no_qt_args(self, parser): + @pytest.mark.parametrize('args, expected', [ + # No Qt arguments + (['--debug'], [sys.argv[0]]), + # Qt flag + (['--debug', '--qt-reverse', '--nocolor'], [sys.argv[0], '-reverse']), + # Qt argument with value + (['--qt-stylesheet', 'foo'], [sys.argv[0], '-stylesheet', 'foo']), + ]) + def test_qt_args(self, args, expected, parser): """Test commandline with no Qt arguments given.""" - args = parser.parse_args(['--debug']) - assert qtutils.get_args(args) == [sys.argv[0]] - - def test_qt_flag(self, parser): - """Test commandline with a Qt flag.""" - args = parser.parse_args(['--debug', '--qt-reverse', '--nocolor']) - assert qtutils.get_args(args) == [sys.argv[0], '-reverse'] - - def test_qt_arg(self, parser): - """Test commandline with a Qt argument.""" - args = parser.parse_args(['--qt-stylesheet', 'foobar']) - assert qtutils.get_args(args) == [sys.argv[0], '-stylesheet', 'foobar'] + parsed = parser.parse_args(args) + assert qtutils.get_args(parsed) == expected def test_qt_both(self, parser): """Test commandline with a Qt argument and flag.""" @@ -91,3 +125,838 @@ class TestGetQtArgs: assert '-reverse' in qt_args assert '-stylesheet' in qt_args assert 'foobar' in qt_args + + +@pytest.mark.parametrize('os_name, qversion, expected', [ + ('linux', '5.2.1', True), # unaffected OS + ('linux', '5.4.1', True), # unaffected OS + ('nt', '5.2.1', False), + ('nt', '5.3.0', True), # unaffected Qt version + ('nt', '5.4.1', True), # unaffected Qt version +]) +def test_check_print_compat(os_name, qversion, expected, monkeypatch): + """Test check_print_compat. + + Args: + os_name: The fake os.name to set. + qversion: The fake qVersion() to set. + expected: The expected return value. + """ + monkeypatch.setattr('qutebrowser.utils.qtutils.os.name', os_name) + monkeypatch.setattr('qutebrowser.utils.qtutils.qVersion', lambda: qversion) + assert qtutils.check_print_compat() == expected + + +class QtObject: + + """Fake Qt object for test_ensure.""" + + def __init__(self, valid=True, null=False, error=None): + self._valid = valid + self._null = null + self._error = error + + def __repr__(self): + return '' + + def errorString(self): + """Get the fake error, or raise AttributeError if set to None.""" + if self._error is None: + raise AttributeError + else: + return self._error + + def isValid(self): + return self._valid + + def isNull(self): + return self._null + + +@pytest.mark.parametrize('func_name, obj, raising, exc_reason, exc_str', [ + # ensure_valid, good examples + ('ensure_valid', QtObject(valid=True, null=True), False, None, None), + ('ensure_valid', QtObject(valid=True, null=False), False, None, None), + # ensure_valid, bad examples + ('ensure_valid', QtObject(valid=False, null=True), True, None, + ' is not valid'), + ('ensure_valid', QtObject(valid=False, null=False), True, None, + ' is not valid'), + ('ensure_valid', QtObject(valid=False, null=True, error='Test'), True, + 'Test', ' is not valid: Test'), + # ensure_not_null, good examples + ('ensure_not_null', QtObject(valid=True, null=False), False, None, None), + ('ensure_not_null', QtObject(valid=False, null=False), False, None, None), + # ensure_not_null, bad examples + ('ensure_not_null', QtObject(valid=True, null=True), True, None, + ' is null'), + ('ensure_not_null', QtObject(valid=False, null=True), True, None, + ' is null'), + ('ensure_not_null', QtObject(valid=False, null=True, error='Test'), True, + 'Test', ' is null: Test'), +]) +def test_ensure(func_name, obj, raising, exc_reason, exc_str): + """Test ensure_valid and ensure_not_null. + + The function is parametrized as they do nearly the same. + + Args: + func_name: The name of the function to call. + obj: The object to test with. + raising: Whether QtValueError is expected to be raised. + exc_reason: The expected .reason attribute of the exception. + exc_str: The expected string of the exception. + """ + func = getattr(qtutils, func_name) + if raising: + with pytest.raises(qtutils.QtValueError) as excinfo: + func(obj) + assert excinfo.value.reason == exc_reason + assert str(excinfo.value) == exc_str + else: + func(obj) + + +@pytest.mark.parametrize('status, raising, message', [ + (QDataStream.Ok, False, None), + (QDataStream.ReadPastEnd, True, "The data stream has read past the end of " + "the data in the underlying device."), + (QDataStream.ReadCorruptData, True, "The data stream has read corrupt " + "data."), + (QDataStream.WriteFailed, True, "The data stream cannot write to the " + "underlying device."), +]) +def test_check_qdatastream(status, raising, message): + """Test check_qdatastream. + + Args: + status: The status to set on the QDataStream we test with. + raising: Whether check_qdatastream is expected to raise OSError. + message: The expected exception string. + """ + stream = QDataStream() + stream.setStatus(status) + if raising: + with pytest.raises(OSError) as excinfo: + qtutils.check_qdatastream(stream) + assert str(excinfo.value) == message + else: + qtutils.check_qdatastream(stream) + + +def test_qdatastream_status_count(): + """Make sure no new members are added to QDataStream.Status.""" + values = vars(QDataStream).values() + status_vals = [e for e in values if isinstance(e, QDataStream.Status)] + assert len(status_vals) == 4 + + +@pytest.mark.parametrize('obj', [ + QPoint(23, 42), + QUrl('http://www.qutebrowser.org/'), +]) +def test_serialize(obj): + """Test a serialize/deserialize round trip. + + Args: + obj: The object to test with. + """ + new_obj = type(obj)() + qtutils.deserialize(qtutils.serialize(obj), new_obj) + assert new_obj == obj + + +class TestSerializeStream: + + """Tests for serialize_stream and deserialize_stream.""" + + def _set_status(self, stream, status): + """Helper function so mocks can set an error status when used.""" + stream.status.return_value = status + + @pytest.fixture + def stream_mock(self): + """Fixture providing a QDataStream-like mock.""" + m = unittest.mock.MagicMock(spec=QDataStream) + m.status.return_value = QDataStream.Ok + return m + + def test_serialize_pre_error_mock(self, stream_mock): + """Test serialize_stream with an error already set.""" + stream_mock.status.return_value = QDataStream.ReadCorruptData + + with pytest.raises(OSError) as excinfo: + qtutils.serialize_stream(stream_mock, QPoint()) + + assert not stream_mock.__lshift__.called + assert str(excinfo.value) == "The data stream has read corrupt data." + + def test_serialize_post_error_mock(self, stream_mock): + """Test serialize_stream with an error while serializing.""" + obj = QPoint() + stream_mock.__lshift__.side_effect = lambda _other: self._set_status( + stream_mock, QDataStream.ReadCorruptData) + + with pytest.raises(OSError) as excinfo: + qtutils.serialize_stream(stream_mock, obj) + + assert stream_mock.__lshift__.called_once_with(obj) + assert str(excinfo.value) == "The data stream has read corrupt data." + + def test_deserialize_pre_error_mock(self, stream_mock): + """Test deserialize_stream with an error already set.""" + stream_mock.status.return_value = QDataStream.ReadCorruptData + + with pytest.raises(OSError) as excinfo: + qtutils.deserialize_stream(stream_mock, QPoint()) + + assert not stream_mock.__rshift__.called + assert str(excinfo.value) == "The data stream has read corrupt data." + + def test_deserialize_post_error_mock(self, stream_mock): + """Test deserialize_stream with an error while deserializing.""" + obj = QPoint() + stream_mock.__rshift__.side_effect = lambda _other: self._set_status( + stream_mock, QDataStream.ReadCorruptData) + + with pytest.raises(OSError) as excinfo: + qtutils.deserialize_stream(stream_mock, obj) + + assert stream_mock.__rshift__.called_once_with(obj) + assert str(excinfo.value) == "The data stream has read corrupt data." + + def test_round_trip_real_stream(self): + """Test a round trip with a real QDataStream.""" + src_obj = QPoint(23, 42) + dest_obj = QPoint() + data = QByteArray() + + write_stream = QDataStream(data, QIODevice.WriteOnly) + qtutils.serialize_stream(write_stream, src_obj) + + read_stream = QDataStream(data, QIODevice.ReadOnly) + qtutils.deserialize_stream(read_stream, dest_obj) + + assert src_obj == dest_obj + + @pytest.mark.qt_log_ignore('^QIODevice::write.*: ReadOnly device') + def test_serialize_readonly_stream(self): + """Test serialize_stream with a read-only stream.""" + data = QByteArray() + stream = QDataStream(data, QIODevice.ReadOnly) + with pytest.raises(OSError) as excinfo: + qtutils.serialize_stream(stream, QPoint()) + assert str(excinfo.value) == ("The data stream cannot write to the " + "underlying device.") + + @pytest.mark.qt_log_ignore('QIODevice::read.*: WriteOnly device') + def test_deserialize_writeonly_stream(self): + """Test deserialize_stream with a write-only stream.""" + data = QByteArray() + obj = QPoint() + stream = QDataStream(data, QIODevice.WriteOnly) + with pytest.raises(OSError) as excinfo: + qtutils.deserialize_stream(stream, obj) + assert str(excinfo.value) == ("The data stream has read past the end " + "of the data in the underlying device.") + + +class SavefileTestException(Exception): + + """Exception raised in TestSavefileOpen for testing.""" + + pass + + +class TestSavefileOpen: + + """Tests for savefile_open.""" + + ## Tests with a mock testing that the needed methods are called. + + @pytest.yield_fixture + def qsavefile_mock(self, mocker): + """Mock for QSaveFile.""" + m = mocker.patch('qutebrowser.utils.qtutils.QSaveFile') + instance = m() + yield instance + instance.commit.assert_called_once_with() + + def test_mock_open_error(self, qsavefile_mock): + """Test with a mock and a failing open().""" + qsavefile_mock.open.return_value = False + qsavefile_mock.errorString.return_value = "Hello World" + + with pytest.raises(OSError) as excinfo: + with qtutils.savefile_open('filename'): + pass + + qsavefile_mock.open.assert_called_once_with(QIODevice.WriteOnly) + qsavefile_mock.cancelWriting.assert_called_once_with() + assert str(excinfo.value) == "Hello World" + + def test_mock_exception(self, qsavefile_mock): + """Test with a mock and an exception in the block.""" + qsavefile_mock.open.return_value = True + + with pytest.raises(SavefileTestException): + with qtutils.savefile_open('filename'): + raise SavefileTestException + + qsavefile_mock.open.assert_called_once_with(QIODevice.WriteOnly) + qsavefile_mock.cancelWriting.assert_called_once_with() + + def test_mock_commit_failed(self, qsavefile_mock): + """Test with a mock and an exception in the block.""" + qsavefile_mock.open.return_value = True + qsavefile_mock.commit.return_value = False + + with pytest.raises(OSError) as excinfo: + with qtutils.savefile_open('filename'): + pass + + qsavefile_mock.open.assert_called_once_with(QIODevice.WriteOnly) + assert not qsavefile_mock.cancelWriting.called + assert not qsavefile_mock.errorString.called + assert str(excinfo.value) == "Commit failed!" + + def test_mock_successful(self, qsavefile_mock): + """Test with a mock and a successful write.""" + qsavefile_mock.open.return_value = True + qsavefile_mock.errorString.return_value = "Hello World" + qsavefile_mock.commit.return_value = True + qsavefile_mock.write.side_effect = len + qsavefile_mock.isOpen.return_value = True + + with qtutils.savefile_open('filename') as f: + f.write("Hello World") + + qsavefile_mock.open.assert_called_once_with(QIODevice.WriteOnly) + assert not qsavefile_mock.cancelWriting.called + qsavefile_mock.write.assert_called_once_with(b"Hello World") + + ## Tests with real files + + @pytest.mark.parametrize('data', ["Hello World", "Snowman! ☃"]) + def test_utf8(self, data, tmpdir): + """Test with UTF8 data.""" + filename = tmpdir / 'foo' + filename.write("Old data") + with qtutils.savefile_open(str(filename)) as f: + f.write(data) + assert tmpdir.listdir() == [filename] + assert filename.read_text(encoding='utf-8') == data + + def test_binary(self, tmpdir): + """Test with binary data.""" + filename = tmpdir / 'foo' + with qtutils.savefile_open(str(filename), binary=True) as f: + f.write(b'\xde\xad\xbe\xef') + assert tmpdir.listdir() == [filename] + assert filename.read_binary() == b'\xde\xad\xbe\xef' + + def test_exception(self, tmpdir): + """Test with an exception in the block.""" + filename = tmpdir / 'foo' + filename.write("Old content") + with pytest.raises(SavefileTestException): + with qtutils.savefile_open(str(filename)) as f: + f.write("Hello World!") + raise SavefileTestException + assert tmpdir.listdir() == [filename] + assert filename.read_text(encoding='utf-8') == "Old content" + + def test_existing_dir(self, tmpdir): + """Test with the filename already occupied by a directory.""" + filename = tmpdir / 'foo' + filename.mkdir() + with pytest.raises(OSError) as excinfo: + with qtutils.savefile_open(str(filename)): + pass + errors = ["Filename refers to a directory", # Qt >= 5.4 + "Commit failed!"] # older Qt versions + assert str(excinfo.value) in errors + assert tmpdir.listdir() == [filename] + + def test_failing_commit(self, tmpdir): + """Test with the file being closed before comitting.""" + filename = tmpdir / 'foo' + with pytest.raises(OSError) as excinfo: + with qtutils.savefile_open(str(filename), binary=True) as f: + f.write(b'Hello') + f.dev.commit() # provoke failing "real" commit + + assert str(excinfo.value) == "Commit failed!" + assert tmpdir.listdir() == [filename] + + def test_line_endings(self, tmpdir): + """Make sure line endings are translated correctly. + + See https://github.com/The-Compiler/qutebrowser/issues/309 + """ + filename = tmpdir / 'foo' + with qtutils.savefile_open(str(filename)) as f: + f.write('foo\nbar\nbaz') + data = filename.read_binary() + if os.name == 'nt': + assert data == b'foo\r\nbar\r\nbaz' + else: + assert data == b'foo\nbar\nbaz' + + +@pytest.mark.parametrize('orgname, expected', [(None, ''), ('test', 'test')]) +def test_unset_organization(orgname, expected): + """Test unset_organization. + + Args: + orgname: The organizationName to set initially. + expected: The organizationName which is expected when reading back. + """ + app = QApplication.instance() + app.setOrganizationName(orgname) + assert app.organizationName() == expected # sanity check + with qtutils.unset_organization(): + assert app.organizationName() == '' + assert app.organizationName() == expected + + +if test_file is not None: + # If we were able to import Python's test_file module, we run some code + # here which defines unittest TestCases to run the python tests over + # PyQIODevice. + + @pytest.yield_fixture(scope='session', autouse=True) + def clean_up_python_testfile(): + """Clean up the python testfile after tests if tests didn't.""" + yield + try: + os.remove(test_file.TESTFN) + except FileNotFoundError: + pass + + class PyIODeviceTestMixin: + + """Some helper code to run Python's tests with PyQIODevice. + + Attributes: + _data: A QByteArray containing the data in memory. + f: The opened PyQIODevice. + """ + + def setUp(self): + """Set up self.f using a PyQIODevice instead of a real file.""" + self._data = QByteArray() + self.f = self.open(test_file.TESTFN, 'wb') + + def open(self, _fname, mode): + """Open an in-memory PyQIODevice instead of a real file.""" + modes = { + 'wb': QIODevice.WriteOnly | QIODevice.Truncate, + 'w': QIODevice.WriteOnly | QIODevice.Text | QIODevice.Truncate, + 'rb': QIODevice.ReadOnly, + 'r': QIODevice.ReadOnly | QIODevice.Text, + } + try: + qt_mode = modes[mode] + except KeyError: + raise ValueError("Invalid mode {}!".format(mode)) + f = QBuffer(self._data) + f.open(qt_mode) + qiodev = qtutils.PyQIODevice(f) + # Make sure tests using name/mode don't blow up. + qiodev.name = test_file.TESTFN + qiodev.mode = mode + # Create empty TESTFN file because the Python tests try to unlink + # it.after the test. + open(test_file.TESTFN, 'w', encoding='utf-8').close() + return qiodev + + class PyAutoFileTests(PyIODeviceTestMixin, test_file.AutoFileTests, + unittest.TestCase): + + """Unittest testcase to run Python's AutoFileTests.""" + + def testReadinto_text(self): + """Skip this test as BufferedIOBase seems to fail it.""" + pass + + class PyOtherFileTests(PyIODeviceTestMixin, test_file.OtherFileTests, + unittest.TestCase): + + """Unittest testcase to run Python's OtherFileTests.""" + + def testSetBufferSize(self): + """Skip this test as setting buffer size is unsupported.""" + pass + + def testTruncateOnWindows(self): + """Skip this test truncating is unsupported.""" + pass + + +class FailingQIODevice(QIODevice): + + """A fake QIODevice where reads/writes fail.""" + + def isOpen(self): + return True + + def isReadable(self): + return True + + def isWritable(self): + return True + + def write(self, _data): + """Simulate failed write.""" + self.setErrorString("Writing failed") + return -1 + + def read(self, _maxsize): + """Simulate failed read.""" + self.setErrorString("Reading failed") + return None + + def readAll(self): + return self.read(0) + + def readLine(self, maxsize): + return self.read(maxsize) + + +class TestPyQIODevice: + + """Tests for PyQIODevice.""" + + @pytest.yield_fixture + def pyqiodev(self): + """Fixture providing a PyQIODevice with a QByteArray to test.""" + data = QByteArray() + f = QBuffer(data) + qiodev = qtutils.PyQIODevice(f) + yield qiodev + qiodev.close() + + @pytest.fixture + def pyqiodev_failing(self): + """Fixture providing a PyQIODevice with a FailingQIODevice to test.""" + failing = FailingQIODevice() + return qtutils.PyQIODevice(failing) + + @pytest.mark.parametrize('method, args', [ + ('seek', [0]), + ('flush', []), + ('isatty', []), + ('readline', []), + ('tell', []), + ('write', [b'']), + ('read', []), + ]) + def test_closed_device(self, pyqiodev, method, args): + """Test various methods with a closed device. + + Args: + method: The name of the method to call. + args: The arguments to pass. + """ + func = getattr(pyqiodev, method) + with pytest.raises(ValueError) as excinfo: + func(*args) + assert str(excinfo.value) == "IO operation on closed device!" + + @pytest.mark.parametrize('method', ['readline', 'read']) + def test_unreadable(self, pyqiodev, method): + """Test methods with an unreadable device. + + Args: + method: The name of the method to call. + """ + pyqiodev.open(QIODevice.WriteOnly) + func = getattr(pyqiodev, method) + with pytest.raises(OSError) as excinfo: + func() + assert str(excinfo.value) == "Trying to read unreadable file!" + + def test_unwritable(self, pyqiodev): + """Test writing with a read-only device.""" + pyqiodev.open(QIODevice.ReadOnly) + with pytest.raises(OSError) as excinfo: + pyqiodev.write(b'') + assert str(excinfo.value) == "Trying to write to unwritable file!" + + @pytest.mark.parametrize('data', [b'12345', b'']) + def test_len(self, pyqiodev, data): + """Test len()/__len__. + + Args: + data: The data to write before checking if the length equals + len(data). + """ + pyqiodev.open(QIODevice.WriteOnly) + pyqiodev.write(data) + assert len(pyqiodev) == len(data) + + def test_failing_open(self, tmpdir): + """Test open() which fails (because it's an existant directory).""" + qf = QFile(str(tmpdir)) + dev = qtutils.PyQIODevice(qf) + with pytest.raises(OSError) as excinfo: + dev.open(QIODevice.WriteOnly) + errors = ['Access is denied.', # Linux/OS X + 'Is a directory'] # Windows + assert str(excinfo.value) in errors + assert dev.closed + + def test_fileno(self, pyqiodev): + with pytest.raises(io.UnsupportedOperation): + pyqiodev.fileno() + + @pytest.mark.qt_log_ignore('^QBuffer::seek: Invalid pos:') + @pytest.mark.parametrize('offset, whence, pos, data, raising', [ + (0, io.SEEK_SET, 0, b'1234567890', False), + (42, io.SEEK_SET, 0, b'1234567890', True), + (8, io.SEEK_CUR, 8, b'90', False), + (-5, io.SEEK_CUR, 0, b'1234567890', True), + (-2, io.SEEK_END, 8, b'90', False), + (2, io.SEEK_END, 0, b'1234567890', True), + (0, io.SEEK_END, 10, b'', False), + ]) + def test_seek_tell(self, pyqiodev, offset, whence, pos, data, raising): + """Test seek() and tell(). + + The initial position when these tests run is 0. + + Args: + offset: The offset to pass to .seek(). + whence: The whence argument to pass to .seek(). + pos: The expected position after seeking. + data: The expected data to read after seeking. + raising: Whether seeking should raise OSError. + """ + with pyqiodev.open(QIODevice.WriteOnly) as f: + f.write(b'1234567890') + pyqiodev.open(QIODevice.ReadOnly) + if raising: + with pytest.raises(OSError) as excinfo: + pyqiodev.seek(offset, whence) + assert str(excinfo.value) == "seek failed!" + else: + pyqiodev.seek(offset, whence) + assert pyqiodev.tell() == pos + assert pyqiodev.read() == data + + def test_seek_unsupported(self, pyqiodev): + """Test seeking with unsupported whence arguments.""" + if hasattr(os, 'SEEK_HOLE'): + whence = os.SEEK_HOLE # pylint: disable=no-member + elif hasattr(os, 'SEEK_DATA'): + whence = os.SEEK_DATA # pylint: disable=no-member + else: + pytest.skip("Needs os.SEEK_HOLE or os.SEEK_DATA available.") + pyqiodev.open(QIODevice.ReadOnly) + with pytest.raises(io.UnsupportedOperation): + pyqiodev.seek(0, whence) + + @pytest.mark.skipif(getattr(sys, 'frozen', False), + reason="Can't be executed when frozen.") + def test_qprocess(self): + """Test PyQIODevice with a QProcess which is non-sequential. + + This also verifies seek() and tell() behave as expected. + """ + proc = QProcess() + proc.start(sys.executable, ['-c', 'print("Hello World")']) + dev = qtutils.PyQIODevice(proc) + assert not dev.closed + with pytest.raises(OSError) as excinfo: + dev.seek(0) + assert str(excinfo.value) == 'Random access not allowed!' + with pytest.raises(OSError) as excinfo: + dev.tell() + assert str(excinfo.value) == 'Random access not allowed!' + proc.waitForFinished(1000) + proc.kill() + assert bytes(dev.read()).rstrip() == b'Hello World' + + def test_truncate(self, pyqiodev): + with pytest.raises(io.UnsupportedOperation): + pyqiodev.truncate() + + def test_closed(self, pyqiodev): + """Test the closed attribute.""" + assert pyqiodev.closed + pyqiodev.open(QIODevice.ReadOnly) + assert not pyqiodev.closed + pyqiodev.close() + assert pyqiodev.closed + + def test_contextmanager(self, pyqiodev): + """Make sure using the PyQIODevice as context manager works.""" + assert pyqiodev.closed + with pyqiodev.open(QIODevice.ReadOnly) as f: + assert not f.closed + assert f is pyqiodev + assert pyqiodev.closed + + def test_flush(self, pyqiodev): + """Make sure flushing doesn't raise an exception.""" + pyqiodev.open(QIODevice.WriteOnly) + pyqiodev.write(b'test') + pyqiodev.flush() + + @pytest.mark.parametrize('method, ret', [ + ('isatty', False), + ('seekable', True), + ]) + def test_bools(self, method, ret, pyqiodev): + """Make sure simple bool arguments return the right thing. + + Args: + method: The name of the method to call. + ret: The return value we expect. + """ + pyqiodev.open(QIODevice.WriteOnly) + func = getattr(pyqiodev, method) + assert func() == ret + + @pytest.mark.parametrize('mode, readable, writable', [ + (QIODevice.ReadOnly, True, False), + (QIODevice.ReadWrite, True, True), + (QIODevice.WriteOnly, False, True), + ]) + def test_readable_writable(self, mode, readable, writable, pyqiodev): + """Test readable() and writable(). + + Args: + mode: The mode to open the PyQIODevice in. + readable: Whether the device should be readable. + writable: Whether the device should be writable. + """ + assert not pyqiodev.readable() + assert not pyqiodev.writable() + pyqiodev.open(mode) + assert pyqiodev.readable() == readable + assert pyqiodev.writable() == writable + + @pytest.mark.parametrize('size, chunks', [ + (-1, [b'one\n', b'two\n', b'three', b'']), + (0, [b'', b'', b'', b'']), + (2, [b'on', b'e\n', b'tw', b'o\n', b'th', b're', b'e']), + (10, [b'one\n', b'two\n', b'three', b'']), + ]) + def test_readline(self, size, chunks, pyqiodev): + """Test readline() with different sizes. + + Args: + size: The size to pass to readline() + chunks: A list of expected chunks to read. + """ + with pyqiodev.open(QIODevice.WriteOnly) as f: + f.write(b'one\ntwo\nthree') + pyqiodev.open(QIODevice.ReadOnly) + for i, chunk in enumerate(chunks, start=1): + print("Expecting chunk {}: {!r}".format(i, chunk)) + assert pyqiodev.readline(size) == chunk + + def test_write(self, pyqiodev): + """Make sure writing and re-reading works.""" + with pyqiodev.open(QIODevice.WriteOnly) as f: + f.write(b'foo\n') + f.write(b'bar\n') + pyqiodev.open(QIODevice.ReadOnly) + assert pyqiodev.read() == b'foo\nbar\n' + + def test_write_error(self, pyqiodev_failing): + """Test writing with FailingQIODevice.""" + with pytest.raises(OSError) as excinfo: + pyqiodev_failing.write(b'x') + assert str(excinfo.value) == 'Writing failed' + + @pytest.mark.skipif(os.name != 'posix', reason="Needs a POSIX OS.") + @pytest.mark.skipif(not os.path.exists('/dev/full'), + reason="Needs /dev/full.") + def test_write_error_real(self): + """Test a real write error with /dev/full on supported systems.""" + qf = QFile('/dev/full') + qf.open(QIODevice.WriteOnly | QIODevice.Unbuffered) + dev = qtutils.PyQIODevice(qf) + with pytest.raises(OSError) as excinfo: + dev.write(b'foo') + qf.close() + assert str(excinfo.value) == 'No space left on device' + + @pytest.mark.parametrize('size, chunks', [ + (-1, [b'1234567890']), + (0, [b'']), + (3, [b'123', b'456', b'789', b'0']), + (20, [b'1234567890']) + ]) + def test_read(self, size, chunks, pyqiodev): + """Test reading with different sizes. + + Args: + size: The size to pass to read() + chunks: A list of expected data chunks. + """ + with pyqiodev.open(QIODevice.WriteOnly) as f: + f.write(b'1234567890') + pyqiodev.open(QIODevice.ReadOnly) + for i, chunk in enumerate(chunks): + print("Expecting chunk {}: {!r}".format(i, chunk)) + assert pyqiodev.read(size) == chunk + + @pytest.mark.parametrize('method, args', [ + ('read', []), + ('read', [5]), + ('readline', []), + ('readline', [5]), + ]) + def test_failing_reads(self, method, args, pyqiodev_failing): + """Test reading with a FailingQIODevice. + + Args: + method: The name of the method to call. + args: A list of arguments to pass. + """ + func = getattr(pyqiodev_failing, method) + with pytest.raises(OSError) as excinfo: + func(*args) + assert str(excinfo.value) == 'Reading failed' + + +class TestEventLoop: + + """Tests for EventLoop. + + Attributes: + loop: The EventLoop we're testing. + """ + + # pylint: disable=protected-access + + def _assert_executing(self): + """Slot which gets called from timers to be sure the loop runs.""" + assert self.loop._executing + + def _double_exec(self): + """Slot which gets called from timers to assert double-exec fails.""" + with pytest.raises(AssertionError): + self.loop.exec_() + + def test_normal_exec(self): + """Test exec_ without double-executing.""" + self.loop = qtutils.EventLoop() + QTimer.singleShot(100, self._assert_executing) + QTimer.singleShot(200, self.loop.quit) + self.loop.exec_() + assert not self.loop._executing + + def test_double_exec(self): + """Test double-executing.""" + self.loop = qtutils.EventLoop() + QTimer.singleShot(100, self._assert_executing) + QTimer.singleShot(200, self._double_exec) + QTimer.singleShot(300, self._assert_executing) + QTimer.singleShot(400, self.loop.quit) + self.loop.exec_() + assert not self.loop._executing diff --git a/tests/utils/test_standarddir.py b/tests/utils/test_standarddir.py index 6be44d92c..02bcc02d6 100644 --- a/tests/utils/test_standarddir.py +++ b/tests/utils/test_standarddir.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +# pylint: disable=protected-access + """Tests for qutebrowser.utils.standarddir.""" import os @@ -24,7 +26,10 @@ import os.path import sys import types import collections +import logging +import textwrap +from PyQt5.QtCore import QStandardPaths from PyQt5.QtWidgets import QApplication import pytest @@ -44,8 +49,61 @@ def change_qapp_name(): QApplication.instance().setApplicationName(old_name) +@pytest.fixture +def no_cachedir_tag(monkeypatch): + """Fixture to prevent writing a CACHEDIR.TAG.""" + monkeypatch.setattr('qutebrowser.utils.standarddir._init_cachedir_tag', + lambda: None) + + +@pytest.fixture(autouse=True) +@pytest.mark.usefixtures('no_cachedir_tag') +def reset_standarddir(): + standarddir.init(None) + + +@pytest.mark.parametrize('data_subdir, config_subdir, expected', [ + ('foo', 'foo', 'foo/data'), + ('foo', 'bar', 'foo'), +]) +def test_get_fake_windows_equal_dir(data_subdir, config_subdir, expected, + monkeypatch, tmpdir): + """Test _get with a fake Windows OS with equal data/config dirs.""" + locations = { + QStandardPaths.DataLocation: str(tmpdir / data_subdir), + QStandardPaths.ConfigLocation: str(tmpdir / config_subdir), + } + monkeypatch.setattr('qutebrowser.utils.standarddir.os.name', 'nt') + monkeypatch.setattr( + 'qutebrowser.utils.standarddir.QStandardPaths.writableLocation', + locations.get) + expected = str(tmpdir / expected) + assert standarddir.data() == expected + + +class TestWritableLocation: + + """Tests for _writable_location.""" + + def test_empty(self, monkeypatch): + """Test QStandardPaths returning an empty value.""" + monkeypatch.setattr( + 'qutebrowser.utils.standarddir.QStandardPaths.writableLocation', + lambda typ: '') + with pytest.raises(ValueError): + standarddir._writable_location(QStandardPaths.DataLocation) + + def test_sep(self, monkeypatch): + """Make sure the right kind of separator is used.""" + monkeypatch.setattr('qutebrowser.utils.standarddir.os.sep', '\\') + loc = standarddir._writable_location(QStandardPaths.DataLocation) + assert '/' not in loc + assert '\\' in loc + + @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="requires Linux") +@pytest.mark.usefixtures('no_cachedir_tag') class TestGetStandardDirLinux: """Tests for standarddir under Linux.""" @@ -53,26 +111,22 @@ class TestGetStandardDirLinux: def test_data_explicit(self, monkeypatch, tmpdir): """Test data dir with XDG_DATA_HOME explicitly set.""" monkeypatch.setenv('XDG_DATA_HOME', str(tmpdir)) - standarddir.init(None) assert standarddir.data() == str(tmpdir / 'qutebrowser_test') def test_config_explicit(self, monkeypatch, tmpdir): """Test config dir with XDG_CONFIG_HOME explicitly set.""" monkeypatch.setenv('XDG_CONFIG_HOME', str(tmpdir)) - standarddir.init(None) assert standarddir.config() == str(tmpdir / 'qutebrowser_test') def test_cache_explicit(self, monkeypatch, tmpdir): """Test cache dir with XDG_CACHE_HOME explicitly set.""" monkeypatch.setenv('XDG_CACHE_HOME', str(tmpdir)) - standarddir.init(None) assert standarddir.cache() == str(tmpdir / 'qutebrowser_test') def test_data(self, monkeypatch, tmpdir): """Test data dir with XDG_DATA_HOME not set.""" monkeypatch.setenv('HOME', str(tmpdir)) monkeypatch.delenv('XDG_DATA_HOME', raising=False) - standarddir.init(None) expected = tmpdir / '.local' / 'share' / 'qutebrowser_test' assert standarddir.data() == str(expected) @@ -80,7 +134,6 @@ class TestGetStandardDirLinux: """Test config dir with XDG_CONFIG_HOME not set.""" monkeypatch.setenv('HOME', str(tmpdir)) monkeypatch.delenv('XDG_CONFIG_HOME', raising=False) - standarddir.init(None) expected = tmpdir / '.config' / 'qutebrowser_test' assert standarddir.config() == str(expected) @@ -88,21 +141,17 @@ class TestGetStandardDirLinux: """Test cache dir with XDG_CACHE_HOME not set.""" monkeypatch.setenv('HOME', str(tmpdir)) monkeypatch.delenv('XDG_CACHE_HOME', raising=False) - standarddir.init(None) expected = tmpdir / '.cache' / 'qutebrowser_test' assert standarddir.cache() == expected @pytest.mark.skipif(not sys.platform.startswith("win"), reason="requires Windows") +@pytest.mark.usefixtures('no_cachedir_tag') class TestGetStandardDirWindows: """Tests for standarddir under Windows.""" - @pytest.fixture(autouse=True) - def reset_standarddir(self): - standarddir.init(None) - def test_data(self): """Test data dir.""" expected = ['qutebrowser_test', 'data'] @@ -121,6 +170,7 @@ class TestGetStandardDirWindows: DirArgTest = collections.namedtuple('DirArgTest', 'arg, expected') +@pytest.mark.usefixtures('no_cachedir_tag') class TestArguments: """Tests with confdir/cachedir/datadir arguments.""" @@ -131,6 +181,7 @@ class TestArguments: if request.param.expected is None: return request.param else: + # prepend tmpdir to both arg = str(tmpdir / request.param.arg) return DirArgTest(arg, arg) @@ -155,6 +206,21 @@ class TestArguments: standarddir.init(args) assert standarddir.data() == testcase.expected + def test_confdir_none(self): + """Test --confdir with None given.""" + args = types.SimpleNamespace(confdir=None, cachedir=None, datadir=None) + standarddir.init(args) + assert standarddir.config().split(os.sep)[-1] == 'qutebrowser_test' + + def test_runtimedir(self, tmpdir, monkeypatch): + """Test runtime dir (which has no args).""" + monkeypatch.setattr( + 'qutebrowser.utils.standarddir.QStandardPaths.writableLocation', + lambda _typ: str(tmpdir)) + args = types.SimpleNamespace(confdir=None, cachedir=None, datadir=None) + standarddir.init(args) + assert standarddir.runtime() == str(tmpdir) + @pytest.mark.parametrize('typ', ['config', 'data', 'cache', 'download', 'runtime']) def test_basedir(self, tmpdir, typ): @@ -164,3 +230,51 @@ class TestArguments: standarddir.init(args) func = getattr(standarddir, typ) assert func() == expected + + +class TestInitCacheDirTag: + + """Tests for _init_cachedir_tag.""" + + def test_no_cache_dir(self, mocker, monkeypatch): + """Smoke test with cache() returning None.""" + monkeypatch.setattr('qutebrowser.utils.standarddir.cache', + lambda: None) + mocker.patch('builtins.open', side_effect=AssertionError) + standarddir._init_cachedir_tag() + + def test_existant_cache_dir_tag(self, tmpdir, mocker, monkeypatch): + """Test with an existant CACHEDIR.TAG.""" + monkeypatch.setattr('qutebrowser.utils.standarddir.cache', + lambda: str(tmpdir)) + mocker.patch('builtins.open', side_effect=AssertionError) + m = mocker.patch('qutebrowser.utils.standarddir.os.path.exists', + return_value=True) + standarddir._init_cachedir_tag() + assert not tmpdir.listdir() + m.assert_called_with(str(tmpdir / 'CACHEDIR.TAG')) + + def test_new_cache_dir_tag(self, tmpdir, mocker, monkeypatch): + """Test creating a new CACHEDIR.TAG.""" + monkeypatch.setattr('qutebrowser.utils.standarddir.cache', + lambda: str(tmpdir)) + standarddir._init_cachedir_tag() + assert tmpdir.listdir() == [(tmpdir / 'CACHEDIR.TAG')] + data = (tmpdir / 'CACHEDIR.TAG').read_text('utf-8') + assert data == textwrap.dedent(""" + Signature: 8a477f597d28d172789f06886806bc55 + # This file is a cache directory tag created by qutebrowser. + # For information about cache directory tags, see: + # http://www.brynosaurus.com/cachedir/ + """).lstrip() + + def test_open_oserror(self, caplog, tmpdir, mocker, monkeypatch): + """Test creating a new CACHEDIR.TAG.""" + monkeypatch.setattr('qutebrowser.utils.standarddir.cache', + lambda: str(tmpdir)) + mocker.patch('builtins.open', side_effect=OSError) + with caplog.atLevel(logging.ERROR, 'misc'): + standarddir._init_cachedir_tag() + assert len(caplog.records()) == 1 + assert caplog.records()[0].message == 'Failed to create CACHEDIR.TAG' + assert not tmpdir.listdir() diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 588753587..ed2028d86 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -23,14 +23,22 @@ import sys import enum import datetime import os.path +import io +import logging +import functools from PyQt5.QtCore import Qt from PyQt5.QtGui import QColor import pytest +import qutebrowser +import qutebrowser.utils # for test_qualname from qutebrowser.utils import utils, qtutils +ELLIPSIS = '\u2026' + + class Color(QColor): """A QColor with a nicer repr().""" @@ -41,39 +49,196 @@ class Color(QColor): alpha=self.alpha()) +class TestCompactText: + + """Test compact_text.""" + + @pytest.mark.parametrize('text, expected', [ + ('foo\nbar', 'foobar'), + (' foo \n bar ', 'foobar'), + ('\nfoo\n', 'foo'), + ]) + def test_compact_text(self, text, expected): + """Test folding of newlines.""" + assert utils.compact_text(text) == expected + + @pytest.mark.parametrize('elidelength, text, expected', [ + (None, 'x' * 100, 'x' * 100), + (6, 'foobar', 'foobar'), + (5, 'foobar', 'foob' + ELLIPSIS), + (5, 'foo\nbar', 'foob' + ELLIPSIS), + (7, 'foo\nbar', 'foobar'), + ]) + def test_eliding(self, elidelength, text, expected): + """Test eliding.""" + assert utils.compact_text(text, elidelength) == expected + + class TestEliding: """Test elide.""" - ELLIPSIS = '\u2026' - def test_too_small(self): """Test eliding to 0 chars which should fail.""" with pytest.raises(ValueError): utils.elide('foo', 0) - def test_length_one(self): - """Test eliding to 1 char which should yield ...""" - assert utils.elide('foo', 1) == self.ELLIPSIS - - def test_fits(self): - """Test eliding with a string which fits exactly.""" - assert utils.elide('foo', 3) == 'foo' - - def test_elided(self): - """Test eliding with a string which should get elided.""" - assert utils.elide('foobar', 3) == 'fo' + self.ELLIPSIS + @pytest.mark.parametrize('text, length, expected', [ + ('foo', 1, ELLIPSIS), + ('foo', 3, 'foo'), + ('foobar', 3, 'fo' + ELLIPSIS), + ]) + def test_elided(self, text, length, expected): + assert utils.elide(text, length) == expected class TestReadFile: """Test read_file.""" + @pytest.fixture(autouse=True, params=[True, False]) + def freezer(self, request, monkeypatch): + if request.param and not getattr(sys, 'frozen', False): + monkeypatch.setattr(sys, 'frozen', True, raising=False) + monkeypatch.setattr('sys.executable', qutebrowser.__file__) + elif not request.param and getattr(sys, 'frozen', False): + # Want to test unfrozen tests, but we are frozen + pytest.skip("Can't run with sys.frozen = True!") + def test_readfile(self): """Read a test file.""" content = utils.read_file(os.path.join('utils', 'testfile')) assert content.splitlines()[0] == "Hello World!" + def test_readfile_binary(self): + """Read a test file in binary mode.""" + content = utils.read_file(os.path.join('utils', 'testfile'), + binary=True) + assert content.splitlines()[0] == b"Hello World!" + + +class Patcher: + + """Helper for TestActuteWarning. + + Attributes: + monkeypatch: The pytest monkeypatch fixture. + """ + + def __init__(self, monkeypatch): + self.monkeypatch = monkeypatch + + def patch_platform(self, platform='linux'): + """Patch sys.platform.""" + self.monkeypatch.setattr('sys.platform', platform) + + def patch_exists(self, exists=True): + """Patch os.path.exists.""" + self.monkeypatch.setattr('qutebrowser.utils.utils.os.path.exists', + lambda path: exists) + + def patch_version(self, version='5.2.0'): + """Patch Qt version.""" + self.monkeypatch.setattr( + 'qutebrowser.utils.utils.qtutils.qVersion', lambda: version) + + def patch_file(self, data): + """Patch open() to return the given data.""" + fake_file = io.StringIO(data) + self.monkeypatch.setattr(utils, 'open', + lambda filename, mode, encoding: fake_file, + raising=False) + + def patch_all(self, data): + """Patch everything so the issue would exist.""" + self.patch_platform() + self.patch_exists() + self.patch_version() + self.patch_file(data) + + +class TestActuteWarning: + + """Test actute_warning.""" + + @pytest.fixture + def patcher(self, monkeypatch): + """Fixture providing a Patcher helper.""" + return Patcher(monkeypatch) + + def test_non_linux(self, patcher, capsys): + """Test with a non-Linux OS.""" + patcher.patch_platform('toaster') + utils.actute_warning() + out, err = capsys.readouterr() + assert not out + assert not err + + def test_no_compose(self, patcher, capsys): + """Test with no compose file.""" + patcher.patch_platform() + patcher.patch_exists(False) + utils.actute_warning() + out, err = capsys.readouterr() + assert not out + assert not err + + def test_newer_qt(self, patcher, capsys): + """Test with compose file but newer Qt version.""" + patcher.patch_platform() + patcher.patch_exists() + patcher.patch_version('5.4') + utils.actute_warning() + out, err = capsys.readouterr() + assert not out + assert not err + + def test_no_match(self, patcher, capsys): + """Test with compose file and affected Qt but no match.""" + patcher.patch_all('foobar') + utils.actute_warning() + out, err = capsys.readouterr() + assert not out + assert not err + + def test_empty(self, patcher, capsys): + """Test with empty compose file.""" + patcher.patch_all(None) + utils.actute_warning() + out, err = capsys.readouterr() + assert not out + assert not err + + def test_match(self, patcher, capsys): + """Test with compose file and affected Qt and a match.""" + patcher.patch_all('foobar\n\nbaz') + utils.actute_warning() + out, err = capsys.readouterr() + assert out.startswith('Note: If you got a') + assert not err + + def test_match_stdout_none(self, monkeypatch, patcher, capsys): + """Test with a match and stdout being None.""" + patcher.patch_all('foobar\n\nbaz') + monkeypatch.setattr('sys.stdout', None) + utils.actute_warning() + + def test_unreadable(self, mocker, patcher, capsys, caplog): + """Test with an unreadable compose file.""" + patcher.patch_platform() + patcher.patch_exists() + patcher.patch_version() + mocker.patch('qutebrowser.utils.utils.open', side_effect=OSError, + create=True) + + with caplog.atLevel(logging.ERROR, 'init'): + utils.actute_warning() + + assert len(caplog.records()) == 1 + assert caplog.records()[0].message == 'Failed to read Compose file' + out, _err = capsys.readouterr() + assert not out + class TestInterpolateColor: @@ -164,62 +329,40 @@ class TestInterpolateColor: assert Color(color) == expected -class TestFormatSeconds: - - """Tests for format_seconds. - - Class attributes: - TESTS: A list of (input, output) tuples. - """ - - TESTS = [ - (-1, '-0:01'), - (0, '0:00'), - (59, '0:59'), - (60, '1:00'), - (60.4, '1:00'), - (61, '1:01'), - (-61, '-1:01'), - (3599, '59:59'), - (3600, '1:00:00'), - (3601, '1:00:01'), - (36000, '10:00:00'), - ] - - @pytest.mark.parametrize('seconds, out', TESTS) - def test_format_seconds(self, seconds, out): - """Test format_seconds with several tests.""" - assert utils.format_seconds(seconds) == out +@pytest.mark.parametrize('seconds, out', [ + (-1, '-0:01'), + (0, '0:00'), + (59, '0:59'), + (60, '1:00'), + (60.4, '1:00'), + (61, '1:01'), + (-61, '-1:01'), + (3599, '59:59'), + (3600, '1:00:00'), + (3601, '1:00:01'), + (36000, '10:00:00'), +]) +def test_format_seconds(seconds, out): + assert utils.format_seconds(seconds) == out -class TestFormatTimedelta: - - """Tests for format_timedelta. - - Class attributes: - TESTS: A list of (input, output) tuples. - """ - - TESTS = [ - (datetime.timedelta(seconds=-1), '-1s'), - (datetime.timedelta(seconds=0), '0s'), - (datetime.timedelta(seconds=59), '59s'), - (datetime.timedelta(seconds=120), '2m'), - (datetime.timedelta(seconds=60.4), '1m'), - (datetime.timedelta(seconds=63), '1m 3s'), - (datetime.timedelta(seconds=-64), '-1m 4s'), - (datetime.timedelta(seconds=3599), '59m 59s'), - (datetime.timedelta(seconds=3600), '1h'), - (datetime.timedelta(seconds=3605), '1h 5s'), - (datetime.timedelta(seconds=3723), '1h 2m 3s'), - (datetime.timedelta(seconds=3780), '1h 3m'), - (datetime.timedelta(seconds=36000), '10h'), - ] - - @pytest.mark.parametrize('td, out', TESTS) - def test_format_seconds(self, td, out): - """Test format_seconds with several tests.""" - assert utils.format_timedelta(td) == out +@pytest.mark.parametrize('td, out', [ + (datetime.timedelta(seconds=-1), '-1s'), + (datetime.timedelta(seconds=0), '0s'), + (datetime.timedelta(seconds=59), '59s'), + (datetime.timedelta(seconds=120), '2m'), + (datetime.timedelta(seconds=60.4), '1m'), + (datetime.timedelta(seconds=63), '1m 3s'), + (datetime.timedelta(seconds=-64), '-1m 4s'), + (datetime.timedelta(seconds=3599), '59m 59s'), + (datetime.timedelta(seconds=3600), '1h'), + (datetime.timedelta(seconds=3605), '1h 5s'), + (datetime.timedelta(seconds=3723), '1h 2m 3s'), + (datetime.timedelta(seconds=3780), '1h 3m'), + (datetime.timedelta(seconds=36000), '10h'), +]) +def test_format_timedelta(td, out): + assert utils.format_timedelta(td) == out class TestFormatSize: @@ -264,29 +407,34 @@ class TestKeyToString: """Test key_to_string.""" - def test_unicode_garbage_keys(self): + @pytest.mark.parametrize('key, expected', [ + (Qt.Key_Blue, 'Blue'), + (Qt.Key_Backtab, 'Tab'), + (Qt.Key_Escape, 'Escape'), + (Qt.Key_A, 'A'), + (Qt.Key_degree, '°'), + (Qt.Key_Meta, 'Meta'), + ]) + def test_normal(self, key, expected): """Test a special key where QKeyEvent::toString works incorrectly.""" - assert utils.key_to_string(Qt.Key_Blue) == 'Blue' + assert utils.key_to_string(key) == expected - def test_backtab(self): - """Test if backtab is normalized to tab correctly.""" - assert utils.key_to_string(Qt.Key_Backtab) == 'Tab' - - def test_escape(self): - """Test if escape is normalized to escape correctly.""" - assert utils.key_to_string(Qt.Key_Escape) == 'Escape' - - def test_letter(self): - """Test a simple letter key.""" + def test_missing(self, monkeypatch): + """Test with a missing key.""" + monkeypatch.delattr('qutebrowser.utils.utils.Qt.Key_Blue') + # We don't want to test the key which is actually missing - we only + # want to know if the mapping still behaves properly. assert utils.key_to_string(Qt.Key_A) == 'A' - def test_unicode(self): - """Test a printable unicode key.""" - assert utils.key_to_string(Qt.Key_degree) == '°' - - def test_special(self): - """Test a non-printable key handled by QKeyEvent::toString.""" - assert utils.key_to_string(Qt.Key_F1) == 'F1' + def test_all(self): + """Make sure there's some sensible output for all keys.""" + for name, value in sorted(vars(Qt).items()): + if not isinstance(value, Qt.Key): + continue + print(name) + string = utils.key_to_string(value) + assert string + string.encode('utf-8') # make sure it's encodable class TestKeyEventToString: @@ -323,26 +471,275 @@ class TestKeyEventToString: Qt.MetaModifier | Qt.ShiftModifier)) assert utils.keyevent_to_string(evt) == 'Ctrl+Alt+Meta+Shift+A' + def test_mac(self, monkeypatch, fake_keyevent_factory): + """Test with a simulated mac.""" + monkeypatch.setattr('sys.platform', 'darwin') + evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) + assert utils.keyevent_to_string(evt) == 'Meta+A' -class TestNormalize: - """Test normalize_keystr.""" +@pytest.mark.parametrize('orig, repl', [ + ('Control+x', 'ctrl+x'), + ('Windows+x', 'meta+x'), + ('Mod1+x', 'alt+x'), + ('Mod4+x', 'meta+x'), + ('Control--', 'ctrl+-'), + ('Windows++', 'meta++'), + ('ctrl-x', 'ctrl+x'), + ('control+x', 'ctrl+x') +]) +def test_normalize_keystr(orig, repl): + assert utils.normalize_keystr(orig) == repl - STRINGS = ( - ('Control+x', 'ctrl+x'), - ('Windows+x', 'meta+x'), - ('Mod1+x', 'alt+x'), - ('Mod4+x', 'meta+x'), - ('Control--', 'ctrl+-'), - ('Windows++', 'meta++'), - ('ctrl-x', 'ctrl+x'), - ('control+x', 'ctrl+x') - ) - @pytest.mark.parametrize('orig, repl', STRINGS) - def test_normalize(self, orig, repl): - """Test normalize with some strings.""" - assert utils.normalize_keystr(orig) == repl +class TestFakeIOStream: + + """Test FakeIOStream.""" + + def _write_func(self, text): + return text + + def test_flush(self): + """Smoke-test to see if flushing works.""" + s = utils.FakeIOStream(self._write_func) + s.flush() + + def test_isatty(self): + """Make sure isatty() is always false.""" + s = utils.FakeIOStream(self._write_func) + assert not s.isatty() + + def test_write(self): + """Make sure writing works.""" + s = utils.FakeIOStream(self._write_func) + assert s.write('echo') == 'echo' + + +class TestFakeIO: + + """Test FakeIO.""" + + @pytest.yield_fixture(autouse=True) + def restore_streams(self): + """Restore sys.stderr/sys.stdout after tests.""" + old_stdout = sys.stdout + old_stderr = sys.stderr + yield + sys.stdout = old_stdout + sys.stderr = old_stderr + + def test_normal(self, capsys): + """Test without changing sys.stderr/sys.stdout.""" + data = io.StringIO() + with utils.fake_io(data.write): + sys.stdout.write('hello\n') + sys.stderr.write('world\n') + + out, err = capsys.readouterr() + assert not out + assert not err + assert data.getvalue() == 'hello\nworld\n' + + sys.stdout.write('back to\n') + sys.stderr.write('normal\n') + out, err = capsys.readouterr() + assert out == 'back to\n' + assert err == 'normal\n' + + def test_stdout_replaced(self, capsys): + """Test with replaced stdout.""" + data = io.StringIO() + new_stdout = io.StringIO() + with utils.fake_io(data.write): + sys.stdout.write('hello\n') + sys.stderr.write('world\n') + sys.stdout = new_stdout + + out, err = capsys.readouterr() + assert not out + assert not err + assert data.getvalue() == 'hello\nworld\n' + + sys.stdout.write('still new\n') + sys.stderr.write('normal\n') + out, err = capsys.readouterr() + assert not out + assert err == 'normal\n' + assert new_stdout.getvalue() == 'still new\n' + + def test_stderr_replaced(self, capsys): + """Test with replaced stderr.""" + data = io.StringIO() + new_stderr = io.StringIO() + with utils.fake_io(data.write): + sys.stdout.write('hello\n') + sys.stderr.write('world\n') + sys.stderr = new_stderr + + out, err = capsys.readouterr() + assert not out + assert not err + assert data.getvalue() == 'hello\nworld\n' + + sys.stdout.write('normal\n') + sys.stderr.write('still new\n') + out, err = capsys.readouterr() + assert out == 'normal\n' + assert not err + assert new_stderr.getvalue() == 'still new\n' + + +class GotException(Exception): + + """Exception used for TestDisabledExcepthook.""" + + pass + + +def excepthook(_exc, _val, _tb): + return + + +def excepthook_2(_exc, _val, _tb): + return + + +class TestDisabledExcepthook: + + """Test disabled_excepthook. + + This doesn't test much as some things are untestable without triggering + the excepthook (which is hard to test). + """ + + @pytest.yield_fixture(autouse=True) + def restore_excepthook(self): + """Restore sys.excepthook and sys.__excepthook__ after tests.""" + old_excepthook = sys.excepthook + old_dunder_excepthook = sys.__excepthook__ + yield + sys.excepthook = old_excepthook + sys.__excepthook__ = old_dunder_excepthook + + def test_normal(self): + """Test without changing sys.excepthook.""" + sys.excepthook = excepthook + assert sys.excepthook is excepthook + with utils.disabled_excepthook(): + assert sys.excepthook is not excepthook + assert sys.excepthook is excepthook + + def test_changed(self): + """Test with changed sys.excepthook.""" + sys.excepthook = excepthook + with utils.disabled_excepthook(): + assert sys.excepthook is not excepthook + sys.excepthook = excepthook_2 + assert sys.excepthook is excepthook_2 + + +class TestPreventExceptions: + + """Test prevent_exceptions.""" + + @utils.prevent_exceptions(42) + def func_raising(self): + raise Exception + + def test_raising(self, caplog): + """Test with a raising function.""" + with caplog.atLevel(logging.ERROR, 'misc'): + ret = self.func_raising() + assert ret == 42 + assert len(caplog.records()) == 1 + expected = 'Error in test_utils.TestPreventExceptions.func_raising' + actual = caplog.records()[0].message + assert actual == expected + + @utils.prevent_exceptions(42) + def func_not_raising(self): + return 23 + + def test_not_raising(self, caplog): + """Test with a non-raising function.""" + with caplog.atLevel(logging.ERROR, 'misc'): + ret = self.func_not_raising() + assert ret == 23 + assert not caplog.records() + + @utils.prevent_exceptions(42, True) + def func_predicate_true(self): + raise Exception + + def test_predicate_true(self, caplog): + """Test with a True predicate.""" + with caplog.atLevel(logging.ERROR, 'misc'): + ret = self.func_predicate_true() + assert ret == 42 + assert len(caplog.records()) == 1 + + @utils.prevent_exceptions(42, False) + def func_predicate_false(self): + raise Exception + + def test_predicate_false(self, caplog): + """Test with a False predicate.""" + with caplog.atLevel(logging.ERROR, 'misc'): + with pytest.raises(Exception): + self.func_predicate_false() + assert not caplog.records() + + +class Obj: + + """Test object for test_get_repr().""" + + pass + + +@pytest.mark.parametrize('constructor, attrs, expected', [ + (False, {}, ''), + (False, {'foo': None}, ''), + (False, {'foo': "b'ar", 'baz': 2}, ''), + (True, {}, 'test_utils.Obj()'), + (True, {'foo': None}, 'test_utils.Obj(foo=None)'), + (True, {'foo': "te'st", 'bar': 2}, 'test_utils.Obj(bar=2, foo="te\'st")'), +]) +def test_get_repr(constructor, attrs, expected): + """Test get_repr().""" + assert utils.get_repr(Obj(), constructor, **attrs) == expected + + +class QualnameObj(): + + """Test object for test_qualname.""" + + def func(self): + """Test method for test_qualname.""" + pass + + +def qualname_func(_blah): + """Test function for test_qualname.""" + pass + + +QUALNAME_OBJ = QualnameObj() + + +@pytest.mark.parametrize('obj, expected', [ + (QUALNAME_OBJ, repr(QUALNAME_OBJ)), # instance - unknown + (QualnameObj, 'test_utils.QualnameObj'), # class + (QualnameObj.func, 'test_utils.QualnameObj.func'), # unbound method + (QualnameObj().func, 'test_utils.QualnameObj.func'), # bound method + (qualname_func, 'test_utils.qualname_func'), # function + (functools.partial(qualname_func, True), 'test_utils.qualname_func'), + (qutebrowser, 'qutebrowser'), # module + (qutebrowser.utils, 'qutebrowser.utils'), # submodule + (utils, 'qutebrowser.utils.utils'), # submodule (from-import) +]) +def test_qualname(obj, expected): + assert utils.qualname(obj) == expected class TestIsEnum: @@ -412,20 +809,13 @@ class TestRaises: utils.raises(ValueError, self.do_raise) -class TestForceEncoding: - - """Test force_encoding.""" - - TESTS = [ - ('hello world', 'ascii', 'hello world'), - ('hellö wörld', 'utf-8', 'hellö wörld'), - ('hellö wörld', 'ascii', 'hell? w?rld'), - ] - - @pytest.mark.parametrize('inp, enc, expected', TESTS) - def test_fitting_ascii(self, inp, enc, expected): - """Test force_encoding will yield expected text.""" - assert utils.force_encoding(inp, enc) == expected +@pytest.mark.parametrize('inp, enc, expected', [ + ('hello world', 'ascii', 'hello world'), + ('hellö wörld', 'utf-8', 'hellö wörld'), + ('hellö wörld', 'ascii', 'hell? w?rld'), +]) +def test_force_encoding(inp, enc, expected): + assert utils.force_encoding(inp, enc) == expected class TestNewestSlice: @@ -437,44 +827,23 @@ class TestNewestSlice: with pytest.raises(ValueError): utils.newest_slice([], -2) - def test_count_minus_one(self): - """Test with a count of -1 (all elements).""" - items = range(20) - sliced = utils.newest_slice(items, -1) - assert list(sliced) == list(items) - - def test_count_zero(self): - """Test with a count of 0 (no elements).""" - items = range(20) - sliced = utils.newest_slice(items, 0) - assert list(sliced) == [] - - def test_count_much_smaller(self): - """Test with a count which is much smaller than the iterable.""" - items = range(20) - sliced = utils.newest_slice(items, 5) - assert list(sliced) == [15, 16, 17, 18, 19] - - def test_count_smaller(self): - """Test with a count which is exactly one smaller.""" - items = range(5) - sliced = utils.newest_slice(items, 4) - assert list(sliced) == [1, 2, 3, 4] - - def test_count_equal(self): - """Test with a count which is just as large as the iterable.""" - items = range(5) - sliced = utils.newest_slice(items, 5) - assert list(sliced) == list(items) - - def test_count_bigger(self): - """Test with a count which is one bigger than the iterable.""" - items = range(5) - sliced = utils.newest_slice(items, 6) - assert list(sliced) == list(items) - - def test_count_much_bigger(self): - """Test with a count which is much bigger than the iterable.""" - items = range(5) - sliced = utils.newest_slice(items, 50) - assert list(sliced) == list(items) + @pytest.mark.parametrize('items, count, expected', [ + # Count of -1 (all elements). + (range(20), -1, range(20)), + # Count of 0 (no elements). + (range(20), 0, []), + # Count which is much smaller than the iterable. + (range(20), 5, [15, 16, 17, 18, 19]), + # Count which is exactly one smaller.""" + (range(5), 4, [1, 2, 3, 4]), + # Count which is just as large as the iterable.""" + (range(5), 5, range(5)), + # Count which is one bigger than the iterable. + (range(5), 6, range(5)), + # Count which is much bigger than the iterable. + (range(5), 50, range(5)), + ]) + def test_good(self, items, count, expected): + """Test slices which shouldn't raise an exception.""" + sliced = utils.newest_slice(items, count) + assert list(sliced) == list(expected) diff --git a/tests/utils/test_version.py b/tests/utils/test_version.py new file mode 100644 index 000000000..3e21a57c1 --- /dev/null +++ b/tests/utils/test_version.py @@ -0,0 +1,628 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2015 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +# pylint: disable=protected-access + +"""Tests for qutebrowser.utils.version.""" + +import io +import sys +import os.path +import subprocess +import contextlib +import builtins +import types +import importlib +import logging +import textwrap + +import pytest + +import qutebrowser +from qutebrowser.utils import version + + +class GitStrSubprocessFake: + + """Object returned by the git_str_subprocess_fake fixture. + + This provides a function which is used to patch _git_str_subprocess. + + Attributes: + retval: The value to return when called. Needs to be set before func is + called. + """ + + UNSET = object() + + def __init__(self): + self.retval = self.UNSET + + def func(self, gitpath): + """Function called instead of _git_str_subprocess. + + Checks whether the path passed is what we expected, and returns + self.retval. + """ + if self.retval is self.UNSET: + raise ValueError("func got called without retval being set!") + retval = self.retval + self.retval = self.UNSET + gitpath = os.path.normpath(gitpath) + expected = os.path.abspath(os.path.join( + os.path.dirname(qutebrowser.__file__), os.pardir)) + assert gitpath == expected + return retval + + +class TestGitStr: + + """Tests for _git_str().""" + + @pytest.yield_fixture + def commit_file_mock(self, mocker): + """Fixture providing a mock for utils.read_file for git-commit-id. + + On fixture teardown, it makes sure it got called with git-commit-id as + argument. + """ + mocker.patch('qutebrowser.utils.version.subprocess', + side_effect=AssertionError) + m = mocker.patch('qutebrowser.utils.version.utils.read_file') + yield m + m.assert_called_with('git-commit-id') + + @pytest.fixture + def git_str_subprocess_fake(self, mocker, monkeypatch): + """Fixture patching _git_str_subprocess with a GitStrSubprocessFake.""" + mocker.patch('qutebrowser.utils.version.subprocess', + side_effect=AssertionError) + fake = GitStrSubprocessFake() + monkeypatch.setattr('qutebrowser.utils.version._git_str_subprocess', + fake.func) + return fake + + def test_frozen_ok(self, commit_file_mock, monkeypatch): + """Test with sys.frozen=True and a successful git-commit-id read.""" + monkeypatch.setattr(qutebrowser.utils.version.sys, 'frozen', True, + raising=False) + commit_file_mock.return_value = 'deadbeef' + assert version._git_str() == 'deadbeef' + + def test_frozen_oserror(self, commit_file_mock, monkeypatch): + """Test with sys.frozen=True and OSError when reading git-commit-id.""" + monkeypatch.setattr(qutebrowser.utils.version.sys, 'frozen', True, + raising=False) + commit_file_mock.side_effect = OSError + assert version._git_str() is None + + @pytest.mark.skipif(getattr(sys, 'frozen', False), + reason="Can't be executed when frozen!") + def test_normal_successful(self, git_str_subprocess_fake): + """Test with git returning a successful result.""" + git_str_subprocess_fake.retval = 'c0ffeebabe' + assert version._git_str() == 'c0ffeebabe' + + @pytest.mark.skipif(not getattr(sys, 'frozen', False), + reason="Can only executed when frozen!") + def test_normal_successful_frozen(self, git_str_subprocess_fake): + """Test with git returning a successful result.""" + # The value is defined in scripts/freeze_tests.py. + assert version._git_str() == 'fake-frozen-git-commit' + + def test_normal_error(self, commit_file_mock, git_str_subprocess_fake): + """Test without repo (but git-commit-id).""" + git_str_subprocess_fake.retval = None + commit_file_mock.return_value = '1b4d1dea' + assert version._git_str() == '1b4d1dea' + + def test_normal_path_oserror(self, mocker, git_str_subprocess_fake): + """Test with things raising OSError.""" + mocker.patch('qutebrowser.utils.version.os.path.join', + side_effect=OSError) + mocker.patch('qutebrowser.utils.version.utils.read_file', + side_effect=OSError) + assert version._git_str() is None + + @pytest.mark.skipif(getattr(sys, 'frozen', False), + reason="Can't be executed when frozen!") + def test_normal_path_nofile(self, monkeypatch, caplog, + git_str_subprocess_fake, commit_file_mock): + """Test with undefined __file__ but available git-commit-id.""" + monkeypatch.delattr('qutebrowser.utils.version.__file__') + commit_file_mock.return_value = '0deadcode' + with caplog.atLevel(logging.ERROR, 'misc'): + assert version._git_str() == '0deadcode' + assert len(caplog.records()) == 1 + assert caplog.records()[0].message == "Error while getting git path" + + +def _has_git(): + """Check if git is installed.""" + try: + subprocess.check_call(['git', '--version'], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + except (OSError, subprocess.CalledProcessError): + return False + else: + return True + + +# Decorator for tests needing git, so they get skipped when it's unavailable. +needs_git = pytest.mark.skipif(not _has_git(), reason='Needs git installed.') + + +class TestGitStrSubprocess: + + """Tests for _git_str_subprocess.""" + + @pytest.fixture + def git_repo(self, tmpdir): + """A fixture to create a temporary git repo. + + Some things are tested against a real repo so we notice if something in + git would change, or we call git incorrectly. + """ + def _git(*args): + """Helper closure to call git.""" + env = { + 'GIT_AUTHOR_NAME': 'qutebrowser testsuite', + 'GIT_AUTHOR_EMAIL': 'mail@qutebrowser.org', + 'GIT_AUTHOR_DATE': 'Thu 1 Jan 01:00:00 CET 1970', + 'GIT_COMMITTER_NAME': 'qutebrowser testsuite', + 'GIT_COMMITTER_EMAIL': 'mail@qutebrowser.org', + 'GIT_COMMITTER_DATE': 'Thu 1 Jan 01:00:00 CET 1970', + } + subprocess.check_call(['git', '-C', str(tmpdir)] + list(args), + env=env) + + (tmpdir / 'file').write_text("Hello World!", encoding='utf-8') + _git('init') + _git('add', 'file') + _git('commit', '-am', 'foo', '--no-verify', '--no-edit', + '--no-post-rewrite', '--quiet', '--no-gpg-sign') + _git('tag', 'foobar') + return tmpdir + + @needs_git + def test_real_git(self, git_repo): + """Test with a real git repository.""" + ret = version._git_str_subprocess(str(git_repo)) + assert ret == 'foobar (1970-01-01 01:00:00 +0100)' + + def test_missing_dir(self, tmpdir): + """Test with a directory which doesn't exist.""" + ret = version._git_str_subprocess(str(tmpdir / 'does-not-exist')) + assert ret is None + + @pytest.mark.parametrize('exc', [ + OSError, + subprocess.CalledProcessError(1, 'foobar') + ]) + def test_exception(self, exc, mocker, tmpdir): + """Test with subprocess.check_output raising an exception. + + Args: + exc: The exception to raise. + """ + mocker.patch('qutebrowser.utils.version.subprocess.os.path.isdir', + return_value=True) + mocker.patch('qutebrowser.utils.version.subprocess.check_output', + side_effect=exc) + ret = version._git_str_subprocess(str(tmpdir)) + assert ret is None + + +class ReleaseInfoFake: + + """An object providing fakes for glob.glob/open for test_release_info. + + Attributes: + _files: The files which should be returned, or None if an exception + should be raised. A {filename: [lines]} dict. + """ + + def __init__(self, files): + self._files = files + + def glob_fake(self, pattern): + """Fake for glob.glob. + + Verifies the arguments and returns the files listed in self._files, or + a single fake file if an exception is expected. + """ + assert pattern == '/etc/*-release' + if self._files is None: + return ['fake-file'] + else: + return sorted(list(self._files)) + + @contextlib.contextmanager + def open_fake(self, filename, mode, encoding): + """Fake for open(). + + Verifies the arguments and returns a StringIO with the content listed + in self._files. + """ + assert mode == 'r' + assert encoding == 'utf-8' + if self._files is None: + raise OSError + yield io.StringIO(''.join(self._files[filename])) + + +@pytest.mark.parametrize('files, expected', [ + ({}, []), + ({'file': ['']}, [('file', '')]), + ({'file': []}, [('file', '')]), + ( + {'file1': ['foo\n', 'bar\n'], 'file2': ['baz\n']}, + [('file1', 'foo\nbar\n'), ('file2', 'baz\n')] + ), + (None, []), +]) +def test_release_info(files, expected, caplog, monkeypatch): + """Test _release_info(). + + Args: + files: The file dict passed to ReleaseInfoFake. + expected: The expected _release_info output. + """ + fake = ReleaseInfoFake(files) + monkeypatch.setattr('qutebrowser.utils.version.glob.glob', fake.glob_fake) + monkeypatch.setattr(version, 'open', fake.open_fake, raising=False) + with caplog.atLevel(logging.ERROR, 'misc'): + assert version._release_info() == expected + if files is None: + assert len(caplog.records()) == 1 + assert caplog.records()[0].message == "Error while reading fake-file." + + +class ImportFake: + + """A fake for __import__ which is used by the import_fake fixture. + + Attributes: + exists: A dict mapping module names to bools. If True, the import will + success. Otherwise, it'll fail with ImportError. + version_attribute: The name to use in the fake modules for the version + attribute. + version: The version to use for the modules. + _real_import: Saving the real __import__ builtin so the imports can be + done normally for modules not in self.exists. + """ + + def __init__(self): + self.exists = { + 'sip': True, + 'colorlog': True, + 'colorama': True, + 'pypeg2': True, + 'jinja2': True, + 'pygments': True, + 'yaml': True, + } + self.version_attribute = '__version__' + self.version = '1.2.3' + self._real_import = builtins.__import__ + + def _do_import(self, name): + """Helper for fake_import and fake_importlib_import to do the work. + + Return: + The imported fake module, or None if normal importing should be + used. + """ + if name not in self.exists: + # Not one of the modules to test -> use real import + return None + elif self.exists[name]: + ns = types.SimpleNamespace() + if self.version_attribute is not None: + setattr(ns, self.version_attribute, self.version) + return ns + else: + raise ImportError("Fake ImportError for {}.".format(name)) + + def fake_import(self, name, *args, **kwargs): + """Fake for the builtin __import__.""" + module = self._do_import(name) + if module is not None: + return module + else: + return self._real_import(name, *args, **kwargs) + + def fake_importlib_import(self, name): + """Fake for importlib.import_module.""" + module = self._do_import(name) + if module is not None: + return module + else: + return importlib.import_module(name) + + +@pytest.fixture +def import_fake(monkeypatch): + """Fixture to patch imports using ImportFake.""" + fake = ImportFake() + monkeypatch.setattr('builtins.__import__', fake.fake_import) + monkeypatch.setattr('qutebrowser.utils.version.importlib.import_module', + fake.fake_importlib_import) + return fake + + +class TestModuleVersions: + + """Tests for _module_versions().""" + + @pytest.mark.usefixtures('import_fake') + def test_all_present(self): + """Test with all modules present in version 1.2.3.""" + expected = ['sip: yes', 'colorlog: yes', 'colorama: 1.2.3', + 'pypeg2: 1.2.3', 'jinja2: 1.2.3', 'pygments: 1.2.3', + 'yaml: 1.2.3'] + assert version._module_versions() == expected + + @pytest.mark.parametrize('module, idx, expected', [ + ('colorlog', 1, 'colorlog: no'), + ('colorama', 2, 'colorama: no'), + ]) + def test_missing_module(self, module, idx, expected, import_fake): + """Test with a module missing. + + Args: + module: The name of the missing module. + idx: The index where the given text is expected. + expected: The expected text. + """ + import_fake.exists[module] = False + assert version._module_versions()[idx] == expected + + @pytest.mark.parametrize('value, expected', [ + ('VERSION', ['sip: yes', 'colorlog: yes', 'colorama: 1.2.3', + 'pypeg2: yes', 'jinja2: yes', 'pygments: yes', + 'yaml: yes']), + ('SIP_VERSION_STR', ['sip: 1.2.3', 'colorlog: yes', 'colorama: yes', + 'pypeg2: yes', 'jinja2: yes', 'pygments: yes', + 'yaml: yes']), + (None, ['sip: yes', 'colorlog: yes', 'colorama: yes', 'pypeg2: yes', + 'jinja2: yes', 'pygments: yes', 'yaml: yes']), + ]) + def test_version_attribute(self, value, expected, import_fake): + """Test with a different version attribute. + + VERSION is tested for old colorama versions, and None to make sure + things still work if some package suddenly doesn't have __version__. + + Args: + value: The name of the version attribute. + expected: The expected return value. + """ + import_fake.version_attribute = value + assert version._module_versions() == expected + + @pytest.mark.parametrize('name, has_version', [ + ('colorlog', False), + ('sip', False), + ('colorama', True), + ('pypeg2', True), + ('jinja2', True), + ('pygments', True), + ('yaml', True), + ]) + def test_existing_attributes(self, name, has_version): + """Check if all dependencies have an expected __version__ attribute. + + The aim of this test is to fail if modules suddenly don't have a + __version__ attribute anymore in a newer version, or colorlog has one. + + Args: + name: The name of the module to check. + has_version: Whether a __version__ attribute is expected. + """ + module = importlib.import_module(name) + assert hasattr(module, '__version__') == has_version + + def test_existing_sip_attribute(self): + """Test if sip has a SIP_VERSION_STR attribute. + + The aim of this test is to fail if that gets missing in some future + version of sip. + """ + import sip + assert isinstance(sip.SIP_VERSION_STR, str) + + +class TestOsInfo: + + """Tests for _os_info.""" + + @pytest.mark.parametrize('dist, dist_str', [ + (('x', '', 'y'), 'x, y'), + (('a', 'b', 'c'), 'a, b, c'), + (('', '', ''), ''), + ]) + def test_linux_fake(self, monkeypatch, dist, dist_str): + """Test with a fake Linux. + + Args: + dist: The value to set platform.dist() to. + dist_str: The expected distribution string in version._os_info(). + """ + monkeypatch.setattr('qutebrowser.utils.version.sys.platform', 'linux') + monkeypatch.setattr('qutebrowser.utils.version._release_info', + lambda: [('releaseinfo', 'Hello World')]) + monkeypatch.setattr('qutebrowser.utils.version.platform.dist', + lambda: dist) + ret = version._os_info() + expected = ['OS Version: {}'.format(dist_str), '', + '--- releaseinfo ---', 'Hello World'] + assert ret == expected + + def test_windows_fake(self, monkeypatch): + """Test with a fake Windows.""" + monkeypatch.setattr('qutebrowser.utils.version.sys.platform', 'win32') + monkeypatch.setattr('qutebrowser.utils.version.platform.win32_ver', + lambda: ('eggs', 'bacon', 'ham', 'spam')) + ret = version._os_info() + expected = ['OS Version: eggs, bacon, ham, spam'] + assert ret == expected + + @pytest.mark.parametrize('mac_ver, mac_ver_str', [ + (('x', ('', '', ''), 'y'), 'x, y'), + (('', ('', '', ''), ''), ''), + (('x', ('1', '2', '3'), 'y'), 'x, 1.2.3, y'), + ]) + def test_os_x_fake(self, monkeypatch, mac_ver, mac_ver_str): + """Test with a fake OS X. + + Args: + mac_ver: The tuple to set platform.mac_ver() to. + mac_ver_str: The expected Mac version string in version._os_info(). + """ + monkeypatch.setattr('qutebrowser.utils.version.sys.platform', 'darwin') + monkeypatch.setattr('qutebrowser.utils.version.platform.mac_ver', + lambda: mac_ver) + ret = version._os_info() + expected = ['OS Version: {}'.format(mac_ver_str)] + assert ret == expected + + def test_unknown_fake(self, monkeypatch): + """Test with a fake unknown sys.platform.""" + monkeypatch.setattr('qutebrowser.utils.version.sys.platform', + 'toaster') + ret = version._os_info() + expected = ['OS Version: ?'] + assert ret == expected + + @pytest.mark.skipif(sys.platform != 'linux', reason="requires Linux") + def test_linux_real(self): + """Make sure there are no exceptions with a real Linux.""" + version._os_info() + + @pytest.mark.skipif(sys.platform != 'win32', reason="requires Windows") + def test_windows_real(self): + """Make sure there are no exceptions with a real Windows.""" + version._os_info() + + @pytest.mark.skipif(sys.platform != 'darwin', reason="requires OS X") + def test_os_x_real(self): + """Make sure there are no exceptions with a real OS X.""" + version._os_info() + + +class FakeQSslSocket: + + """Fake for the QSslSocket Qt class. + + Attributes: + _version: What QSslSocket::sslLibraryVersionString() should return. + """ + + def __init__(self, version=None): + self._version = version + + def supportsSsl(self): + """Fake for QSslSocket::supportsSsl().""" + return True + + def sslLibraryVersionString(self): + """Fake for QSslSocket::sslLibraryVersionString().""" + if self._version is None: + raise AssertionError("Got valled with version None!") + return self._version + + +@pytest.mark.parametrize('git_commit, harfbuzz, frozen, short', [ + (True, True, False, False), # normal + (False, True, False, False), # no git commit + (True, False, False, False), # HARFBUZZ unset + (True, True, True, False), # frozen + (True, True, False, True), # short + (False, True, False, True), # short and no git commit +]) +def test_version_output(git_commit, harfbuzz, frozen, short, stubs, + monkeypatch): + """Test version.version().""" + patches = { + 'qutebrowser.__version__': 'VERSION', + '_git_str': lambda: ('GIT COMMIT' if git_commit else None), + 'platform.python_implementation': lambda: 'PYTHON IMPLEMENTATION', + 'platform.python_version': lambda: 'PYTHON VERSION', + 'QT_VERSION_STR': 'QT VERSION', + 'qVersion': lambda: 'QT RUNTIME VERSION', + 'PYQT_VERSION_STR': 'PYQT VERSION', + '_module_versions': lambda: ['MODULE VERSION 1', 'MODULE VERSION 2'], + 'qWebKitVersion': lambda: 'WEBKIT VERSION', + 'QSslSocket': FakeQSslSocket('SSL VERSION'), + 'platform.platform': lambda: 'PLATFORM', + 'platform.architecture': lambda: ('ARCHITECTURE', ''), + '_os_info': lambda: ['OS INFO 1', 'OS INFO 2'], + 'QApplication': stubs.FakeQApplication(style='STYLE'), + } + + for attr, val in patches.items(): + monkeypatch.setattr('qutebrowser.utils.version.' + attr, val) + + monkeypatch.setenv('DESKTOP_SESSION', 'DESKTOP') + + if harfbuzz: + monkeypatch.setenv('QT_HARFBUZZ', 'HARFBUZZ') + else: + monkeypatch.delenv('QT_HARFBUZZ', raising=False) + + if frozen: + monkeypatch.setattr(sys, 'frozen', True, raising=False) + else: + monkeypatch.delattr(sys, 'frozen', raising=False) + + template = textwrap.dedent(""" + qutebrowser vVERSION + {git_commit} + PYTHON IMPLEMENTATION: PYTHON VERSION + Qt: QT VERSION, runtime: QT RUNTIME VERSION + PyQt: PYQT VERSION + """.lstrip('\n')) + + if git_commit: + substitutions = {'git_commit': 'Git commit: GIT COMMIT\n'} + else: + substitutions = {'git_commit': ''} + + if not short: + template += textwrap.dedent(""" + Style: STYLE + Desktop: DESKTOP + MODULE VERSION 1 + MODULE VERSION 2 + Webkit: WEBKIT VERSION + Harfbuzz: {harfbuzz} + SSL: SSL VERSION + + Frozen: {frozen} + Platform: PLATFORM, ARCHITECTURE + OS INFO 1 + OS INFO 2 + """.lstrip('\n')) + + substitutions['harfbuzz'] = 'HARFBUZZ' if harfbuzz else 'system' + substitutions['frozen'] = str(frozen) + + expected = template.rstrip('\n').format(**substitutions) + assert version.version(short=short) == expected diff --git a/tests/utils/usertypes/test_enum.py b/tests/utils/usertypes/test_enum.py index e1443b7be..7298b2861 100644 --- a/tests/utils/usertypes/test_enum.py +++ b/tests/utils/usertypes/test_enum.py @@ -54,3 +54,9 @@ def test_start(): e = usertypes.enum('Enum', ['three', 'four'], start=3) assert e.three.value == 3 assert e.four.value == 4 + + +def test_exit(): + """Make sure the exit status enum is correct.""" + assert usertypes.Exit.ok == 0 + assert usertypes.Exit.reserved == 1 diff --git a/tox.ini b/tox.ini index 0b14e4e74..6676915ee 100644 --- a/tox.ini +++ b/tox.ini @@ -4,9 +4,10 @@ # and then run "tox" from this directory. [tox] -envlist = unittests,misc,pep257,pyflakes,pep8,mccabe,pylint,pyroma,check-manifest +envlist = smoke,unittests,misc,pep257,pyflakes,pep8,mccabe,pylint,pyroma,check-manifest [testenv] +passenv = PYTHON basepython = python3 [testenv:mkvenv] @@ -17,37 +18,47 @@ usedevelop = true [testenv:unittests] # https://bitbucket.org/hpk42/tox/issue/246/ - only needed for Windows though setenv = QT_QPA_PLATFORM_PLUGIN_PATH={envdir}/Lib/site-packages/PyQt5/plugins/platforms -passenv = DISPLAY XAUTHORITY HOME +passenv = PYTHON DISPLAY XAUTHORITY HOME deps = -r{toxinidir}/requirements.txt - py==1.4.27 - pytest==2.7.1 + py==1.4.30 + pytest==2.7.2 pytest-capturelog==0.7 - pytest-qt==1.3.0 - pytest-mock==0.5 + pytest-qt==1.4.0 + pytest-mock==0.6.0 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 -rfEsw {posargs} +[testenv:unittests-frozen] +setenv = {[testenv:unittests]setenv} +passenv = {[testenv:unittests]passenv} +skip_install = true +deps = + {[testenv:unittests]deps} + cx_Freeze==4.3.4 +commands = + {envpython} scripts/link_pyqt.py --tox {envdir} + {envpython} scripts/freeze_tests.py build_exe -b {envdir}/build + {envdir}/build/run-frozen-tests --strict -rfEsw {posargs} + [testenv:coverage] -passenv = DISPLAY XAUTHORITY HOME +passenv = PYTHON DISPLAY XAUTHORITY HOME deps = {[testenv:unittests]deps} coverage==3.7.1 pytest-cov==1.8.1 cov-core==1.15.0 commands = - {[testenv:mkvenv]commands} + {envpython} scripts/link_pyqt.py --tox {envdir} {envpython} -m py.test --strict -rfEswx -v --cov qutebrowser --cov-report term --cov-report html {posargs} [testenv:misc] commands = {envpython} scripts/misc_checks.py git - {envpython} scripts/misc_checks.py vcs qutebrowser scripts tests - {envpython} scripts/misc_checks.py spelling qutebrowser scripts tests + {envpython} scripts/misc_checks.py vcs + {envpython} scripts/misc_checks.py spelling [testenv:pylint] skip_install = true @@ -60,14 +71,14 @@ deps = logilab-common==0.63.2 six==1.9.0 commands = - {[testenv:mkvenv]commands} - {envdir}/bin/pylint scripts qutebrowser --rcfile=.pylintrc --output-format=colorized --reports=no - {envpython} scripts/run_pylint_on_tests.py --rcfile=.pylintrc --output-format=colorized --reports=no + {envpython} scripts/link_pyqt.py --tox {envdir} + {envpython} -m pylint scripts qutebrowser --rcfile=.pylintrc --output-format=colorized --reports=no --expected-line-ending-format=LF + {envpython} scripts/run_pylint_on_tests.py --rcfile=.pylintrc --output-format=colorized --reports=no --expected-line-ending-format=LF [testenv:pep257] skip_install = true deps = pep257==0.5.0 -passenv = LANG +passenv = PYTHON LANG # Disabled checks: # D102: Docstring missing, will be handled by others # D209: Blank line before closing """ (removed from PEP257) @@ -79,43 +90,43 @@ commands = {envpython} -m pep257 scripts tests qutebrowser --ignore=D102,D103,D2 setenv = LANG=en_US.UTF-8 deps = -r{toxinidir}/requirements.txt - py==1.4.27 - pytest==2.7.1 - pyflakes==0.9.0 - pytest-flakes==0.2 + py==1.4.30 + pytest==2.7.2 + pyflakes==0.9.2 + pytest-flakes==1.0.0 commands = - {[testenv:mkvenv]commands} - {envpython} -m py.test -q --flakes -m flakes + {envpython} scripts/link_pyqt.py --tox {envdir} + {envpython} -m py.test -q --flakes --ignore=tests [testenv:pep8] deps = -r{toxinidir}/requirements.txt - py==1.4.27 - pytest==2.7.1 + py==1.4.30 + pytest==2.7.2 pep8==1.6.2 pytest-pep8==1.0.6 commands = - {[testenv:mkvenv]commands} - {envpython} -m py.test -q --pep8 -m pep8 + {envpython} scripts/link_pyqt.py --tox {envdir} + {envpython} -m py.test -q --pep8 --ignore=tests [testenv:mccabe] deps = -r{toxinidir}/requirements.txt - py==1.4.27 - pytest==2.7.1 - mccabe==0.3 + py==1.4.30 + pytest==2.7.2 + mccabe==0.3.1 pytest-mccabe==0.1 commands = - {[testenv:mkvenv]commands} - {envpython} -m py.test -q --mccabe -m mccabe + {envpython} scripts/link_pyqt.py --tox {envdir} + {envpython} -m py.test -q --mccabe --ignore=tests [testenv:pyroma] skip_install = true deps = - pyroma==1.8.1 + pyroma==1.8.2 docutils==0.12 commands = - {[testenv:mkvenv]commands} + {envpython} scripts/link_pyqt.py --tox {envdir} {envdir}/bin/pyroma . [testenv:check-manifest] @@ -123,7 +134,7 @@ skip_install = true deps = check-manifest==0.25 commands = - {[testenv:mkvenv]commands} + {envpython} scripts/link_pyqt.py --tox {envdir} {envdir}/bin/check-manifest --ignore 'qutebrowser/git-commit-id,qutebrowser/html/doc,qutebrowser/html/doc/*,*/__pycache__' [testenv:docs] @@ -132,7 +143,7 @@ whitelist_externals = git deps = -r{toxinidir}/requirements.txt commands = - {[testenv:mkvenv]commands} + {envpython} scripts/link_pyqt.py --tox {envdir} {envpython} scripts/src2asciidoc.py git --no-pager diff --exit-code --stat {envpython} scripts/asciidoc2html.py {posargs} @@ -140,15 +151,35 @@ commands = [testenv:smoke] # https://bitbucket.org/hpk42/tox/issue/246/ - only needed for Windows though setenv = QT_QPA_PLATFORM_PLUGIN_PATH={envdir}/Lib/site-packages/PyQt5/plugins/platforms -passenv = DISPLAY XAUTHORITY HOME USERNAME USER +passenv = PYTHON DISPLAY XAUTHORITY HOME USERNAME USER deps = -r{toxinidir}/requirements.txt -# 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 qutebrowser --no-err-windows --nowindow --temp-basedir about:blank ":later 500 quit" +[testenv:smoke-frozen] +setenv = {[testenv:smoke]setenv} +passenv = {[testenv:smoke]passenv} +skip_install = true +deps = + {[testenv:smoke]deps} + cx_Freeze==4.3.4 +commands = + {envpython} scripts/link_pyqt.py --tox {envdir} + {envpython} scripts/freeze.py build_exe --qute-skip-html -b {envdir}/build + {envdir}/build/qutebrowser --no-err-windows --nowindow --temp-basedir about:blank ":later 500 quit" + +[testenv:cxfreeze-windows] +# PYTHON is actually required when using this env, but the entire tox.ini would +# fail if we didn't have a fallback defined. +basepython = {env:PYTHON:}/python.exe +skip_install = true +deps = {[testenv:smoke-frozen]deps} +commands = + {envpython} scripts/link_pyqt.py --tox {envdir} + {envpython} scripts/freeze.py {posargs} + [pytest] norecursedirs = .tox .venv markers = @@ -165,3 +196,8 @@ pep8ignore = W503 # line break before binary operator resources.py ALL mccabe-complexity = 12 +qt_log_level_fail = WARNING +qt_log_ignore = + ^SpellCheck: .* + ^SetProcessDpiAwareness failed: .* + ^QWindowsWindow::setGeometryDp: Unable to set geometry .*