diff --git a/.appveyor.yml b/.appveyor.yml new file mode 100644 index 000000000..a1f3e247f --- /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\dev\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 a4abb32a0..664eda267 100644 --- a/.pylintrc +++ b/.pylintrc @@ -27,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..59556f0bf --- /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/dev/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 92cc4cd24..5cb9e9e0b 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -14,42 +14,61 @@ This project adheres to http://semver.org/[Semantic Versioning]. // `Fixed` for any bug fixes. // `Security` to invite users to upgrade in case of vulnerabilities. -v0.3.0 (unreleased) -------------------- - -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 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 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 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 (hidden) command `:clear-keychain` to clear a partially entered keychain (bound to `` by default, in addition to clearing search). +v0.4.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. +- Some developer scripts got moved to `scripts/dev/` +- When downloading to a FIFO or special file, a confirmation is displayed as + this might cause qutebrowser to hang. + +v0.3.0 +------ + +Added +~~~~~ + +- New commands `:message-info`, `:message-error` and `:message-warning` to show messages in the statusbar, e.g. from a userscript. +- 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]. +- 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 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 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 +~~~~~~~ + +- *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 ~~~~~~~~~~ @@ -60,29 +79,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/INSTALL.asciidoc b/INSTALL.asciidoc index 5e615a594..115a56531 100644 --- a/INSTALL.asciidoc +++ b/INSTALL.asciidoc @@ -28,7 +28,7 @@ Then install the packages like this: ---- # apt-get update -# apt-get install -t experimental python3-pyqt5 python3-pyqt5.qtwebkit +# apt-get install -t experimental python3-pyqt5 python3-pyqt5.qtwebkit python3-sip # apt-get install python-tox ---- @@ -46,7 +46,7 @@ For distributions other than Debian or if you prefer to not use the experimental repo: ---- -# apt-get install python3-pyqt5 python3-pyqt5.qtwebkit python-tox +# apt-get install python3-pyqt5 python3-pyqt5.qtwebkit python-tox python3-sip ---- To generate the documentation for the `:help` command, when using the git @@ -107,23 +107,16 @@ or you could use an AUR helper, e.g. `yaourt -S qutebrowser-git`. On Gentoo --------- -A dedicated overlay is available on -https://github.com/posativ/qutebrowser-overlay[GitHub]. To install it, add the -overlay with http://wiki.gentoo.org/wiki/Layman[layman]: - ----- -# layman -a qutebrowser ----- - -Note, that Qt5 is available in the portage tree, but masked. You may need to do -a lot of keywording to install qutebrowser. Also make sure you have `python3_4` -in your `PYTHON_TARGETS` (`/etc/portage/make.conf`) and rebuild your system -(`emerge -uDNav @world`). Afterwards, you can install qutebrowser: +qutebrowser is available in the main repository and can be installed with: ---- # emerge -av qutebrowser ---- +Make sure you have `python3_4` in your `PYTHON_TARGETS` +(`/etc/portage/make.conf`) and rebuild your system (`emerge -uDNav @world`) if +necessary. + On Void Linux ------------- @@ -134,6 +127,16 @@ with: # xbps-install qutebrowser ---- +On NixOS +-------- + +Nixpkgs collection contains `pkgs.qutebrowser` since June 2015. You can install +it with: + +---- +$ nix-env -i qutebrowser +---- + On Windows ---------- diff --git a/MANIFEST.in b/MANIFEST.in index 4092f81c5..fd50d2dbb 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,8 @@ 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 +prune scripts/dev +exclude scripts/asciidoc2html.py exclude doc/notes recursive-exclude doc *.asciidoc include doc/qutebrowser.1.asciidoc @@ -31,3 +27,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 3a701fd76..55a32c298 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,8 +137,10 @@ Contributors, sorted by the number of commits in descending order: * Bruno Oliveira * Raphael Pierzina * Joel Torstensson -* Claude * Martin Tournoij +* Claude +* Lamar Pavel +* Austin Anderson * Artur Shaik * Antoni Boucher * ZDarian @@ -159,15 +161,17 @@ Contributors, sorted by the number of commits in descending order: * Mathias Fussenegger * Larry Hynes * Fritz V155 Reichwald +* Franz Fellner * error800 +* Tim Harder * Thorsten Wißmann * Thiago Barroso Perrotta * Matthias Lisin * Helen Sherwood-Taylor * HalosGhost * Gregor Pohl -* Franz Fellner * Eivind Uggedal +* Arseniy Seroka * Andreas Fischer // QUTE_AUTHORS_END @@ -176,8 +180,8 @@ The following people have contributed graphics: * WOFall (icon) * regines (key binding cheatsheet) -Thanks / Similiar projects --------------------------- +Thanks / Similar projects +------------------------- Many projects with a similar goal as qutebrowser exist: @@ -220,7 +224,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 5a6ce06bf..a822fbb10 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. @@ -210,7 +211,7 @@ Start hinting. - `fill`: Fill the commandline with the command given as argument. - `download`: Download the link. - - `userscript`: Call an userscript with `$QUTE_URL` set to the + - `userscript`: Call a userscript with `$QUTE_URL` set to the link. - `spawn`: Spawn a command. @@ -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. +* +*-u*+, +*--userscript*+: Run the command as a userscript. +* +*-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 diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index af2453378..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. @@ -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. @@ -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 @@ -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/doc/userscripts.asciidoc b/doc/userscripts.asciidoc index 349f0055a..97c820898 100644 --- a/doc/userscripts.asciidoc +++ b/doc/userscripts.asciidoc @@ -5,9 +5,9 @@ The Compiler qutebrowser is extensible by writing userscripts which can be called via the `:spawn --userscript` command, or via a key binding. -These userscripts are similiar to the (non-javascript) dwb userscripts. They -can be written in any language which can read environment variables and write -to a FIFO. Note they are *not* related to Greasemonkey userscripts. +These userscripts are similar to the (non-javascript) dwb userscripts. They can +be written in any language which can read environment variables and write to a +FIFO. Note they are *not* related to Greasemonkey userscripts. Note for simple things such as opening the current page with another browser or mpv, a simple key binding to something like `:spawn mpv {url}` should suffice. @@ -18,7 +18,7 @@ qutebrowser to run them. Getting information ------------------- -The following environment variables will be set when an userscript is launched: +The following environment variables will be set when a userscript is launched: - `QUTE_MODE`: Either `hints` (started via hints) or `command` (started via command or key binding). diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py index ed9c54dc8..90c5dcd96 100644 --- a/qutebrowser/__init__.py +++ b/qutebrowser/__init__.py @@ -28,7 +28,7 @@ __copyright__ = "Copyright 2014-2015 Florian Bruhin (The Compiler)" __license__ = "GPL" __maintainer__ = __author__ __email__ = "mail@qutebrowser.org" -__version_info__ = (0, 2, 1) +__version_info__ = (0, 3, 0) __version__ = '.'.join(map(str, __version_info__)) __description__ = "A keyboard-driven, vim-like browser based on PyQt5 and QtWebKit." diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 19fe98b2e..f9e30af3b 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -51,7 +51,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. @@ -62,7 +62,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__) @@ -149,7 +149,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) @@ -432,6 +434,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 fb539ab10..4335dd1de 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: @@ -620,6 +619,8 @@ class CommandDispatcher: "expected one of: {}".format( direction, ', '.join(fake_keys))) widget = self._current_widget() + frame = widget.page().currentFrame() + press_evt = QKeyEvent(QEvent.KeyPress, key, Qt.NoModifier, 0, 0, 0) release_evt = QKeyEvent(QEvent.KeyRelease, key, Qt.NoModifier, 0, 0, 0) @@ -627,7 +628,25 @@ class CommandDispatcher: if direction in ('top', 'bottom'): count = 1 + max_min = { + 'up': [Qt.Vertical, frame.scrollBarMinimum], + 'down': [Qt.Vertical, frame.scrollBarMaximum], + 'left': [Qt.Horizontal, frame.scrollBarMinimum], + 'right': [Qt.Horizontal, frame.scrollBarMaximum], + 'page-up': [Qt.Vertical, frame.scrollBarMinimum], + 'page-down': [Qt.Vertical, frame.scrollBarMaximum], + } + for _ in range(count): + # Abort scrolling if the minimum/maximum was reached. + try: + qt_dir, getter = max_min[direction] + except KeyError: + pass + else: + if frame.scrollBarValue(qt_dir) == getter(qt_dir): + return + widget.keyPressEvent(press_evt) widget.keyReleaseEvent(release_evt) @@ -743,7 +762,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') @@ -754,7 +777,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') @@ -774,7 +801,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): @@ -927,38 +959,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. + userscript: Run the command as a userscript. + 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): @@ -967,12 +1000,13 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', deprecated='Use :spawn --userscript instead!') - def run_userscript(self, cmd, *args: {'nargs': '*'}): - """Run an userscript given as argument. + def run_userscript(self, cmd, *args: {'nargs': '*'}, verbose=False): + """Run a 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 = { @@ -1000,7 +1034,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): @@ -1192,12 +1227,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: @@ -1219,7 +1248,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. @@ -1602,3 +1631,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..2a1ba6aef 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): @@ -297,10 +296,10 @@ class DownloadItem(QObject): else: self.set_fileobj(fileobj) - def _ask_overwrite_question(self): + def _ask_confirm_question(self, msg): """Create a Question object to be asked.""" q = usertypes.Question(self) - q.text = self._filename + " already exists. Overwrite? (y/n)" + q.text = msg q.mode = usertypes.PromptMode.yesno q.answered_yes.connect(self._create_fileobj) q.answered_no.connect(functools.partial(self.cancel, False)) @@ -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 @@ -446,7 +452,14 @@ class DownloadItem(QObject): if os.path.isfile(self._filename): # The file already exists, so ask the user if it should be # overwritten. - self._ask_overwrite_question() + txt = self._filename + " already exists. Overwrite?" + self._ask_confirm_question(txt) + # FIFO, device node, etc. Make sure we want to do this + elif (os.path.exists(self._filename) and not + os.path.isdir(self._filename)): + txt = (self._filename + " already exists and is a special file. " + "Write to this?") + self._ask_confirm_question(txt) else: self._create_fileobj() @@ -679,7 +692,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 +1036,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..7e6e0575e 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']) @@ -519,7 +519,7 @@ class HintManager(QObject): download_manager.get(url, elem.webFrame().page()) def _call_userscript(self, elem, context): - """Call an userscript from a hint. + """Call a userscript from a hint. Args: elem: The QWebElement to use in the userscript. @@ -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. @@ -733,7 +731,7 @@ class HintManager(QObject): - `fill`: Fill the commandline with the command given as argument. - `download`: Download the link. - - `userscript`: Call an userscript with `$QUTE_URL` set to the + - `userscript`: Call a userscript with `$QUTE_URL` set to the link. - `spawn`: Spawn a command. 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/qutescheme.py b/qutebrowser/browser/network/qutescheme.py index 48b3dbd5f..8c064fb9a 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.") @@ -154,13 +153,13 @@ def qute_help(win_id, request): """Handler for qute:help. Return HTML content as bytes.""" try: utils.read_file('html/doc/index.html') - except FileNotFoundError: + except OSError: html = jinja.env.get_template('error.html').render( title="Error while loading documentation", 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/webpage.py b/qutebrowser/browser/webpage.py index 15659f56f..071e627d9 100644 --- a/qutebrowser/browser/webpage.py +++ b/qutebrowser/browser/webpage.py @@ -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 5a4fc3b69..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', @@ -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): 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..658dfff98 100644 --- a/qutebrowser/commands/userscripts.py +++ b/qutebrowser/commands/userscripts.py @@ -17,18 +17,18 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Functions to execute an userscript.""" +"""Functions to execute a userscript.""" 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,14 +322,15 @@ def store_source(frame): return env -def run(cmd, *args, win_id, env): - """Convenience method to run an userscript. +def run(cmd, *args, win_id, env, verbose=False): + """Convenience method to run a userscript. Args: cmd: The userscript binary to run. *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 197c62ce1..2d4165bbf 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', @@ -71,7 +133,7 @@ class Completer(QObject): """Get a completion model based on an enum member. Args: - completion: An usertypes.Completion member. + completion: A usertypes.Completion member. parts: The parts currently in the commandline. cursor_part: The part the cursor is in. @@ -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 56bf18d34..aae84992e 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 0ad17fa7e..5db383a64 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -29,7 +29,7 @@ from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel from qutebrowser.commands import cmdexc, 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 +96,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 +169,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. """ diff --git a/qutebrowser/completion/models/instances.py b/qutebrowser/completion/models/instances.py index 31654c295..7fa53d6d9 100644 --- a/qutebrowser/completion/models/instances.py +++ b/qutebrowser/completion/models/instances.py @@ -179,9 +179,15 @@ def init(): quickmark_manager.changed.connect( functools.partial(update, [usertypes.Completion.quickmark_by_url, usertypes.Completion.quickmark_by_name])) + bookmark_manager = objreg.get('bookmark-manager') bookmark_manager.changed.connect( functools.partial(update, [usertypes.Completion.bookmark_by_url])) + 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 46db1d54c..ef2b548d6 100644 --- a/qutebrowser/completion/models/urlmodel.py +++ b/qutebrowser/completion/models/urlmodel.py @@ -62,7 +62,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 3f4fdb690..61e8b91a9 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), ''), @@ -347,6 +352,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."), @@ -674,8 +683,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'), @@ -817,34 +826,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 " @@ -881,22 +923,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."), @@ -925,10 +967,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, ' @@ -936,25 +974,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'), @@ -1166,7 +1220,7 @@ KEY_DATA = collections.OrderedDict([ ('tab-clone', ['gC']), ('reload', ['r']), ('reload -f', ['R']), - ('back', ['H', '']), + ('back', ['H']), ('back -t', ['th']), ('back -w', ['wh']), ('forward', ['L']), @@ -1300,7 +1354,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', ['']), diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 748748d42..d3f0b0d8d 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): @@ -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/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 bc828a261..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() @@ -473,7 +513,9 @@ class StatusBar(QWidget): 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) @@ -486,7 +528,9 @@ class StatusBar(QWidget): 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/prompter.py b/qutebrowser/mainwindow/statusbar/prompter.py index 7728cbf46..53831e37c 100644 --- a/qutebrowser/mainwindow/statusbar/prompter.py +++ b/qutebrowser/mainwindow/statusbar/prompter.py @@ -230,7 +230,7 @@ class Prompter(QObject): prompt = objreg.get('prompt', scope='window', window=self._win_id) if (self._question.mode == usertypes.PromptMode.user_pwd and self._question.user is None): - # User just entered an username + # User just entered a username self._question.user = prompt.lineedit.text() prompt.txt.setText("Password:") prompt.lineedit.clear() 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/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 43806df58..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 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 1f1071673..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): 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 143e7cfc5..7d60a41e1 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -141,7 +141,7 @@ def _is_url_dns(urlstr): def fuzzy_url(urlstr, cwd=None, relative=False, do_search=True): - """Get a QUrl based on an user input which is URL or search term. + """Get a QUrl based on a user input which is URL or search term. Args: urlstr: URL to load as a string. @@ -153,15 +153,15 @@ def fuzzy_url(urlstr, cwd=None, relative=False, do_search=True): A target QUrl to a search page or the original URL. """ expanded = os.path.expanduser(urlstr) - if relative and cwd: + if os.path.isabs(expanded): + path = expanded + elif relative and cwd: path = os.path.join(cwd, expanded) elif relative: try: path = os.path.abspath(expanded) except OSError: path = None - elif os.path.isabs(expanded): - path = expanded else: path = None @@ -266,20 +266,20 @@ def is_url(urlstr): elif autosearch == 'naive': log.url.debug("Checking via naive check") url = _is_url_naive(urlstr) - else: + else: # pragma: no cover raise ValueError("Invalid autosearch value") log.url.debug("url = {}".format(url)) return url def qurl_from_user_input(urlstr): - """Get a QUrl based on an user input. Additionally handles IPv6 addresses. + """Get a QUrl based on a user input. Additionally handles IPv6 addresses. QUrl.fromUserInput handles something like '::1' as a file URL instead of an 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 8aebfc6da..467e82633 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -147,7 +147,7 @@ class NeighborList(collections.abc.Sequence): self._idx += offset self._idx %= len(self.items) new = self.curitem() - elif self._mode == self.Modes.exception: + elif self._mode == self.Modes.exception: # pragma: no branch raise else: self._idx += offset @@ -258,7 +258,7 @@ class Question(QObject): mode: A PromptMode enum member. yesno: A question which can be answered with yes/no. text: A question which requires a free text answer. - user_pwd: A question for an username and password. + user_pwd: A question for a username and password. default: The default value. For yesno, None (no default), True or False. For text, a default text as string. 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 add7e4c84..e0e966615 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -30,6 +30,7 @@ import collections from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR, qVersion from PyQt5.QtWebKit import qWebKitVersion from PyQt5.QtNetwork import QSslSocket +from PyQt5.QtWidgets import QApplication import qutebrowser from qutebrowser.utils import log, utils @@ -111,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 @@ -183,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: @@ -197,16 +202,23 @@ def version(): 'PyQt: {}'.format(PYQT_VERSION_STR), ] - lines += _module_versions() + if not short: + style = QApplication.instance().style() + lines += [ + 'Style: {}'.format(style.metaObject().className()), + 'Desktop: {}'.format(os.environ.get('DESKTOP_SESSION')), + ] - 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() + 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/__init__.py b/scripts/__init__.py index ea73ef5bd..90be1e04d 100644 --- a/scripts/__init__.py +++ b/scripts/__init__.py @@ -1,3 +1,3 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -"""Various scripts used to develop/install qutebrowser.""" +"""Various utility scripts.""" diff --git a/scripts/dev/__init__.py b/scripts/dev/__init__.py new file mode 100644 index 000000000..7dc043361 --- /dev/null +++ b/scripts/dev/__init__.py @@ -0,0 +1,3 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +"""Various scripts used for developing qutebrowser.""" diff --git a/scripts/build_release.py b/scripts/dev/build_release.py similarity index 73% rename from scripts/build_release.py rename to scripts/dev/build_release.py index 9f051ff18..a54251d85 100755 --- a/scripts/build_release.py +++ b/scripts/dev/build_release.py @@ -28,7 +28,8 @@ import shutil import subprocess import argparse -sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, + os.pardir)) import qutebrowser from scripts import utils @@ -46,6 +47,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 +79,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 +152,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/dev/ci_install.py b/scripts/dev/ci_install.py new file mode 100644 index 000000000..4bd4698ad --- /dev/null +++ b/scripts/dev/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/cleanup.py b/scripts/dev/cleanup.py similarity index 98% rename from scripts/cleanup.py rename to scripts/dev/cleanup.py index 2a708cf7f..62e6a3537 100755 --- a/scripts/cleanup.py +++ b/scripts/dev/cleanup.py @@ -27,7 +27,8 @@ import glob import shutil import fnmatch -sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, + os.pardir)) from scripts import utils diff --git a/scripts/freeze.py b/scripts/dev/freeze.py similarity index 61% rename from scripts/freeze.py rename to scripts/dev/freeze.py index c5f13cdbb..db6ab1a93 100755 --- a/scripts/freeze.py +++ b/scripts/dev/freeze.py @@ -32,12 +32,13 @@ import distutils 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. -sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, + os.pardir)) from scripts import setupcommon BASEDIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), - os.path.pardir) + os.path.pardir, os.path.pardir) def get_egl_path(): @@ -47,20 +48,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 +114,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/dev/freeze_tests.py b/scripts/dev/freeze_tests.py new file mode 100755 index 000000000..fb687e016 --- /dev/null +++ b/scripts/dev/freeze_tests.py @@ -0,0 +1,72 @@ +#!/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, + os.pardir)) +from scripts import setupcommon +from scripts.dev import 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, os.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/dev/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/gen_resources.py b/scripts/dev/gen_resources.py similarity index 100% rename from scripts/gen_resources.py rename to scripts/dev/gen_resources.py diff --git a/scripts/misc_checks.py b/scripts/dev/misc_checks.py similarity index 84% rename from scripts/misc_checks.py rename to scripts/dev/misc_checks.py index dac0fe017..2f1cdb2ca 100644 --- a/scripts/misc_checks.py +++ b/scripts/dev/misc_checks.py @@ -30,19 +30,26 @@ import tokenize import traceback import collections -sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, + os.pardir)) from scripts import utils -def _py_files(): +def _get_files(only_py=False): """Iterate over all python files and yield filenames.""" 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')): + + if only_py: + endings = {'.py'} + else: + endings = {'.py', '.asciidoc', '.js'} + files = (e for e in filenames if os.path.splitext(e)[1] in endings) + for name in files: yield os.path.join(dirpath, name) @@ -75,20 +82,20 @@ def check_spelling(): '[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'} + '[Rr]eproducable', '[Aa]n [Uu]ser'} # Words which look better when splitted, but might need some fine tuning. - words |= {'[Kk]eystrings', '[Ww]ebelements', '[Mm]ouseevent', - '[Kk]eysequence', '[Nn]ormalmode', '[Ee]ventloops', - '[Ss]izehint', '[Ss]tatemachine', '[Mm]etaobject', - '[Ll]ogrecord', '[Ff]iletype'} + words |= {'[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(): + for fn in _get_files(): with tokenize.open(fn) as f: - if fn == os.path.join('.', 'scripts', 'misc_checks.py'): + if fn == os.path.join('.', 'scripts', 'dev', 'misc_checks.py'): continue for line in f: for w in words: @@ -107,7 +114,7 @@ def check_vcs_conflict(): """Check VCS conflict markers.""" try: ok = True - for fn in _py_files(): + for fn in _get_files(only_py=True): with tokenize.open(fn) as f: for line in f: if any(line.startswith(c * 7) for c in '<>=|'): diff --git a/scripts/pylint_checkers/__init__.py b/scripts/dev/pylint_checkers/__init__.py similarity index 100% rename from scripts/pylint_checkers/__init__.py rename to scripts/dev/pylint_checkers/__init__.py diff --git a/scripts/pylint_checkers/config.py b/scripts/dev/pylint_checkers/config.py similarity index 98% rename from scripts/pylint_checkers/config.py rename to scripts/dev/pylint_checkers/config.py index a4ce51c8e..d703093ed 100644 --- a/scripts/pylint_checkers/config.py +++ b/scripts/dev/pylint_checkers/config.py @@ -28,7 +28,8 @@ from pylint import interfaces, checkers from pylint.checkers import utils sys.path.insert( - 0, os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) + 0, os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, + os.pardir)) from qutebrowser.config import configdata diff --git a/scripts/pylint_checkers/modeline.py b/scripts/dev/pylint_checkers/modeline.py similarity index 100% rename from scripts/pylint_checkers/modeline.py rename to scripts/dev/pylint_checkers/modeline.py diff --git a/scripts/pylint_checkers/openencoding.py b/scripts/dev/pylint_checkers/openencoding.py similarity index 100% rename from scripts/pylint_checkers/openencoding.py rename to scripts/dev/pylint_checkers/openencoding.py diff --git a/scripts/pylint_checkers/settrace.py b/scripts/dev/pylint_checkers/settrace.py similarity index 100% rename from scripts/pylint_checkers/settrace.py rename to scripts/dev/pylint_checkers/settrace.py diff --git a/scripts/quit_segfault_test.sh b/scripts/dev/quit_segfault_test.sh similarity index 100% rename from scripts/quit_segfault_test.sh rename to scripts/dev/quit_segfault_test.sh diff --git a/scripts/dev/run_frozen_tests.py b/scripts/dev/run_frozen_tests.py new file mode 100644 index 000000000..dd70c0505 --- /dev/null +++ b/scripts/dev/run_frozen_tests.py @@ -0,0 +1,33 @@ +#!/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 . + +# pylint: disable=import-error,no-member + +"""cx_Freeze script to run qutebrowser tests on the frozen executable.""" + +import sys + +import pytest +import pytestqt.plugin +import pytest_mock +import pytest_capturelog + +sys.exit(pytest.main(plugins=[pytestqt.plugin, pytest_mock, + pytest_capturelog])) diff --git a/scripts/run_profile.py b/scripts/dev/run_profile.py similarity index 98% rename from scripts/run_profile.py rename to scripts/dev/run_profile.py index 8b0c85aae..9248c4c62 100755 --- a/scripts/run_profile.py +++ b/scripts/dev/run_profile.py @@ -28,7 +28,8 @@ import tempfile import subprocess import shutil -sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, + os.pardir)) import qutebrowser.qutebrowser # pylint: disable=unused-import diff --git a/scripts/run_pylint_on_tests.py b/scripts/dev/run_pylint_on_tests.py similarity index 97% rename from scripts/run_pylint_on_tests.py rename to scripts/dev/run_pylint_on_tests.py index 792eaf7a6..91f5227b2 100644 --- a/scripts/run_pylint_on_tests.py +++ b/scripts/dev/run_pylint_on_tests.py @@ -29,7 +29,8 @@ import sys import os.path import subprocess -sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, + os.pardir)) from scripts import utils diff --git a/scripts/segfault_test.py b/scripts/dev/segfault_test.py similarity index 89% rename from scripts/segfault_test.py rename to scripts/dev/segfault_test.py index e2a374343..56709c6c4 100755 --- a/scripts/segfault_test.py +++ b/scripts/dev/segfault_test.py @@ -26,7 +26,8 @@ import sys import subprocess import os.path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, + os.pardir)) from scripts import utils @@ -70,20 +71,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/dev/src2asciidoc.py similarity index 97% rename from scripts/src2asciidoc.py rename to scripts/dev/src2asciidoc.py index 31d82f6e8..d24c12d7d 100755 --- a/scripts/src2asciidoc.py +++ b/scripts/dev/src2asciidoc.py @@ -31,13 +31,14 @@ import collections import tempfile import argparse -sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, + os.pardir)) # We import qutebrowser.app so all @cmdutils-register decorators are run. 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 +55,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/scripts/importer.py b/scripts/importer.py index 8e807f3e6..15d1daea6 100755 --- a/scripts/importer.py +++ b/scripts/importer.py @@ -51,7 +51,7 @@ def import_chromium(bookmarks_file): """Import bookmarks from a HTML file generated by Chromium.""" import bs4 with open(bookmarks_file, encoding='utf-8') as f: - soup = bs4.BeautifulSoup(f) + soup = bs4.BeautifulSoup(f, 'html.parser') html_tags = soup.findAll('a') 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/minimal_webkit_testbrowser.py b/scripts/minimal_webkit_testbrowser.py index 6375a506c..0b0a02840 100755 --- a/scripts/minimal_webkit_testbrowser.py +++ b/scripts/minimal_webkit_testbrowser.py @@ -21,13 +21,36 @@ """Very simple browser for testing purposes.""" import sys +import argparse from PyQt5.QtCore import QUrl from PyQt5.QtWidgets import QApplication +from PyQt5.QtWebKit import QWebSettings from PyQt5.QtWebKitWidgets import QWebView -app = QApplication(sys.argv) -wv = QWebView() -wv.load(QUrl(sys.argv[1])) -wv.show() -app.exec_() + +def parse_args(): + """Parse commandline arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument('url', help='The URL to open') + parser.add_argument('--plugins', '-p', help='Enable plugins', + default=False, action='store_true') + return parser.parse_args() + + +if __name__ == '__main__': + args = parse_args() + app = QApplication(sys.argv) + + wv = QWebView() + wv.loadStarted.connect(lambda: print("Loading started")) + wv.loadProgress.connect(lambda p: print("Loading progress: {}%".format(p))) + wv.loadFinished.connect(lambda: print("Loading finished")) + + if args.plugins: + wv.settings().setAttribute(QWebSettings.PluginsEnabled, True) + + wv.load(QUrl.fromUserInput(args.url)) + wv.show() + + app.exec_() 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/browser/http/test_content_disposition.py b/tests/browser/http/test_content_disposition.py index 576998bcf..6334480ef 100644 --- a/tests/browser/http/test_content_disposition.py +++ b/tests/browser/http/test_content_disposition.py @@ -19,7 +19,6 @@ """Tests for qutebrowser.browser.http.parse_content_disposition.""" -import os import logging import pytest @@ -586,7 +585,7 @@ class TestAttachment: header_checker.check_filename('attachment; filename="/foo.html"', 'foo.html') - @pytest.mark.skipif(os.name != 'posix', reason="requires POSIX") + @pytest.mark.posix def test_attabspathwin_unix(self, header_checker): """'attachment', specifying an absolute filename in the fs root. @@ -601,7 +600,7 @@ class TestAttachment: header_checker.check_filename(r'attachment; filename="\\foo.html"', r'\foo.html') - @pytest.mark.skipif(os.name != 'nt', reason="requires Windows") + @pytest.mark.windows def test_attabspathwin_win(self, header_checker): """'attachment', specifying an absolute filename in the fs root. @@ -791,7 +790,7 @@ class TestCharacterSet: "attachment; filename*=UTF-8''A-%2541.html", 'A-%41.html') - @pytest.mark.skipif(os.name != 'posix', reason="requires POSIX") + @pytest.mark.posix def test_attwithfn2231abspathdisguised_unix(self, header_checker): r"""'attachment', specifying a filename of \foo.html. @@ -801,7 +800,7 @@ class TestCharacterSet: "attachment; filename*=UTF-8''%5cfoo.html", r'\foo.html') - @pytest.mark.skipif(os.name != 'nt', reason="requires Windows") + @pytest.mark.windows def test_attwithfn2231abspathdisguised_win(self, header_checker): r"""'attachment', specifying a filename of \foo.html. diff --git a/tests/config/test_configtypes.py b/tests/config/test_configtypes.py index 871b6cf4d..c30dafdbd 100644 --- a/tests/config/test_configtypes.py +++ b/tests/config/test_configtypes.py @@ -70,10 +70,10 @@ class NetworkProxy(QNetworkProxy): @pytest.fixture -def os_path(mocker): - """Fixture that mocks and returns os.path from the configtypes module.""" - return mocker.patch('qutebrowser.config.configtypes.os.path', - autospec=True) +def os_mock(mocker): + """Fixture that mocks and returns os from the configtypes module.""" + m = mocker.patch('qutebrowser.config.configtypes.os', autospec=True) + return m class TestValidValues: @@ -1172,14 +1172,14 @@ class TestFont: t.validate('') t2.validate('') - @pytest.mark.parametrize('val, attr', - itertools.product(TESTS, ['t', 't2'])) + @pytest.mark.parametrize('val', TESTS) + @pytest.mark.parametrize('attr', ['t', 't2']) def test_validate_valid(self, val, attr): """Test validate with valid values.""" getattr(self, attr).validate(val) - @pytest.mark.parametrize('val, attr', - itertools.product(INVALID, ['t', 't2'])) + @pytest.mark.parametrize('val', INVALID) + @pytest.mark.parametrize('attr', ['t', 't2']) @pytest.mark.xfail(reason='FIXME: #103') def test_validate_invalid(self, val, attr): """Test validate with invalid values.""" @@ -1359,48 +1359,79 @@ class TestFile: t = configtypes.File(none_ok=True) t.validate("") - def test_validate_does_not_exist(self, os_path): + def test_validate_does_not_exist(self, os_mock): """Test validate with a file which does not exist.""" - os_path.expanduser.side_effect = lambda x: x - os_path.isfile.return_value = False + os_mock.path.expanduser.side_effect = lambda x: x + os_mock.path.expandvars.side_effect = lambda x: x + os_mock.path.isfile.return_value = False with pytest.raises(configexc.ValidationError): self.t.validate('foobar') - def test_validate_exists_abs(self, os_path): + def test_validate_exists_abs(self, os_mock): """Test validate with a file which does exist.""" - os_path.expanduser.side_effect = lambda x: x - os_path.isfile.return_value = True - os_path.isabs.return_value = True + os_mock.path.expanduser.side_effect = lambda x: x + os_mock.path.expandvars.side_effect = lambda x: x + os_mock.path.isfile.return_value = True + os_mock.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.""" - os_path.expanduser.side_effect = lambda x: x - os_path.isfile.return_value = True - os_path.isabs.return_value = False + def test_validate_exists_rel(self, os_mock, monkeypatch): + """Test validate with a relative path to an existing file.""" + monkeypatch.setattr( + 'qutebrowser.config.configtypes.standarddir.config', + lambda: '/home/foo/.config/') + os_mock.path.expanduser.side_effect = lambda x: x + os_mock.path.expandvars.side_effect = lambda x: x + os_mock.path.isfile.return_value = True + os_mock.path.isabs.return_value = False + self.t.validate('foobar') + os_mock.path.join.assert_called_once_with('/home/foo/.config/', + 'foobar') + + def test_validate_rel_config_none(self, os_mock, monkeypatch): + """Test with a relative path and standarddir.config returning None.""" + monkeypatch.setattr( + 'qutebrowser.config.configtypes.standarddir.config', lambda: None) + os_mock.path.isabs.return_value = False with pytest.raises(configexc.ValidationError): self.t.validate('foobar') - def test_validate_expanduser(self, os_path): + def test_validate_expanduser(self, os_mock): """Test if validate expands the user correctly.""" - os_path.expanduser.side_effect = lambda x: x.replace('~', '/home/foo') - os_path.isfile.side_effect = lambda path: path == '/home/foo/foobar' - os_path.isabs.return_value = True + os_mock.path.expanduser.side_effect = (lambda x: + x.replace('~', '/home/foo')) + os_mock.path.expandvars.side_effect = lambda x: x + os_mock.path.isfile.side_effect = (lambda path: + path == '/home/foo/foobar') + os_mock.path.isabs.return_value = True self.t.validate('~/foobar') - os_path.expanduser.assert_called_once_with('~/foobar') + os_mock.path.expanduser.assert_called_once_with('~/foobar') - def test_validate_invalid_encoding(self, os_path, unicode_encode_err): + def test_validate_expandvars(self, os_mock): + """Test if validate expands the environment vars correctly.""" + os_mock.path.expanduser.side_effect = lambda x: x + os_mock.path.expandvars.side_effect = lambda x: x.replace( + '$HOME', '/home/foo') + os_mock.path.isfile.side_effect = (lambda path: + path == '/home/foo/foobar') + os_mock.path.isabs.return_value = True + self.t.validate('$HOME/foobar') + os_mock.path.expandvars.assert_called_once_with('$HOME/foobar') + + def test_validate_invalid_encoding(self, os_mock, unicode_encode_err): """Test validate with an invalid encoding, e.g. LC_ALL=C.""" - os_path.isfile.side_effect = unicode_encode_err - os_path.isabs.side_effect = unicode_encode_err + os_mock.path.isfile.side_effect = unicode_encode_err + os_mock.path.isabs.side_effect = unicode_encode_err with pytest.raises(configexc.ValidationError): self.t.validate('foobar') - def test_transform(self, os_path): + def test_transform(self, os_mock): """Test transform.""" - os_path.expanduser.side_effect = lambda x: x.replace('~', '/home/foo') + os_mock.path.expanduser.side_effect = (lambda x: + x.replace('~', '/home/foo')) + os_mock.path.expandvars.side_effect = lambda x: x assert self.t.transform('~/foobar') == '/home/foo/foobar' - os_path.expanduser.assert_called_once_with('~/foobar') + os_mock.path.expanduser.assert_called_once_with('~/foobar') def test_transform_empty(self): """Test transform with none_ok = False and an empty value.""" @@ -1425,61 +1456,65 @@ class TestDirectory: t = configtypes.Directory(none_ok=True) t.validate("") - def test_validate_does_not_exist(self, os_path): + def test_validate_does_not_exist(self, os_mock): """Test validate with a directory which does not exist.""" - os_path.expanduser.side_effect = lambda x: x - os_path.isdir.return_value = False + os_mock.path.expanduser.side_effect = lambda x: x + os_mock.path.isdir.return_value = False with pytest.raises(configexc.ValidationError): self.t.validate('foobar') - def test_validate_exists_abs(self, os_path): + def test_validate_exists_abs(self, os_mock): """Test validate with a directory which does exist.""" - os_path.expanduser.side_effect = lambda x: x - os_path.isdir.return_value = True - os_path.isabs.return_value = True + os_mock.path.expanduser.side_effect = lambda x: x + os_mock.path.isdir.return_value = True + os_mock.path.isabs.return_value = True self.t.validate('foobar') - def test_validate_exists_not_abs(self, os_path): + def test_validate_exists_not_abs(self, os_mock): """Test validate with a dir which does exist but is not absolute.""" - os_path.expanduser.side_effect = lambda x: x - os_path.isdir.return_value = True - os_path.isabs.return_value = False + os_mock.path.expanduser.side_effect = lambda x: x + os_mock.path.isdir.return_value = True + os_mock.path.isabs.return_value = False with pytest.raises(configexc.ValidationError): self.t.validate('foobar') - def test_validate_expanduser(self, os_path): + def test_validate_expanduser(self, os_mock): """Test if validate expands the user correctly.""" - os_path.expandvars.side_effect = lambda x: x - os_path.expanduser.side_effect = lambda x: x.replace('~', '/home/foo') - os_path.isdir.side_effect = lambda path: path == '/home/foo/foobar' - os_path.isabs.return_value = True + os_mock.path.expandvars.side_effect = lambda x: x + os_mock.path.expanduser.side_effect = (lambda x: + x.replace('~', '/home/foo')) + os_mock.path.isdir.side_effect = (lambda path: + path == '/home/foo/foobar') + os_mock.path.isabs.return_value = True self.t.validate('~/foobar') - os_path.expanduser.assert_called_once_with('~/foobar') + os_mock.path.expanduser.assert_called_once_with('~/foobar') - def test_validate_expandvars(self, os_path, monkeypatch): + def test_validate_expandvars(self, os_mock, monkeypatch): """Test if validate expands the user correctly.""" - os_path.expandvars.side_effect = lambda x: x.replace('$BAR', + os_mock.path.expandvars.side_effect = lambda x: x.replace('$BAR', '/home/foo/bar') - os_path.expanduser.side_effect = lambda x: x - os_path.isdir.side_effect = lambda path: path == '/home/foo/bar/foobar' - os_path.isabs.return_value = True + os_mock.path.expanduser.side_effect = lambda x: x + os_mock.path.isdir.side_effect = (lambda path: + path == '/home/foo/bar/foobar') + os_mock.path.isabs.return_value = True monkeypatch.setenv('BAR', '/home/foo/bar') self.t.validate('$BAR/foobar') - os_path.expandvars.assert_called_once_with('$BAR/foobar') + os_mock.path.expandvars.assert_called_once_with('$BAR/foobar') - def test_validate_invalid_encoding(self, os_path, unicode_encode_err): + def test_validate_invalid_encoding(self, os_mock, unicode_encode_err): """Test validate with an invalid encoding, e.g. LC_ALL=C.""" - os_path.isdir.side_effect = unicode_encode_err - os_path.isabs.side_effect = unicode_encode_err + os_mock.path.isdir.side_effect = unicode_encode_err + os_mock.path.isabs.side_effect = unicode_encode_err with pytest.raises(configexc.ValidationError): self.t.validate('foobar') - def test_transform(self, os_path): + def test_transform(self, os_mock): """Test transform.""" - os_path.expandvars.side_effect = lambda x: x - os_path.expanduser.side_effect = lambda x: x.replace('~', '/home/foo') + os_mock.path.expandvars.side_effect = lambda x: x + os_mock.path.expanduser.side_effect = (lambda x: + x.replace('~', '/home/foo')) assert self.t.transform('~/foobar') == '/home/foo/foobar' - os_path.expanduser.assert_called_once_with('~/foobar') + os_mock.path.expanduser.assert_called_once_with('~/foobar') def test_transform_empty(self): """Test transform with none_ok = False and an empty value.""" @@ -1855,6 +1890,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 @@ -1919,10 +1959,10 @@ class TestUserStyleSheet: def test_validate_invalid_encoding(self, mocker, unicode_encode_err): """Test validate with an invalid encoding, e.g. LC_ALL=C.""" - os_path = mocker.patch('qutebrowser.config.configtypes.os.path', + os_mock = mocker.patch('qutebrowser.config.configtypes.os', autospec=True) - os_path.isfile.side_effect = unicode_encode_err - os_path.isabs.side_effect = unicode_encode_err + os_mock.path.isfile.side_effect = unicode_encode_err + os_mock.path.isabs.side_effect = unicode_encode_err with pytest.raises(configexc.ValidationError): self.t.validate('foobar') @@ -1930,13 +1970,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_mock, mocker): """Test transform with a filename.""" + qurl = mocker.patch('qutebrowser.config.configtypes.QUrl', + autospec=True) + qurl.fromLocalFile.return_value = QUrl("file:///foo/bar") + os_mock.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_mock, 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_mock.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/conftest.py b/tests/conftest.py index ca7042232..6673d08dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,8 @@ """The qutebrowser test suite contest file.""" +import os +import sys import collections import itertools @@ -26,7 +28,7 @@ import pytest import stubs as stubsmod from qutebrowser.config import configexc -from qutebrowser.utils import objreg +from qutebrowser.utils import objreg, usertypes @pytest.fixture(scope='session', autouse=True) @@ -196,3 +198,77 @@ def config_stub(stubs): objreg.register('config', stub) yield stub objreg.delete('config') + + +def pytest_runtest_setup(item): + """Add some custom markers.""" + if not isinstance(item, item.Function): + return + + if item.get_marker('posix') and os.name != 'posix': + pytest.skip("Requires a POSIX os.") + elif item.get_marker('windows') and os.name != 'nt': + pytest.skip("Requires Windows.") + elif item.get_marker('linux') and not sys.platform.startswith('linux'): + pytest.skip("Requires Linux.") + elif item.get_marker('osx') and sys.platform != 'darwin': + pytest.skip("Requires OS X.") + elif item.get_marker('not_frozen') and getattr(sys, 'frozen', False): + pytest.skip("Can't be run when frozen!") + elif item.get_marker('frozen') and not getattr(sys, 'frozen', False): + pytest.skip("Can only run when frozen!") + + +class MessageMock: + + """Helper object for message_mock. + + Attributes: + _monkeypatch: The pytest monkeypatch fixture. + MessageLevel: An enum with possible message levels. + Message: A namedtuple representing a message. + messages: A list of Message tuples. + """ + + Message = collections.namedtuple('Message', ['level', 'win_id', 'text', + 'immediate']) + MessageLevel = usertypes.enum('Level', ('error', 'info', 'warning')) + + def __init__(self, monkeypatch): + self._monkeypatch = monkeypatch + self.messages = [] + + def _handle(self, level, win_id, text, immediately=False): + self.messages.append(self.Message(level, win_id, text, immediately)) + + def _handle_error(self, *args, **kwargs): + self._handle(self.MessageLevel.error, *args, **kwargs) + + def _handle_info(self, *args, **kwargs): + self._handle(self.MessageLevel.info, *args, **kwargs) + + def _handle_warning(self, *args, **kwargs): + self._handle(self.MessageLevel.warning, *args, **kwargs) + + def getmsg(self): + """Get the only message in self.messages. + + Raises ValueError if there are multiple or no messages. + """ + if len(self.messages) != 1: + raise ValueError("Got {} messages but expected a single " + "one.".format(len(self.messages))) + return self.messages[0] + + def patch(self, module_path): + """Patch message.* in the given module (as a string).""" + self._monkeypatch.setattr(module_path + '.error', self._handle_error) + self._monkeypatch.setattr(module_path + '.info', self._handle_info) + self._monkeypatch.setattr(module_path + '.warning', + self._handle_warning) + + +@pytest.fixture +def message_mock(monkeypatch): + """Fixture to get a MessageMock.""" + return MessageMock(monkeypatch) diff --git a/tests/javascript/conftest.py b/tests/javascript/conftest.py index 85b56a577..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): @@ -107,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 b8b03cd29..07e93e0e5 100644 --- a/tests/mainwindow/statusbar/test_progress.py +++ b/tests/mainwindow/statusbar/test_progress.py @@ -39,7 +39,6 @@ def progress_widget(qtbot, monkeypatch, config_stub): 'qutebrowser.mainwindow.statusbar.progress.style.config', config_stub) widget = Progress() qtbot.add_widget(widget) - widget.setGeometry(200, 200, 200, 200) assert not widget.isVisible() assert not widget.isTextVisible() return widget 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..5c75929b9 --- /dev/null +++ b/tests/misc/test_guiprocess.py @@ -0,0 +1,129 @@ +# 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 + + +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=10000) 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 + + +@pytest.mark.not_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', [ + pytest.mark.not_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]) + + +@pytest.mark.not_frozen +def test_double_start(qtbot, proc): + """Test starting a GUIProcess twice.""" + with qtbot.waitSignal(proc.started, raising=True, timeout=10000): + argv = _py_proc("import time; time.sleep(10)") + proc.start(*argv) + with pytest.raises(ValueError): + proc.start('', []) + + +@pytest.mark.not_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=10000): + argv = _py_proc("import sys; sys.exit(0)") + proc.start(*argv) + with qtbot.waitSignals([proc.started, proc.finished], raising=True, + timeout=10000): + 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', []) + + +@pytest.mark.not_frozen +def test_exit_unsuccessful(qtbot, proc): + with qtbot.waitSignal(proc.finished, raising=True, timeout=10000): + proc.start(*_py_proc('import sys; sys.exit(0)')) diff --git a/tests/misc/test_lineparser.py b/tests/misc/test_lineparser.py index 17ae8415b..62f562560 100644 --- a/tests/misc/test_lineparser.py +++ b/tests/misc/test_lineparser.py @@ -109,23 +109,19 @@ class TestBaseLineParser: def test_prepare_save_existing(self, mocker, lineparser): """Test if _prepare_save does what it's supposed to do.""" - exists_mock = mocker.patch( - 'qutebrowser.misc.lineparser.os.path.exists') - makedirs_mock = mocker.patch('qutebrowser.misc.lineparser.os.makedirs') - exists_mock.return_value = True + os_mock = mocker.patch('qutebrowser.misc.lineparser.os') + os_mock.path.exists.return_value = True lineparser._prepare_save() - assert not makedirs_mock.called + assert not os_mock.makedirs.called def test_prepare_save_missing(self, mocker, lineparser): """Test if _prepare_save does what it's supposed to do.""" - exists_mock = mocker.patch( - 'qutebrowser.misc.lineparser.os.path.exists') - exists_mock.return_value = False - makedirs_mock = mocker.patch('qutebrowser.misc.lineparser.os.makedirs') + os_mock = mocker.patch('qutebrowser.misc.lineparser.os') + os_mock.path.exists.return_value = False lineparser._prepare_save() - makedirs_mock.assert_called_with(self.CONFDIR, 0o755) + os_mock.makedirs.assert_called_with(self.CONFDIR, 0o755) class TestAppendLineParser: 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_qtutils.py b/tests/utils/test_qtutils.py index 85a99c2f4..d4921279b 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,837 @@ 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.not_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.posix + @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..3df3ebf88 100644 --- a/tests/utils/test_standarddir.py +++ b/tests/utils/test_standarddir.py @@ -17,14 +17,18 @@ # 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 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 +48,60 @@ def change_qapp_name(): QApplication.instance().setApplicationName(old_name) -@pytest.mark.skipif(not sys.platform.startswith("linux"), - reason="requires Linux") +@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.linux +@pytest.mark.usefixtures('no_cachedir_tag') class TestGetStandardDirLinux: """Tests for standarddir under Linux.""" @@ -53,26 +109,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 +132,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 +139,16 @@ 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.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 +167,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 +178,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 +203,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 +227,52 @@ 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') + m.path.join.side_effect = os.path.join + m.path.exists.return_value = True + standarddir._init_cachedir_tag() + assert not tmpdir.listdir() + m.path.exists.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_urlutils.py b/tests/utils/test_urlutils.py index 264de925b..736827860 100644 --- a/tests/utils/test_urlutils.py +++ b/tests/utils/test_urlutils.py @@ -21,209 +21,479 @@ """Tests for qutebrowser.utils.urlutils.""" +import os.path +import collections + from PyQt5.QtCore import QUrl import pytest -from qutebrowser.utils import urlutils +from qutebrowser.commands import cmdexc +from qutebrowser.utils import utils, urlutils, qtutils -def init_config_stub(stub, auto_search=True): +class FakeDNS: + + """Helper class for the fake_dns fixture. + + Class attributes: + FakeDNSAnswer: Helper class/namedtuple imitating a QHostInfo object + (used by fromname_mock). + + Attributes: + used: Whether the fake DNS server was used since it was + created/reseted. + answer: What to return for the given host(True/False). Needs to be set + when fromname_mock is called. + """ + + FakeDNSAnswer = collections.namedtuple('FakeDNSAnswer', ['error']) + + def __init__(self): + self.reset() + + def __repr__(self): + return utils.get_repr(self, used=self.used, answer=self.answer) + + def reset(self): + """Reset used/answer as if the FakeDNS was freshly created.""" + self.used = False + self.answer = None + + def _get_error(self): + return not self.answer + + def fromname_mock(self, _host): + """Simple mock for QHostInfo::fromName returning a FakeDNSAnswer.""" + if self.answer is None: + raise ValueError("Got called without answer being set. This means " + "something tried to make an unexpected DNS " + "request (QHostInfo::fromName).") + if self.used: + raise ValueError("Got used twice!.") + self.used = True + return self.FakeDNSAnswer(error=self._get_error) + + +@pytest.fixture(autouse=True) +def fake_dns(monkeypatch): + """Patched QHostInfo.fromName to catch DNS requests. + + With autouse=True so accidental DNS requests get discovered because the + fromname_mock will be called without answer being set. + """ + dns = FakeDNS() + monkeypatch.setattr('qutebrowser.utils.urlutils.QHostInfo.fromName', + dns.fromname_mock) + return dns + + +@pytest.fixture(autouse=True) +def urlutils_config_stub(config_stub, monkeypatch): """Initialize the given config_stub. Args: stub: The ConfigStub provided by the config_stub fixture. auto_search: The value auto-search should have. """ - stub.data = { - 'general': {'auto-search': auto_search}, + config_stub.data = { + 'general': {'auto-search': True}, 'searchengines': { 'test': 'http://www.qutebrowser.org/?q={}', 'DEFAULT': 'http://www.example.com/?q={}', }, } + monkeypatch.setattr('qutebrowser.utils.urlutils.config', config_stub) + return config_stub -class TestSpecialURL: +@pytest.fixture +def urlutils_message_mock(message_mock): + """Customized message_mock for the urlutils module.""" + message_mock.patch('qutebrowser.utils.urlutils.message') + return message_mock - """Test is_special_url. - Attributes: - SPECIAL_URLS: URLs which are special. - NORMAL_URLS: URLs which are not special. +class TestFuzzyUrl: + + """Tests for urlutils.fuzzy_url().""" + + @pytest.fixture + def os_mock(self, mocker): + """Mock the os module and some os.path functions.""" + m = mocker.patch('qutebrowser.utils.urlutils.os') + # Using / to get the same behavior across OS' + m.path.join.side_effect = lambda *args: '/'.join(args) + m.path.expanduser.side_effect = os.path.expanduser + return m + + @pytest.fixture + def is_url_mock(self, mocker): + return mocker.patch('qutebrowser.utils.urlutils.is_url') + + @pytest.fixture + def get_search_url_mock(self, mocker): + return mocker.patch('qutebrowser.utils.urlutils._get_search_url') + + def test_file_relative_cwd(self, os_mock): + """Test with relative=True, cwd set, and an existing file.""" + os_mock.path.exists.return_value = True + os_mock.path.isabs.return_value = False + + url = urlutils.fuzzy_url('foo', cwd='cwd', relative=True) + + os_mock.path.exists.assert_called_once_with('cwd/foo') + assert url == QUrl('file:cwd/foo') + + def test_file_relative(self, os_mock): + """Test with relative=True and cwd unset.""" + os_mock.path.exists.return_value = True + os_mock.path.abspath.return_value = 'abs_path' + os_mock.path.isabs.return_value = False + + url = urlutils.fuzzy_url('foo', relative=True) + + os_mock.path.exists.assert_called_once_with('abs_path') + assert url == QUrl('file:abs_path') + + def test_file_relative_os_error(self, os_mock, is_url_mock): + """Test with relative=True, cwd unset and abspath raising OSError.""" + os_mock.path.abspath.side_effect = OSError + os_mock.path.exists.return_value = True + os_mock.path.isabs.return_value = False + is_url_mock.return_value = True + + url = urlutils.fuzzy_url('foo', relative=True) + assert not os_mock.path.exists.called + assert url == QUrl('http://foo') + + def test_file_absolute(self, os_mock): + """Test with an absolute path.""" + os_mock.path.exists.return_value = True + os_mock.path.isabs.return_value = True + + url = urlutils.fuzzy_url('/foo') + assert url == QUrl('file:///foo') + + @pytest.mark.posix + def test_file_absolute_expanded(self, os_mock): + """Make sure ~ gets expanded correctly.""" + os_mock.path.exists.return_value = True + os_mock.path.isabs.return_value = True + + url = urlutils.fuzzy_url('~/foo') + assert url == QUrl('file://' + os.path.expanduser('~/foo')) + + def test_address(self, os_mock, is_url_mock): + """Test passing something with relative=False.""" + os_mock.path.isabs.return_value = False + is_url_mock.return_value = True + + url = urlutils.fuzzy_url('foo') + assert url == QUrl('http://foo') + + def test_search_term(self, os_mock, is_url_mock, get_search_url_mock): + """Test passing something with do_search=True.""" + os_mock.path.isabs.return_value = False + is_url_mock.return_value = False + get_search_url_mock.return_value = QUrl('search_url') + + url = urlutils.fuzzy_url('foo', do_search=True) + assert url == QUrl('search_url') + + def test_search_term_value_error(self, os_mock, is_url_mock, + get_search_url_mock): + """Test with do_search=True and ValueError in _get_search_url.""" + os_mock.path.isabs.return_value = False + is_url_mock.return_value = False + get_search_url_mock.side_effect = ValueError + + url = urlutils.fuzzy_url('foo', do_search=True) + assert url == QUrl('http://foo') + + def test_no_do_search(self, is_url_mock): + """Test with do_search = False.""" + is_url_mock.return_value = False + + url = urlutils.fuzzy_url('foo', do_search=False) + assert url == QUrl('http://foo') + + @pytest.mark.parametrize('do_search, exception', [ + (True, qtutils.QtValueError), + (False, urlutils.FuzzyUrlError), + ]) + def test_invalid_url(self, do_search, exception, is_url_mock, monkeypatch): + """Test with an invalid URL.""" + is_url_mock.return_value = True + monkeypatch.setattr('qutebrowser.utils.urlutils.qurl_from_user_input', + lambda url: QUrl()) + with pytest.raises(exception): + urlutils.fuzzy_url('foo', do_search=do_search) + + +@pytest.mark.parametrize('url, special', [ + ('file:///tmp/foo', True), + ('about:blank', True), + ('qute:version', True), + ('http://www.qutebrowser.org/', False), + ('www.qutebrowser.org', False), +]) +def test_special_urls(url, special): + assert urlutils.is_special_url(QUrl(url)) == special + + +@pytest.mark.parametrize('url, host, query', [ + ('testfoo', 'www.example.com', 'q=testfoo'), + ('test testfoo', 'www.qutebrowser.org', 'q=testfoo'), + ('test testfoo bar foo', 'www.qutebrowser.org', 'q=testfoo bar foo'), + ('test testfoo ', 'www.qutebrowser.org', 'q=testfoo'), + ('!python testfoo', 'www.example.com', 'q=%21python testfoo'), + ('blub testfoo', 'www.example.com', 'q=blub testfoo'), +]) +def test_get_search_url(urlutils_config_stub, url, host, query): + """Test _get_search_url(). + + Args: + url: The "URL" to enter. + host: The expected search machine host. + query: The expected search query. """ - - SPECIAL_URLS = ( - 'file:///tmp/foo', - 'about:blank', - 'qute:version' - ) - - NORMAL_URLS = ( - 'http://www.qutebrowser.org/', - 'www.qutebrowser.org' - ) - - @pytest.mark.parametrize('url', SPECIAL_URLS) - def test_special_urls(self, url): - """Test special URLs.""" - u = QUrl(url) - assert urlutils.is_special_url(u) - - @pytest.mark.parametrize('url', NORMAL_URLS) - def test_normal_urls(self, url): - """Test non-special URLs.""" - u = QUrl(url) - assert not urlutils.is_special_url(u) + url = urlutils._get_search_url(url) + assert url.host() == host + assert url.query() == query -class TestSearchUrl: +@pytest.mark.parametrize('is_url, is_url_no_autosearch, uses_dns, url', [ + # Normal hosts + (True, True, False, 'http://foobar'), + (True, True, False, 'localhost:8080'), + (True, True, True, 'qutebrowser.org'), + (True, True, True, ' qutebrowser.org '), + (True, True, False, 'http://user:password@example.com/foo?bar=baz#fish'), + # IPs + (True, True, False, '127.0.0.1'), + (True, True, False, '::1'), + (True, True, True, '2001:41d0:2:6c11::1'), + (True, True, True, '94.23.233.17'), + # Special URLs + (True, True, False, 'file:///tmp/foo'), + (True, True, False, 'about:blank'), + (True, True, False, 'qute:version'), + (True, True, False, 'localhost'), + # _has_explicit_scheme False, special_url True + (True, True, False, 'qute::foo'), + # Invalid URLs + (False, True, False, ''), + (False, True, False, 'http:foo:0'), + # Not URLs + (False, True, False, 'foo bar'), # no DNS because of space + (False, True, False, 'localhost test'), # no DNS because of space + (False, True, False, 'another . test'), # no DNS because of space + (False, True, True, 'foo'), + (False, True, False, 'this is: not an URL'), # no DNS because of space + (False, True, False, '23.42'), # no DNS because bogus-IP + (False, True, False, '1337'), # no DNS because bogus-IP + (False, True, True, 'deadbeef'), + (False, True, False, '31c3'), # no DNS because bogus-IP + (False, True, False, 'foo::bar'), # no DNS because of no host + # Valid search term with autosearch + (False, False, False, 'test foo'), + # autosearch = False + (False, True, False, 'This is an URL without autosearch'), +]) +def test_is_url(urlutils_config_stub, fake_dns, is_url, is_url_no_autosearch, + uses_dns, url): + """Test is_url(). - """Test _get_search_url.""" - - @pytest.fixture(autouse=True) - def mock_config(self, config_stub, monkeypatch): - """Fixture to patch urlutils.config with a stub.""" - init_config_stub(config_stub) - monkeypatch.setattr('qutebrowser.utils.urlutils.config', config_stub) - - def test_default_engine(self): - """Test default search engine.""" - url = urlutils._get_search_url('testfoo') - assert url.host() == 'www.example.com' - assert url.query() == 'q=testfoo' - - def test_engine_pre(self): - """Test search engine name with one word.""" - url = urlutils._get_search_url('test testfoo') - assert url.host() == 'www.qutebrowser.org' - assert url.query() == 'q=testfoo' - - def test_engine_pre_multiple_words(self): - """Test search engine name with multiple words.""" - url = urlutils._get_search_url('test testfoo bar foo') - assert url.host() == 'www.qutebrowser.org' - assert url.query() == 'q=testfoo bar foo' - - def test_engine_pre_whitespace_at_end(self): - """Test search engine name with one word and whitespace.""" - url = urlutils._get_search_url('test testfoo ') - assert url.host() == 'www.qutebrowser.org' - assert url.query() == 'q=testfoo' - - def test_engine_with_bang_pre(self): - """Test search engine with a prepended !bang.""" - url = urlutils._get_search_url('!python testfoo') - assert url.host() == 'www.example.com' - assert url.query() == 'q=%21python testfoo' - - def test_engine_wrong(self): - """Test with wrong search engine.""" - url = urlutils._get_search_url('blub testfoo') - assert url.host() == 'www.example.com' - assert url.query() == 'q=blub testfoo' - - -class TestIsUrl: - - """Tests for is_url. - - Class attributes: - URLS: A list of strings which are URLs. - NOT_URLS: A list of strings which aren't URLs. + Args: + is_url: Whether the given string is an URL with auto-search dns/naive. + is_url_no_autosearch: Whether the given string is an URL with + auto-search false. + uses_dns: Whether the given string should fire a DNS request for the + given URL. + url: The URL to test, as a string. """ + urlutils_config_stub.data['general']['auto-search'] = 'dns' + if uses_dns: + fake_dns.answer = True + result = urlutils.is_url(url) + assert fake_dns.used + assert result + fake_dns.reset() - URLS = ( - 'http://foobar', - 'localhost:8080', - 'qutebrowser.org', - ' qutebrowser.org ', - '127.0.0.1', - '::1', - '2001:41d0:2:6c11::1', - '94.23.233.17', - 'http://user:password@qutebrowser.org/foo?bar=baz#fish', - ) + fake_dns.answer = False + result = urlutils.is_url(url) + assert fake_dns.used + assert not result + else: + result = urlutils.is_url(url) + assert not fake_dns.used + assert result == is_url - NOT_URLS = ( - 'foo bar', - 'localhost test', - 'another . test', - 'foo', - 'this is: not an URL', - '23.42', - '1337', - 'deadbeef', - '31c3', - 'http:foo:0', - 'foo::bar', - ) + fake_dns.reset() + urlutils_config_stub.data['general']['auto-search'] = 'naive' + assert urlutils.is_url(url) == is_url + assert not fake_dns.used - @pytest.mark.parametrize('url', URLS) - def test_urls(self, monkeypatch, config_stub, url): - """Test things which are URLs.""" - init_config_stub(config_stub, 'naive') - monkeypatch.setattr('qutebrowser.utils.urlutils.config', config_stub) - assert urlutils.is_url(url), url - - @pytest.mark.parametrize('url', NOT_URLS) - def test_not_urls(self, monkeypatch, config_stub, url): - """Test things which are not URLs.""" - init_config_stub(config_stub, 'naive') - monkeypatch.setattr('qutebrowser.utils.urlutils.config', config_stub) - assert not urlutils.is_url(url), url - - @pytest.mark.parametrize('autosearch', [True, False]) - def test_search_autosearch(self, monkeypatch, config_stub, autosearch): - """Test explicit search with auto-search=True.""" - init_config_stub(config_stub, autosearch) - monkeypatch.setattr('qutebrowser.utils.urlutils.config', config_stub) - assert not urlutils.is_url('test foo') + fake_dns.reset() + urlutils_config_stub.data['general']['auto-search'] = False + assert urlutils.is_url(url) == is_url_no_autosearch + assert not fake_dns.used -class TestQurlFromUserInput: +@pytest.mark.parametrize('user_input, output', [ + ('qutebrowser.org', 'http://qutebrowser.org'), + ('http://qutebrowser.org', 'http://qutebrowser.org'), + ('::1/foo', 'http://[::1]/foo'), + ('[::1]/foo', 'http://[::1]/foo'), + ('http://[::1]', 'http://[::1]'), + ('qutebrowser.org', 'http://qutebrowser.org'), + ('http://qutebrowser.org', 'http://qutebrowser.org'), + ('::1/foo', 'http://[::1]/foo'), + ('[::1]/foo', 'http://[::1]/foo'), + ('http://[::1]', 'http://[::1]'), +]) +def test_qurl_from_user_input(user_input, output): + """Test qurl_from_user_input. - """Tests for qurl_from_user_input.""" - - def test_url(self): - """Test a normal URL.""" - url = urlutils.qurl_from_user_input('qutebrowser.org') - assert url.toString() == 'http://qutebrowser.org' - - def test_url_http(self): - """Test a normal URL with http://.""" - url = urlutils.qurl_from_user_input('http://qutebrowser.org') - assert url.toString() == 'http://qutebrowser.org' - - def test_ipv6_bare(self): - """Test an IPv6 without brackets.""" - url = urlutils.qurl_from_user_input('::1/foo') - assert url.toString() == 'http://[::1]/foo' - - def test_ipv6(self): - """Test an IPv6 with brackets.""" - url = urlutils.qurl_from_user_input('[::1]/foo') - assert url.toString() == 'http://[::1]/foo' - - def test_ipv6_http(self): - """Test an IPv6 with http:// and brackets.""" - url = urlutils.qurl_from_user_input('http://[::1]') - assert url.toString() == 'http://[::1]' + Args: + user_input: The string to pass to qurl_from_user_input. + output: The expected QUrl string. + """ + url = urlutils.qurl_from_user_input(user_input) + assert url.toString() == output -class TestFilenameFromUrl: +@pytest.mark.parametrize('url, valid, has_err_string', [ + ('http://www.example.com/', True, False), + ('', False, False), + ('://', False, True), +]) +def test_invalid_url_error(urlutils_message_mock, url, valid, has_err_string): + """Test invalid_url_error(). - """Tests for filename_from_url.""" + Args: + url: The URL to check. + valid: Whether the QUrl is valid (isValid() == True). + has_err_string: Whether the QUrl is expected to have errorString set. + """ + qurl = QUrl(url) + assert qurl.isValid() == valid + if valid: + with pytest.raises(ValueError): + urlutils.invalid_url_error(0, qurl, '') + assert not urlutils_message_mock.messages + else: + assert bool(qurl.errorString()) == has_err_string + urlutils.invalid_url_error(0, qurl, 'frozzle') - def test_invalid_url(self): - """Test with an invalid QUrl.""" - assert urlutils.filename_from_url(QUrl()) is None + msg = urlutils_message_mock.getmsg() + assert msg.win_id == 0 + assert not msg.immediate + if has_err_string: + expected_text = ("Trying to frozzle with invalid URL - " + + qurl.errorString()) + else: + expected_text = "Trying to frozzle with invalid URL" + assert msg.text == expected_text - def test_url_path(self): - """Test with an URL with path.""" - url = QUrl('http://qutebrowser.org/test.html') - assert urlutils.filename_from_url(url) == 'test.html' - def test_url_host(self): - """Test with an URL with no path.""" - url = QUrl('http://qutebrowser.org/') - assert urlutils.filename_from_url(url) == 'qutebrowser.org.html' +@pytest.mark.parametrize('url, valid, has_err_string', [ + ('http://www.example.com/', True, False), + ('', False, False), + ('://', False, True), +]) +def test_raise_cmdexc_if_invalid(url, valid, has_err_string): + """Test raise_cmdexc_if_invalid. + + Args: + url: The URL to check. + valid: Whether the QUrl is valid (isValid() == True). + has_err_string: Whether the QUrl is expected to have errorString set. + """ + qurl = QUrl(url) + assert qurl.isValid() == valid + if valid: + urlutils.raise_cmdexc_if_invalid(qurl) + else: + assert bool(qurl.errorString()) == has_err_string + with pytest.raises(cmdexc.CommandError) as excinfo: + urlutils.raise_cmdexc_if_invalid(qurl) + if has_err_string: + expected_text = "Invalid URL - " + qurl.errorString() + else: + expected_text = "Invalid URL" + assert str(excinfo.value) == expected_text + + +@pytest.mark.parametrize('qurl, output', [ + (QUrl(), None), + (QUrl('http://qutebrowser.org/test.html'), 'test.html'), + (QUrl('http://qutebrowser.org/foo.html#bar'), 'foo.html'), + (QUrl('http://user:password@qutebrowser.org/foo?bar=baz#fish'), 'foo'), + (QUrl('http://qutebrowser.org/'), 'qutebrowser.org.html'), + (QUrl('qute://'), None), +]) +def test_filename_from_url(qurl, output): + assert urlutils.filename_from_url(qurl) == output + + +@pytest.mark.parametrize('qurl, tpl', [ + (QUrl(), None), + (QUrl('qute://'), None), + (QUrl('qute://foobar'), None), + (QUrl('mailto:nobody'), None), + (QUrl('ftp://example.com/'), + ('ftp', 'example.com', 21)), + (QUrl('ftp://example.com:2121/'), + ('ftp', 'example.com', 2121)), + (QUrl('http://qutebrowser.org:8010/waterfall'), + ('http', 'qutebrowser.org', 8010)), + (QUrl('https://example.com/'), + ('https', 'example.com', 443)), + (QUrl('https://example.com:4343/'), + ('https', 'example.com', 4343)), + (QUrl('http://user:password@qutebrowser.org/foo?bar=baz#fish'), + ('http', 'qutebrowser.org', 80)), +]) +def test_host_tuple(qurl, tpl): + """Test host_tuple(). + + Args: + qurl: The QUrl to pass. + tpl: The expected tuple, or None if a ValueError is expected. + """ + if tpl is None: + with pytest.raises(ValueError): + urlutils.host_tuple(qurl) + else: + assert urlutils.host_tuple(qurl) == tpl + + +@pytest.mark.parametrize('url, raising, has_err_string', [ + (None, False, False), + (QUrl(), False, False), + (QUrl('http://www.example.com/'), True, False), + (QUrl('://'), False, True), +]) +def test_fuzzy_url_error(url, raising, has_err_string): + """Test FuzzyUrlError. + + Args: + url: The URL to pass to FuzzyUrlError. + raising; True if the FuzzyUrlError should raise itself. + has_err_string: Whether the QUrl is expected to have errorString set. + """ + if raising: + expected_exc = ValueError + else: + expected_exc = urlutils.FuzzyUrlError + + with pytest.raises(expected_exc) as excinfo: + raise urlutils.FuzzyUrlError("Error message", url) + + if not raising: + if has_err_string: + expected_text = "Error message: " + url.errorString() + else: + expected_text = "Error message" + assert str(excinfo.value) == expected_text 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..8b77a6e43 --- /dev/null +++ b/tests/utils/test_version.py @@ -0,0 +1,625 @@ +# 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.not_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.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.""" + m = mocker.patch('qutebrowser.utils.version.os') + m.path.join.side_effect = OSError + mocker.patch('qutebrowser.utils.version.utils.read_file', + side_effect=OSError) + assert version._git_str() is None + + @pytest.mark.not_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. + """ + m = mocker.patch('qutebrowser.utils.version.subprocess.os') + m.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.linux + def test_linux_real(self): + """Make sure there are no exceptions with a real Linux.""" + version._os_info() + + @pytest.mark.windows + def test_windows_real(self): + """Make sure there are no exceptions with a real Windows.""" + version._os_info() + + @pytest.mark.osx + 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 7298b2861..38fbb3167 100644 --- a/tests/utils/usertypes/test_enum.py +++ b/tests/utils/usertypes/test_enum.py @@ -23,8 +23,6 @@ from qutebrowser.utils import usertypes import pytest -# FIXME: Add some more tests, e.g. for is_int - @pytest.fixture def enum(): @@ -60,3 +58,17 @@ def test_exit(): """Make sure the exit status enum is correct.""" assert usertypes.Exit.ok == 0 assert usertypes.Exit.reserved == 1 + + +def test_is_int(): + """Test the is_int argument.""" + int_enum = usertypes.enum('Enum', ['item'], is_int=True) + no_int_enum = usertypes.enum('Enum', ['item']) + assert isinstance(int_enum.item, int) + assert not isinstance(no_int_enum.item, int) + + +def test_unique(): + """Make sure elements need to be unique.""" + with pytest.raises(TypeError): + usertypes.enum('Enum', ['item', 'item']) diff --git a/tests/utils/usertypes/test_neighborlist.py b/tests/utils/usertypes/test_neighborlist.py index 41e4f547a..ffce4723e 100644 --- a/tests/utils/usertypes/test_neighborlist.py +++ b/tests/utils/usertypes/test_neighborlist.py @@ -51,6 +51,11 @@ class TestInit: assert 2 in nl assert 4 not in nl + def test_invalid_mode(self): + """Test with an invalid mode.""" + with pytest.raises(TypeError): + usertypes.NeighborList(mode='blah') + class TestDefaultArg: @@ -71,6 +76,12 @@ class TestDefaultArg: nl = usertypes.NeighborList([1, 2, 3]) assert nl._idx is None + def test_invalid_reset(self): + """Test reset without default.""" + nl = usertypes.NeighborList([1, 2, 3, 4, 5]) + with pytest.raises(ValueError): + nl.reset() + class TestEmpty: diff --git a/tests/utils/usertypes/test_question.py b/tests/utils/usertypes/test_question.py new file mode 100644 index 000000000..b63106513 --- /dev/null +++ b/tests/utils/usertypes/test_question.py @@ -0,0 +1,91 @@ +# 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 . + +"""Tests for usertypes.Question.""" + +import logging + +import pytest + +from qutebrowser.utils import usertypes + + +@pytest.fixture +def question(): + return usertypes.Question() + + +def test_attributes(question): + """Test setting attributes.""" + question.default = True + question.text = "foo" + + +def test_mode(question): + """Test setting mode to valid members.""" + question.mode = usertypes.PromptMode.yesno + assert question.mode == usertypes.PromptMode.yesno + + +def test_mode_invalid(question): + """Test setting mode to something which is not a PromptMode member.""" + with pytest.raises(TypeError): + question.mode = 42 + + +@pytest.mark.parametrize('mode, answer, signal_names', [ + (usertypes.PromptMode.text, 'foo', ['answered', 'completed']), + (usertypes.PromptMode.yesno, True, ['answered', 'completed', + 'answered_yes']), + (usertypes.PromptMode.yesno, False, ['answered', 'completed', + 'answered_no']), +]) +def test_done(mode, answer, signal_names, question, qtbot): + """Test the 'done' method and completed/answered signals.""" + question.mode = mode + question.answer = answer + signals = [getattr(question, name) for name in signal_names] + with qtbot.waitSignals(signals, raising=True): + question.done() + assert not question.is_aborted + + +def test_cancel(question, qtbot): + """Test Question.cancel().""" + with qtbot.waitSignals([question.cancelled, question.completed], + raising=True): + question.cancel() + assert not question.is_aborted + + +def test_abort(question, qtbot): + """Test Question.abort().""" + with qtbot.waitSignals([question.aborted, question.completed], + raising=True): + question.abort() + assert question.is_aborted + + +def test_abort_typeerror(question, qtbot, mocker, caplog): + """Test Question.abort() with .emit() raising a TypeError.""" + signal_mock = mocker.patch('qutebrowser.utils.usertypes.Question.aborted') + signal_mock.emit.side_effect = TypeError + with caplog.atLevel(logging.ERROR): + question.abort() + assert caplog.records()[0].message == 'Error while aborting question' diff --git a/tests/utils/usertypes/test_timer.py b/tests/utils/usertypes/test_timer.py new file mode 100644 index 000000000..04e163ed1 --- /dev/null +++ b/tests/utils/usertypes/test_timer.py @@ -0,0 +1,86 @@ +# 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 Timer.""" + +from qutebrowser.utils import usertypes + +import pytest +from PyQt5.QtCore import QObject + + +class Parent(QObject): + + """Class for test_parent().""" + + pass + + +def test_parent(): + """Make sure the parent is set correctly.""" + parent = Parent() + t = usertypes.Timer(parent) + assert t.parent() is parent + + +def test_named(): + """Make sure the name is set correctly.""" + t = usertypes.Timer(name='foobar') + assert t._name == 'foobar' + assert t.objectName() == 'foobar' + assert repr(t) == "" + + +def test_unnamed(): + """Make sure an unnamed Timer is named correctly.""" + t = usertypes.Timer() + assert not t.objectName() + assert t._name == 'unnamed' + assert repr(t) == "" + + +def test_set_interval_overflow(): + """Make sure setInterval raises OverflowError with very big numbers.""" + t = usertypes.Timer() + with pytest.raises(OverflowError): + t.setInterval(2 ** 64) + + +def test_start_overflow(): + """Make sure start raises OverflowError with very big numbers.""" + t = usertypes.Timer() + with pytest.raises(OverflowError): + t.start(2 ** 64) + + +def test_timeout_start(qtbot): + """Make sure the timer works with start().""" + t = usertypes.Timer() + with qtbot.waitSignal(t.timeout, raising=True): + t.start(200) + + +def test_timeout_set_interval(qtbot): + """Make sure the timer works with setInterval().""" + t = usertypes.Timer() + with qtbot.waitSignal(t.timeout, raising=True): + t.setInterval(200) + t.start() diff --git a/tox.ini b/tox.ini index 870ee72fd..84b8d58d7 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,57 +18,67 @@ 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.28 - pytest==2.7.1 + py==1.4.30 + pytest==2.7.2 pytest-capturelog==0.7 - pytest-qt==1.4.0 - pytest-mock==0.6.0 + pytest-qt==1.5.0 + pytest-mock==0.7.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} + {envpython} -m py.test --strict -rfEsw tests {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/dev/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} -m py.test --strict -rfEswx -v --cov qutebrowser --cov-report term --cov-report html {posargs} + {envpython} scripts/link_pyqt.py --tox {envdir} + {envpython} -m py.test --strict -rfEswx -v --cov qutebrowser --cov-report term --cov-report html tests {posargs} [testenv:misc] commands = - {envpython} scripts/misc_checks.py git - {envpython} scripts/misc_checks.py vcs - {envpython} scripts/misc_checks.py spelling + {envpython} scripts/dev/misc_checks.py git + {envpython} scripts/dev/misc_checks.py vcs + {envpython} scripts/dev/misc_checks.py spelling [testenv:pylint] skip_install = true -setenv = PYTHONPATH={toxinidir}/scripts +setenv = PYTHONPATH={toxinidir}/scripts/dev deps = -r{toxinidir}/requirements.txt astroid==1.3.6 - beautifulsoup4==4.3.2 - pylint==1.4.3 - logilab-common==0.63.2 + beautifulsoup4==4.4.0 + pylint==1.4.4 + logilab-common==1.0.2 six==1.9.0 commands = - {[testenv:mkvenv]commands} - {envdir}/bin/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 + {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/dev/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.28 - pytest==2.7.1 - pyflakes==0.9.0 + 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.28 - 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.28 - 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,27 +143,53 @@ whitelist_externals = git deps = -r{toxinidir}/requirements.txt commands = - {[testenv:mkvenv]commands} - {envpython} scripts/src2asciidoc.py + {envpython} scripts/link_pyqt.py --tox {envdir} + {envpython} scripts/dev/src2asciidoc.py git --no-pager diff --exit-code --stat {envpython} scripts/asciidoc2html.py {posargs} [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/dev/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/dev/freeze.py {posargs} + [pytest] norecursedirs = .tox .venv markers = gui: Tests using the GUI (e.g. spawning widgets) + posix: Tests which only can run on a POSIX OS. + windows: Tests which only can run on Windows. + linux: Tests which only can run on Linux. + osx: Tests which only can run on OS X. + not_frozen: Tests which can't be run if sys.frozen is True. + frozen: Tests which can only be run if sys.frozen is True. flakes-ignore = UnusedImport UnusedVariable @@ -166,4 +203,7 @@ pep8ignore = resources.py ALL mccabe-complexity = 12 qt_log_level_fail = WARNING -qt_log_ignore = ^SpellCheck: .* +qt_log_ignore = + ^SpellCheck: .* + ^SetProcessDpiAwareness failed: .* + ^QWindowsWindow::setGeometryDp: Unable to set geometry .*