diff options
author | Nicholas42 <nics-lohr@gmx.de> | 2021-07-19 13:55:22 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-07-19 13:55:22 +0200 |
commit | 9a7a905f8f392f65417a49187d413471d2fc8f84 (patch) | |
tree | 6c9b82b20aaab26595cb91b5ce8fe9a8e29fc6d8 | |
parent | 8aef7cedcd97d08ca48cff5f05f7844694c8be7a (diff) | |
parent | 5b0365ddeefbd0fba443f5c152f5efa0a393dbec (diff) | |
download | qutebrowser-9a7a905f8f392f65417a49187d413471d2fc8f84.tar.gz qutebrowser-9a7a905f8f392f65417a49187d413471d2fc8f84.zip |
Merge branch 'qutebrowser:master' into master
135 files changed, 1760 insertions, 796 deletions
diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6b05fd3bc..a95ba918d 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.2.1 +current_version = 2.3.0 commit = True message = Release v{new_version} tag = True @@ -16,6 +16,7 @@ exclude = .*,__pycache__,resources.py # (for pytest's __tracebackhide__) # F401: Unused import # N802: function name should be lowercase +# N818: exception name '...' should be named with an Error suffix # N806: variable in function should be lowercase # P101: format string does contain unindexed parameters # P102: docstring does contain unindexed parameters @@ -44,7 +45,7 @@ ignore = B001,B008,B305, E128,E226,E265,E501,E402,E266,E722,E731, F401, - N802, + N802,N818, P101,P102,P103, D102,D103,D106,D107,D104,D105,D209,D211,D401,D402,D403,D412,D413, A003, diff --git a/.github/workflows/bleeding.yml b/.github/workflows/bleeding.yml index 5d464e3ac..766f535d7 100644 --- a/.github/workflows/bleeding.yml +++ b/.github/workflows/bleeding.yml @@ -78,6 +78,9 @@ jobs: sed -i '' '/.-d., .--debug.,/s/$/ default=True,/' qutebrowser/qutebrowser.py - name: Run tox run: "tox -e build-release -- --asciidoc ../asciidoc/asciidoc.py --gh-token ${{ secrets.GITHUB_TOKEN }} ${{ matrix.args }}" + - name: Wait 90s to avoid upload errors + if: "contains(matrix.args, '--32bit')" + run: "sleep 90" - name: Upload artifacts uses: actions/upload-artifact@v2 with: @@ -98,16 +101,16 @@ jobs: uses: Gottox/irc-message-action@v1 if: "needs.tests.result == 'success' && needs.pyinstaller.result == 'success'" with: - server: chat.freenode.net - channel: '#qutebrowser-dev' + server: irc.libera.chat + channel: '#qutebrowser-bots' nickname: qutebrowser-bot message: "[${{ github.workflow }}] \u00033Success:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})" - name: Send non-success IRC notification uses: Gottox/irc-message-action@v1 if: "needs.tests.result != 'success' || needs.pyinstaller.result != 'success'" with: - server: chat.freenode.net - channel: '#qutebrowser-dev' + server: irc.libera.chat + channel: '#qutebrowser-bots' nickname: qutebrowser-bot message: "[${{ github.workflow }}] \u00034FAIL:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})\n tests: ${{ needs.tests.result }}, pyinstaller: ${{ needs.pyinstaller.result }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71c91e13b..e50ba2c60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,16 +215,16 @@ jobs: uses: Gottox/irc-message-action@v1 if: "needs.linters.result == 'success' && needs.tests.result == 'success' && needs.tests-docker.result == 'success' && needs.codeql.result == 'success'" with: - server: chat.freenode.net - channel: '#qutebrowser-dev' + server: irc.libera.chat + channel: '#qutebrowser-bots' nickname: qutebrowser-bot message: "[${{ github.workflow }}] \u00033Success:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})" - name: Send failure IRC notification uses: Gottox/irc-message-action@v1 if: "needs.linters.result == 'failure' || needs.tests.result == 'failure' || needs.tests-docker.result == 'failure' || needs.codeql.result == 'failure'" with: - server: chat.freenode.net - channel: '#qutebrowser-dev' + server: irc.libera.chat + channel: '#qutebrowser-bots' nickname: qutebrowser-bot message: "[${{ github.workflow }}] \u00034FAIL:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})\n linters: ${{ needs.linters.result }}, tests: ${{ needs.tests.result }}, tests-docker: ${{ needs.tests-docker.result }}, codeql: ${{ needs.codeql.result }}" @@ -232,16 +232,16 @@ jobs: uses: Gottox/irc-message-action@v1 if: "needs.linters.result == 'skipped' || needs.tests.result == 'skipped' || needs.tests-docker.result == 'skipped' || needs.codeql.result == 'skipped'" with: - server: chat.freenode.net - channel: '#qutebrowser-dev' + server: irc.libera.chat + channel: '#qutebrowser-bots' nickname: qutebrowser-bot message: "[${{ github.workflow }}] \u00038Skipped:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})" - name: Send cancelled IRC notification uses: Gottox/irc-message-action@v1 if: "needs.linters.result == 'cancelled' || needs.tests.result == 'cancelled' || needs.tests-docker.result == 'cancelled' || needs.codeql.result == 'cancelled'" with: - server: chat.freenode.net - channel: '#qutebrowser-dev' + server: irc.libera.chat + channel: '#qutebrowser-bots' nickname: qutebrowser-bot message: "[${{ github.workflow }}] \u000314Cancelled:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})\n linters: ${{ needs.linters.result }}, tests: ${{ needs.tests.result }}, tests-docker: ${{ needs.tests-docker.result }}, codeql: ${{ needs.codeql.result }}" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d4023d57c..2ac1bd58f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -47,15 +47,15 @@ jobs: uses: Gottox/irc-message-action@v1 if: "needs.docker.result == 'success'" with: - server: chat.freenode.net - channel: '#qutebrowser-dev' + server: irc.libera.chat + channel: '#qutebrowser-bots' nickname: qutebrowser-bot message: "[${{ github.workflow }}] \u00033Success:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})" - name: Send non-success IRC notification uses: Gottox/irc-message-action@v1 if: "needs.docker.result != 'success'" with: - server: chat.freenode.net - channel: '#qutebrowser-dev' + server: irc.libera.chat + channel: '#qutebrowser-bots' nickname: qutebrowser-bot message: "[${{ github.workflow }}] \u00034FAIL:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})" diff --git a/.github/workflows/recompile-requirements.yml b/.github/workflows/recompile-requirements.yml index 68a0d588f..00d088da8 100644 --- a/.github/workflows/recompile-requirements.yml +++ b/.github/workflows/recompile-requirements.yml @@ -77,16 +77,16 @@ jobs: uses: Gottox/irc-message-action@v1 if: "needs.update.result == 'success'" with: - server: chat.freenode.net - channel: '#qutebrowser-dev' + server: irc.libera.chat + channel: '#qutebrowser-bots' nickname: qutebrowser-bot message: "[${{ github.workflow }}] \u00033Success:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})" - name: Send non-success IRC notification uses: Gottox/irc-message-action@v1 if: "needs.update.result != 'success'" with: - server: chat.freenode.net - channel: '#qutebrowser-dev' + server: irc.libera.chat + channel: '#qutebrowser-bots' nickname: qutebrowser-bot message: "[${{ github.workflow }}] \u00034FAIL:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})\n linters: ${{ needs.linters.result }}, tests: ${{ needs.tests.result }}, tests-docker: ${{ needs.tests-docker.result }}, codeql: ${{ needs.codeql.result }}" @@ -3,7 +3,7 @@ python_version = 3.6 ### --strict warn_unused_configs = True -# disallow_any_generics = True +disallow_any_generics = True disallow_subclassing_any = True # disallow_untyped_calls = True # disallow_untyped_defs = True @@ -83,6 +83,10 @@ disallow_untyped_defs = True [mypy-qutebrowser.config.*] disallow_untyped_defs = True +[mypy-qutebrowser.config.configtypes] +# Needs some major work to use specific generics +disallow_any_generics = False + [mypy-qutebrowser.api.*] disallow_untyped_defs = True diff --git a/README.asciidoc b/README.asciidoc index b12665b7a..5c87df857 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -75,8 +75,8 @@ Requirements The following software and libraries are required to run qutebrowser: * https://www.python.org/[Python] 3.6.1 or newer -* https://www.qt.io/[Qt] 5.12.0 or newer (5.12 LTS or 5.15 recommended) - with the following modules: +* https://www.qt.io/[Qt] 5.12.0 or newer (5.12 LTS or 5.15 recommended, Qt 6 is + not supported yet) with the following modules: - QtCore / qtbase - QtQuick (part of qtbase or qtdeclarative in some distributions) - QtSQL (part of qtbase in some distributions) @@ -205,6 +205,7 @@ Active * https://luakit.github.io/luakit/[luakit] (C/Lua, GTK+ with WebKit2) * https://nyxt.atlas.engineer/[Nyxt browser] (formerly "Next browser", Lisp, Emacs-like but also offers Vim bindings, QtWebKit or GTK+/WebKit2 - note there was a https://jgkamat.gitlab.io/blog/next-rce.html[critical remote code execution] which was handled quite badly) * https://vieb.dev/[Vieb] (JavaScript, Electron) +* https://surf.suckless.org/[surf] (C, GTK+ with WebKit1/WebKit2) * Chrome/Chromium addons: https://vimium.github.io/[Vimium], https://github.com/dcchambers/vb4c[vb4c] (fork of cVim) @@ -236,7 +237,6 @@ original site is gone but the Arch Linux wiki has some data) * https://www.uzbl.org/[uzbl] (C, GTK+ with WebKit1/WebKit2) * https://github.com/conformal/xombrero[xombrero] (C, GTK+ with WebKit1) * https://github.com/linkdd/cream-browser[Cream Browser] (C, GTK+ with WebKit1) -* https://surf.suckless.org/[surf] (C, GTK+ with WebKit1/WebKit2) * Firefox addons (not based on WebExtensions or no recent activity): http://www.vimperator.org/[Vimperator], http://bug.5digits.org/pentadactyl/index[Pentadactyl], diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 149b4f9ee..008c693a3 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -15,8 +15,106 @@ breaking changes (such as renamed commands) can happen in minor releases. // `Fixed` for any bug fixes. // `Security` to invite users to upgrade in case of vulnerabilities. +[[v2.4.0]] +v2.4.0 (unreleased) +------------------- + +Fixed +~~~~~ + +- Switching tabs via mouse wheel scrolling now works properly on macOS. Set + `tabs.mousewheel_switching` to false if you prefer the previous behavior. +- Crash when entering unicode surrogates into the filename prompt. +- `UnboundLocalError` in `qute-keepass` when the database couldn't be opened. + +Changed +~~~~~~~ + +- Typing in the filename prompt now filters matching directories. +- When opening a file qutebrowser can't handle from a `file:///` directory + listing, qutebrowser now opens it with the default application rather than + displaying a download prompt. + +[[v2.3.1]] +v2.3.1 (unreleased) +------------------- + +Fixed +~~~~~ + +- Corrupt cache file exceptions with `adblock` 0.5.0+ are now handled properly. + +[[v2.3.0]] +v2.3.0 (2021-06-28) +------------------- + +Added +~~~~~ + +- New `content.prefers_reduced_motion` setting to request websites to reduce + non-essential motion/animations. +- New `colors.prompts.selected.fg` setting to customize the text color for + selected items in filename prompts. + +Changed +~~~~~~~ + +- The hosts-based adblocker (using `content.blocking.hosts.lists`) now also + blocks all requests to any subdomains of blocked hosts. +- The `fonts.web.*` settings now support URL patterns. +- The `:greasemonkey-reload` command now shows a list of loaded scripts and has + a new `--quiet` switch to suppress that message. +- When launching a userscript via hints, a new `QUTE_CURRENT_URL` environment + variable now points to the current page (rather than the URL of the selected + element, where `QUTE_URL` points to). + +Fixed +~~~~~ + +- Crash on macOS 10.14+ when logging into Google accounts -- the previous fix + was incomplete due wrong information in Apple's documentation. +- Crash when two Greasemonkey scripts have the same name (usually happening + because the same file is in both the data and the config directory). +- Deprecation warnings when using the `link_pyqt.py` script on Python 3.10 + (e.g. via `tox` or `mkvenv.py`). + +[[v2.2.3]] +v2.2.3 (2021-06-01) +------------------- + +Fixed +~~~~~ + +- Logging into Google accounts or sharing the camera on macOS 10.14+ crashed, + which is now fixed. +- The Windows installer now correctly aborts the installation on Windows 7 + (rather than attempting an install which won't work, since Windows 7 is + unsupported since the v2.0.0 release). +- Using `--json-logging` without `--debug` caused qutebrowser to crash since the + v1.13.0 release. It now works correctly again. +- Mixing Qt 5.14+ with QtWebEngine 5.12 caused a crash related to qutebrowser's + notification support, which is now fixed. +- The documentation now points to the new IRC channels on irc.libera.chat + instead of the defunct Freenode channels (due to a hostile takeover by + Freenode staff). +- Setting `content.headers.user_agent` or `.accept_language` to a value + containing non-ascii characters was permitted by qutebrowser, but resulted in + a crash when loading a page. Such values are now rejected properly. +- When quitting qutebrowser on the `qute://settings` page, a crash could happen, which is now fixed. +- When `:edit-text` is used, but the existing text in the input isn't + representable in the configured encoding (`editor.encoding`), qutebrowser would + crash. It now shows a proper error instead. +- The testsuite should now work properly on aarch64. +- When QtWebEngine is in a "stuck" state while `:selection-follow` was used, + this could cause a crash in qutebrowser. This is now fixed (speculatively, due + to lack of a reproducer). +- When the brave adblock data (`adblock-cache.dat`) got corrupted, qutebrowser + would crash when trying to load it. It now displays an error instead. +- Combining `/S` (silent) and `/allusers` when uninstalling via the Windows + installer now works properly. + [[v2.2.2]] -v2.2.2 (unreleased) +v2.2.2 (2021-05-20) ------------------- Fixed @@ -25,6 +123,14 @@ Fixed - When awesomewm's "naughty" notification daemon was used with a development version of AwesomeWM and an unknown version number, qutebrowser would crash when trying to parse the version string. This is now fixed. +- Due to a bug with QtWebEngine 5.15.4, old Service Worker data could cause + renderer process crashes. This is now worked around by qutebrowser. +- When an (broken) binding to `set-cmd-text` without any argument existed, + using `:` would crash, which is now fixed. +- New site-specific quirk (again) working around not being able to type + accented/composed characters on Google Docs. +- When running with `python -OO` (which is not recommended), a notification + being shown would result in a crash, which is now fixed. [[v2.2.1]] v2.2.1 (2021-04-29) diff --git a/doc/contributing.asciidoc b/doc/contributing.asciidoc index dc7b331b1..1f87e9163 100644 --- a/doc/contributing.asciidoc +++ b/doc/contributing.asciidoc @@ -26,9 +26,9 @@ several ways: (optionally https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[subscribe] first). -* Join the IRC channel irc://irc.freenode.org/#qutebrowser[`#qutebrowser`] on -https://freenode.net/[Freenode] -(https://webchat.freenode.net/?channels=#qutebrowser[webchat]). +* Join the IRC channel link:ircs://irc.libera.chat:6697/#qutebrowser[`#qutebrowser`] on +https://libera.chat/[Libera Chat] (https://web.libera.chat/#qutebrowser[webchat], +https://matrix.to/#qutebrowser:libera.chat[via Matrix]). Finding something to work on ---------------------------- @@ -567,25 +567,33 @@ Chrome URLs With the QtWebEngine backend, qutebrowser supports several chrome:// urls which can be useful for debugging: +- chrome://accessibility/ - chrome://appcache-internals/ - chrome://blob-internals/ +- chrome://conversion-internals/ (QtWebEngine 5.15.3+) +- chrome://crash/ (crashes the current renderer process!) - chrome://gpu/ +- chrome://gpuclean/ (crashes the current renderer process!) +- chrome://gpucrash/ (crashes qutebrowser!) +- chrome://gpuhang/ (hangs qutebrowser!) - chrome://histograms/ - chrome://indexeddb-internals/ +- chrome://kill/ (kills the current renderer process!) - chrome://media-internals/ +- chrome://net-internals/ (QtWebEngine 5.15.4+) - chrome://network-errors/ -- chrome://serviceworker-internals/ -- chrome://webrtc-internals/ -- chrome://crash/ (crashes the current renderer process!) -- chrome://kill/ (kills the current renderer process!) -- chrome://gpucrash/ (crashes qutebrowser!) -- chrome://gpuhang/ (hangs qutebrowser!) -- chrome://gpuclean/ (crashes the current renderer process!) - chrome://ppapiflashcrash/ - chrome://ppapiflashhang/ +- chrome://process-internals/ - chrome://quota-internals/ -- chrome://taskscheduler-internals/ - chrome://sandbox/ (Linux only) +- chrome://serviceworker-internals/ +- chrome://taskscheduler-internals/ (removed in QtWebEngine 5.14) +- chrome://tracing/ (QtWebEngine 5.15.3+) +- chrome://ukm/ (QtWebEngine 5.15.3+) +- chrome://user-actions/ (QtWebEngine 5.15.3+) +- chrome://webrtc-internals/ +- chrome://webrtc-logs/ (QtWebEngine 5.15.3+) QtWebEngine internals ~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index d8a6e761c..f570a3ffd 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -577,7 +577,7 @@ Toggle fullscreen mode. [[greasemonkey-reload]] === greasemonkey-reload -Syntax: +:greasemonkey-reload [*--force*]+ +Syntax: +:greasemonkey-reload [*--force*] [*--quiet*]+ Re-read Greasemonkey scripts from disk. @@ -586,6 +586,7 @@ The scripts are read from a 'greasemonkey' subdirectory in qutebrowser's data or ==== optional arguments * +*-f*+, +*--force*+: For any scripts that have required dependencies, re-download them. +* +*-q*+, +*--quiet*+: Suppress message after loading scripts. [[help]] === help diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index ad287b030..e1a57cdfb 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -437,3 +437,9 @@ Various emacs/conkeror-like keybinding configs exist: It's also mostly possible to get rid of modal keybindings by setting `input.insert_mode.auto_enter` to `false`, and `input.forward_unbound_keys` to `all`. + +Other resources +^^^^^^^^^^^^^^^ + +- https://www.ii.com/qutebrowser-tips-fragments/[Infinite Ink: qutebrowser Tips and Fragments] +- https://www.ii.com/qutebrowser-configpy/[Infinite Ink: qutebrowser’s Template config.py] diff --git a/doc/help/index.asciidoc b/doc/help/index.asciidoc index 3a84cfca1..c7fb88c8d 100644 --- a/doc/help/index.asciidoc +++ b/doc/help/index.asciidoc @@ -22,10 +22,10 @@ Getting help ------------ You can get help in the IRC channel -irc://irc.freenode.org/#qutebrowser[`#qutebrowser`] on -http://freenode.net/[Freenode] -(https://webchat.freenode.net/?channels=#qutebrowser[webchat]), or by writing a -message to the +link:ircs://irc.libera.chat:6697/#qutebrowser[`#qutebrowser`] on +https://libera.chat/[Libera Chat] +(https://web.libera.chat/#qutebrowser[webchat], https://matrix.to/#qutebrowser:libera.chat[via Matrix]), +or by writing a message to the https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[mailinglist] at mailto:qutebrowser@lists.qutebrowser.org[]. @@ -52,6 +52,16 @@ ways: https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[mailinglist] at mailto:qutebrowser@lists.qutebrowser.org[]. +Other resources +--------------- + +- https://blog.qutebrowser.org/[Development blog] +- https://twitter.com/qutebrowser[Twitter account], + https://fosstodon.org/@qutebrowser[Mastodon account] +- Infinite Ink: https://www.ii.com/qutebrowser-getting-started/[Getting Started + with qutebrowser] and https://www.ii.com/portal/qutebrowser/[other + qutebrowser articles] + License ------- diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 587576e18..3c00eb083 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -67,6 +67,7 @@ |<<colors.prompts.border,colors.prompts.border>>|Border used around UI elements in prompts. |<<colors.prompts.fg,colors.prompts.fg>>|Foreground color for prompts. |<<colors.prompts.selected.bg,colors.prompts.selected.bg>>|Background color for the selected item in filename prompts. +|<<colors.prompts.selected.fg,colors.prompts.selected.fg>>|Foreground color for the selected item in filename prompts. |<<colors.statusbar.caret.bg,colors.statusbar.caret.bg>>|Background color of the statusbar in caret mode. |<<colors.statusbar.caret.fg,colors.statusbar.caret.fg>>|Foreground color of the statusbar in caret mode. |<<colors.statusbar.caret.selection.bg,colors.statusbar.caret.selection.bg>>|Background color of the statusbar in caret mode with a selection. @@ -187,6 +188,7 @@ |<<content.pdfjs,content.pdfjs>>|Allow pdf.js to view PDF files in the browser. |<<content.persistent_storage,content.persistent_storage>>|Allow websites to request persistent storage quota via `navigator.webkitPersistentStorage.requestQuota`. |<<content.plugins,content.plugins>>|Enable plugins in Web pages. +|<<content.prefers_reduced_motion,content.prefers_reduced_motion>>|Request websites to minimize non-essentials animations and motion. |<<content.print_element_backgrounds,content.print_element_backgrounds>>|Draw the background color and images also when the page is printed. |<<content.private_browsing,content.private_browsing>>|Open new windows in private browsing mode which does not record visited pages. |<<content.proxy,content.proxy>>|Proxy to use. @@ -1232,6 +1234,14 @@ Type: <<types,QssColor>> Default: +pass:[grey]+ +[[colors.prompts.selected.fg]] +=== colors.prompts.selected.fg +Foreground color for the selected item in filename prompts. + +Type: <<types,QssColor>> + +Default: +pass:[white]+ + [[colors.statusbar.caret.bg]] === colors.statusbar.caret.bg Background color of the statusbar in caret mode. @@ -2607,6 +2617,22 @@ Type: <<types,Bool>> Default: +pass:[false]+ +[[content.prefers_reduced_motion]] +=== content.prefers_reduced_motion +Request websites to minimize non-essentials animations and motion. +This results in the `prefers-reduced-motion` CSS media query to evaluate to `reduce` (rather than `no-preference`). +On Windows, if this setting is set to False, the system-wide animation setting is considered. + +This setting requires a restart. + +On QtWebEngine, this setting requires Qt 5.14 or newer. + +On QtWebKit, this setting is unavailable. + +Type: <<types,Bool>> + +Default: +pass:[false]+ + [[content.print_element_backgrounds]] === content.print_element_backgrounds Draw the background color and images also when the page is printed. @@ -2692,6 +2718,7 @@ Valid values: * +ua-whatsapp+ * +ua-google+ * +ua-slack+ + * +ua-googledocs+ * +js-whatsapp-web+ * +js-discord+ * +js-string-replaceall+ @@ -3088,6 +3115,8 @@ Default: +pass:[default_size default_family]+ === fonts.web.family.cursive Font family for cursive fonts. +This setting supports URL patterns. + Type: <<types,FontFamily>> Default: empty @@ -3096,6 +3125,8 @@ Default: empty === fonts.web.family.fantasy Font family for fantasy fonts. +This setting supports URL patterns. + Type: <<types,FontFamily>> Default: empty @@ -3104,6 +3135,8 @@ Default: empty === fonts.web.family.fixed Font family for fixed fonts. +This setting supports URL patterns. + Type: <<types,FontFamily>> Default: empty @@ -3112,6 +3145,8 @@ Default: empty === fonts.web.family.sans_serif Font family for sans-serif fonts. +This setting supports URL patterns. + Type: <<types,FontFamily>> Default: empty @@ -3120,6 +3155,8 @@ Default: empty === fonts.web.family.serif Font family for serif fonts. +This setting supports URL patterns. + Type: <<types,FontFamily>> Default: empty @@ -3128,6 +3165,8 @@ Default: empty === fonts.web.family.standard Font family for standard fonts. +This setting supports URL patterns. + Type: <<types,FontFamily>> Default: empty @@ -3136,6 +3175,8 @@ Default: empty === fonts.web.size.default Default font size (in pixels) for regular text. +This setting supports URL patterns. + Type: <<types,Int>> Default: +pass:[16]+ @@ -3144,6 +3185,8 @@ Default: +pass:[16]+ === fonts.web.size.default_fixed Default font size (in pixels) for fixed-pitch text. +This setting supports URL patterns. + Type: <<types,Int>> Default: +pass:[13]+ @@ -3152,6 +3195,8 @@ Default: +pass:[13]+ === fonts.web.size.minimum Hard minimum font size (in pixels). +This setting supports URL patterns. + Type: <<types,Int>> Default: +pass:[0]+ @@ -3160,6 +3205,8 @@ Default: +pass:[0]+ === fonts.web.size.minimum_logical Minimum logical font size (in pixels) that is applied when zooming out. +This setting supports URL patterns. + Type: <<types,Int>> Default: +pass:[6]+ diff --git a/doc/install.asciidoc b/doc/install.asciidoc index 20e2028d1..83c332b4d 100644 --- a/doc/install.asciidoc +++ b/doc/install.asciidoc @@ -264,6 +264,10 @@ and QtWebEngine backend is also not available. On Windows ---------- +NOTE: As an additional resource, see +https://www.ii.com/installing-qutebrowser-on-windows/[Infinite Ink: Installing +qutebrowser on Windows]. + There are different ways to install qutebrowser on Windows: Prebuilt binaries @@ -338,11 +342,11 @@ The binary release ships with a QtWebEngine built without proprietary codec support. To get support for e.g. h264/mp4 videos, you'll need to build QtWebEngine from source yourself with support for that enabled. -This binary is also available through the -https://caskroom.github.io/[Homebrew Cask] package manager: +This binary is also available through the https://brew.sh/[Homebrew] package +manager as a https://github.com/Homebrew/homebrew-cask[cask]: ---- -$ brew install qutebrowser --cask +$ brew install qutebrowser ---- Nightly builds diff --git a/doc/quickstart.asciidoc b/doc/quickstart.asciidoc index afe5ae231..2e61e442d 100644 --- a/doc/quickstart.asciidoc +++ b/doc/quickstart.asciidoc @@ -46,9 +46,9 @@ If you get stuck, you can get help in multiple ways: * The `:help` command inside qutebrowser shows the built-in documentation. Additionally, each command can be started with a `--help` flag to show its help. -* Chat via the IRC channel: irc://irc.freenode.org/#qutebrowser[`#qutebrowser`] on -https://freenode.net/[Freenode] -(https://webchat.freenode.net/?channels=#qutebrowser[webchat]) +* Chat via the IRC channel: link:ircs://irc.libera.chat:6697/#qutebrowser[`#qutebrowser`] on +https://libera.chat/[Libera Chat] (https://web.libera.chat/#qutebrowser[webchat], +or https://matrix.to/#qutebrowser:libera.chat[via Matrix]) * On Reddit: https://www.reddit.com/r/qutebrowser/[/r/qutebrowser] * Via https://github.com/qutebrowser/qutebrowser/discussions[GitHub Discussions] * Using the mailinglist: mailto:qutebrowser@lists.qutebrowser.org[] diff --git a/doc/qutebrowser.1.asciidoc b/doc/qutebrowser.1.asciidoc index 8564c8a51..8db231add 100644 --- a/doc/qutebrowser.1.asciidoc +++ b/doc/qutebrowser.1.asciidoc @@ -120,7 +120,7 @@ environment, the directories configured there are used instead of the above defaults. == BUGS -Bugs are tracked in the Github issue tracker at +Bugs are tracked in the Github issue tracker at https://github.com/qutebrowser/qutebrowser/issues. If you found a bug, use the built-in ':report' command to create a bug report @@ -152,8 +152,9 @@ this program. If not, see <https://www.gnu.org/licenses/>. https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser * Announce-only mailinglist: mailto:qutebrowser-announce@lists.qutebrowser.org[] / https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser-announce -* IRC: irc://irc.freenode.org/#qutebrowser[`#qutebrowser`] on -https://freenode.net/[Freenode] +* IRC: link:ircs://irc.libera.chat:6697/#qutebrowser[`#qutebrowser`] on +https://libera.chat/[Libera Chat] (https://web.libera.chat/#qutebrowser[webchat], +https://matrix.to/#qutebrowser:libera.chat[via Matrix]) * Github: https://github.com/qutebrowser/qutebrowser == AUTHOR diff --git a/doc/userscripts.asciidoc b/doc/userscripts.asciidoc index 747340454..5ff358ee0 100644 --- a/doc/userscripts.asciidoc +++ b/doc/userscripts.asciidoc @@ -22,7 +22,10 @@ To call a userscript, it needs to be stored in your config or data directory und `userscripts` (for example: `~/.local/share/qutebrowser/userscripts/myscript`), or just use an absolute path. -NOTE: On Windows, only userscripts with `com`, `bat`, or `exe` extensions will be launched. +NOTE: On Windows, only userscripts with `com`, `bat`, or `exe` extensions will +be launched. As an additional resource, see +https://www.ii.com/qutebrowser-userscripts-on-windows/[Infinite Ink: +qutebrowser Userscripts on Windows]. Getting information ------------------- @@ -49,7 +52,7 @@ The following environment variables will be set when a userscript is launched: In `command` mode: -- `QUTE_URL`: The current URL. +- `QUTE_URL`: The current page URL. - `QUTE_TITLE`: The title of the current page. - `QUTE_SELECTED_TEXT`: The text currently selected on the page. - `QUTE_COUNT`: The `count` from the spawn command running the userscript. @@ -57,6 +60,7 @@ In `command` mode: In `hints` mode: - `QUTE_URL`: The URL selected via hints. +- `QUTE_CURRENT_URL`: The current page URL. - `QUTE_SELECTED_TEXT`: The plain text of the element selected via hints. - `QUTE_SELECTED_HTML`: The HTML of the element selected via hints. diff --git a/misc/cheatsheet.svg b/misc/cheatsheet.svg index e908f9496..7dcb6d4e1 100644 --- a/misc/cheatsheet.svg +++ b/misc/cheatsheet.svg @@ -2570,7 +2570,7 @@ id="flowPara5604" style="font-size:13.8667px;line-height:1.25;font-family:sans-serif;stroke-width:1.06667">Website: https://www.qutebrowser.org/ </flowPara><flowPara id="flowPara5595" - style="font-size:13.8667px;line-height:1.25;font-family:sans-serif;stroke-width:1.06667">IRC: #qutebrowser on Freenode</flowPara><flowPara + style="font-size:13.8667px;line-height:1.25;font-family:sans-serif;stroke-width:1.06667">IRC: #qutebrowser on Libera Chat (irc.libera.chat)</flowPara><flowPara id="flowPara5597" style="font-size:13.8667px;line-height:1.25;font-family:sans-serif;stroke-width:1.06667">Mailinglist: qutebrowser@lists.qutebrowser.org</flowPara></flowRoot> <text diff --git a/misc/nsis/install_pages.nsh b/misc/nsis/install_pages.nsh index a8e1f9253..c3cf973df 100755 --- a/misc/nsis/install_pages.nsh +++ b/misc/nsis/install_pages.nsh @@ -22,7 +22,7 @@ ; NsisMultiUser optional defines !define MULTIUSER_INSTALLMODE_ALLOW_BOTH_INSTALLATIONS 0 !define MULTIUSER_INSTALLMODE_ALLOW_ELEVATION 1 -!define MULTIUSER_INSTALLMODE_ALLOW_ELEVATION_IF_SILENT 0 +!define MULTIUSER_INSTALLMODE_ALLOW_ELEVATION_IF_SILENT 1 !define MULTIUSER_INSTALLMODE_DEFAULT_ALLUSERS 1 !if ${PLATFORM} == "win64" !define MULTIUSER_INSTALLMODE_64_BIT 1 diff --git a/misc/nsis/qutebrowser.nsi b/misc/nsis/qutebrowser.nsi index 3a96cb013..51f3b623d 100755 --- a/misc/nsis/qutebrowser.nsi +++ b/misc/nsis/qutebrowser.nsi @@ -59,7 +59,7 @@ ShowUninstDetails hide !define CONTACT "mail@qutebrowser.org"
!define COMMENTS "A keyboard-driven, vim-like browser based on PyQt5."
!define LANGID "1033" ; U.S. English
-!define MIN_WIN_VER "XP"
+!define MIN_WIN_VER "8"
!define SETUP_MUTEX "${PRODUCT_NAME} Setup Mutex" ; do not change this between program versions!
!define APP_MUTEX "${PRODUCT_NAME} App Mutex" ; do not change this between program versions!
!define REG_UN "Software\Microsoft\Windows\CurrentVersion\Uninstall"
diff --git a/misc/org.qutebrowser.qutebrowser.appdata.xml b/misc/org.qutebrowser.qutebrowser.appdata.xml index 6950c45eb..49bc09786 100644 --- a/misc/org.qutebrowser.qutebrowser.appdata.xml +++ b/misc/org.qutebrowser.qutebrowser.appdata.xml @@ -44,6 +44,9 @@ </content_rating> <releases> <!-- Add new releases here --> +<release version="2.3.0" date="2021-06-28"/> +<release version="2.2.3" date="2021-06-01"/> +<release version="2.2.2" date="2021-05-20"/> <release version="2.2.1" date="2021-04-29"/> <release version="2.2.0" date="2021-04-13"/> <release version="2.1.1" date="2021-04-01"/> diff --git a/misc/requirements/requirements-check-manifest.txt b/misc/requirements/requirements-check-manifest.txt index f32dddc28..8b7067709 100644 --- a/misc/requirements/requirements-check-manifest.txt +++ b/misc/requirements/requirements-check-manifest.txt @@ -1,8 +1,8 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -build==0.3.1.post1 +build==0.5.1 check-manifest==0.46 -packaging==20.9 +packaging==21.0 pep517==0.10.0 pyparsing==2.4.7 toml==0.10.2 diff --git a/misc/requirements/requirements-dev.txt b/misc/requirements/requirements-dev.txt index 1c031086f..2963888b6 100644 --- a/misc/requirements/requirements-dev.txt +++ b/misc/requirements/requirements-dev.txt @@ -1,24 +1,26 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py bump2version==1.0.1 -certifi==2020.12.5 -cffi==1.14.5 +certifi==2021.5.30 +cffi==1.14.6 chardet==4.0.0 cryptography==3.4.7 +Deprecated==1.2.12 github3.py==2.0.0 -hunter==3.3.3 +hunter==3.3.8 idna==2.10 -jwcrypto==0.8 +jwcrypto==0.9.1 manhole==1.8.0 -packaging==20.9 +packaging==21.0 pycparser==2.20 Pympler==0.9 pyparsing==2.4.7 -PyQt-builder==1.9.1 +PyQt-builder==1.10.3 python-dateutil==2.8.1 requests==2.25.1 -sip==6.0.3 +sip==6.1.1 six==1.16.0 toml==0.10.2 uritemplate==3.0.1 -# urllib3==1.26.4 +# urllib3==1.26.6 +wrapt==1.12.1 diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt index 2269212bf..cc041b538 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -1,10 +1,10 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -attrs==20.3.0 -flake8==3.9.1 +attrs==21.2.0 +flake8==3.9.2 flake8-bugbear==21.4.3 flake8-builtins==1.5.3 -flake8-comprehensions==3.4.0 +flake8-comprehensions==3.5.0 flake8-copyright==0.2.2 flake8-debugger==4.0.0 flake8-deprecated==1.3 @@ -13,12 +13,12 @@ flake8-future-import==0.4.6 flake8-mock==0.3 flake8-polyfill==1.0.2 flake8-string-format==0.3.0 -flake8-tidy-imports==4.2.1 +flake8-tidy-imports==4.3.0 flake8-tuple==0.4.1 mccabe==0.6.1 -pep8-naming==0.11.1 +pep8-naming==0.12.0 pycodestyle==2.7.0 -pydocstyle==6.0.0 +pydocstyle==6.1.1 pyflakes==2.3.1 six==1.16.0 snowballstemmer==2.1.0 diff --git a/misc/requirements/requirements-mypy.txt b/misc/requirements/requirements-mypy.txt index 7afb8fdc9..e8b09d628 100644 --- a/misc/requirements/requirements-mypy.txt +++ b/misc/requirements/requirements-mypy.txt @@ -1,19 +1,21 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py chardet==4.0.0 -diff-cover==5.0.1 -importlib-metadata==4.0.1 -importlib-resources==5.1.2 +diff-cover==6.1.1 +importlib-metadata==4.6.1 +importlib-resources==5.2.0 inflect==5.3.0 -Jinja2==2.11.3 +Jinja2==3.0.1 jinja2-pluralize==0.3.0 lxml==4.6.3 -MarkupSafe==1.1.1 -mypy==0.812 +MarkupSafe==2.0.1 +mypy==0.910 mypy-extensions==0.4.3 pluggy==0.13.1 Pygments==2.9.0 PyQt5-stubs==5.15.2.0 -typed-ast==1.4.3 +toml==0.10.2 +types-dataclasses==0.1.5 +types-PyYAML==5.4.3 typing-extensions==3.10.0.0 -zipp==3.4.1 +zipp==3.5.0 diff --git a/misc/requirements/requirements-mypy.txt-raw b/misc/requirements/requirements-mypy.txt-raw index e93eaae0b..4baeec11f 100644 --- a/misc/requirements/requirements-mypy.txt-raw +++ b/misc/requirements/requirements-mypy.txt-raw @@ -1,7 +1,10 @@ mypy lxml # For HTML reports diff-cover + PyQt5-stubs +types-dataclasses +types-PyYAML # So stubs are available even on newer Python versions importlib_resources diff --git a/misc/requirements/requirements-pyinstaller.txt b/misc/requirements/requirements-pyinstaller.txt index c5be78194..86d73655d 100644 --- a/misc/requirements/requirements-pyinstaller.txt +++ b/misc/requirements/requirements-pyinstaller.txt @@ -2,4 +2,4 @@ altgraph==0.17 pyinstaller==4.3 -pyinstaller-hooks-contrib==2021.1 +pyinstaller-hooks-contrib==2021.2 diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt index 378517755..f0a1c2cb4 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -1,18 +1,19 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py astroid==2.3.3 # rq.filter: < 2.4 -certifi==2020.12.5 -cffi==1.14.5 +certifi==2021.5.30 +cffi==1.14.6 chardet==4.0.0 cryptography==3.4.7 +Deprecated==1.2.12 future==0.18.2 github3.py==2.0.0 idna==2.10 isort==4.3.21 -jwcrypto==0.8 +jwcrypto==0.9.1 lazy-object-proxy==1.4.3 mccabe==0.6.1 -pefile==2019.4.18 +pefile==2021.5.24 pycparser==2.20 pylint==2.4.4 # rq.filter: < 2.5 python-dateutil==2.8.1 @@ -21,5 +22,5 @@ requests==2.25.1 six==1.16.0 typed-ast==1.4.3 ; python_version<"3.8" uritemplate==3.0.1 -# urllib3==1.26.4 +# urllib3==1.26.6 wrapt==1.11.2 diff --git a/misc/requirements/requirements-pyqt-5.12.txt b/misc/requirements/requirements-pyqt-5.12.txt index 80a700f09..890306127 100644 --- a/misc/requirements/requirements-pyqt-5.12.txt +++ b/misc/requirements/requirements-pyqt-5.12.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py PyQt5==5.12.3 # rq.filter: < 5.13 -PyQt5-sip==12.8.1 +PyQt5-sip==12.9.0 PyQtWebEngine==5.12.1 # rq.filter: < 5.13 diff --git a/misc/requirements/requirements-pyqt-5.13.txt b/misc/requirements/requirements-pyqt-5.13.txt index 438c600da..5f4da4758 100644 --- a/misc/requirements/requirements-pyqt-5.13.txt +++ b/misc/requirements/requirements-pyqt-5.13.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py PyQt5==5.13.2 # rq.filter: < 5.14 -PyQt5-sip==12.8.1 +PyQt5-sip==12.9.0 PyQtWebEngine==5.13.2 # rq.filter: < 5.14 diff --git a/misc/requirements/requirements-pyqt-5.14.txt b/misc/requirements/requirements-pyqt-5.14.txt index d515e717f..9ce643666 100644 --- a/misc/requirements/requirements-pyqt-5.14.txt +++ b/misc/requirements/requirements-pyqt-5.14.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py PyQt5==5.14.2 # rq.filter: < 5.15 -PyQt5-sip==12.8.1 +PyQt5-sip==12.9.0 PyQtWebEngine==5.14.0 # rq.filter: < 5.15 diff --git a/misc/requirements/requirements-pyqt-5.15.0.txt b/misc/requirements/requirements-pyqt-5.15.0.txt index b9ee53f65..b111a93f3 100644 --- a/misc/requirements/requirements-pyqt-5.15.0.txt +++ b/misc/requirements/requirements-pyqt-5.15.0.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py PyQt5==5.15.0 # rq.filter: == 5.15.0 -PyQt5-sip==12.8.1 +PyQt5-sip==12.9.0 PyQtWebEngine==5.15.0 # rq.filter: == 5.15.0 diff --git a/misc/requirements/requirements-pyqt-5.15.txt b/misc/requirements/requirements-pyqt-5.15.txt index a5b3a5787..8b7a53c44 100644 --- a/misc/requirements/requirements-pyqt-5.15.txt +++ b/misc/requirements/requirements-pyqt-5.15.txt @@ -2,6 +2,6 @@ PyQt5==5.15.4 # rq.filter: < 5.16 PyQt5-Qt5==5.15.2 -PyQt5-sip==12.8.1 +PyQt5-sip==12.9.0 PyQtWebEngine==5.15.4 # rq.filter: < 5.16 PyQtWebEngine-Qt5==5.15.2 diff --git a/misc/requirements/requirements-pyqt-pyinstaller.txt b/misc/requirements/requirements-pyqt-pyinstaller.txt index 31ecefad5..678a1d7ea 100644 --- a/misc/requirements/requirements-pyqt-pyinstaller.txt +++ b/misc/requirements/requirements-pyqt-pyinstaller.txt @@ -2,6 +2,6 @@ PyQt5==5.15.3 PyQt5-Qt==5.15.2 -PyQt5-sip==12.8.1 +PyQt5-sip==12.9.0 PyQtWebEngine==5.15.3 PyQtWebEngine-Qt==5.15.2 diff --git a/misc/requirements/requirements-pyqt.txt b/misc/requirements/requirements-pyqt.txt index 7e28f7dc2..75ef27bf4 100644 --- a/misc/requirements/requirements-pyqt.txt +++ b/misc/requirements/requirements-pyqt.txt @@ -2,6 +2,6 @@ PyQt5==5.15.4 PyQt5-Qt5==5.15.2 -PyQt5-sip==12.8.1 +PyQt5-sip==12.9.0 PyQtWebEngine==5.15.4 PyQtWebEngine-Qt5==5.15.2 diff --git a/misc/requirements/requirements-pyroma.txt b/misc/requirements/requirements-pyroma.txt index feceac972..a83804b9e 100644 --- a/misc/requirements/requirements-pyroma.txt +++ b/misc/requirements/requirements-pyroma.txt @@ -1,5 +1,10 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py +certifi==2021.5.30 +chardet==4.0.0 docutils==0.17.1 +idna==2.10 Pygments==2.9.0 -pyroma==3.1 +pyroma==3.2 +requests==2.25.1 +urllib3==1.26.6 diff --git a/misc/requirements/requirements-sphinx.txt b/misc/requirements/requirements-sphinx.txt index 0bdc465b6..f5a7cb3ca 100644 --- a/misc/requirements/requirements-sphinx.txt +++ b/misc/requirements/requirements-sphinx.txt @@ -2,24 +2,24 @@ alabaster==0.7.12 Babel==2.9.1 -certifi==2020.12.5 +certifi==2021.5.30 chardet==4.0.0 -docutils==0.16 +docutils==0.17.1 idna==2.10 imagesize==1.2.0 -Jinja2==2.11.3 -MarkupSafe==1.1.1 -packaging==20.9 +Jinja2==3.0.1 +MarkupSafe==2.0.1 +packaging==21.0 Pygments==2.9.0 pyparsing==2.4.7 pytz==2021.1 requests==2.25.1 snowballstemmer==2.1.0 -Sphinx==3.5.4 +Sphinx==4.1.0 sphinxcontrib-applehelp==1.0.2 sphinxcontrib-devhelp==1.0.2 -sphinxcontrib-htmlhelp==1.0.3 +sphinxcontrib-htmlhelp==2.0.0 sphinxcontrib-jsmath==1.0.1 sphinxcontrib-qthelp==1.0.3 -sphinxcontrib-serializinghtml==1.1.4 -urllib3==1.26.4 +sphinxcontrib-serializinghtml==1.1.5 +urllib3==1.26.6 diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 5f47d0549..0d4db4579 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -1,31 +1,30 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -apipkg==1.5 -attrs==20.3.0 +attrs==21.2.0 beautifulsoup4==4.9.3 -certifi==2020.12.5 +certifi==2021.5.30 chardet==4.0.0 cheroot==8.5.2 -click==7.1.2 +click==8.0.1 coverage==5.5 EasyProcess==0.3 -execnet==1.8.0 +execnet==1.9.0 filelock==3.0.12 -Flask==1.1.2 +Flask==2.0.1 glob2==0.7 -hunter==3.3.3 -hypothesis==6.10.1 +hunter==3.3.8 +hypothesis==6.14.2 icdiff==1.9.1 idna==2.10 iniconfig==1.1.1 -itsdangerous==1.1.0 +itsdangerous==2.0.1 jaraco.functools==3.3.0 -# Jinja2==2.11.3 +# Jinja2==3.0.1 Mako==1.1.4 manhole==1.8.0 -# MarkupSafe==1.1.1 -more-itertools==8.7.0 -packaging==20.9 +# MarkupSafe==2.0.1 +more-itertools==8.8.0 +packaging==21.0 parse==1.19.0 parse-type==0.5.2 pluggy==0.13.1 @@ -35,26 +34,26 @@ py-cpuinfo==8.0.0 Pygments==2.9.0 pyparsing==2.4.7 pytest==6.2.4 -pytest-bdd==4.0.2 +pytest-bdd==4.1.0 pytest-benchmark==3.4.1 -pytest-cov==2.11.1 +pytest-cov==2.12.1 pytest-forked==1.3.0 pytest-icdiff==0.5 pytest-instafail==0.4.2 -pytest-mock==3.6.0 -pytest-qt==3.3.0 +pytest-mock==3.6.1 +pytest-qt==4.0.2 pytest-repeat==0.9.1 -pytest-rerunfailures==9.1.1 -pytest-xdist==2.2.1 +pytest-rerunfailures==10.1 +pytest-xdist==2.3.0 pytest-xvfb==2.0.0 -PyVirtualDisplay==2.1 +PyVirtualDisplay==2.2 requests==2.25.1 requests-file==1.5.1 six==1.16.0 -sortedcontainers==2.3.0 +sortedcontainers==2.4.0 soupsieve==2.2.1 tldextract==3.1.0 toml==0.10.2 -urllib3==1.26.4 +urllib3==1.26.6 vulture==2.3 -Werkzeug==1.0.1 +Werkzeug==2.0.1 diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt index 16c85938e..82eb5b25c 100644 --- a/misc/requirements/requirements-tox.txt +++ b/misc/requirements/requirements-tox.txt @@ -1,16 +1,16 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py appdirs==1.4.4 -distlib==0.3.1 +distlib==0.3.2 filelock==3.0.12 -packaging==20.9 -pip==21.1.1 +packaging==21.0 +pip==21.1.3 pluggy==0.13.1 py==1.10.0 pyparsing==2.4.7 -setuptools==56.1.0 +setuptools==57.1.0 six==1.16.0 toml==0.10.2 -tox==3.23.0 -virtualenv==20.4.5 +tox==3.23.1 +virtualenv==20.4.7 wheel==0.36.2 diff --git a/misc/userscripts/README.md b/misc/userscripts/README.md index 395797805..f5325127b 100644 --- a/misc/userscripts/README.md +++ b/misc/userscripts/README.md @@ -76,7 +76,14 @@ The following userscripts can be found on their own repositories. Opens DOIs on Sci-Hub. - [1password](https://github.com/tomoakley/dotfiles/blob/master/qutebrowser/userscripts/1password): Integration with 1password on macOS. - +- [localhost](https://github.com/SidharthArya/.qutebrowser/blob/master/userscripts/localhost): + Quickly navigate to localhost:port. For reference: [A quicker way to reach localhost with qutebrowser](https://sidhartharya.me/a-quicker-way-to-reach-localhost-with-qutebrowser/) +- [untrack-url](https://github.com/qutebrowser/qutebrowser/discussions/6555), + convert various URLs (YouTube/Reddit/Twitter/Instagram/Google Maps) to other + services (Invidious, Teddit, Nitter, Bibliogram, OpenStreetMap). +- [CIAvash/qutebrowser-userscripts](https://github.com/CIAvash/qutebrowser-userscripts), + various small userscripts written in Raku. + [Zotero]: https://www.zotero.org/ [Pocket]: https://getpocket.com/ [Instapaper]: https://www.instapaper.com/ diff --git a/misc/userscripts/open_download b/misc/userscripts/open_download index e6de005c8..62730f37c 100755 --- a/misc/userscripts/open_download +++ b/misc/userscripts/open_download @@ -15,7 +15,7 @@ # - It comes in handy if you enable downloads.remove_finished. If you want to # see the recent downloads, just press "sd". # -# Thorsten Wißmann, 2015 (thorsten` on freenode) +# Thorsten Wißmann, 2015 (thorsten` on Libera Chat) # Any feedback is welcome! set -e diff --git a/misc/userscripts/password_fill b/misc/userscripts/password_fill index 4ebeebdc5..c46253d41 100755 --- a/misc/userscripts/password_fill +++ b/misc/userscripts/password_fill @@ -5,7 +5,7 @@ cat <<EOF This script can only be used as a userscript for qutebrowser 2015, Thorsten Wißmann <edu _at_ thorsten-wissmann _dot_ de> In case of questions or suggestions, do not hesitate to send me an E-Mail or to -directly ask me via IRC (nickname thorsten\`) in #qutebrowser on freenode. +directly ask me via IRC (nickname thorsten\`) in #qutebrowser on Libera Chat. $blink!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!$reset WARNING: the passwords are stored in qutebrowser's diff --git a/misc/userscripts/qute-keepass b/misc/userscripts/qute-keepass index 090d3c713..285377ffc 100755 --- a/misc/userscripts/qute-keepass +++ b/misc/userscripts/qute-keepass @@ -178,6 +178,7 @@ def find_candidates(args, host): kp = pykeepass.PyKeePass(file_path, password=pw, keyfile=kf) except Exception as e: stderr("There was an error opening the DB: {}".format(str(e))) + sys.exit(ExitCodes.DB_OPEN_FAIL) return kp.find_entries(url="{}{}{}".format(".*", host, ".*"), regex=True) diff --git a/misc/userscripts/readability-js b/misc/userscripts/readability-js index bb681810c..485957ddb 100755 --- a/misc/userscripts/readability-js +++ b/misc/userscripts/readability-js @@ -8,6 +8,7 @@ // # Prerequisites // // - Mozilla's readability library (npm install -g @mozilla/readability) +// - Also available in the AUR as nodejs-readability-git // - jsdom (npm install -g jsdom) // - qutejs (npm install -g qutejs) // diff --git a/misc/userscripts/view_in_mpv b/misc/userscripts/view_in_mpv index 4ab37d617..472920433 100755 --- a/misc/userscripts/view_in_mpv +++ b/misc/userscripts/view_in_mpv @@ -21,7 +21,7 @@ # (comments and video suggestions), i.e. only the videos should disappear # when mpv is started. And that's precisely what the present script does. # -# Thorsten Wißmann, 2015 (thorsten` on freenode) +# Thorsten Wißmann, 2015 (thorsten` on Libera Chat) # Any feedback is welcome! set -e diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py index 91a49d5b5..d146eb8bf 100644 --- a/qutebrowser/__init__.py +++ b/qutebrowser/__init__.py @@ -26,7 +26,7 @@ __copyright__ = "Copyright 2014-2021 Florian Bruhin (The Compiler)" __license__ = "GPL" __maintainer__ = __author__ __email__ = "mail@qutebrowser.org" -__version__ = "2.2.1" +__version__ = "2.3.0" __version_info__ = tuple(int(part) for part in __version__.split('.')) __description__ = "A keyboard-driven, vim-like browser based on PyQt5." diff --git a/qutebrowser/api/cmdutils.py b/qutebrowser/api/cmdutils.py index 85a700c43..73c6a1bc5 100644 --- a/qutebrowser/api/cmdutils.py +++ b/qutebrowser/api/cmdutils.py @@ -105,6 +105,9 @@ def check_exclusive(flags: Iterable[bool], names: Iterable[str]) -> None: raise CommandError("Only one of {} can be given!".format(argstr)) +_CmdHandlerType = Callable[..., Any] + + class register: # noqa: N801,N806 pylint: disable=invalid-name """Decorator to register a new command handler.""" @@ -130,7 +133,7 @@ class register: # noqa: N801,N806 pylint: disable=invalid-name # The arguments to pass to Command. self._kwargs = kwargs - def __call__(self, func: Callable) -> Callable: + def __call__(self, func: _CmdHandlerType) -> _CmdHandlerType: """Register the command before running the function. Gets called when a function should be decorated. @@ -222,7 +225,7 @@ class argument: # noqa: N801,N806 pylint: disable=invalid-name self._argname = argname # The name of the argument to handle. self._kwargs = kwargs # Valid ArgInfo members. - def __call__(self, func: Callable) -> Callable: + def __call__(self, func: _CmdHandlerType) -> _CmdHandlerType: funcname = func.__name__ if self._argname not in inspect.signature(func).parameters: diff --git a/qutebrowser/api/hook.py b/qutebrowser/api/hook.py index eadc310f3..884d6c67f 100644 --- a/qutebrowser/api/hook.py +++ b/qutebrowser/api/hook.py @@ -22,13 +22,13 @@ """Hooks for extensions.""" import importlib -from typing import Callable +from typing import Callable, Any from qutebrowser.extensions import loader -def _add_module_info(func: Callable) -> loader.ModuleInfo: +def _add_module_info(func: Callable[..., Any]) -> loader.ModuleInfo: """Add module info to the given function.""" module = importlib.import_module(func.__module__) return loader.add_module_info(module) @@ -48,7 +48,7 @@ class init: message.info("Extension initialized.") """ - def __call__(self, func: Callable) -> Callable: + def __call__(self, func: loader.InitHookType) -> loader.InitHookType: info = _add_module_info(func) if info.init_hook is not None: raise ValueError("init hook is already registered!") @@ -86,7 +86,10 @@ class config_changed: def __init__(self, option_filter: str = None) -> None: self._filter = option_filter - def __call__(self, func: Callable) -> Callable: + def __call__( + self, + func: loader.ConfigChangedHookType, + ) -> loader.ConfigChangedHookType: info = _add_module_info(func) info.config_changed_hooks.append((self._filter, func)) return func diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 2df0a82f6..1ab28e8d0 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -41,6 +41,7 @@ import os import sys import functools import tempfile +import pathlib import datetime import argparse from typing import Iterable, Optional @@ -479,11 +480,9 @@ def _init_modules(*, args): with debug.log_time("init", "Initializing SQL/history"): try: - log.init.debug("Initializing SQL...") - sql.init(os.path.join(standarddir.data(), 'history.sqlite')) - log.init.debug("Initializing web history...") - history.init(objects.qapp) + history.init(db_path=pathlib.Path(standarddir.data()) / 'history.sqlite', + parent=objects.qapp) except sql.KnownError as e: error.handle_fatal_exc(e, 'Error initializing SQL', pre_text='Error initializing SQL', diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index cbe698009..b1827dbf4 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -34,9 +34,10 @@ from PyQt5.QtPrintSupport import QPrintDialog, QPrinter from PyQt5.QtNetwork import QNetworkAccessManager if TYPE_CHECKING: - from PyQt5.QtWebKit import QWebHistory + from PyQt5.QtWebKit import QWebHistory, QWebHistoryItem from PyQt5.QtWebKitWidgets import QWebPage - from PyQt5.QtWebEngineWidgets import QWebEngineHistory, QWebEnginePage + from PyQt5.QtWebEngineWidgets import ( + QWebEngineHistory, QWebEngineHistoryItem, QWebEnginePage) from qutebrowser.keyinput import modeman from qutebrowser.config import config @@ -634,8 +635,8 @@ class AbstractHistoryPrivate: """Deserialize from a format produced by self.serialize.""" raise NotImplementedError - def load_items(self, items: Sequence) -> None: - """Deserialize from a list of WebHistoryItems.""" + def load_items(self, items: Sequence[sessions.TabHistoryItem]) -> None: + """Deserialize from a list of TabHistoryItems.""" raise NotImplementedError @@ -651,7 +652,7 @@ class AbstractHistory: def __len__(self) -> int: raise NotImplementedError - def __iter__(self) -> Iterable: + def __iter__(self) -> Iterable[Union['QWebHistoryItem', 'QWebEngineHistoryItem']]: raise NotImplementedError def _check_count(self, count: int) -> None: @@ -1003,7 +1004,7 @@ class AbstractTab(QWidget): """Setter for load_status.""" if not isinstance(val, usertypes.LoadStatus): raise TypeError("Type {} is no LoadStatus member!".format(val)) - log.webview.debug(f"load status for {self!r}: {utils.pyenum_str(val)}") + log.webview.debug("load status for {}: {}".format(repr(self), val)) self._load_status = val self.load_status_changed.emit(val) @@ -1063,7 +1064,7 @@ class AbstractTab(QWidget): url = utils.elide(navigation.url.toDisplayString(), 100) log.webview.debug("navigation request: url {}, type {}, is_main_frame " "{}".format(url, - utils.pyenum_str(navigation.navigation_type), + navigation.navigation_type, navigation.is_main_frame)) if navigation.is_main_frame: diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index e9653ae19..8cd73ae4f 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -602,7 +602,7 @@ class CommandDispatcher: widget = self._current_widget() url = self._current_url() - handlers: Dict[str, Callable] = { + handlers: Dict[str, Callable[..., QUrl]] = { 'prev': functools.partial(navigate.prevnext, prev=True), 'next': functools.partial(navigate.prevnext, prev=False), 'up': navigate.path_up, diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index c1574aed1..d0245937f 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -32,7 +32,7 @@ from typing import cast, List, Sequence from PyQt5.QtCore import pyqtSignal, QObject, QUrl from qutebrowser.utils import (log, standarddir, jinja, objreg, utils, - javascript, urlmatch, version, usertypes) + javascript, urlmatch, version, usertypes, message) from qutebrowser.api import cmdutils from qutebrowser.browser import downloads from qutebrowser.misc import objects @@ -67,6 +67,7 @@ class GreasemonkeyScript: self.runs_on_sub_frames = True self.jsworld = "main" self.name = '' + self.dedup_suffix = 1 for name, value in properties: if name == 'name': @@ -101,6 +102,23 @@ class GreasemonkeyScript: HEADER_REGEX = r'// ==UserScript==|\n+// ==/UserScript==\n' PROPS_REGEX = r'// @(?P<prop>[^\s]+)\s*(?P<val>.*)' + def __str__(self): + return self.name + + def full_name(self) -> str: + """Get the full name of this script. + + This includes a GM- prefix, its namespace (if any) and deduplication + counter suffix, if set. + """ + parts = ['GM-'] + if self.namespace is not None: + parts += [self.namespace, '/'] + parts.append(self.name) + if self.dedup_suffix > 1: + parts.append(f"-{self.dedup_suffix}") + return ''.join(parts) + @classmethod def parse(cls, source, filename=None): """GreasemonkeyScript factory. @@ -266,7 +284,7 @@ class GreasemonkeyManager(QObject): self.load_scripts() - def load_scripts(self, *, force=False): + def load_scripts(self, *, force: bool = False) -> List[GreasemonkeyScript]: """Re-read Greasemonkey scripts from disk. The scripts are read from a 'greasemonkey' subdirectory in @@ -275,14 +293,19 @@ class GreasemonkeyManager(QObject): Args: force: For any scripts that have required dependencies, re-download them. + + Return: + A list of loaded scripts. """ self._run_start = [] self._run_end = [] self._run_idle = [] + scripts = [] for scripts_dir in _scripts_dirs(): scripts_dir = os.path.abspath(scripts_dir) log.greasemonkey.debug("Reading scripts from: {}".format(scripts_dir)) + for script_filename in glob.glob(os.path.join(scripts_dir, '*.js')): if not os.path.isfile(script_filename): continue @@ -290,10 +313,12 @@ class GreasemonkeyManager(QObject): with open(script_path, encoding='utf-8-sig') as script_file: script = GreasemonkeyScript.parse(script_file.read(), script_filename) - if not script.name: - script.name = script_filename + assert script.name, script self.add_script(script, force) + scripts.append(script) + self.scripts_reloaded.emit() + return sorted(scripts, key=str) def add_script(self, script, force=False): """Add a GreasemonkeyScript to this manager. @@ -304,8 +329,7 @@ class GreasemonkeyManager(QObject): """ if script.requires: log.greasemonkey.debug( - "Deferring script until requirements are " - "fulfilled: {}".format(script.name)) + f"Deferring script until requirements are fulfilled: {script}") self._get_required_scripts(script, force) else: self._add_script(script) @@ -319,14 +343,13 @@ class GreasemonkeyManager(QObject): self._run_idle.append(script) else: if script.run_at: - log.greasemonkey.warning("Script {} has invalid run-at " - "defined, defaulting to " - "document-end" - .format(script.name)) + log.greasemonkey.warning( + f"Script {script} has invalid run-at defined, defaulting to " + "document-end") # Default as per # https://wiki.greasespot.net/Metadata_Block#.40run-at self._run_end.append(script) - log.greasemonkey.debug("Loaded script: {}".format(script.name)) + log.greasemonkey.debug(f"Loaded script: {script}") def _required_url_to_file_path(self, url): requires_dir = os.path.join(_scripts_dirs()[0], 'requires') @@ -338,9 +361,8 @@ class GreasemonkeyManager(QObject): self._in_progress_dls.remove(download) if not self._add_script_with_requires(script): log.greasemonkey.debug( - "Finished download {} for script {} " - "but some requirements are still pending" - .format(download.basename, script.name)) + f"Finished download {download.basename} for script {script} " + "but some requirements are still pending") def _add_script_with_requires(self, script, quiet=False): """Add a script with pending downloads to this GreasemonkeyManager. @@ -364,8 +386,7 @@ class GreasemonkeyManager(QObject): for url in reversed(script.requires): target_path = self._required_url_to_file_path(url) log.greasemonkey.debug( - "Adding required script for {} to IIFE: {}" - .format(script.name, url)) + f"Adding required script for {script} to IIFE: {url}") with open(target_path, encoding='utf8') as f: script.add_required_script(f.read()) @@ -426,7 +447,7 @@ class GreasemonkeyManager(QObject): @cmdutils.register() -def greasemonkey_reload(force=False): +def greasemonkey_reload(force: bool = False, quiet: bool = False) -> None: """Re-read Greasemonkey scripts from disk. The scripts are read from a 'greasemonkey' subdirectory in @@ -435,8 +456,12 @@ def greasemonkey_reload(force=False): Args: force: For any scripts that have required dependencies, re-download them. + quiet: Suppress message after loading scripts. """ - gm_manager.load_scripts(force=force) + scripts = gm_manager.load_scripts(force=force) + names = '\n'.join(str(script) for script in scripts) + if not quiet: + message.info(f"Loaded scripts:\n\n{names}") def init(): diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 7cdd0fd84..e127cd10a 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -325,14 +325,18 @@ class HintActions: cmd = context.args[0] args = context.args[1:] + flags = QUrl.FullyEncoded + env = { 'QUTE_MODE': 'hints', 'QUTE_SELECTED_TEXT': str(elem), 'QUTE_SELECTED_HTML': elem.outer_xml(), + 'QUTE_CURRENT_URL': + context.baseurl.toString(flags), # type: ignore[arg-type] } + url = elem.resolve_url(context.baseurl) if url is not None: - flags = QUrl.FullyEncoded env['QUTE_URL'] = url.toString(flags) # type: ignore[arg-type] try: diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index 773c6cc51..559992327 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -22,9 +22,10 @@ import os import time import contextlib -from typing import cast, Mapping, MutableSequence +import pathlib +from typing import cast, Mapping, MutableSequence, Optional -from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal +from PyQt5.QtCore import pyqtSlot, QUrl, QObject, pyqtSignal from PyQt5.QtWidgets import QProgressDialog, QApplication from qutebrowser.config import config @@ -91,13 +92,14 @@ class CompletionMetaInfo(sql.SqlTable): 'force_rebuild': False, } - def __init__(self, parent=None): + def __init__(self, database: sql.Database, + parent: Optional[QObject] = None) -> None: self._fields = ['key', 'value'] self._constraints = {'key': 'PRIMARY KEY'} - super().__init__( - "CompletionMetaInfo", self._fields, constraints=self._constraints) + super().__init__(database, "CompletionMetaInfo", self._fields, + constraints=self._constraints, parent=parent) - if sql.user_version_changed(): + if database.user_version_changed(): self._init_default_values() def _check_key(self, key): @@ -125,8 +127,8 @@ class CompletionMetaInfo(sql.SqlTable): def __getitem__(self, key): self._check_key(key) - query = sql.Query('SELECT value FROM CompletionMetaInfo ' - 'WHERE key = :key') + query = self.database.query('SELECT value FROM CompletionMetaInfo ' + 'WHERE key = :key') return query.run(key=key).value() def __setitem__(self, key, value): @@ -138,8 +140,9 @@ class CompletionHistory(sql.SqlTable): """History which only has the newest entry for each URL.""" - def __init__(self, parent=None): - super().__init__("CompletionHistory", ['url', 'title', 'last_atime'], + def __init__(self, database: sql.Database, + parent: Optional[QObject] = None) -> None: + super().__init__(database, "CompletionHistory", ['url', 'title', 'last_atime'], constraints={'url': 'PRIMARY KEY', 'title': 'NOT NULL', 'last_atime': 'NOT NULL'}, @@ -162,8 +165,9 @@ class WebHistory(sql.SqlTable): # one url cleared url_cleared = pyqtSignal(QUrl) - def __init__(self, progress, parent=None): - super().__init__("History", ['url', 'title', 'atime', 'redirect'], + def __init__(self, database: sql.Database, progress: HistoryProgress, + parent: Optional[QObject] = None) -> None: + super().__init__(database, "History", ['url', 'title', 'atime', 'redirect'], constraints={'url': 'NOT NULL', 'title': 'NOT NULL', 'atime': 'NOT NULL', @@ -173,8 +177,8 @@ class WebHistory(sql.SqlTable): # Store the last saved url to avoid duplicate immediate saves. self._last_url = None - self.completion = CompletionHistory(parent=self) - self.metainfo = CompletionMetaInfo(parent=self) + self.completion = CompletionHistory(database, parent=self) + self.metainfo = CompletionMetaInfo(database, parent=self) try: rebuild_completion = self.metainfo['force_rebuild'] @@ -184,16 +188,18 @@ class WebHistory(sql.SqlTable): self.metainfo.try_recover() rebuild_completion = self.metainfo['force_rebuild'] - if sql.user_version_changed(): - # If the DB user version changed, run a full cleanup and rebuild the - # completion history. - # - # In the future, this could be improved to only be done when actually needed - # - but version changes happen very infrequently, rebuilding everything - # gives us less corner-cases to deal with, and we can run a VACUUM to make - # things smaller. - self._cleanup_history() - rebuild_completion = True + if self.database.user_version_changed(): + with self.database.transaction(): + # If the DB user version changed, run a full cleanup and rebuild the + # completion history. + # + # In the future, this could be improved to only be done when actually + # needed - but version changes happen very infrequently, rebuilding + # everything gives us less corner-cases to deal with, and we can run a + # VACUUM to make things smaller. + self._cleanup_history() + rebuild_completion = True + self.database.upgrade_user_version() # Get a string of all patterns patterns = config.instance.get_str('completion.web_history.exclude') @@ -211,19 +217,19 @@ class WebHistory(sql.SqlTable): 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') + self._between_query = self.database.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 = self.database.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)) @@ -271,7 +277,7 @@ class WebHistory(sql.SqlTable): 'qute://pdfjs%', ] where_clause = ' OR '.join(f"url LIKE '{term}'" for term in terms) - q = sql.Query(f'DELETE FROM History WHERE {where_clause}') + q = self.database.query(f'DELETE FROM History WHERE {where_clause}') entries = q.run() log.sql.debug(f"Cleanup removed {entries.rows_affected()} items") @@ -297,9 +303,9 @@ class WebHistory(sql.SqlTable): QApplication.processEvents() # Select the latest entry for each url - q = sql.Query('SELECT url, title, max(atime) AS atime FROM History ' - 'WHERE NOT redirect ' - 'GROUP BY url ORDER BY atime asc') + q = self.database.query('SELECT url, title, max(atime) AS atime FROM History ' + 'WHERE NOT redirect ' + 'GROUP BY url ORDER BY atime asc') result = q.run() QApplication.processEvents() entries = list(result) @@ -319,7 +325,7 @@ class WebHistory(sql.SqlTable): self._progress.set_maximum(0) # We might have caused fragmentation - let's clean up. - sql.Query('VACUUM').run() + self.database.query('VACUUM').run() QApplication.processEvents() self.completion.insert_batch(data, replace=True) @@ -472,15 +478,17 @@ def debug_dump_history(dest): raise cmdutils.CommandError(f'Could not write history: {e}') -def init(parent=None): +def init(db_path: pathlib.Path, parent: Optional[QObject] = None) -> None: """Initialize the web history. Args: + db_path: The path for the SQLite database. parent: The parent to use for WebHistory. """ global web_history progress = HistoryProgress() - web_history = WebHistory(progress=progress, parent=parent) + database = sql.Database(str(db_path)) + web_history = WebHistory(database=database, progress=progress, parent=parent) if objects.backend == usertypes.Backend.QtWebKit: # pragma: no cover from qutebrowser.browser.webkit import webkithistory diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index cfb238188..68e36d249 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -41,7 +41,7 @@ from qutebrowser.browser import pdfjs, downloads, history from qutebrowser.config import config, configdata, configexc from qutebrowser.utils import (version, utils, jinja, log, message, docutils, resources, objreg, standarddir) -from qutebrowser.misc import guiprocess +from qutebrowser.misc import guiprocess, quitter from qutebrowser.qt import sip @@ -92,7 +92,8 @@ class Redirect(Exception): # Return value: (mimetype, data) (encoded as utf-8 if a str is returned) _HandlerRet = Tuple[str, Union[str, bytes]] -_Handler = TypeVar('_Handler', bound=Callable[[QUrl], _HandlerRet]) +_HandlerCallable = Callable[[QUrl], _HandlerRet] +_Handler = TypeVar('_Handler', bound=_HandlerCallable) class add_handler: # noqa: N801,N806 pylint: disable=invalid-name @@ -105,7 +106,7 @@ class add_handler: # noqa: N801,N806 pylint: disable=invalid-name def __init__(self, name: str) -> None: self._name = name - self._function: Optional[Callable] = None + self._function: Optional[_HandlerCallable] = None def __call__(self, function: _Handler) -> _Handler: self._function = function @@ -451,6 +452,9 @@ def qute_settings(url: QUrl) -> _HandlerRet: if url.password() != csrf_token: message.error("Invalid CSRF token for qute://settings!") raise RequestDeniedError("Invalid CSRF token!") + if quitter.instance.is_shutting_down: + log.config.debug("Ignoring /set request during shutdown") + return 'text/html', b'error: ignored' return _qute_settings_set(url) # Requests to qute://settings/set should only be allowed from diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index f53ad1afb..9ec29ce07 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -58,7 +58,8 @@ def css_selector(group: str, url: QUrl) -> str: return ','.join(selectors[group]) -class AbstractWebElement(collections.abc.MutableMapping): +# MutableMapping is only generic in Python 3.9+ +class AbstractWebElement(collections.abc.MutableMapping): # type: ignore[type-arg] """A wrapper around QtWebKit/QtWebEngine web element.""" diff --git a/qutebrowser/browser/webengine/notification.py b/qutebrowser/browser/webengine/notification.py index 50f73fca4..6b26157e6 100644 --- a/qutebrowser/browser/webengine/notification.py +++ b/qutebrowser/browser/webengine/notification.py @@ -65,12 +65,18 @@ if TYPE_CHECKING: from qutebrowser.config import config from qutebrowser.misc import objects -from qutebrowser.utils import qtutils, log, utils, debug, message +from qutebrowser.utils import qtutils, log, utils, debug, message, version bridge: Optional['NotificationBridgePresenter'] = None +def _notifications_supported() -> bool: + """Check whether the current QtWebEngine version has notification support.""" + versions = version.qtwebengine_versions(avoid_init=True) + return versions.webengine >= utils.VersionNumber(5, 14) + + def init() -> None: """Initialize the DBus notification presenter, if applicable. @@ -84,7 +90,8 @@ def init() -> None: # at a later point in time. However, doing so is probably too complex compared # to its usefulness. return - if not qtutils.version_check('5.14'): + + if not _notifications_supported(): return global bridge @@ -163,7 +170,7 @@ class NotificationBridgePresenter(QObject): def __init__(self, parent: QObject = None) -> None: super().__init__(parent) - assert qtutils.version_check('5.14') + assert _notifications_supported() self._active_notifications: Dict[int, 'QWebEngineNotification'] = {} self._adapter: Optional[AbstractNotificationAdapter] = None @@ -669,7 +676,8 @@ class _ServerCapabilities: def _as_uint32(x: int) -> QVariant: """Convert the given int to an uint32 for DBus.""" variant = QVariant(x) - assert variant.convert(QVariant.UInt) + successful = variant.convert(QVariant.UInt) + assert successful return variant @@ -708,8 +716,7 @@ class DBusNotificationAdapter(AbstractNotificationAdapter): def __init__(self, parent: QObject = None) -> None: super().__init__(bridge) - if not qtutils.version_check('5.14'): - raise Error("Notifications are not supported on Qt < 5.14") + assert _notifications_supported() if utils.is_windows: # The QDBusConnection destructor seems to cause error messages (and @@ -777,7 +784,7 @@ class DBusNotificationAdapter(AbstractNotificationAdapter): self, name: str, vendor: str, - version: str, + ver: str, ) -> Optional[_ServerQuirks]: """Find quirks to use based on the server information.""" if (name, vendor) == ("notify-osd", "Canonical Ltd"): @@ -790,15 +797,15 @@ class DBusNotificationAdapter(AbstractNotificationAdapter): # Still in active development but doesn't implement spec 1.2: # https://github.com/mate-desktop/mate-notification-daemon/issues/132 quirks = _ServerQuirks(spec_version="1.1") - if utils.VersionNumber.parse(version) <= utils.VersionNumber(1, 24): + if utils.VersionNumber.parse(ver) <= utils.VersionNumber(1, 24): # https://github.com/mate-desktop/mate-notification-daemon/issues/118 quirks.avoid_body_hyperlinks = True return quirks - elif (name, vendor) == ("naughty", "awesome") and version != "devel": + elif (name, vendor) == ("naughty", "awesome") and ver != "devel": # Still in active development but spec 1.0/1.2 support isn't # released yet: # https://github.com/awesomeWM/awesome/commit/e076bc664e0764a3d3a0164dabd9b58d334355f4 - parsed_version = utils.VersionNumber.parse(version.lstrip('v')) + parsed_version = utils.VersionNumber.parse(ver.lstrip('v')) if parsed_version <= utils.VersionNumber(4, 3): return _ServerQuirks(spec_version="1.0") elif (name, vendor) == ("twmnd", "twmnd"): @@ -809,7 +816,7 @@ class DBusNotificationAdapter(AbstractNotificationAdapter): return _ServerQuirks(skip_capabilities=True) elif (name, vendor) == ("lxqt-notificationd", "lxqt.org"): quirks = _ServerQuirks() - parsed_version = utils.VersionNumber.parse(version) + parsed_version = utils.VersionNumber.parse(ver) if parsed_version <= utils.VersionNumber(0, 16): # https://github.com/lxqt/lxqt-notificationd/issues/253 quirks.escape_title = True @@ -842,13 +849,13 @@ class DBusNotificationAdapter(AbstractNotificationAdapter): """Query notification server information and set quirks.""" reply = self.interface.call(QDBus.BlockWithGui, "GetServerInformation") self._verify_message(reply, "ssss", QDBusMessage.ReplyMessage) - name, vendor, version, spec_version = reply.arguments() + name, vendor, ver, spec_version = reply.arguments() log.misc.debug( - f"Connected to notification server: {name} {version} by {vendor}, " + f"Connected to notification server: {name} {ver} by {vendor}, " f"implementing spec {spec_version}") - quirks = self._find_quirks(name, vendor, version) + quirks = self._find_quirks(name, vendor, ver) if quirks is not None: log.misc.debug(f"Enabling quirks {quirks}") self._quirks = quirks @@ -856,7 +863,7 @@ class DBusNotificationAdapter(AbstractNotificationAdapter): expected_spec_version = self._quirks.spec_version or self.SPEC_VERSION if spec_version != expected_spec_version: log.misc.warning( - f"Notification server ({name} {version} by {vendor}) implements " + f"Notification server ({name} {ver} by {vendor}) implements " f"spec {spec_version}, but {expected_spec_version} was expected. " f"If {name} is up to date, please report a qutebrowser bug.") diff --git a/qutebrowser/browser/webengine/tabhistory.py b/qutebrowser/browser/webengine/tabhistory.py index d6c795fb2..ab4b05fe9 100644 --- a/qutebrowser/browser/webengine/tabhistory.py +++ b/qutebrowser/browser/webengine/tabhistory.py @@ -35,10 +35,10 @@ HISTORY_STREAM_VERSION = 3 def _serialize_item(item, stream): - """Serialize a single WebHistoryItem into a QDataStream. + """Serialize a single TabHistoryItem into a QDataStream. Args: - item: The WebHistoryItem to write. + item: The TabHistoryItem to write. stream: The QDataStream to write to. """ # Thanks to Otter Browser: @@ -108,10 +108,10 @@ def _serialize_item(item, stream): def serialize(items): - """Serialize a list of WebHistoryItems to a data stream. + """Serialize a list of TabHistoryItems to a data stream. Args: - items: An iterable of WebHistoryItems. + items: An iterable of TabHistoryItems. Return: A (stream, data, user_data) tuple. diff --git a/qutebrowser/browser/webengine/webenginedownloads.py b/qutebrowser/browser/webengine/webenginedownloads.py index 0ec9d551c..fc7ed8ca2 100644 --- a/qutebrowser/browser/webengine/webenginedownloads.py +++ b/qutebrowser/browser/webengine/webenginedownloads.py @@ -251,6 +251,7 @@ class DownloadManager(downloads.AbstractDownloadManager): qt_filename = os.path.basename(qt_item.path()) # FIXME use 5.14 API mime_type = qt_item.mimeType() url = qt_item.url() + origin = qt_item.page().url() if qt_item.page() else QUrl() # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-90355 if version.qtwebengine_versions().webengine >= utils.VersionNumber(5, 15, 3): @@ -292,12 +293,16 @@ class DownloadManager(downloads.AbstractDownloadManager): download.set_target(target) return + if url.scheme() == "file" and origin.isValid() and origin.scheme() == "file": + utils.open_file(url.toLocalFile()) + qt_item.cancel() + return + # Ask the user for a filename - needs to be blocking! question = downloads.get_filename_question( suggested_filename=suggested_filename, url=qt_item.url(), parent=self) self._init_filename_question(question, download) - message.global_bridge.ask(question, blocking=True) # The filename is set via the question.answered signal, connected in # _init_filename_question. diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 15784d6bf..ace23d14a 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -401,6 +401,16 @@ class WebEngineCaret(browsertab.AbstractCaret): self._js_call('reverseSelection') def _follow_selected_cb_wrapped(self, js_elem, tab): + if sip.isdeleted(self): + # Sometimes, QtWebEngine JS callbacks seem to be stuck, and will + # later get executed when the tab is closed. However, at this point, + # the WebEngineCaret is already gone. + log.webview.warning( + "Got follow_selected callback for deleted WebEngineCaret. " + "This is most likely due to a QtWebEngine bug, please report a " + "qutebrowser issue if you know a way to reproduce this.") + return + try: self._follow_selected_cb(js_elem, tab) finally: @@ -464,7 +474,7 @@ class WebEngineCaret(browsertab.AbstractCaret): # `:selection-toggle` is executed and before this callback function # is asynchronously called. log.misc.debug("Ignoring caret selection callback in {}".format( - utils.pyenum_str(self._mode_manager.mode))) + self._mode_manager.mode)) return if state_str is None: message.error("Error toggling caret selection") @@ -983,6 +993,11 @@ class _Quirk: QWebEngineScript.DocumentCreation) world: QWebEngineScript.ScriptWorldId = QWebEngineScript.MainWorld predicate: bool = True + name: Optional[str] = None + + def __post_init__(self): + if self.name is None: + self.name = f"js-{self.filename.replace('_', '-')}" class _WebEngineScripts(QObject): @@ -1097,7 +1112,12 @@ class _WebEngineScripts(QObject): page_scripts = self._widget.page().scripts() self._remove_all_greasemonkey_scripts() + seen_names = set() for script in scripts: + while script.full_name() in seen_names: + script.dedup_suffix += 1 + seen_names.add(script.full_name()) + new_script = QWebEngineScript() try: @@ -1129,7 +1149,7 @@ class _WebEngineScripts(QObject): new_script.setInjectionPoint(QWebEngineScript.DocumentReady) new_script.setSourceCode(script.code()) - new_script.setName(f"GM-{script.name}") + new_script.setName(script.full_name()) new_script.setRunsOnSubFrames(script.runs_on_sub_frames) if script.needs_document_end_workaround(): @@ -1154,6 +1174,11 @@ class _WebEngineScripts(QObject): ), _Quirk('discord'), _Quirk( + 'googledocs', + # will be an UA quirk once we set the JS UA as well + name='ua-googledocs', + ), + _Quirk( 'string_replaceall', predicate=versions.webengine < utils.VersionNumber(5, 15, 3), ), @@ -1171,8 +1196,7 @@ class _WebEngineScripts(QObject): if not quirk.predicate: continue src = resources.read_file(f'javascript/quirks/{quirk.filename}.user.js') - name = f"js-{quirk.filename.replace('_', '-')}" - if name not in config.val.content.site_specific_quirks.skip: + if quirk.name not in config.val.content.site_specific_quirks.skip: self._inject_js( f'quirk_{quirk.filename}', src, @@ -1584,7 +1608,9 @@ class WebEngineTab(browsertab.AbstractTab): up doing it twice. """ super()._on_url_changed(url) - if url.isValid() and qtutils.version_check('5.13'): + if (url.isValid() and + qtutils.version_check('5.13') and + not qtutils.version_check('5.14')): self.settings.update_for_url(url) @pyqtSlot(usertypes.NavigationRequest) diff --git a/qutebrowser/browser/webkit/http.py b/qutebrowser/browser/webkit/http.py index 35cecd89a..eacb95679 100644 --- a/qutebrowser/browser/webkit/http.py +++ b/qutebrowser/browser/webkit/http.py @@ -64,7 +64,7 @@ class ContentDisposition: # "duplicate ignored"), because even if we did ignore that one, it still wouldn't # work properly... _IGNORED_DEFECT = DefectWrapper( - email.errors.InvalidHeaderDefect, # type: ignore[attr-defined] + email.errors.InvalidHeaderDefect, 'duplicate parameter name; duplicate ignored' ) diff --git a/qutebrowser/browser/webkit/tabhistory.py b/qutebrowser/browser/webkit/tabhistory.py index c38968b62..a707030d1 100644 --- a/qutebrowser/browser/webkit/tabhistory.py +++ b/qutebrowser/browser/webkit/tabhistory.py @@ -64,10 +64,10 @@ def _serialize_item(item): def serialize(items): - """Serialize a list of WebHistoryItems to a data stream. + """Serialize a list of TabHistoryItems to a data stream. Args: - items: An iterable of WebHistoryItems. + items: An iterable of TabHistoryItems. Return: A (stream, data, user_data) tuple. diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index 6fd11b7c8..ddbd78de2 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -343,7 +343,7 @@ class BrowserPage(QWebPage): for script in toload: if frame is self.mainFrame() or script.runs_on_sub_frames: - log.webview.debug('Running GM script: {}'.format(script.name)) + log.webview.debug(f'Running GM script: {script}') frame.evaluateJavaScript(script.code()) @pyqtSlot('QWebFrame*', 'QWebPage::Feature') diff --git a/qutebrowser/completion/models/filepathcategory.py b/qutebrowser/completion/models/filepathcategory.py index ac5dd2278..b7d74f57a 100644 --- a/qutebrowser/completion/models/filepathcategory.py +++ b/qutebrowser/completion/models/filepathcategory.py @@ -91,7 +91,13 @@ class FilePathCategory(QAbstractListModel): for path in self._glob(url_path) ) else: - paths = self._glob(os.path.expanduser(val)) + try: + expanded = os.path.expanduser(val) + except ValueError: + # os.path.expanduser('~\ud800') can raise UnicodeEncodeError + # via pwd.getpwnam. '~\x00' can raise ValueError. + expanded = val + paths = self._glob(expanded) self._paths = sorted(self._contract_user(val, path) for path in paths) def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Optional[str]: diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py index bee2b43d9..8dd1be838 100644 --- a/qutebrowser/completion/models/histcategory.py +++ b/qutebrowser/completion/models/histcategory.py @@ -34,11 +34,12 @@ class HistoryCategory(QSqlQueryModel): """A completion category that queries the SQL history store.""" - def __init__(self, *, + def __init__(self, *, database: sql.Database, delete_func: util.DeleteFuncType = None, parent: QWidget = None) -> None: """Create a new History completion category.""" super().__init__(parent=parent) + self._database = database self.name = "History" self._query: Optional[sql.Query] = None @@ -56,7 +57,7 @@ class HistoryCategory(QSqlQueryModel): if max_items < 0: return '' - min_atime = sql.Query(' '.join([ + min_atime = self._database.query(' '.join([ 'SELECT min(last_atime) FROM', '(SELECT last_atime FROM CompletionHistory', 'ORDER BY last_atime DESC LIMIT :limit)', @@ -107,7 +108,7 @@ class HistoryCategory(QSqlQueryModel): # if the number of words changed, we need to generate a new # query otherwise, we can reuse the prepared query for # performance - self._query = sql.Query(' '.join([ + self._query = self._database.query(' '.join([ "SELECT url, title, {}".format(timefmt), "FROM CompletionHistory", # the incoming pattern will have literal % and _ escaped we diff --git a/qutebrowser/completion/models/urlmodel.py b/qutebrowser/completion/models/urlmodel.py index 2152f60ec..56af1f7c7 100644 --- a/qutebrowser/completion/models/urlmodel.py +++ b/qutebrowser/completion/models/urlmodel.py @@ -90,7 +90,8 @@ def url(*, info): history_disabled = info.config.get('completion.web_history.max_items') == 0 if not history_disabled and 'history' in categories: - hist_cat = histcategory.HistoryCategory(delete_func=_delete_history) + hist_cat = histcategory.HistoryCategory(database=history.web_history.database, + delete_func=_delete_history) models['history'] = hist_cat if 'filesystem' in categories: diff --git a/qutebrowser/components/braveadblock.py b/qutebrowser/components/braveadblock.py index 0a39d5491..21319cb1b 100644 --- a/qutebrowser/components/braveadblock.py +++ b/qutebrowser/components/braveadblock.py @@ -23,7 +23,9 @@ import io import logging import pathlib import functools -from typing import Optional, IO +import contextlib +import subprocess +from typing import Optional, IO, Iterator from PyQt5.QtCore import QUrl @@ -116,6 +118,37 @@ def _resource_type_to_string(resource_type: Optional[ResourceType]) -> str: return _RESOURCE_TYPE_STRINGS.get(resource_type, "other") +class DeserializationError(Exception): + + """Custom exception for adblock.DeserializationErrors. + + See _map_exception below for details. + """ + + +@contextlib.contextmanager +def _map_exceptions() -> Iterator[None]: + """Handle exception API differences in adblock 0.5.0. + + adblock < 0.5.0 will raise a ValueError with a string describing the + exception class for all exceptions. With adblock 0.5.0+, it raises proper + exception classes. + + This context manager unifies the two (only for DeserializationError so far). + """ + adblock_deserialization_error = getattr( + adblock, "DeserializationError", ValueError) + + try: + yield + except adblock_deserialization_error as e: + if isinstance(e, ValueError) and str(e) != "DeserializationError": + # All Rust exceptions get turned into a ValueError by + # python-adblock + raise + raise DeserializationError(str(e)) + + class BraveAdBlocker: """Manage blocked hosts based on Brave's adblocker. @@ -131,7 +164,23 @@ class BraveAdBlocker: self.enabled = _should_be_used() self._has_basedir = has_basedir self._cache_path = data_dir / "adblock-cache.dat" - self._engine = adblock.Engine(adblock.FilterSet()) + try: + self._engine = adblock.Engine(adblock.FilterSet()) + except AttributeError: + # this should never happen - let's get some infos if it does + logger.debug(f"adblock module: {adblock}") + dist = version.distribution() + if (dist is not None and + dist.parsed == version.Distribution.arch and + hasattr(adblock, "__file__")): + proc = subprocess.run( + ['pacman', '-Qo', adblock.__file__], + stdout=subprocess.PIPE, + universal_newlines=True, + check=False, + ) + logger.debug(proc.stdout) + raise def _is_blocked( self, @@ -211,7 +260,12 @@ class BraveAdBlocker: if cache_exists: logger.debug("Loading cached adblock data: %s", self._cache_path) - self._engine.deserialize_from_file(str(self._cache_path)) + try: + with _map_exceptions(): + self._engine.deserialize_from_file(str(self._cache_path)) + except DeserializationError: + message.error("Reading adblock filter data failed (corrupted data?). " + "Please run :adblock-update.") else: if ( config.val.content.blocking.adblock.lists diff --git a/qutebrowser/components/hostblock.py b/qutebrowser/components/hostblock.py index 8a0174584..2d6086245 100644 --- a/qutebrowser/components/hostblock.py +++ b/qutebrowser/components/hostblock.py @@ -37,7 +37,10 @@ from qutebrowser.api import ( qtutils, ) from qutebrowser.components.utils import blockutils -from qutebrowser.utils import version # FIXME: Move needed parts into api namespace? +from qutebrowser.utils import ( # FIXME: Move needed parts into api namespace? + urlutils, + version +) logger = logging.getLogger("network") @@ -124,10 +127,15 @@ class HostBlocker: if not config.get("content.blocking.enabled", url=first_party_url): return False + if blockutils.is_whitelisted_url(request_url): + return False + host = request_url.host() - return ( - host in self._blocked_hosts or host in self._config_blocked_hosts - ) and not blockutils.is_whitelisted_url(request_url) + + return any( + hostname in self._blocked_hosts or hostname in self._config_blocked_hosts + for hostname in urlutils.widened_hostnames(host) + ) def filter_request(self, info: interceptor.Request) -> None: """Block the given request if necessary.""" diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 374019677..437a54a33 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -97,7 +97,10 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name else: return False - def __call__(self, func: Callable) -> Callable: + def __call__( + self, + func: Callable[..., None], + ) -> Callable[..., None]: """Filter calls to the decorated function. Gets called when a function should be decorated. @@ -105,7 +108,9 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name Adds a filter which returns if we're not interested in the change-event and calls the wrapped function if we are. - We assume the function passed doesn't take any parameters. + We assume the function passed doesn't take any parameters. However, it + could take a "self" argument, so we can't cleary express this in the + type above. Args: func: The function to be decorated. @@ -173,6 +178,8 @@ class KeyConfig: result = results[0] if result.cmd.name != "set-cmd-text": return cmdline + if not result.args: + return None # doesn't look like this sets a command *flags, cmd = result.args if "-a" in flags or "--append" in flags or not cmd.startswith(":"): return None # doesn't look like this sets a command @@ -307,7 +314,7 @@ class Config(QObject): def _init_values(self) -> None: """Populate the self._values dict.""" - self._values: Mapping = {} + self._values: Mapping[str, configutils.Values] = {} for name, opt in configdata.DATA.items(): self._values[name] = configutils.Values(opt) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 2084556da..143b02fca 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -21,7 +21,7 @@ import os.path import contextlib -from typing import TYPE_CHECKING, Iterator, List, Optional +from typing import TYPE_CHECKING, Iterator, List, Optional, Any, Tuple from PyQt5.QtCore import QUrl @@ -475,7 +475,7 @@ class ConfigCommands: raise cmdutils.CommandError("{} already exists - use --force to " "overwrite!".format(filename)) - options: List = [] + options: List[Tuple[Optional[urlmatch.UrlPattern], configdata.Option, Any]] = [] if defaults: options = [(None, opt, opt.default) for _name, opt in sorted(configdata.DATA.items())] diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 5ea30a275..a24da4e82 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -528,6 +528,22 @@ content.frame_flattening: This will flatten all the frames to become one scrollable page. +content.prefers_reduced_motion: + default: false + type: Bool + backend: + QtWebEngine: Qt 5.14 + QtWebKit: false + restart: true + desc: >- + Request websites to minimize non-essentials animations and motion. + + This results in the `prefers-reduced-motion` CSS media query to evaluate to + `reduce` (rather than `no-preference`). + + On Windows, if this setting is set to False, the system-wide animation + setting is considered. + content.site_specific_quirks: renamed: content.site_specific_quirks.enabled @@ -545,6 +561,7 @@ content.site_specific_quirks.skip: - ua-whatsapp - ua-google - ua-slack + - ua-googledocs - js-whatsapp-web - js-discord - js-string-replaceall @@ -581,6 +598,7 @@ content.headers.accept_language: type: name: String none_ok: true + encoding: ascii supports_pattern: true default: en-US,en;q=0.9 desc: >- @@ -642,6 +660,7 @@ content.headers.user_agent: Safari/{webkit_version}' type: name: FormatString + encoding: ascii fields: - os_info - webkit_version @@ -657,15 +676,15 @@ content.headers.user_agent: # 'ua_fetch.py' # Vim-protip: Place your cursor below this comment and run # :r!python scripts/dev/ua_fetch.py - - - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like - Gecko) Chrome/89.0.4389.90 Safari/537.36" - - Chrome 89 Linux - - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, - like Gecko) Chrome/89.0.4389.90 Safari/537.36" - - Chrome 89 Win10 + like Gecko) Chrome/90.0.4430.93 Safari/537.36" + - Chrome 90 Win10 - - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" - - Chrome 89 macOS + (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36" + - Chrome 90 macOS + - - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like + Gecko) Chrome/90.0.4430.93 Safari/537.36" + - Chrome 90 Linux supports_pattern: true desc: | User agent to send. @@ -1382,6 +1401,8 @@ fileselect.single_file.command: - ['["xterm", "-e", "ranger", "--choosefile={}"]', "Ranger in xterm"] - ['["xterm", "-e", "vifm", "--choose-files", "{}"]', "vifm in xterm"] - ['["xterm", "-e", "nnn", "-p", "{}"]', "nnn in xterm"] + - ['["xterm", "-e", "fff", "-p", "{}"]', "fff in xterm"] + - ['["xterm", "-e", "lf", "-selection-path", "{}"]', "lf in xterm"] default: ['xterm', '-e', 'ranger', '--choosefile={}'] desc: >- Command (and arguments) to use for selecting a single file in forms. @@ -1400,6 +1421,8 @@ fileselect.multiple_files.command: - ['["xterm", "-e", "ranger", "--choosefiles={}"]', "Ranger in xterm"] - ['["xterm", "-e", "vifm", "--choose-files", "{}"]', "vifm in xterm"] - ['["xterm", "-e", "nnn", "-p", "{}"]', "nnn in xterm"] + - ['["xterm", "-e", "fff", "-p", "{}"]', "fff in xterm"] + - ['["xterm", "-e", "lf", "-selection-path", "{}"]', "lf in xterm"] default: ['xterm', '-e', 'ranger', '--choosefiles={}'] desc: >- Command (and arguments) to use for selecting multiple files in forms. @@ -2746,6 +2769,11 @@ colors.prompts.bg: type: QssColor desc: Background color for prompts. +colors.prompts.selected.fg: + default: white + type: QssColor + desc: Foreground color for the selected item in filename prompts. + colors.prompts.selected.bg: default: grey type: QssColor @@ -3272,6 +3300,7 @@ fonts.tabs.unselected: fonts.web.family.standard: default: '' + supports_pattern: true type: name: FontFamily none_ok: true @@ -3279,6 +3308,7 @@ fonts.web.family.standard: fonts.web.family.fixed: default: '' + supports_pattern: true type: name: FontFamily none_ok: true @@ -3286,6 +3316,7 @@ fonts.web.family.fixed: fonts.web.family.serif: default: '' + supports_pattern: true type: name: FontFamily none_ok: true @@ -3293,6 +3324,7 @@ fonts.web.family.serif: fonts.web.family.sans_serif: default: '' + supports_pattern: true type: name: FontFamily none_ok: true @@ -3300,6 +3332,7 @@ fonts.web.family.sans_serif: fonts.web.family.cursive: default: '' + supports_pattern: true type: name: FontFamily none_ok: true @@ -3307,6 +3340,7 @@ fonts.web.family.cursive: fonts.web.family.fantasy: default: '' + supports_pattern: true type: name: FontFamily none_ok: true @@ -3319,6 +3353,7 @@ fonts.web.family.fantasy: fonts.web.size.default: default: 16 + supports_pattern: true type: name: Int minval: 1 @@ -3327,6 +3362,7 @@ fonts.web.size.default: fonts.web.size.default_fixed: default: 13 + supports_pattern: true type: name: Int minval: 1 @@ -3335,6 +3371,7 @@ fonts.web.size.default_fixed: fonts.web.size.minimum: default: 0 + supports_pattern: true type: name: Int minval: 0 @@ -3345,6 +3382,7 @@ fonts.web.size.minimum_logical: # This is 0 as default on QtWebKit, and 6 on QtWebEngine - so let's # just go for 6 here. default: 6 + supports_pattern: true type: name: Int minval: 0 diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index f8566e2d0..6f0d0b13c 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -30,7 +30,7 @@ import configparser import contextlib import re from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Mapping, - MutableMapping, Optional, cast) + MutableMapping, Optional, Tuple, cast) import yaml from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QSettings, qVersion @@ -39,7 +39,7 @@ import qutebrowser from qutebrowser.config import (configexc, config, configdata, configutils, configtypes) from qutebrowser.keyinput import keyutils -from qutebrowser.utils import standarddir, utils, qtutils, log, urlmatch +from qutebrowser.utils import standarddir, utils, qtutils, log, urlmatch, version if TYPE_CHECKING: from qutebrowser.misc import savemanager @@ -89,6 +89,7 @@ class StateConfig(configparser.ConfigParser): self.read(self._filename, encoding='utf-8') self.qt_version_changed = False + self.qtwe_version_changed = False self.qutebrowser_version_changed = VersionChange.unknown self._set_changed_attributes() @@ -108,8 +109,20 @@ class StateConfig(configparser.ConfigParser): self[sect].pop(key, None) self['general']['qt_version'] = qVersion() + self['general']['qtwe_version'] = self._qtwe_version_str() self['general']['version'] = qutebrowser.__version__ + def _qtwe_version_str(self) -> str: + """Get the QtWebEngine version string. + + Note that it's too early to use objects.backend here... + """ + try: + import PyQt5.QtWebEngineWidgets # pylint: disable=unused-import + except ImportError: + return 'no' + return str(version.qtwebengine_versions(avoid_init=True).webengine) + def _set_changed_attributes(self) -> None: """Set qt_version_changed/qutebrowser_version_changed attributes. @@ -123,6 +136,9 @@ class StateConfig(configparser.ConfigParser): old_qt_version = self['general'].get('qt_version', None) self.qt_version_changed = old_qt_version != qVersion() + old_qtwe_version = self['general'].get('qtwe_version', None) + self.qtwe_version_changed = old_qtwe_version != self._qtwe_version_str() + old_qutebrowser_version = self['general'].get('version', None) if old_qutebrowser_version is None: # https://github.com/python/typeshed/issues/2093 @@ -286,18 +302,18 @@ class YamlConfig(QObject): self._validate_names(settings) self._build_values(settings) - def _load_settings_object(self, yaml_data: Any) -> '_SettingsType': + def _load_settings_object(self, yaml_data: Any) -> _SettingsType: """Load the settings from the settings: key.""" return self._pop_object(yaml_data, 'settings', dict) - def _load_legacy_settings_object(self, yaml_data: Any) -> '_SettingsType': + def _load_legacy_settings_object(self, yaml_data: Any) -> _SettingsType: data = self._pop_object(yaml_data, 'global', dict) settings = {} for name, value in data.items(): settings[name] = {'global': value} return settings - def _build_values(self, settings: Mapping) -> None: + def _build_values(self, settings: Mapping[str, Any]) -> None: """Build up self._values from the values in the given dict.""" errors = [] for name, yaml_values in settings.items(): @@ -724,9 +740,17 @@ class ConfigPyWriter: def __init__( self, - options: List, + options: List[ + Tuple[ + Optional[urlmatch.UrlPattern], + configdata.Option, + Any + ] + ], bindings: MutableMapping[str, Mapping[str, Optional[str]]], - *, commented: bool) -> None: + *, + commented: bool, + ) -> None: self._options = options self._bindings = bindings self._commented = commented diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 8ceb67b47..6873af7cc 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -86,6 +86,21 @@ _UnsetNone = Union[None, usertypes.Unset] _StrUnsetNone = Union[str, _UnsetNone] +def _validate_encoding(encoding: Optional[str], value: str) -> None: + """Check if the given value fits into the given encoding. + + Raises ValidationError if not. + """ + if encoding is None: + return + + try: + value.encode(encoding) + except UnicodeEncodeError as e: + msg = f"{value!r} contains non-{encoding} characters: {e}" + raise configexc.ValidationError(value, msg) + + class ValidValues: """Container for valid values for a given type. @@ -377,6 +392,7 @@ class String(BaseType): maxlen: Maximum length (inclusive). forbidden: Forbidden chars in the string. regex: A regex used to validate the string. + encoding: The encoding the value needs to fit in. completions: completions to be used, or None """ @@ -407,24 +423,6 @@ class String(BaseType): self.encoding = encoding self.regex = regex - def _validate_encoding(self, value: str) -> None: - """Check if the given value fits into the configured encoding. - - Raises ValidationError if not. - - Args: - value: The value to check. - """ - if self.encoding is None: - return - - try: - value.encode(self.encoding) - except UnicodeEncodeError as e: - msg = "{!r} contains non-{} characters: {}".format( - value, self.encoding, e) - raise configexc.ValidationError(value, msg) - def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) if isinstance(value, usertypes.Unset): @@ -432,7 +430,7 @@ class String(BaseType): elif not value: return None - self._validate_encoding(value) + _validate_encoding(self.encoding, value) self._validate_valid_values(value) if self.forbidden is not None and any(c in value @@ -1544,6 +1542,7 @@ class FormatString(BaseType): Attributes: fields: Which replacements are allowed in the format string. + encoding: Which encoding the string should fit into. completions: completions to be used, or None """ @@ -1551,11 +1550,13 @@ class FormatString(BaseType): self, *, fields: Iterable[str], none_ok: bool = False, + encoding: str = None, completions: _Completions = None, ) -> None: super().__init__( none_ok=none_ok, completions=completions) self.fields = fields + self.encoding = encoding self._completions = completions def to_py(self, value: _StrUnset) -> _StrUnsetNone: @@ -1565,6 +1566,8 @@ class FormatString(BaseType): elif not value: return None + _validate_encoding(self.encoding, value) + try: value.format(**{k: '' for k in self.fields}) except (KeyError, IndexError, AttributeError) as e: diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index d619eb21f..480bbd85f 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -25,29 +25,20 @@ import collections import itertools import operator from typing import ( - TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional, Sequence, Set, Union, + TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Sequence, Set, Union, MutableMapping) from PyQt5.QtCore import QUrl from PyQt5.QtGui import QFontDatabase from PyQt5.QtWidgets import QApplication -from qutebrowser.utils import utils, urlmatch, usertypes, qtutils +from qutebrowser.utils import utils, urlmatch, urlutils, usertypes, qtutils from qutebrowser.config import configexc if TYPE_CHECKING: from qutebrowser.config import configdata -def _widened_hostnames(hostname: str) -> Iterable[str]: - """A generator for widening string hostnames. - - Ex: a.c.foo -> [a.c.foo, c.foo, foo]""" - while hostname: - yield hostname - hostname = hostname.partition(".")[-1] - - class ScopedValue: """A configuration value which is valid for a UrlPattern. @@ -231,7 +222,7 @@ class Values: candidates: List[ScopedValue] = [] # Urls trailing with '.' are equivalent to non-trailing types. # urlutils strips them, so in order to match we will need to as well. - widened_hosts = _widened_hostnames(url.host().rstrip('.')) + widened_hosts = urlutils.widened_hostnames(url.host().rstrip('.')) # We must check the 'None' key as well, in case any patterns that # did not have a domain match. for host in itertools.chain(widened_hosts, [None]): diff --git a/qutebrowser/config/qtargs.py b/qutebrowser/config/qtargs.py index cb17a0ced..c38ef5b01 100644 --- a/qutebrowser/config/qtargs.py +++ b/qutebrowser/config/qtargs.py @@ -334,7 +334,11 @@ def _qtwebengine_settings_args(versions: version.WebEngineVersions) -> Iterator[ }, 'content.headers.referer': { 'always': None, - } + }, + 'content.prefers_reduced_motion': { + True: '--force-prefers-reduced-motion', + False: None, + }, } qt_514_ver = utils.VersionNumber(5, 14) diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 1b07baab7..7556d2b6d 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -23,7 +23,7 @@ import re import argparse import functools import dataclasses -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, Optional, Union from PyQt5.QtCore import QUrl, pyqtSlot, qVersion from PyQt5.QtGui import QFont @@ -85,7 +85,11 @@ class AttributeInfo: """Info about a settings attribute.""" - def __init__(self, *attributes: Any, converter: Callable = None) -> None: + def __init__( + self, + *attributes: Any, + converter: Callable[[Any], bool] = None, + ) -> None: self.attributes = attributes if converter is None: self.converter = lambda val: val @@ -105,9 +109,6 @@ class AbstractSettings: def __init__(self, settings: Any) -> None: self._settings = settings - def _assert_not_unset(self, value: Any) -> None: - assert value is not usertypes.UNSET - def set_attribute(self, name: str, value: Any) -> None: """Set the given QWebSettings/QWebEngineSettings attribute. @@ -129,30 +130,38 @@ class AbstractSettings: info = self._ATTRIBUTES[name] return self._settings.testAttribute(info.attributes[0]) - def set_font_size(self, name: str, value: int) -> None: + def set_font_size(self, name: str, value: Union[int, usertypes.Unset]) -> None: """Set the given QWebSettings/QWebEngineSettings font size.""" - self._assert_not_unset(value) family = self._FONT_SIZES[name] - self._settings.setFontSize(family, value) + if value is usertypes.UNSET: + self._settings.resetFontSize(family) + else: + self._settings.setFontSize(family, value) - def set_font_family(self, name: str, value: Optional[str]) -> None: + def set_font_family( + self, + name: str, + value: Union[str, None, usertypes.Unset], + ) -> None: """Set the given QWebSettings/QWebEngineSettings font family. With None (the default), QFont is used to get the default font for the family. """ - self._assert_not_unset(value) family = self._FONT_FAMILIES[name] - if value is None: + if value is usertypes.UNSET: + self._settings.resetFontFamily(family) + elif value is None: font = QFont() font.setStyleHint(self._FONT_TO_QFONT[family]) value = font.defaultFamily() + self._settings.setFontFamily(family, value) + else: + self._settings.setFontFamily(family, value) - self._settings.setFontFamily(family, value) - - def set_default_text_encoding(self, encoding: str) -> None: + def set_default_text_encoding(self, encoding: Union[str, usertypes.Unset]) -> None: """Set the default text encoding to use.""" - self._assert_not_unset(encoding) + assert encoding is not usertypes.UNSET # unclear how to reset self._settings.setDefaultTextEncoding(encoding) def _update_setting(self, setting: str, value: Any) -> bool: diff --git a/qutebrowser/extensions/loader.py b/qutebrowser/extensions/loader.py index 9b704c94d..7ae45023b 100644 --- a/qutebrowser/extensions/loader.py +++ b/qutebrowser/extensions/loader.py @@ -39,6 +39,9 @@ from qutebrowser.misc import objects # ModuleInfo objects for all loaded plugins _module_infos = [] +InitHookType = Callable[['InitContext'], None] +ConfigChangedHookType = Callable[[], None] + @dataclasses.dataclass class InitContext: @@ -59,9 +62,13 @@ class ModuleInfo: """ skip_hooks: bool = False - init_hook: Optional[Callable] = None - config_changed_hooks: List[Tuple[Optional[str], Callable]] = dataclasses.field( - default_factory=list) + init_hook: Optional[InitHookType] = None + config_changed_hooks: List[ + Tuple[ + Optional[str], + ConfigChangedHookType, + ] + ] = dataclasses.field(default_factory=list) @dataclasses.dataclass diff --git a/qutebrowser/javascript/quirks/googledocs.user.js b/qutebrowser/javascript/quirks/googledocs.user.js new file mode 100644 index 000000000..7ec47f70d --- /dev/null +++ b/qutebrowser/javascript/quirks/googledocs.user.js @@ -0,0 +1,14 @@ +// ==UserScript== +// @include https://docs.google.com/* +// ==/UserScript== + +// Workaround for typing dead keys on Google Docs +// See https://bugreports.qt.io/browse/QTBUG-69652 + +"use strict"; + +Object.defineProperty(navigator, "userAgent", { + get() { + return "Mozilla/5.0 (X11; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0"; + }, +}); diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index d1828791c..7e688dab1 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -112,7 +112,7 @@ class BindingTrie: return lines - def update(self, mapping: Mapping) -> None: + def update(self, mapping: Mapping[keyutils.KeySequence, str]) -> None: """Add data from the given mapping to the trie.""" for key in mapping: self[key] = mapping[key] diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index ddf818708..6bd8c99b8 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -458,7 +458,7 @@ class KeySequence: assert self self._validate() - def _convert_key(self, key: Qt.Key) -> int: + def _convert_key(self, key: Union[int, Qt.KeyboardModifiers]) -> int: """Convert a single key for QKeySequence.""" assert isinstance(key, (int, Qt.KeyboardModifiers)), key return int(key) diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index 21a6be052..3c47fafe3 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -284,8 +284,8 @@ class ModeManager(QObject): curmode = self.mode parser = self.parsers[curmode] if curmode != usertypes.KeyMode.insert: - log.modes.debug("got keypress in mode {} - delegating to {}".format( - utils.pyenum_str(curmode), utils.qualname(parser))) + log.modes.debug("got keypress in mode {} - delegating to " + "{}".format(curmode, utils.qualname(parser))) match = parser.handle(event, dry_run=dry_run) has_modifier = event.modifiers() not in [ @@ -361,8 +361,7 @@ class ModeManager(QObject): return log.modes.debug("Entering mode {}{}".format( - utils.pyenum_str(mode), - '' if reason is None else ' (reason: {})'.format(reason))) + mode, '' if reason is None else ' (reason: {})'.format(reason))) if mode not in self.parsers: raise ValueError("No keyparser for mode {}".format(mode)) if self.mode == mode or (self.mode in PROMPT_MODES and @@ -430,8 +429,7 @@ class ModeManager(QObject): raise NotInModeError("Not in mode {}!".format(mode)) log.modes.debug("Leaving mode {}{}".format( - utils.pyenum_str(mode), - '' if reason is None else ' (reason: {})'.format(reason))) + mode, '' if reason is None else ' (reason: {})'.format(reason))) # leaving a mode implies clearing keychain, see # https://github.com/qutebrowser/qutebrowser/issues/1805 self.clear_keychain() diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py index 485f713d0..c8cbe572b 100644 --- a/qutebrowser/mainwindow/prompt.py +++ b/qutebrowser/mainwindow/prompt.py @@ -268,6 +268,7 @@ class PromptContainer(QWidget): } QTreeView { + selection-color: {{ conf.colors.prompts.selected.fg }}; selection-background-color: {{ conf.colors.prompts.selected.bg }}; border: {{ conf.colors.prompts.border }}; } @@ -278,6 +279,7 @@ class PromptContainer(QWidget): QTreeView::item:selected, QTreeView::item:selected:hover, QTreeView::branch:selected { + color: {{ conf.colors.prompts.selected.fg }}; background-color: {{ conf.colors.prompts.selected.bg }}; } """ @@ -629,6 +631,16 @@ class FilenamePrompt(_BasePrompt): self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) self._to_complete = '' + self._root_index = QModelIndex() + + def _directories_hide_show_model(self): + """Get rid of non-matching directories.""" + num_rows = self._file_model.rowCount(self._root_index) + for row in range(num_rows): + index = self._file_model.index(row, 0, self._root_index) + filename = index.data() + hidden = self._to_complete not in filename and filename != '..' + self._file_view.setRowHidden(index.row(), index.parent(), hidden) @pyqtSlot(str) def _set_fileview_root(self, path, *, tabbed=False): @@ -661,8 +673,10 @@ class FilenamePrompt(_BasePrompt): log.prompt.exception("Failed to get directory information") return - root = self._file_model.setRootPath(path) - self._file_view.setRootIndex(root) + self._root_index = self._file_model.setRootPath(path) + self._file_view.setRootIndex(self._root_index) + + self._directories_hide_show_model() @pyqtSlot(QModelIndex) def _insert_path(self, index, *, clicked=True): @@ -762,15 +776,12 @@ class FilenamePrompt(_BasePrompt): self._insert_path(idx, clicked=False) def _do_completion(self, idx, which): - filename = self._file_model.fileName(idx) - while not filename.startswith(self._to_complete) and idx.isValid(): + while idx.isValid() and self._file_view.isIndexHidden(idx): if which == 'prev': idx = self._file_view.indexAbove(idx) else: assert which == 'next', which idx = self._file_view.indexBelow(idx) - filename = self._file_model.fileName(idx) - return idx def _allowed_commands(self): diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 4041de2c9..7983127d5 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -705,7 +705,21 @@ class TabBar(QTabBar): e: The QWheelEvent """ if config.val.tabs.mousewheel_switching: - super().wheelEvent(e) + if utils.is_mac: + # WORKAROUND for this not being customizable until Qt 6: + # https://codereview.qt-project.org/c/qt/qtbase/+/327746 + index = self.currentIndex() + if index == -1: + return + dx = e.angleDelta().x() + dy = e.angleDelta().y() + delta = dx if abs(dx) > abs(dy) else dy + offset = -1 if delta > 0 else 1 + index += offset + if 0 <= index < self.count(): + self.setCurrentIndex(index) + else: + super().wheelEvent(e) else: tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index 001aa3047..3e14719e0 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -389,6 +389,7 @@ class _BackendProblemChecker: WORKAROUND for: https://bugreports.qt.io/browse/QTBUG-72532 https://bugreports.qt.io/browse/QTBUG-82105 + https://bugreports.qt.io/browse/QTBUG-93744 """ if ('serviceworker_workaround' not in configfiles.state['general'] and qtutils.version_check('5.14', compiled=False)): @@ -398,6 +399,8 @@ class _BackendProblemChecker: reason = 'Qt 5.14' elif configfiles.state.qt_version_changed: reason = 'Qt version changed' + elif configfiles.state.qtwe_version_changed: + reason = 'QtWebEngine version changed' elif config.val.qt.workarounds.remove_service_workers: reason = 'Explicitly enabled' else: diff --git a/qutebrowser/misc/debugcachestats.py b/qutebrowser/misc/debugcachestats.py index f172f0854..2004ad7ab 100644 --- a/qutebrowser/misc/debugcachestats.py +++ b/qutebrowser/misc/debugcachestats.py @@ -30,7 +30,7 @@ from typing import Any, Callable, List, Optional, Tuple, TypeVar _CACHE_FUNCTIONS: List[Tuple[str, Any]] = [] -_T = TypeVar('_T', bound=Callable) +_T = TypeVar('_T', bound=Callable[..., Any]) def register(name: Optional[str] = None) -> Callable[[_T], _T]: diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py index 5741c6b47..d561a7b96 100644 --- a/qutebrowser/misc/editor.py +++ b/qutebrowser/misc/editor.py @@ -131,7 +131,7 @@ class ExternalEditor(QObject): raise ValueError("Already editing a file!") try: self._filename = self._create_tempfile(text, 'qutebrowser-editor-') - except OSError as e: + except (OSError, UnicodeEncodeError) as e: message.error("Failed to create initial file: {}".format(e)) return diff --git a/qutebrowser/misc/elf.py b/qutebrowser/misc/elf.py index 307d3b4ac..bf824880a 100644 --- a/qutebrowser/misc/elf.py +++ b/qutebrowser/misc/elf.py @@ -65,7 +65,7 @@ import re import dataclasses import mmap import pathlib -from typing import IO, ClassVar, Dict, Optional, Tuple, cast +from typing import Any, IO, ClassVar, Dict, Optional, Tuple, cast from PyQt5.QtCore import QLibraryInfo @@ -93,7 +93,7 @@ class Endianness(enum.Enum): big = 2 -def _unpack(fmt: str, fobj: IO[bytes]) -> Tuple: +def _unpack(fmt: str, fobj: IO[bytes]) -> Tuple[Any, ...]: """Unpack the given struct format from the given file.""" size = struct.calcsize(fmt) data = _safe_read(fobj, size) diff --git a/qutebrowser/misc/quitter.py b/qutebrowser/misc/quitter.py index 7d84c57f2..a51891685 100644 --- a/qutebrowser/misc/quitter.py +++ b/qutebrowser/misc/quitter.py @@ -54,7 +54,7 @@ class Quitter(QObject): Attributes: quit_status: The current quitting status. - _is_shutting_down: Whether we're currently shutting down. + is_shutting_down: Whether we're currently shutting down. _args: The argparse namespace. """ @@ -69,7 +69,7 @@ class Quitter(QObject): 'tabs': False, 'main': False, } - self._is_shutting_down = False + self.is_shutting_down = False self._args = args def on_last_window_closed(self) -> None: @@ -214,9 +214,9 @@ class Quitter(QObject): closing. is_restart: If we're planning to restart. """ - if self._is_shutting_down: + if self.is_shutting_down: return - self._is_shutting_down = True + self.is_shutting_down = True log.destroy.debug("Shutting down with status {}, session {}...".format( status, session)) diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py index 68c0fd538..814eb2bb0 100644 --- a/qutebrowser/misc/sql.py +++ b/qutebrowser/misc/sql.py @@ -17,15 +17,19 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see <https://www.gnu.org/licenses/>. -"""Provides access to an in-memory sqlite database.""" +"""Provides access to sqlite databases.""" import collections +import contextlib import dataclasses +import types +from typing import Any, Dict, Iterator, List, Mapping, MutableSequence, Optional, Type from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtSql import QSqlDatabase, QSqlQuery, QSqlError +from PyQt5.QtSql import QSqlDatabase, QSqlError, QSqlQuery -from qutebrowser.utils import log, debug +from qutebrowser.qt import sip +from qutebrowser.utils import debug, log @dataclasses.dataclass @@ -48,32 +52,23 @@ class UserVersion: minor: int @classmethod - def from_int(cls, num): + def from_int(cls, num: int) -> 'UserVersion': """Parse a number from sqlite into a major/minor user version.""" assert 0 <= num <= 0x7FFF_FFFF, num # signed integer, but shouldn't be negative major = (num & 0x7FFF_0000) >> 16 minor = num & 0x0000_FFFF return cls(major, minor) - def to_int(self): + def to_int(self) -> int: """Get a sqlite integer from a major/minor user version.""" assert 0 <= self.major <= 0x7FFF # signed integer assert 0 <= self.minor <= 0xFFFF return self.major << 16 | self.minor - def __str__(self): + def __str__(self) -> str: return f'{self.major}.{self.minor}' -_db_user_version = None # The user version we got from the database -_USER_VERSION = UserVersion(0, 4) # The current / newest user version - - -def user_version_changed(): - """Whether the version stored in the database is different from the current one.""" - return _db_user_version != _USER_VERSION - - class SqliteErrorCode: """Error codes as used by sqlite. @@ -98,11 +93,11 @@ class Error(Exception): """Base class for all SQL related errors.""" - def __init__(self, msg, error=None): + def __init__(self, msg: str, error: Optional[QSqlError] = None) -> None: super().__init__(msg) self.error = error - def text(self): + def text(self) -> str: """Get a short text description of the error. This is a string suitable to show to the user as error message. @@ -130,18 +125,17 @@ class BugError(Error): """ -def raise_sqlite_error(msg, error): +def raise_sqlite_error(msg: str, error: QSqlError) -> None: """Raise either a BugError or KnownError.""" error_code = error.nativeErrorCode() database_text = error.databaseText() driver_text = error.driverText() log.sql.debug("SQL error:") - log.sql.debug("type: {}".format( - debug.qenum_key(QSqlError, error.type()))) - log.sql.debug("database text: {}".format(database_text)) - log.sql.debug("driver text: {}".format(driver_text)) - log.sql.debug("error code: {}".format(error_code)) + log.sql.debug(f"type: {debug.qenum_key(QSqlError, error.type())}") + log.sql.debug(f"database text: {database_text}") + log.sql.debug(f"driver text: {driver_text}") + log.sql.debug(f"error code: {error_code}") known_errors = [ SqliteErrorCode.BUSY, @@ -168,82 +162,145 @@ def raise_sqlite_error(msg, error): raise BugError(msg, error) -def init(db_path): - """Initialize the SQL database connection.""" - database = QSqlDatabase.addDatabase('QSQLITE') - if not database.isValid(): - raise KnownError('Failed to add database. Are sqlite and Qt sqlite ' - 'support installed?') - database.setDatabaseName(db_path) - if not database.open(): - error = database.lastError() - msg = "Failed to open sqlite database at {}: {}".format(db_path, - error.text()) - raise_sqlite_error(msg, error) - - global _db_user_version - version_int = Query('pragma user_version').run().value() - _db_user_version = UserVersion.from_int(version_int) - - if _db_user_version.major > _USER_VERSION.major: - raise KnownError( - "Database is too new for this qutebrowser version (database version " - f"{_db_user_version}, but {_USER_VERSION.major}.x is supported)") - - if user_version_changed(): - log.sql.debug(f"Migrating from version {_db_user_version} to {_USER_VERSION}") - # Note we're *not* updating the _db_user_version global here. We still want - # user_version_changed() to return True, as other modules (such as history.py) - # use it to create the initial table structure. - Query(f'PRAGMA user_version = {_USER_VERSION.to_int()}').run() - - # Enable write-ahead-logging and reduce disk write frequency - # see https://sqlite.org/pragma.html and issues #2930 and #3507 - # - # We might already have done this (without a migration) in earlier versions, but - # as those are idempotent, let's make sure we run them once again. - Query("PRAGMA journal_mode=WAL").run() - Query("PRAGMA synchronous=NORMAL").run() - - -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 KnownError as e: - return 'UNAVAILABLE ({})'.format(e) +class Database: + + """A wrapper over a QSqlDatabase connection.""" + + _USER_VERSION = UserVersion(0, 4) # The current / newest user version + + def __init__(self, path: str) -> None: + if QSqlDatabase.database(path).isValid(): + raise BugError(f'A connection to the database at "{path}" already exists') + + self._path = path + database = QSqlDatabase.addDatabase('QSQLITE', path) + if not database.isValid(): + raise KnownError('Failed to add database. Are sqlite and Qt sqlite ' + 'support installed?') + database.setDatabaseName(path) + if not database.open(): + error = database.lastError() + msg = f"Failed to open sqlite database at {path}: {error.text()}" + raise_sqlite_error(msg, error) + + version_int = self.query('pragma user_version').run().value() + self._user_version = UserVersion.from_int(version_int) + + if self._user_version.major > self._USER_VERSION.major: + raise KnownError( + "Database is too new for this qutebrowser version (database version " + f"{self._user_version}, but {self._USER_VERSION.major}.x is supported)") + + if self.user_version_changed(): + # Enable write-ahead-logging and reduce disk write frequency + # see https://sqlite.org/pragma.html and issues #2930 and #3507 + # + # We might already have done this (without a migration) in earlier versions, + # but as those are idempotent, let's make sure we run them once again. + self.query("PRAGMA journal_mode=WAL").run() + self.query("PRAGMA synchronous=NORMAL").run() + + def qt_database(self) -> QSqlDatabase: + """Return the wrapped QSqlDatabase instance.""" + database = QSqlDatabase.database(self._path, open=True) + if not database.isValid(): + raise BugError('Failed to get connection. Did you close() this Database ' + 'instance?') + return database + + def query(self, querystr: str, forward_only: bool = True) -> 'Query': + """Return a Query instance linked to this Database.""" + return Query(self, querystr, forward_only) + + def table(self, name: str, fields: List[str], + constraints: Optional[Dict[str, str]] = None, + parent: Optional[QObject] = None) -> 'SqlTable': + """Return a SqlTable instance linked to this Database.""" + return SqlTable(self, name, fields, constraints, parent) + + def user_version_changed(self) -> bool: + """Whether the version stored in the database differs from the current one.""" + return self._user_version != self._USER_VERSION + + def upgrade_user_version(self) -> None: + """Upgrade the user version to the latest version. + + This method should be called once all required operations to migrate from one + version to another have been run. + """ + log.sql.debug(f"Migrating from version {self._user_version} " + f"to {self._USER_VERSION}") + self.query(f'PRAGMA user_version = {self._USER_VERSION.to_int()}').run() + self._user_version = self._USER_VERSION + + def close(self) -> None: + """Close the SQL connection.""" + database = self.qt_database() + database.close() + sip.delete(database) + QSqlDatabase.removeDatabase(self._path) + + def transaction(self) -> 'Transaction': + """Return a Transaction object linked to this Database.""" + return Transaction(self) + + +class Transaction(contextlib.AbstractContextManager): # type: ignore[type-arg] + + """A Database transaction that can be used as a context manager.""" + + def __init__(self, database: Database) -> None: + self._database = database + + def __enter__(self) -> None: + log.sql.debug('Starting a transaction') + db = self._database.qt_database() + ok = db.transaction() + if not ok: + error = db.lastError() + msg = f'Failed to start a transaction: "{error.text()}"' + raise_sqlite_error(msg, error) + + def __exit__(self, + _exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + _exc_tb: Optional[types.TracebackType]) -> None: + db = self._database.qt_database() + if exc_val: + log.sql.debug('Rolling back a transaction') + db.rollback() + else: + log.sql.debug('Committing a transaction') + ok = db.commit() + if not ok: + error = db.lastError() + msg = f'Failed to commit a transaction: "{error.text()}"' + raise_sqlite_error(msg, error) class Query: """A prepared SQL query.""" - def __init__(self, querystr, forward_only=True): + def __init__(self, database: Database, querystr: str, + forward_only: bool = True) -> None: """Prepare a new SQL query. Args: + database: The Database object on which to operate. querystr: String to prepare query from. forward_only: Optimization for queries that will only step forward. Must be false for completion queries. """ - self.query = QSqlQuery(QSqlDatabase.database()) + self._database = database + self.query = QSqlQuery(database.qt_database()) log.sql.vdebug(f'Preparing: {querystr}') # type: ignore[attr-defined] ok = self.query.prepare(querystr) self._check_ok('prepare', ok) self.query.setForwardOnly(forward_only) - def __iter__(self): + def __iter__(self) -> Iterator[Any]: if not self.query.isActive(): raise BugError("Cannot iterate inactive query") rec = self.query.record() @@ -255,17 +312,16 @@ class Query: rec = self.query.record() yield rowtype(*[rec.value(i) for i in range(rec.count())]) - def _check_ok(self, step, ok): + def _check_ok(self, step: str, ok: bool) -> None: if not ok: query = self.query.lastQuery() error = self.query.lastError() - msg = 'Failed to {} query "{}": "{}"'.format(step, query, - error.text()) + msg = f'Failed to {step} query "{query}": "{error.text()}"' raise_sqlite_error(msg, error) - def _bind_values(self, values): + def _bind_values(self, values: Mapping[str, Any]) -> Dict[str, Any]: for key, val in values.items(): - self.query.bindValue(':{}'.format(key), val) + self.query.bindValue(f':{key}', val) bound_values = self.bound_values() if None in bound_values.values(): @@ -273,7 +329,7 @@ class Query: return bound_values - def run(self, **values): + def run(self, **values: Any) -> 'Query': """Execute the prepared query.""" log.sql.debug(self.query.lastQuery()) @@ -286,14 +342,13 @@ class Query: return self - def run_batch(self, values): + def run_batch(self, values: Mapping[str, MutableSequence[Any]]) -> None: """Execute the query in batch mode.""" - log.sql.debug('Running SQL query (batch): "{}"'.format( - self.query.lastQuery())) + log.sql.debug(f'Running SQL query (batch): "{self.query.lastQuery()}"') self._bind_values(values) - db = QSqlDatabase.database() + db = self._database.qt_database() ok = db.transaction() self._check_ok('transaction', ok) @@ -308,13 +363,13 @@ class Query: ok = db.commit() self._check_ok('commit', ok) - def value(self): + def value(self) -> Any: """Return the result of a single-value query (e.g. an EXISTS).""" if not self.query.next(): raise BugError("No result for single-result query") return self.query.record().value(0) - def rows_affected(self): + def rows_affected(self) -> int: """Return how many rows were affected by a non-SELECT query.""" assert not self.query.isSelect(), self assert self.query.isActive(), self @@ -322,7 +377,7 @@ class Query: assert rows != -1 return rows - def bound_values(self): + def bound_values(self) -> Dict[str, Any]: return self.query.boundValues() @@ -332,84 +387,93 @@ class SqlTable(QObject): Attributes: _name: Name of the SQL table this wraps. + database: The Database to which this table belongs. Signals: changed: Emitted when the table is modified. """ changed = pyqtSignal() + database: Database - def __init__(self, name, fields, constraints=None, parent=None): + def __init__(self, database: Database, name: str, fields: List[str], + constraints: Optional[Dict[str, str]] = None, + parent: Optional[QObject] = None) -> None: """Wrapper over a table in the SQL database. Args: + database: The Database to which this table belongs. 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 + self.database = database self._create_table(fields, constraints) - def _create_table(self, fields, constraints, *, force=False): + def _create_table(self, fields: List[str], constraints: Optional[Dict[str, str]], + *, force: bool = False) -> None: """Create the table if the database is uninitialized. If the table already exists, this does nothing (except with force=True), so it can e.g. be called on every user_version change. """ - if not user_version_changed() and not force: + if not self.database.user_version_changed() and not force: return constraints = constraints or {} - column_defs = ['{} {}'.format(field, constraints.get(field, '')) + column_defs = [f'{field} {constraints.get(field, "")}' for field in fields] - q = Query("CREATE TABLE IF NOT EXISTS {name} ({column_defs})" - .format(name=self._name, column_defs=', '.join(column_defs))) + q = self.database.query( + f"CREATE TABLE IF NOT EXISTS {self._name} ({', '.join(column_defs)})" + ) q.run() - def create_index(self, name, field): + def create_index(self, name: str, field: str) -> None: """Create an index over this table if the database is uninitialized. Args: name: Name of the index, should be unique. field: Name of the field to index. """ - if not user_version_changed(): + if not self.database.user_version_changed(): return - q = Query("CREATE INDEX IF NOT EXISTS {name} ON {table} ({field})" - .format(name=name, table=self._name, field=field)) + q = self.database.query( + f"CREATE INDEX IF NOT EXISTS {name} ON {self._name} ({field})" + ) q.run() - def __iter__(self): + def __iter__(self) -> Iterator[Any]: """Iterate rows in the table.""" - q = Query("SELECT * FROM {table}".format(table=self._name)) + q = self.database.query(f"SELECT * FROM {self._name}") q.run() return iter(q) - def contains_query(self, field): + def contains_query(self, field: str) -> Query: """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)) + return self.database.query( + f"SELECT EXISTS(SELECT * FROM {self._name} WHERE {field} = :val)" + ) - def __len__(self): + def __len__(self) -> int: """Return the count of rows in the table.""" - q = Query("SELECT count(*) FROM {table}".format(table=self._name)) + q = self.database.query(f"SELECT count(*) FROM {self._name}") q.run() return q.value() - def __bool__(self): + def __bool__(self) -> bool: """Check whether there's any data in the table.""" - q = Query(f"SELECT 1 FROM {self._name} LIMIT 1") + q = self.database.query(f"SELECT 1 FROM {self._name} LIMIT 1") q.run() return q.query.next() - def delete(self, field, value): + def delete(self, field: str, value: Any) -> None: """Remove all rows for which `field` equals `value`. Args: @@ -419,20 +483,21 @@ class SqlTable(QObject): Return: The number of rows deleted. """ - q = Query(f"DELETE FROM {self._name} where {field} = :val") + q = self.database.query(f"DELETE FROM {self._name} where {field} = :val") q.run(val=value) if not q.rows_affected(): - raise KeyError('No row with {} = "{}"'.format(field, value)) + raise KeyError('No row with {field} = "{value}"') self.changed.emit() - def _insert_query(self, values, replace): - params = ', '.join(':{}'.format(key) for key in values) + def _insert_query(self, values: Mapping[str, Any], replace: bool) -> Query: + params = ', '.join(f':{key}' for key in values) + columns = ', '.join(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)) + return self.database.query( + f"{verb} INTO {self._name} ({columns}) values({params})" + ) - def insert(self, values, replace=False): + def insert(self, values: Mapping[str, Any], replace: bool = False) -> None: """Append a row to the table. Args: @@ -443,7 +508,8 @@ class SqlTable(QObject): q.run(**values) self.changed.emit() - def insert_batch(self, values, replace=False): + def insert_batch(self, values: Mapping[str, MutableSequence[Any]], + replace: bool = False) -> None: """Performantly append multiple rows to the table. Args: @@ -454,12 +520,12 @@ class SqlTable(QObject): q.run_batch(values) self.changed.emit() - def delete_all(self): + def delete_all(self) -> None: """Remove all rows from the table.""" - Query("DELETE FROM {table}".format(table=self._name)).run() + self.database.query(f"DELETE FROM {self._name}").run() self.changed.emit() - def select(self, sort_by, sort_order, limit=-1): + def select(self, sort_by: str, sort_order: str, limit: int = -1) -> Query: """Prepare, run, and return a select statement on this table. Args: @@ -469,9 +535,17 @@ class SqlTable(QObject): 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 = self.database.query( + f"SELECT * FROM {self._name} ORDER BY {sort_by} {sort_order} LIMIT :limit" + ) q.run(limit=limit) return q + + +def version() -> str: + """Return the sqlite version string.""" + try: + with contextlib.closing(Database(':memory:')) as in_memory_db: + return in_memory_db.query("select sqlite_version()").run().value() + except KnownError as e: + return f'UNAVAILABLE ({e})' diff --git a/qutebrowser/misc/throttle.py b/qutebrowser/misc/throttle.py index 1beebe1aa..ac565b68d 100644 --- a/qutebrowser/misc/throttle.py +++ b/qutebrowser/misc/throttle.py @@ -45,7 +45,7 @@ class Throttle(QObject): """ def __init__(self, - func: Callable, + func: Callable[..., None], delay_ms: int, parent: QObject = None) -> None: """Constructor. diff --git a/qutebrowser/utils/debug.py b/qutebrowser/utils/debug.py index 54fcd5aa9..7d069909a 100644 --- a/qutebrowser/utils/debug.py +++ b/qutebrowser/utils/debug.py @@ -35,7 +35,7 @@ from qutebrowser.misc import objects from qutebrowser.qt import sip -def log_events(klass: Type) -> Type: +def log_events(klass: Type[QObject]) -> Type[QObject]: """Class decorator to log Qt events.""" old_event = klass.event @@ -46,7 +46,7 @@ def log_events(klass: Type) -> Type: qenum_key(QEvent, e.type()))) return old_event(self, e) - klass.event = new_event + klass.event = new_event # type: ignore[assignment] return klass @@ -96,10 +96,13 @@ def log_signals(obj: QObject) -> QObject: return obj -def qenum_key(base: Type, - value: Union[int, sip.simplewrapper], +_EnumValueType = Union[sip.simplewrapper, int] + + +def qenum_key(base: Type[_EnumValueType], + value: _EnumValueType, add_base: bool = False, - klass: Type = None) -> str: + klass: Type[_EnumValueType] = None) -> str: """Convert a Qt Enum value to its key as a string. Args: @@ -119,8 +122,9 @@ def qenum_key(base: Type, raise TypeError("Can't guess enum class of an int!") try: - idx = base.staticMetaObject.indexOfEnumerator(klass.__name__) - meta_enum = base.staticMetaObject.enumerator(idx) + meta_obj = base.staticMetaObject # type: ignore[union-attr] + idx = meta_obj.indexOfEnumerator(klass.__name__) + meta_enum = meta_obj.enumerator(idx) ret = meta_enum.valueToKey(int(value)) # type: ignore[arg-type] except AttributeError: ret = None @@ -139,10 +143,10 @@ def qenum_key(base: Type, return ret -def qflags_key(base: Type, - value: Union[int, sip.simplewrapper], +def qflags_key(base: Type[_EnumValueType], + value: _EnumValueType, add_base: bool = False, - klass: Type = None) -> str: + klass: Type[_EnumValueType] = None) -> str: """Convert a Qt QFlags value to its keys as string. Note: Passing a combined value (such as Qt.AlignCenter) will get the names @@ -220,7 +224,7 @@ def signal_name(sig: pyqtBoundSignal) -> str: return m.group('name') -def format_args(args: Sequence = None, kwargs: Mapping = None) -> str: +def format_args(args: Sequence[Any] = None, kwargs: Mapping[str, Any] = None) -> str: """Format a list of arguments/kwargs to a function-call like string.""" if args is not None: arglist = [utils.compact_text(repr(arg), 200) for arg in args] @@ -245,9 +249,9 @@ def dbg_signal(sig: pyqtBoundSignal, args: Any) -> str: return '{}({})'.format(signal_name(sig), format_args(args)) -def format_call(func: Callable, - args: Sequence = None, - kwargs: Mapping = None, +def format_call(func: Callable[..., Any], + args: Sequence[Any] = None, + kwargs: Mapping[str, Any] = None, full: bool = True) -> str: """Get a string representation of a function calls with the given args. @@ -302,7 +306,7 @@ class log_time: # noqa: N801,N806 pylint: disable=invalid-name self._logger.debug("{} took {} seconds.".format( self._action.capitalize(), delta)) - def __call__(self, func: Callable) -> Callable: + def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: @functools.wraps(func) def wrapped(*args: Any, **kwargs: Any) -> Any: """Call the original function.""" diff --git a/qutebrowser/utils/docutils.py b/qutebrowser/utils/docutils.py index 202fcba95..89e799c89 100644 --- a/qutebrowser/utils/docutils.py +++ b/qutebrowser/utils/docutils.py @@ -25,7 +25,7 @@ import inspect import os.path import collections import enum -from typing import Callable, MutableMapping, Optional, List, Union +from typing import Any, Callable, MutableMapping, Optional, List, Union import qutebrowser from qutebrowser.utils import log, utils @@ -88,7 +88,7 @@ class DocstringParser: arg_inside = enum.auto() misc = enum.auto() - def __init__(self, func: Callable) -> None: + def __init__(self, func: Callable[..., Any]) -> None: """Constructor. Args: diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py index 61d8ccdad..a44a0235e 100644 --- a/qutebrowser/utils/jinja.py +++ b/qutebrowser/utils/jinja.py @@ -145,7 +145,7 @@ js_environment = jinja2.Environment(loader=Loader('javascript')) @functools.lru_cache() def template_config_variables(template: str) -> FrozenSet[str]: """Return the config variables used in the template.""" - unvisted_nodes = [environment.parse(template)] + unvisted_nodes: List[jinja2.nodes.Node] = [environment.parse(template)] result: Set[str] = set() while unvisted_nodes: node = unvisted_nodes.pop() @@ -157,11 +157,11 @@ def template_config_variables(template: str) -> FrozenSet[str]: # For example it's ['ab', 'c', 'd'] for 'conf.d.c.ab'. attrlist: List[str] = [] while isinstance(node, jinja2.nodes.Getattr): - attrlist.append(node.attr) # type: ignore[attr-defined] - node = node.node # type: ignore[attr-defined] + attrlist.append(node.attr) + node = node.node if isinstance(node, jinja2.nodes.Name): - if node.name == 'conf': # type: ignore[attr-defined] + if node.name == 'conf': result.add('.'.join(reversed(attrlist))) # otherwise, the node is a Name node so it doesn't have any # child nodes diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index b6f1f3e9b..9cd07e2e3 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -33,7 +33,7 @@ import json import inspect import argparse from typing import (TYPE_CHECKING, Any, Iterator, Mapping, MutableSequence, - Optional, Set, Tuple, Union, cast) + Optional, Set, Tuple, Union) from PyQt5 import QtCore # Optional imports @@ -363,12 +363,16 @@ def change_console_formatter(level: int) -> None: level: The numeric logging level """ assert console_handler is not None + old_formatter = console_handler.formatter - old_formatter = cast(ColoredFormatter, console_handler.formatter) - console_fmt = get_console_format(level) - console_formatter = ColoredFormatter(console_fmt, DATEFMT, '{', - use_colors=old_formatter.use_colors) - console_handler.setFormatter(console_formatter) + if isinstance(old_formatter, ColoredFormatter): + console_fmt = get_console_format(level) + console_formatter = ColoredFormatter( + console_fmt, DATEFMT, '{', use_colors=old_formatter.use_colors) + console_handler.setFormatter(console_formatter) + else: + # Same format for all levels + assert isinstance(old_formatter, JSONFormatter), old_formatter def qt_message_handler(msg_type: QtCore.QtMsgType, diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py index 99d8a0936..0819a5d0a 100644 --- a/qutebrowser/utils/objreg.py +++ b/qutebrowser/utils/objreg.py @@ -55,7 +55,8 @@ class CommandOnlyError(Exception): _IndexType = Union[str, int] -class ObjectRegistry(collections.UserDict): +# UserDict is only generic in Python 3.9+ +class ObjectRegistry(collections.UserDict): # type: ignore[type-arg] """A registry of long-living objects in qutebrowser. diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py index 01234a42b..ff8983c50 100644 --- a/qutebrowser/utils/qtutils.py +++ b/qutebrowser/utils/qtutils.py @@ -31,7 +31,8 @@ Module attributes: import io import operator import contextlib -from typing import TYPE_CHECKING, BinaryIO, IO, Iterator, Optional, Union, Tuple, cast +from typing import (Any, AnyStr, TYPE_CHECKING, BinaryIO, IO, Iterator, + Optional, Union, Tuple, cast) from PyQt5.QtCore import (qVersion, QEventLoop, QDataStream, QByteArray, QIODevice, QFileDevice, QSaveFile, QT_VERSION_STR, @@ -227,7 +228,7 @@ def savefile_open( filename: str, binary: bool = False, encoding: str = 'utf-8' -) -> Iterator[IO]: +) -> Iterator[IO[AnyStr]]: """Context manager to easily use a QSaveFile.""" f = QSaveFile(filename) cancelled = False @@ -239,7 +240,7 @@ def savefile_open( dev = cast(BinaryIO, PyQIODevice(f)) if binary: - new_f: IO = dev + new_f: IO[Any] = dev # FIXME:mypy Why doesn't AnyStr work? else: new_f = io.TextIOWrapper(dev, encoding=encoding) @@ -298,7 +299,11 @@ class PyQIODevice(io.BufferedIOBase): if not self.writable(): raise OSError("Trying to write to unwritable file!") - def open(self, mode: QIODevice.OpenMode) -> contextlib.closing: + # contextlib.closing is only generic in Python 3.9+ + def open( + self, + mode: QIODevice.OpenMode, + ) -> contextlib.closing: # type: ignore[type-arg] """Open the underlying device and ensure opening succeeded. Raises OSError if opening failed. diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 8dfd6d273..f14c2083d 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -104,7 +104,14 @@ class UrlPattern: self._init_path(parsed) self._init_port(parsed) - def _to_tuple(self) -> Tuple: + def _to_tuple(self) -> Tuple[ + bool, # _match_all + bool, # _match_subdomains + Optional[str], # _scheme + Optional[str], # host + Optional[str], # _path + Optional[int], # _port + ]: """Get a pattern with information used for __eq__/__hash__.""" return (self._match_all, self._match_subdomains, self._scheme, self.host, self._path, self._port) diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index 045981680..002f10411 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -26,7 +26,7 @@ import ipaddress import posixpath import urllib.parse import mimetypes -from typing import Optional, Tuple, Union +from typing import Optional, Tuple, Union, Iterable from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QHostInfo, QHostAddress, QNetworkProxy @@ -619,3 +619,12 @@ def parse_javascript_url(url: QUrl) -> str: raise Error("Resulted in empty JavaScript code") return code + + +def widened_hostnames(hostname: str) -> Iterable[str]: + """A generator for widening string hostnames. + + Ex: a.c.foo -> [a.c.foo, c.foo, foo]""" + while hostname: + yield hostname + hostname = hostname.partition(".")[-1] diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 56ebe45c4..a56769255 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -341,7 +341,7 @@ class prevent_exceptions: # noqa: N801,N806 pylint: disable=invalid-name self._retval = retval self._predicate = predicate - def __call__(self, func: Callable) -> Callable: + def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: """Called when a function should be decorated. Args: @@ -375,18 +375,6 @@ def is_enum(obj: Any) -> bool: return False -def pyenum_str(value: enum.Enum) -> str: - """Get a string representation of a Python enum value. - - This will have the form of "EnumType.membername", which is the default string - representation for Python up to 3.10. Unfortunately, that changes with Python 3.10: - https://bugs.python.org/issue40066 - """ - if sys.version_info[:2] >= (3, 10): - return repr(value) - return str(value) - - def get_repr(obj: Any, constructor: bool = False, **attrs: Any) -> str: """Get a suitable __repr__ string for an object. @@ -399,14 +387,8 @@ def get_repr(obj: Any, constructor: bool = False, **attrs: Any) -> str: cls = qualname(obj.__class__) parts = [] items = sorted(attrs.items()) - for name, val in items: - if isinstance(val, enum.Enum): - s = pyenum_str(val) - else: - s = repr(val) - parts.append(f'{name}={s}') - + parts.append('{}={!r}'.format(name, val)) if constructor: return '{}({})'.format(cls, ', '.join(parts)) else: @@ -447,7 +429,7 @@ def qualname(obj: Any) -> str: _ExceptionType = Union[Type[BaseException], Tuple[Type[BaseException]]] -def raises(exc: _ExceptionType, func: Callable, *args: Any) -> bool: +def raises(exc: _ExceptionType, func: Callable[..., Any], *args: Any) -> bool: """Check if a function raises a given exception. Args: @@ -725,7 +707,10 @@ def yaml_dump(data: Any, f: IO[str] = None) -> Optional[str]: return yaml_data.decode('utf-8') -def chunk(elems: Sequence, n: int) -> Iterator[Sequence]: +_T = TypeVar('_T') + + +def chunk(elems: Sequence[_T], n: int) -> Iterator[Sequence[_T]]: """Yield successive n-sized chunks from elems. If elems % n != 0, the last chunk will be smaller. diff --git a/requirements.txt b/requirements.txt index 77c839b86..e6b75b5ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,13 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -adblock==0.4.4 +adblock==0.5.0 colorama==0.4.4 dataclasses==0.6 ; python_version<"3.7" -importlib-metadata==4.0.1 ; python_version<"3.8" -importlib-resources==5.1.2 ; python_version<"3.9" -Jinja2==2.11.3 -MarkupSafe==1.1.1 +importlib-metadata==4.6.1 ; python_version<"3.8" +importlib-resources==5.2.0 ; python_version<"3.9" +Jinja2==3.0.1 +MarkupSafe==2.0.1 Pygments==2.9.0 PyYAML==5.4.1 typing-extensions==3.10.0.0 -zipp==3.4.1 +zipp==3.5.0 diff --git a/scripts/cycle-inputs.js b/scripts/cycle-inputs.js index bb667bda7..fc2397c23 100644 --- a/scripts/cycle-inputs.js +++ b/scripts/cycle-inputs.js @@ -6,7 +6,7 @@ * CYCLE_INPUTS = "jseval -q -f ~/.config/qutebrowser/cycle-inputs.js" * config.bind('gi', CYCLE_INPUTS) * - * By dive on freenode <dave@dawoodfall.net> + * By dive <dave@dawoodfall.net> */ (function() { diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index be6492358..a1c6646eb 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -269,7 +269,24 @@ INFO_PLIST_UPDATES = { "CFBundleTypeMIMETypes": ["text/xhtml"], "CFBundleTypeName": "XHTML document", "CFBundleTypeRole": "Viewer", - }] + }], + + # https://developer.apple.com/documentation/avfoundation/cameras_and_media_capture/requesting_authorization_for_media_capture_on_macos + # + # Keys based on Google Chrome's .app, except Bluetooth keys which seem to + # be iOS-only. + # + # If we don't do this, we get a SIGABRT from macOS when those permissions + # are used, and even in some other situations (like logging into Google + # accounts)... + 'NSCameraUsageDescription': + 'A website in qutebrowser wants to use the camera.', + 'NSLocationUsageDescription': + 'A website in qutebrowser wants to use your location information.', + 'NSMicrophoneUsageDescription': + 'A website in qutebrowser wants to use your microphone.', + 'NSBluetoothAlwaysUsageDescription': + 'A website in qutebrowser wants to access Bluetooth.', } diff --git a/scripts/dev/recompile_requirements.py b/scripts/dev/recompile_requirements.py index b545dbc5f..158741e5c 100644 --- a/scripts/dev/recompile_requirements.py +++ b/scripts/dev/recompile_requirements.py @@ -50,22 +50,23 @@ CHANGELOG_URLS = { 'EasyProcess': 'https://github.com/ponty/EasyProcess/commits/master', 'PyVirtualDisplay': 'https://github.com/ponty/PyVirtualDisplay/commits/master', 'execnet': 'https://execnet.readthedocs.io/en/latest/changelog.html', - 'apipkg': 'https://github.com/pytest-dev/apipkg/blob/master/CHANGELOG', 'pytest-rerunfailures': 'https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst', 'pytest-repeat': 'https://github.com/pytest-dev/pytest-repeat/blob/master/CHANGES.rst', 'requests': 'https://github.com/psf/requests/blob/master/HISTORY.md', 'requests-file': 'https://github.com/dashea/requests-file/blob/master/CHANGES.rst', - 'Werkzeug': 'https://github.com/pallets/werkzeug/blob/master/CHANGES.rst', - 'click': 'https://click.palletsprojects.com/en/7.x/changelog/', - 'itsdangerous': 'https://itsdangerous.palletsprojects.com/en/1.1.x/changes/', + 'Werkzeug': 'https://werkzeug.palletsprojects.com/en/latest/changes/', + 'click': 'https://click.palletsprojects.com/en/latest/changes/', + 'itsdangerous': 'https://itsdangerous.palletsprojects.com/en/latest/changes/', 'parse-type': 'https://github.com/jenisys/parse_type/blob/master/CHANGES.txt', 'sortedcontainers': 'https://github.com/grantjenks/python-sortedcontainers/blob/master/HISTORY.rst', 'soupsieve': 'https://facelessuser.github.io/soupsieve/about/changelog/', - 'Flask': 'https://flask.palletsprojects.com/en/1.1.x/changelog/', + 'Flask': 'https://flask.palletsprojects.com/en/latest/changes/', 'Mako': 'https://docs.makotemplates.org/en/latest/changelog.html', 'glob2': 'https://github.com/miracle2k/python-glob2/blob/master/CHANGES', 'hypothesis': 'https://hypothesis.readthedocs.io/en/latest/changes.html', 'mypy': 'https://mypy-lang.blogspot.com/', + 'types-PyYAML': 'https://github.com/python/typeshed/commits/master/stubs/PyYAML', + 'types-dataclasses': 'https://github.com/python/typeshed/commits/master/stubs/dataclasses', 'pytest': 'https://docs.pytest.org/en/latest/changelog.html', 'iniconfig': 'https://github.com/RonnyPfannschmidt/iniconfig/blob/master/CHANGELOG', 'tox': 'https://tox.readthedocs.io/en/latest/changelog.html', @@ -76,8 +77,8 @@ CHANGELOG_URLS = { 'packaging': 'https://packaging.pypa.io/en/latest/changelog.html', 'build': 'https://github.com/pypa/build/blob/main/CHANGELOG.rst', 'attrs': 'https://www.attrs.org/en/stable/changelog.html', - 'Jinja2': 'https://github.com/pallets/jinja/blob/master/CHANGES.rst', - 'MarkupSafe': 'https://markupsafe.palletsprojects.com/en/1.1.x/changes/', + 'Jinja2': 'https://jinja.palletsprojects.com/en/latest/changes/', + 'MarkupSafe': 'https://markupsafe.palletsprojects.com/en/latest/changes/', 'flake8': 'https://gitlab.com/pycqa/flake8/tree/master/docs/source/release-notes', 'flake8-docstrings': 'https://pypi.org/project/flake8-docstrings/', 'flake8-debugger': 'https://github.com/JBKahn/flake8-debugger/', @@ -185,6 +186,7 @@ CHANGELOG_URLS = { 'setuptools': 'https://setuptools.readthedocs.io/en/latest/history.html', 'future': 'https://python-future.org/whatsnew.html', 'pefile': 'https://github.com/erocarrera/pefile/commits/master', + 'Deprecated': 'https://github.com/tantale/deprecated/blob/master/CHANGELOG.rst', } diff --git a/scripts/link_pyqt.py b/scripts/link_pyqt.py index 0341de096..158cc145d 100644 --- a/scripts/link_pyqt.py +++ b/scripts/link_pyqt.py @@ -189,8 +189,8 @@ def get_venv_lib_path(path): subdir = 'Scripts' if os.name == 'nt' else 'bin' executable = os.path.join(path, subdir, 'python') return run_py(executable, - 'from distutils.sysconfig import get_python_lib', - 'print(get_python_lib())') + 'from sysconfig import get_path', + 'print(get_path("platlib"))') def get_tox_syspython(tox_path): diff --git a/scripts/mkvenv.py b/scripts/mkvenv.py index 31b185fe1..7f6920bb8 100755 --- a/scripts/mkvenv.py +++ b/scripts/mkvenv.py @@ -30,6 +30,7 @@ import os.path import shutil import venv as pyvenv import subprocess +import platform from typing import List, Optional, Tuple, Dict, Union sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) @@ -224,6 +225,23 @@ def install_pyqt_binary(venv_dir: pathlib.Path, version: str) -> None: utils.print_title("Installing PyQt from binary") utils.print_col("No proprietary codec support will be available in " "qutebrowser.", 'bold') + + supported_archs = { + 'linux': {'x86_64'}, + 'win32': {'x86', 'AMD64'}, + 'darwin': {'x86_64'}, + } + if sys.platform not in supported_archs: + utils.print_error(f"{sys.platform} is not a supported platform by PyQt5 binary " + "packages, this will most likely fail.") + elif platform.machine() not in supported_archs[sys.platform]: + utils.print_error( + f"{platform.machine()} is not a supported architecture for PyQt5 binaries " + f"on {sys.platform}, this will most likely fail.") + elif sys.platform == 'linux' and platform.libc_ver()[0] != 'glibc': + utils.print_error("Non-glibc Linux is not a supported platform for PyQt5 " + "binaries, this will most likely fail.") + pip_install(venv_dir, '-r', pyqt_requirements_file(version), '--only-binary', 'PyQt5,PyQtWebEngine') @@ -335,7 +353,7 @@ def _find_libs() -> Dict[Tuple[str, str], List[str]]: for line in ldconfig_proc.stdout.splitlines(): match = pattern.fullmatch(line.strip()) if match is None: - if 'libs found in cache' not in line: + if 'libs found in cache' not in line and 'Cache generated by:' not in line: utils.print_col(f'Failed to match ldconfig output: {line}', 'yellow') continue @@ -391,7 +409,13 @@ def install_dev_requirements(venv_dir: pathlib.Path) -> None: utils.print_title("Installing dev dependencies") pip_install(venv_dir, '-r', str(requirements_file('dev')), - '-r', requirements_file('tests')) + '-r', str(requirements_file('check-manifest')), + '-r', str(requirements_file('flake8')), + '-r', str(requirements_file('mypy')), + '-r', str(requirements_file('pyroma')), + '-r', str(requirements_file('vulture')), + '-r', str(requirements_file('yamllint')), + '-r', str(requirements_file('tests'))) def install_qutebrowser(venv_dir: pathlib.Path) -> None: diff --git a/tests/end2end/data/downloads/mhtml/complex/complex.html b/tests/end2end/data/downloads/mhtml/complex/complex.html index b298aa37c..d44e9be0f 100644 --- a/tests/end2end/data/downloads/mhtml/complex/complex.html +++ b/tests/end2end/data/downloads/mhtml/complex/complex.html @@ -91,7 +91,7 @@ <div class="dyk"> ...the IRC channel for qutebrowser is <code>#qutebrowser</code> on - irc.freenode.net + irc.libera.chat </div> <div class="dyk"> diff --git a/tests/end2end/data/downloads/mhtml/complex/complex.mht b/tests/end2end/data/downloads/mhtml/complex/complex.mht index 0467da22f..a458f4dcb 100644 --- a/tests/end2end/data/downloads/mhtml/complex/complex.mht +++ b/tests/end2end/data/downloads/mhtml/complex/complex.mht @@ -108,7 +108,7 @@ aster/doc/contributing.asciidoc"> =20
<div class=3D"dyk">
...the IRC channel for qutebrowser is <code>#qutebrowser</code> on
- irc.freenode.net
+ irc.libera.chat
</div>
=20
<div class=3D"dyk">
diff --git a/tests/end2end/data/prefers_reduced_motion.html b/tests/end2end/data/prefers_reduced_motion.html new file mode 100644 index 000000000..a37d43864 --- /dev/null +++ b/tests/end2end/data/prefers_reduced_motion.html @@ -0,0 +1,38 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>Prefers reduced motion test</title> + <style> +#reduce-text { + display: none; +} +#no-preference-text { + display: none; +} + +@media (prefers-reduced-motion: no-preference) { + #no-preference-text { + display: inline; + } + #missing-support-text { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + #reduce-text { + display: inline; + } + #missing-support-text { + display: none; + } +} + </style> + </head> + <body> + <p id="reduce-text">Reduced motion preference detected.</p> + <p id="no-preference-text">No preference detected.</p> + <p id="missing-support-text">Preference support missing.</p> + </body> +</html> diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature index 6448e7809..dfdb24704 100644 --- a/tests/end2end/features/downloads.feature +++ b/tests/end2end/features/downloads.feature @@ -79,7 +79,7 @@ Feature: Downloading things from a website. And I set downloads.location.prompt to true And I open data/downloads/issue1243.html And I hint with args "links download" and follow a - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='qutebrowser-download' mode=PromptMode.download option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='qutebrowser-download' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log Then the error "Download error: No handler found for qute://" should be shown And "NotFoundError while handling qute://* URL" should be logged @@ -88,7 +88,7 @@ Feature: Downloading things from a website. And I set downloads.location.prompt to true And I open data/data_link.html And I hint with args "links download" and follow s - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='download.pdf' mode=PromptMode.download option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='download.pdf' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log And I run :mode-leave Then no crash should happen @@ -96,7 +96,7 @@ Feature: Downloading things from a website. When I set downloads.location.suggestion to filename And I set downloads.location.prompt to true And I open data/downloads/download.bin in a new window without waiting - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=PromptMode.download *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> *" in the log And I run :window-only And I run :mode-leave Then no crash should happen @@ -164,7 +164,7 @@ Feature: Downloading things from a website. Scenario: Downloading a file to a reserved path When I set downloads.location.prompt to true And I open data/downloads/download.bin without waiting - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=PromptMode.download option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log And I run :prompt-accept COM1 And I run :mode-leave Then the error "Invalid filename" should be shown @@ -173,7 +173,7 @@ Feature: Downloading things from a website. Scenario: Downloading a file to a drive-relative working directory When I set downloads.location.prompt to true And I open data/downloads/download.bin without waiting - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=PromptMode.download option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log And I run :prompt-accept C:foobar And I run :mode-leave Then the error "Invalid filename" should be shown @@ -247,14 +247,14 @@ Feature: Downloading things from a website. Scenario: :download with a filename and directory which doesn't exist When I run :download --dest (tmpdir)(dirsep)downloads(dirsep)somedir(dirsep)file http://localhost:(port)/data/downloads/download.bin - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=PromptMode.yesno option=None text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log And I run :prompt-accept yes And I wait until the download is finished Then the downloaded file somedir/file should exist Scenario: :download with a directory which doesn't exist When I run :download --dest (tmpdir)(dirsep)downloads(dirsep)somedir(dirsep) http://localhost:(port)/data/downloads/download.bin - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=PromptMode.yesno option=None text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log And I run :prompt-accept yes And I wait until the download is finished Then the downloaded file somedir/download.bin should exist @@ -279,13 +279,13 @@ Feature: Downloading things from a website. When I set downloads.location.prompt to true And I open data/title.html And I run :download --mhtml - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=PromptMode.download option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' title='Save file to:'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' title='Save file to:'>, *" in the log And I run :prompt-accept And I wait for "File successfully written." in the log And I run :download --mhtml - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=PromptMode.download option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' title='Save file to:'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' title='Save file to:'>, *" in the log And I run :prompt-accept - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=PromptMode.yesno option=None text='<b>*</b> already exists. Overwrite?' title='Overwrite existing file?'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> already exists. Overwrite?' title='Overwrite existing file?'>, *" in the log And I run :prompt-accept yes And I wait for "File successfully written." in the log Then the downloaded file Test title.mhtml should exist @@ -655,9 +655,9 @@ Feature: Downloading things from a website. Scenario: Answering a question for a cancelled download (#415) When I set downloads.location.prompt to true And I run :download http://localhost:(port)/data/downloads/download.bin - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=PromptMode.download option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log And I run :download http://localhost:(port)/data/downloads/download2.bin - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=PromptMode.download option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log And I run :download-cancel with count 2 And I run :prompt-accept And I wait until the download is finished diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 3951dd2b0..e6a02e038 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -196,7 +196,7 @@ Feature: Various utility commands. # We can't use "When I open" because we don't want to wait for load # finished When I run :open http://localhost:(port)/redirect-later?delay=-1 - And I wait for "emitting: cur_load_status_changed(*loading*) (tab *)" in the log + And I wait for "emitting: cur_load_status_changed(<LoadStatus.loading: *>) (tab *)" in the log And I wait 1s And I run :stop And I open redirect-later-continue in a new tab diff --git a/tests/end2end/features/qutescheme.feature b/tests/end2end/features/qutescheme.feature index bb556df53..039434f1c 100644 --- a/tests/end2end/features/qutescheme.feature +++ b/tests/end2end/features/qutescheme.feature @@ -192,7 +192,7 @@ Feature: Special qute:// pages And I open data/misc/test.pdf without waiting And I wait for "[qute://pdfjs/*] PDF * (PDF.js: *)" in the log And I run :jseval document.getElementById("download").click() - And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=* mode=PromptMode.download option=None text=* title='Save file to:'>, *" in the log + And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=* mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log And I run :mode-leave Then no crash should happen diff --git a/tests/end2end/features/test_downloads_bdd.py b/tests/end2end/features/test_downloads_bdd.py index f7b19b4b0..804ed40fe 100644 --- a/tests/end2end/features/test_downloads_bdd.py +++ b/tests/end2end/features/test_downloads_bdd.py @@ -28,7 +28,7 @@ bdd.scenarios('downloads.feature') PROMPT_MSG = ("Asking question <qutebrowser.utils.usertypes.Question " - "default={!r} mode=PromptMode.download option=None " + "default={!r} mode=<PromptMode.download: 5> option=None " "text=* title='Save file to:'>, *") diff --git a/tests/end2end/features/test_history_bdd.py b/tests/end2end/features/test_history_bdd.py index 3a44c2c11..f2e018b9f 100644 --- a/tests/end2end/features/test_history_bdd.py +++ b/tests/end2end/features/test_history_bdd.py @@ -33,6 +33,7 @@ def turn_on_sql_history(quteproc): cmd = ":debug-pyeval objects.debug_flags.remove('no-sql-history')" quteproc.send_cmd(cmd) quteproc.wait_for_load_finished_url('qute://pyeval') + quteproc.wait_for(message='INSERT INTO History *', category='sql') @bdd.then(bdd.parsers.parse("the query parameter {name} should be set to " @@ -50,6 +51,7 @@ def check_query(quteproc, name, value): @bdd.then(bdd.parsers.parse("the history should contain:\n{expected}")) def check_history(quteproc, server, tmpdir, expected): + quteproc.wait_for(message='INSERT INTO History *', category='sql') path = tmpdir / 'history' quteproc.send_cmd(':debug-dump-history "{}"'.format(path)) quteproc.wait_for(category='message', loglevel=logging.INFO, diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index 0c0cfc50f..3cbea01ad 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -229,6 +229,10 @@ def is_ignored_chromium_message(line): # gpu_process_transport_factory.cc(1019)] Lost UI shared context. 'Lost UI shared context.', + # [20870:20908:0607/081717.652282:ERROR:block_files.cc(465)] Failed to + # open /tmp/qutebrowser-basedir-cg284f_m/data/webengine/GPUCache/data_2 + 'Failed to open *GPUCache*', + # Qt 5.12 # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-70702 # [32123:32123:0923/224739.457307:ERROR:in_progress_cache_impl.cc(192)] @@ -330,6 +334,7 @@ def is_ignored_chromium_message(line): 'filtering (maybe)?'), ('[.DisplayCompositor]GL ERROR :GL_INVALID_OPERATION : ' 'DoEndSharedImageAccessCHROMIUM: bound texture is not a shared image'), + 'Unable to map Index file', # WebRTC with Qt 5.13 / 5.14 'Failed to query stereo recording.', @@ -347,6 +352,20 @@ def is_ignored_chromium_message(line): # Flatpak 'mDNS responder manager failed to start.', 'The mDNS responder manager is not started yet.', + + # GitHub Actions with Qt 5.15.0 + # [5387:5407:0713/142608.526916:ERROR:cache_util.cc(135)] Unable to + # move cache folder + # /tmp/qutebrowser-basedir-4x3ue9fq/data/webengine/GPUCache to + # /tmp/qutebrowser-basedir-4x3ue9fq/data/webengine/old_GPUCache_000 + # [5387:5407:0713/142608.526934:ERROR:disk_cache.cc(184)] Unable to + # create cache + # [5387:5407:0713/142608.526938:ERROR:shader_disk_cache.cc(606)] Shader + # Cache Creation failed: -2 + ('Unable to move cache folder */data/webengine/GPUCache to ' + '*/data/webengine/old_GPUCache_000'), + 'Unable to create cache', + 'Shader Cache Creation failed: -2', ] return any(testutils.pattern_match(pattern=pattern, value=message) for pattern in ignored_messages) diff --git a/tests/end2end/fixtures/testprocess.py b/tests/end2end/fixtures/testprocess.py index 33b154e9a..96e700390 100644 --- a/tests/end2end/fixtures/testprocess.py +++ b/tests/end2end/fixtures/testprocess.py @@ -25,7 +25,7 @@ import warnings import dataclasses import pytest -import pytestqt.plugin +import pytestqt.wait_signal from PyQt5.QtCore import (pyqtSlot, pyqtSignal, QProcess, QObject, QElapsedTimer, QProcessEnvironment) from PyQt5.QtTest import QSignalSpy @@ -198,8 +198,7 @@ class Process(QObject): Should be used in a contextmanager. """ - blocker = pytestqt.plugin.SignalBlocker(timeout=timeout, - raising=raising) + blocker = pytestqt.wait_signal.SignalBlocker(timeout=timeout, raising=raising) blocker.connect(signal) return blocker diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index 1ce22b7ea..b860feed0 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -26,6 +26,7 @@ import logging import importlib import re import json +import platform import pytest from PyQt5.QtCore import QProcess, QPoint @@ -39,6 +40,14 @@ ascii_locale = pytest.mark.skipif(sys.hexversion >= 0x03070000, "locale with LC_ALL=C") +# For some reason (some floating point rounding differences?), color values are +# slightly different (and wrong!) on ARM machines. We adjust our expected values +# accordingly, since we don't really care about the exact value, we just want to +# know that the underlying Chromium is respecting our preferences. +# FIXME what to do about 32-bit ARM? +IS_ARM = platform.machine() == 'aarch64' + + def _base_args(config): """Get the arguments to pass with every invocation.""" args = ['--debug', '--json-logging', '--no-err-windows'] @@ -55,7 +64,16 @@ def _base_args(config): @pytest.fixture -def temp_basedir_env(tmp_path, short_tmpdir): +def runtime_tmpdir(short_tmpdir): + """A directory suitable for XDG_RUNTIME_DIR.""" + runtime_dir = short_tmpdir / 'rt' + runtime_dir.ensure(dir=True) + runtime_dir.chmod(0o700) + return runtime_dir + + +@pytest.fixture +def temp_basedir_env(tmp_path, runtime_tmpdir): """Return a dict of environment variables that fakes --temp-basedir. We can't run --basedir or --temp-basedir for some tests, so we mess with @@ -63,12 +81,8 @@ def temp_basedir_env(tmp_path, short_tmpdir): """ data_dir = tmp_path / 'data' config_dir = tmp_path / 'config' - runtime_dir = short_tmpdir / 'rt' cache_dir = tmp_path / 'cache' - runtime_dir.ensure(dir=True) - runtime_dir.chmod(0o700) - lines = [ '[general]', 'quickstart-done = 1', @@ -83,7 +97,7 @@ def temp_basedir_env(tmp_path, short_tmpdir): env = { 'XDG_DATA_HOME': str(data_dir), 'XDG_CONFIG_HOME': str(config_dir), - 'XDG_RUNTIME_DIR': str(runtime_dir), + 'XDG_RUNTIME_DIR': str(runtime_tmpdir), 'XDG_CACHE_HOME': str(cache_dir), } return env @@ -512,7 +526,8 @@ def test_preferred_colorscheme_with_dark_mode( # No workaround known. expected_text = 'Light preference detected.' # light website color, inverted by darkmode - expected_color = testutils.Color(127, 127, 127) + expected_color = (testutils.Color(123, 125, 123) if IS_ARM + else testutils.Color(127, 127, 127)) xfail = "Chromium bug 1177973" elif qtwe_version == utils.VersionNumber(5, 15, 2): # Our workaround breaks when dark mode is enabled... @@ -524,7 +539,8 @@ def test_preferred_colorscheme_with_dark_mode( # Qt 5.14 and 5.15.0/.1 work correctly. # Hopefully, so does Qt 6.x in the future? expected_text = 'Dark preference detected.' - expected_color = testutils.Color(34, 34, 34) # dark website color + expected_color = (testutils.Color(33, 32, 33) if IS_ARM + else testutils.Color(34, 34, 34)) # dark website color xfail = False pos = QPoint(0, 0) @@ -625,30 +641,51 @@ def test_cookies_store(quteproc_new, request, short_tmpdir, store): quteproc_new.wait_for_quit() +# The 'colors' dictionaries in the parametrize decorator below have (QtWebEngine +# version, CPU architecture) as keys. Either of those (or both) can be None to +# say "on all other Qt versions" or "on all other CPU architectures". @pytest.mark.parametrize('filename, algorithm, colors', [ ( 'blank', 'lightness-cielab', { - '5.15': testutils.Color(18, 18, 18), - '5.14': testutils.Color(27, 27, 27), - None: testutils.Color(0, 0, 0), + ('5.15', None): testutils.Color(18, 18, 18), + ('5.15', 'aarch64'): testutils.Color(16, 16, 16), + ('5.14', None): testutils.Color(27, 27, 27), + ('5.14', 'aarch64'): testutils.Color(24, 24, 24), + (None, None): testutils.Color(0, 0, 0), } ), - ('blank', 'lightness-hsl', {None: testutils.Color(0, 0, 0)}), - ('blank', 'brightness-rgb', {None: testutils.Color(0, 0, 0)}), + ('blank', 'lightness-hsl', {(None, None): testutils.Color(0, 0, 0)}), + ('blank', 'brightness-rgb', {(None, None): testutils.Color(0, 0, 0)}), ( 'yellow', 'lightness-cielab', { - '5.15': testutils.Color(35, 34, 0), - '5.14': testutils.Color(35, 34, 0), - None: testutils.Color(204, 204, 0), + ('5.15', None): testutils.Color(35, 34, 0), + ('5.15', 'aarch64'): testutils.Color(33, 32, 0), + ('5.14', None): testutils.Color(35, 34, 0), + ('5.14', 'aarch64'): testutils.Color(33, 32, 0), + (None, None): testutils.Color(204, 204, 0), + } + ), + ( + 'yellow', + 'lightness-hsl', + { + (None, None): testutils.Color(204, 204, 0), + (None, 'aarch64'): testutils.Color(206, 207, 0), + }, + ), + ( + 'yellow', + 'brightness-rgb', + { + (None, None): testutils.Color(0, 0, 204), + (None, 'aarch64'): testutils.Color(0, 0, 206), } ), - ('yellow', 'lightness-hsl', {None: testutils.Color(204, 204, 0)}), - ('yellow', 'brightness-rgb', {None: testutils.Color(0, 0, 204)}), ]) def test_dark_mode(webengine_versions, quteproc_new, request, filename, algorithm, colors): @@ -664,7 +701,17 @@ def test_dark_mode(webengine_versions, quteproc_new, request, ver = webengine_versions.webengine minor_version = str(ver.strip_patch()) - expected = colors.get(minor_version, colors[None]) + + arch = platform.machine() + for key in [ + (minor_version, arch), + (minor_version, None), + (None, arch), + (None, None), + ]: + if key in colors: + expected = colors[key] + break quteproc_new.open_path(f'data/darkmode/{filename}.html') @@ -691,9 +738,11 @@ def test_dark_mode_mathml(quteproc_new, request, qtbot): quteproc_new.wait_for_js('Image loaded') # First make sure loading finished by looking outside of the image + expected = testutils.Color(0, 0, 206) if IS_ARM else testutils.Color(0, 0, 204) + quteproc_new.get_screenshot( probe_pos=QPoint(105, 0), - probe_color=testutils.Color(0, 0, 204), + probe_color=expected, ) # Then get the actual formula color, probing again in case it's not displayed yet... @@ -703,6 +752,30 @@ def test_dark_mode_mathml(quteproc_new, request, qtbot): ) +@testutils.qt514 +@pytest.mark.parametrize('value, preference', [ + ('true', 'Reduced motion'), + ('false', 'No'), +]) +@pytest.mark.skipif( + utils.is_windows, + reason="Outcome on Windows depends on system settings", +) +def test_prefers_reduced_motion(quteproc_new, request, value, preference): + if not request.config.webengine: + pytest.skip("Skipped with QtWebKit") + + args = _base_args(request.config) + [ + '--temp-basedir', + '-s', 'content.prefers_reduced_motion', value, + ] + quteproc_new.start(args) + + quteproc_new.open_path('data/prefers_reduced_motion.html') + content = quteproc_new.get_content() + assert content == f"{preference} preference detected." + + def test_unavailable_backend(request, quteproc_new): """Test starting with a backend which isn't available. @@ -743,3 +816,13 @@ def test_unavailable_backend(request, quteproc_new): message=('*qutebrowser tried to start with the Qt* backend but failed ' 'because * could not be imported.*')) line.expected = True + + +def test_json_logging_without_debug(request, quteproc_new, runtime_tmpdir): + args = _base_args(request.config) + ['--temp-basedir', ':quit'] + args.remove('--debug') + args.remove('about:blank') # interfers with :quit at the end + + quteproc_new.exit_expected = True + quteproc_new.start(args, env={'XDG_RUNTIME_DIR': str(runtime_tmpdir)}) + assert not quteproc_new.is_running() diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index 7106698be..cd3778b8a 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -639,15 +639,6 @@ def short_tmpdir(): 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.""" @@ -682,12 +673,20 @@ def download_stub(win_registry, tmpdir, stubs): @pytest.fixture -def web_history(fake_save_manager, tmpdir, init_sql, config_stub, stubs, +def database(data_tmpdir): + """Create a Database object.""" + db = sql.Database(str(data_tmpdir / 'test.db')) + yield db + db.close() + + +@pytest.fixture +def web_history(fake_save_manager, tmpdir, database, config_stub, stubs, monkeypatch): """Create a WebHistory object.""" config_stub.val.completion.timestamp_format = '%Y-%m-%d' config_stub.val.completion.web_history.max_items = -1 - web_history = history.WebHistory(stubs.FakeHistoryProgress()) + web_history = history.WebHistory(database, stubs.FakeHistoryProgress()) monkeypatch.setattr(history, 'web_history', web_history) return web_history diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py index 1a46c5be0..7906d385c 100644 --- a/tests/unit/browser/test_history.py +++ b/tests/unit/browser/test_history.py @@ -31,7 +31,7 @@ from qutebrowser.misc import sql, objects @pytest.fixture(autouse=True) -def prerequisites(config_stub, fake_save_manager, init_sql, fake_args): +def prerequisites(config_stub, fake_save_manager, fake_args): """Make sure everything is ready to initialize a WebHistory.""" config_stub.data = {'general': {'private-browsing': False}} @@ -311,14 +311,14 @@ class TestInit: @pytest.mark.parametrize('backend', [usertypes.Backend.QtWebEngine, usertypes.Backend.QtWebKit]) - def test_init(self, backend, qapp, tmpdir, monkeypatch, cleanup_init): + def test_init(self, backend, qapp, tmpdir, data_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) + history.init(data_tmpdir / f'test_init_{backend}', qapp) assert history.web_history.parent() is qapp try: @@ -368,44 +368,40 @@ class TestDump: class TestRebuild: - # FIXME: Some of those tests might be a bit misleading, as creating a new - # history.WebHistory will regenerate the completion either way with the SQL changes - # in v2.0.0 (because the user version changed from 0 -> 3). - # - # They should be revisited once we can actually create two independent sqlite - # databases and copy the data over, for a "real" test. - - def test_user_version(self, web_history, stubs, monkeypatch): + def test_user_version(self, database, stubs, monkeypatch): """Ensure that completion is regenerated if user_version changes.""" + web_history = history.WebHistory(database, stubs.FakeHistoryProgress()) web_history.add_url(QUrl('example.com/1'), redirect=False, atime=1) web_history.add_url(QUrl('example.com/2'), redirect=False, atime=2) web_history.completion.delete('url', 'example.com/2') - # User version always changes, so this won't work - # hist2 = history.WebHistory(progress=stubs.FakeHistoryProgress()) - # assert list(hist2.completion) == [('example.com/1', '', 1)] + hist2 = history.WebHistory(database, progress=stubs.FakeHistoryProgress()) + assert list(hist2.completion) == [('example.com/1', '', 1)] - monkeypatch.setattr(sql, 'user_version_changed', lambda: True) + monkeypatch.setattr(web_history.database, 'user_version_changed', lambda: True) - hist3 = history.WebHistory(progress=stubs.FakeHistoryProgress()) + hist3 = history.WebHistory(web_history.database, + progress=stubs.FakeHistoryProgress()) assert list(hist3.completion) == [ ('example.com/1', '', 1), ('example.com/2', '', 2), ] assert not hist3.metainfo['force_rebuild'] - def test_force_rebuild(self, web_history, stubs): + def test_force_rebuild(self, database, stubs): """Ensure that completion is regenerated if we force a rebuild.""" + web_history = history.WebHistory(database, stubs.FakeHistoryProgress()) web_history.add_url(QUrl('example.com/1'), redirect=False, atime=1) web_history.add_url(QUrl('example.com/2'), redirect=False, atime=2) web_history.completion.delete('url', 'example.com/2') - hist2 = history.WebHistory(progress=stubs.FakeHistoryProgress()) - # User version always changes, so this won't work - # assert list(hist2.completion) == [('example.com/1', '', 1)] + hist2 = history.WebHistory(web_history.database, + progress=stubs.FakeHistoryProgress()) + assert list(hist2.completion) == [('example.com/1', '', 1)] hist2.metainfo['force_rebuild'] = True - hist3 = history.WebHistory(progress=stubs.FakeHistoryProgress()) + hist3 = history.WebHistory(web_history.database, + progress=stubs.FakeHistoryProgress()) assert list(hist3.completion) == [ ('example.com/1', '', 1), ('example.com/2', '', 2), @@ -424,7 +420,8 @@ class TestRebuild: web_history.add_url(QUrl('http://example.org'), redirect=False, atime=2) - hist2 = history.WebHistory(progress=stubs.FakeHistoryProgress()) + hist2 = history.WebHistory(web_history.database, + progress=stubs.FakeHistoryProgress()) assert list(hist2.completion) == [('http://example.com', '', 1)] def test_pattern_change_rebuild(self, config_stub, web_history, stubs): @@ -436,14 +433,16 @@ class TestRebuild: web_history.add_url(QUrl('http://example.org'), redirect=False, atime=2) - hist2 = history.WebHistory(progress=stubs.FakeHistoryProgress()) + hist2 = history.WebHistory(web_history.database, + progress=stubs.FakeHistoryProgress()) assert list(hist2.completion) == [ ('http://example.com', '', 1), ] config_stub.val.completion.web_history.exclude = [] - hist3 = history.WebHistory(progress=stubs.FakeHistoryProgress()) + hist3 = history.WebHistory(web_history.database, + progress=stubs.FakeHistoryProgress()) assert list(hist3.completion) == [ ('http://example.com', '', 1), ('http://example.org', '', 2) @@ -454,37 +453,39 @@ class TestRebuild: web_history.add_url(QUrl('example.com/2'), redirect=False, atime=2) # Trigger a completion rebuild - monkeypatch.setattr(sql, 'user_version_changed', lambda: True) + monkeypatch.setattr(web_history.database, 'user_version_changed', lambda: True) progress = stubs.FakeHistoryProgress() - history.WebHistory(progress=progress) + history.WebHistory(web_history.database, progress=progress) assert progress._value == 2 assert progress._started assert progress._finished - def test_interrupted(self, stubs, web_history, monkeypatch): + def test_interrupted(self, stubs, database, monkeypatch): """If we interrupt the rebuilding process, force_rebuild should still be set.""" + web_history = history.WebHistory(database, stubs.FakeHistoryProgress()) web_history.add_url(QUrl('example.com/1'), redirect=False, atime=1) + web_history.completion.delete('url', 'example.com/1') progress = stubs.FakeHistoryProgress(raise_on_tick=True) # Trigger a completion rebuild - monkeypatch.setattr(sql, 'user_version_changed', lambda: True) + monkeypatch.setattr(web_history.database, 'user_version_changed', lambda: True) with pytest.raises(Exception, match='tick-tock'): - history.WebHistory(progress=progress) + history.WebHistory(web_history.database, progress=progress) assert web_history.metainfo['force_rebuild'] - # If we now try again, we should get another rebuild. But due to user_version - # always changing, we can't test this at the moment (see the FIXME in the - # docstring for details) + hist2 = history.WebHistory(web_history.database, + progress=stubs.FakeHistoryProgress()) + assert list(hist2.completion) == [('example.com/1', '', 1)] class TestCompletionMetaInfo: @pytest.fixture - def metainfo(self): - return history.CompletionMetaInfo() + def metainfo(self, database): + return history.CompletionMetaInfo(database) def test_contains_keyerror(self, metainfo): with pytest.raises(KeyError): @@ -507,27 +508,27 @@ class TestCompletionMetaInfo: metainfo['excluded_patterns'] = value assert metainfo['excluded_patterns'] == value - # FIXME: It'd be good to test those two things via WebHistory (and not just - # CompletionMetaInfo in isolation), but we can't do that right now - see the - # docstring of TestRebuild for details. - - def test_recovery_no_key(self, metainfo): - metainfo.delete('key', 'force_rebuild') + def test_recovery_no_key(self, caplog, database, stubs): + web_history = history.WebHistory(database, stubs.FakeHistoryProgress()) + web_history.metainfo.delete('key', 'force_rebuild') with pytest.raises(sql.BugError, match='No result for single-result query'): - metainfo['force_rebuild'] + web_history.metainfo['force_rebuild'] - metainfo.try_recover() - assert not metainfo['force_rebuild'] + with caplog.at_level(logging.WARNING): + web_history2 = history.WebHistory(database, stubs.FakeHistoryProgress()) + assert not web_history2.metainfo['force_rebuild'] - def test_recovery_no_table(self, metainfo): - sql.Query("DROP TABLE CompletionMetaInfo").run() + def test_recovery_no_table(self, caplog, database, stubs): + web_history = history.WebHistory(database, stubs.FakeHistoryProgress()) + web_history.metainfo.database.query("DROP TABLE CompletionMetaInfo").run() with pytest.raises(sql.BugError, match='no such table: CompletionMetaInfo'): - metainfo['force_rebuild'] + web_history.metainfo['force_rebuild'] - metainfo.try_recover() - assert not metainfo['force_rebuild'] + with caplog.at_level(logging.WARNING): + web_history2 = history.WebHistory(database, stubs.FakeHistoryProgress()) + assert not web_history2.metainfo['force_rebuild'] class TestHistoryProgress: diff --git a/tests/unit/browser/webengine/test_webenginetab.py b/tests/unit/browser/webengine/test_webenginetab.py index 274e216ba..3d8eec663 100644 --- a/tests/unit/browser/webengine/test_webenginetab.py +++ b/tests/unit/browser/webengine/test_webenginetab.py @@ -164,6 +164,45 @@ class TestWebengineScripts: assert scripts_helper.get_script().injectionPoint() == expected + @pytest.mark.parametrize('header1, header2, expected_names', [ + ( + ["// @namespace ns1", "// @name same"], + ["// @namespace ns2", "// @name same"], + ['GM-ns1/same', 'GM-ns2/same'], + ), + ( + ["// @name same"], + ["// @name same"], + ['GM-same', 'GM-same-2'], + ), + ( + ["// @name same"], + ["// @name sam"], + ['GM-same', 'GM-sam'], + ), + ]) + def test_greasemonkey_duplicate_name(self, scripts_helper, + header1, header2, expected_names): + template = """ + // ==UserScript== + {header} + // ==/UserScript== + """ + template = textwrap.dedent(template.lstrip('\n')) + + source1 = template.format(header="\n".join(header1)) + script1 = greasemonkey.GreasemonkeyScript.parse(source1) + source2 = template.format(header="\n".join(header2)) + script2 = greasemonkey.GreasemonkeyScript.parse(source2) + scripts_helper.inject([script1, script2]) + + names = [script.name() for script in scripts_helper.get_scripts()] + assert names == expected_names + + source3 = textwrap.dedent(template.lstrip('\n')).format(header="// @name other") + script3 = greasemonkey.GreasemonkeyScript.parse(source3) + scripts_helper.inject([script3]) + def test_notification_permission_workaround(): """Make sure the value for QWebEnginePage::Notifications is correct.""" diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py index e0a12943b..cb37fb784 100644 --- a/tests/unit/completion/test_histcategory.py +++ b/tests/unit/completion/test_histcategory.py @@ -32,10 +32,11 @@ from qutebrowser.utils import usertypes @pytest.fixture -def hist(init_sql, config_stub): +def hist(data_tmpdir, config_stub): + db = sql.Database(str(data_tmpdir / 'test_histcategory.db')) config_stub.val.completion.timestamp_format = '%Y-%m-%d' config_stub.val.completion.web_history.max_items = -1 - return sql.SqlTable('CompletionHistory', ['url', 'title', 'last_atime']) + return sql.SqlTable(db, 'CompletionHistory', ['url', 'title', 'last_atime']) @pytest.mark.parametrize('pattern, before, after', [ @@ -99,7 +100,7 @@ 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() + cat = histcategory.HistoryCategory(database=hist.database) model_validator.set_model(cat) cat.set_pattern(pattern) model_validator.validate(after) @@ -110,7 +111,7 @@ def test_set_pattern_repeated(model_validator, hist): hist.insert({'url': 'example.com/foo', 'title': 'title1', 'last_atime': 1}) hist.insert({'url': 'example.com/bar', 'title': 'title2', 'last_atime': 1}) hist.insert({'url': 'example.com/baz', 'title': 'title3', 'last_atime': 1}) - cat = histcategory.HistoryCategory() + cat = histcategory.HistoryCategory(database=hist.database) model_validator.set_model(cat) cat.set_pattern('b') @@ -143,7 +144,7 @@ def test_set_pattern_repeated(model_validator, hist): ], ids=['numbers', 'characters']) def test_set_pattern_long(hist, message_mock, caplog, pattern): hist.insert({'url': 'example.com/foo', 'title': 'title1', 'last_atime': 1}) - cat = histcategory.HistoryCategory() + cat = histcategory.HistoryCategory(database=hist.database) with caplog.at_level(logging.ERROR): cat.set_pattern(pattern) msg = message_mock.getmsg(usertypes.MessageLevel.error) @@ -153,7 +154,7 @@ def test_set_pattern_long(hist, message_mock, caplog, pattern): @hypothesis.given(pat=strategies.text()) def test_set_pattern_hypothesis(hist, pat, caplog): hist.insert({'url': 'example.com/foo', 'title': 'title1', 'last_atime': 1}) - cat = histcategory.HistoryCategory() + cat = histcategory.HistoryCategory(database=hist.database) with caplog.at_level(logging.ERROR): cat.set_pattern(pat) @@ -202,7 +203,7 @@ def test_sorting(max_items, before, after, model_validator, hist, config_stub): 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() + cat = histcategory.HistoryCategory(database=hist.database) model_validator.set_model(cat) cat.set_pattern('') model_validator.validate(after) @@ -211,7 +212,7 @@ def test_sorting(max_items, before, after, model_validator, hist, config_stub): def test_remove_rows(hist, model_validator): hist.insert({'url': 'foo', 'title': 'Foo', 'last_atime': 0}) hist.insert({'url': 'bar', 'title': 'Bar', 'last_atime': 0}) - cat = histcategory.HistoryCategory() + cat = histcategory.HistoryCategory(database=hist.database) model_validator.set_model(cat) cat.set_pattern('') hist.delete('url', 'foo') @@ -227,7 +228,7 @@ def test_remove_rows_fetch(hist): 'title': [str(i) for i in range(300)], 'last_atime': [0] * 300, }) - cat = histcategory.HistoryCategory() + cat = histcategory.HistoryCategory(database=hist.database) cat.set_pattern('') # sanity check that we didn't fetch everything up front @@ -245,20 +246,21 @@ def test_remove_rows_fetch(hist): ('%m/%d/%Y %H:%M', '02/27/2018 08:30'), ('', ''), ]) -def test_timestamp_fmt(fmt, expected, model_validator, config_stub, init_sql): +def test_timestamp_fmt(fmt, expected, model_validator, config_stub, data_tmpdir): """Validate the filtering and sorting results of set_pattern.""" config_stub.val.completion.timestamp_format = fmt - hist = sql.SqlTable('CompletionHistory', ['url', 'title', 'last_atime']) + db = sql.Database(str(data_tmpdir / 'test_timestamp_fmt.db')) + hist = sql.SqlTable(db, 'CompletionHistory', ['url', 'title', 'last_atime']) atime = datetime.datetime(2018, 2, 27, 8, 30) hist.insert({'url': 'foo', 'title': '', 'last_atime': atime.timestamp()}) - cat = histcategory.HistoryCategory() + cat = histcategory.HistoryCategory(database=hist.database) model_validator.set_model(cat) cat.set_pattern('') model_validator.validate([('foo', '', expected)]) def test_skip_duplicate_set(message_mock, caplog, hist): - cat = histcategory.HistoryCategory() + cat = histcategory.HistoryCategory(database=hist.database) cat.set_pattern('foo') cat.set_pattern('foobarbaz') msg = caplog.messages[-1] diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 45506fe6a..c20fe293c 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -462,8 +462,7 @@ def test_filesystem_completion_model_interface(info, local_files_path): @hypothesis.given( as_uri=hst.booleans(), add_sep=hst.booleans(), - text=hst.text(alphabet=hst.characters( - blacklist_categories=['Cc'], blacklist_characters='\x00')), + text=hst.text(), ) def test_filesystem_completion_hypothesis(info, as_uri, add_sep, text): if as_uri: @@ -475,6 +474,12 @@ def test_filesystem_completion_hypothesis(info, as_uri, add_sep, text): model.set_pattern(text) +@pytest.mark.parametrize('text', ['~\ud800', '~\x00']) +def test_filesystem_completion_corner_cases(info, text): + model = filepathcategory.FilePathCategory('filepaths') + model.set_pattern(text) + + def test_default_filesystem_completion(qtmodeltester, config_stub, info, web_history_populated, quickmarks, bookmarks, local_files_path): diff --git a/tests/unit/components/test_braveadblock.py b/tests/unit/components/test_braveadblock.py index 02f7c1074..fc50cb595 100644 --- a/tests/unit/components/test_braveadblock.py +++ b/tests/unit/components/test_braveadblock.py @@ -29,6 +29,7 @@ import pytest from qutebrowser.api.interceptor import ResourceType from qutebrowser.components import braveadblock from qutebrowser.components.utils import blockutils +from qutebrowser.utils import usertypes from helpers import testutils pytestmark = pytest.mark.usefixtures("qapp") @@ -417,3 +418,15 @@ def test_buggy_url_workaround_needed(ad_blocker, config_stub, easylist_easypriva request_type=resource_type_str ) assert result.matched + + +def test_corrupt_cache_handling(ad_blocker, message_mock, caplog): + ad_blocker._cache_path.write_text("blablub") + + with caplog.at_level(logging.ERROR): + ad_blocker.read_cache() + + msg = message_mock.getmsg(usertypes.MessageLevel.error) + assert msg.text == ( + "Reading adblock filter data failed (corrupted data?). " + "Please run :adblock-update.") diff --git a/tests/unit/components/test_hostblock.py b/tests/unit/components/test_hostblock.py index 8dd8d6dda..00a7a5f8f 100644 --- a/tests/unit/components/test_hostblock.py +++ b/tests/unit/components/test_hostblock.py @@ -279,7 +279,7 @@ def test_disabled_blocking_per_url(config_stub, host_blocker_factory): pattern = urlmatch.UrlPattern(example_com) config_stub.set_obj("content.blocking.enabled", False, pattern=pattern) - url = QUrl("blocked.example.com") + url = QUrl("https://blocked.example.com") host_blocker = host_blocker_factory() host_blocker._blocked_hosts.add(url.host()) @@ -563,3 +563,11 @@ def test_adblock_benchmark(data_tmpdir, benchmark, host_blocker_factory): assert blocker._blocked_hosts benchmark(lambda: blocker._is_blocked(url)) + + +def test_subdomain_blocking(config_stub, host_blocker_factory): + config_stub.val.content.blocking.method = "hosts" + config_stub.val.content.blocking.hosts.lists = None + host_blocker = host_blocker_factory() + host_blocker._blocked_hosts.add("example.com") + assert host_blocker._is_blocked(QUrl("https://subdomain.example.com")) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index dd6ef54fa..b88bc2f8d 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -211,6 +211,7 @@ class TestKeyConfig: "a": "set-cmd-text no_leading_colon", "b": "set-cmd-text -s -a :skip_cuz_append", "c": "set-cmd-text --append :skip_cuz_append", + "x": "set-cmd-text", }, { "open": ["o"], diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index e0d64bffc..65952ddb4 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -29,7 +29,7 @@ from PyQt5.QtCore import QSettings from qutebrowser.config import (config, configfiles, configexc, configdata, configtypes) -from qutebrowser.utils import utils, usertypes, urlmatch, standarddir +from qutebrowser.utils import utils, usertypes, urlmatch, standarddir, version from qutebrowser.keyinput import keyutils @@ -81,6 +81,7 @@ def autoconfig(config_tmpdir): False, '[general]\n' 'qt_version = 5.6.7\n' + 'qtwe_version = 7.8.9\n' 'version = 1.2.3\n' '\n' '[geometry]\n' @@ -92,6 +93,7 @@ def autoconfig(config_tmpdir): False, '[general]\n' 'qt_version = 5.6.7\n' + 'qtwe_version = 7.8.9\n' 'version = 1.2.3\n' '\n' '[geometry]\n' @@ -104,6 +106,7 @@ def autoconfig(config_tmpdir): '[general]\n' 'foobar = 42\n' 'qt_version = 5.6.7\n' + 'qtwe_version = 7.8.9\n' 'version = 1.2.3\n' '\n' '[geometry]\n' @@ -114,6 +117,7 @@ def autoconfig(config_tmpdir): True, '[general]\n' 'qt_version = 5.6.7\n' + 'qtwe_version = 7.8.9\n' 'version = 1.2.3\n' 'newval = 23\n' '\n' @@ -122,10 +126,13 @@ def autoconfig(config_tmpdir): '[inspector]\n' '\n'), ]) -def test_state_config(fake_save_manager, data_tmpdir, monkeypatch, - old_data, insert, new_data): +def test_state_config( + fake_save_manager, data_tmpdir, monkeypatch, qtwe_version_patcher, + old_data, insert, new_data +): monkeypatch.setattr(configfiles.qutebrowser, '__version__', '1.2.3') monkeypatch.setattr(configfiles, 'qVersion', lambda: '5.6.7') + qtwe_version_patcher('7.8.9') statefile = data_tmpdir / 'state' if old_data is not None: @@ -157,6 +164,28 @@ def state_writer(data_tmpdir): return _write +@pytest.fixture +def qtwe_version_patcher(monkeypatch): + try: + from PyQt5 import QtWebEngineWidgets # pylint: disable=unused-import + except ImportError: + pytest.skip("QtWebEngine not available") + + def patch(ver): + monkeypatch.setattr( + configfiles.version, + 'qtwebengine_versions', + lambda avoid_init=False: + version.WebEngineVersions( + webengine=utils.VersionNumber.parse(ver), + chromium=None, + source='test', + ) + ) + + return patch + + @pytest.mark.parametrize('old_version, new_version, changed', [ (None, '5.12.1', False), ('5.12.1', '5.12.1', False), @@ -176,6 +205,32 @@ def test_qt_version_changed(state_writer, monkeypatch, assert state.qt_version_changed == changed +@pytest.mark.parametrize('old_version, new_version, changed', [ + (None, '5.15.1', False), + ('5.15.1', '5.15.1', False), + ('5.15.1', '5.15.2', True), + ('5.14.0', '5.15.2', True), +]) +def test_qtwe_version_changed(state_writer, qtwe_version_patcher, + old_version, new_version, changed): + qtwe_version_patcher(new_version) + + if old_version is not None: + state_writer('qtwe_version', old_version) + + state = configfiles.StateConfig() + assert state.qtwe_version_changed == changed + + +def test_qtwe_version_changed_webkit(stubs, monkeypatch, state_writer): + fake = stubs.ImportFake({'PyQt5.QtWebEngineWidgets': False}, monkeypatch) + fake.patch() + + state_writer('qtwe_version', 'no') + state = configfiles.StateConfig() + assert not state.qtwe_version_changed + + @pytest.mark.parametrize('old_version, new_version, expected', [ (None, '2.0.0', configfiles.VersionChange.unknown), ('1.14.1', '1.14.1', configfiles.VersionChange.equal), diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 3e1d15099..66b152937 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -1839,6 +1839,11 @@ class TestFormatString: with pytest.raises(configexc.ValidationError): typ.to_py(val) + def test_invalid_encoding(self, klass): + typ = klass(fields=[], encoding='ascii') + with pytest.raises(configexc.ValidationError): + typ.to_py('fooäbar') + @pytest.mark.parametrize('value', [ None, ['one', 'two'], diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py index 4d3082a92..e7ce15aff 100644 --- a/tests/unit/config/test_configutils.py +++ b/tests/unit/config/test_configutils.py @@ -300,29 +300,6 @@ def test_domain_lookup_sparse_benchmark(url, values, benchmark): benchmark(lambda: values.get_for_url(url)) -class TestWiden: - - @pytest.mark.parametrize('hostname, expected', [ - ('a.b.c', ['a.b.c', 'b.c', 'c']), - ('foobarbaz', ['foobarbaz']), - ('', []), - ('.c', ['.c', 'c']), - ('c.', ['c.']), - ('.c.', ['.c.', 'c.']), - (None, []), - ]) - def test_widen_hostnames(self, hostname, expected): - assert list(configutils._widened_hostnames(hostname)) == expected - - @pytest.mark.parametrize('hostname', [ - 'test.qutebrowser.org', - 'a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.z.y.z', - 'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq.c', - ]) - def test_bench_widen_hostnames(self, hostname, benchmark): - benchmark(lambda: list(configutils._widened_hostnames(hostname))) - - class TestFontFamilies: @pytest.mark.parametrize('family_str, expected', [ @@ -405,5 +382,9 @@ class TestFontFamilies: if info.family() == fallback_family: return + if info.family() == 'Noto Sans Mono': + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-94090 + return + # If we didn't fall back, we should've gotten a fixed-pitch font. assert info.fixedPitch(), info.family() diff --git a/tests/unit/javascript/test_greasemonkey.py b/tests/unit/javascript/test_greasemonkey.py index 2bfb9ca83..17847816d 100644 --- a/tests/unit/javascript/test_greasemonkey.py +++ b/tests/unit/javascript/test_greasemonkey.py @@ -131,6 +131,20 @@ def test_no_name_with_fallback(): assert script.name == r"C:\COM1" +@pytest.mark.parametrize('properties, inc_counter, expected', [ + ([("name", "gorilla")], False, "GM-gorilla"), + ([("namespace", "apes"), ("name", "gorilla")], False, "GM-apes/gorilla"), + + ([("name", "gorilla")], True, "GM-gorilla-2"), + ([("namespace", "apes"), ("name", "gorilla")], True, "GM-apes/gorilla-2"), +]) +def test_full_name(properties, inc_counter, expected): + script = greasemonkey.GreasemonkeyScript(properties, code="") + if inc_counter: + script.dedup_suffix += 1 + assert script.full_name() == expected + + def test_bad_scheme(caplog): """qute:// isn't in the list of allowed schemes.""" _save_script("var nothing = true;\n", 'nothing.user.js') diff --git a/tests/unit/mainwindow/test_prompt.py b/tests/unit/mainwindow/test_prompt.py index 668cd0710..5b774bdaa 100644 --- a/tests/unit/mainwindow/test_prompt.py +++ b/tests/unit/mainwindow/test_prompt.py @@ -81,7 +81,12 @@ class TestFileCompletion: for _ in range(3): qtbot.keyPress(prompt._lineedit, Qt.Key_Backspace) - # foo should get completed from f + # For some reason, this isn't always called when using qtbot.keyPress. + prompt._set_fileview_root(prompt._lineedit.text()) + + # '..' and 'foo' should get completed from 'f' + prompt.item_focus('next') + assert prompt._lineedit.text() == str(tmp_path) prompt.item_focus('next') assert prompt._lineedit.text() == str(testdir / 'foo') @@ -94,6 +99,32 @@ class TestFileCompletion: prompt.item_focus('next') assert prompt._lineedit.text() == str(testdir / 'bar') + @pytest.mark.parametrize("keys, expected", [ + ([], ['..', 'bar', 'bat', 'foo']), + ([Qt.Key_F], ['..', 'foo']), + ([Qt.Key_A], ['..', 'bar', 'bat']), + ]) + def test_filtering_path(self, qtbot, tmp_path, get_prompt, keys, expected): + testdir = tmp_path / 'test' + + for directory in ['bar', 'foo', 'bat']: + (testdir / directory).mkdir(parents=True) + + prompt = get_prompt(str(testdir) + os.sep) + for key in keys: + qtbot.keyPress(prompt._lineedit, key) + prompt._set_fileview_root(prompt._lineedit.text()) + + num_rows = prompt._file_model.rowCount(prompt._file_view.rootIndex()) + visible = [] + for row in range(num_rows): + parent = prompt._file_model.index( + os.path.dirname(prompt._lineedit.text())) + index = prompt._file_model.index(row, 0, parent) + if not prompt._file_view.isRowHidden(index.row(), index.parent()): + visible.append(index.data()) + assert visible == expected + @pytest.mark.linux def test_root_path(self, get_prompt): """With / as path, show root contents.""" diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py index cd21308f8..8e5597a0e 100644 --- a/tests/unit/misc/test_editor.py +++ b/tests/unit/misc/test_editor.py @@ -148,6 +148,17 @@ class TestFileHandling: assert msg.text.startswith("Failed to create initial file: ") assert editor._proc is None + def test_encode_error(self, message_mock, editor, caplog, config_stub): + """Test file handling when the initial text can't be encoded.""" + config_stub.val.editor.encoding = 'ascii' + + with caplog.at_level(logging.ERROR): + editor.edit("fooäbar") + + msg = message_mock.getmsg(usertypes.MessageLevel.error) + assert msg.text.startswith("Failed to create initial file: ") + assert editor._proc is None + def test_double_edit(self, editor): editor.edit("") with pytest.raises(ValueError): diff --git a/tests/unit/misc/test_sql.py b/tests/unit/misc/test_sql.py index f6fa68869..80ab7513c 100644 --- a/tests/unit/misc/test_sql.py +++ b/tests/unit/misc/test_sql.py @@ -23,12 +23,12 @@ import pytest import hypothesis from hypothesis import strategies -from PyQt5.QtSql import QSqlError +from PyQt5.QtSql import QSqlDatabase, QSqlError, QSqlQuery from qutebrowser.misc import sql -pytestmark = pytest.mark.usefixtures('init_sql') +pytestmark = pytest.mark.usefixtures('data_tmpdir') class TestUserVersion: @@ -120,23 +120,23 @@ class TestSqlError: assert err.text() == "db text" -def test_init(): - sql.SqlTable('Foo', ['name', 'val', 'lucky']) +def test_init_table(database): + database.table('Foo', ['name', 'val', 'lucky']) # should not error if table already exists - sql.SqlTable('Foo', ['name', 'val', 'lucky']) + database.table('Foo', ['name', 'val', 'lucky']) -def test_insert(qtbot): - table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) +def test_insert(qtbot, database): + table = database.table('Foo', ['name', 'val', 'lucky']) with qtbot.wait_signal(table.changed): table.insert({'name': 'one', 'val': 1, 'lucky': False}) with qtbot.wait_signal(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'}) +def test_insert_replace(qtbot, database): + table = database.table('Foo', ['name', 'val', 'lucky'], + constraints={'name': 'PRIMARY KEY'}) with qtbot.wait_signal(table.changed): table.insert({'name': 'one', 'val': 1, 'lucky': False}, replace=True) with qtbot.wait_signal(table.changed): @@ -147,8 +147,8 @@ def test_insert_replace(qtbot): table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=False) -def test_insert_batch(qtbot): - table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) +def test_insert_batch(qtbot, database): + table = database.table('Foo', ['name', 'val', 'lucky']) with qtbot.wait_signal(table.changed): table.insert_batch({'name': ['one', 'nine', 'thirteen'], @@ -160,9 +160,9 @@ def test_insert_batch(qtbot): ('thirteen', 13, True)] -def test_insert_batch_replace(qtbot): - table = sql.SqlTable('Foo', ['name', 'val', 'lucky'], - constraints={'name': 'PRIMARY KEY'}) +def test_insert_batch_replace(qtbot, database): + table = database.table('Foo', ['name', 'val', 'lucky'], + constraints={'name': 'PRIMARY KEY'}) with qtbot.wait_signal(table.changed): table.insert_batch({'name': ['one', 'nine', 'thirteen'], @@ -185,8 +185,8 @@ def test_insert_batch_replace(qtbot): 'lucky': [True, True]}) -def test_iter(): - table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) +def test_iter(database): + table = database.table('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}) @@ -205,15 +205,15 @@ def test_iter(): ([{"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']) +def test_select(rows, sort_by, sort_order, limit, result, database): + table = database.table('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']) +def test_delete(qtbot, database): + table = database.table('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}) @@ -227,8 +227,8 @@ def test_delete(qtbot): assert not list(table) -def test_len(): - table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) +def test_len(database): + table = database.table('Foo', ['name', 'val', 'lucky']) assert len(table) == 0 table.insert({'name': 'one', 'val': 1, 'lucky': False}) assert len(table) == 1 @@ -238,15 +238,15 @@ def test_len(): assert len(table) == 3 -def test_bool(): - table = sql.SqlTable('Foo', ['name']) +def test_bool(database): + table = database.table('Foo', ['name']) assert not table table.insert({'name': 'one'}) assert table -def test_bool_benchmark(benchmark): - table = sql.SqlTable('Foo', ['number']) +def test_bool_benchmark(benchmark, database): + table = database.table('Foo', ['number']) # Simulate a history table table.create_index('NumberIndex', 'number') @@ -258,8 +258,8 @@ def test_bool_benchmark(benchmark): benchmark(run) -def test_contains(): - table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) +def test_contains(database): + table = database.table('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}) @@ -279,8 +279,8 @@ def test_contains(): assert not val_query.run(val=10).value() -def test_delete_all(qtbot): - table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) +def test_delete_all(qtbot, database): + table = database.table('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}) @@ -295,90 +295,118 @@ def test_version(): class TestSqlQuery: - def test_prepare_error(self): + def test_prepare_error(self, database): with pytest.raises(sql.BugError) as excinfo: - sql.Query('invalid') + database.query('invalid') expected = ('Failed to prepare query "invalid": "near "invalid": ' 'syntax error Unable to execute statement"') assert str(excinfo.value) == expected @pytest.mark.parametrize('forward_only', [True, False]) - def test_forward_only(self, forward_only): - q = sql.Query('SELECT 0 WHERE 0', forward_only=forward_only) + def test_forward_only(self, forward_only, database): + q = database.query('SELECT 0 WHERE 0', forward_only=forward_only) assert q.query.isForwardOnly() == forward_only - def test_iter_inactive(self): - q = sql.Query('SELECT 0') + def test_iter_inactive(self, database): + q = database.query('SELECT 0') with pytest.raises(sql.BugError, match='Cannot iterate inactive query'): next(iter(q)) - def test_iter_empty(self): - q = sql.Query('SELECT 0 AS col WHERE 0') + def test_iter_empty(self, database): + q = database.query('SELECT 0 AS col WHERE 0') q.run() with pytest.raises(StopIteration): next(iter(q)) - def test_iter(self): - q = sql.Query('SELECT 0 AS col') + def test_iter(self, database): + q = database.query('SELECT 0 AS col') q.run() result = next(iter(q)) assert result.col == 0 - def test_iter_multiple(self): - q = sql.Query('VALUES (1), (2), (3);') + def test_iter_multiple(self, database): + q = database.query('VALUES (1), (2), (3);') res = list(q.run()) assert len(res) == 3 assert res[0].column1 == 1 - def test_run_binding(self): - q = sql.Query('SELECT :answer') + def test_run_binding(self, database): + q = database.query('SELECT :answer') q.run(answer=42) assert q.value() == 42 - def test_run_missing_binding(self): - q = sql.Query('SELECT :answer') + def test_run_missing_binding(self, database): + q = database.query('SELECT :answer') with pytest.raises(sql.BugError, match='Missing bound values!'): q.run() - def test_run_batch(self): - q = sql.Query('SELECT :answer') + def test_run_batch(self, database): + q = database.query('SELECT :answer') q.run_batch(values={'answer': [42]}) assert q.value() == 42 - def test_run_batch_missing_binding(self): - q = sql.Query('SELECT :answer') + def test_run_batch_missing_binding(self, database): + q = database.query('SELECT :answer') with pytest.raises(sql.BugError, match='Missing bound values!'): q.run_batch(values={}) - def test_value_missing(self): - q = sql.Query('SELECT 0 WHERE 0') + def test_value_missing(self, database): + q = database.query('SELECT 0 WHERE 0') q.run() - with pytest.raises(sql.BugError, - match='No result for single-result query'): + with pytest.raises(sql.BugError, match='No result for single-result query'): q.value() - def test_num_rows_affected_not_active(self): + def test_num_rows_affected_not_active(self, database): with pytest.raises(AssertionError): - q = sql.Query('SELECT 0') + q = database.query('SELECT 0') q.rows_affected() - def test_num_rows_affected_select(self): + def test_num_rows_affected_select(self, database): with pytest.raises(AssertionError): - q = sql.Query('SELECT 0') + q = database.query('SELECT 0') q.run() q.rows_affected() @pytest.mark.parametrize('condition', [0, 1]) - def test_num_rows_affected(self, condition): - table = sql.SqlTable('Foo', ['name']) + def test_num_rows_affected(self, condition, database): + table = database.table('Foo', ['name']) table.insert({'name': 'helloworld'}) - q = sql.Query(f'DELETE FROM Foo WHERE {condition}') + q = database.query(f'DELETE FROM Foo WHERE {condition}') q.run() assert q.rows_affected() == condition - def test_bound_values(self): - q = sql.Query('SELECT :answer') + def test_bound_values(self, database): + q = database.query('SELECT :answer') q.run(answer=42) assert q.bound_values() == {':answer': 42} + + +class TestTransaction: + + def test_successful_transaction(self, database): + my_table = database.table('my_table', ['column']) + with database.transaction(): + my_table.insert({'column': 1}) + my_table.insert({'column': 2}) + + db2 = QSqlDatabase.addDatabase('QSQLITE', 'db2') + db2.setDatabaseName(database.qt_database().databaseName()) + db2.open() + query = QSqlQuery(db2) + query.exec('select count(*) from my_table') + query.next() + assert query.record().value(0) == 0 + assert database.query('select count(*) from my_table').run().value() == 2 + + def test_failed_transaction(self, database): + my_table = database.table('my_table', ['column']) + try: + with database.transaction(): + my_table.insert({'column': 1}) + my_table.insert({'column': 2}) + raise Exception('something went horribly wrong') + except Exception: + pass + assert database.query('select count(*) from my_table').run().value() == 0 diff --git a/tests/unit/scripts/test_check_coverage.py b/tests/unit/scripts/test_check_coverage.py index efd35ce82..abb0969b6 100644 --- a/tests/unit/scripts/test_check_coverage.py +++ b/tests/unit/scripts/test_check_coverage.py @@ -98,7 +98,12 @@ def covtest(testdir, monkeypatch): # Check if coverage plugin is available res = testdir.runpytest('--version', '--version') assert res.ret == 0 + output = res.stderr.str() + if not output: + # pytest >= 7.0: https://github.com/pytest-dev/pytest/pull/8247 + output = res.stdout.str() + assert 'This is pytest version' in output if 'pytest-cov' not in output: pytest.skip("cov plugin not available") diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py index a5599c6c9..97ff268ca 100644 --- a/tests/unit/utils/test_urlutils.py +++ b/tests/unit/utils/test_urlutils.py @@ -778,3 +778,26 @@ class TestParseJavascriptUrl: pass else: assert parsed == source + + +class TestWiden: + + @pytest.mark.parametrize('hostname, expected', [ + ('a.b.c', ['a.b.c', 'b.c', 'c']), + ('foobarbaz', ['foobarbaz']), + ('', []), + ('.c', ['.c', 'c']), + ('c.', ['c.']), + ('.c.', ['.c.', 'c.']), + (None, []), + ]) + def test_widen_hostnames(self, hostname, expected): + assert list(urlutils.widened_hostnames(hostname)) == expected + + @pytest.mark.parametrize('hostname', [ + 'test.qutebrowser.org', + 'a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.z.y.z', + 'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq.c', + ]) + def test_bench_widen_hostnames(self, hostname, benchmark): + benchmark(lambda: list(urlutils.widened_hostnames(hostname))) diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index 42b95a1a8..57adc883c 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -546,34 +546,6 @@ class TestIsEnum: assert not utils.is_enum(23) -class SomeEnum(enum.Enum): - - some_value = enum.auto() - - -class TestPyEnumStr: - - @pytest.fixture - def val(self): - return SomeEnum.some_value - - def test_fake_old_python_version(self, monkeypatch, val): - monkeypatch.setattr(sys, 'version_info', (3, 9, 2)) - assert utils.pyenum_str(val) == str(val) - - def test_fake_new_python_version(self, monkeypatch, val): - monkeypatch.setattr(sys, 'version_info', (3, 10, 0)) - assert utils.pyenum_str(val) == repr(val) - - def test_real_result(self, val): - assert utils.pyenum_str(val) == 'SomeEnum.some_value' - - @pytest.mark.skipif(sys.version_info[:2] < (3, 10), reason='Needs Python 3.10+') - def test_needed(self, val): - """Fail if this change gets revered before the final 3.10 release.""" - assert str(val) != 'SomeEnum.some_value' - - class TestRaises: """Test raises.""" |