summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNicholas42 <nics-lohr@gmx.de>2021-07-19 13:55:22 +0200
committerGitHub <noreply@github.com>2021-07-19 13:55:22 +0200
commit9a7a905f8f392f65417a49187d413471d2fc8f84 (patch)
tree6c9b82b20aaab26595cb91b5ce8fe9a8e29fc6d8
parent8aef7cedcd97d08ca48cff5f05f7844694c8be7a (diff)
parent5b0365ddeefbd0fba443f5c152f5efa0a393dbec (diff)
downloadqutebrowser-9a7a905f8f392f65417a49187d413471d2fc8f84.tar.gz
qutebrowser-9a7a905f8f392f65417a49187d413471d2fc8f84.zip
Merge branch 'qutebrowser:master' into master
-rw-r--r--.bumpversion.cfg2
-rw-r--r--.flake83
-rw-r--r--.github/workflows/bleeding.yml11
-rw-r--r--.github/workflows/ci.yml16
-rw-r--r--.github/workflows/docker.yml8
-rw-r--r--.github/workflows/recompile-requirements.yml8
-rw-r--r--.mypy.ini6
-rw-r--r--README.asciidoc6
-rw-r--r--doc/changelog.asciidoc108
-rw-r--r--doc/contributing.asciidoc30
-rw-r--r--doc/help/commands.asciidoc3
-rw-r--r--doc/help/configuring.asciidoc6
-rw-r--r--doc/help/index.asciidoc18
-rw-r--r--doc/help/settings.asciidoc47
-rw-r--r--doc/install.asciidoc10
-rw-r--r--doc/quickstart.asciidoc6
-rw-r--r--doc/qutebrowser.1.asciidoc7
-rw-r--r--doc/userscripts.asciidoc8
-rw-r--r--misc/cheatsheet.svg2
-rwxr-xr-xmisc/nsis/install_pages.nsh2
-rwxr-xr-xmisc/nsis/qutebrowser.nsi2
-rw-r--r--misc/org.qutebrowser.qutebrowser.appdata.xml3
-rw-r--r--misc/requirements/requirements-check-manifest.txt4
-rw-r--r--misc/requirements/requirements-dev.txt18
-rw-r--r--misc/requirements/requirements-flake8.txt12
-rw-r--r--misc/requirements/requirements-mypy.txt18
-rw-r--r--misc/requirements/requirements-mypy.txt-raw3
-rw-r--r--misc/requirements/requirements-pyinstaller.txt2
-rw-r--r--misc/requirements/requirements-pylint.txt11
-rw-r--r--misc/requirements/requirements-pyqt-5.12.txt2
-rw-r--r--misc/requirements/requirements-pyqt-5.13.txt2
-rw-r--r--misc/requirements/requirements-pyqt-5.14.txt2
-rw-r--r--misc/requirements/requirements-pyqt-5.15.0.txt2
-rw-r--r--misc/requirements/requirements-pyqt-5.15.txt2
-rw-r--r--misc/requirements/requirements-pyqt-pyinstaller.txt2
-rw-r--r--misc/requirements/requirements-pyqt.txt2
-rw-r--r--misc/requirements/requirements-pyroma.txt7
-rw-r--r--misc/requirements/requirements-sphinx.txt18
-rw-r--r--misc/requirements/requirements-tests.txt45
-rw-r--r--misc/requirements/requirements-tox.txt12
-rw-r--r--misc/userscripts/README.md9
-rwxr-xr-xmisc/userscripts/open_download2
-rwxr-xr-xmisc/userscripts/password_fill2
-rwxr-xr-xmisc/userscripts/qute-keepass1
-rwxr-xr-xmisc/userscripts/readability-js1
-rwxr-xr-xmisc/userscripts/view_in_mpv2
-rw-r--r--qutebrowser/__init__.py2
-rw-r--r--qutebrowser/api/cmdutils.py7
-rw-r--r--qutebrowser/api/hook.py11
-rw-r--r--qutebrowser/app.py7
-rw-r--r--qutebrowser/browser/browsertab.py15
-rw-r--r--qutebrowser/browser/commands.py2
-rw-r--r--qutebrowser/browser/greasemonkey.py61
-rw-r--r--qutebrowser/browser/hints.py6
-rw-r--r--qutebrowser/browser/history.py96
-rw-r--r--qutebrowser/browser/qutescheme.py10
-rw-r--r--qutebrowser/browser/webelem.py3
-rw-r--r--qutebrowser/browser/webengine/notification.py37
-rw-r--r--qutebrowser/browser/webengine/tabhistory.py8
-rw-r--r--qutebrowser/browser/webengine/webenginedownloads.py7
-rw-r--r--qutebrowser/browser/webengine/webenginetab.py36
-rw-r--r--qutebrowser/browser/webkit/http.py2
-rw-r--r--qutebrowser/browser/webkit/tabhistory.py4
-rw-r--r--qutebrowser/browser/webkit/webpage.py2
-rw-r--r--qutebrowser/completion/models/filepathcategory.py8
-rw-r--r--qutebrowser/completion/models/histcategory.py7
-rw-r--r--qutebrowser/completion/models/urlmodel.py3
-rw-r--r--qutebrowser/components/braveadblock.py60
-rw-r--r--qutebrowser/components/hostblock.py16
-rw-r--r--qutebrowser/config/config.py13
-rw-r--r--qutebrowser/config/configcommands.py4
-rw-r--r--qutebrowser/config/configdata.yml52
-rw-r--r--qutebrowser/config/configfiles.py38
-rw-r--r--qutebrowser/config/configtypes.py41
-rw-r--r--qutebrowser/config/configutils.py15
-rw-r--r--qutebrowser/config/qtargs.py6
-rw-r--r--qutebrowser/config/websettings.py39
-rw-r--r--qutebrowser/extensions/loader.py13
-rw-r--r--qutebrowser/javascript/quirks/googledocs.user.js14
-rw-r--r--qutebrowser/keyinput/basekeyparser.py2
-rw-r--r--qutebrowser/keyinput/keyutils.py2
-rw-r--r--qutebrowser/keyinput/modeman.py10
-rw-r--r--qutebrowser/mainwindow/prompt.py23
-rw-r--r--qutebrowser/mainwindow/tabwidget.py16
-rw-r--r--qutebrowser/misc/backendproblem.py3
-rw-r--r--qutebrowser/misc/debugcachestats.py2
-rw-r--r--qutebrowser/misc/editor.py2
-rw-r--r--qutebrowser/misc/elf.py4
-rw-r--r--qutebrowser/misc/quitter.py8
-rw-r--r--qutebrowser/misc/sql.py334
-rw-r--r--qutebrowser/misc/throttle.py2
-rw-r--r--qutebrowser/utils/debug.py34
-rw-r--r--qutebrowser/utils/docutils.py4
-rw-r--r--qutebrowser/utils/jinja.py8
-rw-r--r--qutebrowser/utils/log.py16
-rw-r--r--qutebrowser/utils/objreg.py3
-rw-r--r--qutebrowser/utils/qtutils.py13
-rw-r--r--qutebrowser/utils/urlmatch.py9
-rw-r--r--qutebrowser/utils/urlutils.py11
-rw-r--r--qutebrowser/utils/utils.py29
-rw-r--r--requirements.txt12
-rw-r--r--scripts/cycle-inputs.js2
-rwxr-xr-xscripts/dev/build_release.py19
-rw-r--r--scripts/dev/recompile_requirements.py16
-rw-r--r--scripts/link_pyqt.py4
-rwxr-xr-xscripts/mkvenv.py28
-rw-r--r--tests/end2end/data/downloads/mhtml/complex/complex.html2
-rw-r--r--tests/end2end/data/downloads/mhtml/complex/complex.mht2
-rw-r--r--tests/end2end/data/prefers_reduced_motion.html38
-rw-r--r--tests/end2end/features/downloads.feature24
-rw-r--r--tests/end2end/features/misc.feature2
-rw-r--r--tests/end2end/features/qutescheme.feature2
-rw-r--r--tests/end2end/features/test_downloads_bdd.py2
-rw-r--r--tests/end2end/features/test_history_bdd.py2
-rw-r--r--tests/end2end/fixtures/quteprocess.py19
-rw-r--r--tests/end2end/fixtures/testprocess.py5
-rw-r--r--tests/end2end/test_invocations.py123
-rw-r--r--tests/helpers/fixtures.py21
-rw-r--r--tests/unit/browser/test_history.py97
-rw-r--r--tests/unit/browser/webengine/test_webenginetab.py39
-rw-r--r--tests/unit/completion/test_histcategory.py28
-rw-r--r--tests/unit/completion/test_models.py9
-rw-r--r--tests/unit/components/test_braveadblock.py13
-rw-r--r--tests/unit/components/test_hostblock.py10
-rw-r--r--tests/unit/config/test_config.py1
-rw-r--r--tests/unit/config/test_configfiles.py61
-rw-r--r--tests/unit/config/test_configtypes.py5
-rw-r--r--tests/unit/config/test_configutils.py27
-rw-r--r--tests/unit/javascript/test_greasemonkey.py14
-rw-r--r--tests/unit/mainwindow/test_prompt.py33
-rw-r--r--tests/unit/misc/test_editor.py11
-rw-r--r--tests/unit/misc/test_sql.py156
-rw-r--r--tests/unit/scripts/test_check_coverage.py5
-rw-r--r--tests/unit/utils/test_urlutils.py23
-rw-r--r--tests/unit/utils/test_utils.py28
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
diff --git a/.flake8 b/.flake8
index 9331484c3..5cfa2e76c 100644
--- a/.flake8
+++ b/.flake8
@@ -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 }}"
diff --git a/.mypy.ini b/.mypy.ini
index b1cd8967c..289f3eb87 100644
--- a/.mypy.ini
+++ b/.mypy.ini
@@ -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."""