diff options
author | Ryan Roden-Corrent <ryan@rcorre.net> | 2017-08-06 10:54:19 -0400 |
---|---|---|
committer | Ryan Roden-Corrent <ryan@rcorre.net> | 2017-08-06 18:13:49 -0400 |
commit | 71b71dbc58092b6068a3f5ee4b1c287193ad348d (patch) | |
tree | 28c7ccffac920c433d92740a0f575f9d8357ef09 | |
parent | 0f85898137e6065e8558b3b01b010255f3ca3404 (diff) | |
parent | 49b858e3599a361cf5c994358c6b189dddfb522c (diff) | |
download | qutebrowser-71b71dbc58092b6068a3f5ee4b1c287193ad348d.tar.gz qutebrowser-71b71dbc58092b6068a3f5ee4b1c287193ad348d.zip |
Merge remote-tracking branch 'upstream/master' into HEAD
128 files changed, 3955 insertions, 3671 deletions
@@ -11,6 +11,7 @@ exclude = .*,__pycache__,resources.py # (for pytest's __tracebackhide__) # F401: Unused import # N802: function name should be lowercase +# N806: variable in function should be lowercase # P101: format string does contain unindexed parameters # P102: docstring does contain unindexed parameters # P103: other string does contain unindexed parameters @@ -38,7 +39,7 @@ putty-ignore = /# pragma: no mccabe/ : +C901 tests/*/test_*.py : +D100,D101,D401 tests/conftest.py : +F403 - tests/unit/browser/webkit/test_history.py : +N806 + tests/unit/browser/test_history.py : +N806 tests/helpers/fixtures.py : +N806 tests/unit/browser/webkit/http/test_content_disposition.py : +D400 scripts/dev/ci/appveyor_install.py : +FI53 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..2b8c12de9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +qutebrowser/browser/history.py @rcorre +qutebrowser/completion/* @rcorre +qutebrowser/misc/sql.py @rcorre +tests/end2end/features/completion.feature @rcorre +tests/end2end/features/test_completion_bdd.py @rcorre +tests/unit/browser/test_history.py @rcorre +tests/unit/completion/* @rcorre +tests/unit/misc/test_sql.py @rcorre diff --git a/.travis.yml b/.travis.yml index e18bd2efa..ec2868730 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,12 +25,16 @@ matrix: env: TESTENV=py36-pyqt571 - os: linux language: python + python: 3.6 + env: TESTENV=py36-pyqt58 + - os: linux + language: python python: 3.5 - env: TESTENV=py35-pyqt58 + env: TESTENV=py35-pyqt59 - os: linux language: python python: 3.6 - env: TESTENV=py36-pyqt58 + env: TESTENV=py36-pyqt59 - os: osx env: TESTENV=py36 OSX=elcapitan osx_image: xcode7.3 diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 406400413..9f93d26bd 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -14,9 +14,69 @@ 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.11.0 (unreleased) +v1.0.0 (unreleased) +------------------- + +Breaking changes +~~~~~~~~~~~~~~~~ + +- Support for legacy QtWebKit (before 5.212 which is distributed + independently from Qt) is dropped. +- Support for Python 3.4 is dropped. +- Support for Qt before 5.7 is dropped. +- New dependency on the QtSql module and Qt sqlite support. +- New dependency on ruamel.yaml; dropped PyYAML dependency. +- The QtWebEngine backend is now used by default if available. +- New config system which ignores the old config file. +- The depedency on PyOpenGL (when using QtWebEngine) got removed. Note + that PyQt5.QtOpenGL is still a dependency. + +Major changes +~~~~~~~~~~~~~ + +- New completion engine based on sqlite, which allows to complete + the entire browsing history. +- Completely rewritten configuration system. + +Added +~~~~~ + +- New back/forward indicator in the statusbar + +Changed +~~~~~~~ + +- Upgrading qutebrowser with a version older than v0.4.0 still running now won't + work properly anymore. +- Using `:download` now uses the page's title as filename. +- Using `:back` or `:forward` with a count now skips intermediate pages. +- When there are multiple messages shown, the timeout is increased. +- `:search` now only clears the search if one was displayed before, so pressing + `<Escape>` doesn't un-focus inputs anymore. + +Fixes +~~~~~ + +- Exiting fullscreen via `:fullscreen` or buttons on a page now + restores the correct previous window state (maximized/fullscreen). + +v0.11.1 (unreleased) -------------------- +Fixes +~~~~~ + +- Fixed empty space being shown after tabs in the tabbar in some cases. +- Fixed `:restart` in private browsing mode. +- Fixed printing on macOS. +- Closing a pinned tab via mouse now also prompts for confirmation. +- The "try again" button on error pages works correctly again. +- :spawn -u -d is now disallowed. +- :spawn -d shows error messages correctly now. + +v0.11.0 +------- + New dependencies ~~~~~~~~~~~~~~~~ @@ -28,7 +88,10 @@ New dependencies Added ~~~~~ -- New `-p` flag for `:open` to open a private window. +- Private browsing is now implemented for QtWebEngine, *and changed its + behavior*: The `general -> private-browsing` setting now only applies to newly + opened windows, and you can use the `-p` flag to `:open` to open a private + window. - New "pinned tabs" feature, with a new `:tab-pin` command (bound to `<Ctrl-p>` by default). - (QtWebEngine) Implemented `:follow-selected`. @@ -45,6 +108,8 @@ Added customize statusbar colors for private windows. - New `{private}` field displaying `[Private Mode]` for `ui -> window-title-format` and `tabs -> title-format`. +- (QtWebEngine) Proxy support with Qt 5.7.1 (already was supported for 5.8 and + newer) Changed ~~~~~~~ @@ -52,62 +117,51 @@ Changed - To prevent elaborate phishing attacks, the Punycode version (`xn--*`) is now shown in addition to the decoded version for international domain names (IDN). -- Private browsing is now implemented for QtWebEngine, and changed it's - behavior: The `general -> private-browsing` setting now only applies to newly - opened windows, and you can use the `-p` flag to `:open` to open a private - window. +- Starting with legacy QtWebKit now shows a warning message. + *With the next release, support for it will be removed.* +- The Windows releases are redone from scratch, which means: + - They now use the new QtWebEngine backend + - The bundled Qt is updated from 5.5 to 5.9 + - The bundled Python is updated from 3.4 to 3.6 + - They are now generated with PyInstaller instead of cx_Freeze + - The installer is now generated using NSIS instead of being a MSI - Improved `qute://history` page (with lazy loading) -- Starting with legacy QtWebKit now shows a warning message once. - Crash reports are not public anymore. - Paths like `C:` are now treated as absolute paths on Windows for downloads, and invalid paths are handled properly. -- PAC on QtWebKit now supports SOCKS5 as type. -- Comments in the config file are now before the individual options instead of - being before sections. +- Comments in the config file are now placed before the individual options + instead of being before sections. - Messages are now hidden when clicked. - stdin is now closed immediately for processes spawned from qutebrowser. - When `ui -> message-timeout` is set to 0, messages are now never cleared. - Middle/right-clicking the blank parts of the tab bar (when vertical) now closes the current tab. -- (QtWebEngine) With Qt 5.9, `content -> cookies-store` can now be set without - a restart. -- (QtWebEngine) With Qt 5.9, better error messages are now shown for failed - downloads. - The adblocker now also blocks non-GET requests (e.g. POST). - `javascript:` links can now be hinted. - `:view-source`, `:tab-clone` and `:navigate --tab` now don't open the tab as "explicit" anymore, i.e. (with the default settings) open it next to the active tab. -- (QtWebEngine) The underlying Chromium version is now shown in the version - info. - `qute:*` pages now use `qute://*` instead (e.g. `qute://version` instead of `qute:version`), but the old versions are automatically redirected. -- The Windows releases are redone from scratch, which means: - - They now use the new QtWebEngine backend - - The bundled Qt is updated from 5.5 to 5.9 - - The bundled Python is updated from 3.4 to 3.6 - - They are now generated with PyInstaller instead of cx_Freeze - - The installer is now generated using NSIS instead of being a MSI - Texts in prompts are now selectable. -- Renderer process crashes now show an error page. -- (QtWebKit) storage -> offline-web-application-storage` got renamed to `...-cache` - The default level for `:messages` is now `info`, not `error` - Trying to focus the currently focused tab with `:tab-focus` now focuses the last viewed tab. +- (QtWebEngine) With Qt 5.9, `content -> cookies-store` can now be set without + a restart. +- (QtWebEngine) With Qt 5.9, better error messages are now shown for failed + downloads. +- (QtWebEngine) The underlying Chromium version is now shown in the version + info. +- (QtWebKit) Renderer process crashes now show an error page on Qt 5.9 or newer. +- (QtWebKit) storage -> offline-web-application-storage` got renamed to `...-cache` +- (QtWebKit) PAC now supports SOCKS5 as type. Fixed ~~~~~ - The macOS .dmg is now built against Qt 5.9 which fixes various important issues (such as not being able to type dead keys). -- (QtWebEngine) Added a workaround for a black screen with some setups - (the workaround requires PyOpenGL to be installed, but it's optional) -- (QtWebEngine) Starting with Nouveau graphics now shows an error message - instead of crashing in Qt. This adds a new dependency on `PyQt5.QtOpenGL`. -- (QtWebEngine) Retrying downloads now shows an error instead of crashing. -- (QtWebEngine) Cloning a view-source tab now doesn't crash anymore. -- (QtWebKit) The HTTP cache is disabled on Qt 5.7.1 and 5.8 now as it leads to - frequent crashes due to a Qt bug. - Fixed crash with `:download` on PyQt 5.9. - Cloning a page without history doesn't crash anymore. - When a download results in a HTTP error, it now shows the error correctly @@ -117,7 +171,6 @@ Fixed - Fixed crash when unbinding an unbound key in the key config. - Fixed crash when using `:debug-log-filter` when `--filter` wasn't given on startup. - Fixed crash with some invalid setting values. -- (QtWebKit) Fixed Crash when a PAC file returns an invalid value. - Continuing a search after clearing it now works correctly. - The tabbar and completion should now be more consistently and correctly styled with various system styles. @@ -125,19 +178,27 @@ Fixed - The validation for colors in stylesheets is now less strict, allowing for all valid Qt values. - `data:` URLs now aren't added to the history anymore. -- (QtWebEngine) `window.navigator.userAgent` is now set correctly when - customizing the user agent. - Accidentally starting with Python 2 now shows a proper error message again. -- (QtWebEngine) HTML fullscreen is now tracked for each tab separately, which - means it's not possible anymore to accidentally get stuck in fullscreen state - by closing a tab with a fullscreen video. - For some people, running some userscripts crashed - this should now be fixed. - Various other rare crashes should now be fixed. - The settings documentation was truncated with v0.10.1 which should now be fixed. - Scrolling to an anchor in a background tab now works correctly, and javascript gets the correct window size for background tabs. -- (QtWebEngine) `:scroll-page` with `--bottom-navigate` now works correctly +- (QtWebEngine) Added a workaround for a black screen with some setups +- (QtWebEngine) Starting with Nouveau graphics now shows an error message + instead of crashing in Qt. +- (QtWebEngine) Retrying downloads now shows an error instead of crashing. +- (QtWebEngine) Cloning a view-source tab now doesn't crash anymore. +- (QtWebEngine) `window.navigator.userAgent` is now set correctly when + customizing the user agent. +- (QtWebEngine) HTML fullscreen is now tracked for each tab separately, which + means it's not possible anymore to accidentally get stuck in fullscreen state + by closing a tab with a fullscreen video. +- (QtWebEngine) `:scroll-page` with `--bottom-navigate` now works correctly. +- (QtWebKit) The HTTP cache is disabled on Qt 5.7.1 and 5.8 now as it leads to + frequent crashes due to a Qt bug. +- (QtWebKit) Fixed Crash when a PAC file returns an invalid value. v0.10.1 ------- @@ -182,7 +243,7 @@ Added - Open tabs are now auto-saved on each successful load and restored in case of a crash - `:jseval` now has a `--file` flag so you can pass a javascript file - `:session-save` now has a `--only-active-window` flag to only save the active window -- OS X builds are back, and built with QtWebEngine +- macOS builds are back, and built with QtWebEngine Changed ~~~~~~~ @@ -484,7 +545,7 @@ Fixed - Fix crash when pressing enter without a command - Adjust error message to point out QtWebEngine is unsupported with the OS X .app currently. -- Hide Harfbuzz warning with the OS X .app +- Hide Harfbuzz warning with the macOS .app v0.8.0 ------ @@ -847,7 +908,7 @@ Fixed - Fixed scrolling to the very left/right with `:scroll-perc`. - Using an external editor should now work correctly with some funny chars (U+2028/U+2029/BOM). -- Movements in caret mode now should work correctly on OS X and Windows. +- Movements in caret mode now should work correctly on macOS and Windows. - Fixed upgrade from earlier config versions. - Fixed crash when killing a running userscript. - Fixed characters being passed through when shifted with @@ -922,7 +983,7 @@ Changed - The completion widget doesn't show a border anymore. - The tabbar doesn't display ugly arrows anymore if there isn't enough space for all tabs. -- Some insignificant Qt warnings which were printed on OS X are now hidden. +- Some insignificant Qt warnings which were printed on macOS are now hidden. - Better support for Qt 5.5 and Python 3.5. Fixed @@ -1033,7 +1094,7 @@ Fixed - 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). -- Fixed handling of keybindings containing Ctrl/Meta on OS X. +- Fixed handling of keybindings containing Ctrl/Meta on macOS. - Fixed crash when downloading a 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. @@ -1131,7 +1192,7 @@ Changed - Add a `:search` command in addition to `/foo` so it's more visible and can be used from scripts. - Various improvements to documentation, logging, and the crash reporter. - Expand `~` to the users home directory with `:run-userscript`. -- Improve the userscript runner on Linux/OS X by using `QSocketNotifier`. +- Improve the userscript runner on Linux/macOS by using `QSocketNotifier`. - Add luakit-like `gt`/`gT` keybindings to cycle through tabs. - Show default value for config values in the completion. - Clone tab icon, tab text and zoom level when cloning tabs. @@ -1151,7 +1212,7 @@ Changed * `init_venv.py` and `run_checks.py` have been replaced by http://tox.readthedocs.org/[tox]. Install tox and run `tox -e mkvenv` instead. * The tests now use http://pytest.org/[pytest] * Many new tests added - * Mac Mini buildbot to run the tests on OS X. + * Mac Mini buildbot to run the tests on macOS. * Coverage recording via http://nedbatchelder.com/code/coverage/[coverage.py]. * New `--pdb-postmortem argument` to drop into the pdb debugger on exceptions. * Use https://github.com/ionelmc/python-hunter[hunter] for line tracing instead of a selfmade solution. @@ -1287,7 +1348,7 @@ Fixed * Fix rare exception when a key is pressed shortly after opening a window * Fix exception with certain invalid URLs like `http:foo:0` -* Work around Qt bug which renders checkboxes on OS X unusable +* Work around Qt bug which renders checkboxes on macOS unusable * Fix exception when a local files can't be read in `:adblock-update` * Hide 2 more Qt warnings. * Add `!important` to hint CSS so websites don't override the hint look @@ -1323,7 +1384,7 @@ Changes * Set zoom to default instead of 100% with `:zoom`/`=`. * Adjust page zoom if default zoom changed. * Force tabs to be focused on `:undo`. -* Replace manual installation instructions on OS X with homebrew/macports. +* Replace manual installation instructions on macOS with homebrew/macports. * Allow min-/maximizing of print preview on Windows. * Various documentation improvements. * Various other small improvements and cleanups. diff --git a/CONTRIBUTING.asciidoc b/CONTRIBUTING.asciidoc index d5c77f521..7d42cad28 100644 --- a/CONTRIBUTING.asciidoc +++ b/CONTRIBUTING.asciidoc @@ -5,6 +5,12 @@ The Compiler <mail@qutebrowser.org> :data-uri: :toc: +IMPORTANT: I'm currently (July 2017) more busy than usual until September, +because of exams coming up. In addition to that, a new config system is coming +which will conflict with many non-trivial contributions. Because of that, please +refrain from contributing new features until then. If you're reading this note +after mid-September, please open an issue. + I `<3` footnote:[Of course, that says `<3` in HTML.] contributors! This document contains guidelines for contributing to qutebrowser, as well as @@ -39,8 +45,8 @@ pointers: * https://github.com/qutebrowser/qutebrowser/labels/easy[Issues which should be easy to solve] -* https://github.com/qutebrowser/qutebrowser/labels/not%20code[Issues which -require little/no coding] +* https://github.com/qutebrowser/qutebrowser/labels/component%3A%20docs[Documentation +* issues which require little/no coding] If you prefer C++ or Javascript to Python, see the relevant issues which involve work in those languages: @@ -682,8 +688,9 @@ qutebrowser release * Add newest config to `tests/unit/config/old_configs` and update `test_upgrade_version` - `python -m qutebrowser --basedir conf :quit` - - `sed '/^#/d' conf/config/qutebrowser.conf > tests/unit/config/old_configs/qutebrowser-v0.x.y.conf` + - `sed '/^#/d' conf/config/qutebrowser.conf > tests/unit/config/old_configs/qutebrowser-v0.$x.$y.conf` - `rm -r conf` + - git add - commit * Adjust `__version_info__` in `qutebrowser/__init__.py`. * Update changelog (remove *(unreleased)*) @@ -698,8 +705,8 @@ qutebrowser release as closed. * Linux: Run `python3 scripts/dev/build_release.py --upload v0.$x.$y` -* Windows: Run `C:\Python34_x32\python scripts\dev\build_release.py --asciidoc C:\Python27\python C:\asciidoc-8.6.9\asciidoc.py --upload v0.X.Y` (replace X/Y by hand) -* OS X: Run `python3 scripts/dev/build_release.py --upload v0.X.Y` (replace X/Y by hand) +* Windows: Run `C:\Python36-32\python scripts\dev\build_release.py --asciidoc C:\Python27\python C:\asciidoc-8.6.9\asciidoc.py --upload v0.X.Y` (replace X/Y by hand) +* macOS: Run `python3 scripts/dev/build_release.py --upload v0.X.Y` (replace X/Y by hand) * On server: Run `python3 scripts/dev/download_release.py v0.X.Y` (replace X/Y by hand) * Update `qutebrowser-git` PKGBUILD if dependencies/install changed * Announce to qutebrowser and qutebrowser-announce mailinglist diff --git a/FAQ.asciidoc b/FAQ.asciidoc index 75aec1583..0fe340474 100644 --- a/FAQ.asciidoc +++ b/FAQ.asciidoc @@ -171,6 +171,20 @@ What's the difference between insert and passthrough mode?:: be useful to rebind escape to something else in passthrough mode only, to be able to send an escape keypress to the website. +Why takes it longer to open an URL in qutebrowser than in chromium?:: + When opening an URL in an existing instance the normal qutebrowser + Python script is started and a few PyQt libraries need to be + loaded until it is detected that there is an instance running + where the URL is then passed to. This takes some time. + One workaround is to use this + https://github.com/qutebrowser/qutebrowser/blob/master/scripts/open_url_in_instance.sh[script] + and place it in your $PATH with the name "qutebrowser". This + script passes the URL via an unix socket to qutebrowser (if its + running already) using socat which is much faster and starts a new + qutebrowser if it is not running already. Also check if you want + to use webengine as backend in line 17 and change it to your + needs. + == Troubleshooting Configuration not saved after modifying config.:: @@ -211,6 +225,18 @@ it's still https://bugreports.qt.io/browse/QTBUG-42417?jql=component%20%3D%20WebKit%20and%20resolution%20%3D%20Done%20and%20fixVersion%20in%20(5.4.0%2C%20%225.4.0%20Alpha%22%2C%20%225.4.0%20Beta%22%2C%20%225.4.0%20RC%22)%20and%20priority%20in%20(%22P2%3A%20Important%22%2C%20%22P1%3A%20Critical%22%2C%20%22P0%3A%20Blocker%22)[nearly 20 important bugs]. +When using QtWebEngine, qutebrowser reports "Render Process Crashed" and the console prints a traceback on Gentoo Linux or another Source-Based Distro:: + As stated in https://gcc.gnu.org/gcc-6/changes.html[GCC's Website] GCC 6 has introduced some optimizations that could break non-conforming codebases, like QtWebEngine. + + As a workaround, you can disable the nullpointer check optimization by adding the -fno-delete-null-pointer-checks flag while compiling. + + On gentoo, you just need to add it into your make.conf, like this: + + + CFLAGS="... -fno-delete-null-pointer-checks" + CXXFLAGS="... -fno-delete-null-pointer-checks" ++ +And then re-emerging qtwebengine with: + + + emerge -1 qtwebengine + My issue is not listed.:: If you experience any segfaults or crashes, you can report the issue in https://github.com/qutebrowser/qutebrowser/issues[the issue tracker] or diff --git a/INSTALL.asciidoc b/INSTALL.asciidoc index 425bf738c..0cfc3d651 100644 --- a/INSTALL.asciidoc +++ b/INSTALL.asciidoc @@ -27,7 +27,7 @@ Using the packages Install the dependencies via apt-get: ---- -# apt-get install python3-lxml python-tox python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python3-sip python3-jinja2 python3-pygments python3-yaml +# apt-get install python3-lxml python-tox python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python3-sip python3-jinja2 python3-pygments python3-yaml python3-pyqt5.qtsql libqt5sql5-sqlite ---- On Debian Stretch or Ubuntu 17.04 or later, it's also recommended to use the @@ -53,7 +53,7 @@ Build it from git Install the dependencies via apt-get: ---- -# apt-get install python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python-tox python3-sip python3-dev +# apt-get install python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python-tox python3-sip python3-dev python3-pyqt5.qtsql libqt5sql5-sqlite ---- On Debian Stretch or Ubuntu 17.04 or later, it's also recommended to install @@ -277,13 +277,13 @@ $ pip install tox Then <<tox,install qutebrowser via tox>>. -On OS X -------- +On macOS +-------- Prebuilt binary ~~~~~~~~~~~~~~~ -The easiest way to install qutebrowser on OS X is to use the prebuilt `.app` +The easiest way to install qutebrowser on macOS is to use the prebuilt `.app` files from the https://github.com/qutebrowser/qutebrowser/releases[release page]. diff --git a/MANIFEST.in b/MANIFEST.in index 88c320868..52beeab1e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,7 +8,7 @@ graft icons graft doc/img graft misc/apparmor graft misc/userscripts -recursive-include scripts *.py +recursive-include scripts *.py *.sh include qutebrowser/utils/testfile include qutebrowser/git-commit-id include COPYING doc/* README.asciidoc CONTRIBUTING.asciidoc FAQ.asciidoc INSTALL.asciidoc CHANGELOG.asciidoc diff --git a/README.asciidoc b/README.asciidoc index 347b8356b..bf6225062 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -36,11 +36,8 @@ Downloads --------- See the https://github.com/qutebrowser/qutebrowser/releases[github releases -page] for available downloads (currently a source archive, and standalone -packages as well as MSI installers for Windows). - -See link:INSTALL.asciidoc[INSTALL] for detailed instructions on how to get -qutebrowser running for various platforms. +page] for available downloads and the link:INSTALL.asciidoc[INSTALL] file for +detailed instructions on how to get qutebrowser running on various platforms. Documentation ------------- @@ -74,6 +71,9 @@ There's also a https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser-ann at mailto:qutebrowser-announce@lists.qutebrowser.org[] (the announcements also get sent to the general qutebrowser@ list). +If you're a reddit user, there's a +https://www.reddit.com/r/qutebrowser/[/r/qutebrowser] subreddit there. + Contributions / Bugs -------------------- @@ -98,27 +98,35 @@ Requirements The following software and libraries are required to run qutebrowser: -* http://www.python.org/[Python] 3.4 or newer (3.5 recommended) -* http://qt.io/[Qt] 5.2.0 or newer (5.9.0 recommended) -* QtWebKit (old or link:https://github.com/annulen/webkit/wiki[reloaded]/NG) or QtWebEngine +* http://www.python.org/[Python] 3.4 or newer (3.6 recommended) - note that + support for Python 3.4 + https://github.com/qutebrowser/qutebrowser/issues/2742[will be dropped soon]. +* http://qt.io/[Qt] 5.2.0 or newer (5.9 recommended - note that support for Qt + < 5.7.1 will be dropped soon) with the following modules: + - QtCore / qtbase + - QtQuick (part of qtbase in some distributions) + - QtSQL (part of qtbase in some distributions) + - QtWebEngine, or + - QtWebKit (old or link:https://github.com/annulen/webkit/wiki[reloaded]/NG). + Note that support for legacy QtWebKit (before 5.212) will be + dropped soon. * http://www.riverbankcomputing.com/software/pyqt/intro[PyQt] 5.2.0 or newer -(5.8.1 recommended) for Python 3 + (5.9 recommended) for Python 3. Note that support for PyQt < 5.7 will be + dropped soon. * https://pypi.python.org/pypi/setuptools/[pkg_resources/setuptools] * http://fdik.org/pyPEG/[pyPEG2] * http://jinja.pocoo.org/[jinja2] * http://pygments.org/[pygments] * http://pyyaml.org/wiki/PyYAML[PyYAML] -* http://pyopengl.sourceforge.net/[PyOpenGL] when using QtWebEngine - -The following libraries are optional and provide a better user experience: - -* http://cthedot.de/cssutils/[cssutils] -To generate the documentation for the `:help` command, when using the git -repository (rather than a release), http://asciidoc.org/[asciidoc] is needed. +The following libraries are optional: -On Windows, https://pypi.python.org/pypi/colorama/[colorama] is needed to -display colored log output. +* http://cthedot.de/cssutils/[cssutils] (for an improved `:download --mhtml` + with QtWebKit) +* On Windows, https://pypi.python.org/pypi/colorama/[colorama] for colored log + output. +* http://asciidoc.org/[asciidoc] to generate the documentation for the `:help` + command, when using the git repository (rather than a release). See link:INSTALL.asciidoc[INSTALL] for directions on how to install qutebrowser and its dependencies. @@ -142,219 +150,59 @@ get in touch! Authors ------- -Contributors, sorted by the number of commits in descending order: - -// QUTE_AUTHORS_START -* Florian Bruhin -* Daniel Schadt -* Ryan Roden-Corrent -* Jan Verbeek -* Jakub Klinkovský -* Antoni Boucher -* Lamar Pavel -* Marshall Lochbaum -* Bruno Oliveira -* thuck -* Martin Tournoij -* Imran Sobir -* Alexander Cogneau -* Felix Van der Jeugt -* Daniel Karbach -* Kevin Velghe -* Raphael Pierzina -* Joel Torstensson -* Patric Schmitz -* Tarcisio Fedrizzi -* Jay Kamat -* Claude -* Philipp Hansch -* Fritz Reichwald -* Corentin Julé -* meles5 -* Panagiotis Ktistakis -* Artur Shaik -* Nathan Isom -* Thorsten Wißmann -* Austin Anderson -* Jimmy -* Niklas Haas -* Maciej Wołczyk -* Clayton Craft -* sandrosc -* Alexey "Averrin" Nabrodov -* pkill9 -* nanjekyejoannah -* avk -* ZDarian -* Milan Svoboda -* John ShaggyTwoDope Jenkins -* Peter Vilim -* Jacob Sword -* knaggita -* Oliver Caldwell -* Nikolay Amiantov -* Julian Weigt -* Tomasz Kramkowski -* Sebastian Frysztak -* Julie Engel -* Jonas Schürmann -* error800 -* Michael Hoang -* Liam BEGUIN -* Daniel Fiser -* skinnay -* Zach-Button -* Samuel Walladge -* Peter Rice -* Ismail S -* Halfwit -* David Vogt -* Claire Cavanaugh -* rikn00 -* kanikaa1234 -* haitaka -* Nick Ginther -* Michał Góral -* Michael Ilsaas -* Martin Zimmermann -* Marius -* Link -* Jussi Timperi -* Cosmin Popescu -* Brian Jackson -* sbinix -* rsteube -* neeasade -* jnphilipp -* Yannis Rohloff -* Tobias Patzl -* Stefan Tatschner -* Samuel Loury -* Peter Michely -* Panashe M. Fundira -* Lucas Hoffmann -* Larry Hynes -* Kirill A. Shutemov -* Johannes Altmanninger -* Jeremy Kaplan -* Ismail -* Iordanis Grigoriou -* Edgar Hipp -* Daryl Finlay -* arza -* adam -* Samir Benmendil -* Regina Hug -* Penaz -* Matthias Lisin -* Mathias Fussenegger -* Marcelo Santos -* Marcel Schilling -* Joel Bradshaw -* Jean-Louis Fuchs -* Franz Fellner -* Eric Drechsel -* zwarag -* xd1le -* rmortens -* oniondreams -* issue -* haxwithaxe -* evan -* dylan araps -* caveman -* addictedtoflames -* Xitian9 -* Vasilij Schneidermann -* Tomas Orsava -* Tom Janson -* Tobias Werth -* Tim Harder -* Thiago Barroso Perrotta -* Steve Peak -* Sorokin Alexei -* Simon Désaulniers -* Rok Mandeljc -* Noah Huesser -* Moez Bouhlel -* MikeinRealLife -* Lazlow Carmichael -* Kevin Wang -* Ján Kobezda -* Justin Partain -* Johannes Martinsson -* Jean-Christophe Petkovich -* Helen Sherwood-Taylor -* HalosGhost -* Gregor Pohl -* Eivind Uggedal -* Dietrich Daroch -* Derek Sivers -* Daniel Lu -* Daniel Jakots -* Arseniy Seroka -* Anton Grensjö -* Andy Balaam -* Andreas Fischer -* Amos Bird -* Akselmo -// QUTE_AUTHORS_END - -The following people have contributed graphics: +qutebrowser's primary author is Florian Bruhin (The Compiler), but qutebrowser +wouldn't be what it is without the help of +https://github.com/qutebrowser/qutebrowser/graphs/contributors[hundreds of contributors]! + +Additionally, the following people have contributed graphics: * Jad/link:http://yelostudio.com[yelo] (new icon) * WOFall (original icon) * regines (key binding cheatsheet) -Thanks / Similar projects -------------------------- +Also, thanks to everyone who contributed to one of qutebrowser's +link:doc/backers.asciidoc[crowdfunding campaigns]! -Many projects with a similar goal as qutebrowser exist: - -* http://portix.bitbucket.org/dwb/[dwb] (C, GTK+ with WebKit1, currently -http://www.reddit.com/r/linux/comments/2huqbc/dwb_abandoned/[unmaintained] - -main inspiration for qutebrowser) -* https://github.com/fanglingsu/vimb[vimb] (C, GTK+ with WebKit1, active) -* http://sourceforge.net/p/vimprobable/wiki/Home/[vimprobable] (C, GTK+ with -WebKit1, dead) -* http://surf.suckless.org/[surf] (C, GTK+ with WebKit1, active) -* https://mason-larobina.github.io/luakit/[luakit] (C/Lua, GTK+ with -WebKit1, not very active) -* http://pwmt.org/projects/jumanji/[jumanji] (C, GTK+ with WebKit1, not very -active) -* http://www.uzbl.org/[uzbl] (C, GTK+ with WebKit1/WebKit2, active) -* http://conkeror.org/[conkeror] (Javascript, Emacs-like, XULRunner/Gecko, -active) -* https://github.com/AeroNotix/lispkit[lispkit] (quite new, lisp, GTK+ with -WebKit, active) -* http://www.vimperator.org/[Vimperator] (Firefox addon) -* http://5digits.org/pentadactyl/[Pentadactyl] (Firefox addon) -* https://github.com/akhodakivskiy/VimFx[VimFx] (Firefox addon) -* https://github.com/1995eaton/chromium-vim[cVim] (Chrome/Chromium addon) -* http://vimium.github.io/[vimium] (Chrome/Chromium addon) -* https://chrome.google.com/webstore/detail/vichrome/gghkfhpblkcmlkmpcpgaajbbiikbhpdi?hl=en[ViChrome] (Chrome/Chromium addon) -* https://github.com/jinzhu/vrome[Vrome] (Chrome/Chromium addon) +Similar projects +---------------- +Many projects with a similar goal as qutebrowser exist. Most of them were inspirations for qutebrowser in some way, thanks for that! -Thanks as well to the following projects and people for helping me with -problems and helpful hints: - -* http://eric-ide.python-projects.org/[eric5] / Detlev Offenbach -* https://code.google.com/p/devicenzo/[devicenzo] -* portix -* seir -* nitroxleecher - -Also, thanks to: - -* Everyone contributing to the link:doc/backers.asciidoc[crowdfunding]. -* Everyone who had the patience to test qutebrowser before v0.1. -* Everyone triaging/fixing my bugs in the -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. +Active +~~~~~~ + +* https://fanglingsu.github.io/vimb/[vimb] (C, GTK+ with WebKit2) +* https://luakit.github.io/luakit/[luakit] (C/Lua, GTK+ with WebKit2) +* http://surf.suckless.org/[surf] (C, GTK+ with WebKit1/WebKit2) +* http://www.uzbl.org/[uzbl] (C, GTK+ with WebKit1/WebKit2) +* Chrome/Chromium addons: + https://github.com/1995eaton/chromium-vim[cVim], + http://vimium.github.io/[Vimium], + https://github.com/brookhong/Surfingkeys[Surfingkeys], + http://saka-key.lusakasa.com/[Saka Key] +* Firefox addons (based on WebExtensions): + https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/[Vimium-FF] (experimental), + http://saka-key.lusakasa.com/[Saka Key] + +Inactive +~~~~~~~~ + +* https://bitbucket.org/portix/dwb[dwb] (C, GTK+ with WebKit1, +https://bitbucket.org/portix/dwb/pull-requests/22/several-cleanups-to-increase-portability/diff[unmaintained] - +main inspiration for qutebrowser) +* http://sourceforge.net/p/vimprobable/wiki/Home/[vimprobable] (C, GTK+ with + WebKit1) +* http://pwmt.org/projects/jumanji/[jumanji] (C, GTK+ with WebKit1) +* http://conkeror.org/[conkeror] (Javascript, Emacs-like, XULRunner/Gecko) +* Firefox addons (not based on WebExtensions or no recent activity): + http://www.vimperator.org/[Vimperator], + http://5digits.org/pentadactyl/[Pentadactyl], + https://github.com/akhodakivskiy/VimFx[VimFx], + https://github.com/shinglyu/QuantumVim[QuantumVim] +* Chrome/Chromium addons: + https://chrome.google.com/webstore/detail/vichrome/gghkfhpblkcmlkmpcpgaajbbiikbhpdi?hl=en[ViChrome], + https://github.com/jinzhu/vrome[Vrome] License ------- diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index e60c0f01b..3c0344fb8 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -1,5 +1,5 @@ // DO NOT EDIT THIS FILE DIRECTLY! -// It is autogenerated from docstrings by running: +// It is autogenerated by running: // $ python3 scripts/dev/src2asciidoc.py = Commands @@ -1565,6 +1565,7 @@ These commands are mainly intended for debugging. They are hidden if qutebrowser |<<debug-clear-ssl-errors,debug-clear-ssl-errors>>|Clear remembered SSL error answers. |<<debug-console,debug-console>>|Show the debugging console. |<<debug-crash,debug-crash>>|Crash for debugging purposes. +|<<debug-dump-history,debug-dump-history>>|Dump the history to a file in the old pre-SQL format. |<<debug-dump-page,debug-dump-page>>|Dump the current page's content to a file. |<<debug-log-capacity,debug-log-capacity>>|Change the number of log lines to be stored in RAM. |<<debug-log-filter,debug-log-filter>>|Change the log filter for console logging. @@ -1599,6 +1600,15 @@ Crash for debugging purposes. ==== positional arguments * +'typ'+: either 'exception' or 'segfault'. +[[debug-dump-history]] +=== debug-dump-history +Syntax: +:debug-dump-history 'dest'+ + +Dump the history to a file in the old pre-SQL format. + +==== positional arguments +* +'dest'+: Where to write the file to. + [[debug-dump-page]] === debug-dump-page Syntax: +:debug-dump-page [*--plain*] 'dest'+ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 05daf73fe..effe982e0 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -1,5 +1,5 @@ // DO NOT EDIT THIS FILE DIRECTLY! -// It is autogenerated from docstrings by running: +// It is autogenerated by running: // $ python3 scripts/dev/src2asciidoc.py = Settings @@ -1067,11 +1067,15 @@ Default: +pass:[white]+ == colors.tabs.selected.even.bg Background color of selected even tabs. +<<<<<<< HEAD Default: +pass:[black]+ [[colors.tabs.selected.even.fg]] == colors.tabs.selected.even.fg Foreground color of selected even tabs. +======= +Valid values: +>>>>>>> upstream/master Default: +pass:[white]+ @@ -1163,7 +1167,7 @@ Default: +pass:[%Y-%m-%d]+ How many URLs to show in the web history. 0: no history / -1: unlimited -Default: +pass:[1000]+ +Default: +pass:[-1]+ [[confirm_quit]] == confirm_quit diff --git a/doc/notes b/doc/notes deleted file mode 100644 index 2261e3228..000000000 --- a/doc/notes +++ /dev/null @@ -1,196 +0,0 @@ -henk's thoughts -=============== - -1. Power to the user! Protect privacy! -Things the browser should only do with explicit consent from the user, if -applicable the user should be able to choose which protocol/host/port triplets -to white/blacklist: - -- load/run executable code, like js, flash, java applets, ... (think NoScript) -- requests to other domains, ports or using a different protocol than what the - user requested (think RequestPolicy) -- accept cookies -- storing/saving/caching things, e.g. open tabs ("session"), cookies, page - contents, browsing/download history, form data, ... -- send referrer -- disclose any (presence, type, version, settings, capabilities, etc.) - information about OS, browser, installed fonts, plugins, addons, etc. - -2. Be efficient! -I tend to leave a lot of tabs open and nobody can deny that some websites -simply suck, so the browser should, unless told otherwise by the user: - -- load tabs only when needed -- run code in tabs only when needed, i.e. when the tab is currently being - used/viewed (background tabs doing some JS magic even when they are not being - used can create a lot of unnecessary load on the machine) -- finish requests to the domain the user requested (e.g. www.example.org) - before doing any requests to other subdomains (e.g. images.example.org) and - finish those before doing requests to thirdparty domains (e.g. example.com) - -3. Be stable! -- one site should not make the complete browser crash, only that site's tab - - -Upstream Bugs -============= - -- Web inspector is blank unless .hide()/.show() is called. - Asked on SO: http://stackoverflow.com/q/23499159/2085149 - TODO: Report to PyQt/Qt - -- Report some other crashes - - -/u/angelic_sedition's thoughts -============================== - -Well support for greasemonkey scripts and bookmarklets/js (which was mentioned -in the arch forum post) would be a big addition. What I've usually missed when -using other vim-like browsers is things that allow for different settings and -key bindings for different contexts. With that implemented I think I could -switch to a lightweight browser (and believe me, I'd like to) for the most part -and only use firefox when I needed downthemall or something. - -For example, I have different bindings based on tab position that are reloaded -with a pentadactyl autocmd so that <space><homerow keys> will take me to tab -1-10 if I'm in that range or 2-20 if I'm in that range. I have an autocmd that -will run on completed downloads that passes the file path to a script that will -open ranger in a floating window with that file cut (this is basically like -using ranger to save files instead of the crappy gui popup). - -I also have a few bindings based on tabgroups. Tabgroups are a firefox feature, -but I find them very useful for sorting things by topic so that only the tabs -I'm interested at the moment are visible. - -Pentadactyl has a feature it calls groups. You can create a group that will -activate for sites/urls that match a pattern with some regex support. This -allows me, for example, to set up different (more convenient) bindings for -zooming only on images. I'll never need use the equivalent of vim n (next text -search match), so I can bind that to zoom. This allows setting up custom -quickmarks/gotos using the same keys for different websites. For example, on -reddit I have different g(some key) bindings to go to different subreddits. -This can also be used to pass certain keys directly to the site (e.g. for use -with RES). For sites that don't have modifiable bindings, I can use this with -pentadactyl's feedkeys or xdotool to create my own custom bindings. I even have -a binding that will call out to bash script with different arguments depending -on the site to download an image or an image gallery depending on the site (in -some cases passing the url to some cli program). - -I've also noticed the lack of completion. For example, on "o" pentadactyl will -show sites (e.g. from history) that can be completed. I think I've been spoiled -by pentadactyl having completion for just about everything. - - -suckless surf ML post -===================== - -From: Ben Woolley <tautolog_AT_gmail.com> -Date: Wed, 7 Jan 2015 18:29:25 -0800 - -Hi all, - -This patch is a bit of a beast for surf. It is intended to be applied after -the disk cache patch. It breaks some internal interfaces, so it could -conflict with other patches. - -I have been wanting a browser to implement a complete same-origin policy, -and have been investigating how to do this in various browsers for many -months. When I saw how surf opened new windows in a separate process, and -was so simple, I knew I could do it quickly. Over the last two weeks, I -have been developing this implementation on surf. - -The basic idea is to prevent browser-based tracking as you browse from site -to site, or origin to origin. By "origin" domain, I mean the "first-party" -domain, the domain normally in the location bar (of the typical browser -interface). Each origin domain effectively gets its own browser profile, -and a browser process only ever deals with one origin domain at a time. -This isolates origins vertically, preventing cookies, disk cache, memory -cache, and window.name vulnerabilities. Basically, all known -vulnerabilities that google and Mozilla cite as counter-examples when they -explain why they haven't disabled third-party cookies yet. - -When you are on msnbc.com, the tracking pixels will be stored in a cookie -file for msnbc.com. When you go to cnn.com, the tracking pixels will be -stored in a cookie file for cnn.com. You will not be tracked between them. -However, third-party cookies, and the caching of third party resources will -still work, but they will be isolated between origin domains. Instead of -blocking cookies and cache entries, they are "double-keyed", or *also* -keyed by origin. - -There is a unidirectional communication channel, however, from one origin -to the next, through navigation from one origin to the next. That is, the -query string is passed from one origin to the next, and may embed -identifiers. One example is an affiliate link that identifies where the -lead came from. I have implemented what I call "horizontal isolation", in -the form of an "Origin Crossing Gate". - -Whenever you follow a link to a new domain, or even are just redirected to -a new domain, a new window/tab is opened, and passed the referring origin -via -R. The page passed to -O, for example -O originprompt.html, is an HTML -page that is loaded in the new origin's context. That page tells you the -origin you were on, the new origin, and the full link, and you can decide -to go just to the new origin, or go to the full URL, after reviewing it for -tracking data. - -Also, you may click links that store your trust of that relationship with -various expiration times, the same way you would trust geolocation requests -for a particular origin for a period of time. The database used is actually -the new origin's cookie file. Since the origin prompt is loaded in the new -origin's context, I can set a cookie on behalf of the new origin. The -expiration time of the trust is the expiration time of the cookie. The -cookie implementation in webkit automatically expires the trust as part of -how cookies work. Each time you cross an origin, the origin crossing page -checks the cookie to see if trust is still established. If so, it will use -window.location.replace() to continue on automatically. The initial page -renders blank until the trust is invalidated, in which case the content of -the gate is made visible. - -However, the new origin is technically able to mess with those cookies, so -a website could set trust for an origin crossing. I have addressed that by -hashing the key with a salt, and setting the real expiration time as the -value, along with an HMAC to verify the contents of the value. If the -cookie is messed with in any way, the trust will be disabled, and the -prompt will appear again. So it has a fail-safe function. - -I know it seems a bit convoluted, but it just started out as a nice little -rabbit hole, and I just wanted to get something workable. At first I -thought using the cookie expiration time was convenient, but then when I -realized that I needed to protect the cookie, things got a bit hairy. But -it works. - -Each profile is, by default, stored in ~/.surf/origins/$origin/ -The interesting side effect is that if there is a problem where a website -relies on the cross-site cookie vulnerability to make a connection, you can -simply make a symbolic link from one origin folder to another, and they -will share the same profile. And if you want to delete cookies and/or cache -for a particular origin, you just rm -rf the origin's profile folder, and -don't have to interfere with your other sites that are working just fine. - -One thing I don't handle are cross-origins POSTs. They just end up as GET -requests right now. I intend to do something about that, but I haven't -figured that out yet. - -I have only been using this functionality for a few days myself, so I have -absolutely no feedback yet. I wanted to provide the first implementation of -the management of identity as a system resource the same way that things -like geolocation, camera, and microphone resources are managed in browsers -and mobile apps. - -Currently, Mozilla and Tor have are working on third-party tracking issues -in Firefox. -https://blog.mozilla.org/privacy/2014/11/10/introducing-polaris-privacy-initiative-to-accelerate-user-focused-privacy-online/ - -Up to this point, Tor has provided a patch that double-keys cookies with -the origin domain, but no other progress is visible. I have seen no -discussion of how horizontal isolation is supposed to happen, and I wanted -to show people that it can be done, and this is one way it can be done, and -to compel the other browser makers to catch up, and hopefully the community -can work toward a standard *without* the tracking loopholes, by showing -people what a *complete* solution looks like. - -Thank you, - -Ben Woolley - -Patch: http://lists.suckless.org/dev/att-25070/0005-same-origin-policy.patch diff --git a/doc/quickstart.asciidoc b/doc/quickstart.asciidoc index 7d597ed2e..4881cca62 100644 --- a/doc/quickstart.asciidoc +++ b/doc/quickstart.asciidoc @@ -31,7 +31,7 @@ image:http://qutebrowser.org/img/cheatsheet-small.png["qutebrowser key binding c * Run `:adblock-update` to download adblock lists and activate adblocking. * If you just cloned the repository, you'll need to run `scripts/asciidoc2html.py` to generate the documentation. -* Go to the link:qute://settings[settings page] to set up qutebrowser the way you want it. (Currently not available with the QtWebEngine backend and on the OS X build - use the `:set` command instead) +* Go to the link:qute://settings[settings page] to set up qutebrowser the way you want it. (Currently not available with the QtWebEngine backend and on the macOS build - use the `:set` command instead) * Subscribe to https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[the mailinglist] or https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser-announce[the announce-only mailinglist]. diff --git a/doc/userscripts.asciidoc b/doc/userscripts.asciidoc index b44a6b8ff..85811266d 100644 --- a/doc/userscripts.asciidoc +++ b/doc/userscripts.asciidoc @@ -60,7 +60,7 @@ Sending commands Normal qutebrowser commands can be written to `$QUTE_FIFO` and will be executed. -On Unix/OS X, this is a named pipe and commands written to it will get executed +On Unix/macOS, this is a named pipe and commands written to it will get executed immediately. On Windows, this is a regular file, and the commands in it will be executed as diff --git a/misc/qutebrowser.spec b/misc/qutebrowser.spec index 5dc51015d..cd0ce3883 100644 --- a/misc/qutebrowser.spec +++ b/misc/qutebrowser.spec @@ -41,7 +41,7 @@ a = Analysis(['../qutebrowser/__main__.py'], pathex=['misc'], binaries=None, datas=get_data_files(), - hiddenimports=['PyQt5.QtOpenGL'], + hiddenimports=['PyQt5.QtOpenGL', 'PyQt5._QOpenGLFunctions_2_0'], hookspath=[], runtime_hooks=[], excludes=['tkinter'], diff --git a/misc/requirements/requirements-codecov.txt b/misc/requirements/requirements-codecov.txt index 86f78562d..9d6737a96 100644 --- a/misc/requirements/requirements-codecov.txt +++ b/misc/requirements/requirements-codecov.txt @@ -1,9 +1,9 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -certifi==2017.4.17 +certifi==2017.7.27.1 chardet==3.0.4 codecov==2.0.9 coverage==4.4.1 idna==2.5 -requests==2.18.1 -urllib3==1.21.1 +requests==2.18.2 +urllib3==1.22 diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt index ecec607b5..5e5980525 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -3,7 +3,7 @@ flake8==2.6.2 # rq.filter: < 3.0.0 flake8-copyright==0.2.0 flake8-debugger==1.4.0 # rq.filter: != 2.0.0 -flake8-deprecated==1.2 +flake8-deprecated==1.2.1 flake8-docstrings==1.0.3 # rq.filter: < 1.1.0 flake8-future-import==0.4.3 flake8-mock==0.3 @@ -11,7 +11,7 @@ flake8-pep3101==1.0 # rq.filter: < 1.1 flake8-polyfill==1.0.1 flake8-putty==0.4.0 flake8-string-format==0.2.3 -flake8-tidy-imports==1.0.6 +flake8-tidy-imports==1.1.0 flake8-tuple==0.2.13 mccabe==0.6.1 packaging==16.8 diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt index 9bae64d6d..3b36a0e5c 100644 --- a/misc/requirements/requirements-pip.txt +++ b/misc/requirements/requirements-pip.txt @@ -3,6 +3,6 @@ appdirs==1.4.3 packaging==16.8 pyparsing==2.2.0 -setuptools==36.0.1 +setuptools==36.2.5 six==1.10.0 wheel==0.29.0 diff --git a/misc/requirements/requirements-pylint-master.txt b/misc/requirements/requirements-pylint-master.txt index 37e705b7a..d4058b1d0 100644 --- a/misc/requirements/requirements-pylint-master.txt +++ b/misc/requirements/requirements-pylint-master.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -e git+https://github.com/PyCQA/astroid.git#egg=astroid -certifi==2017.4.17 +certifi==2017.7.27.1 chardet==3.0.4 github3.py==0.9.6 idna==2.5 @@ -10,9 +10,9 @@ lazy-object-proxy==1.3.1 mccabe==0.6.1 -e git+https://github.com/PyCQA/pylint.git#egg=pylint ./scripts/dev/pylint_checkers -requests==2.18.1 +requests==2.18.2 six==1.10.0 uritemplate==3.0.0 uritemplate.py==3.0.2 -urllib3==1.21.1 +urllib3==1.22 wrapt==1.10.10 diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt index a76d0dbf4..b5d44cb64 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py astroid==1.5.3 -certifi==2017.4.17 +certifi==2017.7.27.1 chardet==3.0.4 github3.py==0.9.6 idna==2.5 @@ -10,9 +10,9 @@ lazy-object-proxy==1.3.1 mccabe==0.6.1 pylint==1.7.2 ./scripts/dev/pylint_checkers -requests==2.18.1 +requests==2.18.2 six==1.10.0 uritemplate==3.0.0 uritemplate.py==3.0.2 -urllib3==1.21.1 +urllib3==1.22 wrapt==1.10.10 diff --git a/misc/requirements/requirements-pyqt.txt b/misc/requirements/requirements-pyqt.txt index da611589a..fffa133ab 100644 --- a/misc/requirements/requirements-pyqt.txt +++ b/misc/requirements/requirements-pyqt.txt @@ -1,4 +1,4 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -PyQt5==5.8.2 -sip==4.19.2 +PyQt5==5.9 +sip==4.19.3 diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 57a4daff7..171013afb 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -5,35 +5,35 @@ cheroot==5.7.0 click==6.7 # colorama==0.3.9 coverage==4.4.1 -decorator==4.0.11 +decorator==4.1.2 EasyProcess==0.2.3 fields==5.0.0 Flask==0.12.2 glob2==0.5 httpbin==0.5.0 hunter==1.4.1 -hypothesis==3.11.6 +hypothesis==3.14.0 itsdangerous==0.24 # Jinja2==2.9.6 -Mako==1.0.6 +Mako==1.0.7 # MarkupSafe==1.0 parse==1.8.2 parse-type==0.3.4 py==1.4.34 -pytest==3.1.2 +pytest==3.1.3 pytest-bdd==2.18.2 -pytest-benchmark==3.0.0 +pytest-benchmark==3.1.1 pytest-catchlog==1.2.2 pytest-cov==2.5.1 pytest-faulthandler==1.3.1 pytest-instafail==0.3.0 -pytest-mock==1.6.0 -pytest-qt==2.1.0 +pytest-mock==1.6.2 +pytest-qt==2.1.2 pytest-repeat==0.4.1 pytest-rerunfailures==2.2 pytest-travis-fold==1.2.0 pytest-xvfb==1.0.0 PyVirtualDisplay==0.2.1 six==1.10.0 -vulture==0.14 +vulture==0.21 Werkzeug==0.12.2 diff --git a/misc/requirements/requirements-vulture.txt b/misc/requirements/requirements-vulture.txt index 56e20c603..d1c1bc41d 100644 --- a/misc/requirements/requirements-vulture.txt +++ b/misc/requirements/requirements-vulture.txt @@ -1,3 +1,3 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -vulture==0.14 +vulture==0.21 diff --git a/misc/userscripts/readability b/misc/userscripts/readability index 2de4be5ab..639e3a111 100755 --- a/misc/userscripts/readability +++ b/misc/userscripts/readability @@ -2,20 +2,32 @@ # # Executes python-readability on current page and opens the summary as new tab. # +# Depends on the python-readability package, or its fork: +# +# - https://github.com/buriy/python-readability +# - https://github.com/bookieio/breadability +# # Usage: # :spawn --userscript readability # from __future__ import absolute_import import codecs, os -from readability.readability import Document tmpfile=os.path.expanduser('~/.local/share/qutebrowser/userscripts/readability.html') if not os.path.exists(os.path.dirname(tmpfile)): os.makedirs(os.path.dirname(tmpfile)) with codecs.open(os.environ['QUTE_HTML'], 'r', 'utf-8') as source: - doc = Document(source.read()) - content = doc.summary().replace('<html>', '<html><head><title>%s</title></head>' % doc.title()) + data = source.read() + + try: + from breadability.readable import Article as reader + doc = reader(data) + content = doc.readable + except ImportError: + from readability import Document + doc = Document(data) + content = doc.summary().replace('<html>', '<html><head><title>%s</title></head>' % doc.title()) with codecs.open(tmpfile, 'w', 'utf-8') as target: target.write('<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />') diff --git a/pytest.ini b/pytest.ini index e13ab3a3b..0062b26e2 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,12 +1,13 @@ [pytest] -addopts = --strict -rfEw --faulthandler-timeout=70 --instafail --pythonwarnings error +addopts = --strict -rfEw --faulthandler-timeout=70 --instafail --pythonwarnings error --benchmark-columns=Min,Max,Median +testpaths = tests 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_osx: Tests which can not run on OS X. + mac: Tests which only can run on macOS. + not_mac: Tests which can not run on macOS. not_frozen: Tests which can't be run if sys.frozen is True. no_xvfb: Tests which can't be run with Xvfb. frozen: Tests which can only be run if sys.frozen is True. @@ -21,7 +22,7 @@ markers = qtwebkit_ng_xfail: Tests failing with QtWebKit-NG qtwebkit_ng_skip: Tests skipped with QtWebKit-NG qtwebengine_flaky: Tests which are flaky (and currently skipped) with QtWebEngine - qtwebengine_osx_xfail: Tests which fail on OS X with QtWebEngine + qtwebengine_mac_xfail: Tests which fail on macOS with QtWebEngine js_prompt: Tests needing to display a javascript prompt this: Used to mark tests during development no_invalid_lines: Don't fail on unparseable lines in end2end tests @@ -47,6 +48,7 @@ qt_log_ignore = ^QGeoclueMaster error creating GeoclueMasterClient\. ^Geoclue error: Process org\.freedesktop\.Geoclue\.Master exited with status 127 ^Failed to create Geoclue client interface. Geoclue error: org\.freedesktop\.DBus\.Error\.Disconnected + ^QDBusConnection: name 'org.freedesktop.Geoclue.Master' had owner '' but we thought it was ':1.1' ^QObject::connect: Cannot connect \(null\)::stateChanged\(QNetworkSession::State\) to QNetworkReplyHttpImpl::_q_networkSessionStateChanged\(QNetworkSession::State\) ^QXcbClipboard: Cannot transfer data, no data available ^load glyph failed diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py index e61419c0c..cca2bf1b8 100644 --- a/qutebrowser/__init__.py +++ b/qutebrowser/__init__.py @@ -26,7 +26,7 @@ __copyright__ = "Copyright 2014-2017 Florian Bruhin (The Compiler)" __license__ = "GPL" __maintainer__ = __author__ __email__ = "mail@qutebrowser.org" -__version_info__ = (0, 10, 1) +__version_info__ = (0, 11, 0) __version__ = '.'.join(str(e) for e in __version_info__) __description__ = "A keyboard-driven, vim-like browser based on PyQt5." diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 66d4d5eb8..d1aafce19 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -41,9 +41,10 @@ except ImportError: import qutebrowser import qutebrowser.resources -from qutebrowser.completion.models import instances as completionmodels +from qutebrowser.completion.models import miscmodels from qutebrowser.commands import cmdutils, runners, cmdexc from qutebrowser.config import config, websettings, configexc +from qutebrowser.config.parsers import keyconf from qutebrowser.browser import (urlmarks, adblock, history, browsertab, downloads) from qutebrowser.browser.network import proxy @@ -52,10 +53,10 @@ from qutebrowser.browser.webkit.network import networkmanager from qutebrowser.keyinput import macros from qutebrowser.mainwindow import mainwindow, prompt from qutebrowser.misc import (readline, ipc, savemanager, sessions, - crashsignal, earlyinit, objects) + crashsignal, earlyinit, objects, sql) from qutebrowser.misc import utilcmds # pylint: disable=unused-import from qutebrowser.utils import (log, version, message, utils, qtutils, urlutils, - objreg, usertypes, standarddir, error, debug) + objreg, usertypes, standarddir, error) # We import utilcmds to run the cmdutils.register decorators. @@ -154,7 +155,7 @@ def init(args, crash_handler): QDesktopServices.setUrlHandler('https', open_desktopservices_url) QDesktopServices.setUrlHandler('qute', open_desktopservices_url) - QTimer.singleShot(10, functools.partial(_init_late_modules, args)) + objreg.get('web-history').import_txt() log.init.debug("Init done!") crash_handler.raise_crashdlg() @@ -400,10 +401,8 @@ def _init_modules(args, crash_handler): log.init.debug("Initializing network...") networkmanager.init() - if qtutils.version_check('5.8'): - # Otherwise we can only initialize it for QtWebKit because of crashes - log.init.debug("Initializing proxy...") - proxy.init() + log.init.debug("Initializing proxy...") + proxy.init() log.init.debug("Initializing readline-bridge...") readline_bridge = readline.ReadlineBridge() @@ -413,6 +412,17 @@ def _init_modules(args, crash_handler): config.init(qApp) save_manager.init_autosave() + log.init.debug("Initializing keys...") + keyconf.init(qApp) + + log.init.debug("Initializing sql...") + try: + sql.init(os.path.join(standarddir.data(), 'history.sqlite')) + except sql.SqlException as e: + error.handle_fatal_exc(e, args, 'Error initializing SQL', + pre_text='Error initializing SQL') + sys.exit(usertypes.Exit.err_init) + log.init.debug("Initializing web history...") history.init(qApp) @@ -449,9 +459,6 @@ def _init_modules(args, crash_handler): diskcache = cache.DiskCache(standarddir.cache(), parent=qApp) objreg.register('cache', diskcache) - log.init.debug("Initializing completions...") - completionmodels.init() - log.init.debug("Misc initialization...") if config.val.window.hide_wayland_decoration: os.environ['QT_WAYLAND_DISABLE_WINDOWDECORATION'] = '1' @@ -462,23 +469,6 @@ def _init_modules(args, crash_handler): browsertab.init() -def _init_late_modules(args): - """Initialize modules which can be inited after the window is shown.""" - 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() - try: - next(reader) - except StopIteration: - break - 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. @@ -626,7 +616,7 @@ class Quitter: # Save the session if one is given. if session is not None: session_manager = objreg.get('session-manager') - session_manager.save(session) + session_manager.save(session, with_private=True) # Open a new process and immediately shutdown the existing one try: args, cwd = self._get_restart_args(pages, session) @@ -760,7 +750,7 @@ class Quitter: QTimer.singleShot(0, functools.partial(qApp.exit, status)) @cmdutils.register(instance='quitter', name='wq') - @cmdutils.argument('name', completion=usertypes.Completion.sessions) + @cmdutils.argument('name', completion=miscmodels.session) def save_and_quit(self, name=sessions.default): """Save open pages and quit. diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index c3e511171..6183474a4 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -479,11 +479,21 @@ class AbstractHistory: def current_idx(self): raise NotImplementedError - def back(self): - raise NotImplementedError + def back(self, count=1): + idx = self.current_idx() - count + if idx >= 0: + self._go_to_item(self._item_at(idx)) + else: + self._go_to_item(self._item_at(0)) + raise WebTabError("At beginning of history.") - def forward(self): - raise NotImplementedError + def forward(self, count=1): + idx = self.current_idx() + count + if idx < len(self): + self._go_to_item(self._item_at(idx)) + else: + self._go_to_item(self._item_at(len(self) - 1)) + raise WebTabError("At end of history.") def can_go_back(self): raise NotImplementedError @@ -491,6 +501,12 @@ class AbstractHistory: def can_go_forward(self): raise NotImplementedError + def _item_at(self, i): + raise NotImplementedError + + def _go_to_item(self, item): + raise NotImplementedError + def serialize(self): """Serialize into an opaque format understood by self.deserialize.""" raise NotImplementedError diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index d771414ae..b11c2e277 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -20,11 +20,12 @@ """Command dispatcher for TabbedBrowser.""" import os +import sys import os.path import shlex import functools -from PyQt5.QtWidgets import QApplication, QTabBar +from PyQt5.QtWidgets import QApplication, QTabBar, QDialog from PyQt5.QtCore import Qt, QUrl, QEvent, QUrlQuery from PyQt5.QtGui import QKeyEvent from PyQt5.QtPrintSupport import QPrintDialog, QPrintPreviewDialog @@ -38,10 +39,10 @@ from qutebrowser.browser import (urlmarks, browsertab, inspector, navigate, webelem, downloads) from qutebrowser.keyinput import modeman from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, - objreg, utils, typing) + objreg, utils, typing, debug) from qutebrowser.utils.usertypes import KeyMode from qutebrowser.misc import editor, guiprocess -from qutebrowser.completion.models import instances, sortfilter +from qutebrowser.completion.models import urlmodel, miscmodels class CommandDispatcher: @@ -227,19 +228,6 @@ class CommandDispatcher: self._tabbed_browser.close_tab(tab) tabbar.setSelectionBehaviorOnRemove(old_selection_behavior) - def _tab_close_prompt_if_pinned(self, tab, force, yes_action): - """Helper method for tab_close. - - If tab is pinned, prompt. If everything is good, run yes_action. - """ - if tab.data.pinned and not force: - message.confirm_async( - title='Pinned Tab', - text="Are you sure you want to close a pinned tab?", - yes_action=yes_action, default=False) - else: - yes_action() - @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', count=True) def tab_close(self, prev=False, next_=False, opposite=False, @@ -260,7 +248,7 @@ class CommandDispatcher: close = functools.partial(self._tab_close, tab, prev, next_, opposite) - self._tab_close_prompt_if_pinned(tab, force, close) + self._tabbed_browser.tab_close_prompt_if_pinned(tab, force, close) @cmdutils.register(instance='command-dispatcher', scope='window', name='tab-pin') @@ -280,13 +268,11 @@ class CommandDispatcher: return to_pin = not tab.data.pinned - tab_index = self._current_index() if count is None else count - 1 - cmdutils.check_overflow(tab_index + 1, 'int') - self._tabbed_browser.set_tab_pinned(tab_index, to_pin) + self._tabbed_browser.set_tab_pinned(tab, to_pin) @cmdutils.register(instance='command-dispatcher', name='open', maxsplit=0, scope='window') - @cmdutils.argument('url', completion=usertypes.Completion.url) + @cmdutils.argument('url', completion=urlmodel.url) @cmdutils.argument('count', count=True) def openurl(self, url=None, related=False, bg=False, tab=False, window=False, count=None, secure=False, @@ -438,9 +424,18 @@ class CommandDispatcher: message.error("Printing failed!") diag.deleteLater() + def do_print(): + """Called when the dialog was closed.""" + tab.printing.to_printer(diag.printer(), print_callback) + diag = QPrintDialog(tab) - diag.open(lambda: tab.printing.to_printer(diag.printer(), - print_callback)) + if sys.platform == 'darwin': + # For some reason we get a segfault when using open() on macOS + ret = diag.exec_() + if ret == QDialog.Accepted: + do_print() + else: + diag.open(do_print) @cmdutils.register(instance='command-dispatcher', name='print', scope='window') @@ -515,7 +510,7 @@ class CommandDispatcher: newtab.data.keep_icon = True newtab.history.deserialize(history) newtab.zoom.set_factor(curtab.zoom.factor()) - new_tabbed_browser.set_tab_pinned(idx, curtab.data.pinned) + new_tabbed_browser.set_tab_pinned(newtab, curtab.data.pinned) return newtab @cmdutils.register(instance='command-dispatcher', scope='window') @@ -542,15 +537,13 @@ class CommandDispatcher: else: widget = self._current_widget() - for _ in range(count): + try: if forward: - if not widget.history.can_go_forward(): - raise cmdexc.CommandError("At end of history.") - widget.history.forward() + widget.history.forward(count) else: - if not widget.history.can_go_back(): - raise cmdexc.CommandError("At beginning of history.") - widget.history.back() + widget.history.back(count) + except browsertab.WebTabError as e: + raise cmdexc.CommandError(e) @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', count=True) @@ -920,8 +913,9 @@ class CommandDispatcher: if not force: for i, tab in enumerate(self._tabbed_browser.widgets()): if _to_close(i) and tab.data.pinned: - self._tab_close_prompt_if_pinned( - tab, force, + self._tabbed_browser.tab_close_prompt_if_pinned( + tab, + force, lambda: self.tab_only( prev=prev, next_=next_, force=True)) return @@ -1016,7 +1010,7 @@ class CommandDispatcher: self._open(url, tab, bg, window) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('index', completion=usertypes.Completion.tab) + @cmdutils.argument('index', completion=miscmodels.buffer) def buffer(self, index): """Select tab by index or url/title best match. @@ -1032,11 +1026,10 @@ class CommandDispatcher: for part in index_parts: int(part) except ValueError: - model = instances.get(usertypes.Completion.tab) - sf = sortfilter.CompletionFilterModel(source=model) - sf.set_pattern(index) - if sf.count() > 0: - index = sf.data(sf.first_item()) + model = miscmodels.buffer() + model.set_pattern(index) + if model.count() > 0: + index = model.data(model.first_item()) index_parts = index.split('/', 1) else: raise cmdexc.CommandError( @@ -1167,6 +1160,7 @@ class CommandDispatcher: detach: Whether the command should be detached from qutebrowser. cmdline: The commandline to execute. """ + cmdutils.check_exclusive((userscript, detach), 'ud') try: cmd, *args = shlex.split(cmdline) except ValueError as e: @@ -1241,8 +1235,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) - @cmdutils.argument('name', - completion=usertypes.Completion.quickmark_by_name) + @cmdutils.argument('name', completion=miscmodels.quickmark) def quickmark_load(self, name, tab=False, bg=False, window=False): """Load a quickmark. @@ -1260,8 +1253,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) - @cmdutils.argument('name', - completion=usertypes.Completion.quickmark_by_name) + @cmdutils.argument('name', completion=miscmodels.quickmark) def quickmark_del(self, name=None): """Delete a quickmark. @@ -1323,7 +1315,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) - @cmdutils.argument('url', completion=usertypes.Completion.bookmark_by_url) + @cmdutils.argument('url', completion=miscmodels.bookmark) def bookmark_load(self, url, tab=False, bg=False, window=False, delete=False): """Load a bookmark. @@ -1345,7 +1337,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) - @cmdutils.argument('url', completion=usertypes.Completion.bookmark_by_url) + @cmdutils.argument('url', completion=miscmodels.bookmark) def bookmark_del(self, url=None): """Delete a bookmark. @@ -1450,8 +1442,18 @@ class CommandDispatcher: download_manager.get_mhtml(tab, target) else: qnam = tab.networkaccessmanager() - download_manager.get(self._current_url(), user_agent=user_agent, - qnam=qnam, target=target) + + suggested_fn = downloads.suggested_fn_from_title( + self._current_url().path(), tab.title() + ) + + download_manager.get( + self._current_url(), + user_agent=user_agent, + qnam=qnam, + target=target, + suggested_fn=suggested_fn + ) @cmdutils.register(instance='command-dispatcher', scope='window') def view_source(self): @@ -1519,7 +1521,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', name='help', scope='window') - @cmdutils.argument('topic', completion=usertypes.Completion.helptopic) + @cmdutils.argument('topic', completion=miscmodels.helptopic) def show_help(self, tab=False, bg=False, window=False, topic=None): r"""Show help about a command or setting. @@ -1728,7 +1730,8 @@ class CommandDispatcher: """ self.set_mark("'") tab = self._current_widget() - tab.search.clear() + if tab.search.search_displayed: + tab.search.clear() if not text: return @@ -2159,6 +2162,10 @@ class CommandDispatcher: window = self._tabbed_browser.window() if window.isFullScreen(): - window.showNormal() + window.setWindowState( + window.state_before_fullscreen & ~Qt.WindowFullScreen) else: + window.state_before_fullscreen = window.windowState() window.showFullScreen() + log.misc.debug('state before fullscreen: {}'.format( + debug.qflags_key(Qt, window.state_before_fullscreen))) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index fa34648e0..478bd7483 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -181,6 +181,28 @@ def transform_path(path): return path +def suggested_fn_from_title(url_path, title=None): + """Suggest a filename depending on the URL extension and page title. + + Args: + url_path: a string with the URL path + title: the page title string + + Return: + The download filename based on the title, or None if the extension is + not found in the whitelist (or if there is no page title). + """ + ext_whitelist = [".html", ".htm", ".php", ""] + _, ext = os.path.splitext(url_path) + if ext.lower() in ext_whitelist and title: + suggested_fn = utils.sanitize_filename(title) + if not suggested_fn.lower().endswith((".html", ".htm")): + suggested_fn += ".html" + else: + suggested_fn = None + return suggested_fn + + class NoFilenameError(Exception): """Raised when we can't find out a filename in DownloadTarget.""" diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index aaad08fb3..86e597bcd 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -19,214 +19,82 @@ """Simple history which gets written to disk.""" +import os import time -import collections -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, QObject +from PyQt5.QtCore import pyqtSlot, QUrl, QTimer -from qutebrowser.commands import cmdutils -from qutebrowser.utils import (utils, objreg, standarddir, log, qtutils, - usertypes, message) -from qutebrowser.misc import lineparser, objects +from qutebrowser.commands import cmdutils, cmdexc +from qutebrowser.utils import (utils, objreg, log, usertypes, message, + debug, standarddir) +from qutebrowser.misc import objects, sql -class Entry: +class CompletionHistory(sql.SqlTable): - """A single entry in the web history. + """History which only has the newest entry for each URL.""" - Attributes: - atime: The time the page was accessed. - url: The URL which was accessed as QUrl. - redirect: If True, don't save this entry to disk - """ - - def __init__(self, atime, url, title, redirect=False): - self.atime = float(atime) - self.url = url - self.title = title - self.redirect = redirect - qtutils.ensure_valid(url) - - def __repr__(self): - return utils.get_repr(self, constructor=True, atime=self.atime, - url=self.url_str(), title=self.title, - redirect=self.redirect) - - def __str__(self): - atime = str(int(self.atime)) - if self.redirect: - atime += '-r' # redirect flag - elems = [atime, self.url_str()] - if self.title: - elems.append(self.title) - return ' '.join(elems) - - def __eq__(self, other): - return (self.atime == other.atime and - self.title == other.title and - self.url == other.url and - self.redirect == other.redirect) - - def url_str(self): - """Get the URL as a lossless string.""" - return self.url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) - - @classmethod - def from_str(cls, line): - """Parse a history line like '12345 http://example.com title'.""" - data = line.split(maxsplit=2) - if len(data) == 2: - atime, url = data - title = "" - elif len(data) == 3: - atime, url, title = data - else: - raise ValueError("2 or 3 fields expected") - - url = QUrl(url) - if not url.isValid(): - raise ValueError("Invalid URL: {}".format(url.errorString())) - - # https://github.com/qutebrowser/qutebrowser/issues/670 - atime = atime.lstrip('\0') - - if '-' in atime: - atime, flags = atime.split('-') - else: - flags = '' - - if not set(flags).issubset('r'): - raise ValueError("Invalid flags {!r}".format(flags)) - - redirect = 'r' in flags - - return cls(atime, url, title, redirect=redirect) - - -class WebHistory(QObject): - - """The global history of visited pages. - - This is a little more complex as you'd expect so the history can be read - from disk async while new history is already arriving. - - self.history_dict is the main place where the history is stored, in an - OrderedDict (sorted by time) of URL strings mapped to Entry objects. + def __init__(self, parent=None): + super().__init__("CompletionHistory", ['url', 'title', 'last_atime'], + constraints={'url': 'PRIMARY KEY'}, parent=parent) + self.create_index('CompletionHistoryAtimeIndex', 'last_atime') - While reading from disk is still ongoing, the history is saved in - self._temp_history instead, and then appended to self.history_dict once - that's fully populated. - All history which is new in this session (rather than read from disk from a - previous browsing session) is also stored in self._new_history. - self._saved_count tracks how many of those entries were already written to - disk, so we can always append to the existing data. +class WebHistory(sql.SqlTable): - Attributes: - history_dict: An OrderedDict of URLs read from the on-disk history. - _lineparser: The AppendLineParser used to save the history. - _new_history: A list of Entry 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. + """The global history of visited pages.""" - Signals: - add_completion_item: Emitted before a new Entry is added. - Used to sync with the completion. - arg: The new Entry. - item_added: Emitted after a new Entry is added. - Used to tell the savemanager that the history is dirty. - arg: The new Entry. - cleared: Emitted after the history is cleared. - """ - - add_completion_item = pyqtSignal(Entry) - item_added = pyqtSignal(Entry) - cleared = pyqtSignal() - async_read_done = pyqtSignal() - - def __init__(self, hist_dir, hist_name, parent=None): - super().__init__(parent) - self._initial_read_started = False - self._initial_read_done = False - self._lineparser = lineparser.AppendLineParser(hist_dir, hist_name, - parent=self) - self.history_dict = collections.OrderedDict() - self._temp_history = collections.OrderedDict() - self._new_history = [] - self._saved_count = 0 - objreg.get('save-manager').add_saveable( - 'history', self.save, self.item_added) + def __init__(self, parent=None): + super().__init__("History", ['url', 'title', 'atime', 'redirect'], + parent=parent) + self.completion = CompletionHistory(parent=self) + self.create_index('HistoryIndex', 'url') + self.create_index('HistoryAtimeIndex', 'atime') + self._contains_query = self.contains_query('url') + self._between_query = sql.Query('SELECT * FROM History ' + 'where not redirect ' + 'and not url like "qute://%" ' + 'and atime > :earliest ' + 'and atime <= :latest ' + 'ORDER BY atime desc') + + self._before_query = sql.Query('SELECT * FROM History ' + 'where not redirect ' + 'and not url like "qute://%" ' + 'and atime <= :latest ' + 'ORDER BY atime desc ' + 'limit :limit offset :offset') def __repr__(self): return utils.get_repr(self, length=len(self)) - def __iter__(self): - return iter(self.history_dict.values()) + def __contains__(self, url): + return self._contains_query.run(val=url).value() - def __len__(self): - return len(self.history_dict) - - def async_read(self): - """Read the initial history.""" - if self._initial_read_started: - log.init.debug("Ignoring async_read() because reading is started.") - return - self._initial_read_started = True + def get_recent(self): + """Get the most recent history entries.""" + return self.select(sort_by='atime', sort_order='desc', limit=100) - with self._lineparser.open(): - for line in self._lineparser: - yield + def entries_between(self, earliest, latest): + """Iterate non-redirect, non-qute entries between two timestamps. - line = line.rstrip() - if not line: - continue + Args: + earliest: Omit timestamps earlier than this. + latest: Omit timestamps later than this. + """ + self._between_query.run(earliest=earliest, latest=latest) + return iter(self._between_query) - try: - entry = Entry.from_str(line) - except ValueError as e: - log.init.warning("Invalid history entry {!r}: {}!".format( - line, e)) - continue - - # This de-duplicates history entries; only the latest - # entry for each URL is kept. If you want to keep - # information about previous hits change the items in - # old_urls to be lists or change Entry to have a - # list of atimes. - self._add_entry(entry) - - self._initial_read_done = True - self.async_read_done.emit() - - for entry in self._temp_history.values(): - self._add_entry(entry) - self._new_history.append(entry) - if not entry.redirect: - self.add_completion_item.emit(entry) - self._temp_history.clear() - - 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 - url_str = entry.url_str() - target[url_str] = entry - target.move_to_end(url_str) + def entries_before(self, latest, limit, offset): + """Iterate non-redirect, non-qute entries occurring before a timestamp. - def get_recent(self): - """Get the most recent history entries.""" - old = self._lineparser.get_recent() - return old + [str(e) for e in self._new_history] - - def save(self): - """Save the history to disk.""" - new = (str(e) for e in self._new_history[self._saved_count:]) - self._lineparser.new_data = new - self._lineparser.save() - self._saved_count = len(self._new_history) + Args: + latest: Omit timestamps more recent than this. + limit: Max number of entries to include. + offset: Number of entries to skip. + """ + self._before_query.run(latest=latest, limit=limit, offset=offset) + return iter(self._before_query) @cmdutils.register(name='history-clear', instance='web-history') def clear(self, force=False): @@ -246,12 +114,17 @@ class WebHistory(QObject): "history?") def _do_clear(self): - self._lineparser.clear() - self.history_dict.clear() - self._temp_history.clear() - self._new_history.clear() - self._saved_count = 0 - self.cleared.emit() + self.delete_all() + self.completion.delete_all() + + def delete_url(self, url): + """Remove all history entries with the given url. + + Args: + url: URL string to delete. + """ + self.delete('url', url) + self.completion.delete('url', url) @pyqtSlot(QUrl, QUrl, str) def add_from_tab(self, url, requested_url, title): @@ -285,17 +158,130 @@ class WebHistory(QObject): log.misc.warning("Ignoring invalid URL being added to history") return - if atime is None: - atime = time.time() - entry = Entry(atime, url, title, redirect=redirect) - if self._initial_read_done: - self._add_entry(entry) - self._new_history.append(entry) - self.item_added.emit(entry) - if not entry.redirect: - self.add_completion_item.emit(entry) + atime = int(atime) if (atime is not None) else int(time.time()) + url_str = url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) + self.insert({'url': url_str, + 'title': title, + 'atime': atime, + 'redirect': redirect}) + if not redirect: + self.completion.insert({'url': url_str, + 'title': title, + 'last_atime': atime}, + replace=True) + + def _parse_entry(self, line): + """Parse a history line like '12345 http://example.com title'.""" + if not line or line.startswith('#'): + return None + data = line.split(maxsplit=2) + if len(data) == 2: + atime, url = data + title = "" + elif len(data) == 3: + atime, url, title = data else: - self._add_entry(entry, target=self._temp_history) + raise ValueError("2 or 3 fields expected") + + # http://xn--pple-43d.com/ with + # https://bugreports.qt.io/browse/QTBUG-60364 + if url in ['http://.com/', 'https://.com/', + 'http://www..com/', 'https://www..com/']: + return None + + url = QUrl(url) + if not url.isValid(): + raise ValueError("Invalid URL: {}".format(url.errorString())) + + # https://github.com/qutebrowser/qutebrowser/issues/2646 + if url.scheme() == 'data': + return None + + # https://github.com/qutebrowser/qutebrowser/issues/670 + atime = atime.lstrip('\0') + + if '-' in atime: + atime, flags = atime.split('-') + else: + flags = '' + + if not set(flags).issubset('r'): + raise ValueError("Invalid flags {!r}".format(flags)) + + redirect = 'r' in flags + return (url, title, int(atime), redirect) + + def import_txt(self): + """Import a history text file into sqlite if it exists. + + In older versions of qutebrowser, history was stored in a text format. + This converts that file into the new sqlite format and moves it to a + backup location. + """ + path = os.path.join(standarddir.data(), 'history') + if not os.path.isfile(path): + return + + def action(): + with debug.log_time(log.init, 'Import old history file to sqlite'): + try: + self._read(path) + except ValueError as ex: + message.error('Failed to import history: {}'.format(ex)) + else: + bakpath = path + '.bak' + message.info('History import complete. Moving {} to {}' + .format(path, bakpath)) + os.rename(path, bakpath) + + # delay to give message time to appear before locking down for import + message.info('Converting {} to sqlite...'.format(path)) + QTimer.singleShot(100, action) + + def _read(self, path): + """Import a text file into the sql database.""" + with open(path, 'r', encoding='utf-8') as f: + data = {'url': [], 'title': [], 'atime': [], 'redirect': []} + completion_data = {'url': [], 'title': [], 'last_atime': []} + for (i, line) in enumerate(f): + try: + parsed = self._parse_entry(line.strip()) + if parsed is None: + continue + url, title, atime, redirect = parsed + data['url'].append(url) + data['title'].append(title) + data['atime'].append(atime) + data['redirect'].append(redirect) + if not redirect: + completion_data['url'].append(url) + completion_data['title'].append(title) + completion_data['last_atime'].append(atime) + except ValueError as ex: + raise ValueError('Failed to parse line #{} of {}: "{}"' + .format(i, path, ex)) + self.insert_batch(data) + self.completion.insert_batch(completion_data, replace=True) + + @cmdutils.register(instance='web-history', debug=True) + def debug_dump_history(self, dest): + """Dump the history to a file in the old pre-SQL format. + + Args: + dest: Where to write the file to. + """ + dest = os.path.expanduser(dest) + + lines = ('{}{} {} {}' + .format(int(x.atime), '-r' * x.redirect, x.url, x.title) + for x in self.select(sort_by='atime', sort_order='asc')) + + try: + with open(dest, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines)) + message.info("Dumped history to {}".format(dest)) + except OSError as e: + raise cmdexc.CommandError('Could not write history: {}', e) def init(parent=None): @@ -304,8 +290,7 @@ def init(parent=None): Args: parent: The parent to use for WebHistory. """ - history = WebHistory(hist_dir=standarddir.data(), hist_name='history', - parent=parent) + history = WebHistory(parent=parent) objreg.register('web-history', history) if objects.backend == usertypes.Backend.QtWebKit: diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py index d5f232e8a..22ec4c02c 100644 --- a/qutebrowser/browser/qtnetworkdownloads.py +++ b/qutebrowser/browser/qtnetworkdownloads.py @@ -412,7 +412,8 @@ class DownloadManager(downloads.AbstractDownloadManager): mhtml.start_download_checked, tab=tab)) message.global_bridge.ask(question, blocking=False) - def get_request(self, request, *, target=None, **kwargs): + def get_request(self, request, *, target=None, + suggested_fn=None, **kwargs): """Start a download with a QNetworkRequest. Args: @@ -428,7 +429,9 @@ class DownloadManager(downloads.AbstractDownloadManager): request.setAttribute(QNetworkRequest.CacheLoadControlAttribute, QNetworkRequest.AlwaysNetwork) - if request.url().scheme().lower() != 'data': + if suggested_fn is not None: + pass + elif request.url().scheme().lower() != 'data': suggested_fn = urlutils.filename_from_url(request.url()) else: # We might be downloading a binary blob embedded on a page or even diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 1ef00150e..b6f50c2b3 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -26,7 +26,6 @@ Module attributes: import json import os -import sys import time import urllib.parse import datetime @@ -185,88 +184,36 @@ def qute_bookmarks(_url): return 'text/html', html -def history_data(start_time): # noqa - """Return history data +def history_data(start_time, offset=None): + """Return history data. Arguments: - start_time -- select history starting from this timestamp. + start_time: select history starting from this timestamp. + offset: number of items to skip """ - def history_iter(start_time, reverse=False): - """Iterate through the history and get items we're interested. - - Arguments: - reverse -- whether to reverse the history_dict before iterating. - """ - history = objreg.get('web-history').history_dict.values() - if reverse: - history = reversed(history) - - # when history_dict is not reversed, we need to keep track of last item - # so that we can yield its atime - last_item = None - + # history atimes are stored as ints, ensure start_time is not a float + start_time = int(start_time) + hist = objreg.get('web-history') + if offset is not None: + entries = hist.entries_before(start_time, limit=1000, offset=offset) + else: # end is 24hrs earlier than start end_time = start_time - 24*60*60 + entries = hist.entries_between(end_time, start_time) - for item in history: - # Skip redirects - # Skip qute:// links - if item.redirect or item.url.scheme() == 'qute': - continue - - # Skip items out of time window - item_newer = item.atime > start_time - item_older = item.atime <= end_time - if reverse: - # history_dict is reversed, we are going back in history. - # so: - # abort if item is older than start_time+24hr - # skip if item is newer than start - if item_older: - yield {"next": int(item.atime)} - return - if item_newer: - continue - else: - # history_dict isn't reversed, we are going forward in history. - # so: - # abort if item is newer than start_time - # skip if item is older than start_time+24hrs - if item_older: - last_item = item - continue - if item_newer: - yield {"next": int(last_item.atime if last_item else -1)} - return - - # Use item's url as title if there's no title. - item_url = item.url.toDisplayString() - item_title = item.title if item.title else item_url - item_time = int(item.atime * 1000) - - yield {"url": item_url, "title": item_title, "time": item_time} - - # if we reached here, we had reached the end of history - yield {"next": int(last_item.atime if last_item else -1)} - - if sys.hexversion >= 0x03050000: - # On Python >= 3.5 we can reverse the ordereddict in-place and thus - # apply an additional performance improvement in history_iter. - # On my machine, this gets us down from 550ms to 72us with 500k old - # items. - history = history_iter(start_time, reverse=True) - else: - # On Python 3.4, we can't do that, so we'd need to copy the entire - # history to a list. There, filter first and then reverse it here. - history = reversed(list(history_iter(start_time, reverse=False))) - - return list(history) + return [{"url": e.url, "title": e.title or e.url, "time": e.atime} + for e in entries] @add_handler('history') def qute_history(url): """Handler for qute://history. Display and serve history.""" if url.path() == '/data': + try: + offset = QUrlQuery(url).queryItemValue("offset") + offset = int(offset) if offset else None + except ValueError as e: + raise QuteSchemeError("Query parameter offset is invalid", e) # Use start_time in query or current time. try: start_time = QUrlQuery(url).queryItemValue("start_time") @@ -274,7 +221,7 @@ def qute_history(url): except ValueError as e: raise QuteSchemeError("Query parameter start_time is invalid", e) - return 'text/html', json.dumps(history_data(start_time)) + return 'text/html', json.dumps(history_data(start_time, offset)) else: if ( config.val.content.javascript.enabled and @@ -306,9 +253,9 @@ def qute_history(url): start_time = time.mktime(next_date.timetuple()) - 1 history = [ (i["url"], i["title"], - datetime.datetime.fromtimestamp(i["time"]/1000), + datetime.datetime.fromtimestamp(i["time"]), QUrl(i["url"]).host()) - for i in history_data(start_time) if "next" not in i + for i in history_data(start_time) ] return 'text/html', jinja.render( diff --git a/qutebrowser/browser/urlmarks.py b/qutebrowser/browser/urlmarks.py index 013de408c..b7c93a994 100644 --- a/qutebrowser/browser/urlmarks.py +++ b/qutebrowser/browser/urlmarks.py @@ -77,13 +77,9 @@ class UrlMarkManager(QObject): Signals: changed: Emitted when anything changed. - added: Emitted when a new quickmark/bookmark was added. - removed: Emitted when an existing quickmark/bookmark was removed. """ changed = pyqtSignal() - added = pyqtSignal(str, str) - removed = pyqtSignal(str) def __init__(self, parent=None): """Initialize and read quickmarks.""" @@ -121,7 +117,6 @@ class UrlMarkManager(QObject): """ del self.marks[key] self.changed.emit() - self.removed.emit(key) class QuickmarkManager(UrlMarkManager): @@ -133,7 +128,6 @@ class QuickmarkManager(UrlMarkManager): - self.marks maps names to URLs. - changed gets emitted with the name as first argument and the URL as second argument. - - removed gets emitted with the name as argument. """ def _init_lineparser(self): @@ -193,7 +187,6 @@ class QuickmarkManager(UrlMarkManager): """Really set the quickmark.""" self.marks[name] = url self.changed.emit() - self.added.emit(name, url) log.misc.debug("Added quickmark {} for {}".format(name, url)) if name in self.marks: @@ -243,7 +236,6 @@ class BookmarkManager(UrlMarkManager): - self.marks maps URLs to titles. - changed gets emitted with the URL as first argument and the title as second argument. - - removed gets emitted with the URL as argument. """ def _init_lineparser(self): @@ -295,5 +287,4 @@ class BookmarkManager(UrlMarkManager): else: self.marks[urlstr] = title self.changed.emit() - self.added.emit(title, urlstr) return True diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 45a26fbdd..0bac53915 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -28,7 +28,9 @@ Module attributes: """ import os -import logging +import sys +import ctypes +import ctypes.util from PyQt5.QtGui import QFont from PyQt5.QtWebEngineWidgets import (QWebEngineSettings, QWebEngineProfile, @@ -203,12 +205,10 @@ def init(args): if args.enable_webengine_inspector: os.environ['QTWEBENGINE_REMOTE_DEBUGGING'] = str(utils.random_port()) - # Workaround for a black screen with some setups - # https://github.com/spyder-ide/spyder/issues/3226 - if not os.environ.get('QUTE_NO_OPENGL_WORKAROUND'): - # Hide "No OpenGL_accelerate module loaded: ..." message - logging.getLogger('OpenGL.acceleratesupport').propagate = False - from OpenGL import GL # pylint: disable=unused-variable + # WORKAROUND for + # https://bugs.launchpad.net/ubuntu/+source/python-qt4/+bug/941826 + if sys.platform == 'linux': + ctypes.CDLL(ctypes.util.find_library("GL"), mode=ctypes.RTLD_GLOBAL) _init_profiles() diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index fa443918f..7dd8b0629 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -51,12 +51,13 @@ def init(): global _qute_scheme_handler app = QApplication.instance() - software_rendering = os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1' + software_rendering = (os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1' or + 'QT_XCB_FORCE_SOFTWARE_OPENGL' in os.environ) if version.opengl_vendor() == 'nouveau' and not software_rendering: # FIXME:qtwebengine display something more sophisticated here raise browsertab.WebTabError( "QtWebEngine is not supported with Nouveau graphics (unless " - "LIBGL_ALWAYS_SOFTWARE is set as environment variable).") + "QT_XCB_FORCE_SOFTWARE_OPENGL is set as environment variable).") log.init.debug("Initializing qute://* handler...") _qute_scheme_handler = webenginequtescheme.QuteSchemeHandler(parent=app) @@ -406,18 +407,18 @@ class WebEngineHistory(browsertab.AbstractHistory): def current_idx(self): return self._history.currentItemIndex() - def back(self): - self._history.back() - - def forward(self): - self._history.forward() - def can_go_back(self): return self._history.canGoBack() def can_go_forward(self): return self._history.canGoForward() + def _item_at(self, i): + return self._history.itemAt(i) + + def _go_to_item(self, item): + return self._history.goToItem(item) + def serialize(self): if not qtutils.version_check('5.9'): # WORKAROUND for @@ -611,6 +612,7 @@ class WebEngineTab(browsertab.AbstractTab): def shutdown(self): self.shutting_down.emit() + self.action.exit_fullscreen() if qtutils.version_check('5.8', exact=True): # WORKAROUND for # https://bugreports.qt.io/browse/QTBUG-58563 @@ -711,7 +713,8 @@ class WebEngineTab(browsertab.AbstractTab): @pyqtSlot() def _on_load_started(self): """Clear search when a new load is started if needed.""" - if qtutils.version_check('5.9'): + if (qtutils.version_check('5.9') and + not qtutils.version_check('5.9.2')): # WORKAROUND for # https://bugreports.qt.io/browse/QTBUG-61506 self.search.clear() diff --git a/qutebrowser/browser/webkit/webkithistory.py b/qutebrowser/browser/webkit/webkithistory.py index 0f9d64460..0edbb3fa3 100644 --- a/qutebrowser/browser/webkit/webkithistory.py +++ b/qutebrowser/browser/webkit/webkithistory.py @@ -19,9 +19,12 @@ """QtWebKit specific part of history.""" +import functools from PyQt5.QtWebKit import QWebHistoryInterface +from qutebrowser.utils import debug + class WebHistoryInterface(QWebHistoryInterface): @@ -34,11 +37,13 @@ class WebHistoryInterface(QWebHistoryInterface): def __init__(self, webhistory, parent=None): super().__init__(parent) self._history = webhistory + self._history.changed.connect(self.historyContains.cache_clear) def addHistoryEntry(self, url_string): """Required for a QWebHistoryInterface impl, obsoleted by add_url.""" pass + @functools.lru_cache(maxsize=32768) def historyContains(self, url_string): """Called by WebKit to determine if a URL is contained in the history. @@ -48,7 +53,8 @@ class WebHistoryInterface(QWebHistoryInterface): Return: True if the url is in the history, False otherwise. """ - return url_string in self._history.history_dict + with debug.log_time('sql', 'historyContains'): + return url_string in self._history def init(history): diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index abf77f048..005c1ac96 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -32,7 +32,6 @@ from PyQt5.QtWebKit import QWebSettings from PyQt5.QtPrintSupport import QPrinter from qutebrowser.browser import browsertab -from qutebrowser.browser.network import proxy from qutebrowser.browser.webkit import webview, tabhistory, webkitelem from qutebrowser.utils import qtutils, objreg, usertypes, utils, log, debug @@ -502,18 +501,18 @@ class WebKitHistory(browsertab.AbstractHistory): def current_idx(self): return self._history.currentItemIndex() - def back(self): - self._history.back() - - def forward(self): - self._history.forward() - def can_go_back(self): return self._history.canGoBack() def can_go_forward(self): return self._history.canGoForward() + def _item_at(self, i): + return self._history.itemAt(i) + + def _go_to_item(self, item): + return self._history.goToItem(item) + def serialize(self): return qtutils.serialize(self._history) diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 58be86d17..3d8ba3113 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -23,8 +23,8 @@ from PyQt5.QtCore import pyqtSlot, QObject, QTimer from qutebrowser.config import config from qutebrowser.commands import cmdutils, runners -from qutebrowser.utils import usertypes, log, utils -from qutebrowser.completion.models import instances, sortfilter +from qutebrowser.utils import log, utils, debug +from qutebrowser.completion.models import miscmodels class Completer(QObject): @@ -38,6 +38,7 @@ 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. + _last_completion_func: The completion function used for the last text. """ def __init__(self, cmd, parent=None): @@ -50,6 +51,7 @@ class Completer(QObject): self._timer.timeout.connect(self._update_completion) self._last_cursor_pos = None self._last_text = None + self._last_completion_func = None self._cmd.update_completion.connect(self.schedule_completion_update) def __repr__(self): @@ -60,37 +62,8 @@ class Completer(QObject): completion = self.parent() return completion.model() - def _get_completion_model(self, completion, pos_args): - """Get a completion model based on an enum member. - - Args: - completion: A usertypes.Completion member. - pos_args: The positional args entered before the cursor. - - Return: - A completion model or None. - """ - if completion == usertypes.Completion.option: - section = pos_args[0] - model = instances.get(completion).get(section) - elif completion == usertypes.Completion.value: - section = pos_args[0] - option = pos_args[1] - try: - model = instances.get(completion)[section][option] - except KeyError: - # No completion model for this section/option. - model = None - else: - model = instances.get(completion) - - if model is None: - return None - else: - return sortfilter.CompletionFilterModel(source=model, parent=self) - def _get_new_completion(self, before_cursor, under_cursor): - """Get a new completion. + """Get the completion function based on the current command text. Args: before_cursor: The command chunks before the cursor. @@ -107,8 +80,8 @@ class Completer(QObject): log.completion.debug("After removing flags: {}".format(before_cursor)) if not before_cursor: # '|' or 'set|' - model = instances.get(usertypes.Completion.command) - return sortfilter.CompletionFilterModel(source=model, parent=self) + log.completion.debug('Starting command completion') + return miscmodels.command try: cmd = cmdutils.cmd_dict[before_cursor[0]] except KeyError: @@ -117,14 +90,11 @@ class Completer(QObject): return None argpos = len(before_cursor) - 1 try: - completion = cmd.get_pos_arg_info(argpos).completion + func = cmd.get_pos_arg_info(argpos).completion except IndexError: log.completion.debug("No completion in position {}".format(argpos)) return None - if completion is None: - return None - model = self._get_completion_model(completion, before_cursor[1:]) - return model + return func def _quote(self, s): """Quote s if it needs quoting for the commandline. @@ -239,6 +209,7 @@ class Completer(QObject): # FIXME complete searches # https://github.com/qutebrowser/qutebrowser/issues/32 completion.set_model(None) + self._last_completion_func = None return before_cursor, pattern, after_cursor = self._partition() @@ -247,13 +218,24 @@ class Completer(QObject): before_cursor, pattern, after_cursor)) pattern = pattern.strip("'\"") - model = self._get_new_completion(before_cursor, pattern) + func = self._get_new_completion(before_cursor, pattern) + + if func is None: + log.completion.debug('Clearing completion') + completion.set_model(None) + self._last_completion_func = None + return - log.completion.debug("Setting completion model to {} with pattern '{}'" - .format(model.srcmodel.__class__.__name__ if model else 'None', - pattern)) + if func != self._last_completion_func: + self._last_completion_func = func + args = (x for x in before_cursor[1:] if not x.startswith('-')) + with debug.log_time(log.completion, + 'Starting {} completion'.format(func.__name__)): + model = func(*args) + with debug.log_time(log.completion, 'Set completion model'): + completion.set_model(model) - completion.set_model(model, pattern) + completion.set_pattern(pattern) def _change_completed_part(self, newtext, before, after, immediate=False): """Change the part we're currently completing in the commandline. diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py index c7ca94a16..2a0c29bc2 100644 --- a/qutebrowser/completion/completiondelegate.py +++ b/qutebrowser/completion/completiondelegate.py @@ -198,8 +198,9 @@ class CompletionItemDelegate(QStyledItemDelegate): self._doc.setDefaultStyleSheet(template.render(conf=config.val)) if index.parent().isValid(): - pattern = index.model().pattern - columns_to_filter = index.model().srcmodel.columns_to_filter + view = self.parent() + pattern = view.pattern + columns_to_filter = index.model().columns_to_filter(index) if index.column() in columns_to_filter and pattern: repl = r'<span class="highlight">\g<0></span>' text = re.sub(re.escape(pattern).replace(r'\ ', r'|'), diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index 440b0dc78..43ebffa84 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -28,8 +28,7 @@ from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel, QSize from qutebrowser.config import config from qutebrowser.completion import completiondelegate -from qutebrowser.completion.models import base -from qutebrowser.utils import utils, usertypes +from qutebrowser.utils import utils, usertypes, debug, log from qutebrowser.commands import cmdexc, cmdutils @@ -41,6 +40,7 @@ class CompletionView(QTreeView): headers, and children show as flat list. Attributes: + pattern: Current filter pattern, used for highlighting. _win_id: The ID of the window this CompletionView is associated with. _height: The height to use for the CompletionView. _height_perc: Either None or a percentage if height should be relative. @@ -107,12 +107,10 @@ class CompletionView(QTreeView): def __init__(self, win_id, parent=None): super().__init__(parent) + self.pattern = '' self._win_id = win_id - # FIXME handle new aliases. - # config.instance.changed.connect(self.init_command_completion) config.instance.changed.connect(self._on_config_changed) - self._column_widths = base.BaseCompletionModel.COLUMN_WIDTHS self._active = False self._delegate = completiondelegate.CompletionItemDelegate(self) @@ -148,8 +146,11 @@ class CompletionView(QTreeView): def _resize_columns(self): """Resize the completion columns based on column_widths.""" + if self.model() is None: + return width = self.size().width() - pixel_widths = [(width * perc // 100) for perc in self._column_widths] + column_widths = self.model().column_widths + pixel_widths = [(width * perc // 100) for perc in column_widths] if self.verticalScrollBar().isVisible(): delta = self.style().pixelMetric(QStyle.PM_ScrollBarExtent) + 5 @@ -252,6 +253,10 @@ class CompletionView(QTreeView): selmodel.setCurrentIndex( idx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows) + # if the last item is focused, try to fetch more + if idx.row() == self.model().rowCount(idx.parent()) - 1: + self.expandAll() + count = self.model().count() if count == 0: self.hide() @@ -260,48 +265,51 @@ class CompletionView(QTreeView): elif config.val.completion.show == 'auto': self.show() - def set_model(self, model, pattern=None): + def set_model(self, model): """Switch completion to a new model. Called from on_update_completion(). Args: model: The model to use. - pattern: The filter pattern to set (what the user entered). """ + if self.model() is not None and model is not self.model(): + self.model().deleteLater() + self.selectionModel().deleteLater() + + self.setModel(model) + if model is None: self._active = False self.hide() return - old_model = self.model() - if model is not old_model: - sel_model = self.selectionModel() + model.setParent(self) + self._active = True + self._maybe_show() - self.setModel(model) - self._active = True - - if sel_model is not None: - sel_model.deleteLater() - if old_model is not None: - old_model.deleteLater() + self._resize_columns() + for i in range(model.rowCount()): + self.expand(model.index(i, 0)) + def set_pattern(self, pattern): + """Set the pattern on the underlying model.""" + if not self.model(): + return + self.pattern = pattern + with debug.log_time(log.completion, 'Set pattern {}'.format(pattern)): + self.model().set_pattern(pattern) + self.selectionModel().clear() + self._maybe_update_geometry() + self._maybe_show() + + def _maybe_show(self): if (config.val.completion.show == 'always' and - model.count() > 0): + self.model().count() > 0): self.show() else: self.hide() - for i in range(model.rowCount()): - self.expand(model.index(i, 0)) - - if pattern is not None: - model.set_pattern(pattern) - - self._column_widths = model.srcmodel.COLUMN_WIDTHS - self._resize_columns() - self._maybe_update_geometry() - def _maybe_update_geometry(self): """Emit the update_geometry signal if the config says so.""" if config.val.completion.shrink: @@ -345,7 +353,7 @@ class CompletionView(QTreeView): indexes = selected.indexes() if not indexes: return - data = self.model().data(indexes[0]) + data = str(self.model().data(indexes[0])) self.selection_changed.emit(data) def resizeEvent(self, e): @@ -365,9 +373,7 @@ class CompletionView(QTreeView): modes=[usertypes.KeyMode.command], scope='window') def completion_item_del(self): """Delete the current completion item.""" - if not self.currentIndex().isValid(): + index = self.currentIndex() + if not index.isValid(): raise cmdexc.CommandError("No item selected!") - try: - self.model().srcmodel.delete_cur_item(self) - except NotImplementedError: - raise cmdexc.CommandError("Cannot delete this item.") + self.model().delete_cur_item(index) diff --git a/qutebrowser/completion/models/base.py b/qutebrowser/completion/models/base.py deleted file mode 100644 index b1cad276a..000000000 --- a/qutebrowser/completion/models/base.py +++ /dev/null @@ -1,130 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2014-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org> -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. - -"""The base completion model for completion in the command line. - -Module attributes: - Role: An enum of user defined model roles. -""" - -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QStandardItemModel, QStandardItem - -from qutebrowser.utils import usertypes - - -Role = usertypes.enum('Role', ['sort', 'userdata'], start=Qt.UserRole, - is_int=True) - - -class BaseCompletionModel(QStandardItemModel): - - """A simple QStandardItemModel adopted for completions. - - Used for showing completions later in the CompletionView. Supports setting - marks and adding new categories/items easily. - - Class Attributes: - COLUMN_WIDTHS: The width percentages of the columns used in the - completion view. - DUMB_SORT: the dumb sorting used by the model - """ - - COLUMN_WIDTHS = (30, 70, 0) - DUMB_SORT = None - - def __init__(self, parent=None): - super().__init__(parent) - self.setColumnCount(3) - self.columns_to_filter = [0] - - def new_category(self, name, sort=None): - """Add a new category to the model. - - Args: - name: The name of the category to add. - sort: The value to use for the sort role. - - Return: - The created QStandardItem. - """ - cat = QStandardItem(name) - if sort is not None: - cat.setData(sort, Role.sort) - self.appendRow(cat) - return cat - - def new_item(self, cat, name, desc='', misc=None, sort=None, - userdata=None): - """Add a new item to a category. - - Args: - cat: The parent category. - name: The name of the item. - desc: The description of the item. - misc: Misc text to display. - sort: Data for the sort role (int). - userdata: User data to be added for the first column. - - Return: - A (nameitem, descitem, miscitem) tuple. - """ - assert not isinstance(name, int) - assert not isinstance(desc, int) - assert not isinstance(misc, int) - - nameitem = QStandardItem(name) - descitem = QStandardItem(desc) - if misc is None: - miscitem = QStandardItem() - else: - miscitem = QStandardItem(misc) - - cat.appendRow([nameitem, descitem, miscitem]) - if sort is not None: - nameitem.setData(sort, Role.sort) - if userdata is not None: - nameitem.setData(userdata, Role.userdata) - return nameitem, descitem, miscitem - - def delete_cur_item(self, completion): - """Delete the selected item.""" - raise NotImplementedError - - def flags(self, index): - """Return the item flags for index. - - Override QAbstractItemModel::flags. - - Args: - index: The QModelIndex to get item flags for. - - Return: - The item flags, or Qt.NoItemFlags on error. - """ - if not index.isValid(): - return - - if index.parent().isValid(): - # item - return (Qt.ItemIsEnabled | Qt.ItemIsSelectable | - Qt.ItemNeverHasChildren) - else: - # category - return Qt.NoItemFlags diff --git a/qutebrowser/completion/models/completionmodel.py b/qutebrowser/completion/models/completionmodel.py new file mode 100644 index 000000000..398673200 --- /dev/null +++ b/qutebrowser/completion/models/completionmodel.py @@ -0,0 +1,232 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""A model that proxies access to one or more completion categories.""" + +from PyQt5.QtCore import Qt, QModelIndex, QAbstractItemModel + +from qutebrowser.utils import log, qtutils +from qutebrowser.commands import cmdexc + + +class CompletionModel(QAbstractItemModel): + + """A model that proxies access to one or more completion categories. + + Top level indices represent categories. + Child indices represent rows of those tables. + + Attributes: + column_widths: The width percentages of the columns used in the + completion view. + _categories: The sub-categories. + """ + + def __init__(self, *, column_widths=(30, 70, 0), parent=None): + super().__init__(parent) + self.column_widths = column_widths + self._categories = [] + + def _cat_from_idx(self, index): + """Return the category pointed to by the given index. + + Args: + idx: A QModelIndex + Returns: + A category if the index points at one, else None + """ + # items hold an index to the parent category in their internalPointer + # categories have an empty internalPointer + if index.isValid() and not index.internalPointer(): + return self._categories[index.row()] + return None + + def add_category(self, cat): + """Add a completion category to the model.""" + self._categories.append(cat) + cat.layoutAboutToBeChanged.connect(self.layoutAboutToBeChanged) + cat.layoutChanged.connect(self.layoutChanged) + + def data(self, index, role=Qt.DisplayRole): + """Return the item data for index. + + Override QAbstractItemModel::data. + + Args: + index: The QModelIndex to get item flags for. + + Return: The item data, or None on an invalid index. + """ + if role != Qt.DisplayRole: + return None + cat = self._cat_from_idx(index) + if cat: + # category header + if index.column() == 0: + return self._categories[index.row()].name + return None + # item + cat = self._cat_from_idx(index.parent()) + if not cat: + return None + idx = cat.index(index.row(), index.column()) + return cat.data(idx) + + def flags(self, index): + """Return the item flags for index. + + Override QAbstractItemModel::flags. + + Return: The item flags, or Qt.NoItemFlags on error. + """ + if not index.isValid(): + return Qt.NoItemFlags + if index.parent().isValid(): + # item + return (Qt.ItemIsEnabled | Qt.ItemIsSelectable | + Qt.ItemNeverHasChildren) + else: + # category + return Qt.NoItemFlags + + def index(self, row, col, parent=QModelIndex()): + """Get an index into the model. + + Override QAbstractItemModel::index. + + Return: A QModelIndex. + """ + if (row < 0 or row >= self.rowCount(parent) or + col < 0 or col >= self.columnCount(parent)): + return QModelIndex() + if parent.isValid(): + if parent.column() != 0: + return QModelIndex() + # store a pointer to the parent category in internalPointer + return self.createIndex(row, col, self._categories[parent.row()]) + return self.createIndex(row, col, None) + + def parent(self, index): + """Get an index to the parent of the given index. + + Override QAbstractItemModel::parent. + + Args: + index: The QModelIndex to get the parent index for. + """ + parent_cat = index.internalPointer() + if not parent_cat: + # categories have no parent + return QModelIndex() + row = self._categories.index(parent_cat) + return self.createIndex(row, 0, None) + + def rowCount(self, parent=QModelIndex()): + """Override QAbstractItemModel::rowCount.""" + if not parent.isValid(): + # top-level + return len(self._categories) + cat = self._cat_from_idx(parent) + if not cat or parent.column() != 0: + # item or nonzero category column (only first col has children) + return 0 + else: + # category + return cat.rowCount() + + def columnCount(self, parent=QModelIndex()): + """Override QAbstractItemModel::columnCount.""" + # pylint: disable=unused-argument + return 3 + + def canFetchMore(self, parent): + """Override to forward the call to the categories.""" + cat = self._cat_from_idx(parent) + if cat: + return cat.canFetchMore(QModelIndex()) + return False + + def fetchMore(self, parent): + """Override to forward the call to the categories.""" + cat = self._cat_from_idx(parent) + if cat: + cat.fetchMore(QModelIndex()) + + def count(self): + """Return the count of non-category items.""" + return sum(t.rowCount() for t in self._categories) + + def set_pattern(self, pattern): + """Set the filter pattern for all categories. + + Args: + pattern: The filter pattern to set. + """ + log.completion.debug("Setting completion pattern '{}'".format(pattern)) + for cat in self._categories: + cat.set_pattern(pattern) + + def first_item(self): + """Return the index of the first child (non-category) in the model.""" + for row, cat in enumerate(self._categories): + if cat.rowCount() > 0: + parent = self.index(row, 0) + index = self.index(0, 0, parent) + qtutils.ensure_valid(index) + return index + return QModelIndex() + + def last_item(self): + """Return the index of the last child (non-category) in the model.""" + for row, cat in reversed(list(enumerate(self._categories))): + childcount = cat.rowCount() + if childcount > 0: + parent = self.index(row, 0) + index = self.index(childcount - 1, 0, parent) + qtutils.ensure_valid(index) + return index + return QModelIndex() + + def columns_to_filter(self, index): + """Return the column indices the filter pattern applies to. + + Args: + index: index of the item to check. + + Return: A list of integers. + """ + cat = self._cat_from_idx(index.parent()) + return cat.columns_to_filter if cat else [] + + def delete_cur_item(self, index): + """Delete the row at the given index.""" + qtutils.ensure_valid(index) + parent = index.parent() + cat = self._cat_from_idx(parent) + assert cat, "CompletionView sent invalid index for deletion" + if not cat.delete_func: + raise cmdexc.CommandError("Cannot delete this item.") + + data = [cat.data(cat.index(index.row(), i)) + for i in range(cat.columnCount())] + cat.delete_func(data) + + self.beginRemoveRows(parent, index.row(), index.row()) + cat.removeRow(index.row(), QModelIndex()) + self.endRemoveRows() diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index 678811f9c..663a0b7f7 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -17,145 +17,80 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. -"""CompletionModels for the config.""" +"""Functions that return config-related completion models.""" -# FIXME:conf -# pylint: disable=no-member +from qutebrowser.config import configdata, configexc +from qutebrowser.completion.models import completionmodel, listcategory +from qutebrowser.utils import objreg -from PyQt5.QtCore import pyqtSlot, Qt - -from qutebrowser.config import config, configdata -from qutebrowser.utils import log, qtutils -from qutebrowser.completion.models import base - - -class SettingSectionCompletionModel(base.BaseCompletionModel): +def section(): """A CompletionModel filled with settings sections.""" + model = completionmodel.CompletionModel(column_widths=(20, 70, 10)) + sections = ((name, configdata.SECTION_DESC[name].splitlines()[0].strip()) + for name in configdata.DATA) + model.add_category(listcategory.ListCategory("Sections", sections)) + return model - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - COLUMN_WIDTHS = (20, 70, 10) - - def __init__(self, parent=None): - super().__init__(parent) - cat = self.new_category("Sections") - for name in configdata.DATA: - desc = configdata.SECTION_DESC[name].splitlines()[0].strip() - self.new_item(cat, name, desc) - - -class SettingOptionCompletionModel(base.BaseCompletionModel): +def option(sectname): """A CompletionModel filled with settings and their descriptions. - Attributes: - _misc_items: A dict of the misc. column items which will be set later. - _section: The config section this model shows. + Args: + sectname: The name of the config section this model shows. """ - - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - COLUMN_WIDTHS = (20, 70, 10) - - def __init__(self, section, parent=None): - super().__init__(parent) - cat = self.new_category(section) - sectdata = configdata.DATA[section] - self._misc_items = {} - self._section = section - config.instance.changed.connect(self._update_misc_column) - for name in sectdata: - try: - desc = sectdata.descriptions[name] - except (KeyError, AttributeError): - # Some stuff (especially ValueList items) don't have a - # description. - desc = "" - else: - desc = desc.splitlines()[0] - value = config.get(section, name, raw=True) - _valitem, _descitem, miscitem = self.new_item(cat, name, desc, - value) - self._misc_items[name] = miscitem - - @pyqtSlot(str, str) - def _update_misc_column(self, section, option): - """Update misc column when config changed.""" - if section != self._section: - return + model = completionmodel.CompletionModel(column_widths=(20, 70, 10)) + try: + sectdata = configdata.DATA[sectname] + except KeyError: + return None + options = [] + for name in sectdata: try: - item = self._misc_items[option] - except KeyError: - log.completion.debug("Couldn't get item {}.{} from model!".format( - section, option)) - # changed before init - return - val = config.get(section, option, raw=True) - idx = item.index() - qtutils.ensure_valid(idx) - ok = self.setData(idx, val, Qt.DisplayRole) - if not ok: - raise ValueError("Setting data failed! (section: {}, option: {}, " - "value: {})".format(section, option, val)) - + desc = sectdata.descriptions[name] + except (KeyError, AttributeError): + # Some stuff (especially ValueList items) don't have a + # description. + desc = "" + else: + desc = desc.splitlines()[0] + config = objreg.get('config') + val = config.get(sectname, name, raw=True) + options.append((name, desc, val)) + model.add_category(listcategory.ListCategory(sectname, options)) + return model -class SettingValueCompletionModel(base.BaseCompletionModel): +def value(sectname, optname): """A CompletionModel filled with setting values. - Attributes: - _section: The config section this model shows. - _option: The config option this model shows. + Args: + sectname: The name of the config section this model shows. + optname: The name of the config option this model shows. """ - - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - COLUMN_WIDTHS = (20, 70, 10) - - def __init__(self, section, option, parent=None): - super().__init__(parent) - self._section = section - self._option = option - config.instance.changed.connect(self._update_current_value) - cur_cat = self.new_category("Current/Default", sort=0) - value = config.get(section, option, raw=True) - if not value: - value = '""' - self.cur_item, _descitem, _miscitem = self.new_item(cur_cat, value, - "Current value") - default_value = configdata.DATA[section][option].default() - if not default_value: - default_value = '""' - self.new_item(cur_cat, default_value, "Default value") - if hasattr(configdata.DATA[section], 'valtype'): - # Same type for all values (ValueList) - vals = configdata.DATA[section].valtype.complete() - else: - if option is None: - raise ValueError("option may only be None for ValueList " - "sections, but {} is not!".format(section)) - # Different type for each value (KeyValue) - vals = configdata.DATA[section][option].typ.complete() - if vals is not None: - cat = self.new_category("Completions", sort=1) - for (val, desc) in vals: - self.new_item(cat, val, desc) - - @pyqtSlot(str, str) - def _update_current_value(self, section, option): - """Update current value when config changed.""" - if (section, option) != (self._section, self._option): - return - value = config.get(section, option, raw=True) - if not value: - value = '""' - idx = self.cur_item.index() - qtutils.ensure_valid(idx) - ok = self.setData(idx, value, Qt.DisplayRole) - if not ok: - raise ValueError("Setting data failed! (section: {}, option: {}, " - "value: {})".format(section, option, value)) + model = completionmodel.CompletionModel(column_widths=(20, 70, 10)) + config = objreg.get('config') + + try: + current = config.get(sectname, optname, raw=True) or '""' + except (configexc.NoSectionError, configexc.NoOptionError): + return None + + default = configdata.DATA[sectname][optname].default() or '""' + + if hasattr(configdata.DATA[sectname], 'valtype'): + # Same type for all values (ValueList) + vals = configdata.DATA[sectname].valtype.complete() + else: + if optname is None: + raise ValueError("optname may only be None for ValueList " + "sections, but {} is not!".format(sectname)) + # Different type for each value (KeyValue) + vals = configdata.DATA[sectname][optname].typ.complete() + + cur_cat = listcategory.ListCategory("Current/Default", + [(current, "Current value"), (default, "Default value")]) + model.add_category(cur_cat) + if vals is not None: + model.add_category(listcategory.ListCategory("Completions", vals)) + return model diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py new file mode 100644 index 000000000..606351440 --- /dev/null +++ b/qutebrowser/completion/models/histcategory.py @@ -0,0 +1,104 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""A completion category that queries the SQL History store.""" + +import re + +from PyQt5.QtSql import QSqlQueryModel + +from qutebrowser.misc import sql +from qutebrowser.utils import debug +from qutebrowser.config import config + + +class HistoryCategory(QSqlQueryModel): + + """A completion category that queries the SQL History store.""" + + def __init__(self, *, delete_func=None, parent=None): + """Create a new History completion category.""" + super().__init__(parent=parent) + self.name = "History" + + # replace ' in timestamp-format to avoid breaking the query + timefmt = ("strftime('{}', last_atime, 'unixepoch', 'localtime')" + .format(config.get('completion', 'timestamp-format') + .replace("'", "`"))) + + self._query = sql.Query(' '.join([ + "SELECT url, title, {}".format(timefmt), + "FROM CompletionHistory", + # the incoming pattern will have literal % and _ escaped with '\' + # we need to tell sql to treat '\' as an escape character + "WHERE (url LIKE :pat escape '\\' or title LIKE :pat escape '\\')", + self._atime_expr(), + "ORDER BY last_atime DESC", + ]), forward_only=False) + + # advertise that this model filters by URL and title + self.columns_to_filter = [0, 1] + self.delete_func = delete_func + + def _atime_expr(self): + """If max_items is set, return an expression to limit the query.""" + max_items = config.get('completion', 'web-history-max-items') + # HistoryCategory should not be added to the completion in that case. + assert max_items != 0 + + if max_items < 0: + return '' + + min_atime = sql.Query(' '.join([ + 'SELECT min(last_atime) FROM', + '(SELECT last_atime FROM CompletionHistory', + 'ORDER BY last_atime DESC LIMIT :limit)', + ])).run(limit=max_items).value() + + if not min_atime: + # if there are no history items, min_atime may be '' (issue #2849) + return '' + + return "AND last_atime >= {}".format(min_atime) + + def set_pattern(self, pattern): + """Set the pattern used to filter results. + + Args: + pattern: string pattern to filter by. + """ + # escape to treat a user input % or _ as a literal, not a wildcard + pattern = pattern.replace('%', '\\%') + pattern = pattern.replace('_', '\\_') + # treat spaces as wildcards to match any of the typed words + pattern = re.sub(r' +', '%', pattern) + pattern = '%{}%'.format(pattern) + with debug.log_time('sql', 'Running completion query'): + self._query.run(pat=pattern) + self.setQuery(self._query) + + def removeRows(self, row, _count, _parent=None): + """Override QAbstractItemModel::removeRows to re-run sql query.""" + # re-run query to reload updated table + with debug.log_time('sql', 'Re-running completion query post-delete'): + self._query.run() + self.setQuery(self._query) + while self.rowCount() < row: + self.fetchMore() + return True diff --git a/qutebrowser/completion/models/instances.py b/qutebrowser/completion/models/instances.py deleted file mode 100644 index 2e65a3198..000000000 --- a/qutebrowser/completion/models/instances.py +++ /dev/null @@ -1,162 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2015-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org> -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. - -"""Global instances of the completion models. - -Module attributes: - _instances: A dict of available completions. - INITIALIZERS: A {usertypes.Completion: callable} dict of functions to - initialize completions. -""" - -import functools - -from qutebrowser.completion.models import miscmodels, urlmodel -from qutebrowser.utils import objreg, usertypes, log, debug - - -_instances = {} - - -def _init_command_completion(): - """Initialize the command completion model.""" - log.completion.debug("Initializing command completion.") - model = miscmodels.CommandCompletionModel() - _instances[usertypes.Completion.command] = model - - -def _init_helptopic_completion(): - """Initialize the helptopic completion model.""" - log.completion.debug("Initializing helptopic completion.") - model = miscmodels.HelpCompletionModel() - _instances[usertypes.Completion.helptopic] = model - - -def _init_url_completion(): - """Initialize the URL completion model.""" - log.completion.debug("Initializing URL completion.") - with debug.log_time(log.completion, 'URL completion init'): - model = urlmodel.UrlCompletionModel() - _instances[usertypes.Completion.url] = model - - -def _init_tab_completion(): - """Initialize the tab completion model.""" - log.completion.debug("Initializing tab completion.") - with debug.log_time(log.completion, 'tab completion init'): - model = miscmodels.TabCompletionModel() - _instances[usertypes.Completion.tab] = model - - -def init_quickmark_completions(): - """Initialize quickmark completion models.""" - log.completion.debug("Initializing quickmark completion.") - try: - _instances[usertypes.Completion.quickmark_by_name].deleteLater() - except KeyError: - pass - model = miscmodels.QuickmarkCompletionModel() - _instances[usertypes.Completion.quickmark_by_name] = model - - -def init_bookmark_completions(): - """Initialize bookmark completion models.""" - log.completion.debug("Initializing bookmark completion.") - try: - _instances[usertypes.Completion.bookmark_by_url].deleteLater() - except KeyError: - pass - model = miscmodels.BookmarkCompletionModel() - _instances[usertypes.Completion.bookmark_by_url] = model - - -def init_session_completion(): - """Initialize session completion model.""" - log.completion.debug("Initializing session completion.") - try: - _instances[usertypes.Completion.sessions].deleteLater() - except KeyError: - pass - model = miscmodels.SessionCompletionModel() - _instances[usertypes.Completion.sessions] = model - - -def _init_bind_completion(): - """Initialize the command completion model.""" - log.completion.debug("Initializing bind completion.") - model = miscmodels.BindCompletionModel() - _instances[usertypes.Completion.bind] = model - - -INITIALIZERS = { - usertypes.Completion.command: _init_command_completion, - usertypes.Completion.helptopic: _init_helptopic_completion, - usertypes.Completion.url: _init_url_completion, - usertypes.Completion.tab: _init_tab_completion, - usertypes.Completion.quickmark_by_name: init_quickmark_completions, - usertypes.Completion.bookmark_by_url: init_bookmark_completions, - usertypes.Completion.sessions: init_session_completion, - usertypes.Completion.bind: _init_bind_completion, -} - - -def get(completion): - """Get a certain completion. Initializes the completion if needed.""" - try: - return _instances[completion] - except KeyError: - if completion in INITIALIZERS: - INITIALIZERS[completion]() - return _instances[completion] - else: - raise - - -def update(completions): - """Update an already existing completion. - - Args: - completions: An iterable of usertypes.Completions. - """ - did_run = [] - for completion in completions: - if completion in _instances: - func = INITIALIZERS[completion] - if func not in did_run: - func() - did_run.append(func) - - -def init(): - """Initialize completions. Note this only connects signals.""" - quickmark_manager = objreg.get('quickmark-manager') - quickmark_manager.changed.connect( - functools.partial(update, [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/listcategory.py b/qutebrowser/completion/models/listcategory.py new file mode 100644 index 000000000..b1ad77bae --- /dev/null +++ b/qutebrowser/completion/models/listcategory.py @@ -0,0 +1,92 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""Completion category that uses a list of tuples as a data source.""" + +import re + +from PyQt5.QtCore import Qt, QSortFilterProxyModel, QRegExp +from PyQt5.QtGui import QStandardItem, QStandardItemModel + +from qutebrowser.utils import qtutils + + +class ListCategory(QSortFilterProxyModel): + + """Expose a list of items as a category for the CompletionModel.""" + + def __init__(self, name, items, delete_func=None, parent=None): + super().__init__(parent) + self.name = name + self.srcmodel = QStandardItemModel(parent=self) + self._pattern = '' + # ListCategory filters all columns + self.columns_to_filter = [0, 1, 2] + self.setFilterKeyColumn(-1) + for item in items: + self.srcmodel.appendRow([QStandardItem(x) for x in item]) + self.setSourceModel(self.srcmodel) + self.delete_func = delete_func + + def set_pattern(self, val): + """Setter for pattern. + + Args: + val: The value to set. + """ + self._pattern = val + val = re.sub(r' +', r' ', val) # See #1919 + val = re.escape(val) + val = val.replace(r'\ ', '.*') + rx = QRegExp(val, Qt.CaseInsensitive) + self.setFilterRegExp(rx) + self.invalidate() + sortcol = 0 + self.sort(sortcol) + + def lessThan(self, lindex, rindex): + """Custom sorting implementation. + + Prefers all items which start with self._pattern. Other than that, uses + normal Python string sorting. + + Args: + lindex: The QModelIndex of the left item (*left* < right) + rindex: The QModelIndex of the right item (left < *right*) + + Return: + True if left < right, else False + """ + qtutils.ensure_valid(lindex) + qtutils.ensure_valid(rindex) + + left = self.srcmodel.data(lindex) + right = self.srcmodel.data(rindex) + + leftstart = left.startswith(self._pattern) + rightstart = right.startswith(self._pattern) + + if leftstart and rightstart: + return left < right + elif leftstart: + return True + elif rightstart: + return False + else: + return left < right diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py index a85ba2db9..0e55e6696 100644 --- a/qutebrowser/completion/models/miscmodels.py +++ b/qutebrowser/completion/models/miscmodels.py @@ -17,255 +17,142 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. -"""Misc. CompletionModels.""" +"""Functions that return miscellaneous completion models.""" -# FIXME:conf -# pylint: disable=unused-argument - -from PyQt5.QtCore import Qt, QTimer, pyqtSlot - -from qutebrowser.browser import browsertab from qutebrowser.config import config, configdata -from qutebrowser.utils import objreg, log, qtutils +from qutebrowser.utils import objreg, log from qutebrowser.commands import cmdutils -from qutebrowser.completion.models import base - +from qutebrowser.completion.models import completionmodel, listcategory -class CommandCompletionModel(base.BaseCompletionModel): +def command(): """A CompletionModel filled with non-hidden commands and descriptions.""" - - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - COLUMN_WIDTHS = (20, 60, 20) - - def __init__(self, parent=None): - super().__init__(parent) - cmdlist = _get_cmd_completions(include_aliases=True, - include_hidden=False) - cat = self.new_category("Commands") - for (name, desc, misc) in cmdlist: - self.new_item(cat, name, desc, misc) + model = completionmodel.CompletionModel(column_widths=(20, 60, 20)) + cmdlist = _get_cmd_completions(include_aliases=True, include_hidden=False) + model.add_category(listcategory.ListCategory("Commands", cmdlist)) + return model -class HelpCompletionModel(base.BaseCompletionModel): - +def helptopic(): """A CompletionModel filled with help topics.""" + model = completionmodel.CompletionModel() + + cmdlist = _get_cmd_completions(include_aliases=False, include_hidden=True, + prefix=':') + settings = [] + for sectname, sectdata in configdata.DATA.items(): + for optname in sectdata: + try: + desc = sectdata.descriptions[optname] + except (KeyError, AttributeError): + # Some stuff (especially ValueList items) don't have a + # description. + desc = "" + else: + desc = desc.splitlines()[0] + name = '{}->{}'.format(sectname, optname) + settings.append((name, desc)) - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - COLUMN_WIDTHS = (20, 60, 20) - - def __init__(self, parent=None): - super().__init__(parent) - self._init_commands() - self._init_settings() - - def _init_commands(self): - """Fill completion with :command entries.""" - cmdlist = _get_cmd_completions(include_aliases=False, - include_hidden=True, prefix=':') - cat = self.new_category("Commands") - for (name, desc, misc) in cmdlist: - self.new_item(cat, name, desc, misc) - - def _init_settings(self): - """Fill completion with section->option entries.""" - cat = self.new_category("Settings") - for sectname, sectdata in configdata.DATA.items(): - for optname in sectdata: - try: - desc = sectdata.descriptions[optname] - except (KeyError, AttributeError): - # Some stuff (especially ValueList items) don't have a - # description. - desc = "" - else: - desc = desc.splitlines()[0] - name = '{}->{}'.format(sectname, optname) - self.new_item(cat, name, desc) - - -class QuickmarkCompletionModel(base.BaseCompletionModel): - - """A CompletionModel filled with all quickmarks.""" + model.add_category(listcategory.ListCategory("Commands", cmdlist)) + model.add_category(listcategory.ListCategory("Settings", settings)) + return model - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - def __init__(self, parent=None): - super().__init__(parent) - cat = self.new_category("Quickmarks") - quickmarks = objreg.get('quickmark-manager').marks.items() - for qm_name, qm_url in quickmarks: - self.new_item(cat, qm_name, qm_url) +def quickmark(): + """A CompletionModel filled with all quickmarks.""" + def delete(data): + """Delete a quickmark from the completion menu.""" + name = data[0] + quickmark_manager = objreg.get('quickmark-manager') + log.completion.debug('Deleting quickmark {}'.format(name)) + quickmark_manager.delete(name) + model = completionmodel.CompletionModel(column_widths=(30, 70, 0)) + marks = objreg.get('quickmark-manager').marks.items() + model.add_category(listcategory.ListCategory('Quickmarks', marks, + delete_func=delete)) + return model -class BookmarkCompletionModel(base.BaseCompletionModel): +def bookmark(): """A CompletionModel filled with all bookmarks.""" + def delete(data): + """Delete a bookmark from the completion menu.""" + urlstr = data[0] + log.completion.debug('Deleting bookmark {}'.format(urlstr)) + bookmark_manager = objreg.get('bookmark-manager') + bookmark_manager.delete(urlstr) - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - def __init__(self, parent=None): - super().__init__(parent) - cat = self.new_category("Bookmarks") - bookmarks = objreg.get('bookmark-manager').marks.items() - for bm_url, bm_title in bookmarks: - self.new_item(cat, bm_url, bm_title) + model = completionmodel.CompletionModel(column_widths=(30, 70, 0)) + marks = objreg.get('bookmark-manager').marks.items() + model.add_category(listcategory.ListCategory('Bookmarks', marks, + delete_func=delete)) + return model -class SessionCompletionModel(base.BaseCompletionModel): - +def session(): """A CompletionModel filled with session names.""" - - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - def __init__(self, parent=None): - super().__init__(parent) - cat = self.new_category("Sessions") - try: - for name in objreg.get('session-manager').list_sessions(): - if not name.startswith('_'): - self.new_item(cat, name) - except OSError: - log.completion.exception("Failed to list sessions!") - - -class TabCompletionModel(base.BaseCompletionModel): - + model = completionmodel.CompletionModel() + try: + manager = objreg.get('session-manager') + sessions = ((name,) for name in manager.list_sessions() + if not name.startswith('_')) + model.add_category(listcategory.ListCategory("Sessions", sessions)) + except OSError: + log.completion.exception("Failed to list sessions!") + return model + + +def buffer(): """A model to complete on open tabs across all windows. Used for switching the buffer command. """ - - IDX_COLUMN = 0 - URL_COLUMN = 1 - TEXT_COLUMN = 2 - - COLUMN_WIDTHS = (6, 40, 54) - DUMB_SORT = Qt.DescendingOrder - - def __init__(self, parent=None): - super().__init__(parent) - - self.columns_to_filter = [self.IDX_COLUMN, self.URL_COLUMN, - self.TEXT_COLUMN] - - for win_id in objreg.window_registry: - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=win_id) - for i in range(tabbed_browser.count()): - tab = tabbed_browser.widget(i) - tab.url_changed.connect(self.rebuild) - tab.title_changed.connect(self.rebuild) - tab.shutting_down.connect(self.delayed_rebuild) - tabbed_browser.new_tab.connect(self.on_new_tab) - tabbed_browser.tabBar().tabMoved.connect(self.rebuild) - objreg.get("app").new_window.connect(self.on_new_window) - self.rebuild() - - def on_new_window(self, window): - """Add hooks to new windows.""" - window.tabbed_browser.new_tab.connect(self.on_new_tab) - - @pyqtSlot(browsertab.AbstractTab) - def on_new_tab(self, tab): - """Add hooks to new tabs.""" - tab.url_changed.connect(self.rebuild) - tab.title_changed.connect(self.rebuild) - tab.shutting_down.connect(self.delayed_rebuild) - self.rebuild() - - @pyqtSlot() - def delayed_rebuild(self): - """Fire a rebuild indirectly so widgets get a chance to update.""" - QTimer.singleShot(0, self.rebuild) - - @pyqtSlot() - def rebuild(self): - """Rebuild completion model from current tabs. - - Very lazy method of keeping the model up to date. We could connect to - signals for new tab, tab url/title changed, tab close, tab moved and - make sure we handled background loads too ... but iterating over a - few/few dozen/few hundred tabs doesn't take very long at all. - """ - window_count = 0 - for win_id in objreg.window_registry: - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=win_id) - if not tabbed_browser.shutting_down: - window_count += 1 - - if window_count < self.rowCount(): - self.removeRows(window_count, self.rowCount() - window_count) - - for i, win_id in enumerate(objreg.window_registry): - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=win_id) - if tabbed_browser.shutting_down: - continue - if i >= self.rowCount(): - c = self.new_category("{}".format(win_id)) - else: - c = self.item(i, 0) - c.setData("{}".format(win_id), Qt.DisplayRole) - if tabbed_browser.count() < c.rowCount(): - c.removeRows(tabbed_browser.count(), - c.rowCount() - tabbed_browser.count()) - for idx in range(tabbed_browser.count()): - tab = tabbed_browser.widget(idx) - if idx >= c.rowCount(): - self.new_item(c, "{}/{}".format(win_id, idx + 1), - tab.url().toDisplayString(), - tabbed_browser.page_title(idx)) - else: - c.child(idx, 0).setData("{}/{}".format(win_id, idx + 1), - Qt.DisplayRole) - c.child(idx, 1).setData(tab.url().toDisplayString(), - Qt.DisplayRole) - c.child(idx, 2).setData(tabbed_browser.page_title(idx), - Qt.DisplayRole) - - def delete_cur_item(self, completion): - """Delete the selected item. - - Args: - completion: The Completion object to use. - """ - index = completion.currentIndex() - qtutils.ensure_valid(index) - category = index.parent() - qtutils.ensure_valid(category) - index = category.child(index.row(), self.IDX_COLUMN) - win_id, tab_index = index.data().split('/') - + def delete_buffer(data): + """Close the selected tab.""" + win_id, tab_index = data[0].split('/') tabbed_browser = objreg.get('tabbed-browser', scope='window', window=int(win_id)) tabbed_browser.on_tab_close_requested(int(tab_index) - 1) + model = completionmodel.CompletionModel(column_widths=(6, 40, 54)) -class BindCompletionModel(base.BaseCompletionModel): + for win_id in objreg.window_registry: + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + if tabbed_browser.shutting_down: + continue + tabs = [] + for idx in range(tabbed_browser.count()): + tab = tabbed_browser.widget(idx) + tabs.append(("{}/{}".format(win_id, idx + 1), + tab.url().toDisplayString(), + tabbed_browser.page_title(idx))) + cat = listcategory.ListCategory("{}".format(win_id), tabs, + delete_func=delete_buffer) + model.add_category(cat) - """A CompletionModel filled with all bindable commands and descriptions.""" + return model - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - COLUMN_WIDTHS = (20, 60, 20) +def bind(key): + """A CompletionModel filled with all bindable commands and descriptions. - def __init__(self, parent=None): - super().__init__(parent) - cmdlist = _get_cmd_completions(include_hidden=True, - include_aliases=True) - cat = self.new_category("Commands") - for (name, desc, misc) in cmdlist: - self.new_item(cat, name, desc, misc) + Args: + key: the key being bound. + """ + model = completionmodel.CompletionModel(column_widths=(20, 60, 20)) + cmd_text = objreg.get('key-config').get_bindings_for('normal').get(key) + + if cmd_text: + cmd_name = cmd_text.split(' ')[0] + cmd = cmdutils.cmd_dict.get(cmd_name) + data = [(cmd_text, cmd.desc, key)] + model.add_category(listcategory.ListCategory("Current", data)) + + cmdlist = _get_cmd_completions(include_hidden=True, include_aliases=True) + model.add_category(listcategory.ListCategory("Commands", cmdlist)) + return model def _get_cmd_completions(include_hidden, include_aliases, prefix=''): diff --git a/qutebrowser/completion/models/sortfilter.py b/qutebrowser/completion/models/sortfilter.py deleted file mode 100644 index e2db88b9e..000000000 --- a/qutebrowser/completion/models/sortfilter.py +++ /dev/null @@ -1,191 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2014-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org> -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. - -"""A filtering/sorting base model for completions. - -Contains: - CompletionFilterModel -- A QSortFilterProxyModel subclass for completions. -""" - -import re - -from PyQt5.QtCore import QSortFilterProxyModel, QModelIndex, Qt - -from qutebrowser.utils import log, qtutils, debug -from qutebrowser.completion.models import base as completion - - -class CompletionFilterModel(QSortFilterProxyModel): - - """Subclass of QSortFilterProxyModel with custom sorting/filtering. - - Attributes: - pattern: The pattern to filter with. - srcmodel: The current source model. - Kept as attribute because calling `sourceModel` takes quite - a long time for some reason. - _sort_order: The order to use for sorting if using dumb_sort. - """ - - def __init__(self, source, parent=None): - super().__init__(parent) - super().setSourceModel(source) - self.srcmodel = source - self.pattern = '' - self.pattern_re = None - - dumb_sort = self.srcmodel.DUMB_SORT - if dumb_sort is None: - # pylint: disable=invalid-name - self.lessThan = self.intelligentLessThan - self._sort_order = Qt.AscendingOrder - else: - self.setSortRole(completion.Role.sort) - self._sort_order = dumb_sort - - def set_pattern(self, val): - """Setter for pattern. - - Invalidates the filter and re-sorts the model. - - Args: - val: The value to set. - """ - with debug.log_time(log.completion, 'Setting filter pattern'): - self.pattern = val - val = re.sub(r' +', r' ', val) # See #1919 - val = re.escape(val) - val = val.replace(r'\ ', '.*') - self.pattern_re = re.compile(val, re.IGNORECASE) - self.invalidate() - sortcol = 0 - self.sort(sortcol) - - def count(self): - """Get the count of non-toplevel items currently visible. - - Note this only iterates one level deep, as we only need root items - (categories) and children (items) in our model. - """ - count = 0 - for i in range(self.rowCount()): - cat = self.index(i, 0) - qtutils.ensure_valid(cat) - count += self.rowCount(cat) - return count - - def first_item(self): - """Return the first item in the model.""" - for i in range(self.rowCount()): - cat = self.index(i, 0) - qtutils.ensure_valid(cat) - if cat.model().hasChildren(cat): - index = self.index(0, 0, cat) - qtutils.ensure_valid(index) - return index - return QModelIndex() - - def last_item(self): - """Return the last item in the model.""" - for i in range(self.rowCount() - 1, -1, -1): - cat = self.index(i, 0) - qtutils.ensure_valid(cat) - if cat.model().hasChildren(cat): - index = self.index(self.rowCount(cat) - 1, 0, cat) - qtutils.ensure_valid(index) - return index - return QModelIndex() - - def setSourceModel(self, model): - """Override QSortFilterProxyModel's setSourceModel to clear pattern.""" - log.completion.debug("Setting source model: {}".format(model)) - self.set_pattern('') - super().setSourceModel(model) - self.srcmodel = model - - def filterAcceptsRow(self, row, parent): - """Custom filter implementation. - - Override QSortFilterProxyModel::filterAcceptsRow. - - Args: - row: The row of the item. - parent: The parent item QModelIndex. - - Return: - True if self.pattern is contained in item, or if it's a root item - (category). False in all other cases - """ - if parent == QModelIndex() or not self.pattern: - return True - - for col in self.srcmodel.columns_to_filter: - idx = self.srcmodel.index(row, col, parent) - if not idx.isValid(): # pragma: no cover - # this is a sanity check not hit by any test case - continue - data = self.srcmodel.data(idx) - if not data: - continue - elif self.pattern_re.search(data): - return True - return False - - def intelligentLessThan(self, lindex, rindex): - """Custom sorting implementation. - - Prefers all items which start with self.pattern. Other than that, uses - normal Python string sorting. - - Args: - lindex: The QModelIndex of the left item (*left* < right) - rindex: The QModelIndex of the right item (left < *right*) - - Return: - True if left < right, else False - """ - qtutils.ensure_valid(lindex) - qtutils.ensure_valid(rindex) - - left_sort = self.srcmodel.data(lindex, role=completion.Role.sort) - right_sort = self.srcmodel.data(rindex, role=completion.Role.sort) - - if left_sort is not None and right_sort is not None: - return left_sort < right_sort - - left = self.srcmodel.data(lindex) - right = self.srcmodel.data(rindex) - - leftstart = left.startswith(self.pattern) - rightstart = right.startswith(self.pattern) - - if leftstart and rightstart: - return left < right - elif leftstart: - return True - elif rightstart: - return False - else: - return left < right - - def sort(self, column, order=None): - """Extend sort to respect self._sort_order if no order was given.""" - if order is None: - order = self._sort_order - super().sort(column, order) diff --git a/qutebrowser/completion/models/urlmodel.py b/qutebrowser/completion/models/urlmodel.py index 3069b5674..341e7c24a 100644 --- a/qutebrowser/completion/models/urlmodel.py +++ b/qutebrowser/completion/models/urlmodel.py @@ -17,176 +17,56 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. -"""CompletionModels for URLs.""" +"""Function to return the url completion model for the `open` command.""" -import datetime +from qutebrowser.completion.models import (completionmodel, listcategory, + histcategory) +from qutebrowser.utils import log, objreg +from qutebrowser.config import config -from PyQt5.QtCore import pyqtSlot, Qt -from qutebrowser.utils import objreg, utils, qtutils, log -from qutebrowser.completion.models import base -from qutebrowser.config import config +_URLCOL = 0 +_TEXTCOL = 1 + + +def _delete_history(data): + urlstr = data[_URLCOL] + log.completion.debug('Deleting history entry {}'.format(urlstr)) + hist = objreg.get('web-history') + hist.delete_url(urlstr) + +def _delete_bookmark(data): + urlstr = data[_URLCOL] + log.completion.debug('Deleting bookmark {}'.format(urlstr)) + bookmark_manager = objreg.get('bookmark-manager') + bookmark_manager.delete(urlstr) -class UrlCompletionModel(base.BaseCompletionModel): +def _delete_quickmark(data): + name = data[_TEXTCOL] + quickmark_manager = objreg.get('quickmark-manager') + log.completion.debug('Deleting quickmark {}'.format(name)) + quickmark_manager.delete(name) + + +def url(): """A model which combines bookmarks, quickmarks and web history URLs. Used for the `open` command. """ + model = completionmodel.CompletionModel(column_widths=(40, 50, 10)) + + quickmarks = ((url, name) for (name, url) + in objreg.get('quickmark-manager').marks.items()) + bookmarks = objreg.get('bookmark-manager').marks.items() + + model.add_category(listcategory.ListCategory( + 'Quickmarks', quickmarks, delete_func=_delete_quickmark)) + model.add_category(listcategory.ListCategory( + 'Bookmarks', bookmarks, delete_func=_delete_bookmark)) - URL_COLUMN = 0 - TEXT_COLUMN = 1 - TIME_COLUMN = 2 - - COLUMN_WIDTHS = (40, 50, 10) - DUMB_SORT = Qt.DescendingOrder - - def __init__(self, parent=None): - super().__init__(parent) - - self.columns_to_filter = [self.URL_COLUMN, self.TEXT_COLUMN] - - self._quickmark_cat = self.new_category("Quickmarks") - self._bookmark_cat = self.new_category("Bookmarks") - self._history_cat = self.new_category("History") - - quickmark_manager = objreg.get('quickmark-manager') - quickmarks = quickmark_manager.marks.items() - for qm_name, qm_url in quickmarks: - self.new_item(self._quickmark_cat, qm_url, qm_name) - quickmark_manager.added.connect( - lambda name, url: self.new_item(self._quickmark_cat, url, name)) - quickmark_manager.removed.connect(self.on_quickmark_removed) - - bookmark_manager = objreg.get('bookmark-manager') - bookmarks = bookmark_manager.marks.items() - for bm_url, bm_title in bookmarks: - self.new_item(self._bookmark_cat, bm_url, bm_title) - bookmark_manager.added.connect( - lambda name, url: self.new_item(self._bookmark_cat, url, name)) - bookmark_manager.removed.connect(self.on_bookmark_removed) - - self._history = objreg.get('web-history') - self._max_history = config.val.completion.web_history_max_items - history = utils.newest_slice(self._history, self._max_history) - for entry in history: - if not entry.redirect: - self._add_history_entry(entry) - self._history.add_completion_item.connect(self.on_history_item_added) - self._history.cleared.connect(self.on_history_cleared) - - config.instance.changed.connect(self._reformat_timestamps) - - def _fmt_atime(self, atime): - """Format an atime to a human-readable string.""" - fmt = config.val.completion.timestamp_format - if fmt is None: - return '' - try: - dt = datetime.datetime.fromtimestamp(atime) - except (ValueError, OSError, OverflowError): - # Different errors which can occur for too large values... - log.misc.error("Got invalid timestamp {}!".format(atime)) - return '(invalid)' - else: - return dt.strftime(fmt) - - def _remove_oldest_history(self): - """Remove the oldest history entry.""" - self._history_cat.removeRow(0) - - def _add_history_entry(self, entry): - """Add a new history entry to the completion.""" - self.new_item(self._history_cat, entry.url.toDisplayString(), - entry.title, - self._fmt_atime(entry.atime), sort=int(entry.atime), - userdata=entry.url) - - if (self._max_history != -1 and - self._history_cat.rowCount() > self._max_history): - self._remove_oldest_history() - - @config.change_filter('completion.timestamp_format') - def _reformat_timestamps(self): - """Reformat the timestamps if the config option was changed.""" - for i in range(self._history_cat.rowCount()): - url_item = self._history_cat.child(i, self.URL_COLUMN) - atime_item = self._history_cat.child(i, self.TIME_COLUMN) - atime = url_item.data(base.Role.sort) - atime_item.setText(self._fmt_atime(atime)) - - @pyqtSlot(object) - def on_history_item_added(self, entry): - """Slot called when a new history item was added.""" - for i in range(self._history_cat.rowCount()): - url_item = self._history_cat.child(i, self.URL_COLUMN) - atime_item = self._history_cat.child(i, self.TIME_COLUMN) - title_item = self._history_cat.child(i, self.TEXT_COLUMN) - url = url_item.data(base.Role.userdata) - if url == entry.url: - atime_item.setText(self._fmt_atime(entry.atime)) - title_item.setText(entry.title) - url_item.setData(int(entry.atime), base.Role.sort) - break - else: - self._add_history_entry(entry) - - @pyqtSlot() - def on_history_cleared(self): - self._history_cat.removeRows(0, self._history_cat.rowCount()) - - def _remove_item(self, data, category, column): - """Helper function for on_quickmark_removed and on_bookmark_removed. - - Args: - data: The item to search for. - category: The category to search in. - column: The column to use for matching. - """ - for i in range(category.rowCount()): - item = category.child(i, column) - if item.data(Qt.DisplayRole) == data: - category.removeRow(i) - break - - @pyqtSlot(str) - def on_quickmark_removed(self, name): - """Called when a quickmark has been removed by the user. - - Args: - name: The name of the quickmark which has been removed. - """ - self._remove_item(name, self._quickmark_cat, self.TEXT_COLUMN) - - @pyqtSlot(str) - def on_bookmark_removed(self, url): - """Called when a bookmark has been removed by the user. - - Args: - url: The url of the bookmark which has been removed. - """ - self._remove_item(url, self._bookmark_cat, self.URL_COLUMN) - - def delete_cur_item(self, completion): - """Delete the selected item. - - Args: - completion: The Completion object to use. - """ - index = completion.currentIndex() - qtutils.ensure_valid(index) - category = index.parent() - index = category.child(index.row(), self.URL_COLUMN) - url = index.data() - qtutils.ensure_valid(category) - - if category.data() == 'Bookmarks': - bookmark_manager = objreg.get('bookmark-manager') - bookmark_manager.delete(url) - elif category.data() == 'Quickmarks': - quickmark_manager = objreg.get('quickmark-manager') - sibling = index.sibling(index.row(), self.TEXT_COLUMN) - qtutils.ensure_valid(sibling) - name = sibling.data() - quickmark_manager.delete(name) + if config.val.completion.web_history_max_items != 0: + hist_cat = histcategory.HistoryCategory(delete_func=_delete_history) + model.add_category(hist_cat) + return model diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 049f0898b..a99f610b4 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -29,7 +29,7 @@ from qutebrowser.config import configdata, configexc, configtypes, configfiles from qutebrowser.utils import utils, objreg, message, log, usertypes from qutebrowser.misc import objects from qutebrowser.commands import cmdexc, cmdutils - +from qutebrowser.completion.models import configmodel # An easy way to access the config from other code via config.val.foo val = None @@ -229,6 +229,7 @@ class ConfigCommands: self._keyconfig = keyconfig @cmdutils.register(instance='config-commands', star_args_optional=True) + @cmdutils.argument('option', completion=configmodel.option) @cmdutils.argument('win_id', win_id=True) def set(self, win_id, option=None, *values, temp=False, print_=False): """Set an option. diff --git a/qutebrowser/html/backend-warning.html b/qutebrowser/html/backend-warning.html index ffff0e59b..2b631d6a5 100644 --- a/qutebrowser/html/backend-warning.html +++ b/qutebrowser/html/backend-warning.html @@ -70,6 +70,8 @@ the <span class="mono">qute://settings</span> page or caret browsing).</span> {{ install_webengine('qt5-qtwebengine') }} {% elif distribution.parsed == Distribution.opensuse %} {{ install_webengine('libqt5-qtwebengine') }} +{% elif distribution.parsed == Distribution.gentoo %} + {{ install_webengine('dev-qt/qtwebengine') }} {% else %} {{ unknown_system() }} {% endif %} diff --git a/qutebrowser/html/error.html b/qutebrowser/html/error.html index 06261a06a..615e4ba8b 100644 --- a/qutebrowser/html/error.html +++ b/qutebrowser/html/error.html @@ -61,7 +61,7 @@ li { {{ super() }} function tryagain() { - location.href = url; + location.href = "{{ url }}"; } {% endblock %} diff --git a/qutebrowser/javascript/history.js b/qutebrowser/javascript/history.js index 12fa8f014..edc51ae01 100644 --- a/qutebrowser/javascript/history.js +++ b/qutebrowser/javascript/history.js @@ -23,8 +23,12 @@ window.loadHistory = (function() { // Date of last seen item. var lastItemDate = null; - // The time to load next. + // Each request for new items includes the time of the last item and an + // offset. The offset is equal to the number of items from the previous + // request that had time=nextTime, and causes the next request to skip + // those items to avoid duplicates. var nextTime = null; + var nextOffset = 0; // The URL to fetch data from. var DATA_URL = "qute://history/data"; @@ -157,23 +161,28 @@ window.loadHistory = (function() { return; } - for (var i = 0, len = history.length - 1; i < len; i++) { + if (history.length === 0) { + // Reached end of history + window.onscroll = null; + EOF_MESSAGE.style.display = "block"; + LOAD_LINK.style.display = "none"; + return; + } + + nextTime = history[history.length - 1].time; + nextOffset = 0; + + for (var i = 0, len = history.length; i < len; i++) { var item = history[i]; - var currentItemDate = new Date(item.time); + // python's time.time returns seconds, but js Date expects ms + var currentItemDate = new Date(item.time * 1000); getSessionNode(currentItemDate).appendChild(makeHistoryRow( item.url, item.title, currentItemDate.toLocaleTimeString() )); lastItemDate = currentItemDate; - } - - var next = history[history.length - 1].next; - if (next === -1) { - // Reached end of history - window.onscroll = null; - EOF_MESSAGE.style.display = "block"; - LOAD_LINK.style.display = "none"; - } else { - nextTime = next; + if (item.time === nextTime) { + nextOffset++; + } } } @@ -182,10 +191,11 @@ window.loadHistory = (function() { * @return {void} */ function loadHistory() { + var url = DATA_URL.concat("?offset=", nextOffset.toString()); if (nextTime === null) { - getJSON(DATA_URL, receiveHistory); + getJSON(url, receiveHistory); } else { - var url = DATA_URL.concat("?start_time=", nextTime.toString()); + url = url.concat("&start_time=", nextTime.toString()); getJSON(url, receiveHistory); } } diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index cc060e911..465c1e13d 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -123,6 +123,7 @@ class MainWindow(QWidget): Attributes: status: The StatusBar widget. tabbed_browser: The TabbedBrowser widget. + state_before_fullscreen: window state before activation of fullscreen. _downloadview: The DownloadView widget. _vbox: The main QVBoxLayout. _commandrunner: The main CommandRunner instance. @@ -217,6 +218,8 @@ class MainWindow(QWidget): objreg.get("app").new_window.emit(self) + self.state_before_fullscreen = self.windowState() + def _init_geometry(self, geometry): """Initialize the window geometry or load it from disk.""" if geometry is not None: @@ -461,6 +464,8 @@ class MainWindow(QWidget): tabs.tab_index_changed.connect(status.tabindex.on_tab_index_changed) tabs.cur_url_changed.connect(status.url.set_url) + tabs.cur_url_changed.connect(functools.partial( + status.backforward.on_tab_cur_url_changed, tabs=tabs)) tabs.cur_link_hovered.connect(status.url.set_hover_url) tabs.cur_load_status_changed.connect(status.url.on_load_status_changed) tabs.cur_fullscreen_requested.connect(self._on_fullscreen_requested) @@ -475,9 +480,12 @@ class MainWindow(QWidget): @pyqtSlot(bool) def _on_fullscreen_requested(self, on): if on: + self.state_before_fullscreen = self.windowState() self.showFullScreen() - else: - self.showNormal() + elif self.isFullScreen(): + self.setWindowState(self.state_before_fullscreen) + log.misc.debug('on: {}, state before fullscreen: {}'.format( + on, debug.qflags_key(Qt, self.state_before_fullscreen))) @cmdutils.register(instance='main-window', scope='window') @pyqtSlot() diff --git a/qutebrowser/mainwindow/messageview.py b/qutebrowser/mainwindow/messageview.py index d485ae9c1..e83efda0a 100644 --- a/qutebrowser/mainwindow/messageview.py +++ b/qutebrowser/mainwindow/messageview.py @@ -99,8 +99,10 @@ class MessageView(QWidget): @config.change_filter('messages.timeout') def _set_clear_timer_interval(self): """Configure self._clear_timer according to the config.""" - if config.val.messages.timeout != 0: - self._clear_timer.setInterval(config.val.messages.timeout) + interval = config.val.messages.timeout + if interval > 0: + interval *= min(5, len(self._messages)) + self._clear_timer.setInterval(interval) @pyqtSlot() def clear_messages(self): @@ -127,12 +129,13 @@ class MessageView(QWidget): widget = Message(level, text, replace=replace, parent=self) self._vbox.addWidget(widget) widget.show() - if config.val.messages.timeout != 0: - self._clear_timer.start() self._messages.append(widget) self._last_text = text self.show() self.update_geometry.emit() + if config.val.messages.timeout != 0: + self._set_clear_timer_interval() + self._clear_timer.start() def mousePressEvent(self, e): """Clear messages when they are clicked on.""" diff --git a/qutebrowser/mainwindow/statusbar/backforward.py b/qutebrowser/mainwindow/statusbar/backforward.py new file mode 100644 index 000000000..fe044e621 --- /dev/null +++ b/qutebrowser/mainwindow/statusbar/backforward.py @@ -0,0 +1,49 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""Navigation (back/forward) indicator displayed in the statusbar.""" + +from qutebrowser.mainwindow.statusbar import textbase + + +class Backforward(textbase.TextBase): + + """Shows navigation indicator (if you can go backward and/or forward).""" + + def on_tab_cur_url_changed(self, tabs): + """Called on URL changes.""" + tab = tabs.currentWidget() + if tab is None: # pragma: no cover + # WORKAROUND: Doesn't get tested on older PyQt + self.setText('') + self.hide() + return + self.on_tab_changed(tab) + + def on_tab_changed(self, tab): + """Update the text based on the given tab.""" + text = '' + if tab.history.can_go_back(): + text += '<' + if tab.history.can_go_forward(): + text += '>' + if text: + text = '[' + text + ']' + self.setText(text) + self.setVisible(bool(text)) diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py index df26d8de1..ca75be1ac 100644 --- a/qutebrowser/mainwindow/statusbar/bar.py +++ b/qutebrowser/mainwindow/statusbar/bar.py @@ -25,8 +25,9 @@ from PyQt5.QtWidgets import QWidget, QHBoxLayout, QStackedLayout, QSizePolicy from qutebrowser.browser import browsertab from qutebrowser.config import config from qutebrowser.utils import usertypes, log, objreg, utils -from qutebrowser.mainwindow.statusbar import (command, progress, keystring, - percentage, url, tabindex) +from qutebrowser.mainwindow.statusbar import (backforward, command, progress, + keystring, percentage, url, + tabindex) from qutebrowser.mainwindow.statusbar import text as textwidget @@ -184,6 +185,9 @@ class StatusBar(QWidget): self.percentage = percentage.Percentage() self._hbox.addWidget(self.percentage) + self.backforward = backforward.Backforward() + self._hbox.addWidget(self.backforward) + self.tabindex = tabindex.TabIndex() self._hbox.addWidget(self.tabindex) @@ -329,6 +333,7 @@ class StatusBar(QWidget): self.url.on_tab_changed(tab) self.prog.on_tab_changed(tab) self.percentage.on_tab_changed(tab) + self.backforward.on_tab_changed(tab) self.maybe_hide() assert tab.private == self._color_flags.private diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 66ee899b1..72d700893 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -23,7 +23,7 @@ import functools import collections from PyQt5.QtWidgets import QSizePolicy -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QTimer, QUrl, QSize +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QTimer, QUrl from PyQt5.QtGui import QIcon from qutebrowser.config import config @@ -239,6 +239,19 @@ class TabbedBrowser(tabwidget.TabWidget): for tab in self.widgets(): self._remove_tab(tab) + def tab_close_prompt_if_pinned(self, tab, force, yes_action): + """Helper method for tab_close. + + If tab is pinned, prompt. If everything is good, run yes_action. + """ + if tab.data.pinned and not force: + message.confirm_async( + title='Pinned Tab', + text="Are you sure you want to close a pinned tab?", + yes_action=yes_action, default=False) + else: + yes_action() + def close_tab(self, tab, *, add_undo=True): """Close a tab. @@ -348,7 +361,7 @@ class TabbedBrowser(tabwidget.TabWidget): newtab = self.tabopen(url, background=False, idx=idx) newtab.history.deserialize(history_data) - self.set_tab_pinned(idx, pinned) + self.set_tab_pinned(newtab, pinned) @pyqtSlot('QUrl', bool) def openurl(self, url, newtab): @@ -372,7 +385,8 @@ class TabbedBrowser(tabwidget.TabWidget): log.webview.debug("Got invalid tab {} for index {}!".format( tab, idx)) return - self.close_tab(tab) + self.tab_close_prompt_if_pinned( + tab, False, lambda: self.close_tab(tab)) @pyqtSlot(browsertab.AbstractTab) def on_window_close_requested(self, widget): @@ -443,13 +457,7 @@ class TabbedBrowser(tabwidget.TabWidget): # Make sure the background tab has the correct initial size. # With a foreground tab, it's going to be resized correctly by the # layout anyways. - if self.tabBar().vertical: - tab_size = QSize(self.width() - self.tabBar().width(), - self.height()) - else: - tab_size = QSize(self.width(), - self.height() - self.tabBar().height()) - tab.resize(tab_size) + tab.resize(self.currentWidget().size()) self.tab_index_changed.emit(self.currentIndex(), self.count()) else: self.setCurrentWidget(tab) @@ -705,12 +713,16 @@ class TabbedBrowser(tabwidget.TabWidget): } msg = messages[status] + def show_error_page(html): + tab.set_html(html) + log.webview.error(msg) + if qtutils.version_check('5.9'): url_string = tab.url(requested=True).toDisplayString() error_page = jinja.render( 'error.html', title="Error loading {}".format(url_string), - url=url_string, error=msg) - QTimer.singleShot(0, lambda: tab.set_html(error_page)) + url=url_string, error=msg, icon='') + QTimer.singleShot(100, lambda: show_error_page(error_page)) log.webview.error(msg) else: # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-58698 diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 476e6752e..2e5c0856a 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -26,7 +26,7 @@ from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QSize, QRect, QPoint, QTimer, QUrl) from PyQt5.QtWidgets import (QTabWidget, QTabBar, QSizePolicy, QCommonStyle, QStyle, QStylePainter, QStyleOptionTab, - QStyleFactory) + QStyleFactory, QWidget) from PyQt5.QtGui import QIcon, QPalette, QColor from qutebrowser.utils import qtutils, objreg, utils, usertypes, log @@ -94,17 +94,18 @@ class TabWidget(QTabWidget): bar.set_tab_data(idx, 'indicator-color', color) bar.update(bar.tabRect(idx)) - def set_tab_pinned(self, idx, pinned, *, loading=False): + def set_tab_pinned(self, tab: QWidget, + pinned: bool, *, loading: bool = False) -> None: """Set the tab status as pinned. Args: - idx: The tab index. + tab: The tab to pin pinned: Pinned tab state to set. loading: Whether to ignore current data state when counting pinned_count. """ bar = self.tabBar() - tab = self.widget(idx) + idx = self.indexOf(tab) # Only modify pinned_count if we had a change # always modify pinned_count if we are loading @@ -487,14 +488,10 @@ class TabBar(QTabBar): width = int(confwidth) size = QSize(max(minimum_size.width(), width), height) elif self.count() == 0: - # This happens on startup on OS X. + # This happens on startup on macOS. # We return it directly rather than setting `size' because we don't # want to ensure it's valid in this special case. return QSize() - elif self.count() * minimum_size.width() > self.width(): - # If we don't have enough space, we return the minimum size so we - # get scroll buttons as soon as needed. - size = minimum_size else: try: pinned = self.tab_data(index, 'pinned') @@ -522,13 +519,13 @@ class TabBar(QTabBar): width = no_pinned_width / (self.count() - self.pinned_count) else: - # If we *do* have enough space, tabs should occupy the whole - # window width. If there are pinned tabs their size will be - # subtracted from the total window width. - # During shutdown the self.count goes down, - # but the self.pinned_count not - this generates some odd + # Tabs should attempt to occupy the whole window width. If + # there are pinned tabs their size will be subtracted from the + # total window width. During shutdown the self.count goes + # down, but the self.pinned_count not - this generates some odd # behavior. To avoid this we compare self.count against - # self.pinned_count. + # self.pinned_count. If we end up having too little space, we + # set the minimum size below. if self.pinned_count > 0 and no_pinned_count > 0: width = no_pinned_width / no_pinned_count else: @@ -540,6 +537,10 @@ class TabBar(QTabBar): index < no_pinned_width % no_pinned_count): width += 1 + # If we don't have enough space, we return the minimum size so we + # get scroll buttons as soon as needed. + width = max(width, minimum_size.width()) + size = QSize(width, height) qtutils.ensure_valid(size) return size @@ -750,6 +751,17 @@ class TabBarStyle(QCommonStyle): rct = super().subElementRect(sr, opt, widget) return rct else: + try: + # We need this so the left scroll button is aligned properly. + # Otherwise, empty space will be shown after the last tab even + # though the button width is set to 0 + # + # QStyle.SE_TabBarScrollLeftButton was added in Qt 5.7 + if sr == QStyle.SE_TabBarScrollLeftButton: + return super().subElementRect(sr, opt, widget) + except AttributeError: + pass + return self._style.subElementRect(sr, opt, widget) def _tab_layout(self, opt): diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py index 5f161312a..e51855af2 100644 --- a/qutebrowser/misc/crashsignal.py +++ b/qutebrowser/misc/crashsignal.py @@ -28,6 +28,11 @@ import functools import faulthandler import os.path import collections +try: + # WORKAROUND for segfaults when using pdb in pytest for some reason... + import readline # pylint: disable=unused-import +except ImportError: + pass from PyQt5.QtCore import (pyqtSlot, qInstallMessageHandler, QObject, QSocketNotifier, QTimer, QUrl) diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index 4993d6927..a64a2799b 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -337,12 +337,12 @@ def check_libraries(backend): "or Install via pip.", pip="PyYAML"), 'PyQt5.QtQml': _missing_str("PyQt5.QtQml"), + 'PyQt5.QtSql': _missing_str("PyQt5.QtSql"), } if backend == 'webengine': modules['PyQt5.QtWebEngineWidgets'] = _missing_str("QtWebEngine", webengine=True) modules['PyQt5.QtOpenGL'] = _missing_str("PyQt5.QtOpenGL") - modules['OpenGL'] = _missing_str("PyOpenGL") else: assert backend == 'webkit' modules['PyQt5.QtWebKit'] = _missing_str("PyQt5.QtWebKit") diff --git a/qutebrowser/misc/guiprocess.py b/qutebrowser/misc/guiprocess.py index e8d224f2e..95bfac79e 100644 --- a/qutebrowser/misc/guiprocess.py +++ b/qutebrowser/misc/guiprocess.py @@ -154,8 +154,8 @@ class GUIProcess(QObject): log.procs.debug("Process started.") self._started = True else: - message.error("Error while spawning {}: {}.".format( - self._what, self._proc.error())) + message.error("Error while spawning {}: {}".format( + self._what, ERROR_STRINGS[self._proc.error()])) def exit_status(self): return self._proc.exitStatus() diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py index eb9aa4a3b..562cc84cc 100644 --- a/qutebrowser/misc/ipc.py +++ b/qutebrowser/misc/ipc.py @@ -20,7 +20,6 @@ """Utilities for IPC with existing instances.""" import os -import sys import time import json import getpass @@ -41,8 +40,8 @@ ATIME_INTERVAL = 60 * 60 * 3 * 1000 # 3 hours PROTOCOL_VERSION = 1 -def _get_socketname_legacy(basedir): - """Legacy implementation of _get_socketname.""" +def _get_socketname_windows(basedir): + """Get a socketname to use for Windows.""" parts = ['qutebrowser', getpass.getuser()] if basedir is not None: md5 = hashlib.md5(basedir.encode('utf-8')).hexdigest() @@ -50,10 +49,10 @@ def _get_socketname_legacy(basedir): return '-'.join(parts) -def _get_socketname(basedir, legacy=False): +def _get_socketname(basedir): """Get a socketname to use.""" - if legacy or os.name == 'nt': - return _get_socketname_legacy(basedir) + if os.name == 'nt': # pragma: no cover + return _get_socketname_windows(basedir) parts_to_hash = [getpass.getuser()] if basedir is not None: @@ -415,41 +414,7 @@ class IPCServer(QObject): self._remove_server() -def _has_legacy_server(name): - """Check if there is a legacy server. - - Args: - name: The name to try to connect to. - - Return: - True if there is a server with the given name, False otherwise. - """ - socket = QLocalSocket() - log.ipc.debug("Trying to connect to {}".format(name)) - socket.connectToServer(name) - - err = socket.error() - - if err != QLocalSocket.UnknownSocketError: - log.ipc.debug("Socket error: {} ({})".format( - socket.errorString(), err)) - - os_x_fail = (sys.platform == 'darwin' and - socket.errorString() == 'QLocalSocket::connectToServer: ' - 'Unknown error 38') - - if err not in [QLocalSocket.ServerNotFoundError, - QLocalSocket.ConnectionRefusedError] and not os_x_fail: - return True - - socket.disconnectFromServer() - if socket.state() != QLocalSocket.UnconnectedState: - socket.waitForDisconnected(CONNECT_TIMEOUT) - return False - - -def send_to_running_instance(socketname, command, target_arg, *, - legacy_name=None, socket=None): +def send_to_running_instance(socketname, command, target_arg, *, socket=None): """Try to send a commandline to a running instance. Blocks for CONNECT_TIMEOUT ms. @@ -459,7 +424,6 @@ def send_to_running_instance(socketname, command, target_arg, *, command: The command to send to the running instance. target_arg: --target command line argument socket: The socket to read data from, or None. - legacy_name: The legacy name to first try to connect to. Return: True if connecting was successful, False if no connection was made. @@ -467,13 +431,8 @@ def send_to_running_instance(socketname, command, target_arg, *, if socket is None: socket = QLocalSocket() - if legacy_name is not None and _has_legacy_server(legacy_name): - name_to_use = legacy_name - else: - name_to_use = socketname - - log.ipc.debug("Connecting to {}".format(name_to_use)) - socket.connectToServer(name_to_use) + log.ipc.debug("Connecting to {}".format(socketname)) + socket.connectToServer(socketname) connected = socket.waitForConnected(CONNECT_TIMEOUT) if connected: @@ -527,12 +486,10 @@ def send_or_listen(args): None if an instance was running and received our request. """ socketname = _get_socketname(args.basedir) - legacy_socketname = _get_socketname(args.basedir, legacy=True) try: try: sent = send_to_running_instance(socketname, args.command, - args.target, - legacy_name=legacy_socketname) + args.target) if sent: return None log.init.debug("Starting IPC server...") @@ -545,8 +502,7 @@ def send_or_listen(args): log.init.debug("Got AddressInUseError, trying again.") time.sleep(0.5) sent = send_to_running_instance(socketname, args.command, - args.target, - legacy_name=legacy_socketname) + args.target) if sent: return None else: diff --git a/qutebrowser/misc/lineparser.py b/qutebrowser/misc/lineparser.py index bd07f7903..155cbd1b0 100644 --- a/qutebrowser/misc/lineparser.py +++ b/qutebrowser/misc/lineparser.py @@ -21,7 +21,6 @@ import os import os.path -import itertools import contextlib from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject @@ -96,7 +95,7 @@ class BaseLineParser(QObject): """ assert self._configfile is not None if self._opened: - raise IOError("Refusing to double-open AppendLineParser.") + raise IOError("Refusing to double-open LineParser.") self._opened = True try: if self._binary: @@ -133,73 +132,6 @@ class BaseLineParser(QObject): raise NotImplementedError -class AppendLineParser(BaseLineParser): - - """LineParser which reads lazily and appends data to existing one. - - Attributes: - _new_data: The data which was added in this session. - """ - - def __init__(self, configdir, fname, *, parent=None): - super().__init__(configdir, fname, binary=False, parent=parent) - self.new_data = [] - self._fileobj = None - - def __iter__(self): - if self._fileobj is None: - raise ValueError("Iterating without open() being called!") - file_iter = (line.rstrip('\n') for line in self._fileobj) - return itertools.chain(file_iter, iter(self.new_data)) - - @contextlib.contextmanager - def open(self): - """Open the on-disk history file. Needed for __iter__.""" - try: - with self._open('r') as f: - self._fileobj = f - yield - except FileNotFoundError: - self._fileobj = [] - yield - finally: - self._fileobj = None - - def get_recent(self, count=4096): - """Get the last count bytes from the underlying file.""" - with self._open('r') as f: - f.seek(0, os.SEEK_END) - size = f.tell() - try: - if size - count > 0: - offset = size - count - else: - offset = 0 - f.seek(offset) - data = f.readlines() - finally: - f.seek(0, os.SEEK_END) - return data - - def save(self): - do_save = self._prepare_save() - if not do_save: - return - with self._open('a') as f: - self._write(f, self.new_data) - self.new_data = [] - self._after_save() - - def clear(self): - do_save = self._prepare_save() - if not do_save: - return - with self._open('w'): - pass - self.new_data = [] - self._after_save() - - class LineParser(BaseLineParser): """Parser for configuration files which are simply line-based. @@ -240,7 +172,7 @@ class LineParser(BaseLineParser): def save(self): """Save the config file.""" if self._opened: - raise IOError("Refusing to double-open AppendLineParser.") + raise IOError("Refusing to double-open LineParser.") do_save = self._prepare_save() if not do_save: return diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index db34a821a..94e9fd8f9 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -23,14 +23,15 @@ import os import os.path import sip -from PyQt5.QtCore import pyqtSignal, QUrl, QObject, QPoint, QTimer +from PyQt5.QtCore import QUrl, QObject, QPoint, QTimer from PyQt5.QtWidgets import QApplication import yaml -from qutebrowser.utils import (standarddir, objreg, qtutils, log, usertypes, - message, utils) +from qutebrowser.utils import (standarddir, objreg, qtutils, log, message, + utils) from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.config import config +from qutebrowser.completion.models import miscmodels default = object() # Sentinel value @@ -101,14 +102,8 @@ class SessionManager(QObject): closed. _current: The name of the currently loaded session, or None. did_load: Set when a session was loaded. - - Signals: - update_completion: Emitted when the session completion should get - updated. """ - update_completion = pyqtSignal() - def __init__(self, base_path, parent=None): super().__init__(parent) self._current = None @@ -297,8 +292,7 @@ class SessionManager(QObject): utils.yaml_dump(data, f) except (OSError, UnicodeEncodeError, yaml.YAMLError) as e: raise SessionError(e) - else: - self.update_completion.emit() + if load_next_time: state_config = objreg.get('state-config') state_config['general']['session'] = name @@ -401,7 +395,7 @@ class SessionManager(QObject): tab_to_focus = i if new_tab.data.pinned: tabbed_browser.set_tab_pinned( - i, new_tab.data.pinned, loading=True) + new_tab, new_tab.data.pinned, loading=True) if tab_to_focus is not None: tabbed_browser.setCurrentIndex(tab_to_focus) if win.get('active', False): @@ -419,7 +413,6 @@ class SessionManager(QObject): os.remove(path) except OSError as e: raise SessionError(e) - self.update_completion.emit() def list_sessions(self): """Get a list of all session names.""" @@ -431,7 +424,7 @@ class SessionManager(QObject): return sessions @cmdutils.register(instance='session-manager') - @cmdutils.argument('name', completion=usertypes.Completion.sessions) + @cmdutils.argument('name', completion=miscmodels.session) def session_load(self, name, clear=False, temp=False, force=False): """Load a session. @@ -459,7 +452,7 @@ class SessionManager(QObject): win.close() @cmdutils.register(instance='session-manager') - @cmdutils.argument('name', completion=usertypes.Completion.sessions) + @cmdutils.argument('name', completion=miscmodels.session) @cmdutils.argument('win_id', win_id=True) @cmdutils.argument('with_private', flag='p') def session_save(self, name: str = default, current=False, quiet=False, @@ -498,7 +491,7 @@ class SessionManager(QObject): message.info("Saved session {}.".format(name)) @cmdutils.register(instance='session-manager') - @cmdutils.argument('name', completion=usertypes.Completion.sessions) + @cmdutils.argument('name', completion=miscmodels.session) def session_delete(self, name, force=False): """Delete a session. diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py new file mode 100644 index 000000000..a288df475 --- /dev/null +++ b/qutebrowser/misc/sql.py @@ -0,0 +1,256 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""Provides access to an in-memory sqlite database.""" + +import collections + +from PyQt5.QtCore import QObject, pyqtSignal +from PyQt5.QtSql import QSqlDatabase, QSqlQuery + +from qutebrowser.utils import log + + +class SqlException(Exception): + + """Raised on an error interacting with the SQL database.""" + + pass + + +def init(db_path): + """Initialize the SQL database connection.""" + database = QSqlDatabase.addDatabase('QSQLITE') + if not database.isValid(): + raise SqlException('Failed to add database. ' + 'Are sqlite and Qt sqlite support installed?') + database.setDatabaseName(db_path) + if not database.open(): + raise SqlException("Failed to open sqlite database at {}: {}" + .format(db_path, database.lastError().text())) + + +def close(): + """Close the SQL connection.""" + QSqlDatabase.removeDatabase(QSqlDatabase.database().connectionName()) + + +def version(): + """Return the sqlite version string.""" + try: + if not QSqlDatabase.database().isOpen(): + init(':memory:') + ver = Query("select sqlite_version()").run().value() + close() + return ver + return Query("select sqlite_version()").run().value() + except SqlException as e: + return 'UNAVAILABLE ({})'.format(e) + + +class Query(QSqlQuery): + + """A prepared SQL Query.""" + + def __init__(self, querystr, forward_only=True): + """Prepare a new sql query. + + Args: + querystr: String to prepare query from. + forward_only: Optimization for queries that will only step forward. + Must be false for completion queries. + """ + super().__init__(QSqlDatabase.database()) + log.sql.debug('Preparing SQL query: "{}"'.format(querystr)) + if not self.prepare(querystr): + raise SqlException('Failed to prepare query "{}": "{}"'.format( + querystr, self.lastError().text())) + self.setForwardOnly(forward_only) + + def __iter__(self): + if not self.isActive(): + raise SqlException("Cannot iterate inactive query") + rec = self.record() + fields = [rec.fieldName(i) for i in range(rec.count())] + rowtype = collections.namedtuple('ResultRow', fields) + + while self.next(): + rec = self.record() + yield rowtype(*[rec.value(i) for i in range(rec.count())]) + + def run(self, **values): + """Execute the prepared query.""" + log.sql.debug('Running SQL query: "{}"'.format(self.lastQuery())) + for key, val in values.items(): + self.bindValue(':{}'.format(key), val) + log.sql.debug('query bindings: {}'.format(self.boundValues())) + if not self.exec_(): + raise SqlException('Failed to exec query "{}": "{}"'.format( + self.lastQuery(), self.lastError().text())) + return self + + def value(self): + """Return the result of a single-value query (e.g. an EXISTS).""" + if not self.next(): + raise SqlException("No result for single-result query") + return self.record().value(0) + + +class SqlTable(QObject): + + """Interface to a sql table. + + Attributes: + _name: Name of the SQL table this wraps. + + Signals: + changed: Emitted when the table is modified. + """ + + changed = pyqtSignal() + + def __init__(self, name, fields, constraints=None, parent=None): + """Create a new table in the sql database. + + Raises SqlException if the table already exists. + + Args: + name: Name of the table. + fields: A list of field names. + constraints: A dict mapping field names to constraint strings. + """ + super().__init__(parent) + self._name = name + + constraints = constraints or {} + column_defs = ['{} {}'.format(field, constraints.get(field, '')) + for field in fields] + q = Query("CREATE TABLE IF NOT EXISTS {name} ({column_defs})" + .format(name=name, column_defs=', '.join(column_defs))) + + q.run() + + def create_index(self, name, field): + """Create an index over this table. + + Args: + name: Name of the index, should be unique. + field: Name of the field to index. + """ + q = Query("CREATE INDEX IF NOT EXISTS {name} ON {table} ({field})" + .format(name=name, table=self._name, field=field)) + q.run() + + def __iter__(self): + """Iterate rows in the table.""" + q = Query("SELECT * FROM {table}".format(table=self._name)) + q.run() + return iter(q) + + def contains_query(self, field): + """Return a prepared query that checks for the existence of an item. + + Args: + field: Field to match. + """ + return Query( + "SELECT EXISTS(SELECT * FROM {table} WHERE {field} = :val)" + .format(table=self._name, field=field)) + + def __len__(self): + """Return the count of rows in the table.""" + q = Query("SELECT count(*) FROM {table}".format(table=self._name)) + q.run() + return q.value() + + def delete(self, field, value): + """Remove all rows for which `field` equals `value`. + + Args: + field: Field to use as the key. + value: Key value to delete. + + Return: + The number of rows deleted. + """ + q = Query("DELETE FROM {table} where {field} = :val" + .format(table=self._name, field=field)) + q.run(val=value) + if not q.numRowsAffected(): + raise KeyError('No row with {} = "{}"'.format(field, value)) + self.changed.emit() + + def _insert_query(self, values, replace): + params = ', '.join(':{}'.format(key) for key in values) + verb = "REPLACE" if replace else "INSERT" + return Query("{verb} INTO {table} ({columns}) values({params})".format( + verb=verb, table=self._name, columns=', '.join(values), + params=params)) + + def insert(self, values, replace=False): + """Append a row to the table. + + Args: + values: A dict with a value to insert for each field name. + replace: If set, replace existing values. + """ + q = self._insert_query(values, replace) + q.run(**values) + self.changed.emit() + + def insert_batch(self, values, replace=False): + """Performantly append multiple rows to the table. + + Args: + values: A dict with a list of values to insert for each field name. + replace: If true, overwrite rows with a primary key match. + """ + q = self._insert_query(values, replace) + for key, val in values.items(): + q.bindValue(':{}'.format(key), val) + + db = QSqlDatabase.database() + db.transaction() + if not q.execBatch(): + raise SqlException('Failed to exec query "{}": "{}"'.format( + q.lastQuery(), q.lastError().text())) + db.commit() + self.changed.emit() + + def delete_all(self): + """Remove all rows from the table.""" + Query("DELETE FROM {table}".format(table=self._name)).run() + self.changed.emit() + + def select(self, sort_by, sort_order, limit=-1): + """Prepare, run, and return a select statement on this table. + + Args: + sort_by: name of column to sort by. + sort_order: 'asc' or 'desc'. + limit: max number of rows in result, defaults to -1 (unlimited). + + Return: A prepared and executed select query. + """ + q = Query("SELECT * FROM {table} ORDER BY {sort_by} {sort_order} " + "LIMIT :limit" + .format(table=self._name, sort_by=sort_by, + sort_order=sort_order)) + q.run(limit=limit) + return q diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index c2abbfb87..6cdc61f41 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -94,7 +94,7 @@ LOGGER_NAMES = [ 'commands', 'signals', 'downloads', 'js', 'qt', 'rfc6266', 'ipc', 'shlexer', 'save', 'message', 'config', 'sessions', - 'webelem', 'prompt', 'network' + 'webelem', 'prompt', 'network', 'sql' ] @@ -141,6 +141,7 @@ sessions = logging.getLogger('sessions') webelem = logging.getLogger('webelem') prompt = logging.getLogger('prompt') network = logging.getLogger('network') +sql = logging.getLogger('sql') ram_handler = None diff --git a/qutebrowser/utils/standarddir.py b/qutebrowser/utils/standarddir.py index a1cedddd0..28d84764f 100644 --- a/qutebrowser/utils/standarddir.py +++ b/qutebrowser/utils/standarddir.py @@ -107,7 +107,7 @@ def runtime(): if sys.platform.startswith('linux'): typ = QStandardPaths.RuntimeLocation else: # pragma: no cover - # RuntimeLocation is a weird path on OS X and Windows. + # RuntimeLocation is a weird path on macOS and Windows. typ = QStandardPaths.TempLocation overridden, path = _from_args(typ, _args) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 7d31ba6ac..31f2f79cb 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -236,13 +236,6 @@ KeyMode = enum('KeyMode', ['normal', 'hint', 'command', 'yesno', 'prompt', 'jump_mark', 'record_macro', 'run_macro']) -# Available command completions -Completion = enum('Completion', ['command', 'section', 'option', 'value', - 'helptopic', 'quickmark_by_name', - 'bookmark_by_url', 'url', 'tab', 'sessions', - 'bind']) - - # Exit statuses for errors. Needs to be an int for sys.exit. Exit = enum('Exit', ['ok', 'reserved', 'exception', 'err_ipc', 'err_init', 'err_config', 'err_key_config'], is_int=True, start=0) diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 13b432c6a..e795cfdd8 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -28,7 +28,6 @@ import os.path import collections import functools import contextlib -import itertools import socket import shlex @@ -378,8 +377,8 @@ def keyevent_to_string(e): None if only modifiers are pressed.. """ if sys.platform == 'darwin': - # Qt swaps Ctrl/Meta on OS X, so we switch it back here so the user can - # use it in the config as expected. See: + # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the user + # can use it in the config as expected. See: # https://github.com/qutebrowser/qutebrowser/issues/110 # http://doc.qt.io/qt-5.4/osx-issues.html#special-keys modmask2str = collections.OrderedDict([ @@ -742,25 +741,6 @@ def sanitize_filename(name, replacement='_'): return name -def newest_slice(iterable, count): - """Get an iterable for the n newest items of the given iterable. - - Args: - count: How many elements to get. - 0: get no items: - n: get the n newest items - -1: get all items - """ - if count < -1: - raise ValueError("count can't be smaller than -1!") - elif count == 0: - return [] - elif count == -1 or len(iterable) < count: - return iterable - else: - return itertools.islice(iterable, len(iterable) - count, len(iterable)) - - def set_clipboard(data, selection=False): """Set the clipboard to some given data.""" if selection and not supports_selection(): diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index e1cc26e64..0b650a97e 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -45,7 +45,7 @@ except ImportError: # pragma: no cover import qutebrowser from qutebrowser.utils import log, utils, standarddir, usertypes, qtutils -from qutebrowser.misc import objects, earlyinit +from qutebrowser.misc import objects, earlyinit, sql from qutebrowser.browser import pdfjs @@ -186,7 +186,6 @@ def _module_versions(): ('yaml', ['__version__']), ('cssutils', ['__version__']), ('typing', []), - ('OpenGL', ['__version__']), ('PyQt5.QtWebEngineWidgets', []), ('PyQt5.QtWebKitWidgets', []), ]) @@ -326,11 +325,11 @@ def version(): lines += _module_versions() - lines += ['pdf.js: {}'.format(_pdfjs_version())] - lines += [ - 'SSL: {}'.format(QSslSocket.sslLibraryVersionString()), - '', + 'pdf.js: {}'.format(_pdfjs_version()), + 'sqlite: {}'.format(sql.version()), + 'QtNetwork SSL: {}\n'.format(QSslSocket.sslLibraryVersionString() + if QSslSocket.supportsSsl() else 'no'), ] qapp = QApplication.instance() diff --git a/requirements.txt b/requirements.txt index cbf9ba407..b2cc93c1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,3 @@ MarkupSafe==1.0 Pygments==2.2.0 pyPEG2==2.15.2 PyYAML==3.12 -PyOpenGL==3.1.0 diff --git a/scripts/asciidoc2html.py b/scripts/asciidoc2html.py index 39695bd23..6c7fdaf6e 100755 --- a/scripts/asciidoc2html.py +++ b/scripts/asciidoc2html.py @@ -280,8 +280,6 @@ def main(colors=False): "asciidoc.py. If not given, it's searched in PATH.", nargs=2, required=False, metavar=('PYTHON', 'ASCIIDOC')) - parser.add_argument('--no-authors', help=argparse.SUPPRESS, - action='store_true') args = parser.parse_args() try: os.mkdir('qutebrowser/html/doc') diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index 073b9a58e..d5c03ad02 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -64,7 +64,7 @@ def call_tox(toxenv, *args, python=sys.executable): env['PYTHON'] = python env['PATH'] = os.environ['PATH'] + os.pathsep + os.path.dirname(python) subprocess.check_call( - [sys.executable, '-m', 'tox', '-v', '-e', toxenv] + list(args), + [sys.executable, '-m', 'tox', '-vv', '-e', toxenv] + list(args), env=env) @@ -92,7 +92,7 @@ def smoke_test(executable): '--temp-basedir', 'about:blank', ':later 500 quit']) -def patch_osx_app(): +def patch_mac_app(): """Patch .app to copy missing data and link some libs. See https://github.com/pyinstaller/pyinstaller/issues/2276 @@ -109,8 +109,11 @@ def patch_osx_app(): for f in glob.glob(os.path.join(qtwe_core_dir, 'Resources', '*')): dest = os.path.join(app_path, 'Contents', 'Resources') if os.path.isdir(f): - shutil.copytree(f, os.path.join(dest, f)) + dir_dest = os.path.join(dest, os.path.basename(f)) + print("Copying directory {} to {}".format(f, dir_dest)) + shutil.copytree(f, dir_dest) else: + print("Copying {} to {}".format(f, dest)) shutil.copy(f, dest) # Link dependencies for lib in ['QtCore', 'QtWebEngineCore', 'QtQuick', 'QtQml', 'QtNetwork', @@ -122,37 +125,45 @@ def patch_osx_app(): os.path.join(dest, lib)) -def build_osx(): - """Build OS X .dmg/.app.""" +def build_mac(): + """Build macOS .dmg/.app.""" + utils.print_title("Cleaning up...") + for f in ['wc.dmg', 'template.dmg']: + try: + os.remove(f) + except FileNotFoundError: + pass + for d in ['dist', 'build']: + shutil.rmtree(d, ignore_errors=True) utils.print_title("Updating 3rdparty content") + # Currently disabled because QtWebEngine has no pdfjs support # update_3rdparty.run(ace=False, pdfjs=True, fancy_dmg=False) utils.print_title("Building .app via pyinstaller") call_tox('pyinstaller', '-r') utils.print_title("Patching .app") - patch_osx_app() + patch_mac_app() utils.print_title("Building .dmg") subprocess.check_call(['make', '-f', 'scripts/dev/Makefile-dmg']) - utils.print_title("Cleaning up...") - for f in ['wc.dmg', 'template.dmg']: - os.remove(f) - for d in ['dist', 'build']: - shutil.rmtree(d) dmg_name = 'qutebrowser-{}.dmg'.format(qutebrowser.__version__) os.rename('qutebrowser.dmg', dmg_name) utils.print_title("Running smoke test") - with tempfile.TemporaryDirectory() as tmpdir: - subprocess.check_call(['hdiutil', 'attach', dmg_name, - '-mountpoint', tmpdir]) - try: - binary = os.path.join(tmpdir, 'qutebrowser.app', 'Contents', - 'MacOS', 'qutebrowser') - smoke_test(binary) - finally: - subprocess.check_call(['hdiutil', 'detach', tmpdir]) - return [(dmg_name, 'application/x-apple-diskimage', 'OS X .dmg')] + try: + with tempfile.TemporaryDirectory() as tmpdir: + subprocess.check_call(['hdiutil', 'attach', dmg_name, + '-mountpoint', tmpdir]) + try: + binary = os.path.join(tmpdir, 'qutebrowser.app', 'Contents', + 'MacOS', 'qutebrowser') + smoke_test(binary) + finally: + subprocess.call(['hdiutil', 'detach', tmpdir]) + except PermissionError as e: + print("Failed to remove tempdir: {}".format(e)) + + return [(dmg_name, 'application/x-apple-diskimage', 'macOS .dmg')] def patch_windows(out_dir): @@ -167,6 +178,7 @@ def patch_windows(out_dir): def build_windows(): """Build windows executables/setups.""" utils.print_title("Updating 3rdparty content") + # Currently disabled because QtWebEngine has no pdfjs support # update_3rdparty.run(ace=False, pdfjs=True, fancy_dmg=False) utils.print_title("Building Windows binaries") @@ -203,8 +215,8 @@ def build_windows(): '/DVERSION={}'.format(qutebrowser.__version__), 'misc/qutebrowser.nsi']) - name_32 = 'qutebrowser-{}-win32.msi'.format(qutebrowser.__version__) - name_64 = 'qutebrowser-{}-amd64.msi'.format(qutebrowser.__version__) + name_32 = 'qutebrowser-{}-win32.exe'.format(qutebrowser.__version__) + name_64 = 'qutebrowser-{}-amd64.exe'.format(qutebrowser.__version__) artifacts += [ (os.path.join('dist', name_32), @@ -280,6 +292,14 @@ def build_sdist(): return artifacts +def read_github_token(): + """Read the GitHub API token from disk.""" + token_file = os.path.join(os.path.expanduser('~'), '.gh_token') + with open(token_file, encoding='ascii') as f: + token = f.read().strip() + return token + + def github_upload(artifacts, tag): """Upload the given artifacts to GitHub. @@ -290,9 +310,7 @@ def github_upload(artifacts, tag): import github3 utils.print_title("Uploading to github...") - token_file = os.path.join(os.path.expanduser('~'), '.gh_token') - with open(token_file, encoding='ascii') as f: - token = f.read().strip() + token = read_github_token() gh = github3.login(token=token) repo = gh.repository('qutebrowser', 'qutebrowser') @@ -329,6 +347,12 @@ def main(): upload_to_pypi = False + if args.upload is not None: + # Fail early when trying to upload without github3 installed + # or without API token + import github3 # pylint: disable=unused-variable + read_github_token() + if os.name == 'nt': if sys.maxsize > 2**32: # WORKAROUND @@ -342,7 +366,7 @@ def main(): artifacts = build_windows() elif sys.platform == 'darwin': run_asciidoc2html(args) - artifacts = build_osx() + artifacts = build_mac() else: artifacts = build_sdist() upload_to_pypi = True diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 90c142185..815716437 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -51,9 +51,9 @@ PERFECT_FILES = [ 'browser/webkit/cache.py'), ('tests/unit/browser/webkit/test_cookies.py', 'browser/webkit/cookies.py'), - ('tests/unit/browser/webkit/test_history.py', + ('tests/unit/browser/test_history.py', 'browser/history.py'), - ('tests/unit/browser/webkit/test_history.py', + ('tests/unit/browser/test_history.py', 'browser/webkit/webkithistory.py'), ('tests/unit/browser/webkit/http/test_http.py', 'browser/webkit/http.py'), @@ -117,6 +117,8 @@ PERFECT_FILES = [ 'mainwindow/statusbar/textbase.py'), ('tests/unit/mainwindow/statusbar/test_url.py', 'mainwindow/statusbar/url.py'), + ('tests/unit/mainwindow/statusbar/test_backforward.py', + 'mainwindow/statusbar/backforward.py'), ('tests/unit/mainwindow/test_messageview.py', 'mainwindow/messageview.py'), @@ -155,9 +157,11 @@ PERFECT_FILES = [ 'utils/javascript.py'), ('tests/unit/completion/test_models.py', - 'completion/models/base.py'), - ('tests/unit/completion/test_sortfilter.py', - 'completion/models/sortfilter.py'), + 'completion/models/urlmodel.py'), + ('tests/unit/completion/test_histcategory.py', + 'completion/models/histcategory.py'), + ('tests/unit/completion/test_listcategory.py', + 'completion/models/listcategory.py'), ] diff --git a/scripts/dev/ci/travis_install.sh b/scripts/dev/ci/travis_install.sh index 9bcb5e07c..53bcf06e8 100644 --- a/scripts/dev/ci/travis_install.sh +++ b/scripts/dev/ci/travis_install.sh @@ -109,7 +109,7 @@ elif [[ $TRAVIS_OS_NAME == osx ]]; then exit 0 fi -pyqt_pkgs="python3-pyqt5 python3-pyqt5.qtquick python3-pyqt5.qtwebkit" +pyqt_pkgs="python3-pyqt5 python3-pyqt5.qtquick python3-pyqt5.qtwebkit python3-pyqt5.qtsql libqt5sql5-sqlite" pip_install pip pip_install -r misc/requirements/requirements-tox.txt diff --git a/scripts/dev/ci/travis_run.sh b/scripts/dev/ci/travis_run.sh index fc4fde0fe..19386a1f5 100644 --- a/scripts/dev/ci/travis_run.sh +++ b/scripts/dev/ci/travis_run.sh @@ -4,7 +4,6 @@ if [[ $DOCKER ]]; then docker run --privileged -v $PWD:/outside -e QUTE_BDD_WEBENGINE=$QUTE_BDD_WEBENGINE -e DOCKER=$DOCKER -e CI=$CI qutebrowser/travis:$DOCKER else args=() - [[ $TESTENV == docs ]] && args=('--no-authors') [[ $TRAVIS_OS_NAME == osx ]] && args=('--qute-bdd-webengine' '--no-xvfb') tox -e $TESTENV -- "${args[@]}" diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py index 0d4f8dabb..4a31c56c1 100755 --- a/scripts/dev/run_vulture.py +++ b/scripts/dev/run_vulture.py @@ -89,6 +89,12 @@ def whitelist_generator(): # vulture doesn't notice the hasattr() and thus thinks netrc_used is unused # in NetworkManager.on_authentication_required yield 'PyQt5.QtNetwork.QNetworkReply.netrc_used' + yield 'qutebrowser.browser.downloads.last_used_directory' + yield 'PaintContext.clip' # from completiondelegate.py + yield 'logging.LogRecord.log_color' # from logging.py + yield 'scripts.utils.use_color' # from asciidoc2html.py + for attr in ['pyeval_output', 'log_clipboard', 'fake_clipboard']: + yield 'qutebrowser.misc.utilcmds.' + attr for attr in ['fileno', 'truncate', 'closed', 'readable']: yield 'qutebrowser.utils.qtutils.PyQIODevice.' + attr @@ -123,7 +129,7 @@ def filter_func(item): True if the missing function should be filtered/ignored, False otherwise. """ - return bool(re.match(r'[a-z]+[A-Z][a-zA-Z]+', str(item))) + return bool(re.match(r'[a-z]+[A-Z][a-zA-Z]+', item.name)) def report(items): @@ -137,7 +143,7 @@ def report(items): relpath = os.path.relpath(item.filename) path = relpath if not relpath.startswith('..') else item.filename output.append("{}:{}: Unused {} '{}'".format(path, item.lineno, - item.typ, item)) + item.typ, item.name)) return output diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py index 13861e431..8804b5c0d 100755 --- a/scripts/dev/src2asciidoc.py +++ b/scripts/dev/src2asciidoc.py @@ -26,7 +26,6 @@ import shutil import os.path import inspect import subprocess -import collections import tempfile import argparse @@ -43,7 +42,7 @@ from qutebrowser.utils import docutils, usertypes FILE_HEADER = """ // DO NOT EDIT THIS FILE DIRECTLY! -// It is autogenerated from docstrings by running: +// It is autogenerated by running: // $ python3 scripts/dev/src2asciidoc.py """.lstrip() @@ -415,32 +414,6 @@ def generate_settings(filename): _generate_setting_option(f, opt) -def _get_authors(): - """Get a list of authors based on git commit logs.""" - corrections = { - 'binix': 'sbinix', - 'Averrin': 'Alexey "Averrin" Nabrodov', - 'Alexey Nabrodov': 'Alexey "Averrin" Nabrodov', - 'Michael': 'Halfwit', - 'Error 800': 'error800', - 'larryhynes': 'Larry Hynes', - 'Daniel': 'Daniel Schadt', - 'Alexey Glushko': 'haitaka', - 'Corentin Jule': 'Corentin Julé', - 'Claire C.C': 'Claire Cavanaugh', - 'Rahid': 'Maciej Wołczyk', - 'Fritz V155 Reichwald': 'Fritz Reichwald', - 'Spreadyy': 'sandrosc', - } - ignored = ['pyup-bot'] - commits = subprocess.check_output(['git', 'log', '--format=%aN']) - authors = [corrections.get(author, author) - for author in commits.decode('utf-8').splitlines() - if author not in ignored] - cnt = collections.Counter(authors) - return sorted(cnt, key=lambda k: (cnt[k], k), reverse=True) - - def _format_block(filename, what, data): """Format a block in a file. @@ -487,12 +460,6 @@ def _format_block(filename, what, data): shutil.move(tmpname, filename) -def regenerate_authors(filename): - """Re-generate the authors inside README based on the commits made.""" - data = ['* {}\n'.format(author) for author in _get_authors()] - _format_block(filename, 'authors', data) - - def regenerate_manpage(filename): """Update manpage OPTIONS using an argparse parser.""" # pylint: disable=protected-access @@ -538,9 +505,6 @@ def main(): generate_settings('doc/help/settings.asciidoc') print("Generating command help...") generate_commands('doc/help/commands.asciidoc') - if '--no-authors' not in sys.argv: - print("Generating authors in README...") - regenerate_authors('README.asciidoc') if '--cheatsheet' in sys.argv: print("Regenerating cheatsheet .pngs") regenerate_cheatsheet() diff --git a/scripts/open_url_in_instance.sh b/scripts/open_url_in_instance.sh new file mode 100755 index 000000000..119c3aa4f --- /dev/null +++ b/scripts/open_url_in_instance.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# initial idea: Florian Bruhin (The-Compiler) +# author: Thore Bödecker (foxxx0) + +_url="$1" +_qb_version='0.10.1' +_proto_version=1 +_ipc_socket="${XDG_RUNTIME_DIR}/qutebrowser/ipc-$(echo -n "$USER" | md5sum | cut -d' ' -f1)" + +if [[ -e "${_ipc_socket}" ]]; then + exec printf '{"args": ["%s"], "target_arg": null, "version": "%s", "protocol_version": %d, "cwd": "%s"}\n' \ + "${_url}" \ + "${_qb_version}" \ + "${_proto_version}" \ + "${PWD}" | socat - UNIX-CONNECT:"${_ipc_socket}" +else + exec /usr/bin/qutebrowser --backend webengine "$@" +fi diff --git a/tests/conftest.py b/tests/conftest.py index bebe4e76f..f5018de66 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,8 +52,8 @@ def _apply_platform_markers(config, item): ('posix', os.name != 'posix', "Requires a POSIX os"), ('windows', os.name != 'nt', "Requires Windows"), ('linux', not sys.platform.startswith('linux'), "Requires Linux"), - ('osx', sys.platform != 'darwin', "Requires OS X"), - ('not_osx', sys.platform == 'darwin', "Skipped on OS X"), + ('mac', sys.platform != 'darwin', "Requires macOS"), + ('not_mac', sys.platform == 'darwin', "Skipped on macOS"), ('not_frozen', getattr(sys, 'frozen', False), "Can't be run when frozen"), ('frozen', not getattr(sys, 'frozen', False), diff --git a/tests/end2end/conftest.py b/tests/end2end/conftest.py index 8dac6a41d..75c6845f4 100644 --- a/tests/end2end/conftest.py +++ b/tests/end2end/conftest.py @@ -149,7 +149,7 @@ def pytest_collection_modifyitems(config, items): not config.webengine and qtutils.is_qtwebkit_ng()), ('qtwebengine_flaky', 'Flaky with QtWebEngine', pytest.mark.skipif, config.webengine), - ('qtwebengine_osx_xfail', 'Fails on OS X with QtWebEngine', + ('qtwebengine_mac_xfail', 'Fails on macOS with QtWebEngine', pytest.mark.xfail, config.webengine and sys.platform == 'darwin'), ] diff --git a/tests/end2end/data/downloads/download with no title.html b/tests/end2end/data/downloads/download with no title.html new file mode 100644 index 000000000..da4352e59 --- /dev/null +++ b/tests/end2end/data/downloads/download with no title.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + </body> +</html> diff --git a/tests/end2end/data/downloads/qutebrowser.png b/tests/end2end/data/downloads/qutebrowser.png Binary files differnew file mode 100644 index 000000000..e8bbb6b56 --- /dev/null +++ b/tests/end2end/data/downloads/qutebrowser.png diff --git a/tests/end2end/features/completion.feature b/tests/end2end/features/completion.feature index 94c194e4f..e93518199 100644 --- a/tests/end2end/features/completion.feature +++ b/tests/end2end/features/completion.feature @@ -32,23 +32,23 @@ Feature: Using completion Scenario: Using command completion When I run :set-cmd-text : - Then the completion model should be CommandCompletionModel + Then the completion model should be command Scenario: Using help completion When I run :set-cmd-text -s :help - Then the completion model should be HelpCompletionModel + Then the completion model should be helptopic Scenario: Using quickmark completion When I run :set-cmd-text -s :quickmark-load - Then the completion model should be QuickmarkCompletionModel + Then the completion model should be quickmark Scenario: Using bookmark completion When I run :set-cmd-text -s :bookmark-load - Then the completion model should be BookmarkCompletionModel + Then the completion model should be bookmark Scenario: Using bind completion When I run :set-cmd-text -s :bind X - Then the completion model should be BindCompletionModel + Then the completion model should be bind Scenario: Using session completion Given I open data/hello.txt @@ -60,43 +60,13 @@ Feature: Using completion And I run :command-accept Then the error "Session hello not found!" should be shown - # FIXME:conf + Scenario: Using option completion + When I run :set-cmd-text -s :set colors + Then the completion model should be option - # Scenario: Using option completion - # When I run :set-cmd-text -s :set colors - # Then the completion model should be SettingOptionCompletionModel - - # Scenario: Using value completion - # When I run :set-cmd-text -s :set colors statusbar.bg - # Then the completion model should be SettingValueCompletionModel - - Scenario: Updating the completion in realtime - Given I have a fresh instance - And I set completion.quick to false - When I open data/hello.txt - And I run :set-cmd-text -s :buffer - And I run :completion-item-focus next - And I open data/hello2.txt in a new background tab - And I run :completion-item-focus next - And I open data/hello3.txt in a new background tab - And I run :completion-item-focus next - And I run :command-accept - Then the following tabs should be open: - - data/hello.txt - - data/hello2.txt - - data/hello3.txt (active) - - # FIXME:conf - - # Scenario: Updating the value completion in realtime - # Given I set colors.statusbar.normal.bg to green - # When I run :set-cmd-text -s :set colors.statusbar.normal.bg - # And I set colors.statusbar.normal.bg to yellow - # And I run :completion-item-focus next - # And I run :completion-item-focus next - # And I set colors.statusbar.normal.bg to red - # And I run :command-accept - # Then the option colors.statusbar.normal.bg should be set to yellow + Scenario: Using value completion + When I run :set-cmd-text -s :set colors statusbar.bg + Then the completion model should be value Scenario: Deleting an open tab via the completion Given I have a fresh instance diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py index ea910c578..70d25c2fe 100644 --- a/tests/end2end/features/conftest.py +++ b/tests/end2end/features/conftest.py @@ -61,8 +61,8 @@ def pytest_runtest_makereport(item, call): if (not hasattr(report.longrepr, 'addsection') or not hasattr(report, 'scenario')): - # In some conditions (on OS X and Windows it seems), report.longrepr is - # actually a tuple. This is handled similarily in pytest-qt too. + # In some conditions (on macOS and Windows it seems), report.longrepr + # is actually a tuple. This is handled similarily in pytest-qt too. # # Since this hook is invoked for any test, we also need to skip it for # non-BDD ones. diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature index e7c5a1446..3800cdb0b 100644 --- a/tests/end2end/features/downloads.feature +++ b/tests/end2end/features/downloads.feature @@ -22,6 +22,20 @@ Feature: Downloading things from a website. And I wait until the download is finished Then the downloaded file download.bin should exist + Scenario: Using :download with no URL + When I set storage -> prompt-download-directory to false + And I open data/downloads/downloads.html + And I run :download + And I wait until the download is finished + Then the downloaded file Simple downloads.html should exist + + Scenario: Using :download with no URL on an image + When I set storage -> prompt-download-directory to false + And I open data/downloads/qutebrowser.png + And I run :download + And I wait until the download is finished + Then the downloaded file qutebrowser.png should exist + Scenario: Using hints When I set downloads.location.prompt to false And I open data/downloads/downloads.html @@ -579,7 +593,6 @@ Feature: Downloading things from a website. And I wait until the download is finished Then the downloaded file content-size should exist - @posix Scenario: Downloading to unwritable destination When I set downloads.location.prompt to false And I run :download http://localhost:(port)/data/downloads/download.bin --dest (tmpdir)/downloads/unwritable @@ -637,7 +650,7 @@ Feature: Downloading things from a website. @qtwebengine_skip: We can't get the UA from the page there Scenario: user-agent when using :download When I open user-agent - And I run :download + And I run :download --dest user-agent And I wait until the download is finished Then the downloaded file user-agent should contain Safari/ diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index 4ebbfe6dd..e2d15e72d 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -243,7 +243,7 @@ Feature: Using hints ### hints.auto_follow.timeout - @not_osx + @not_mac Scenario: Ignoring key presses after auto-following hints When I set hints.auto_follow_timeout to 1000 And I set hints.mode to number diff --git a/tests/end2end/features/history.feature b/tests/end2end/features/history.feature index 51c12feb7..a340db429 100644 --- a/tests/end2end/features/history.feature +++ b/tests/end2end/features/history.feature @@ -11,44 +11,44 @@ Feature: Page history Scenario: Simple history saving When I open data/numbers/1.txt And I open data/numbers/2.txt - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/numbers/1.txt http://localhost:(port)/data/numbers/2.txt - + Scenario: History item with title When I open data/title.html - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/title.html Test title Scenario: History item with redirect When I open redirect-to?url=data/title.html without waiting And I wait until data/title.html is loaded - Then the history file should contain: + Then the history should contain: r http://localhost:(port)/redirect-to?url=data/title.html Test title http://localhost:(port)/data/title.html Test title - + Scenario: History item with spaces in URL When I open data/title with spaces.html - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/title%20with%20spaces.html Test title Scenario: History item with umlauts When I open data/äöü.html - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/%C3%A4%C3%B6%C3%BC.html Chäschüechli - + @flaky @qtwebengine_todo: Error page message is not implemented Scenario: History with an error When I run :open file:///does/not/exist And I wait for "Error while loading file:///does/not/exist: Error opening /does/not/exist: *" in the log - Then the history file should contain: + Then the history should contain: file:///does/not/exist Error loading page: file:///does/not/exist @qtwebengine_todo: Error page message is not implemented Scenario: History with a 404 When I open status/404 without waiting And I wait for "Error while loading http://localhost:*/status/404: NOT FOUND" in the log - Then the history file should contain: + Then the history should contain: http://localhost:(port)/status/404 Error loading page: http://localhost:(port)/status/404 Scenario: History with invalid URL @@ -61,32 +61,32 @@ Feature: Page history When I open data/data_link.html And I run :click-element id link And I wait until data:;base64,cXV0ZWJyb3dzZXI= is loaded - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/data_link.html data: link Scenario: History with view-source URL When I open data/title.html And I run :view-source And I wait for "Changing title for idx * to 'Source for http://localhost:*/data/title.html'" in the log - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/title.html Test title Scenario: Clearing history When I open data/title.html And I run :history-clear --force - Then the history file should be empty + Then the history should be empty Scenario: Clearing history with confirmation When I open data/title.html And I run :history-clear And I wait for "Asking question <* title='Clear all browsing history?'>, *" in the log And I run :prompt-accept yes - Then the history file should be empty + Then the history should be empty Scenario: History with yanked URL and 'add to history' flag When I open data/hints/html/simple.html And I hint with args "--add-history links yank" and follow a - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/hints/html/simple.html Simple link http://localhost:(port)/data/hello.txt diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index cd6504582..dfc1e0f73 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -278,7 +278,7 @@ Feature: Various utility commands. And I run :debug-pyeval QApplication.instance().activeModalWidget().close() Then no crash should happen - # On Windows/OS X, we get a "QPrintDialog: Cannot be used on non-native + # On Windows/macOS, we get a "QPrintDialog: Cannot be used on non-native # printers" qWarning. # # Disabled because it causes weird segfaults and QPainter warnings in Qt... @@ -532,3 +532,9 @@ Feature: Various utility commands. And I wait for "Renderer process was killed" in the log And I open data/numbers/3.txt Then no crash should happen + + ## Other + + Scenario: Open qute://version + When I open qute://version + Then the page should contain the plaintext "Version info" diff --git a/tests/end2end/features/prompts.feature b/tests/end2end/features/prompts.feature index b4b95835c..3b69db6f7 100644 --- a/tests/end2end/features/prompts.feature +++ b/tests/end2end/features/prompts.feature @@ -219,14 +219,14 @@ Feature: Prompts And I run :click-element id button Then the javascript message "geolocation permission denied" should be logged - @ci @not_osx @qt!=5.8 + @ci @not_mac @qt!=5.8 Scenario: Always accepting geolocation When I set content.geolocation to true And I open data/prompt/geolocation.html in a new tab And I run :click-element id button Then the javascript message "geolocation permission denied" should not be logged - @ci @not_osx @qt!=5.8 + @ci @not_mac @qt!=5.8 Scenario: geolocation with ask -> true When I set content.geolocation to ask And I open data/prompt/geolocation.html in a new tab diff --git a/tests/end2end/features/spawn.feature b/tests/end2end/features/spawn.feature index dc0485391..e2b5fdd5b 100644 --- a/tests/end2end/features/spawn.feature +++ b/tests/end2end/features/spawn.feature @@ -60,8 +60,3 @@ Feature: :spawn Scenario: Running :spawn with userscript that expects the stdin getting closed When I run :spawn -u (testdata)/userscripts/stdinclose.py Then the message "stdin closed" should be shown - - @posix - Scenario: Running :spawn -d with userscript that expects the stdin getting closed - When I run :spawn -d -u (testdata)/userscripts/stdinclose.py - Then the message "stdin closed" should be shown diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index 771215c0b..bcf7f15f0 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -1075,6 +1075,16 @@ Feature: Tab management - data/numbers/2.txt (pinned) - data/numbers/3.txt (active) + Scenario: :tab-pin with an invalid count + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new tab + And I open data/numbers/3.txt in a new tab + And I run :tab-pin with count 23 + Then the following tabs should be open: + - data/numbers/1.txt + - data/numbers/2.txt + - data/numbers/3.txt (active) + Scenario: Pinned :tab-close prompt yes When I open data/numbers/1.txt And I run :tab-pin diff --git a/tests/end2end/features/test_completion_bdd.py b/tests/end2end/features/test_completion_bdd.py index f4ada848f..82e2df030 100644 --- a/tests/end2end/features/test_completion_bdd.py +++ b/tests/end2end/features/test_completion_bdd.py @@ -24,5 +24,5 @@ bdd.scenarios('completion.feature') @bdd.then(bdd.parsers.parse("the completion model should be {model}")) def check_model(quteproc, model): """Make sure the completion model was set to something.""" - pattern = "Setting completion model to {} with pattern *".format(model) + pattern = "Starting {} completion *".format(model) quteproc.wait_for(message=pattern) diff --git a/tests/end2end/features/test_downloads_bdd.py b/tests/end2end/features/test_downloads_bdd.py index c5bd7cbeb..c42d281c4 100644 --- a/tests/end2end/features/test_downloads_bdd.py +++ b/tests/end2end/features/test_downloads_bdd.py @@ -21,6 +21,7 @@ import os import sys import shlex +import pytest import pytest_bdd as bdd bdd.scenarios('downloads.feature') @@ -53,6 +54,14 @@ def clean_old_downloads(quteproc): quteproc.send_cmd(':download-clear') +@bdd.when("the unwritable dir is unwritable") +def check_unwritable(tmpdir): + unwritable = tmpdir / 'downloads' / 'unwritable' + if os.access(str(unwritable), os.W_OK): + # Docker container or similar + pytest.skip("Unwritable dir was writable") + + @bdd.when("I wait until the download is finished") def wait_for_download_finished(quteproc): quteproc.wait_for(category='downloads', message='Download * finished') diff --git a/tests/end2end/features/test_history_bdd.py b/tests/end2end/features/test_history_bdd.py index 1fee533eb..319e36aee 100644 --- a/tests/end2end/features/test_history_bdd.py +++ b/tests/end2end/features/test_history_bdd.py @@ -17,36 +17,29 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. -import os.path +import logging +import re import pytest_bdd as bdd -bdd.scenarios('history.feature') +bdd.scenarios('history.feature') -@bdd.then(bdd.parsers.parse("the history file should contain:\n{expected}")) -def check_history(quteproc, httpbin, expected): - history_file = os.path.join(quteproc.basedir, 'data', 'history') - quteproc.send_cmd(':save history') - quteproc.wait_for(message=':save saved history') - expected = expected.replace('(port)', str(httpbin.port)).splitlines() +@bdd.then(bdd.parsers.parse("the history should contain:\n{expected}")) +def check_history(quteproc, httpbin, tmpdir, expected): + path = tmpdir / 'history' + quteproc.send_cmd(':debug-dump-history "{}"'.format(path)) + quteproc.wait_for(category='message', loglevel=logging.INFO, + message='Dumped history to {}'.format(path)) - with open(history_file, 'r', encoding='utf-8') as f: - lines = [] - for line in f: - if not line.strip(): - continue - print('history line: ' + line) - atime, line = line.split(' ', maxsplit=1) - line = line.rstrip() - if '-' in atime: - flags = atime.split('-')[1] - line = '{} {}'.format(flags, line) - lines.append(line) + with path.open('r', encoding='utf-8') as f: + # ignore access times, they will differ in each run + actual = '\n'.join(re.sub('^\\d+-?', '', line).strip() for line in f) - assert lines == expected + expected = expected.replace('(port)', str(httpbin.port)) + assert actual == expected -@bdd.then("the history file should be empty") -def check_history_empty(quteproc, httpbin): - check_history(quteproc, httpbin, '') +@bdd.then("the history should be empty") +def check_history_empty(quteproc, httpbin, tmpdir): + check_history(quteproc, httpbin, tmpdir, '') diff --git a/tests/end2end/features/yankpaste.feature b/tests/end2end/features/yankpaste.feature index 7c8cb3ca7..e38b0a8b0 100644 --- a/tests/end2end/features/yankpaste.feature +++ b/tests/end2end/features/yankpaste.feature @@ -291,7 +291,7 @@ Feature: Yanking and pasting. # Compare Then the javascript message "textarea contents: onHello worlde two three four" should be logged - @qtwebengine_osx_xfail + @qtwebengine_mac_xfail Scenario: Inserting text into a text field with undo When I set content.javascript.log to info And I open data/paste_primary.html diff --git a/tests/end2end/fixtures/testprocess.py b/tests/end2end/fixtures/testprocess.py index f94ec4e22..1a2c51baf 100644 --- a/tests/end2end/fixtures/testprocess.py +++ b/tests/end2end/fixtures/testprocess.py @@ -103,8 +103,8 @@ def pytest_runtest_makereport(item, call): httpbin_log = getattr(item, '_httpbin_log', None) if not hasattr(report.longrepr, 'addsection'): - # In some conditions (on OS X and Windows it seems), report.longrepr is - # actually a tuple. This is handled similarily in pytest-qt too. + # In some conditions (on macOS and Windows it seems), report.longrepr + # is actually a tuple. This is handled similarily in pytest-qt too. return if pytest.config.getoption('--capture') == 'no': diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index 5c5a7fbc2..5466b77f9 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -163,6 +163,7 @@ def test_optimize(request, quteproc_new, capfd, level): @pytest.mark.not_frozen +@pytest.mark.flaky # Fails sometimes with empty output... def test_version(request): """Test invocation with --version argument.""" args = ['-m', 'qutebrowser', '--version'] + _base_args(request.config) diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index 4a0f1e8dc..051b1f2a6 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -41,7 +41,7 @@ import helpers.stubs as stubsmod from qutebrowser.config import config, configdata from qutebrowser.utils import objreg, standarddir from qutebrowser.browser.webkit import cookies -from qutebrowser.misc import savemanager +from qutebrowser.misc import savemanager, sql from qutebrowser.keyinput import modeman from PyQt5.QtCore import PYQT_VERSION, pyqtSignal, QEvent, QSize, Qt, QObject @@ -265,17 +265,8 @@ def bookmark_manager_stub(stubs): @pytest.fixture -def web_history_stub(stubs): - """Fixture which provides a fake web-history object.""" - stub = stubs.WebHistoryStub() - objreg.register('web-history', stub) - yield stub - objreg.delete('web-history') - - -@pytest.fixture def session_manager_stub(stubs): - """Fixture which provides a fake web-history object.""" + """Fixture which provides a fake session-manager object.""" stub = stubs.SessionManagerStub() objreg.register('session-manager', stub) yield stub @@ -488,3 +479,37 @@ def short_tmpdir(): """A short temporary directory for a XDG_RUNTIME_DIR.""" with tempfile.TemporaryDirectory() as tdir: yield py.path.local(tdir) # pylint: disable=no-member + + +@pytest.fixture +def init_sql(data_tmpdir): + """Initialize the SQL module, and shut it down after the test.""" + path = str(data_tmpdir / 'test.db') + sql.init(path) + yield + sql.close() + + +class ModelValidator: + + """Validates completion models.""" + + def __init__(self, modeltester): + modeltester.data_display_may_return_none = True + self._model = None + self._modeltester = modeltester + + def set_model(self, model): + self._model = model + self._modeltester.check(model) + + def validate(self, expected): + assert self._model.rowCount() == len(expected) + for row, items in enumerate(expected): + for col, item in enumerate(items): + assert self._model.data(self._model.index(row, col)) == item + + +@pytest.fixture +def model_validator(qtmodeltester): + return ModelValidator(qtmodeltester) diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 06fc5ef39..8f8cd66bb 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -29,8 +29,9 @@ from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache, QNetworkCacheMetaData) from PyQt5.QtWidgets import QCommonStyle, QLineEdit, QWidget, QTabBar -from qutebrowser.browser import browsertab, history -from qutebrowser.utils import usertypes +from qutebrowser.browser import browsertab +from qutebrowser.config import configexc +from qutebrowser.utils import usertypes, utils from qutebrowser.mainwindow import mainwindow @@ -221,6 +222,24 @@ class FakeWebTabScroller(browsertab.AbstractScroller): return self._pos_perc +class FakeWebTabHistory(browsertab.AbstractHistory): + + """Fake for Web{Kit,Engine}History.""" + + def __init__(self, tab, *, can_go_back, can_go_forward): + super().__init__(tab) + self._can_go_back = can_go_back + self._can_go_forward = can_go_forward + + def can_go_back(self): + assert self._can_go_back is not None + return self._can_go_back + + def can_go_forward(self): + assert self._can_go_forward is not None + return self._can_go_forward + + class FakeWebTab(browsertab.AbstractTab): """Fake AbstractTab to use in tests.""" @@ -228,12 +247,14 @@ class FakeWebTab(browsertab.AbstractTab): def __init__(self, url=FakeUrl(), title='', tab_id=0, *, scroll_pos_perc=(0, 0), load_status=usertypes.LoadStatus.success, - progress=0): + progress=0, can_go_back=None, can_go_forward=None): super().__init__(win_id=0, mode_manager=None, private=False) self._load_status = load_status self._title = title self._url = url self._progress = progress + self.history = FakeWebTabHistory(self, can_go_back=can_go_back, + can_go_forward=can_go_forward) self.scroller = FakeWebTabScroller(self, scroll_pos_perc) wrapped = QWidget() self._layout.wrap(self, wrapped) @@ -385,6 +406,10 @@ class InstaTimer(QObject): def setInterval(self, interval): pass + @staticmethod + def singleShot(_interval, fun): + fun() + class FakeYamlConfig: @@ -454,24 +479,6 @@ class QuickmarkManagerStub(UrlMarkManagerStub): self.delete(key) -class WebHistoryStub(QObject): - - """Stub for the web-history object.""" - - add_completion_item = pyqtSignal(history.Entry) - cleared = pyqtSignal() - - def __init__(self, parent=None): - super().__init__(parent) - self.history_dict = collections.OrderedDict() - - def __iter__(self): - return iter(self.history_dict.values()) - - def __len__(self): - return len(self.history_dict) - - class HostBlockerStub: """Stub for the host-blocker object.""" @@ -536,7 +543,10 @@ class TabbedBrowserStub(QObject): return self.current_index def currentWidget(self): - return self.tabs[self.currentIndex() - 1] + idx = self.currentIndex() + if idx == -1: + return None + return self.tabs[idx - 1] def tabopen(self, url): self.opened_url = url diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py new file mode 100644 index 000000000..c109f44eb --- /dev/null +++ b/tests/unit/browser/test_history.py @@ -0,0 +1,351 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""Tests for the global page history.""" + +import logging + +import pytest +from PyQt5.QtCore import QUrl + +from qutebrowser.browser import history +from qutebrowser.utils import objreg, urlutils, usertypes +from qutebrowser.commands import cmdexc + + +@pytest.fixture(autouse=True) +def prerequisites(config_stub, fake_save_manager, init_sql): + """Make sure everything is ready to initialize a WebHistory.""" + config_stub.data = {'general': {'private-browsing': False}} + + +@pytest.fixture() +def hist(tmpdir): + return history.WebHistory() + + +@pytest.fixture() +def mock_time(mocker): + m = mocker.patch('qutebrowser.browser.history.time') + m.time.return_value = 12345 + return 12345 + + +def test_iter(hist): + urlstr = 'http://www.example.com/' + url = QUrl(urlstr) + hist.add_url(url, atime=12345) + + assert list(hist) == [(urlstr, '', 12345, False)] + + +def test_len(hist): + assert len(hist) == 0 + + url = QUrl('http://www.example.com/') + hist.add_url(url) + + assert len(hist) == 1 + + +def test_contains(hist): + hist.add_url(QUrl('http://www.example.com/'), title='Title', atime=12345) + assert 'http://www.example.com/' in hist + assert 'www.example.com' not in hist + assert 'Title' not in hist + assert 12345 not in hist + + +def test_get_recent(hist): + hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890) + hist.add_url(QUrl('http://example.com/'), atime=12345) + assert list(hist.get_recent()) == [ + ('http://www.qutebrowser.org/', '', 67890, False), + ('http://example.com/', '', 12345, False), + ] + + +def test_entries_between(hist): + hist.add_url(QUrl('http://www.example.com/1'), atime=12345) + hist.add_url(QUrl('http://www.example.com/2'), atime=12346) + hist.add_url(QUrl('http://www.example.com/3'), atime=12347) + hist.add_url(QUrl('http://www.example.com/4'), atime=12348) + hist.add_url(QUrl('http://www.example.com/5'), atime=12348) + hist.add_url(QUrl('http://www.example.com/6'), atime=12349) + hist.add_url(QUrl('http://www.example.com/7'), atime=12350) + + times = [x.atime for x in hist.entries_between(12346, 12349)] + assert times == [12349, 12348, 12348, 12347] + + +def test_entries_before(hist): + hist.add_url(QUrl('http://www.example.com/1'), atime=12346) + hist.add_url(QUrl('http://www.example.com/2'), atime=12346) + hist.add_url(QUrl('http://www.example.com/3'), atime=12347) + hist.add_url(QUrl('http://www.example.com/4'), atime=12348) + hist.add_url(QUrl('http://www.example.com/5'), atime=12348) + hist.add_url(QUrl('http://www.example.com/6'), atime=12348) + hist.add_url(QUrl('http://www.example.com/7'), atime=12349) + hist.add_url(QUrl('http://www.example.com/8'), atime=12349) + + times = [x.atime for x in hist.entries_before(12348, limit=3, offset=2)] + assert times == [12348, 12347, 12346] + + +def test_clear(qtbot, tmpdir, hist, mocker): + hist.add_url(QUrl('http://example.com/')) + hist.add_url(QUrl('http://www.qutebrowser.org/')) + + m = mocker.patch('qutebrowser.browser.history.message.confirm_async', + new=mocker.Mock, spec=[]) + hist.clear() + assert m.called + + +def test_clear_force(qtbot, tmpdir, hist): + hist.add_url(QUrl('http://example.com/')) + hist.add_url(QUrl('http://www.qutebrowser.org/')) + hist.clear(force=True) + assert not len(hist) + assert not len(hist.completion) + + +def test_delete_url(hist): + hist.add_url(QUrl('http://example.com/'), atime=0) + hist.add_url(QUrl('http://example.com/1'), atime=0) + hist.add_url(QUrl('http://example.com/2'), atime=0) + + before = set(hist) + completion_before = set(hist.completion) + + hist.delete_url(QUrl('http://example.com/1')) + + diff = before.difference(set(hist)) + assert diff == {('http://example.com/1', '', 0, False)} + + completion_diff = completion_before.difference(set(hist.completion)) + assert completion_diff == {('http://example.com/1', '', 0)} + + +@pytest.mark.parametrize('url, atime, title, redirect, expected_url', [ + ('http://www.example.com', 12346, 'the title', False, + 'http://www.example.com'), + ('http://www.example.com', 12346, 'the title', True, + 'http://www.example.com'), + ('http://www.example.com/spa ce', 12346, 'the title', False, + 'http://www.example.com/spa%20ce'), + ('https://user:pass@example.com', 12346, 'the title', False, + 'https://user@example.com'), +]) +def test_add_url(qtbot, hist, url, atime, title, redirect, expected_url): + hist.add_url(QUrl(url), atime=atime, title=title, redirect=redirect) + assert list(hist) == [(expected_url, title, atime, redirect)] + if redirect: + assert not len(hist.completion) + else: + assert list(hist.completion) == [(expected_url, title, atime)] + + +def test_add_url_invalid(qtbot, hist, caplog): + with caplog.at_level(logging.WARNING): + hist.add_url(QUrl()) + assert not list(hist) + assert not list(hist.completion) + + +@pytest.mark.parametrize('level, url, req_url, expected', [ + (logging.DEBUG, 'a.com', 'a.com', [('a.com', 'title', 12345, False)]), + (logging.DEBUG, 'a.com', 'b.com', [('a.com', 'title', 12345, False), + ('b.com', 'title', 12345, True)]), + (logging.WARNING, 'a.com', '', [('a.com', 'title', 12345, False)]), + (logging.WARNING, '', '', []), + (logging.WARNING, 'data:foo', '', []), + (logging.WARNING, 'a.com', 'data:foo', []), +]) +def test_add_from_tab(hist, level, url, req_url, expected, mock_time, caplog): + with caplog.at_level(level): + hist.add_from_tab(QUrl(url), QUrl(req_url), 'title') + assert set(hist) == set(expected) + + +@pytest.fixture +def hist_interface(hist): + # pylint: disable=invalid-name + QtWebKit = pytest.importorskip('PyQt5.QtWebKit') + from qutebrowser.browser.webkit import webkithistory + QWebHistoryInterface = QtWebKit.QWebHistoryInterface + # pylint: enable=invalid-name + hist.add_url(url=QUrl('http://www.example.com/'), title='example') + interface = webkithistory.WebHistoryInterface(hist) + QWebHistoryInterface.setDefaultInterface(interface) + yield + QWebHistoryInterface.setDefaultInterface(None) + + +def test_history_interface(qtbot, webview, hist_interface): + html = b"<a href='about:blank'>foo</a>" + url = urlutils.data_url('text/html', html) + with qtbot.waitSignal(webview.loadFinished): + webview.load(url) + + +@pytest.fixture +def cleanup_init(): + # prevent test_init from leaking state + yield + hist = objreg.get('web-history', None) + if hist is not None: + hist.setParent(None) + objreg.delete('web-history') + try: + from PyQt5.QtWebKit import QWebHistoryInterface + QWebHistoryInterface.setDefaultInterface(None) + except ImportError: + pass + + +@pytest.mark.parametrize('backend', [usertypes.Backend.QtWebEngine, + usertypes.Backend.QtWebKit]) +def test_init(backend, qapp, tmpdir, monkeypatch, cleanup_init): + if backend == usertypes.Backend.QtWebKit: + pytest.importorskip('PyQt5.QtWebKitWidgets') + else: + assert backend == usertypes.Backend.QtWebEngine + + monkeypatch.setattr(history.objects, 'backend', backend) + history.init(qapp) + hist = objreg.get('web-history') + assert hist.parent() is qapp + + try: + from PyQt5.QtWebKit import QWebHistoryInterface + except ImportError: + QWebHistoryInterface = None + + if backend == usertypes.Backend.QtWebKit: + default_interface = QWebHistoryInterface.defaultInterface() + assert default_interface._history is hist + else: + assert backend == usertypes.Backend.QtWebEngine + if QWebHistoryInterface is None: + default_interface = None + else: + default_interface = QWebHistoryInterface.defaultInterface() + # For this to work, nothing can ever have called setDefaultInterface + # before (so we need to test webengine before webkit) + assert default_interface is None + + +def test_import_txt(hist, data_tmpdir, monkeypatch, stubs): + monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer) + histfile = data_tmpdir / 'history' + # empty line is deliberate, to test skipping empty lines + histfile.write('''12345 http://example.com/ title + 12346 http://qutebrowser.org/ + 67890 http://example.com/path + + 68891-r http://example.com/path/other ''') + + hist.import_txt() + + assert list(hist) == [ + ('http://example.com/', 'title', 12345, False), + ('http://qutebrowser.org/', '', 12346, False), + ('http://example.com/path', '', 67890, False), + ('http://example.com/path/other', '', 68891, True) + ] + + assert not histfile.exists() + assert (data_tmpdir / 'history.bak').exists() + + +@pytest.mark.parametrize('line', [ + '', + '#12345 http://example.com/commented', + + # https://bugreports.qt.io/browse/QTBUG-60364 + '12345 http://.com/', + '12345 https://.com/', + '12345 http://www..com/', + '12345 https://www..com/', + + # issue #2646 + '12345 data:text/html;charset=UTF-8,%3C%21DOCTYPE%20html%20PUBLIC%20%22-', +]) +def test_import_txt_skip(hist, data_tmpdir, line, monkeypatch, stubs): + """import_txt should skip certain lines silently.""" + monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer) + histfile = data_tmpdir / 'history' + histfile.write(line) + + hist.import_txt() + + assert not histfile.exists() + assert not len(hist) + + +@pytest.mark.parametrize('line', [ + 'xyz http://example.com/bad-timestamp', + '12345', + 'http://example.com/no-timestamp', + '68891-r-r http://example.com/double-flag', + '68891-x http://example.com/bad-flag', + '68891 http://.com', +]) +def test_import_txt_invalid(hist, data_tmpdir, line, monkeypatch, stubs, + caplog): + """import_txt should fail on certain lines.""" + monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer) + histfile = data_tmpdir / 'history' + histfile.write(line) + + with caplog.at_level(logging.ERROR): + hist.import_txt() + + assert any(rec.msg.startswith("Failed to import history:") + for rec in caplog.records) + + assert histfile.exists() + + +def test_import_txt_nonexistent(hist, data_tmpdir, monkeypatch, stubs): + """import_txt should do nothing if the history file doesn't exist.""" + monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer) + hist.import_txt() + + +def test_debug_dump_history(hist, tmpdir): + hist.add_url(QUrl('http://example.com/1'), title="Title1", atime=12345) + hist.add_url(QUrl('http://example.com/2'), title="Title2", atime=12346) + hist.add_url(QUrl('http://example.com/3'), title="Title3", atime=12347) + hist.add_url(QUrl('http://example.com/4'), title="Title4", atime=12348, + redirect=True) + histfile = tmpdir / 'history' + hist.debug_dump_history(str(histfile)) + expected = ['12345 http://example.com/1 Title1', + '12346 http://example.com/2 Title2', + '12347 http://example.com/3 Title3', + '12348-r http://example.com/4 Title4'] + assert histfile.read() == '\n'.join(expected) + + +def test_debug_dump_history_nonexistent(hist, tmpdir): + histfile = tmpdir / 'nonexistent' / 'history' + with pytest.raises(cmdexc.CommandError): + hist.debug_dump_history(str(histfile)) diff --git a/tests/unit/browser/test_qutescheme.py b/tests/unit/browser/test_qutescheme.py index 92ad30574..693b2607c 100644 --- a/tests/unit/browser/test_qutescheme.py +++ b/tests/unit/browser/test_qutescheme.py @@ -89,16 +89,17 @@ class TestHistoryHandler: items = [] for i in range(entry_count): entry_atime = now - i * interval - entry = history.Entry(atime=str(entry_atime), - url=QUrl("www.x.com/" + str(i)), title="Page " + str(i)) + entry = {"atime": str(entry_atime), + "url": QUrl("www.x.com/" + str(i)), + "title": "Page " + str(i)} items.insert(0, entry) return items @pytest.fixture - def fake_web_history(self, fake_save_manager, tmpdir): + def fake_web_history(self, fake_save_manager, tmpdir, init_sql): """Create a fake web-history and register it into objreg.""" - web_history = history.WebHistory(tmpdir.dirname, 'fake-history') + web_history = history.WebHistory() objreg.register('web-history', web_history) yield web_history objreg.delete('web-history') @@ -107,8 +108,7 @@ class TestHistoryHandler: def fake_history(self, fake_web_history, entries): """Create fake history.""" for item in entries: - fake_web_history._add_entry(item) - fake_web_history.save() + fake_web_history.add_url(**item) @pytest.mark.parametrize("start_time_offset, expected_item_count", [ (0, 4), @@ -123,45 +123,25 @@ class TestHistoryHandler: url = QUrl("qute://history/data?start_time=" + str(start_time)) _mimetype, data = qutescheme.qute_history(url) items = json.loads(data) - items = [item for item in items if 'time' in item] # skip 'next' item assert len(items) == expected_item_count # test times end_time = start_time - 24*60*60 for item in items: - assert item['time'] <= start_time * 1000 - assert item['time'] > end_time * 1000 - - @pytest.mark.parametrize("start_time_offset, next_time", [ - (0, 24*60*60), - (24*60*60, 48*60*60), - (48*60*60, -1), - (72*60*60, -1) - ]) - def test_qutehistory_next(self, start_time_offset, next_time, now): - """Ensure qute://history/data returns correct items.""" - start_time = now - start_time_offset - url = QUrl("qute://history/data?start_time=" + str(start_time)) - _mimetype, data = qutescheme.qute_history(url) - items = json.loads(data) - items = [item for item in items if 'next' in item] # 'next' items - assert len(items) == 1 - - if next_time == -1: - assert items[0]["next"] == -1 - else: - assert items[0]["next"] == now - next_time + assert item['time'] <= start_time + assert item['time'] > end_time def test_qute_history_benchmark(self, fake_web_history, benchmark, now): - # items must be earliest-first to ensure history is sorted properly - for t in range(100000, 0, -1): # one history per second - entry = history.Entry( - atime=str(now - t), - url=QUrl('www.x.com/{}'.format(t)), - title='x at {}'.format(t)) - fake_web_history._add_entry(entry) - + r = range(100000) + entries = { + 'atime': [int(now - t) for t in r], + 'url': ['www.x.com/{}'.format(t) for t in r], + 'title': ['x at {}'.format(t) for t in r], + 'redirect': [False for _ in r], + } + + fake_web_history.insert_batch(entries) url = QUrl("qute://history/data?start_time={}".format(now)) _mimetype, data = benchmark(qutescheme.qute_history, url) assert len(json.loads(data)) > 1 diff --git a/tests/unit/browser/webkit/test_downloads.py b/tests/unit/browser/webkit/test_downloads.py index 59613776a..77a1b0298 100644 --- a/tests/unit/browser/webkit/test_downloads.py +++ b/tests/unit/browser/webkit/test_downloads.py @@ -29,6 +29,42 @@ def test_download_model(qapp, qtmodeltester, config_stub, cookiejar_and_cache): qtmodeltester.check(model) +@pytest.mark.parametrize('url, title, out', [ + ('http://qutebrowser.org/INSTALL.html', + 'Installing qutebrowser | qutebrowser', + 'Installing qutebrowser _ qutebrowser.html'), + ('http://qutebrowser.org/INSTALL.html', + 'Installing qutebrowser | qutebrowser.html', + 'Installing qutebrowser _ qutebrowser.html'), + ('http://qutebrowser.org/INSTALL.HTML', + 'Installing qutebrowser | qutebrowser', + 'Installing qutebrowser _ qutebrowser.html'), + ('http://qutebrowser.org/INSTALL.html', + 'Installing qutebrowser | qutebrowser.HTML', + 'Installing qutebrowser _ qutebrowser.HTML'), + ('http://qutebrowser.org/', + 'qutebrowser | qutebrowser', + 'qutebrowser _ qutebrowser.html'), + ('https://github.com/qutebrowser/qutebrowser/releases', + 'Releases · qutebrowser/qutebrowser', + 'Releases · qutebrowser_qutebrowser.html'), + ('http://qutebrowser.org/index.php', + 'qutebrowser | qutebrowser', + 'qutebrowser _ qutebrowser.html'), + ('http://qutebrowser.org/index.php', + 'qutebrowser | qutebrowser - index.php', + 'qutebrowser _ qutebrowser - index.php.html'), + ('https://qutebrowser.org/img/cheatsheet-big.png', + 'cheatsheet-big.png (3342×2060)', + None), + ('http://qutebrowser.org/page-with-no-title.html', + '', + None), +]) +def test_page_titles(url, title, out): + assert downloads.suggested_fn_from_title(url, title) == out + + class TestDownloadTarget: def test_base(self): diff --git a/tests/unit/browser/webkit/test_history.py b/tests/unit/browser/webkit/test_history.py deleted file mode 100644 index f40e41c2c..000000000 --- a/tests/unit/browser/webkit/test_history.py +++ /dev/null @@ -1,383 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2016-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org> -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. - -"""Tests for the global page history.""" - -import logging - -import pytest -import hypothesis -from hypothesis import strategies -from PyQt5.QtCore import QUrl - -from qutebrowser.browser import history -from qutebrowser.utils import objreg, urlutils, usertypes - - -class FakeWebHistory: - - """A fake WebHistory object.""" - - def __init__(self, history_dict): - self.history_dict = history_dict - - -@pytest.fixture() -def hist(tmpdir, fake_save_manager): - return history.WebHistory(hist_dir=str(tmpdir), hist_name='history') - - -def test_async_read_twice(monkeypatch, qtbot, tmpdir, caplog, - fake_save_manager): - (tmpdir / 'filled-history').write('\n'.join([ - '12345 http://example.com/ title', - '67890 http://example.com/', - '12345 http://qutebrowser.org/ blah', - ])) - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - next(hist.async_read()) - with pytest.raises(StopIteration): - next(hist.async_read()) - expected = "Ignoring async_read() because reading is started." - assert len(caplog.records) == 1 - assert caplog.records[0].msg == expected - - -@pytest.mark.parametrize('redirect', [True, False]) -def test_adding_item_during_async_read(qtbot, hist, redirect): - """Check what happens when adding URL while reading the history.""" - url = QUrl('http://www.example.com/') - - with qtbot.assertNotEmitted(hist.add_completion_item), \ - qtbot.assertNotEmitted(hist.item_added): - hist.add_url(url, redirect=redirect, atime=12345) - - if redirect: - with qtbot.assertNotEmitted(hist.add_completion_item): - with qtbot.waitSignal(hist.async_read_done): - list(hist.async_read()) - else: - with qtbot.waitSignals([hist.add_completion_item, - hist.async_read_done], order='strict'): - list(hist.async_read()) - - assert not hist._temp_history - - expected = history.Entry(url=url, atime=12345, redirect=redirect, title="") - assert list(hist.history_dict.values()) == [expected] - - -def test_iter(hist): - list(hist.async_read()) - - url = QUrl('http://www.example.com/') - hist.add_url(url, atime=12345) - - entry = history.Entry(url=url, atime=12345, redirect=False, title="") - assert list(hist) == [entry] - - -def test_len(hist): - assert len(hist) == 0 - list(hist.async_read()) - - url = QUrl('http://www.example.com/') - hist.add_url(url) - - assert len(hist) == 1 - - -@pytest.mark.parametrize('line', [ - '12345 http://example.com/ title', # with title - '67890 http://example.com/', # no title - '12345 http://qutebrowser.org/ ', # trailing space - ' ', - '', -]) -def test_read(hist, tmpdir, line): - (tmpdir / 'filled-history').write(line + '\n') - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - -def test_updated_entries(hist, tmpdir): - (tmpdir / 'filled-history').write('12345 http://example.com/\n' - '67890 http://example.com/\n') - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - assert hist.history_dict['http://example.com/'].atime == 67890 - hist.add_url(QUrl('http://example.com/'), atime=99999) - assert hist.history_dict['http://example.com/'].atime == 99999 - - -def test_invalid_read(hist, tmpdir, caplog): - (tmpdir / 'filled-history').write('foobar\n12345 http://example.com/') - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - with caplog.at_level(logging.WARNING): - list(hist.async_read()) - - entries = list(hist.history_dict.values()) - - assert len(entries) == 1 - assert len(caplog.records) == 1 - msg = "Invalid history entry 'foobar': 2 or 3 fields expected!" - assert caplog.records[0].msg == msg - - -def test_get_recent(hist, tmpdir): - (tmpdir / 'filled-history').write('12345 http://example.com/') - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890) - lines = hist.get_recent() - - expected = ['12345 http://example.com/', - '67890 http://www.qutebrowser.org/'] - assert lines == expected - - -def test_save(hist, tmpdir): - hist_file = tmpdir / 'filled-history' - hist_file.write('12345 http://example.com/\n') - - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890) - hist.save() - - lines = hist_file.read().splitlines() - expected = ['12345 http://example.com/', - '67890 http://www.qutebrowser.org/'] - assert lines == expected - - hist.add_url(QUrl('http://www.the-compiler.org/'), atime=99999) - hist.save() - expected.append('99999 http://www.the-compiler.org/') - - lines = hist_file.read().splitlines() - assert lines == expected - - -def test_clear(qtbot, hist, tmpdir): - hist_file = tmpdir / 'filled-history' - hist_file.write('12345 http://example.com/\n') - - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - hist.add_url(QUrl('http://www.qutebrowser.org/')) - - with qtbot.waitSignal(hist.cleared): - hist._do_clear() - - assert not hist_file.read() - assert not hist.history_dict - assert not hist._new_history - - hist.add_url(QUrl('http://www.the-compiler.org/'), atime=67890) - hist.save() - - lines = hist_file.read().splitlines() - assert lines == ['67890 http://www.the-compiler.org/'] - - -def test_add_item(qtbot, hist): - list(hist.async_read()) - url = 'http://www.example.com/' - - with qtbot.waitSignals([hist.add_completion_item, hist.item_added], - order='strict'): - hist.add_url(QUrl(url), atime=12345, title="the title") - - entry = history.Entry(url=QUrl(url), redirect=False, atime=12345, - title="the title") - assert hist.history_dict[url] == entry - - -def test_add_item_redirect(qtbot, hist): - list(hist.async_read()) - url = 'http://www.example.com/' - with qtbot.assertNotEmitted(hist.add_completion_item): - with qtbot.waitSignal(hist.item_added): - hist.add_url(QUrl(url), redirect=True, atime=12345) - - entry = history.Entry(url=QUrl(url), redirect=True, atime=12345, title="") - assert hist.history_dict[url] == entry - - -def test_add_item_redirect_update(qtbot, tmpdir, fake_save_manager): - """A redirect update added should override a non-redirect one.""" - url = 'http://www.example.com/' - - hist_file = tmpdir / 'filled-history' - hist_file.write('12345 {}\n'.format(url)) - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - with qtbot.assertNotEmitted(hist.add_completion_item): - with qtbot.waitSignal(hist.item_added): - hist.add_url(QUrl(url), redirect=True, atime=67890) - - entry = history.Entry(url=QUrl(url), redirect=True, atime=67890, title="") - assert hist.history_dict[url] == entry - - -@pytest.mark.parametrize('line, expected', [ - ( - # old format without title - '12345 http://example.com/', - history.Entry(atime=12345, url=QUrl('http://example.com/'), title='',) - ), - ( - # trailing space without title - '12345 http://example.com/ ', - history.Entry(atime=12345, url=QUrl('http://example.com/'), title='',) - ), - ( - # new format with title - '12345 http://example.com/ this is a title', - history.Entry(atime=12345, url=QUrl('http://example.com/'), - title='this is a title') - ), - ( - # weird NUL bytes - '\x0012345 http://example.com/', - history.Entry(atime=12345, url=QUrl('http://example.com/'), title=''), - ), - ( - # redirect flag - '12345-r http://example.com/ this is a title', - history.Entry(atime=12345, url=QUrl('http://example.com/'), - title='this is a title', redirect=True) - ), -]) -def test_entry_parse_valid(line, expected): - entry = history.Entry.from_str(line) - assert entry == expected - - -@pytest.mark.parametrize('line', [ - '12345', # one field - '12345 ::', # invalid URL - 'xyz http://www.example.com/', # invalid timestamp - '12345-x http://www.example.com/', # invalid flags - '12345-r-r http://www.example.com/', # double flags -]) -def test_entry_parse_invalid(line): - with pytest.raises(ValueError): - history.Entry.from_str(line) - - -@hypothesis.given(strategies.text()) -def test_entry_parse_hypothesis(text): - """Make sure parsing works or gives us ValueError.""" - try: - history.Entry.from_str(text) - except ValueError: - pass - - -@pytest.mark.parametrize('entry, expected', [ - # simple - ( - history.Entry(12345, QUrl('http://example.com/'), "the title"), - "12345 http://example.com/ the title", - ), - # timestamp as float - ( - history.Entry(12345.678, QUrl('http://example.com/'), "the title"), - "12345 http://example.com/ the title", - ), - # no title - ( - history.Entry(12345.678, QUrl('http://example.com/'), ""), - "12345 http://example.com/", - ), - # redirect flag - ( - history.Entry(12345.678, QUrl('http://example.com/'), "", - redirect=True), - "12345-r http://example.com/", - ), -]) -def test_entry_str(entry, expected): - assert str(entry) == expected - - -@pytest.fixture -def hist_interface(): - # pylint: disable=invalid-name - QtWebKit = pytest.importorskip('PyQt5.QtWebKit') - from qutebrowser.browser.webkit import webkithistory - QWebHistoryInterface = QtWebKit.QWebHistoryInterface - # pylint: enable=invalid-name - entry = history.Entry(atime=0, url=QUrl('http://www.example.com/'), - title='example') - history_dict = {'http://www.example.com/': entry} - fake_hist = FakeWebHistory(history_dict) - interface = webkithistory.WebHistoryInterface(fake_hist) - QWebHistoryInterface.setDefaultInterface(interface) - yield - QWebHistoryInterface.setDefaultInterface(None) - - -def test_history_interface(qtbot, webview, hist_interface): - html = b"<a href='about:blank'>foo</a>" - url = urlutils.data_url('text/html', html) - with qtbot.waitSignal(webview.loadFinished): - webview.load(url) - - -@pytest.mark.parametrize('backend', [usertypes.Backend.QtWebEngine, - usertypes.Backend.QtWebKit]) -def test_init(backend, qapp, tmpdir, monkeypatch, fake_save_manager): - if backend == usertypes.Backend.QtWebKit: - pytest.importorskip('PyQt5.QtWebKitWidgets') - else: - assert backend == usertypes.Backend.QtWebEngine - - monkeypatch.setattr(history.standarddir, 'data', lambda: str(tmpdir)) - monkeypatch.setattr(history.objects, 'backend', backend) - history.init(qapp) - hist = objreg.get('web-history') - assert hist.parent() is qapp - - try: - from PyQt5.QtWebKit import QWebHistoryInterface - except ImportError: - QWebHistoryInterface = None - - if backend == usertypes.Backend.QtWebKit: - default_interface = QWebHistoryInterface.defaultInterface() - assert default_interface._history is hist - else: - assert backend == usertypes.Backend.QtWebEngine - if QWebHistoryInterface is None: - default_interface = None - else: - default_interface = QWebHistoryInterface.defaultInterface() - # For this to work, nothing can ever have called setDefaultInterface - # before (so we need to test webengine before webkit) - assert default_interface is None - - assert fake_save_manager.add_saveable.called - objreg.delete('web-history') diff --git a/tests/unit/completion/test_column_widths.py b/tests/unit/completion/test_column_widths.py deleted file mode 100644 index 21456ed37..000000000 --- a/tests/unit/completion/test_column_widths.py +++ /dev/null @@ -1,50 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2015-2017 Alexander Cogneau <alexander.cogneau@gmail.com> -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. - -"""Tests for qutebrowser.completion.models column widths.""" - -import pytest - -from qutebrowser.completion.models.base import BaseCompletionModel -from qutebrowser.completion.models.configmodel import ( - SettingOptionCompletionModel, SettingSectionCompletionModel, - SettingValueCompletionModel) -from qutebrowser.completion.models.miscmodels import ( - CommandCompletionModel, HelpCompletionModel, QuickmarkCompletionModel, - BookmarkCompletionModel, SessionCompletionModel) -from qutebrowser.completion.models.urlmodel import UrlCompletionModel - - -CLASSES = [BaseCompletionModel, SettingOptionCompletionModel, - SettingOptionCompletionModel, SettingSectionCompletionModel, - SettingValueCompletionModel, CommandCompletionModel, - HelpCompletionModel, QuickmarkCompletionModel, - BookmarkCompletionModel, SessionCompletionModel, UrlCompletionModel] - - -@pytest.mark.parametrize("model", CLASSES) -def test_list_size(model): - """Test if there are 3 items in the COLUMN_WIDTHS property.""" - assert len(model.COLUMN_WIDTHS) == 3 - - -@pytest.mark.parametrize("model", CLASSES) -def test_column_width_sum(model): - """Test if the sum of the widths asserts to 100.""" - assert sum(model.COLUMN_WIDTHS) == 100 diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py index 80e3cd05a..74b2e51f5 100644 --- a/tests/unit/completion/test_completer.py +++ b/tests/unit/completion/test_completer.py @@ -26,7 +26,6 @@ from PyQt5.QtCore import QObject from PyQt5.QtGui import QStandardItemModel from qutebrowser.completion import completer -from qutebrowser.utils import usertypes from qutebrowser.commands import command, cmdutils @@ -38,11 +37,10 @@ class FakeCompletionModel(QStandardItemModel): """Stub for a completion model.""" - DUMB_SORT = None - - def __init__(self, kind, parent=None): + def __init__(self, kind, *pos_args, parent=None): super().__init__(parent) self.kind = kind + self.pos_args = list(pos_args) class CompletionWidgetStub(QObject): @@ -74,39 +72,45 @@ def completer_obj(qtbot, status_command_stub, config_stub, monkeypatch, stubs, @pytest.fixture(autouse=True) -def instances(monkeypatch): - """Mock the instances module so get returns a fake completion model.""" - # populate a model for each completion type, with a nested structure for - # option and value completion - instances = {kind: FakeCompletionModel(kind) - for kind in usertypes.Completion} - instances[usertypes.Completion.option] = { - 'general': FakeCompletionModel(usertypes.Completion.option), - } - instances[usertypes.Completion.value] = { - 'general': { - 'editor': FakeCompletionModel(usertypes.Completion.value), - } - } - monkeypatch.setattr(completer, 'instances', instances) +def miscmodels_patch(mocker): + """Patch the miscmodels module to provide fake completion functions. + + Technically some of these are not part of miscmodels, but rolling them into + one module is easier and sufficient for mocking. The only one referenced + directly by Completer is miscmodels.command. + """ + m = mocker.patch('qutebrowser.completion.completer.miscmodels', + autospec=True) + m.command = lambda *args: FakeCompletionModel('command', *args) + m.helptopic = lambda *args: FakeCompletionModel('helptopic', *args) + m.quickmark = lambda *args: FakeCompletionModel('quickmark', *args) + m.bookmark = lambda *args: FakeCompletionModel('bookmark', *args) + m.session = lambda *args: FakeCompletionModel('session', *args) + m.buffer = lambda *args: FakeCompletionModel('buffer', *args) + m.bind = lambda *args: FakeCompletionModel('bind', *args) + m.url = lambda *args: FakeCompletionModel('url', *args) + m.section = lambda *args: FakeCompletionModel('section', *args) + m.option = lambda *args: FakeCompletionModel('option', *args) + m.value = lambda *args: FakeCompletionModel('value', *args) + return m @pytest.fixture(autouse=True) -def cmdutils_patch(monkeypatch, stubs): +def cmdutils_patch(monkeypatch, stubs, miscmodels_patch): """Patch the cmdutils module to provide fake commands.""" - @cmdutils.argument('section_', completion=usertypes.Completion.section) - @cmdutils.argument('option', completion=usertypes.Completion.option) - @cmdutils.argument('value', completion=usertypes.Completion.value) + @cmdutils.argument('section_', completion=miscmodels_patch.section) + @cmdutils.argument('option', completion=miscmodels_patch.option) + @cmdutils.argument('value', completion=miscmodels_patch.value) def set_command(section_=None, option=None, value=None): """docstring.""" pass - @cmdutils.argument('topic', completion=usertypes.Completion.helptopic) + @cmdutils.argument('topic', completion=miscmodels_patch.helptopic) def show_help(tab=False, bg=False, window=False, topic=None): """docstring.""" pass - @cmdutils.argument('url', completion=usertypes.Completion.url) + @cmdutils.argument('url', completion=miscmodels_patch.url) @cmdutils.argument('count', count=True) def openurl(url=None, related=False, bg=False, tab=False, window=False, count=None): @@ -114,7 +118,7 @@ def cmdutils_patch(monkeypatch, stubs): pass @cmdutils.argument('win_id', win_id=True) - @cmdutils.argument('command', completion=usertypes.Completion.command) + @cmdutils.argument('command', completion=miscmodels_patch.command) def bind(key, win_id, command=None, *, mode='normal', force=False): """docstring.""" pass @@ -144,60 +148,61 @@ def _set_cmd_prompt(cmd, txt): cmd.setCursorPosition(txt.index('|')) -@pytest.mark.parametrize('txt, kind, pattern', [ - (':nope|', usertypes.Completion.command, 'nope'), - (':nope |', None, ''), - (':set |', usertypes.Completion.section, ''), - (':set gen|', usertypes.Completion.section, 'gen'), - (':set general |', usertypes.Completion.option, ''), - (':set what |', None, ''), - (':set general editor |', usertypes.Completion.value, ''), - (':set general editor gv|', usertypes.Completion.value, 'gv'), - (':set general editor "gvim -f"|', usertypes.Completion.value, 'gvim -f'), - (':set general editor "gvim |', usertypes.Completion.value, 'gvim'), - (':set general huh |', None, ''), - (':help |', usertypes.Completion.helptopic, ''), - (':help |', usertypes.Completion.helptopic, ''), - (':open |', usertypes.Completion.url, ''), - (':bind |', None, ''), - (':bind <c-x> |', usertypes.Completion.command, ''), - (':bind <c-x> foo|', usertypes.Completion.command, 'foo'), - (':bind <c-x>| foo', None, '<c-x>'), - (':set| general ', usertypes.Completion.command, 'set'), - (':|set general ', usertypes.Completion.command, 'set'), - (':set gene|ral ignore-case', usertypes.Completion.section, 'general'), - (':|', usertypes.Completion.command, ''), - (': |', usertypes.Completion.command, ''), - ('/|', None, ''), - (':open -t|', None, ''), - (':open --tab|', None, ''), - (':open -t |', usertypes.Completion.url, ''), - (':open --tab |', usertypes.Completion.url, ''), - (':open | -t', usertypes.Completion.url, ''), - (':tab-detach |', None, ''), - (':bind --mode=caret <c-x> |', usertypes.Completion.command, ''), - pytest.param(':bind --mode caret <c-x> |', usertypes.Completion.command, - '', marks=pytest.mark.xfail(reason='issue #74')), - (':set -t -p |', usertypes.Completion.section, ''), - (':open -- |', None, ''), - (':gibberish nonesense |', None, ''), - ('/:help|', None, ''), - ('::bind|', usertypes.Completion.command, ':bind'), +@pytest.mark.parametrize('txt, kind, pattern, pos_args', [ + (':nope|', 'command', 'nope', []), + (':nope |', None, '', []), + (':set |', 'section', '', []), + (':set gen|', 'section', 'gen', []), + (':set general |', 'option', '', ['general']), + (':set what |', 'option', '', ['what']), + (':set general editor |', 'value', '', ['general', 'editor']), + (':set general editor gv|', 'value', 'gv', ['general', 'editor']), + (':set general editor "gvim -f"|', 'value', 'gvim -f', + ['general', 'editor']), + (':set general editor "gvim |', 'value', 'gvim', ['general', 'editor']), + (':set general huh |', 'value', '', ['general', 'huh']), + (':help |', 'helptopic', '', []), + (':help |', 'helptopic', '', []), + (':open |', 'url', '', []), + (':bind |', None, '', []), + (':bind <c-x> |', 'command', '', ['<c-x>']), + (':bind <c-x> foo|', 'command', 'foo', ['<c-x>']), + (':bind <c-x>| foo', None, '<c-x>', []), + (':set| general ', 'command', 'set', []), + (':|set general ', 'command', 'set', []), + (':set gene|ral ignore-case', 'section', 'general', []), + (':|', 'command', '', []), + (': |', 'command', '', []), + ('/|', None, '', []), + (':open -t|', None, '', []), + (':open --tab|', None, '', []), + (':open -t |', 'url', '', []), + (':open --tab |', 'url', '', []), + (':open | -t', 'url', '', []), + (':tab-detach |', None, '', []), + (':bind --mode=caret <c-x> |', 'command', '', ['<c-x>']), + pytest.param(':bind --mode caret <c-x> |', 'command', '', [], + marks=pytest.mark.xfail(reason='issue #74')), + (':set -t -p |', 'section', '', []), + (':open -- |', None, '', []), + (':gibberish nonesense |', None, '', []), + ('/:help|', None, '', []), + ('::bind|', 'command', ':bind', []), ]) -def test_update_completion(txt, kind, pattern, status_command_stub, +def test_update_completion(txt, kind, pattern, pos_args, status_command_stub, completer_obj, completion_widget_stub): """Test setting the completion widget's model based on command text.""" # this test uses | as a placeholder for the current cursor position _set_cmd_prompt(status_command_stub, txt) completer_obj.schedule_completion_update() - assert completion_widget_stub.set_model.call_count == 1 - args = completion_widget_stub.set_model.call_args[0] - # the outer model is just for sorting; srcmodel is the completion model if kind is None: - assert args[0] is None + assert completion_widget_stub.set_pattern.call_count == 0 else: - assert args[0].srcmodel.kind == kind - assert args[1] == pattern + assert completion_widget_stub.set_model.call_count == 1 + model = completion_widget_stub.set_model.call_args[0][0] + assert model.kind == kind + assert model.pos_args == pos_args + completion_widget_stub.set_pattern.assert_called_once_with(pattern) @pytest.mark.parametrize('before, newtxt, after', [ diff --git a/tests/unit/completion/test_completionmodel.py b/tests/unit/completion/test_completionmodel.py new file mode 100644 index 000000000..9e73e533a --- /dev/null +++ b/tests/unit/completion/test_completionmodel.py @@ -0,0 +1,117 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""Tests for CompletionModel.""" + +from unittest import mock +import hypothesis +from hypothesis import strategies + +import pytest +from PyQt5.QtCore import QModelIndex + +from qutebrowser.completion.models import completionmodel, listcategory +from qutebrowser.utils import qtutils +from qutebrowser.commands import cmdexc + + +@hypothesis.given(strategies.lists(min_size=0, max_size=3, + elements=strategies.integers(min_value=0, max_value=2**31))) +def test_first_last_item(counts): + """Test that first() and last() index to the first and last items.""" + model = completionmodel.CompletionModel() + for c in counts: + cat = mock.Mock(spec=['layoutChanged', 'layoutAboutToBeChanged']) + cat.rowCount = mock.Mock(return_value=c, spec=[]) + model.add_category(cat) + data = [i for i, rowCount in enumerate(counts) if rowCount > 0] + if not data: + # with no items, first and last should be an invalid index + assert not model.first_item().isValid() + assert not model.last_item().isValid() + else: + first = data[0] + last = data[-1] + # first item of the first data category + assert model.first_item().row() == 0 + assert model.first_item().parent().row() == first + # last item of the last data category + assert model.last_item().row() == counts[last] - 1 + assert model.last_item().parent().row() == last + + +@hypothesis.given(strategies.lists(elements=strategies.integers(), + min_size=0, max_size=3)) +def test_count(counts): + model = completionmodel.CompletionModel() + for c in counts: + cat = mock.Mock(spec=['rowCount', 'layoutChanged', + 'layoutAboutToBeChanged']) + cat.rowCount = mock.Mock(return_value=c, spec=[]) + model.add_category(cat) + assert model.count() == sum(counts) + + +@hypothesis.given(strategies.text()) +def test_set_pattern(pat): + """Validate the filtering and sorting results of set_pattern.""" + model = completionmodel.CompletionModel() + cats = [mock.Mock(spec=['set_pattern', 'layoutChanged', + 'layoutAboutToBeChanged']) + for _ in range(3)] + for c in cats: + c.set_pattern = mock.Mock(spec=[]) + model.add_category(c) + model.set_pattern(pat) + for c in cats: + c.set_pattern.assert_called_with(pat) + + +def test_delete_cur_item(): + func = mock.Mock(spec=[]) + model = completionmodel.CompletionModel() + cat = listcategory.ListCategory('', [('foo', 'bar')], delete_func=func) + model.add_category(cat) + parent = model.index(0, 0) + model.delete_cur_item(model.index(0, 0, parent)) + func.assert_called_once_with(['foo', 'bar']) + + +def test_delete_cur_item_no_func(): + callback = mock.Mock(spec=[]) + model = completionmodel.CompletionModel() + cat = listcategory.ListCategory('', [('foo', 'bar')], delete_func=None) + model.rowsAboutToBeRemoved.connect(callback) + model.rowsRemoved.connect(callback) + model.add_category(cat) + parent = model.index(0, 0) + with pytest.raises(cmdexc.CommandError): + model.delete_cur_item(model.index(0, 0, parent)) + assert not callback.called + + +def test_delete_cur_item_no_cat(): + """Test completion_item_del with no selected category.""" + callback = mock.Mock(spec=[]) + model = completionmodel.CompletionModel() + model.rowsAboutToBeRemoved.connect(callback) + model.rowsRemoved.connect(callback) + with pytest.raises(qtutils.QtValueError): + model.delete_cur_item(QModelIndex()) + assert not callback.called diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index a19161b91..7eb7fe2b5 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -19,13 +19,13 @@ """Tests for the CompletionView Object.""" -import unittest.mock +from unittest import mock import pytest -from PyQt5.QtGui import QStandardItem from qutebrowser.completion import completionwidget -from qutebrowser.completion.models import base, sortfilter +from qutebrowser.completion.models import completionmodel, listcategory +from qutebrowser.commands import cmdexc @pytest.fixture @@ -34,6 +34,9 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry, """Create the CompletionView used for testing.""" # mock the Completer that the widget creates in its constructor mocker.patch('qutebrowser.completion.completer.Completer', autospec=True) + mocker.patch( + 'qutebrowser.completion.completiondelegate.CompletionItemDelegate', + new=lambda *_: None) view = completionwidget.CompletionView(win_id=0) qtbot.addWidget(view) return view @@ -41,21 +44,27 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry, def test_set_model(completionview): """Ensure set_model actually sets the model and expands all categories.""" - model = base.BaseCompletionModel() - filtermodel = sortfilter.CompletionFilterModel(model) + model = completionmodel.CompletionModel() for i in range(3): - model.appendRow(QStandardItem(str(i))) - completionview.set_model(filtermodel) - assert completionview.model() is filtermodel - for i in range(model.rowCount()): - assert completionview.isExpanded(filtermodel.index(i, 0)) + model.add_category(listcategory.ListCategory('', [('foo',)])) + completionview.set_model(model) + assert completionview.model() is model + for i in range(3): + assert completionview.isExpanded(model.index(i, 0)) def test_set_pattern(completionview): - model = sortfilter.CompletionFilterModel(base.BaseCompletionModel()) - model.set_pattern = unittest.mock.Mock() - completionview.set_model(model, 'foo') + model = completionmodel.CompletionModel() + model.set_pattern = mock.Mock(spec=[]) + completionview.set_model(model) + completionview.set_pattern('foo') model.set_pattern.assert_called_with('foo') + assert not completionview.selectionModel().currentIndex().isValid() + + +def test_set_pattern_no_model(completionview): + """Ensure that setting a pattern with no model does not fail.""" + completionview.set_pattern('foo') def test_maybe_update_geometry(completionview, config_stub, qtbot): @@ -118,15 +127,11 @@ def test_completion_item_focus(which, tree, expected, completionview, qtbot): successive movement. None implies no signal should be emitted. """ - model = base.BaseCompletionModel() + model = completionmodel.CompletionModel() for catdata in tree: - cat = QStandardItem() - model.appendRow(cat) - for name in catdata: - cat.appendRow(QStandardItem(name)) - filtermodel = sortfilter.CompletionFilterModel(model, - parent=completionview) - completionview.set_model(filtermodel) + cat = listcategory.ListCategory('', ((x,) for x in catdata)) + model.add_category(cat) + completionview.set_model(model) for entry in expected: if entry is None: with qtbot.assertNotEmitted(completionview.selection_changed): @@ -146,15 +151,44 @@ def test_completion_item_focus_no_model(which, completionview, qtbot): """ with qtbot.assertNotEmitted(completionview.selection_changed): completionview.completion_item_focus(which) - model = base.BaseCompletionModel() - filtermodel = sortfilter.CompletionFilterModel(model, - parent=completionview) - completionview.set_model(filtermodel) + model = completionmodel.CompletionModel() + completionview.set_model(model) completionview.set_model(None) with qtbot.assertNotEmitted(completionview.selection_changed): completionview.completion_item_focus(which) +def test_completion_item_focus_fetch(completionview, qtbot): + """Test that on_next_prev_item moves the selection properly. + + Args: + which: the direction in which to move the selection. + tree: Each list represents a completion category, with each string + being an item under that category. + expected: expected argument from on_selection_changed for each + successive movement. None implies no signal should be + emitted. + """ + model = completionmodel.CompletionModel() + cat = mock.Mock(spec=['layoutChanged', 'layoutAboutToBeChanged', + 'canFetchMore', 'fetchMore', 'rowCount', 'index', 'data']) + cat.canFetchMore = lambda *_: True + cat.rowCount = lambda *_: 2 + cat.fetchMore = mock.Mock() + model.add_category(cat) + completionview.set_model(model) + # clear the fetchMore call that happens on set_model + cat.reset_mock() + + # not at end, fetchMore shouldn't be called + completionview.completion_item_focus('next') + assert not cat.fetchMore.called + + # at end, fetchMore should be called + completionview.completion_item_focus('next') + assert cat.fetchMore.called + + @pytest.mark.parametrize('show', ['always', 'auto', 'never']) @pytest.mark.parametrize('rows', [[], ['Aa'], ['Aa', 'Bb']]) @pytest.mark.parametrize('quick_complete', [True, False]) @@ -170,16 +204,13 @@ def test_completion_show(show, rows, quick_complete, completionview, config_stub.val.completion.show = show config_stub.val.completion.quick = quick_complete - model = base.BaseCompletionModel() + model = completionmodel.CompletionModel() for name in rows: - cat = QStandardItem() - model.appendRow(cat) - cat.appendRow(QStandardItem(name)) - filtermodel = sortfilter.CompletionFilterModel(model, - parent=completionview) + cat = listcategory.ListCategory('', [(name,)]) + model.add_category(cat) assert not completionview.isVisible() - completionview.set_model(filtermodel) + completionview.set_model(model) assert completionview.isVisible() == (show == 'always' and len(rows) > 0) completionview.completion_item_focus('next') expected = (show != 'never' and len(rows) > 0 and @@ -188,3 +219,32 @@ def test_completion_show(show, rows, quick_complete, completionview, completionview.set_model(None) completionview.completion_item_focus('next') assert not completionview.isVisible() + + +def test_completion_item_del(completionview): + """Test that completion_item_del invokes delete_cur_item in the model.""" + func = mock.Mock(spec=[]) + model = completionmodel.CompletionModel() + cat = listcategory.ListCategory('', [('foo', 'bar')], delete_func=func) + model.add_category(cat) + completionview.set_model(model) + completionview.completion_item_focus('next') + completionview.completion_item_del() + func.assert_called_once_with(['foo', 'bar']) + + +def test_completion_item_del_no_selection(completionview): + """Test that completion_item_del with an invalid index.""" + func = mock.Mock(spec=[]) + model = completionmodel.CompletionModel() + cat = listcategory.ListCategory('', [('foo',)], delete_func=func) + model.add_category(cat) + completionview.set_model(model) + with pytest.raises(cmdexc.CommandError, match='No item selected!'): + completionview.completion_item_del() + assert not func.called + + +def test_resize_no_model(completionview, qtbot): + """Ensure no crash if resizeEvent is triggered with no model (#2854).""" + completionview.resizeEvent(None) diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py new file mode 100644 index 000000000..1397b8b5f --- /dev/null +++ b/tests/unit/completion/test_histcategory.py @@ -0,0 +1,167 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""Test the web history completion category.""" + +import datetime + +import pytest + +from qutebrowser.misc import sql +from qutebrowser.completion.models import histcategory + + +@pytest.fixture +def hist(init_sql, config_stub): + config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', + 'web-history-max-items': -1} + return sql.SqlTable('CompletionHistory', ['url', 'title', 'last_atime']) + + +@pytest.mark.parametrize('pattern, before, after', [ + ('foo', + [('foo', ''), ('bar', ''), ('aafobbb', '')], + [('foo',)]), + + ('FOO', + [('foo', ''), ('bar', ''), ('aafobbb', '')], + [('foo',)]), + + ('foo', + [('FOO', ''), ('BAR', ''), ('AAFOBBB', '')], + [('FOO',)]), + + ('foo', + [('baz', 'bar'), ('foo', ''), ('bar', 'foo')], + [('foo', ''), ('bar', 'foo')]), + + ('foo', + [('fooa', ''), ('foob', ''), ('fooc', '')], + [('fooa', ''), ('foob', ''), ('fooc', '')]), + + ('foo', + [('foo', 'bar'), ('bar', 'foo'), ('biz', 'baz')], + [('foo', 'bar'), ('bar', 'foo')]), + + ('foo bar', + [('foo', ''), ('bar foo', ''), ('xfooyybarz', '')], + [('xfooyybarz', '')]), + + ('foo%bar', + [('foo%bar', ''), ('foo bar', ''), ('foobar', '')], + [('foo%bar', '')]), + + ('_', + [('a_b', ''), ('__a', ''), ('abc', '')], + [('a_b', ''), ('__a', '')]), + + ('%', + [('\\foo', '\\bar')], + []), + + ("can't", + [("can't touch this", ''), ('a', '')], + [("can't touch this", '')]), +]) +def test_set_pattern(pattern, before, after, model_validator, hist): + """Validate the filtering and sorting results of set_pattern.""" + for row in before: + hist.insert({'url': row[0], 'title': row[1], 'last_atime': 1}) + cat = histcategory.HistoryCategory() + model_validator.set_model(cat) + cat.set_pattern(pattern) + model_validator.validate(after) + + +@pytest.mark.parametrize('max_items, before, after', [ + (-1, [ + ('a', 'a', '2017-04-16'), + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ], [ + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ('a', 'a', '2017-04-16'), + ]), + (3, [ + ('a', 'a', '2017-04-16'), + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ], [ + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ('a', 'a', '2017-04-16'), + ]), + (2 ** 63 - 1, [ # Maximum value sqlite can handle for LIMIT + ('a', 'a', '2017-04-16'), + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ], [ + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ('a', 'a', '2017-04-16'), + ]), + (2, [ + ('a', 'a', '2017-04-16'), + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ], [ + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ]), + (1, [], []), # issue 2849 (crash with empty history) +]) +def test_sorting(max_items, before, after, model_validator, hist, config_stub): + """Validate the filtering and sorting results of set_pattern.""" + config_stub.data['completion']['web-history-max-items'] = max_items + for url, title, atime in before: + timestamp = datetime.datetime.strptime(atime, '%Y-%m-%d').timestamp() + hist.insert({'url': url, 'title': title, 'last_atime': timestamp}) + cat = histcategory.HistoryCategory() + model_validator.set_model(cat) + cat.set_pattern('') + model_validator.validate(after) + + +def test_remove_rows(hist, model_validator): + hist.insert({'url': 'foo', 'title': 'Foo'}) + hist.insert({'url': 'bar', 'title': 'Bar'}) + cat = histcategory.HistoryCategory() + model_validator.set_model(cat) + cat.set_pattern('') + hist.delete('url', 'foo') + cat.removeRows(0, 1) + model_validator.validate([('bar', 'Bar', '')]) + + +def test_remove_rows_fetch(hist): + """removeRows should fetch enough data to make the current index valid.""" + # we cannot use model_validator as it will fetch everything up front + hist.insert_batch({'url': [str(i) for i in range(300)]}) + cat = histcategory.HistoryCategory() + cat.set_pattern('') + + # sanity check that we didn't fetch everything up front + assert cat.rowCount() < 300 + cat.fetchMore() + assert cat.rowCount() == 300 + + hist.delete('url', '298') + cat.removeRows(297, 1) + assert cat.rowCount() == 299 diff --git a/tests/unit/completion/test_listcategory.py b/tests/unit/completion/test_listcategory.py new file mode 100644 index 000000000..8d8936167 --- /dev/null +++ b/tests/unit/completion/test_listcategory.py @@ -0,0 +1,50 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""Tests for CompletionFilterModel.""" + +import pytest + +from qutebrowser.completion.models import listcategory + + +@pytest.mark.parametrize('pattern, before, after', [ + ('foo', + [('foo', ''), ('bar', '')], + [('foo', '')]), + + ('foo', + [('foob', ''), ('fooc', ''), ('fooa', '')], + [('fooa', ''), ('foob', ''), ('fooc', '')]), + + # prefer foobar as it starts with the pattern + ('foo', + [('barfoo', ''), ('foobar', '')], + [('foobar', ''), ('barfoo', '')]), + + ('foo', + [('foo', 'bar'), ('bar', 'foo'), ('bar', 'bar')], + [('foo', 'bar'), ('bar', 'foo')]), +]) +def test_set_pattern(pattern, before, after, model_validator): + """Validate the filtering and sorting results of set_pattern.""" + cat = listcategory.ListCategory('Foo', before) + model_validator.set_model(cat) + cat.set_pattern(pattern) + model_validator.validate(after) diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 5004fe077..2c9607cbd 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -27,15 +27,10 @@ from datetime import datetime import pytest from PyQt5.QtCore import QUrl -from PyQt5.QtWidgets import QTreeView -from qutebrowser.completion.models import (miscmodels, urlmodel, configmodel, - sortfilter) -from qutebrowser.browser import history - - -pytestmark = pytest.mark.skip("FIXME:conf reintroduce after new completion " - "is in") +from qutebrowser.completion.models import miscmodels, urlmodel, configmodel +from qutebrowser.config import sections, value +from qutebrowser.utils import objreg def _check_completions(model, expected): @@ -49,19 +44,21 @@ def _check_completions(model, expected): ... } """ + actual = {} assert model.rowCount() == len(expected) for i in range(0, model.rowCount()): - actual_cat = model.item(i) - catname = actual_cat.text() - assert catname in expected - expected_cat = expected[catname] - assert actual_cat.rowCount() == len(expected_cat) - for j in range(0, actual_cat.rowCount()): - name = actual_cat.child(j, 0) - desc = actual_cat.child(j, 1) - misc = actual_cat.child(j, 2) - actual_item = (name.text(), desc.text(), misc.text()) - assert actual_item in expected_cat + catidx = model.index(i, 0) + catname = model.data(catidx) + actual[catname] = [] + for j in range(model.rowCount(catidx)): + name = model.data(model.index(j, 0, parent=catidx)) + desc = model.data(model.index(j, 1, parent=catidx)) + misc = model.data(model.index(j, 2, parent=catidx)) + actual[catname].append((name, desc, misc)) + assert actual == expected + # sanity-check the column_widths + assert len(model.column_widths) == 3 + assert sum(model.column_widths) == 100 def _patch_cmdutils(monkeypatch, stubs, symbol): @@ -119,22 +116,6 @@ def _patch_config_section_desc(monkeypatch, stubs, symbol): monkeypatch.setattr(symbol, section_desc) -def _mock_view_index(model, category_idx, child_idx, qtbot): - """Create a tree view from a model and set the current index. - - Args: - model: model to create a fake view for. - category_idx: index of the category to select. - child_idx: index of the child item under that category to select. - """ - view = QTreeView() - qtbot.add_widget(view) - view.setModel(model) - idx = model.indexFromItem(model.item(category_idx).child(child_idx)) - view.setCurrentIndex(idx) - return view - - @pytest.fixture def quickmarks(quickmark_manager_stub): """Pre-populate the quickmark-manager stub with some quickmarks.""" @@ -158,20 +139,35 @@ def bookmarks(bookmark_manager_stub): @pytest.fixture -def web_history(stubs, web_history_stub): - """Pre-populate the web-history stub with some history entries.""" - web_history_stub.history_dict = collections.OrderedDict([ - ('http://qutebrowser.org', history.Entry( - datetime(2015, 9, 5).timestamp(), - QUrl('http://qutebrowser.org'), 'qutebrowser | qutebrowser')), - ('https://python.org', history.Entry( - datetime(2016, 3, 8).timestamp(), - QUrl('https://python.org'), 'Welcome to Python.org')), - ('https://github.com', history.Entry( - datetime(2016, 5, 1).timestamp(), - QUrl('https://github.com'), 'GitHub')), - ]) - return web_history_stub +def web_history(init_sql, stubs, config_stub): + """Fixture which provides a web-history object.""" + config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', + 'web-history-max-items': -1} + stub = history.WebHistory() + objreg.register('web-history', stub) + yield stub + objreg.delete('web-history') + + +@pytest.fixture +def web_history_populated(web_history): + """Pre-populate the web-history database.""" + web_history.add_url( + url=QUrl('http://qutebrowser.org'), + title='qutebrowser', + atime=datetime(2015, 9, 5).timestamp() + ) + web_history.add_url( + url=QUrl('https://python.org'), + title='Welcome to Python.org', + atime=datetime(2016, 3, 8).timestamp() + ) + web_history.add_url( + url=QUrl('https://github.com'), + title='https://github.com', + atime=datetime(2016, 5, 1).timestamp() + ) + return web_history def test_command_completion(qtmodeltester, monkeypatch, stubs, config_stub, @@ -190,16 +186,17 @@ def test_command_completion(qtmodeltester, monkeypatch, stubs, config_stub, key_config_stub.set_bindings_for('normal', {'s': 'stop', 'rr': 'roll', 'ro': 'rock'}) - model = miscmodels.CommandCompletionModel() + model = miscmodels.command() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Commands": [ - ('stop', 'stop qutebrowser', 's'), ('drop', 'drop all user data', ''), - ('roll', 'never gonna give you up', 'rr'), ('rock', "Alias for 'roll'", 'ro'), + ('roll', 'never gonna give you up', 'rr'), + ('stop', 'stop qutebrowser', 's'), ] }) @@ -218,135 +215,252 @@ def test_help_completion(qtmodeltester, monkeypatch, stubs, key_config_stub): key_config_stub.set_bindings_for('normal', {'s': 'stop', 'rr': 'roll'}) _patch_cmdutils(monkeypatch, stubs, module + '.cmdutils') _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') - model = miscmodels.HelpCompletionModel() + model = miscmodels.helptopic() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Commands": [ - (':stop', 'stop qutebrowser', 's'), (':drop', 'drop all user data', ''), - (':roll', 'never gonna give you up', 'rr'), (':hide', '', ''), + (':roll', 'never gonna give you up', 'rr'), + (':stop', 'stop qutebrowser', 's'), ], "Settings": [ - ('general->time', 'Is an illusion.', ''), - ('general->volume', 'Goes to 11', ''), - ('ui->gesture', 'Waggle your hands to control qutebrowser', ''), - ('ui->mind', 'Enable mind-control ui (experimental)', ''), - ('ui->voice', 'Whether to respond to voice commands', ''), - ('searchengines->DEFAULT', '', ''), + ('general->time', 'Is an illusion.', None), + ('general->volume', 'Goes to 11', None), + ('searchengines->DEFAULT', '', None), + ('ui->gesture', 'Waggle your hands to control qutebrowser', None), + ('ui->mind', 'Enable mind-control ui (experimental)', None), + ('ui->voice', 'Whether to respond to voice commands', None), ] }) def test_quickmark_completion(qtmodeltester, quickmarks): """Test the results of quickmark completion.""" - model = miscmodels.QuickmarkCompletionModel() + model = miscmodels.quickmark() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Quickmarks": [ - ('aw', 'https://wiki.archlinux.org', ''), - ('ddg', 'https://duckduckgo.com', ''), - ('wiki', 'https://wikipedia.org', ''), + ('aw', 'https://wiki.archlinux.org', None), + ('ddg', 'https://duckduckgo.com', None), + ('wiki', 'https://wikipedia.org', None), ] }) +@pytest.mark.parametrize('row, removed', [ + (0, 'aw'), + (1, 'ddg'), + (2, 'wiki'), +]) +def test_quickmark_completion_delete(qtmodeltester, quickmarks, row, removed): + """Test deleting a quickmark from the quickmark completion model.""" + model = miscmodels.quickmark() + model.set_pattern('') + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + parent = model.index(0, 0) + idx = model.index(row, 0, parent) + + before = set(quickmarks.marks.keys()) + model.delete_cur_item(idx) + after = set(quickmarks.marks.keys()) + assert before.difference(after) == {removed} + + def test_bookmark_completion(qtmodeltester, bookmarks): """Test the results of bookmark completion.""" - model = miscmodels.BookmarkCompletionModel() + model = miscmodels.bookmark() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Bookmarks": [ - ('https://github.com', 'GitHub', ''), - ('https://python.org', 'Welcome to Python.org', ''), - ('http://qutebrowser.org', 'qutebrowser | qutebrowser', ''), + ('http://qutebrowser.org', 'qutebrowser | qutebrowser', None), + ('https://github.com', 'GitHub', None), + ('https://python.org', 'Welcome to Python.org', None), ] }) -def test_url_completion(qtmodeltester, config_stub, web_history, quickmarks, - bookmarks): +@pytest.mark.parametrize('row, removed', [ + (0, 'http://qutebrowser.org'), + (1, 'https://github.com'), + (2, 'https://python.org'), +]) +def test_bookmark_completion_delete(qtmodeltester, bookmarks, row, removed): + """Test deleting a quickmark from the quickmark completion model.""" + model = miscmodels.bookmark() + model.set_pattern('') + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + parent = model.index(0, 0) + idx = model.index(row, 0, parent) + + before = set(bookmarks.marks.keys()) + model.delete_cur_item(idx) + after = set(bookmarks.marks.keys()) + assert before.difference(after) == {removed} + + +def test_url_completion(qtmodeltester, web_history_populated, + quickmarks, bookmarks): """Test the results of url completion. Verify that: - quickmarks, bookmarks, and urls are included - - no more than 'web-history-max-items' history entries are included - - the most recent entries are included + - entries are sorted by access time + - only the most recent entry is included for each url """ - config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', - 'web-history-max-items': 2} - model = urlmodel.UrlCompletionModel() + model = urlmodel.url() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Quickmarks": [ - ('https://wiki.archlinux.org', 'aw', ''), - ('https://duckduckgo.com', 'ddg', ''), - ('https://wikipedia.org', 'wiki', ''), + ('https://duckduckgo.com', 'ddg', None), + ('https://wiki.archlinux.org', 'aw', None), + ('https://wikipedia.org', 'wiki', None), ], "Bookmarks": [ - ('https://github.com', 'GitHub', ''), - ('https://python.org', 'Welcome to Python.org', ''), - ('http://qutebrowser.org', 'qutebrowser | qutebrowser', ''), + ('http://qutebrowser.org', 'qutebrowser | qutebrowser', None), + ('https://github.com', 'GitHub', None), + ('https://python.org', 'Welcome to Python.org', None), ], "History": [ + ('https://github.com', 'https://github.com', '2016-05-01'), ('https://python.org', 'Welcome to Python.org', '2016-03-08'), - ('https://github.com', 'GitHub', '2016-05-01'), + ('http://qutebrowser.org', 'qutebrowser', '2015-09-05'), ], }) -def test_url_completion_delete_bookmark(qtmodeltester, config_stub, - web_history, quickmarks, bookmarks, - qtbot): +@pytest.mark.parametrize('url, title, pattern, rowcount', [ + ('example.com', 'Site Title', '', 1), + ('example.com', 'Site Title', 'ex', 1), + ('example.com', 'Site Title', 'am', 1), + ('example.com', 'Site Title', 'com', 1), + ('example.com', 'Site Title', 'ex com', 1), + ('example.com', 'Site Title', 'com ex', 0), + ('example.com', 'Site Title', 'ex foo', 0), + ('example.com', 'Site Title', 'foo com', 0), + ('example.com', 'Site Title', 'exm', 0), + ('example.com', 'Site Title', 'Si Ti', 1), + ('example.com', 'Site Title', 'Ti Si', 0), + ('example.com', '', 'foo', 0), + ('foo_bar', '', '_', 1), + ('foobar', '', '_', 0), + ('foo%bar', '', '%', 1), + ('foobar', '', '%', 0), +]) +def test_url_completion_pattern(web_history, quickmark_manager_stub, + bookmark_manager_stub, url, title, pattern, + rowcount): + """Test that url completion filters by url and title.""" + web_history.add_url(QUrl(url), title) + model = urlmodel.url() + model.set_pattern(pattern) + # 2, 0 is History + assert model.rowCount(model.index(2, 0)) == rowcount + + +def test_url_completion_delete_bookmark(qtmodeltester, bookmarks, + web_history, quickmarks): """Test deleting a bookmark from the url completion model.""" - config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', - 'web-history-max-items': 2} - model = urlmodel.UrlCompletionModel() + model = urlmodel.url() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - # delete item (1, 0) -> (bookmarks, 'https://github.com' ) - view = _mock_view_index(model, 1, 0, qtbot) - model.delete_cur_item(view) + parent = model.index(1, 0) + idx = model.index(1, 0, parent) + + # sanity checks + assert model.data(parent) == "Bookmarks" + assert model.data(idx) == 'https://github.com' + assert 'https://github.com' in bookmarks.marks + + len_before = len(bookmarks.marks) + model.delete_cur_item(idx) assert 'https://github.com' not in bookmarks.marks - assert 'https://python.org' in bookmarks.marks - assert 'http://qutebrowser.org' in bookmarks.marks + assert len_before == len(bookmarks.marks) + 1 -def test_url_completion_delete_quickmark(qtmodeltester, config_stub, - web_history, quickmarks, bookmarks, +def test_url_completion_delete_quickmark(qtmodeltester, + quickmarks, web_history, bookmarks, qtbot): """Test deleting a bookmark from the url completion model.""" - config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', - 'web-history-max-items': 2} - model = urlmodel.UrlCompletionModel() + model = urlmodel.url() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - # delete item (0, 1) -> (quickmarks, 'ddg' ) - view = _mock_view_index(model, 0, 1, qtbot) - model.delete_cur_item(view) - assert 'aw' in quickmarks.marks + parent = model.index(0, 0) + idx = model.index(0, 0, parent) + + # sanity checks + assert model.data(parent) == "Quickmarks" + assert model.data(idx) == 'https://duckduckgo.com' + assert 'ddg' in quickmarks.marks + + len_before = len(quickmarks.marks) + model.delete_cur_item(idx) assert 'ddg' not in quickmarks.marks - assert 'wiki' in quickmarks.marks + assert len_before == len(quickmarks.marks) + 1 + + +def test_url_completion_delete_history(qtmodeltester, + web_history_populated, + quickmarks, bookmarks): + """Test deleting a history entry.""" + model = urlmodel.url() + model.set_pattern('') + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + parent = model.index(2, 0) + idx = model.index(1, 0, parent) + + # sanity checks + assert model.data(parent) == "History" + assert model.data(idx) == 'https://python.org' + + assert 'https://python.org' in web_history_populated + model.delete_cur_item(idx) + assert 'https://python.org' not in web_history_populated + + +def test_url_completion_zero_limit(config_stub, web_history, quickmarks, + bookmarks): + """Make sure there's no history if the limit was set to zero.""" + config_stub.data['completion']['web-history-max-items'] = 0 + model = urlmodel.url() + model.set_pattern('') + category = model.index(2, 0) # "History" normally + assert model.data(category) is None def test_session_completion(qtmodeltester, session_manager_stub): session_manager_stub.sessions = ['default', '1', '2'] - model = miscmodels.SessionCompletionModel() + model = miscmodels.session() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { - "Sessions": [('default', '', ''), ('1', '', ''), ('2', '', '')] + "Sessions": [('1', None, None), + ('2', None, None), + ('default', None, None)] }) @@ -360,7 +474,8 @@ def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, tabbed_browser_stubs[1].tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] - model = miscmodels.TabCompletionModel() + model = miscmodels.buffer() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) @@ -376,7 +491,7 @@ def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, }) -def test_tab_completion_delete(qtmodeltester, fake_web_tab, qtbot, app_stub, +def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub, win_registry, tabbed_browser_stubs): """Verify closing a tab by deleting it from the completion widget.""" tabbed_browser_stubs[0].tabs = [ @@ -387,13 +502,19 @@ def test_tab_completion_delete(qtmodeltester, fake_web_tab, qtbot, app_stub, tabbed_browser_stubs[1].tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] - model = miscmodels.TabCompletionModel() + model = miscmodels.buffer() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - view = _mock_view_index(model, 0, 1, qtbot) - qtbot.add_widget(view) - model.delete_cur_item(view) + parent = model.index(0, 0) + idx = model.index(1, 0, parent) + + # sanity checks + assert model.data(parent) == "0" + assert model.data(idx) == '0/2' + + model.delete_cur_item(idx) actual = [tab.url() for tab in tabbed_browser_stubs[0].tabs] assert actual == [QUrl('https://github.com'), QUrl('https://duckduckgo.com')] @@ -404,15 +525,16 @@ def test_setting_section_completion(qtmodeltester, monkeypatch, stubs): _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') _patch_config_section_desc(monkeypatch, stubs, module + '.configdata.SECTION_DESC') - model = configmodel.SettingSectionCompletionModel() + model = configmodel.section() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Sections": [ - ('general', 'General/miscellaneous options.', ''), - ('ui', 'General options related to the user interface.', ''), - ('searchengines', 'Definitions of search engines ...', ''), + ('general', 'General/miscellaneous options.', None), + ('searchengines', 'Definitions of search engines ...', None), + ('ui', 'General options related to the user interface.', None), ] }) @@ -424,7 +546,8 @@ def test_setting_option_completion(qtmodeltester, monkeypatch, stubs, config_stub.data = {'ui': {'gesture': 'off', 'mind': 'on', 'voice': 'sometimes'}} - model = configmodel.SettingOptionCompletionModel('ui') + model = configmodel.option('ui') + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) @@ -437,6 +560,12 @@ def test_setting_option_completion(qtmodeltester, monkeypatch, stubs, }) +def test_setting_option_completion_empty(monkeypatch, stubs, config_stub): + module = 'qutebrowser.completion.models.configmodel' + _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') + assert configmodel.option('typo') is None + + def test_setting_option_completion_valuelist(qtmodeltester, monkeypatch, stubs, config_stub): module = 'qutebrowser.completion.models.configmodel' @@ -446,7 +575,8 @@ def test_setting_option_completion_valuelist(qtmodeltester, monkeypatch, stubs, 'DEFAULT': 'https://duckduckgo.com/?q={}' } } - model = configmodel.SettingOptionCompletionModel('searchengines') + model = configmodel.option('searchengines') + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) @@ -460,22 +590,30 @@ def test_setting_value_completion(qtmodeltester, monkeypatch, stubs, module = 'qutebrowser.completion.models.configmodel' _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') config_stub.data = {'general': {'volume': '0'}} - model = configmodel.SettingValueCompletionModel('general', 'volume') + model = configmodel.value('general', 'volume') + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Current/Default": [ - ('0', 'Current value', ''), - ('11', 'Default value', ''), + ('0', 'Current value', None), + ('11', 'Default value', None), ], "Completions": [ - ('0', '', ''), - ('11', '', ''), + ('0', '', None), + ('11', '', None), ] }) +def test_setting_value_completion_empty(monkeypatch, stubs, config_stub): + module = 'qutebrowser.completion.models.configmodel' + _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') + config_stub.data = {'general': {}} + assert configmodel.value('general', 'typo') is None + + def test_bind_completion(qtmodeltester, monkeypatch, stubs, config_stub, key_config_stub): """Test the results of keybinding command completion. @@ -489,58 +627,58 @@ def test_bind_completion(qtmodeltester, monkeypatch, stubs, config_stub, _patch_cmdutils(monkeypatch, stubs, 'qutebrowser.completion.models.miscmodels.cmdutils') config_stub.data['aliases'] = {'rock': 'roll'} - key_config_stub.set_bindings_for('normal', {'s': 'stop', + key_config_stub.set_bindings_for('normal', {'s': 'stop now', 'rr': 'roll', 'ro': 'rock'}) - model = miscmodels.BindCompletionModel() + model = miscmodels.bind('s') + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { + "Current": [ + ('stop now', 'stop qutebrowser', 's'), + ], "Commands": [ - ('stop', 'stop qutebrowser', 's'), ('drop', 'drop all user data', ''), ('hide', '', ''), - ('roll', 'never gonna give you up', 'rr'), ('rock', "Alias for 'roll'", 'ro'), + ('roll', 'never gonna give you up', 'rr'), + ('stop', 'stop qutebrowser', ''), ] }) -def test_url_completion_benchmark(benchmark, config_stub, +def test_url_completion_benchmark(benchmark, quickmark_manager_stub, bookmark_manager_stub, - web_history_stub): + web_history): """Benchmark url completion.""" - config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', - 'web-history-max-items': 1000} - - entries = [history.Entry( - atime=i, - url=QUrl('http://example.com/{}'.format(i)), - title='title{}'.format(i)) - for i in range(100000)] + r = range(100000) + entries = { + 'last_atime': list(r), + 'url': ['http://example.com/{}'.format(i) for i in r], + 'title': ['title{}'.format(i) for i in r] + } - web_history_stub.history_dict = collections.OrderedDict( - ((e.url_str(), e) for e in entries)) + web_history.completion.insert_batch(entries) - quickmark_manager_stub.marks = collections.OrderedDict( - (e.title, e.url_str()) - for e in entries[0:1000]) + quickmark_manager_stub.marks = collections.OrderedDict([ + ('title{}'.format(i), 'example.com/{}'.format(i)) + for i in range(1000)]) - bookmark_manager_stub.marks = collections.OrderedDict( - (e.url_str(), e.title) - for e in entries[0:1000]) + bookmark_manager_stub.marks = collections.OrderedDict([ + ('example.com/{}'.format(i), 'title{}'.format(i)) + for i in range(1000)]) def bench(): - model = urlmodel.UrlCompletionModel() - filtermodel = sortfilter.CompletionFilterModel(model) - filtermodel.set_pattern('') - filtermodel.set_pattern('e') - filtermodel.set_pattern('ex') - filtermodel.set_pattern('ex ') - filtermodel.set_pattern('ex 1') - filtermodel.set_pattern('ex 12') - filtermodel.set_pattern('ex 123') + model = urlmodel.url() + model.set_pattern('') + model.set_pattern('e') + model.set_pattern('ex') + model.set_pattern('ex ') + model.set_pattern('ex 1') + model.set_pattern('ex 12') + model.set_pattern('ex 123') benchmark(bench) diff --git a/tests/unit/completion/test_sortfilter.py b/tests/unit/completion/test_sortfilter.py deleted file mode 100644 index 2d4a4e25d..000000000 --- a/tests/unit/completion/test_sortfilter.py +++ /dev/null @@ -1,230 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2015-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org> -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. - -"""Tests for CompletionFilterModel.""" - -import pytest - -from PyQt5.QtCore import Qt - -from qutebrowser.completion.models import base, sortfilter - - -def _create_model(data): - """Create a completion model populated with the given data. - - data: A list of lists, where each sub-list represents a category, each - tuple in the sub-list represents an item, and each value in the - tuple represents the item data for that column - """ - model = base.BaseCompletionModel() - for catdata in data: - cat = model.new_category('') - for itemdata in catdata: - model.new_item(cat, *itemdata) - return model - - -def _extract_model_data(model): - """Express a model's data as a list for easier comparison. - - Return: A list of lists, where each sub-list represents a category, each - tuple in the sub-list represents an item, and each value in the - tuple represents the item data for that column - """ - data = [] - for i in range(0, model.rowCount()): - cat_idx = model.index(i, 0) - row = [] - for j in range(0, model.rowCount(cat_idx)): - row.append((model.data(cat_idx.child(j, 0)), - model.data(cat_idx.child(j, 1)), - model.data(cat_idx.child(j, 2)))) - data.append(row) - return data - - -@pytest.mark.parametrize('pattern, data, expected', [ - ('foo', 'barfoobar', True), - ('foo bar', 'barfoobar', True), - ('foo bar', 'barfoobar', True), - ('foo bar', 'barfoobazbar', True), - ('foo bar', 'barfoobazbar', True), - ('foo', 'barFOObar', True), - ('Foo', 'barfOObar', True), - ('ab', 'aonebtwo', False), - ('33', 'l33t', True), - ('x', 'blah', False), - ('4', 'blah', False), -]) -def test_filter_accepts_row(pattern, data, expected): - source_model = base.BaseCompletionModel() - cat = source_model.new_category('test') - source_model.new_item(cat, data) - - filter_model = sortfilter.CompletionFilterModel(source_model) - filter_model.set_pattern(pattern) - assert filter_model.rowCount() == 1 # "test" category - idx = filter_model.index(0, 0) - assert idx.isValid() - - row_count = filter_model.rowCount(idx) - assert row_count == (1 if expected else 0) - - -@pytest.mark.parametrize('tree, first, last', [ - ([[('Aa',)]], 'Aa', 'Aa'), - ([[('Aa',)], [('Ba',)]], 'Aa', 'Ba'), - ([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]], - 'Aa', 'Ca'), - ([[], [('Ba',)]], 'Ba', 'Ba'), - ([[], [], [('Ca',)]], 'Ca', 'Ca'), - ([[], [], [('Ca',), ('Cb',)]], 'Ca', 'Cb'), - ([[('Aa',)], []], 'Aa', 'Aa'), - ([[('Aa',)], []], 'Aa', 'Aa'), - ([[('Aa',)], [], []], 'Aa', 'Aa'), - ([[('Aa',)], [], [('Ca',)]], 'Aa', 'Ca'), - ([[], []], None, None), -]) -def test_first_last_item(tree, first, last): - """Test that first() and last() return indexes to the first and last items. - - Args: - tree: Each list represents a completion category, with each string - being an item under that category. - first: text of the first item - last: text of the last item - """ - model = _create_model(tree) - filter_model = sortfilter.CompletionFilterModel(model) - assert filter_model.data(filter_model.first_item()) == first - assert filter_model.data(filter_model.last_item()) == last - - -def test_set_source_model(): - """Ensure setSourceModel sets source_model and clears the pattern.""" - model1 = base.BaseCompletionModel() - model2 = base.BaseCompletionModel() - filter_model = sortfilter.CompletionFilterModel(model1) - filter_model.set_pattern('foo') - # sourceModel() is cached as srcmodel, so make sure both match - assert filter_model.srcmodel is model1 - assert filter_model.sourceModel() is model1 - assert filter_model.pattern == 'foo' - filter_model.setSourceModel(model2) - assert filter_model.srcmodel is model2 - assert filter_model.sourceModel() is model2 - assert not filter_model.pattern - - -@pytest.mark.parametrize('tree, expected', [ - ([[('Aa',)]], 1), - ([[('Aa',)], [('Ba',)]], 2), - ([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]], 6), - ([[], [('Ba',)]], 1), - ([[], [], [('Ca',)]], 1), - ([[], [], [('Ca',), ('Cb',)]], 2), - ([[('Aa',)], []], 1), - ([[('Aa',)], []], 1), - ([[('Aa',)], [], []], 1), - ([[('Aa',)], [], [('Ca',)]], 2), -]) -def test_count(tree, expected): - model = _create_model(tree) - filter_model = sortfilter.CompletionFilterModel(model) - assert filter_model.count() == expected - - -@pytest.mark.parametrize('pattern, dumb_sort, filter_cols, before, after', [ - ('foo', None, [0], - [[('foo', '', ''), ('bar', '', '')]], - [[('foo', '', '')]]), - - ('foo', None, [0], - [[('foob', '', ''), ('fooc', '', ''), ('fooa', '', '')]], - [[('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')]]), - - ('foo', None, [0], - [[('foo', '', '')], [('bar', '', '')]], - [[('foo', '', '')], []]), - - # prefer foobar as it starts with the pattern - ('foo', None, [0], - [[('barfoo', '', ''), ('foobar', '', '')]], - [[('foobar', '', ''), ('barfoo', '', '')]]), - - # however, don't rearrange categories - ('foo', None, [0], - [[('barfoo', '', '')], [('foobar', '', '')]], - [[('barfoo', '', '')], [('foobar', '', '')]]), - - ('foo', None, [1], - [[('foo', 'bar', ''), ('bar', 'foo', '')]], - [[('bar', 'foo', '')]]), - - ('foo', None, [0, 1], - [[('foo', 'bar', ''), ('bar', 'foo', ''), ('bar', 'bar', '')]], - [[('foo', 'bar', ''), ('bar', 'foo', '')]]), - - ('foo', None, [0, 1, 2], - [[('foo', '', ''), ('bar', '')]], - [[('foo', '', '')]]), - - # the fourth column is the sort role, which overrides data-based sorting - ('', None, [0], - [[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]], - [[('one', '', ''), ('two', '', ''), ('three', '', '')]]), - - ('', Qt.AscendingOrder, [0], - [[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]], - [[('one', '', ''), ('two', '', ''), ('three', '', '')]]), - - ('', Qt.DescendingOrder, [0], - [[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]], - [[('three', '', ''), ('two', '', ''), ('one', '', '')]]), -]) -def test_set_pattern(pattern, dumb_sort, filter_cols, before, after): - """Validate the filtering and sorting results of set_pattern.""" - model = _create_model(before) - model.DUMB_SORT = dumb_sort - model.columns_to_filter = filter_cols - filter_model = sortfilter.CompletionFilterModel(model) - filter_model.set_pattern(pattern) - actual = _extract_model_data(filter_model) - assert actual == after - - -def test_sort(): - """Ensure that a sort argument passed to sort overrides DUMB_SORT. - - While test_set_pattern above covers most of the sorting logic, this - particular case is easier to test separately. - """ - model = _create_model([[('B', '', '', 1), - ('C', '', '', 2), - ('A', '', '', 0)]]) - filter_model = sortfilter.CompletionFilterModel(model) - - filter_model.sort(0, Qt.AscendingOrder) - actual = _extract_model_data(filter_model) - assert actual == [[('A', '', ''), ('B', '', ''), ('C', '', '')]] - - filter_model.sort(0, Qt.DescendingOrder) - actual = _extract_model_data(filter_model) - assert actual == [[('C', '', ''), ('B', '', ''), ('A', '', '')]] diff --git a/tests/unit/config/old_configs/qutebrowser-v0.11.0.conf b/tests/unit/config/old_configs/qutebrowser-v0.11.0.conf new file mode 100644 index 000000000..ba48a6ff5 --- /dev/null +++ b/tests/unit/config/old_configs/qutebrowser-v0.11.0.conf @@ -0,0 +1,251 @@ +[general] +ignore-case = smart +startpage = https://start.duckduckgo.com +yank-ignored-url-parameters = ref,utm_source,utm_medium,utm_campaign,utm_term,utm_content +default-open-dispatcher = +default-page = ${startpage} +auto-search = naive +auto-save-config = true +auto-save-interval = 15000 +editor = gvim -f "{}" +editor-encoding = utf-8 +private-browsing = false +developer-extras = false +print-element-backgrounds = true +xss-auditing = false +default-encoding = iso-8859-1 +new-instance-open-target = tab +new-instance-open-target.window = last-focused +log-javascript-console = debug +save-session = false +session-default-name = +url-incdec-segments = path,query +[ui] +history-session-interval = 30 +zoom-levels = 25%,33%,50%,67%,75%,90%,100%,110%,125%,150%,175%,200%,250%,300%,400%,500% +default-zoom = 100% +downloads-position = top +status-position = bottom +message-timeout = 2000 +message-unfocused = false +confirm-quit = never +zoom-text-only = false +frame-flattening = false +user-stylesheet = +hide-scrollbar = true +smooth-scrolling = false +remove-finished-downloads = -1 +hide-statusbar = false +statusbar-padding = 1,1,0,0 +window-title-format = {perc}{title}{title_sep}qutebrowser +modal-js-dialog = false +hide-wayland-decoration = false +keyhint-blacklist = +keyhint-delay = 500 +prompt-radius = 8 +prompt-filebrowser = true +[network] +do-not-track = true +accept-language = en-US,en +referer-header = same-domain +user-agent = +proxy = system +proxy-dns-requests = true +ssl-strict = ask +dns-prefetch = true +custom-headers = +netrc-file = +[completion] +show = always +download-path-suggestion = path +timestamp-format = %Y-%m-%d +height = 50% +cmd-history-max-items = 100 +web-history-max-items = 1000 +quick-complete = true +shrink = false +scrollbar-width = 12 +scrollbar-padding = 2 +[input] +timeout = 500 +partial-timeout = 5000 +insert-mode-on-plugins = false +auto-leave-insert-mode = true +auto-insert-mode = false +forward-unbound-keys = auto +spatial-navigation = false +links-included-in-focus-chain = true +rocker-gestures = false +mouse-zoom-divider = 512 +[tabs] +background-tabs = false +select-on-remove = next +new-tab-position = next +new-tab-position-explicit = last +last-close = ignore +show = always +show-switching-delay = 800 +wrap = true +movable = true +close-mouse-button = middle +position = top +show-favicons = true +favicon-scale = 1.0 +width = 20% +pinned-width = 43 +indicator-width = 3 +tabs-are-windows = false +title-format = {index}: {title} +title-format-pinned = {index} +title-alignment = left +mousewheel-tab-switching = true +padding = 0,0,5,5 +indicator-padding = 2,2,0,4 +[storage] +download-directory = +prompt-download-directory = true +remember-download-directory = true +maximum-pages-in-cache = 0 +offline-web-application-cache = true +local-storage = true +cache-size = +[content] +allow-images = true +allow-javascript = true +allow-plugins = false +webgl = true +hyperlink-auditing = false +geolocation = ask +notifications = ask +media-capture = ask +javascript-can-open-windows-automatically = false +javascript-can-close-windows = false +javascript-can-access-clipboard = false +ignore-javascript-prompt = false +ignore-javascript-alert = false +local-content-can-access-remote-urls = false +local-content-can-access-file-urls = true +cookies-accept = no-3rdparty +cookies-store = true +host-block-lists = https://www.malwaredomainlist.com/hostslist/hosts.txt,http://someonewhocares.org/hosts/hosts,http://winhelp2002.mvps.org/hosts.zip,http://malwaredomains.lehigh.edu/files/justdomains.zip,https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&mimetype=plaintext +host-blocking-enabled = true +host-blocking-whitelist = piwik.org +enable-pdfjs = false +[hints] +border = 1px solid #E3BE23 +mode = letter +chars = asdfghjkl +min-chars = 1 +scatter = true +uppercase = false +dictionary = /usr/share/dict/words +auto-follow = unique-match +auto-follow-timeout = 0 +next-regexes = \bnext\b,\bmore\b,\bnewer\b,\b[>→≫]\b,\b(>>|»)\b,\bcontinue\b +prev-regexes = \bprev(ious)?\b,\bback\b,\bolder\b,\b[<←≪]\b,\b(<<|«)\b +find-implementation = python +hide-unmatched-rapid-hints = true +[searchengines] +DEFAULT = https://duckduckgo.com/?q={} +[aliases] +[colors] +completion.fg = white +completion.bg = #333333 +completion.alternate-bg = #444444 +completion.category.fg = white +completion.category.bg = qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #888888, stop:1 #505050) +completion.category.border.top = black +completion.category.border.bottom = ${completion.category.border.top} +completion.item.selected.fg = black +completion.item.selected.bg = #e8c000 +completion.item.selected.border.top = #bbbb00 +completion.item.selected.border.bottom = ${completion.item.selected.border.top} +completion.match.fg = #ff4444 +completion.scrollbar.fg = ${completion.fg} +completion.scrollbar.bg = ${completion.bg} +statusbar.fg = white +statusbar.bg = black +statusbar.fg.private = ${statusbar.fg} +statusbar.bg.private = #666666 +statusbar.fg.insert = ${statusbar.fg} +statusbar.bg.insert = darkgreen +statusbar.fg.command = ${statusbar.fg} +statusbar.bg.command = ${statusbar.bg} +statusbar.fg.command.private = ${statusbar.fg.private} +statusbar.bg.command.private = ${statusbar.bg.private} +statusbar.fg.caret = ${statusbar.fg} +statusbar.bg.caret = purple +statusbar.fg.caret-selection = ${statusbar.fg} +statusbar.bg.caret-selection = #a12dff +statusbar.progress.bg = white +statusbar.url.fg = ${statusbar.fg} +statusbar.url.fg.success = white +statusbar.url.fg.success.https = lime +statusbar.url.fg.error = orange +statusbar.url.fg.warn = yellow +statusbar.url.fg.hover = aqua +tabs.fg.odd = white +tabs.bg.odd = grey +tabs.fg.even = white +tabs.bg.even = darkgrey +tabs.fg.selected.odd = white +tabs.bg.selected.odd = black +tabs.fg.selected.even = ${tabs.fg.selected.odd} +tabs.bg.selected.even = ${tabs.bg.selected.odd} +tabs.bg.bar = #555555 +tabs.indicator.start = #0000aa +tabs.indicator.stop = #00aa00 +tabs.indicator.error = #ff0000 +tabs.indicator.system = rgb +hints.fg = black +hints.bg = qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 rgba(255, 247, 133, 0.8), stop:1 rgba(255, 197, 66, 0.8)) +hints.fg.match = green +downloads.bg.bar = black +downloads.fg.start = white +downloads.bg.start = #0000aa +downloads.fg.stop = ${downloads.fg.start} +downloads.bg.stop = #00aa00 +downloads.fg.system = rgb +downloads.bg.system = rgb +downloads.fg.error = white +downloads.bg.error = red +webpage.bg = white +keyhint.fg = #FFFFFF +keyhint.fg.suffix = #FFFF00 +keyhint.bg = rgba(0, 0, 0, 80%) +messages.fg.error = white +messages.bg.error = red +messages.border.error = #bb0000 +messages.fg.warning = white +messages.bg.warning = darkorange +messages.border.warning = #d47300 +messages.fg.info = white +messages.bg.info = black +messages.border.info = #333333 +prompts.fg = white +prompts.bg = darkblue +prompts.selected.bg = #308cc6 +[fonts] +_monospace = xos4 Terminus, Terminus, Monospace, "DejaVu Sans Mono", Monaco, "Bitstream Vera Sans Mono", "Andale Mono", "Courier New", Courier, "Liberation Mono", monospace, Fixed, Consolas, Terminal +completion = 8pt ${_monospace} +completion.category = bold ${completion} +tabbar = 8pt ${_monospace} +statusbar = 8pt ${_monospace} +downloads = 8pt ${_monospace} +hints = bold 13px ${_monospace} +debug-console = 8pt ${_monospace} +web-family-standard = +web-family-fixed = +web-family-serif = +web-family-sans-serif = +web-family-cursive = +web-family-fantasy = +web-size-minimum = 0 +web-size-minimum-logical = 6 +web-size-default = 16 +web-size-default-fixed = 13 +keyhint = 8pt ${_monospace} +messages.error = 8pt ${_monospace} +messages.warning = 8pt ${_monospace} +messages.info = 8pt ${_monospace} +prompts = 8pt sans-serif diff --git a/tests/unit/mainwindow/statusbar/test_backforward.py b/tests/unit/mainwindow/statusbar/test_backforward.py new file mode 100644 index 000000000..f2dec3d3f --- /dev/null +++ b/tests/unit/mainwindow/statusbar/test_backforward.py @@ -0,0 +1,76 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""Test Backforward widget.""" + +import pytest + +from qutebrowser.mainwindow.statusbar import backforward + + +@pytest.fixture +def backforward_widget(qtbot): + widget = backforward.Backforward() + qtbot.add_widget(widget) + return widget + + +@pytest.mark.parametrize('can_go_back, can_go_forward, expected_text', [ + (False, False, ''), + (True, False, '[<]'), + (False, True, '[>]'), + (True, True, '[<>]'), +]) +def test_backforward_widget(backforward_widget, stubs, + fake_web_tab, can_go_back, can_go_forward, + expected_text): + """Ensure the Backforward widget shows the correct text.""" + tab = fake_web_tab(can_go_back=can_go_back, can_go_forward=can_go_forward) + tabbed_browser = stubs.TabbedBrowserStub() + tabbed_browser.current_index = 1 + tabbed_browser.tabs = [tab] + backforward_widget.on_tab_cur_url_changed(tabbed_browser) + assert backforward_widget.text() == expected_text + assert backforward_widget.isVisible() == bool(expected_text) + + # Check that the widget gets reset if empty. + if can_go_back and can_go_forward: + tab = fake_web_tab(can_go_back=False, can_go_forward=False) + tabbed_browser.tabs = [tab] + backforward_widget.on_tab_cur_url_changed(tabbed_browser) + assert backforward_widget.text() == '' + assert not backforward_widget.isVisible() + + +def test_none_tab(backforward_widget, stubs, fake_web_tab): + """Make sure nothing crashes when passing None as tab.""" + tab = fake_web_tab(can_go_back=True, can_go_forward=True) + tabbed_browser = stubs.TabbedBrowserStub() + tabbed_browser.current_index = 1 + tabbed_browser.tabs = [tab] + backforward_widget.on_tab_cur_url_changed(tabbed_browser) + + assert backforward_widget.text() == '[<>]' + assert backforward_widget.isVisible() + + tabbed_browser.current_index = -1 + backforward_widget.on_tab_cur_url_changed(tabbed_browser) + + assert backforward_widget.text() == '' + assert not backforward_widget.isVisible() diff --git a/tests/unit/mainwindow/test_messageview.py b/tests/unit/mainwindow/test_messageview.py index bb913aaa0..3a502b2bd 100644 --- a/tests/unit/mainwindow/test_messageview.py +++ b/tests/unit/mainwindow/test_messageview.py @@ -84,6 +84,19 @@ def test_changing_timer_with_messages_shown(qtbot, view, config_stub): config_stub.set_obj('messages.timeout', 100) +@pytest.mark.parametrize('count, expected', [(1, 100), (3, 300), + (5, 500), (7, 500)]) +def test_show_multiple_messages_longer(view, count, expected): + """When there are multiple messages, messages should be shown longer. + + There is an upper maximum to avoid messages never disappearing. + """ + for message_number in range(1, count+1): + view.show_message(usertypes.MessageLevel.info, + 'test ' + str(message_number)) + assert view._clear_timer.interval() == expected + + @pytest.mark.parametrize('replace1, replace2, length', [ (False, False, 2), # Two stacked messages (True, True, 1), # Two replaceable messages diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py index b71b0c457..9ca4fdcbb 100644 --- a/tests/unit/misc/test_editor.py +++ b/tests/unit/misc/test_editor.py @@ -111,24 +111,30 @@ class TestFileHandling: os.remove(filename) - @pytest.mark.posix def test_unreadable(self, message_mock, editor, caplog): """Test file handling when closing with an unreadable file.""" editor.edit("") filename = editor._file.name assert os.path.exists(filename) os.chmod(filename, 0o077) + if os.access(filename, os.R_OK): + # Docker container or similar + pytest.skip("File was still readable") + with caplog.at_level(logging.ERROR): editor._proc.finished.emit(0, QProcess.NormalExit) assert not os.path.exists(filename) msg = message_mock.getmsg(usertypes.MessageLevel.error) assert msg.text.startswith("Failed to read back edited file: ") - @pytest.mark.posix def test_unwritable(self, monkeypatch, message_mock, editor, tmpdir, caplog): """Test file handling when the initial file is not writable.""" tmpdir.chmod(0) + if os.access(str(tmpdir), os.W_OK): + # Docker container or similar + pytest.skip("File was still writable") + monkeypatch.setattr(editormod.tempfile, 'tempdir', str(tmpdir)) with caplog.at_level(logging.ERROR): diff --git a/tests/unit/misc/test_guiprocess.py b/tests/unit/misc/test_guiprocess.py index 3101b7427..749031367 100644 --- a/tests/unit/misc/test_guiprocess.py +++ b/tests/unit/misc/test_guiprocess.py @@ -128,11 +128,13 @@ def test_start_detached_error(fake_proc, message_mock, caplog): """Test starting a detached process with ok=False.""" argv = ['foo', 'bar'] fake_proc._proc.startDetached.return_value = (False, 0) - fake_proc._proc.error.return_value = "Error message" + fake_proc._proc.error.return_value = QProcess.FailedToStart with caplog.at_level(logging.ERROR): fake_proc.start_detached(*argv) msg = message_mock.getmsg(usertypes.MessageLevel.error) - assert msg.text == "Error while spawning testprocess: Error message." + expected = ("Error while spawning testprocess: The process failed to " + "start.") + assert msg.text == expected def test_double_start(qtbot, proc, py_proc): diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py index e364d6cc6..d0758b28d 100644 --- a/tests/unit/misc/test_ipc.py +++ b/tests/unit/misc/test_ipc.py @@ -26,7 +26,6 @@ import collections import logging import json import hashlib -import subprocess from unittest import mock import pytest @@ -182,34 +181,34 @@ def md5(inp): class TestSocketName: - LEGACY_TESTS = [ - (None, 'qutebrowser-testusername'), - ('/x', 'qutebrowser-testusername-{}'.format(md5('/x'))), - ] - POSIX_TESTS = [ (None, 'ipc-{}'.format(md5('testusername'))), ('/x', 'ipc-{}'.format(md5('testusername-/x'))), ] + WINDOWS_TESTS = [ + (None, 'qutebrowser-testusername'), + ('/x', 'qutebrowser-testusername-{}'.format(md5('/x'))), + ] + @pytest.fixture(autouse=True) def patch_user(self, monkeypatch): monkeypatch.setattr(ipc.getpass, 'getuser', lambda: 'testusername') - @pytest.mark.parametrize('basedir, expected', LEGACY_TESTS) - def test_legacy(self, basedir, expected): - socketname = ipc._get_socketname(basedir, legacy=True) - assert socketname == expected - - @pytest.mark.parametrize('basedir, expected', LEGACY_TESTS) + @pytest.mark.parametrize('basedir, expected', WINDOWS_TESTS) @pytest.mark.windows def test_windows(self, basedir, expected): socketname = ipc._get_socketname(basedir) assert socketname == expected - @pytest.mark.osx + @pytest.mark.parametrize('basedir, expected', WINDOWS_TESTS) + def test_windows_on_posix(self, basedir, expected): + socketname = ipc._get_socketname_windows(basedir) + assert socketname == expected + + @pytest.mark.mac @pytest.mark.parametrize('basedir, expected', POSIX_TESTS) - def test_os_x(self, basedir, expected): + def test_mac(self, basedir, expected): socketname = ipc._get_socketname(basedir) parts = socketname.split(os.sep) assert parts[-2] == 'qute_test' @@ -223,7 +222,7 @@ class TestSocketName: assert socketname == expected_path def test_other_unix(self): - """Fake test for POSIX systems which aren't Linux/OS X. + """Fake test for POSIX systems which aren't Linux/macOS. We probably would adjust the code first to make it work on that platform. @@ -512,7 +511,7 @@ class TestSendToRunningInstance: assert msg == "No existing instance present (error 2)" @pytest.mark.parametrize('has_cwd', [True, False]) - @pytest.mark.linux(reason="Causes random trouble on Windows and OS X") + @pytest.mark.linux(reason="Causes random trouble on Windows and macOS") def test_normal(self, qtbot, tmpdir, ipc_server, mocker, has_cwd): ipc_server.listen() @@ -562,7 +561,7 @@ class TestSendToRunningInstance: ipc.send_to_running_instance('qute-test', [], None, socket=socket) -@pytest.mark.not_osx(reason="https://github.com/qutebrowser/qutebrowser/" +@pytest.mark.not_mac(reason="https://github.com/qutebrowser/qutebrowser/" "issues/975") def test_timeout(qtbot, caplog, qlocalsocket, ipc_server): ipc_server._timer.setInterval(100) @@ -629,15 +628,7 @@ class TestSendOrListen: setattr(m, attr, getattr(QLocalSocket, attr)) return m - @pytest.fixture - def legacy_server(self, args): - legacy_name = ipc._get_socketname(args.basedir, legacy=True) - legacy_server = ipc.IPCServer(legacy_name) - legacy_server.listen() - yield legacy_server - legacy_server.shutdown() - - @pytest.mark.linux(reason="Flaky on Windows and OS X") + @pytest.mark.linux(reason="Flaky on Windows and macOS") def test_normal_connection(self, caplog, qtbot, args): ret_server = ipc.send_or_listen(args) assert isinstance(ret_server, ipc.IPCServer) @@ -652,54 +643,6 @@ class TestSendOrListen: assert ret_client is None @pytest.mark.posix(reason="Unneeded on Windows") - def test_legacy_name(self, caplog, qtbot, args, legacy_server): - with qtbot.waitSignal(legacy_server.got_args): - ret = ipc.send_or_listen(args) - assert ret is None - msgs = [e.message for e in caplog.records] - assert "Connecting to {}".format(legacy_server._socketname) in msgs - - @pytest.mark.posix(reason="Unneeded on Windows") - def test_stale_legacy_server(self, caplog, qtbot, args, legacy_server, - ipc_server, py_proc): - legacy_name = ipc._get_socketname(args.basedir, legacy=True) - logging.debug('== Setting up the legacy server ==') - cmdline = py_proc(""" - import sys - - from PyQt5.QtCore import QCoreApplication - from PyQt5.QtNetwork import QLocalServer - - app = QCoreApplication([]) - - QLocalServer.removeServer(sys.argv[1]) - server = QLocalServer() - - ok = server.listen(sys.argv[1]) - assert ok - - print(server.fullServerName()) - """) - - name = subprocess.check_output( - [cmdline[0]] + cmdline[1] + [legacy_name]) - name = name.decode('utf-8').rstrip('\n') - - # Closing the server should not remove the FIFO yet - assert os.path.exists(name) - - ## Setting up the new server - logging.debug('== Setting up new server ==') - ret_server = ipc.send_or_listen(args) - assert isinstance(ret_server, ipc.IPCServer) - - logging.debug('== Connecting ==') - with qtbot.waitSignal(ret_server.got_args): - ret_client = ipc.send_or_listen(args) - - assert ret_client is None - - @pytest.mark.posix(reason="Unneeded on Windows") def test_correct_socket_name(self, args): server = ipc.send_or_listen(args) expected_dir = ipc._get_socketname(args.basedir) @@ -723,9 +666,7 @@ class TestSendOrListen: qlocalsocket_mock().waitForConnected.side_effect = [False, True] qlocalsocket_mock().error.side_effect = [ - QLocalSocket.ServerNotFoundError, # legacy name QLocalSocket.ServerNotFoundError, - QLocalSocket.ServerNotFoundError, # legacy name QLocalSocket.UnknownSocketError, QLocalSocket.UnknownSocketError, # error() gets called twice ] @@ -761,10 +702,8 @@ class TestSendOrListen: # If it fails, that's the "not sent" case above. qlocalsocket_mock().waitForConnected.side_effect = [False, has_error] qlocalsocket_mock().error.side_effect = [ - QLocalSocket.ServerNotFoundError, # legacy name QLocalSocket.ServerNotFoundError, QLocalSocket.ServerNotFoundError, - QLocalSocket.ServerNotFoundError, # legacy name QLocalSocket.ConnectionRefusedError, QLocalSocket.ConnectionRefusedError, # error() gets called twice ] @@ -812,7 +751,7 @@ class TestSendOrListen: @pytest.mark.windows -@pytest.mark.osx +@pytest.mark.mac def test_long_username(monkeypatch): """See https://github.com/qutebrowser/qutebrowser/issues/888.""" username = 'alexandercogneau' diff --git a/tests/unit/misc/test_lineparser.py b/tests/unit/misc/test_lineparser.py index af439c006..0c78035b7 100644 --- a/tests/unit/misc/test_lineparser.py +++ b/tests/unit/misc/test_lineparser.py @@ -58,8 +58,8 @@ class TestBaseLineParser: mocker.patch('builtins.open', mock.mock_open()) with lineparser._open('r'): - with pytest.raises(IOError, match="Refusing to double-open " - "AppendLineParser."): + with pytest.raises(IOError, + match="Refusing to double-open LineParser."): with lineparser._open('r'): pass @@ -115,7 +115,8 @@ class TestLineParser: def test_double_open(self, lineparser): """Test if save() bails on an already open file.""" with lineparser._open('r'): - with pytest.raises(IOError): + with pytest.raises(IOError, + match="Refusing to double-open LineParser."): lineparser.save() def test_prepare_save(self, tmpdir, lineparser): @@ -125,83 +126,3 @@ class TestLineParser: lineparser._prepare_save = lambda: False lineparser.save() assert (tmpdir / 'file').read() == 'pristine\n' - - -class TestAppendLineParser: - - BASE_DATA = ['old data 1', 'old data 2'] - - @pytest.fixture - def lineparser(self, tmpdir): - """Fixture to get an AppendLineParser for tests.""" - lp = lineparsermod.AppendLineParser(str(tmpdir), 'file') - lp.new_data = self.BASE_DATA - lp.save() - return lp - - def _get_expected(self, new_data): - """Get the expected data with newlines.""" - return '\n'.join(self.BASE_DATA + new_data) + '\n' - - def test_save(self, tmpdir, lineparser): - """Test save().""" - new_data = ['new data 1', 'new data 2'] - lineparser.new_data = new_data - lineparser.save() - assert (tmpdir / 'file').read() == self._get_expected(new_data) - - def test_clear(self, tmpdir, lineparser): - """Check if calling clear() empties both pending and persisted data.""" - lineparser.new_data = ['one', 'two'] - lineparser.save() - assert (tmpdir / 'file').read() == "old data 1\nold data 2\none\ntwo\n" - - lineparser.new_data = ['one', 'two'] - lineparser.clear() - lineparser.save() - assert not lineparser.new_data - assert (tmpdir / 'file').read() == "" - - def test_iter_without_open(self, lineparser): - """Test __iter__ without having called open().""" - with pytest.raises(ValueError): - iter(lineparser) - - def test_iter(self, lineparser): - """Test __iter__.""" - new_data = ['new data 1', 'new data 2'] - lineparser.new_data = new_data - with lineparser.open(): - assert list(lineparser) == self.BASE_DATA + new_data - - def test_iter_not_found(self, mocker): - """Test __iter__ with no file.""" - open_mock = mocker.patch( - 'qutebrowser.misc.lineparser.AppendLineParser._open') - open_mock.side_effect = FileNotFoundError - new_data = ['new data 1', 'new data 2'] - linep = lineparsermod.AppendLineParser('foo', 'bar') - linep.new_data = new_data - with linep.open(): - assert list(linep) == new_data - - def test_get_recent_none(self, tmpdir): - """Test get_recent with no data.""" - (tmpdir / 'file2').ensure() - linep = lineparsermod.AppendLineParser(str(tmpdir), 'file2') - assert linep.get_recent() == [] - - def test_get_recent_little(self, lineparser): - """Test get_recent with little data.""" - data = [e + '\n' for e in self.BASE_DATA] - assert lineparser.get_recent() == data - - def test_get_recent_much(self, lineparser): - """Test get_recent with much data.""" - size = 64 - new_data = ['new data {}'.format(i) for i in range(size)] - lineparser.new_data = new_data - lineparser.save() - data = os.linesep.join(self.BASE_DATA + new_data) + os.linesep - data = [e + '\n' for e in data[-size:].splitlines()] - assert lineparser.get_recent(size) == data diff --git a/tests/unit/misc/test_sessions.py b/tests/unit/misc/test_sessions.py index 0ae55476f..231e7f997 100644 --- a/tests/unit/misc/test_sessions.py +++ b/tests/unit/misc/test_sessions.py @@ -215,11 +215,6 @@ class TestSave: objreg.delete('main-window', scope='window', window=0) objreg.delete('tabbed-browser', scope='window', window=0) - def test_update_completion_signal(self, sess_man, tmpdir, qtbot): - session_path = tmpdir / 'foo.yml' - with qtbot.waitSignal(sess_man.update_completion): - sess_man.save(str(session_path)) - def test_no_state_config(self, sess_man, tmpdir, state_config): session_path = tmpdir / 'foo.yml' sess_man.save(str(session_path)) @@ -367,14 +362,6 @@ class TestLoadTab: assert loaded_item.original_url == expected -def test_delete_update_completion_signal(sess_man, qtbot, tmpdir): - sess = tmpdir / 'foo.yml' - sess.ensure() - - with qtbot.waitSignal(sess_man.update_completion): - sess_man.delete(str(sess)) - - class TestListSessions: def test_no_sessions(self, tmpdir): diff --git a/tests/unit/misc/test_sql.py b/tests/unit/misc/test_sql.py new file mode 100644 index 000000000..8997afc3b --- /dev/null +++ b/tests/unit/misc/test_sql.py @@ -0,0 +1,179 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""Test the SQL API.""" + +import pytest +from qutebrowser.misc import sql + + +pytestmark = pytest.mark.usefixtures('init_sql') + + +def test_init(): + sql.SqlTable('Foo', ['name', 'val', 'lucky']) + # should not error if table already exists + sql.SqlTable('Foo', ['name', 'val', 'lucky']) + + +def test_insert(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + with qtbot.waitSignal(table.changed): + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + with qtbot.waitSignal(table.changed): + table.insert({'name': 'wan', 'val': 1, 'lucky': False}) + + +def test_insert_replace(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky'], + constraints={'name': 'PRIMARY KEY'}) + with qtbot.waitSignal(table.changed): + table.insert({'name': 'one', 'val': 1, 'lucky': False}, replace=True) + with qtbot.waitSignal(table.changed): + table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=True) + assert list(table) == [('one', 11, True)] + + with pytest.raises(sql.SqlException): + table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=False) + + +def test_insert_batch(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + + with qtbot.waitSignal(table.changed): + table.insert_batch({'name': ['one', 'nine', 'thirteen'], + 'val': [1, 9, 13], + 'lucky': [False, False, True]}) + + assert list(table) == [('one', 1, False), + ('nine', 9, False), + ('thirteen', 13, True)] + + +def test_insert_batch_replace(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky'], + constraints={'name': 'PRIMARY KEY'}) + + with qtbot.waitSignal(table.changed): + table.insert_batch({'name': ['one', 'nine', 'thirteen'], + 'val': [1, 9, 13], + 'lucky': [False, False, True]}) + + with qtbot.waitSignal(table.changed): + table.insert_batch({'name': ['one', 'nine'], + 'val': [11, 19], + 'lucky': [True, True]}, + replace=True) + + assert list(table) == [('thirteen', 13, True), + ('one', 11, True), + ('nine', 19, True)] + + with pytest.raises(sql.SqlException): + table.insert_batch({'name': ['one', 'nine'], + 'val': [11, 19], + 'lucky': [True, True]}) + + +def test_iter(): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + table.insert({'name': 'nine', 'val': 9, 'lucky': False}) + table.insert({'name': 'thirteen', 'val': 13, 'lucky': True}) + assert list(table) == [('one', 1, False), + ('nine', 9, False), + ('thirteen', 13, True)] + + +@pytest.mark.parametrize('rows, sort_by, sort_order, limit, result', [ + ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'asc', 5, + [(1, 6), (2, 5), (3, 4)]), + ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'desc', 3, + [(3, 4), (2, 5), (1, 6)]), + ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'b', 'desc', 2, + [(1, 6), (2, 5)]), + ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'asc', -1, + [(1, 6), (2, 5), (3, 4)]), +]) +def test_select(rows, sort_by, sort_order, limit, result): + table = sql.SqlTable('Foo', ['a', 'b']) + for row in rows: + table.insert(row) + assert list(table.select(sort_by, sort_order, limit)) == result + + +def test_delete(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + table.insert({'name': 'nine', 'val': 9, 'lucky': False}) + table.insert({'name': 'thirteen', 'val': 13, 'lucky': True}) + with pytest.raises(KeyError): + table.delete('name', 'nope') + with qtbot.waitSignal(table.changed): + table.delete('name', 'thirteen') + assert list(table) == [('one', 1, False), ('nine', 9, False)] + with qtbot.waitSignal(table.changed): + table.delete('lucky', False) + assert not list(table) + + +def test_len(): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + assert len(table) == 0 + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + assert len(table) == 1 + table.insert({'name': 'nine', 'val': 9, 'lucky': False}) + assert len(table) == 2 + table.insert({'name': 'thirteen', 'val': 13, 'lucky': True}) + assert len(table) == 3 + + +def test_contains(): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + table.insert({'name': 'nine', 'val': 9, 'lucky': False}) + table.insert({'name': 'thirteen', 'val': 13, 'lucky': True}) + + name_query = table.contains_query('name') + val_query = table.contains_query('val') + lucky_query = table.contains_query('lucky') + + assert name_query.run(val='one').value() + assert name_query.run(val='thirteen').value() + assert val_query.run(val=9).value() + assert lucky_query.run(val=False).value() + assert lucky_query.run(val=True).value() + assert not name_query.run(val='oone').value() + assert not name_query.run(val=1).value() + assert not name_query.run(val='*').value() + assert not val_query.run(val=10).value() + + +def test_delete_all(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + table.insert({'name': 'nine', 'val': 9, 'lucky': False}) + table.insert({'name': 'thirteen', 'val': 13, 'lucky': True}) + with qtbot.waitSignal(table.changed): + table.delete_all() + assert list(table) == [] + + +def test_version(): + assert isinstance(sql.version(), str) diff --git a/tests/unit/utils/test_qtutils.py b/tests/unit/utils/test_qtutils.py index 99e1091b4..ff8b81c9a 100644 --- a/tests/unit/utils/test_qtutils.py +++ b/tests/unit/utils/test_qtutils.py @@ -550,7 +550,7 @@ if test_file is not None and sys.platform != 'darwin': # here which defines unittest TestCases to run the python tests over # PyQIODevice. - # Those are not run on OS X because that seems to cause a hang sometimes. + # Those are not run on macOS because that seems to cause a hang sometimes. @pytest.fixture(scope='session', autouse=True) def clean_up_python_testfile(): diff --git a/tests/unit/utils/test_standarddir.py b/tests/unit/utils/test_standarddir.py index c21ff7416..ea3368862 100644 --- a/tests/unit/utils/test_standarddir.py +++ b/tests/unit/utils/test_standarddir.py @@ -167,8 +167,8 @@ class TestStandardDir: (standarddir.cache, 2, ['Caches', 'qute_test']), (standarddir.download, 1, ['Downloads']), ]) - @pytest.mark.osx - def test_os_x(self, func, elems, expected): + @pytest.mark.mac + def test_mac(self, func, elems, expected): assert func().split(os.sep)[-elems:] == expected diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index dc925e215..255c88344 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -759,37 +759,6 @@ def test_sanitize_filename_empty_replacement(): assert utils.sanitize_filename(name, replacement=None) == 'Bad File' -class TestNewestSlice: - - """Test newest_slice.""" - - def test_count_minus_two(self): - """Test with a count of -2.""" - with pytest.raises(ValueError): - utils.newest_slice([], -2) - - @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) - - class TestGetSetClipboard: @pytest.fixture(autouse=True) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 32c3fcf5c..f9da37841 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -21,6 +21,7 @@ import io import sys +import collections import os.path import subprocess import contextlib @@ -475,29 +476,31 @@ 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. + modules: 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. + done normally for modules not in self. modules. """ def __init__(self): - self.exists = { - 'sip': True, - 'colorama': True, - 'pypeg2': True, - 'jinja2': True, - 'pygments': True, - 'yaml': True, - 'cssutils': True, - 'typing': True, - 'PyQt5.QtWebEngineWidgets': True, - 'PyQt5.QtWebKitWidgets': True, - 'OpenGL': True, - } + self.modules = collections.OrderedDict([ + ('sip', True), + ('colorama', True), + ('pypeg2', True), + ('jinja2', True), + ('pygments', True), + ('yaml', True), + ('cssutils', True), + ('typing', True), + ('PyQt5.QtWebEngineWidgets', True), + ('PyQt5.QtWebKitWidgets', True), + ]) + self.no_version_attribute = ['sip', 'typing', + 'PyQt5.QtWebEngineWidgets', + 'PyQt5.QtWebKitWidgets'] self.version_attribute = '__version__' self.version = '1.2.3' self._real_import = builtins.__import__ @@ -509,10 +512,10 @@ class ImportFake: The imported fake module, or None if normal importing should be used. """ - if name not in self.exists: + if name not in self.modules: # Not one of the modules to test -> use real import return None - elif self.exists[name]: + elif self.modules[name]: ns = types.SimpleNamespace() if self.version_attribute is not None: setattr(ns, self.version_attribute, self.version) @@ -551,14 +554,14 @@ class TestModuleVersions: """Tests for _module_versions().""" - @pytest.mark.usefixtures('import_fake') - def test_all_present(self): + def test_all_present(self, import_fake): """Test with all modules present in version 1.2.3.""" - expected = ['sip: yes', 'colorama: 1.2.3', 'pypeg2: 1.2.3', - 'jinja2: 1.2.3', 'pygments: 1.2.3', 'yaml: 1.2.3', - 'cssutils: 1.2.3', 'typing: yes', 'OpenGL: 1.2.3', - 'PyQt5.QtWebEngineWidgets: yes', - 'PyQt5.QtWebKitWidgets: yes'] + expected = [] + for name in import_fake.modules: + if name in import_fake.no_version_attribute: + expected.append('{}: yes'.format(name)) + else: + expected.append('{}: 1.2.3'.format(name)) assert version._module_versions() == expected @pytest.mark.parametrize('module, idx, expected', [ @@ -574,36 +577,31 @@ class TestModuleVersions: idx: The index where the given text is expected. expected: The expected text. """ - import_fake.exists[module] = False + import_fake.modules[module] = False assert version._module_versions()[idx] == expected - @pytest.mark.parametrize('value, expected', [ - ('VERSION', ['sip: yes', 'colorama: 1.2.3', 'pypeg2: yes', - 'jinja2: yes', 'pygments: yes', 'yaml: yes', - 'cssutils: yes', 'typing: yes', 'OpenGL: yes', - 'PyQt5.QtWebEngineWidgets: yes', - 'PyQt5.QtWebKitWidgets: yes']), - ('SIP_VERSION_STR', ['sip: 1.2.3', 'colorama: yes', 'pypeg2: yes', - 'jinja2: yes', 'pygments: yes', 'yaml: yes', - 'cssutils: yes', 'typing: yes', 'OpenGL: yes', - 'PyQt5.QtWebEngineWidgets: yes', - 'PyQt5.QtWebKitWidgets: yes']), - (None, ['sip: yes', 'colorama: yes', 'pypeg2: yes', 'jinja2: yes', - 'pygments: yes', 'yaml: yes', 'cssutils: yes', 'typing: yes', - 'OpenGL: yes', 'PyQt5.QtWebEngineWidgets: yes', - 'PyQt5.QtWebKitWidgets: yes']), + @pytest.mark.parametrize('attribute, expected_modules', [ + ('VERSION', ['colorama']), + ('SIP_VERSION_STR', ['sip']), + (None, []), ]) - def test_version_attribute(self, value, expected, import_fake): + def test_version_attribute(self, attribute, expected_modules, 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. + attribute: The name of the version attribute. expected: The expected return value. """ - import_fake.version_attribute = value + import_fake.version_attribute = attribute + expected = [] + for name in import_fake.modules: + if name in expected_modules: + expected.append('{}: 1.2.3'.format(name)) + else: + expected.append('{}: yes'.format(name)) assert version._module_versions() == expected @pytest.mark.parametrize('name, has_version', [ @@ -669,8 +667,8 @@ class TestOsInfo: (('', ('', '', ''), ''), ''), (('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. + def test_mac_fake(self, monkeypatch, mac_ver, mac_ver_str): + """Test with a fake macOS. Args: mac_ver: The tuple to set platform.mac_ver() to. @@ -699,9 +697,9 @@ class TestOsInfo: """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.""" + @pytest.mark.mac + def test_mac_real(self): + """Make sure there are no exceptions with a real macOS.""" version._os_info() @@ -759,14 +757,16 @@ class FakeQSslSocket: Attributes: _version: What QSslSocket::sslLibraryVersionString() should return. + _support: Whether SSL is supported. """ - def __init__(self, version=None): + def __init__(self, version=None, support=True): self._version = version + self._support = support def supportsSsl(self): """Fake for QSslSocket::supportsSsl().""" - return True + return self._support def sslLibraryVersionString(self): """Fake for QSslSocket::sslLibraryVersionString().""" @@ -799,18 +799,30 @@ def test_chromium_version_unpatched(qapp): assert version._chromium_version() not in ['', 'unknown', 'unavailable'] -@pytest.mark.parametrize(['git_commit', 'frozen', 'style', 'with_webkit', - 'known_distribution'], [ - (True, False, True, True, True), # normal - (False, False, True, True, True), # no git commit - (True, True, True, True, True), # frozen - (True, True, False, True, True), # no style - (True, False, True, False, True), # no webkit - (True, False, True, 'ng', True), # QtWebKit-NG - (True, False, True, True, False), # unknown Linux distribution -]) # pylint: disable=too-many-locals -def test_version_output(git_commit, frozen, style, with_webkit, - known_distribution, stubs, monkeypatch): +class VersionParams: + + def __init__(self, name, git_commit=True, frozen=False, style=True, + with_webkit=True, known_distribution=True, ssl_support=True): + self.name = name + self.git_commit = git_commit + self.frozen = frozen + self.style = style + self.with_webkit = with_webkit + self.known_distribution = known_distribution + self.ssl_support = ssl_support + + +@pytest.mark.parametrize('params', [ + VersionParams('normal'), + VersionParams('no-git-commit', git_commit=False), + VersionParams('frozen', frozen=True), + VersionParams('no-style', style=False), + VersionParams('no-webkit', with_webkit=False), + VersionParams('webkit-ng', with_webkit='ng'), + VersionParams('unknown-dist', known_distribution=False), + VersionParams('no-ssl', ssl_support=False), +], ids=lambda param: param.name) +def test_version_output(params, stubs, monkeypatch): """Test version.version().""" class FakeWebEngineProfile: def httpUserAgent(self): @@ -820,36 +832,38 @@ def test_version_output(git_commit, frozen, style, with_webkit, patches = { 'qutebrowser.__file__': os.path.join(import_path, '__init__.py'), 'qutebrowser.__version__': 'VERSION', - '_git_str': lambda: ('GIT COMMIT' if git_commit else None), + '_git_str': lambda: ('GIT COMMIT' if params.git_commit else None), 'platform.python_implementation': lambda: 'PYTHON IMPLEMENTATION', 'platform.python_version': lambda: 'PYTHON VERSION', 'PYQT_VERSION_STR': 'PYQT VERSION', 'earlyinit.qt_version': lambda: 'QT VERSION', '_module_versions': lambda: ['MODULE VERSION 1', 'MODULE VERSION 2'], '_pdfjs_version': lambda: 'PDFJS VERSION', - 'QSslSocket': FakeQSslSocket('SSL VERSION'), + 'QSslSocket': FakeQSslSocket('SSL VERSION', params.ssl_support), 'platform.platform': lambda: 'PLATFORM', 'platform.architecture': lambda: ('ARCHITECTURE', ''), '_os_info': lambda: ['OS INFO 1', 'OS INFO 2'], '_path_info': lambda: {'PATH DESC': 'PATH NAME'}, - 'QApplication': (stubs.FakeQApplication(style='STYLE') if style else + 'QApplication': (stubs.FakeQApplication(style='STYLE') + if params.style else stubs.FakeQApplication(instance=None)), 'QLibraryInfo.location': (lambda _loc: 'QT PATH'), + 'sql.version': lambda: 'SQLITE VERSION', } substitutions = { - 'git_commit': '\nGit commit: GIT COMMIT' if git_commit else '', - 'style': '\nStyle: STYLE' if style else '', + 'git_commit': '\nGit commit: GIT COMMIT' if params.git_commit else '', + 'style': '\nStyle: STYLE' if params.style else '', 'qt': 'QT VERSION', - 'frozen': str(frozen), + 'frozen': str(params.frozen), 'import_path': import_path, } - if with_webkit: + if params.with_webkit: patches['qWebKitVersion'] = lambda: 'WEBKIT VERSION' patches['objects.backend'] = usertypes.Backend.QtWebKit patches['QWebEngineProfile'] = None - if with_webkit == 'ng': + if params.with_webkit == 'ng': backend = 'QtWebKit-NG' patches['qtutils.is_qtwebkit_ng'] = lambda: True else: @@ -862,7 +876,7 @@ def test_version_output(git_commit, frozen, style, with_webkit, patches['QWebEngineProfile'] = FakeWebEngineProfile substitutions['backend'] = 'QtWebEngine (Chromium CHROMIUMVERSION)' - if known_distribution: + if params.known_distribution: patches['distribution'] = lambda: version.DistributionInfo( parsed=version.Distribution.arch, version=None, pretty='LINUX DISTRIBUTION', id='arch') @@ -874,10 +888,12 @@ def test_version_output(git_commit, frozen, style, with_webkit, substitutions['linuxdist'] = '' substitutions['osinfo'] = 'OS INFO 1\nOS INFO 2\n' + substitutions['ssl'] = 'SSL VERSION' if params.ssl_support else 'no' + for attr, val in patches.items(): monkeypatch.setattr('qutebrowser.utils.version.' + attr, val) - if frozen: + if params.frozen: monkeypatch.setattr(sys, 'frozen', True, raising=False) else: monkeypatch.delattr(sys, 'frozen', raising=False) @@ -893,7 +909,8 @@ def test_version_output(git_commit, frozen, style, with_webkit, MODULE VERSION 1 MODULE VERSION 2 pdf.js: PDFJS VERSION - SSL: SSL VERSION + sqlite: SQLITE VERSION + QtNetwork SSL: {ssl} {style} Platform: PLATFORM, ARCHITECTURE{linuxdist} Frozen: {frozen} @@ -111,6 +111,28 @@ deps = PyQt5==5.8.2 commands = {envpython} -bb -m pytest {posargs:tests} +[testenv:py35-pyqt59] +basepython = python3.5 +setenv = + {[testenv]setenv} + QUTE_BDD_WEBENGINE=true +passenv = {[testenv]passenv} +deps = + {[testenv]deps} + PyQt5==5.9 +commands = {envpython} -bb -m pytest {posargs:tests} + +[testenv:py36-pyqt59] +basepython = {env:PYTHON:python3.6} +setenv = + {[testenv]setenv} + QUTE_BDD_WEBENGINE=true +passenv = {[testenv]passenv} +deps = + {[testenv]deps} + PyQt5==5.9 +commands = {envpython} -bb -m pytest {posargs:tests} + # other envs [testenv:mkvenv] |