summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.appveyor.yml4
-rw-r--r--.bumpversion.cfg2
-rw-r--r--.github/ISSUE_TEMPLATE/3_Support_question.md16
-rw-r--r--.github/ISSUE_TEMPLATE/4_Security_issue.md11
-rw-r--r--.github/ISSUE_TEMPLATE/config.yml5
-rw-r--r--.github/SECURITY.md1
-rw-r--r--.github/workflows/codeql-analysis.yml33
-rw-r--r--.gitignore3
-rw-r--r--.mypy.ini (renamed from mypy.ini)43
-rw-r--r--.pylintrc2
-rw-r--r--.travis.yml24
-rw-r--r--MANIFEST.in8
-rw-r--r--README.asciidoc27
-rw-r--r--doc/changelog.asciidoc238
-rw-r--r--doc/contributing.asciidoc3
-rw-r--r--doc/faq.asciidoc24
-rw-r--r--doc/help/commands.asciidoc48
-rw-r--r--doc/help/settings.asciidoc433
-rw-r--r--doc/install.asciidoc4
-rw-r--r--doc/qutebrowser.1.asciidoc2
-rw-r--r--doc/stacktrace.asciidoc21
-rw-r--r--misc/org.qutebrowser.qutebrowser.appdata.xml4
-rw-r--r--misc/requirements/requirements-check-manifest.txt6
-rw-r--r--misc/requirements/requirements-codecov.txt8
-rw-r--r--misc/requirements/requirements-dev.txt19
-rw-r--r--misc/requirements/requirements-dev.txt-raw1
-rw-r--r--misc/requirements/requirements-flake8.txt17
-rw-r--r--misc/requirements/requirements-mypy.txt8
-rw-r--r--misc/requirements/requirements-mypy.txt-raw5
-rw-r--r--misc/requirements/requirements-pip.txt10
-rw-r--r--misc/requirements/requirements-pyinstaller.txt2
-rw-r--r--misc/requirements/requirements-pylint.txt14
-rw-r--r--misc/requirements/requirements-pylint.txt-raw4
-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.txt4
-rw-r--r--misc/requirements/requirements-pyqt-5.15.txt5
-rw-r--r--misc/requirements/requirements-pyqt-5.15.txt-raw4
-rw-r--r--misc/requirements/requirements-pyqt.txt6
-rw-r--r--misc/requirements/requirements-sphinx.txt16
-rw-r--r--misc/requirements/requirements-tests.txt46
-rw-r--r--misc/requirements/requirements-tests.txt-raw16
-rw-r--r--misc/requirements/requirements-tox.txt14
-rw-r--r--misc/requirements/requirements-vulture.txt2
-rwxr-xr-xmisc/userscripts/getbib1
-rwxr-xr-xmisc/userscripts/password_fill2
-rwxr-xr-xmisc/userscripts/qute-bitwarden59
-rwxr-xr-xmisc/userscripts/qute-lastpass2
-rwxr-xr-xmisc/userscripts/readability-js14
-rwxr-xr-xmisc/userscripts/tor_identity29
-rw-r--r--pytest.ini3
-rw-r--r--qutebrowser/__init__.py2
-rw-r--r--qutebrowser/api/cmdutils.py6
-rw-r--r--qutebrowser/app.py30
-rw-r--r--qutebrowser/browser/browsertab.py81
-rw-r--r--qutebrowser/browser/commands.py78
-rw-r--r--qutebrowser/browser/downloads.py2
-rw-r--r--qutebrowser/browser/downloadview.py7
-rw-r--r--qutebrowser/browser/eventfilter.py76
-rw-r--r--qutebrowser/browser/hints.py14
-rw-r--r--qutebrowser/browser/history.py2
-rw-r--r--qutebrowser/browser/inspector.py2
-rw-r--r--qutebrowser/browser/navigate.py8
-rw-r--r--qutebrowser/browser/network/pac.py13
-rw-r--r--qutebrowser/browser/network/proxy.py32
-rw-r--r--qutebrowser/browser/pdfjs.py32
-rw-r--r--qutebrowser/browser/qutescheme.py112
-rw-r--r--qutebrowser/browser/shared.py16
-rw-r--r--qutebrowser/browser/webelem.py2
-rw-r--r--qutebrowser/browser/webengine/cookies.py35
-rw-r--r--qutebrowser/browser/webengine/interceptor.py8
-rw-r--r--qutebrowser/browser/webengine/tabhistory.py51
-rw-r--r--qutebrowser/browser/webengine/webenginedownloads.py15
-rw-r--r--qutebrowser/browser/webengine/webengineelem.py2
-rw-r--r--qutebrowser/browser/webengine/webenginequtescheme.py21
-rw-r--r--qutebrowser/browser/webengine/webenginesettings.py74
-rw-r--r--qutebrowser/browser/webengine/webenginetab.py245
-rw-r--r--qutebrowser/browser/webengine/webview.py25
-rw-r--r--qutebrowser/browser/webkit/cookies.py18
-rw-r--r--qutebrowser/browser/webkit/network/networkmanager.py25
-rw-r--r--qutebrowser/browser/webkit/network/networkreply.py21
-rw-r--r--qutebrowser/browser/webkit/network/webkitqutescheme.py2
-rw-r--r--qutebrowser/browser/webkit/webkitelem.py7
-rw-r--r--qutebrowser/browser/webkit/webkittab.py238
-rw-r--r--qutebrowser/browser/webkit/webpage.py37
-rw-r--r--qutebrowser/browser/webkit/webview.py9
-rw-r--r--qutebrowser/commands/command.py11
-rw-r--r--qutebrowser/commands/runners.py4
-rw-r--r--qutebrowser/commands/userscripts.py38
-rw-r--r--qutebrowser/completion/completiondelegate.py2
-rw-r--r--qutebrowser/completion/completionwidget.py6
-rw-r--r--qutebrowser/completion/models/completionmodel.py8
-rw-r--r--qutebrowser/completion/models/miscmodels.py34
-rw-r--r--qutebrowser/completion/models/urlmodel.py5
-rw-r--r--qutebrowser/components/adblock.py6
-rw-r--r--qutebrowser/components/caretcommands.py10
-rw-r--r--qutebrowser/components/misccommands.py6
-rw-r--r--qutebrowser/config/config.py2
-rw-r--r--qutebrowser/config/configcommands.py19
-rw-r--r--qutebrowser/config/configdata.yml367
-rw-r--r--qutebrowser/config/configfiles.py55
-rw-r--r--qutebrowser/config/configinit.py119
-rw-r--r--qutebrowser/config/configtypes.py199
-rw-r--r--qutebrowser/config/configutils.py5
-rw-r--r--qutebrowser/config/stylesheet.py2
-rw-r--r--qutebrowser/config/websettings.py13
-rw-r--r--qutebrowser/extensions/loader.py6
-rw-r--r--qutebrowser/html/warning-old-qt.html20
-rw-r--r--qutebrowser/html/warning-sessions.html22
-rw-r--r--qutebrowser/html/warning-webkit.html14
-rw-r--r--qutebrowser/javascript/.eslintrc.yaml1
-rw-r--r--qutebrowser/javascript/caret.js86
-rw-r--r--qutebrowser/javascript/globalthis_quirk.user.js9
-rw-r--r--qutebrowser/javascript/pac_utils.js2
-rw-r--r--qutebrowser/keyinput/basekeyparser.py89
-rw-r--r--qutebrowser/keyinput/eventfilter.py29
-rw-r--r--qutebrowser/keyinput/keyutils.py49
-rw-r--r--qutebrowser/keyinput/modeman.py104
-rw-r--r--qutebrowser/keyinput/modeparsers.py140
-rw-r--r--qutebrowser/mainwindow/mainwindow.py50
-rw-r--r--qutebrowser/mainwindow/prompt.py17
-rw-r--r--qutebrowser/mainwindow/statusbar/bar.py41
-rw-r--r--qutebrowser/mainwindow/statusbar/command.py9
-rw-r--r--qutebrowser/mainwindow/statusbar/keystring.py7
-rw-r--r--qutebrowser/mainwindow/statusbar/url.py3
-rw-r--r--qutebrowser/mainwindow/tabbedbrowser.py31
-rw-r--r--qutebrowser/mainwindow/tabwidget.py45
-rw-r--r--qutebrowser/misc/autoupdate.py2
-rw-r--r--qutebrowser/misc/backendproblem.py163
-rw-r--r--qutebrowser/misc/checkpyver.py8
-rw-r--r--qutebrowser/misc/consolewidget.py23
-rw-r--r--qutebrowser/misc/crashdialog.py28
-rw-r--r--qutebrowser/misc/crashsignal.py57
-rw-r--r--qutebrowser/misc/earlyinit.py7
-rw-r--r--qutebrowser/misc/editor.py2
-rw-r--r--qutebrowser/misc/guiprocess.py19
-rw-r--r--qutebrowser/misc/ipc.py25
-rw-r--r--qutebrowser/misc/keyhintwidget.py6
-rw-r--r--qutebrowser/misc/miscwidgets.py37
-rw-r--r--qutebrowser/misc/msgbox.py2
-rw-r--r--qutebrowser/misc/sessions.py19
-rw-r--r--qutebrowser/misc/split.py3
-rw-r--r--qutebrowser/misc/sql.py3
-rw-r--r--qutebrowser/misc/utilcmds.py49
-rw-r--r--qutebrowser/qt.py2
-rw-r--r--qutebrowser/qutebrowser.py19
-rw-r--r--qutebrowser/utils/debug.py24
-rw-r--r--qutebrowser/utils/error.py1
-rw-r--r--qutebrowser/utils/jinja.py8
-rw-r--r--qutebrowser/utils/log.py163
-rw-r--r--qutebrowser/utils/message.py4
-rw-r--r--qutebrowser/utils/objreg.py6
-rw-r--r--qutebrowser/utils/qtutils.py108
-rw-r--r--qutebrowser/utils/standarddir.py5
-rw-r--r--qutebrowser/utils/urlutils.py25
-rw-r--r--qutebrowser/utils/usertypes.py9
-rw-r--r--qutebrowser/utils/utils.py27
-rw-r--r--qutebrowser/utils/version.py123
-rw-r--r--requirements.txt4
-rwxr-xr-xscripts/asciidoc2html.py152
-rw-r--r--scripts/dev/build_pyqt_wheel.py99
-rwxr-xr-xscripts/dev/build_release.py27
-rw-r--r--scripts/dev/check_coverage.py3
-rw-r--r--scripts/dev/misc_checks.py4
-rw-r--r--scripts/dev/recompile_requirements.py53
-rwxr-xr-xscripts/dev/src2asciidoc.py7
-rw-r--r--scripts/dev/update_version.py8
-rwxr-xr-xscripts/dictcli.py9
-rw-r--r--scripts/keytester.py29
-rw-r--r--scripts/mkvenv.py61
-rw-r--r--scripts/setupcommon.py22
-rw-r--r--scripts/utils.py11
-rwxr-xr-xsetup.py1
-rw-r--r--tests/conftest.py10
-rw-r--r--tests/end2end/conftest.py8
-rw-r--r--tests/end2end/features/caret.feature3
-rw-r--r--tests/end2end/features/conftest.py14
-rw-r--r--tests/end2end/features/hints.feature4
-rw-r--r--tests/end2end/features/misc.feature12
-rw-r--r--tests/end2end/features/private.feature2
-rw-r--r--tests/end2end/features/qutescheme.feature23
-rw-r--r--tests/end2end/features/search.feature50
-rw-r--r--tests/end2end/features/sessions.feature1
-rw-r--r--tests/end2end/features/spawn.feature8
-rw-r--r--tests/end2end/features/test_editor_bdd.py8
-rw-r--r--tests/end2end/features/test_open_bdd.py21
-rw-r--r--tests/end2end/features/test_prompts_bdd.py31
-rw-r--r--tests/end2end/features/utilcmds.feature6
-rw-r--r--tests/end2end/features/yankpaste.feature13
-rw-r--r--tests/end2end/fixtures/quteprocess.py23
-rw-r--r--tests/end2end/fixtures/testprocess.py5
-rw-r--r--tests/end2end/fixtures/webserver.py3
-rw-r--r--tests/end2end/fixtures/webserver_sub.py9
-rw-r--r--tests/end2end/fixtures/webserver_sub_ssl.py5
-rw-r--r--tests/end2end/test_invocations.py3
-rw-r--r--tests/helpers/fixtures.py34
-rw-r--r--tests/helpers/stubs.py29
-rw-r--r--tests/helpers/utils.py12
-rw-r--r--tests/manual/mouse.html5
-rw-r--r--tests/unit/api/test_cmdutils.py21
-rw-r--r--tests/unit/browser/test_caret.py144
-rw-r--r--tests/unit/browser/test_pdfjs.py9
-rw-r--r--tests/unit/browser/test_qutescheme.py2
-rw-r--r--tests/unit/browser/test_shared.py4
-rw-r--r--tests/unit/browser/test_urlmarks.py (renamed from tests/unit/browser/urlmarks.py)8
-rw-r--r--tests/unit/browser/webengine/test_webengine_cookies.py128
-rw-r--r--tests/unit/browser/webkit/test_cookies.py93
-rw-r--r--tests/unit/completion/test_models.py35
-rw-r--r--tests/unit/config/test_configcommands.py23
-rw-r--r--tests/unit/config/test_configdata.py14
-rw-r--r--tests/unit/config/test_configfiles.py49
-rw-r--r--tests/unit/config/test_configinit.py201
-rw-r--r--tests/unit/config/test_configtypes.py95
-rw-r--r--tests/unit/config/test_configutils.py7
-rw-r--r--tests/unit/keyinput/conftest.py16
-rw-r--r--tests/unit/keyinput/test_basekeyparser.py327
-rw-r--r--tests/unit/keyinput/test_bindingtrie.py34
-rw-r--r--tests/unit/keyinput/test_keyutils.py28
-rw-r--r--tests/unit/keyinput/test_modeman.py6
-rw-r--r--tests/unit/keyinput/test_modeparsers.py87
-rw-r--r--tests/unit/mainwindow/test_messageview.py2
-rw-r--r--tests/unit/mainwindow/test_tabbedbrowser.py32
-rw-r--r--tests/unit/misc/test_editor.py58
-rw-r--r--tests/unit/misc/test_ipc.py38
-rw-r--r--tests/unit/misc/test_keyhints.py19
-rw-r--r--tests/unit/misc/test_lineparser.py31
-rw-r--r--tests/unit/misc/test_sessions.py77
-rw-r--r--tests/unit/misc/test_throttle.py2
-rw-r--r--tests/unit/misc/test_utilcmds.py13
-rw-r--r--tests/unit/scripts/test_check_coverage.py13
-rw-r--r--tests/unit/scripts/test_dictcli.py37
-rw-r--r--tests/unit/scripts/test_importer.py18
-rw-r--r--tests/unit/scripts/test_run_vulture.py22
-rw-r--r--tests/unit/test_qutebrowser.py62
-rw-r--r--tests/unit/utils/test_error.py13
-rw-r--r--tests/unit/utils/test_log.py184
-rw-r--r--tests/unit/utils/test_qtutils.py6
-rw-r--r--tests/unit/utils/test_standarddir.py60
-rw-r--r--tests/unit/utils/test_urlutils.py28
-rw-r--r--tests/unit/utils/test_utils.py7
-rw-r--r--tests/unit/utils/test_version.py118
-rw-r--r--tox.ini39
-rw-r--r--www/header.asciidoc4
243 files changed, 5888 insertions, 2445 deletions
diff --git a/.appveyor.yml b/.appveyor.yml
index 0ee670b37..47ad9964a 100644
--- a/.appveyor.yml
+++ b/.appveyor.yml
@@ -10,8 +10,8 @@ image:
environment:
PYTHONUNBUFFERED: 1
- PYTHON: C:\Python37-x64\python.exe
- TESTENV: py37-pyqt514
+ PYTHON: C:\Python38-x64\python.exe
+ TESTENV: py38-pyqt514
install:
- '%PYTHON% --version'
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index bac515da4..c260a28da 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 1.10.1
+current_version = 1.12.0
commit = True
message = Release v{new_version}
tag = True
diff --git a/.github/ISSUE_TEMPLATE/3_Support_question.md b/.github/ISSUE_TEMPLATE/3_Support_question.md
deleted file mode 100644
index 9d67d716b..000000000
--- a/.github/ISSUE_TEMPLATE/3_Support_question.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-name: ❓ Support Question
-about: It's okay to ask questions via GitHub, but IRC/Reddit/Mailinglist might be better.
-
----
-
-<!--
-While it's fine to ask questions here, check the documentation for better
-ways to get help:
-
-https://github.com/qutebrowser/qutebrowser#getting-help
--->
-
-**Version info (see `:version`)**:
-
-**If applicable: Does the issue happen if you start with `--temp-basedir`?**:
diff --git a/.github/ISSUE_TEMPLATE/4_Security_issue.md b/.github/ISSUE_TEMPLATE/4_Security_issue.md
deleted file mode 100644
index b8f7d25e4..000000000
--- a/.github/ISSUE_TEMPLATE/4_Security_issue.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-name: ⛔ Security Issue
-about: Contact mail@qutebrowser.org for security issues.
-
----
-
-⚠ PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, SEE BELOW.
-
-If you have found a security issue in qutebrowser, please send the details to
-mail [at] qutebrowser.org and don't disclose it publicly until we can provide a
-fix for it
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..f736a6fd6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: ❓ Support Question
+ url: https://github.com/qutebrowser/qutebrowser/discussions
+ about: Use GitHub's new discussions feature for questions
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644
index 000000000..7df41b38e
--- /dev/null
+++ b/.github/SECURITY.md
@@ -0,0 +1 @@
+Please report security bugs to [security@qutebrowser.org](mailto:security@qutebrowser.org).
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 000000000..5de8a8726
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,33 @@
+name: "Code scanning"
+
+on:
+ push:
+ pull_request:
+ schedule:
+ - cron: '0 3 * * 1'
+
+jobs:
+ CodeQL-Build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ with:
+ # We must fetch at least the immediate parents so that if this is
+ # a pull request then we can checkout the head.
+ fetch-depth: 2
+
+ # If this run was triggered by a pull request event, then checkout
+ # the head of the pull request instead of the merge commit.
+ - run: git checkout HEAD^2
+ if: ${{ github.event_name == 'pull_request' }}
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v1
+ with:
+ languages: javascript, python
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v1
diff --git a/.gitignore b/.gitignore
index 6074de319..aa5b853f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,7 +27,7 @@ __pycache__
/.pytest_cache
/.testmondata
/.hypothesis
-/.mypy_cache
+.mypy_cache
/prof
/venv
TODO
@@ -44,3 +44,4 @@ TODO
/doc/extapi/_build
/misc/nsis/include
/misc/nsis/plugins
+/wheels
diff --git a/mypy.ini b/.mypy.ini
index 15fd4bd7c..9bce08b62 100644
--- a/mypy.ini
+++ b/.mypy.ini
@@ -4,23 +4,23 @@
python_version = 3.6
# --strict
-warn_redundant_casts = True
-warn_unused_ignores = True
+warn_unused_configs = True
+# disallow_any_generics = True
disallow_subclassing_any = True
-disallow_untyped_decorators = True
-## https://github.com/python/mypy/issues/5957
-# warn_unused_configs = True
# disallow_untyped_calls = True
# disallow_untyped_defs = True
-## https://github.com/python/mypy/issues/5954
-# disallow_incomplete_defs = True
+disallow_incomplete_defs = True
check_untyped_defs = True
+disallow_untyped_decorators = True
# no_implicit_optional = True
+warn_redundant_casts = True
+warn_unused_ignores = True
# warn_return_any = True
-warn_unreachable = True
+# no_implicit_reexport = True
+strict_equality = True
# Other strictness flags
-strict_equality = True
+warn_unreachable = True
# Output
show_error_codes = True
@@ -69,76 +69,51 @@ disallow_subclassing_any = False
[mypy-qutebrowser.browser.browsertab]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.browser.hints]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.misc.objects]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.misc.debugcachestats]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.misc.utilcmds]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.misc.throttle]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.misc.backendproblem]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
-
-[mypy-qutebrowser.misc.eventfilter]
-disallow_untyped_defs = True
-disallow_incomplete_defs = True
-
-[mypy-qutebrowser.commands.cmdutils]
-disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.config.*]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.api.*]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.components.*]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.extensions.*]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.browser.webelem]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.browser.webkit.webkitelem]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.browser.webengine.webengineelem]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.keyinput.*]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.utils.*]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.mainwindow.statusbar.command]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
diff --git a/.pylintrc b/.pylintrc
index c68b4ae3f..1fedefb6d 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -60,7 +60,7 @@ no-docstring-rgx=(^_|^main$)
[FORMAT]
max-line-length=79
-ignore-long-lines=(<?https?://|^# Copyright 201\d|link:)
+ignore-long-lines=(<?https?://|file://|^# Copyright 201\d|link:)
expected-line-ending-format=LF
[VARIABLES]
diff --git a/.travis.yml b/.travis.yml
index 54eab192e..28ad24af9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -19,8 +19,8 @@ matrix:
services: docker
### Archlinux QtWebEngine with testing/KDE-Unstable
- # - env: DOCKER=archlinux-webengine-unstable QUTE_BDD_WEBENGINE=true
- # services: docker
+ - env: DOCKER=archlinux-webengine-unstable QUTE_BDD_WEBENGINE=true
+ services: docker
### PyQt 5.7.1 (Python 3.5)
- python: 3.5
@@ -45,7 +45,6 @@ matrix:
### PyQt 5.12 (Python 3.8)
- env: TESTENV=py38-pyqt512
- # http://code.qt.io/cgit/qt/qtbase.git/commit/?id=c3a963da1f9e7b1d37e63eedded61da4fbdaaf9a
addons:
apt:
packages:
@@ -53,19 +52,30 @@ matrix:
### PyQt 5.13 (Python 3.8)
- env: TESTENV=py38-pyqt513
- # http://code.qt.io/cgit/qt/qtbase.git/commit/?id=c3a963da1f9e7b1d37e63eedded61da4fbdaaf9a
addons:
apt:
packages:
- libxkbcommon-x11-0
- ### PyQt 5.14 (Python 3.8, with coverage)
- - env: TESTENV=py38-pyqt514-cov
- # http://code.qt.io/cgit/qt/qtbase.git/commit/?id=c3a963da1f9e7b1d37e63eedded61da4fbdaaf9a
+ ### PyQt 5.14 (Python 3.8)
+ - env: TESTENV=py38-pyqt514
+ addons:
+ apt:
+ packages:
+ - libxkbcommon-x11-0
+
+ ### PyQt 5.15 (Python 3.8, with coverage)
+ - env: TESTENV=py38-pyqt515-cov
addons:
apt:
packages:
- libxkbcommon-x11-0
+ - libxcb-icccm4
+ - libxcb-image0
+ - libxcb-keysyms1
+ - libxcb-randr0
+ - libxcb-render-util0
+ - libxcb-xinerama0
### macOS Mojave (10.14)
- os: osx
diff --git a/MANIFEST.in b/MANIFEST.in
index c8c11c32e..e163bde9f 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,6 +1,7 @@
recursive-include qutebrowser *.py
recursive-include qutebrowser/img *.svg *.png
recursive-include qutebrowser/javascript *.js
+graft tests
graft qutebrowser/html
graft qutebrowser/3rdparty
graft icons
@@ -16,10 +17,10 @@ include misc/org.qutebrowser.qutebrowser.desktop
include misc/org.qutebrowser.qutebrowser.appdata.xml
include misc/Makefile
include requirements.txt
-include tox.ini
include qutebrowser.py
include misc/cheatsheet.svg
include qutebrowser/config/configdata.yml
+include pytest.ini
prune www
prune scripts/dev
@@ -29,16 +30,17 @@ exclude scripts/asciidoc2html.py
recursive-exclude doc *.asciidoc
include doc/qutebrowser.1.asciidoc
include doc/changelog.asciidoc
-prune tests
prune qutebrowser/3rdparty
-exclude pytest.ini
exclude mypy.ini
+exclude tox.ini
exclude qutebrowser/javascript/.eslintrc.yaml
exclude qutebrowser/javascript/.eslintignore
exclude doc/help
exclude .*
exclude misc/qutebrowser.spec
exclude misc/qutebrowser.rcc
+exclude tests/unit/scripts/test_run_vulture.py
+exclude tests/unit/scripts/test_check_coverage.py
prune doc/extapi
prune misc/nsis
diff --git a/README.asciidoc b/README.asciidoc
index 6f61e6ffb..2eec8e3c4 100644
--- a/README.asciidoc
+++ b/README.asciidoc
@@ -28,9 +28,6 @@ time, your help is needed! See the
https://github.com/sponsors/The-Compiler/[GitHub Sponsors page] for more
information. Depending on your sign-up date and how long you keep a certain
level, you can get qutebrowser t-shirts, stickers and more!
-
-Thanks to the GitHub Sponsors Matching Fund, all donations done via GitHub
-Sponsors (up to a $5000 total) will be doubled until October 2020.
// QUTE_WEB_HIDE_END
Screenshots
@@ -84,6 +81,11 @@ get sent to the general qutebrowser@ list).
If you're a reddit user, there's a
https://www.reddit.com/r/qutebrowser/[/r/qutebrowser] subreddit there.
+Finally, qutebrowser is participating in the Beta for GitHub's new Discussions
+feature, so you can also use the
+https://github.com/qutebrowser/qutebrowser/discussions[discussions tab] on
+GitHub to get in touch.
+
Contributions / Bugs
--------------------
@@ -108,9 +110,10 @@ Requirements
The following software and libraries are required to run qutebrowser:
-* https://www.python.org/[Python] 3.5.2 or newer (3.6 recommended)
-* https://www.qt.io/[Qt] 5.7.1 or newer (5.14 recommended; support for < 5.9
- will be dropped soon) with the following modules:
+* https://www.python.org/[Python] 3.5.2 or newer (3.6 - 3.8 recommended;
+ support for 3.5 will be dropped with qutebrowser v2.0.0)
+* https://www.qt.io/[Qt] 5.7.1 or newer (5.14 recommended; support for < 5.11
+ will be dropped with qutebrowser v2.0.0) with the following modules:
- QtCore / qtbase
- QtQuick (part of qtbase in some distributions)
- QtSQL (part of qtbase in some distributions)
@@ -123,7 +126,7 @@ The following software and libraries are required to run qutebrowser:
avoid visiting untrusted websites and using it for transmission of
sensitive data.**
* https://www.riverbankcomputing.com/software/pyqt/intro[PyQt] 5.7.0 or newer
- (5.14 recommended, support for < 5.9 will be dropped soon) for Python 3
+ (5.14 recommended, support for < 5.11 will be dropped soon) for Python 3
* https://pypi.python.org/pypi/setuptools/[pkg_resources/setuptools]
* https://fdik.org/pyPEG/[pyPEG2]
* http://jinja.pocoo.org/[jinja2]
@@ -154,9 +157,6 @@ https://github.com/sponsors/The-Compiler/[GitHub Sponsors page] for more
information. Depending on your sign-up date and how long you keep a certain
level, you can get qutebrowser t-shirts, stickers and more!
-Thanks to the GitHub Sponsors Matching Fund, all donations done via GitHub
-Sponsors (up to a $5000 total) will be doubled until October 2020!
-
Alternatively, the following donation methods are available -- note that
eligibility for swag (shirts/stickers/etc.) is handled on a case-by-case basis
for those, please mailto:mail@qutebrowser.org[get in touch] for details.
@@ -212,8 +212,8 @@ link:doc/backers.asciidoc[crowdfunding campaigns]!
Similar projects
----------------
-Many projects with a similar goal as qutebrowser exist.
-Most of them were inspirations for qutebrowser in some way, thanks for that!
+Various projects with a similar goal like qutebrowser exist.
+Many of them were inspirations for qutebrowser in some way, thanks for that!
Active
~~~~~~
@@ -221,8 +221,9 @@ Active
* https://fanglingsu.github.io/vimb/[vimb] (C, GTK+ with WebKit2)
* https://luakit.github.io/luakit/[luakit] (C/Lua, GTK+ with WebKit2)
* https://surf.suckless.org/[surf] (C, GTK+ with WebKit1/WebKit2)
-* https://github.com/next-browser/next/[next] (Lisp, Emacs-like but also offers Vim bindings, various backends - note there was a http://jgkamat.gitlab.io/blog/next-rce.html[critical remote code execution] which was handled quite badly)
+* https://next.atlas.engineer/[next] (Lisp, Emacs-like but also offers Vim bindings, QtWebKit or GTK+/WebKit2 - note there was a http://jgkamat.gitlab.io/blog/next-rce.html[critical remote code execution] which was handled quite badly)
* https://github.com/parkouss/webmacs/[webmacs] (Python, Emacs-like with QtWebEngine)
+* https://vieb.dev/[Vieb] (JavaScript, Electron)
* Chrome/Chromium addons:
https://vimium.github.io/[Vimium],
* Firefox addons (based on WebExtensions):
diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc
index 7bb1e5a20..6d070bf28 100644
--- a/doc/changelog.asciidoc
+++ b/doc/changelog.asciidoc
@@ -15,9 +15,226 @@ 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.
-v1.11.0 (unreleased)
+v1.13.0 (unreleased)
--------------------
+Removed
+~~~~~~~
+
+- The `:debug-log-level` command was removed as it's replaced by the new
+ `logging.level.console` setting.
+- The `qute://plainlog` special page got replaced by `qute://log?plain` - the
+ names of those pages is considered an implementation detail, and
+ `:messages --plain` should be used instead.
+
+Changed
+~~~~~~~
+
+- New handling of bindings in hint mode which fixes various bugs and allows for
+ single-letter keybindings in hint mode.
+- The `tor_identity` userscript now takes the password via a `-p` flag and has
+ a new `-c` flag to customize the Tor control port.
+- `:config-write-py` now adds a note about `config.py` files being targeted at
+ advanced users.
+- `:report` now takes two optional arguments for bug/contact information, so
+ that it can be used without the report window popping up.
+- New `t[Cc][Hh]` default bindings which work similarly to the `t[Ss][Hh]`
+ bindings for JavaScript but toggle cookie permissions.
+- The `:message` command now takes a `--logfilter` / `-f` argument, which is a
+ list of logging categories to show.
+- The `:debug-log-filter` command now understands the full logfilter syntax.
+- Changes to settings:
+ * `fonts.tabs` has been split into `fonts.tabs.{selected,unselected}` (see
+ below).
+ * `statusbar.hide` has been renamed to `statusbar.show` with the possible
+ values being `always` (`hide = False`), `never` (`hide = True`) or
+ `in-mode` (new, only show statusbar outside of normal mode.
+ * The `QtFont` config type formerly used for `fonts.tabs` and
+ `fonts.debug_console` is now removed and entirely replaced by `Font`. The
+ former distinction was mainly an implementation detail, and the accepted
+ values shouldn't have changed.
+ * `input.rocker_gestures` has been renamed to `input.mouse.rocker_gestures`.
+ * `content.dns_prefetch` is now enabled by default again, since the crashes
+ it caused are now fixed (Qt 5.15) or worked around.
+ * `scrolling.bar` supports a new `overlay` value to show an overlay
+ scrollbar, which is now the default. On unsupported configurations (on Qt <
+ 5.11, with QtWebKit or on macOS), the value falls back to `when-searching`
+ or `never` (QtWebKit).
+- The statusbar now shows partial keychains in all modes (e.g. while hinting)
+- Small performance improvements.
+
+Added
+~~~~~
+
+- New settings:
+ * `logging.level.ram` and `logging.level.console` to configure the default
+ logging levels via the config.
+ * `fonts.tabs.selected` and `fonts.tabs.unselected` to set the font of the
+ selected tab independently from unselected tabs (e.g. to make it bold).
+ * `input.mouse.back_forward_buttons` which can be set to `false` to disable
+ back/forward mouse buttons.
+
+Fixed
+~~~~~
+
+- Crash when `tabs.focus_stack_size` is set to -1.
+- Crash when a `pdf.js` file for PDF.js exists, but `viewer.html` does not.
+- Crash when `:completion-item-yank --sel` is used on a platform without
+ primary selection support (e.g. Windows/macOS).
+- Crash when there's a feature permission request from Qt with an invalid URL
+ (which seems to happen with Qt 5.15 sometimes).
+- Crash in rare cases where QtWebKit/QtWebEngine imports fail in unexpected
+ ways.
+- Crash when something removed qutebrowser's IPC socket file and it's been
+ running for 6 hours.
+- `:config-write-py` now works with paths starting with `~/...` again.
+- New site-specific quirk for a missing `globalThis` in Qt <= 5.12 on Reddit
+ and Spotify.
+- When `;` is added to `hints.chars`, using hint labels containing `;;` now
+ works properly.
+- Hint letters outside of ASCII should now work.
+- When `bindings.key_mappings` is used with hints, it now works properly with
+ letters outside of ASCII as well.
+- With Qt 5.15, the audible/muted indicators are not updated properly due to a
+ Qt bug. This release adds a workaround so that at least the muted indicator
+ is shown properly.
+- As a workaround for crashes with QtWebEngine versions between 5.12 and 5.14
+ (inclusive), changing the user agent (`content.headers.user_agent`) exposed
+ to JS now requires a restart. The corresponding HTTP header is not affected.
+
+v1.12.0 (2020-06-01)
+--------------------
+
+Removed
+~~~~~~~
+
+- `tox -e mkvenv` which was deprecated in qutebrowser v1.10.0 is now
+ removed. Use the `mkvenv.py` script instead.
+- Support for using `config.bind(key, None)` in `config.py` to unbind a
+ key was deprecated in v1.8.2 and is now removed. Use
+ `config.unbind(key)` instead.
+- `:yank markdown` was deprecated in v1.7.0 and is now removed. Use
+ `:yank inline [{title}]({url})` instead.
+
+Added
+~~~~~
+
+- New `:debug-keytester` command, which shows a "key tester" widget.
+ Previously, that was only available as a separate application via `python3 -m
+ scripts.keytester`.
+- New `:config-diff` command which opens the `qute://configdiff` page.
+- New `--debug-flag log-cookies` to log cookies to the debug log.
+- New `colors.contextmenu.disabled.{fg,bg}` settings to customize colors for
+ disabled items in the context menu.
+- New line selection mode (`:toggle-selection --line`), bound to `Shift-V` in caret mode.
+- New `colors.webpage.darkmode.*` settings to control Chromium's dark mode.
+ Note that those settings only work with QtWebEngine on Qt >= 5.14 and require
+ a restart of qutebrowser.
+
+Changed
+~~~~~~~
+
+- Windows and macOS releases now ship Qt 5.15, which is based on Chromium
+ 80.0.3987.163 with security fixes up to 81.0.4044.138.
+- The `content.cookies.accept` setting now accepts URL patterns.
+- Tests are now included in release tarballs. Note that only running them with
+ the exact dependencies listed in
+ `misc/requirements/requirements-tests.txt{,-raw}` is supported.
+- The `:tab-focus` command now has completion for tabs in the current window.
+- The `bindings.key_mappings` setting now maps `<Ctrl+I>` to the tab key by default.
+- `:tab-give --private` now detaches a tab into a new private window.
+
+Fixed
+~~~~~
+
+- Using `:open -s` now only rewrites `http://` in URLs to `https://`, not other
+ schemes like `qute://`.
+- When an unhandled exception happens in certain parts of the code (outside of
+ the main thread), qutebrowser did crash or freeze when trying to show its
+ exception handler. This is now fixed.
+- `:inspector` now works correctly when cookies are disabled globally.
+- Added workaround for a (Gentoo?) PyQt/packaging issue related to the
+ `QWebEngineFindTextResult` handling added in v1.11.0.
+- When entering caret selection mode (`v, v`) very early before a page is
+ loaded, an error is now shown instead of a crash happening.
+- The workaround for session loading with Qt 5.15 now handles
+ `sessions.lazy_restore` so that the saved page is loaded instead of the
+ "stub" page with no possibility to get to the web page.
+- A site specific quirk to allow typing accented characters on Google
+ Docs was active for docs.google.com, but not drive.google.com. It is
+ now applied for both subdomains.
+- With older graphics hardware (OpenGL < 4.3) with Qt 5.14 on Wayland, WebGL
+ causes segfaults. Now qutebrowser detects that combination and suggests to
+ disable WebGL or use XWayland.
+
+v1.11.1 (2020-05-07)
+--------------------
+
+Security
+~~~~~~~~
+
+- CVE-2020-11054: After a certificate error was overridden by the user,
+ qutebrowser displays the URL as yellow (`colors.statusbar.url.warn.fg`).
+ However, when the affected website was subsequently loaded again, the URL was
+ mistakenly displayed as green (`colors.statusbar.url.success_https`). While
+ the user already has seen a certificate error prompt at this point (or set
+ `content.ssl_strict` to `false` which is not recommended), this could still
+ provide a false sense of security. This is now fixed.
+
+v1.11.0 (2020-04-27)
+--------------------
+
+Added
+~~~~~
+
+- New settings:
+ * `search.wrap` which can be set to false to prevent wrapping around the page
+ when searching. With QtWebEngine, Qt 5.14 or newer is required.
+ * `content.unknown_url_scheme_policy` which allows controlling when an
+ external application is opened for external links (never, from user
+ interaction, always).
+ * `content.fullscreen.overlay_timeout` to configure how long the fullscreen
+ overlay should be displayed. If set to `0`, no overlay is displayed.
+ * `hints.padding` to add additional padding for hints.
+ * `hints.radius` to set a border radius for hints (set to `3` by default).
+- New placeholders for `url.searchengines` values:
+ * `{unquoted}` inserts the search term without any quoting.
+ * `{semiquoted}` (same as `{}`) quotes most special characters, but slashes
+ remain unquoted.
+ * `{quoted}` (same as `{}` in earlier releases) also quotes slashes.
+
+Changed
+~~~~~~~
+
+- First adaptions to Qt 5.15, including a stop-gap measure for session loading
+ not working properly with it.
+- Searching now wraps around the page by default with QtWebKit (where it didn't
+ before). Set `search.wrap` to `false` to restore the old behavior.
+- The `{}` placeholder for search engines (the `url.searchengines` setting) now
+ does not quote slashes anymore, but other characters typically encoded in
+ URLs still get encoded. This matches the behavior of search engines in
+ Chromium. To revert to the old behavior, use `{quoted}` instead.
+- The `content.windowed_fullscreen` setting got renamed to
+ `content.fullscreen.window`.
+- Mouse-wheel scrolling is now prevented while hints are active.
+- Changes to userscripts:
+ * `qute-bitwarden` now has an optional `--totp` flag which can be used
+ to copy TOTP codes to clipboard (requires the `pyperclip` module).
+ * `readability-js` now opens readability tabs next to the original
+ tab (using the `:open --related` flag).
+ * `readability-js` now displays a favicon for readability tabs.
+ * `password_fill` now triggers a `change` JavaScript event after filling the
+ data.
+- The `dictcli.py` script now shows better error messages.
+- Various improvements to the `mkvenv.py` script (mainly useful for development).
+- Minor performance improvements.
+
+Deprecated
+~~~~~~~~~~
+
+- A warning about old Qt versions is now also shown with Qt 5.9 and 5.10, as
+ support for Qt < 5.11 will be dropped in qutebrowser v2.0.
+
Fixed
~~~~~
@@ -25,6 +242,25 @@ Fixed
- The proxied `window` global is now shared between different
Greasemonkey scripts (but still separate from the page's `window`), to
match the original Greasemonkey implementation.
+- The `--output-messages` (`-m`) flag added in v1.9.0 now also works correctly
+ when using `:spawn --userscript`.
+- `:version` and `--version` now don't crash if there's an (invalid)
+ `/etc/os-release` file which has non-comment lines without a `=` character.
+- Scripts in `scripts/` now report errors to `stderr` correctly, instead of
+ using `stdout`.
+
+v1.10.2 (2020-04-17)
+--------------------
+
+Changed
+~~~~~~~
+
+- Windows and macOS releases now bundle Qt 5.14.2, including security fixes up
+ to Chromium 80.0.3987.132.
+
+Fixed
+~~~~~
+
- The WhatsApp workaround now also works when using WhatsApp in languages other
than English.
- The `mkvenv.py` script now also works properly on Windows.
diff --git a/doc/contributing.asciidoc b/doc/contributing.asciidoc
index 8dfc94c4f..fdaf7dd37 100644
--- a/doc/contributing.asciidoc
+++ b/doc/contributing.asciidoc
@@ -707,6 +707,9 @@ qutebrowser release
* Make sure there are no unstaged changes and the tests are green.
* Make sure all issues with the related milestone are closed.
* Consider updating the completions for `content.headers.user_agent` in `configdata.yml`.
+* Minor release: Consider updating some files from master:
+ - `misc/requirements/` and `requirements.txt`
+ - `scripts/`
* Make sure Python is up-to-date on build machines.
* Mark the milestone at https://github.com/qutebrowser/qutebrowser/milestones as closed.
* Update changelog in master branch
diff --git a/doc/faq.asciidoc b/doc/faq.asciidoc
index 32861c87c..651df9665 100644
--- a/doc/faq.asciidoc
+++ b/doc/faq.asciidoc
@@ -21,7 +21,7 @@ Why another browser?::
Read the next few questions to find out why I was unhappy with existing
software.
-What's wrong with link:https://bitbucket.org/portix/dwb/[dwb]/link:https://sourceforge.net/projects/vimprobable/[vimprobable]/link:https://mason-larobina.github.io/luakit/[luakit]/jumanji/... (projects based on WebKitGTK)?::
+What's wrong with link:https://bitbucket.org/portix/dwb/[dwb]/link:https://sourceforge.net/projects/vimprobable/[vimprobable]/link:https://luakit.github.io/[luakit]/jumanji/... (projects based on WebKitGTK)?::
Most of them are based on the https://webkitgtk.org/[WebKitGTK+]
https://webkitgtk.org/reference/webkitgtk/stable/index.html[WebKit1] API,
which causes a lot of crashes. As the GTK API using WebKit1 is
@@ -305,13 +305,13 @@ If you ever need to renew any of these certificates, you can take a look
at the currently imported certificates using:
+
----
-certutil -D "sql:${HOME}/.pki/nssdb" -L
+certutil -d "sql:${HOME}/.pki/nssdb" -L
----
+
Then remove the expired certificates using:
+
----
-certutil -D "sql:${HOME}/.pki/nssdb" -D -n "My Fancy Certificate Nickname"
+certutil -d "sql:${HOME}/.pki/nssdb" -D -n "My Fancy Certificate Nickname"
----
+
And then import the new and valid certificates using the procedure
@@ -367,14 +367,15 @@ up for a monthly donation to The-Compiler (qutebrowser's main developer),
allowing him to work part-time on qutebrowser. If you keep your donation level
for long enough, you can get some qutebrowser stickers!
-Why GitHub Sponsors? What is the GitHub Matching Fund?::
- Thanks to the
- https://help.github.com/en/github/supporting-the-open-source-community-with-github-sponsors/about-github-sponsors#about-the-github-sponsors-matching-fund[GitHub Sponsors Matching Fund],
- all donations are doubled by GitHub in the first year, up to a $5000 total limit.
+Why GitHub Sponsors?::
+ GitHub Sponsors is a crowdfundign platform nicely integrated with
+ qutebrowser's existing GitHub page and a better offering than alternatives such
+ as Patreon or Liberapay.
+
-Even outside of the matching fund, GitHub Sponsors is nicely integrated with
-qutebrowser's existing GitHub page and a better offering than alternatives such
-as Patreon or Liberapay.
+It also offers a
+https://help.github.com/en/github/supporting-the-open-source-community-with-github-sponsors/about-github-sponsors#about-the-github-sponsors-matching-fund[Matching Fund]
+which matches all donations until a cap of $5000, which has already been
+reached by qutebrowser.
Is it possible to contribute via a one-time donation instead?::
If you prefer a one-time donation, there are various possibilities:
@@ -382,8 +383,7 @@ Is it possible to contribute via a one-time donation instead?::
- Select a tier which covers the total amount you'd like to donate (note that
payments are prorated based on the current date). After the payment is
processed, cancel your GitHub sponsors subscription again. This has a big
- benefit: Thanks to GitHub's matching fund, your donation will be doubled (and
- nothing will be lost to fees).
+ benefit: There are no fees deducted from your amount.
+
- Sign up for a lower recurring donation instead.
+
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc
index 28c10326d..b36c662f7 100644
--- a/doc/help/commands.asciidoc
+++ b/doc/help/commands.asciidoc
@@ -45,6 +45,7 @@ possible to run or bind multiple commands by separating them with `;;`.
|<<config-cycle,config-cycle>>|Cycle an option between multiple values.
|<<config-dict-add,config-dict-add>>|Add a key/value pair to a dictionary option.
|<<config-dict-remove,config-dict-remove>>|Remove a key from a dict.
+|<<config-diff,config-diff>>|Show all customized options.
|<<config-edit,config-edit>>|Open the config.py file in the editor.
|<<config-list-add,config-list-add>>|Append a value to a config option that is a list.
|<<config-list-remove,config-list-remove>>|Remove a value from a list.
@@ -334,6 +335,16 @@ Remove a key from a dict.
==== optional arguments
* +*-t*+, +*--temp*+: Remove value temporarily until qutebrowser is closed.
+[[config-diff]]
+=== config-diff
+Syntax: +:config-diff [*--old*]+
+
+Show all customized options.
+
+==== optional arguments
+* +*-o*+, +*--old*+: Show difference for the pre-v1.0 files (qutebrowser.conf/keys.conf).
+
+
[[config-edit]]
=== config-edit
Syntax: +:config-edit [*--no-source*]+
@@ -805,7 +816,7 @@ Show a warning message in the statusbar.
[[messages]]
=== messages
-Syntax: +:messages [*--plain*] [*--tab*] [*--bg*] [*--window*] ['level']+
+Syntax: +:messages [*--plain*] [*--tab*] [*--bg*] [*--window*] [*--logfilter* 'logfilter'] ['level']+
Show a log of past messages.
@@ -818,6 +829,9 @@ Show a log of past messages.
* +*-t*+, +*--tab*+: Open in a new tab.
* +*-b*+, +*--bg*+: Open in a background tab.
* +*-w*+, +*--window*+: Open in a new window.
+* +*-f*+, +*--logfilter*+: A comma-separated filter string of logging categories. If the filter string starts with an exclamation mark, it
+ is negated.
+
[[navigate]]
=== navigate
@@ -1011,8 +1025,15 @@ Which count to pass the command.
[[report]]
=== report
+Syntax: +:report ['info'] ['contact']+
+
Report a bug in qutebrowser.
+==== positional arguments
+* +'info'+: Information about the bug report. If given, no report dialog shows up.
+
+* +'contact'+: Contact information for the report.
+
[[restart]]
=== restart
Restart qutebrowser while keeping existing tabs open.
@@ -1337,7 +1358,7 @@ The tab index to focus, starting with 1.
[[tab-give]]
=== tab-give
-Syntax: +:tab-give [*--keep*] ['win-id']+
+Syntax: +:tab-give [*--keep*] [*--private*] ['win-id']+
Give the current tab to a new or existing window if win_id given.
@@ -1348,6 +1369,7 @@ If no win_id is given, the tab will get detached into a new window.
==== optional arguments
* +*-k*+, +*--keep*+: If given, keep the old tab around.
+* +*-p*+, +*--private*+: If the tab should be detached into a private instance.
==== count
Overrides win_id (index starts at 1 for win_id=0).
@@ -1485,8 +1507,6 @@ Yank (copy) something to the clipboard or primary selection.
- `title`: The current page's title.
- `domain`: The current scheme, domain, and port number.
- `selection`: The selection under the cursor.
- - `markdown`: Yank title and URL in markdown format
- (deprecated, use `:yank inline [{title}]({url})` instead).
- `inline`: Yank the text contained in the 'inline' argument.
@@ -1894,8 +1914,13 @@ This acts like readline's yank.
[[toggle-selection]]
=== toggle-selection
+Syntax: +:toggle-selection [*--line*]+
+
Toggle caret selection mode.
+==== optional arguments
+* +*-l*+, +*--line*+: Enables line-selection.
+
== Debugging commands
These commands are mainly intended for debugging. They are hidden if qutebrowser was started without the `--debug`-flag.
@@ -1911,9 +1936,9 @@ These commands are mainly intended for debugging. They are hidden if qutebrowser
|<<debug-crash,debug-crash>>|Crash for debugging purposes.
|<<debug-dump-history,debug-dump-history>>|Dump the history to a file in the old pre-SQL format.
|<<debug-dump-page,debug-dump-page>>|Dump the current page's content to a file.
+|<<debug-keytester,debug-keytester>>|Show a keytester widget.
|<<debug-log-capacity,debug-log-capacity>>|Change the number of log lines to be stored in RAM.
|<<debug-log-filter,debug-log-filter>>|Change the log filter for console logging.
-|<<debug-log-level,debug-log-level>>|Change the log level for console logging.
|<<debug-pyeval,debug-pyeval>>|Evaluate a python string and display the results as a web page.
|<<debug-set-fake-clipboard,debug-set-fake-clipboard>>|Put data into the fake clipboard and enable logging, used for tests.
|<<debug-trace,debug-trace>>|Trace executed code via hunter.
@@ -1965,6 +1990,10 @@ Dump the current page's content to a file.
==== optional arguments
* +*-p*+, +*--plain*+: Write plain text instead of HTML.
+[[debug-keytester]]
+=== debug-keytester
+Show a keytester widget.
+
[[debug-log-capacity]]
=== debug-log-capacity
Syntax: +:debug-log-capacity 'capacity'+
@@ -1984,15 +2013,6 @@ Change the log filter for console logging.
* +'filters'+: A comma separated list of logger names. Can also be "none" to clear any existing filters.
-[[debug-log-level]]
-=== debug-log-level
-Syntax: +:debug-log-level 'level'+
-
-Change the log level for console logging.
-
-==== positional arguments
-* +'level'+: The log level to set.
-
[[debug-pyeval]]
=== debug-pyeval
Syntax: +:debug-pyeval [*--file*] [*--quiet*] 's'+
diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc
index d3bd17dee..a6138eade 100644
--- a/doc/help/settings.asciidoc
+++ b/doc/help/settings.asciidoc
@@ -32,6 +32,8 @@
|<<colors.completion.odd.bg,colors.completion.odd.bg>>|Background color of the completion widget for odd rows.
|<<colors.completion.scrollbar.bg,colors.completion.scrollbar.bg>>|Color of the scrollbar in the completion view.
|<<colors.completion.scrollbar.fg,colors.completion.scrollbar.fg>>|Color of the scrollbar handle in the completion view.
+|<<colors.contextmenu.disabled.bg,colors.contextmenu.disabled.bg>>|Background color of disabled items in the context menu.
+|<<colors.contextmenu.disabled.fg,colors.contextmenu.disabled.fg>>|Foreground color of disabled items in the context menu.
|<<colors.contextmenu.menu.bg,colors.contextmenu.menu.bg>>|Background color of the context menu.
|<<colors.contextmenu.menu.fg,colors.contextmenu.menu.fg>>|Foreground color of the context menu.
|<<colors.contextmenu.selected.bg,colors.contextmenu.selected.bg>>|Background color of the context menu's selected item.
@@ -109,6 +111,15 @@
|<<colors.tabs.selected.odd.bg,colors.tabs.selected.odd.bg>>|Background color of selected odd tabs.
|<<colors.tabs.selected.odd.fg,colors.tabs.selected.odd.fg>>|Foreground color of selected odd tabs.
|<<colors.webpage.bg,colors.webpage.bg>>|Background color for webpages if unset (or empty to use the theme's color).
+|<<colors.webpage.darkmode.algorithm,colors.webpage.darkmode.algorithm>>|Which algorithm to use for modifying how colors are rendered with darkmode.
+|<<colors.webpage.darkmode.contrast,colors.webpage.darkmode.contrast>>|Contrast for dark mode.
+|<<colors.webpage.darkmode.enabled,colors.webpage.darkmode.enabled>>|Render all web contents using a dark theme.
+|<<colors.webpage.darkmode.grayscale.all,colors.webpage.darkmode.grayscale.all>>|Render all colors as grayscale.
+|<<colors.webpage.darkmode.grayscale.images,colors.webpage.darkmode.grayscale.images>>|Desaturation factor for images in dark mode.
+|<<colors.webpage.darkmode.policy.images,colors.webpage.darkmode.policy.images>>|Which images to apply dark mode to.
+|<<colors.webpage.darkmode.policy.page,colors.webpage.darkmode.policy.page>>|Which pages to apply dark mode to.
+|<<colors.webpage.darkmode.threshold.background,colors.webpage.darkmode.threshold.background>>|Threshold for inverting background elements with dark mode.
+|<<colors.webpage.darkmode.threshold.text,colors.webpage.darkmode.threshold.text>>|Threshold for inverting text with dark mode.
|<<colors.webpage.prefers_color_scheme_dark,colors.webpage.prefers_color_scheme_dark>>|Force `prefers-color-scheme: dark` colors for websites.
|<<completion.cmd_history_max_items,completion.cmd_history_max_items>>|Number of commands to save in the command history.
|<<completion.delay,completion.delay>>|Delay (in milliseconds) before updating completions after typing a character.
@@ -141,6 +152,8 @@
|<<content.desktop_capture,content.desktop_capture>>|Allow websites to share screen content.
|<<content.dns_prefetch,content.dns_prefetch>>|Try to pre-fetch DNS entries to speed up browsing.
|<<content.frame_flattening,content.frame_flattening>>|Expand each subframe to its contents.
+|<<content.fullscreen.overlay_timeout,content.fullscreen.overlay_timeout>>|Set fullscreen notification overlay timeout in milliseconds.
+|<<content.fullscreen.window,content.fullscreen.window>>|Limit fullscreen to the browser window (does not expand to fill the screen).
|<<content.geolocation,content.geolocation>>|Allow websites to request geolocations.
|<<content.headers.accept_language,content.headers.accept_language>>|Value to send in the `Accept-Language` header.
|<<content.headers.custom,content.headers.custom>>|Custom headers for qutebrowser HTTP requests.
@@ -175,10 +188,10 @@
|<<content.register_protocol_handler,content.register_protocol_handler>>|Allow websites to register protocol handlers via `navigator.registerProtocolHandler`.
|<<content.site_specific_quirks,content.site_specific_quirks>>|Enable quirks (such as faked user agent headers) needed to get specific sites to work properly.
|<<content.ssl_strict,content.ssl_strict>>|Validate SSL handshakes.
+|<<content.unknown_url_scheme_policy,content.unknown_url_scheme_policy>>|How navigation requests to URLs with unknown schemes are handled.
|<<content.user_stylesheets,content.user_stylesheets>>|List of user stylesheet filenames to use.
|<<content.webgl,content.webgl>>|Enable WebGL.
|<<content.webrtc_ip_handling_policy,content.webrtc_ip_handling_policy>>|Which interfaces to expose via WebRTC.
-|<<content.windowed_fullscreen,content.windowed_fullscreen>>|Limit fullscreen to the browser window (does not expand to fill the screen).
|<<content.xss_auditing,content.xss_auditing>>|Monitor load requests for cross-site scripting attempts.
|<<downloads.location.directory,downloads.location.directory>>|Directory to save downloads to.
|<<downloads.location.prompt,downloads.location.prompt>>|Prompt the user for the download location.
@@ -203,7 +216,8 @@
|<<fonts.messages.warning,fonts.messages.warning>>|Font used for warning messages.
|<<fonts.prompts,fonts.prompts>>|Font used for prompts.
|<<fonts.statusbar,fonts.statusbar>>|Font used in the statusbar.
-|<<fonts.tabs,fonts.tabs>>|Font used in the tab bar.
+|<<fonts.tabs.selected,fonts.tabs.selected>>|Font used for selected tabs.
+|<<fonts.tabs.unselected,fonts.tabs.unselected>>|Font used for unselected tabs.
|<<fonts.web.family.cursive,fonts.web.family.cursive>>|Font family for cursive fonts.
|<<fonts.web.family.fantasy,fonts.web.family.fantasy>>|Font family for fantasy fonts.
|<<fonts.web.family.fixed,fonts.web.family.fixed>>|Font family for fixed fonts.
@@ -225,7 +239,9 @@
|<<hints.min_chars,hints.min_chars>>|Minimum number of characters used for hint strings.
|<<hints.mode,hints.mode>>|Mode to use for hints.
|<<hints.next_regexes,hints.next_regexes>>|Comma-separated list of regular expressions to use for 'next' links.
+|<<hints.padding,hints.padding>>|Padding (in pixels) for hints.
|<<hints.prev_regexes,hints.prev_regexes>>|Comma-separated list of regular expressions to use for 'prev' links.
+|<<hints.radius,hints.radius>>|Rounding radius (in pixels) for the edges of hints.
|<<hints.scatter,hints.scatter>>|Scatter hint key chains (like Vimium) or not (like dwb).
|<<hints.selectors,hints.selectors>>|CSS selectors used to determine which elements on a page should have hints.
|<<hints.uppercase,hints.uppercase>>|Make characters in hint strings uppercase.
@@ -238,16 +254,19 @@
|<<input.insert_mode.leave_on_load,input.insert_mode.leave_on_load>>|Leave insert mode when starting a new page load.
|<<input.insert_mode.plugins,input.insert_mode.plugins>>|Switch to insert mode when clicking flash and other plugins.
|<<input.links_included_in_focus_chain,input.links_included_in_focus_chain>>|Include hyperlinks in the keyboard focus chain when tabbing.
+|<<input.mouse.back_forward_buttons,input.mouse.back_forward_buttons>>|Enable back and forward buttons on the mouse.
+|<<input.mouse.rocker_gestures,input.mouse.rocker_gestures>>|Enable Opera-like mouse rocker gestures.
|<<input.partial_timeout,input.partial_timeout>>|Timeout (in milliseconds) for partially typed key bindings.
-|<<input.rocker_gestures,input.rocker_gestures>>|Enable Opera-like mouse rocker gestures.
|<<input.spatial_navigation,input.spatial_navigation>>|Enable spatial navigation.
|<<keyhint.blacklist,keyhint.blacklist>>|Keychains that shouldn't be shown in the keyhint dialog.
|<<keyhint.delay,keyhint.delay>>|Time (in milliseconds) from pressing a key to seeing the keyhint dialog.
|<<keyhint.radius,keyhint.radius>>|Rounding radius (in pixels) for the edges of the keyhint dialog.
+|<<logging.level.console,logging.level.console>>|Level for console (stdout/stderr) logs. Ignored if the `--loglevel` or `--debug` CLI flags are used.
+|<<logging.level.ram,logging.level.ram>>|Level for in-memory logs.
|<<messages.timeout,messages.timeout>>|Duration (in milliseconds) to show messages in the statusbar for.
|<<new_instance_open_target,new_instance_open_target>>|How to open links in an existing instance if a new one is launched.
|<<new_instance_open_target_window,new_instance_open_target_window>>|Which window to choose when opening links as new tabs.
-|<<prompt.filebrowser,prompt.filebrowser>>|Show a filebrowser in upload/download prompts.
+|<<prompt.filebrowser,prompt.filebrowser>>|Show a filebrowser in download prompts.
|<<prompt.radius,prompt.radius>>|Rounding radius (in pixels) for the edges of prompts.
|<<qt.args,qt.args>>|Additional arguments to pass to Qt, without leading `--`.
|<<qt.force_platform,qt.force_platform>>|Force a Qt platform to use.
@@ -256,16 +275,17 @@
|<<qt.highdpi,qt.highdpi>>|Turn on Qt HighDPI scaling.
|<<qt.low_end_device_mode,qt.low_end_device_mode>>|When to use Chromium's low-end device mode.
|<<qt.process_model,qt.process_model>>|Which Chromium process model to use.
-|<<scrolling.bar,scrolling.bar>>|When to show the scrollbar.
+|<<scrolling.bar,scrolling.bar>>|When/how to show the scrollbar.
|<<scrolling.smooth,scrolling.smooth>>|Enable smooth scrolling for web pages.
|<<search.ignore_case,search.ignore_case>>|When to find text on a page case-insensitively.
|<<search.incremental,search.incremental>>|Find text on a page incrementally, renewing the search for each typed character.
+|<<search.wrap,search.wrap>>|Wrap around at the top and bottom of the page when advancing through text matches using `:search-next` and `:search-prev`.
|<<session.default_name,session.default_name>>|Name of the session to save by default.
|<<session.lazy_restore,session.lazy_restore>>|Load a restored tab as soon as it takes focus.
|<<spellcheck.languages,spellcheck.languages>>|Languages to use for spell checking.
-|<<statusbar.hide,statusbar.hide>>|Hide the statusbar unless a message is shown.
|<<statusbar.padding,statusbar.padding>>|Padding (in pixels) for the statusbar.
|<<statusbar.position,statusbar.position>>|Position of the status bar.
+|<<statusbar.show,statusbar.show>>|When to show the statusbar.
|<<statusbar.widgets,statusbar.widgets>>|List of widgets displayed in the statusbar.
|<<tabs.background,tabs.background>>|Open new tabs (middleclick/ctrl+click) in the background.
|<<tabs.close_mouse_button,tabs.close_mouse_button>>|Mouse button with which to close tabs.
@@ -441,6 +461,7 @@ Default:
* +pass:[J]+: +pass:[scroll down]+
* +pass:[K]+: +pass:[scroll up]+
* +pass:[L]+: +pass:[scroll right]+
+* +pass:[V]+: +pass:[toggle-selection --line]+
* +pass:[Y]+: +pass:[yank selection -s]+
* +pass:[[]+: +pass:[move-to-start-of-prev-block]+
* +pass:[]]+: +pass:[move-to-start-of-next-block]+
@@ -585,6 +606,7 @@ Default:
* +pass:[Sq]+: +pass:[open qute://bookmarks]+
* +pass:[Ss]+: +pass:[open qute://settings]+
* +pass:[T]+: +pass:[tab-focus]+
+* +pass:[V]+: +pass:[enter-mode caret ;; toggle-selection --line]+
* +pass:[ZQ]+: +pass:[quit]+
* +pass:[ZZ]+: +pass:[quit --save]+
* +pass:[[[]+: +pass:[navigate prev]+
@@ -632,6 +654,9 @@ Default:
* +pass:[sk]+: +pass:[set-cmd-text -s :bind]+
* +pass:[sl]+: +pass:[set-cmd-text -s :set -t]+
* +pass:[ss]+: +pass:[set-cmd-text -s :set]+
+* +pass:[tCH]+: +pass:[config-cycle -p -u *://*.{url:host}/* content.cookies.accept all no-3rdparty never ;; reload]+
+* +pass:[tCh]+: +pass:[config-cycle -p -u *://{url:host}/* content.cookies.accept all no-3rdparty never ;; reload]+
+* +pass:[tCu]+: +pass:[config-cycle -p -u {url} content.cookies.accept all no-3rdparty never ;; reload]+
* +pass:[tIH]+: +pass:[config-cycle -p -u *://*.{url:host}/* content.images ;; reload]+
* +pass:[tIh]+: +pass:[config-cycle -p -u *://{url:host}/* content.images ;; reload]+
* +pass:[tIu]+: +pass:[config-cycle -p -u {url} content.images ;; reload]+
@@ -641,6 +666,9 @@ Default:
* +pass:[tSH]+: +pass:[config-cycle -p -u *://*.{url:host}/* content.javascript.enabled ;; reload]+
* +pass:[tSh]+: +pass:[config-cycle -p -u *://{url:host}/* content.javascript.enabled ;; reload]+
* +pass:[tSu]+: +pass:[config-cycle -p -u {url} content.javascript.enabled ;; reload]+
+* +pass:[tcH]+: +pass:[config-cycle -p -t -u *://*.{url:host}/* content.cookies.accept all no-3rdparty never ;; reload]+
+* +pass:[tch]+: +pass:[config-cycle -p -t -u *://{url:host}/* content.cookies.accept all no-3rdparty never ;; reload]+
+* +pass:[tcu]+: +pass:[config-cycle -p -t -u {url} content.cookies.accept all no-3rdparty never ;; reload]+
* +pass:[th]+: +pass:[back -t]+
* +pass:[tiH]+: +pass:[config-cycle -p -t -u *://*.{url:host}/* content.images ;; reload]+
* +pass:[tih]+: +pass:[config-cycle -p -t -u *://{url:host}/* content.images ;; reload]+
@@ -734,6 +762,7 @@ Default:
- +pass:[&lt;Ctrl-6&gt;]+: +pass:[&lt;Ctrl-^&gt;]+
- +pass:[&lt;Ctrl-Enter&gt;]+: +pass:[&lt;Ctrl-Return&gt;]+
+- +pass:[&lt;Ctrl-I&gt;]+: +pass:[&lt;Tab&gt;]+
- +pass:[&lt;Ctrl-J&gt;]+: +pass:[&lt;Return&gt;]+
- +pass:[&lt;Ctrl-M&gt;]+: +pass:[&lt;Return&gt;]+
- +pass:[&lt;Ctrl-[&gt;]+: +pass:[&lt;Escape&gt;]+
@@ -866,6 +895,24 @@ Type: <<types,QssColor>>
Default: +pass:[white]+
+[[colors.contextmenu.disabled.bg]]
+=== colors.contextmenu.disabled.bg
+Background color of disabled items in the context menu.
+If set to null, the Qt default is used.
+
+Type: <<types,QssColor>>
+
+Default: empty
+
+[[colors.contextmenu.disabled.fg]]
+=== colors.contextmenu.disabled.fg
+Foreground color of disabled items in the context menu.
+If set to null, the Qt default is used.
+
+Type: <<types,QssColor>>
+
+Default: empty
+
[[colors.contextmenu.menu.bg]]
=== colors.contextmenu.menu.bg
Background color of the context menu.
@@ -1508,6 +1555,161 @@ Type: <<types,QtColor>>
Default: +pass:[white]+
+[[colors.webpage.darkmode.algorithm]]
+=== colors.webpage.darkmode.algorithm
+Which algorithm to use for modifying how colors are rendered with darkmode.
+This setting requires a restart.
+
+Type: <<types,String>>
+
+Valid values:
+
+ * +lightness-cielab+: Modify colors by converting them to CIELAB color space and inverting the L value.
+ * +lightness-hsl+: Modify colors by converting them to the HSL color space and inverting the lightness (i.e. the "L" in HSL).
+ * +brightness-rgb+: Modify colors by subtracting each of r, g, and b from their maximum value.
+
+Default: +pass:[lightness-cielab]+
+
+On QtWebEngine, this setting requires Qt 5.14 or newer.
+
+On QtWebKit, this setting is unavailable.
+
+[[colors.webpage.darkmode.contrast]]
+=== colors.webpage.darkmode.contrast
+Contrast for dark mode.
+This only has an effect when `colors.webpage.darkmode.algorithm` is set to `lightness-hsl` or `brightness-rgb`.
+This setting requires a restart.
+
+Type: <<types,Float>>
+
+Default: +pass:[0.0]+
+
+On QtWebEngine, this setting requires Qt 5.14 or newer.
+
+On QtWebKit, this setting is unavailable.
+
+[[colors.webpage.darkmode.enabled]]
+=== colors.webpage.darkmode.enabled
+Render all web contents using a dark theme.
+Example configurations from Chromium's `chrome://flags`:
+
+- "With simple HSL/CIELAB/RGB-based inversion": Set
+ `colors.webpage.darkmode.algorithm` accordingly.
+
+- "With selective image inversion": Set
+ `colors.webpage.darkmode.policy.images` to `smart`.
+
+- "With selective inversion of non-image elements": Set
+ `colors.webpage.darkmode.threshold.text` to 150 and
+ `colors.webpage.darkmode.threshold.background` to 205.
+
+- "With selective inversion of everything": Combines the two variants
+ above.
+This setting requires a restart.
+
+Type: <<types,Bool>>
+
+Default: +pass:[false]+
+
+On QtWebEngine, this setting requires Qt 5.14 or newer.
+
+On QtWebKit, this setting is unavailable.
+
+[[colors.webpage.darkmode.grayscale.all]]
+=== colors.webpage.darkmode.grayscale.all
+Render all colors as grayscale.
+This only has an effect when `colors.webpage.darkmode.algorithm` is set to `lightness-hsl` or `brightness-rgb`.
+This setting requires a restart.
+
+Type: <<types,Bool>>
+
+Default: +pass:[false]+
+
+On QtWebEngine, this setting requires Qt 5.14 or newer.
+
+On QtWebKit, this setting is unavailable.
+
+[[colors.webpage.darkmode.grayscale.images]]
+=== colors.webpage.darkmode.grayscale.images
+Desaturation factor for images in dark mode.
+If set to 0, images are left as-is. If set to 1, images are completely grayscale. Values between 0 and 1 desaturate the colors accordingly.
+This setting requires a restart.
+
+Type: <<types,Float>>
+
+Default: +pass:[0.0]+
+
+On QtWebEngine, this setting requires Qt 5.14 or newer.
+
+On QtWebKit, this setting is unavailable.
+
+[[colors.webpage.darkmode.policy.images]]
+=== colors.webpage.darkmode.policy.images
+Which images to apply dark mode to.
+WARNING: On Qt 5.15.0, this setting can cause frequent renderer process crashes due to a https://codereview.qt-project.org/c/qt/qtwebengine-chromium/+/304211[bug in Qt].
+This setting requires a restart.
+
+Type: <<types,String>>
+
+Valid values:
+
+ * +always+: Apply dark mode filter to all images.
+ * +never+: Never apply dark mode filter to any images.
+ * +smart+: Apply dark mode based on image content.
+
+Default: +pass:[never]+
+
+On QtWebEngine, this setting requires Qt 5.14 or newer.
+
+On QtWebKit, this setting is unavailable.
+
+[[colors.webpage.darkmode.policy.page]]
+=== colors.webpage.darkmode.policy.page
+Which pages to apply dark mode to.
+This setting requires a restart.
+
+Type: <<types,String>>
+
+Valid values:
+
+ * +always+: Apply dark mode filter to all frames, regardless of content.
+ * +smart+: Apply dark mode filter to frames based on background color.
+
+Default: +pass:[smart]+
+
+On QtWebEngine, this setting requires Qt 5.14 or newer.
+
+On QtWebKit, this setting is unavailable.
+
+[[colors.webpage.darkmode.threshold.background]]
+=== colors.webpage.darkmode.threshold.background
+Threshold for inverting background elements with dark mode.
+Background elements with brightness above this threshold will be inverted, and below it will be left as in the original, non-dark-mode page. Set to 256 to never invert the color or to 0 to always invert it.
+Note: This behavior is the opposite of `colors.webpage.darkmode.threshold.text`!
+This setting requires a restart.
+
+Type: <<types,Int>>
+
+Default: +pass:[0]+
+
+On QtWebEngine, this setting requires Qt 5.14 or newer.
+
+On QtWebKit, this setting is unavailable.
+
+[[colors.webpage.darkmode.threshold.text]]
+=== colors.webpage.darkmode.threshold.text
+Threshold for inverting text with dark mode.
+Text colors with brightness below this threshold will be inverted, and above it will be left as in the original, non-dark-mode page. Set to 256 to always invert text color or to 0 to never invert text color.
+This setting requires a restart.
+
+Type: <<types,Int>>
+
+Default: +pass:[256]+
+
+On QtWebEngine, this setting requires Qt 5.14 or newer.
+
+On QtWebKit, this setting is unavailable.
+
[[colors.webpage.prefers_color_scheme_dark]]
=== colors.webpage.prefers_color_scheme_dark
Force `prefers-color-scheme: dark` colors for websites.
@@ -1800,6 +2002,10 @@ This setting is only available with the QtWebEngine backend.
[[content.cookies.accept]]
=== content.cookies.accept
Which cookies to accept.
+With QtWebEngine, this setting also controls other features with tracking capabilities similar to those of cookies; including IndexedDB, DOM storage, filesystem API, service workers, and AppCache.
+Note that with QtWebKit, only `all` and `never` are supported as per-domain values. Setting `no-3rdparty` or `no-unknown-3rdparty` per-domain on QtWebKit will have the same effect as `all`.
+
+This setting supports URL patterns.
Type: <<types,String>>
@@ -1857,7 +2063,7 @@ This setting supports URL patterns.
Type: <<types,Bool>>
-Default: +pass:[false]+
+Default: +pass:[true]+
On QtWebEngine, this setting requires Qt 5.12 or newer.
@@ -1874,6 +2080,23 @@ Default: +pass:[false]+
This setting is only available with the QtWebKit backend.
+[[content.fullscreen.overlay_timeout]]
+=== content.fullscreen.overlay_timeout
+Set fullscreen notification overlay timeout in milliseconds.
+If set to 0, no overlay will be displayed.
+
+Type: <<types,Int>>
+
+Default: +pass:[3000]+
+
+[[content.fullscreen.window]]
+=== content.fullscreen.window
+Limit fullscreen to the browser window (does not expand to fill the screen).
+
+Type: <<types,Bool>>
+
+Default: +pass:[false]+
+
[[content.geolocation]]
=== content.geolocation
Allow websites to request geolocations.
@@ -1957,7 +2180,9 @@ The following placeholders are defined:
The default value is equal to the unchanged user agent of
QtWebKit/QtWebEngine.
-Note that the value read from JavaScript is always the global value.
+Note that the value read from JavaScript is always the global value. With
+QtWebEngine between 5.12 and 5.14 (inclusive), changing the value exposed
+to JavaScript requires a restart.
This setting supports URL patterns.
@@ -2305,6 +2530,26 @@ Valid values:
Default: +pass:[ask]+
+[[content.unknown_url_scheme_policy]]
+=== content.unknown_url_scheme_policy
+How navigation requests to URLs with unknown schemes are handled.
+
+This setting supports URL patterns.
+
+Type: <<types,String>>
+
+Valid values:
+
+ * +disallow+: Disallows all navigation requests to URLs with unknown schemes.
+ * +allow-from-user-interaction+: Allows navigation requests to URLs with unknown schemes that are issued from user-interaction (like a mouse-click), whereas other navigation requests (for example from JavaScript) are suppressed.
+ * +allow-all+: Allows all navigation requests to URLs with unknown schemes.
+
+Default: +pass:[allow-from-user-interaction]+
+
+On QtWebEngine, this setting requires Qt 5.11 or newer.
+
+On QtWebKit, this setting is unavailable.
+
[[content.user_stylesheets]]
=== content.user_stylesheets
List of user stylesheet filenames to use.
@@ -2344,14 +2589,6 @@ On QtWebEngine, this setting requires Qt 5.9.2 or newer.
On QtWebKit, this setting is unavailable.
-[[content.windowed_fullscreen]]
-=== content.windowed_fullscreen
-Limit fullscreen to the browser window (does not expand to fill the screen).
-
-Type: <<types,Bool>>
-
-Default: +pass:[false]+
-
[[content.xss_auditing]]
=== content.xss_auditing
Monitor load requests for cross-site scripting attempts.
@@ -2495,7 +2732,7 @@ Default: empty
=== fonts.debug_console
Font used for the debugging console.
-Type: <<types,QtFont>>
+Type: <<types,Font>>
Default: +pass:[default_size default_family]+
@@ -2583,11 +2820,19 @@ Type: <<types,Font>>
Default: +pass:[default_size default_family]+
-[[fonts.tabs]]
-=== fonts.tabs
-Font used in the tab bar.
+[[fonts.tabs.selected]]
+=== fonts.tabs.selected
+Font used for selected tabs.
-Type: <<types,QtFont>>
+Type: <<types,Font>>
+
+Default: +pass:[default_size default_family]+
+
+[[fonts.tabs.unselected]]
+=== fonts.tabs.unselected
+Font used for unselected tabs.
+
+Type: <<types,Font>>
Default: +pass:[default_size default_family]+
@@ -2786,6 +3031,19 @@ Default:
- +pass:[\b(&gt;&gt;|»)\b]+
- +pass:[\bcontinue\b]+
+[[hints.padding]]
+=== hints.padding
+Padding (in pixels) for hints.
+
+Type: <<types,Padding>>
+
+Default:
+
+- +pass:[bottom]+: +pass:[0]+
+- +pass:[left]+: +pass:[3]+
+- +pass:[right]+: +pass:[3]+
+- +pass:[top]+: +pass:[0]+
+
[[hints.prev_regexes]]
=== hints.prev_regexes
Comma-separated list of regular expressions to use for 'prev' links.
@@ -2800,6 +3058,14 @@ Default:
- +pass:[\b[&lt;←≪]\b]+
- +pass:[\b(&lt;&lt;|«)\b]+
+[[hints.radius]]
+=== hints.radius
+Rounding radius (in pixels) for the edges of hints.
+
+Type: <<types,Int>>
+
+Default: +pass:[3]+
+
[[hints.scatter]]
=== hints.scatter
Scatter hint key chains (like Vimium) or not (like dwb).
@@ -2971,17 +3237,16 @@ Type: <<types,Bool>>
Default: +pass:[true]+
-[[input.partial_timeout]]
-=== input.partial_timeout
-Timeout (in milliseconds) for partially typed key bindings.
-If the current input forms only partial matches, the keystring will be cleared after this time.
+[[input.mouse.back_forward_buttons]]
+=== input.mouse.back_forward_buttons
+Enable back and forward buttons on the mouse.
-Type: <<types,Int>>
+Type: <<types,Bool>>
-Default: +pass:[5000]+
+Default: +pass:[true]+
-[[input.rocker_gestures]]
-=== input.rocker_gestures
+[[input.mouse.rocker_gestures]]
+=== input.mouse.rocker_gestures
Enable Opera-like mouse rocker gestures.
This disables the context menu.
@@ -2989,6 +3254,15 @@ Type: <<types,Bool>>
Default: +pass:[false]+
+[[input.partial_timeout]]
+=== input.partial_timeout
+Timeout (in milliseconds) for partially typed key bindings.
+If the current input forms only partial matches, the keystring will be cleared after this time.
+
+Type: <<types,Int>>
+
+Default: +pass:[5000]+
+
[[input.spatial_navigation]]
=== input.spatial_navigation
Enable spatial navigation.
@@ -3025,6 +3299,40 @@ Type: <<types,Int>>
Default: +pass:[6]+
+[[logging.level.console]]
+=== logging.level.console
+Level for console (stdout/stderr) logs. Ignored if the `--loglevel` or `--debug` CLI flags are used.
+
+Type: <<types,LogLevel>>
+
+Valid values:
+
+ * +vdebug+
+ * +debug+
+ * +info+
+ * +warning+
+ * +error+
+ * +critical+
+
+Default: +pass:[info]+
+
+[[logging.level.ram]]
+=== logging.level.ram
+Level for in-memory logs.
+
+Type: <<types,LogLevel>>
+
+Valid values:
+
+ * +vdebug+
+ * +debug+
+ * +info+
+ * +warning+
+ * +error+
+ * +critical+
+
+Default: +pass:[debug]+
+
[[messages.timeout]]
=== messages.timeout
Duration (in milliseconds) to show messages in the statusbar for.
@@ -3070,7 +3378,7 @@ Default: +pass:[last-focused]+
[[prompt.filebrowser]]
=== prompt.filebrowser
-Show a filebrowser in upload/download prompts.
+Show a filebrowser in download prompts.
Type: <<types,Bool>>
@@ -3186,7 +3494,7 @@ This setting is only available with the QtWebEngine backend.
[[scrolling.bar]]
=== scrolling.bar
-When to show the scrollbar.
+When/how to show the scrollbar.
Type: <<types,String>>
@@ -3195,8 +3503,9 @@ Valid values:
* +always+: Always show the scrollbar.
* +never+: Never show the scrollbar.
* +when-searching+: Show the scrollbar when searching for text in the webpage. With the QtWebKit backend, this is equal to `never`.
+ * +overlay+: Show an overlay scrollbar. With Qt < 5.11, this is equal to `when-searching`; with the QtWebKit backend, this is equal to `never`. Enabling/disabling overlay scrollbars requires a restart.
-Default: +pass:[when-searching]+
+Default: +pass:[overlay]+
[[scrolling.smooth]]
=== scrolling.smooth
@@ -3231,6 +3540,16 @@ Type: <<types,Bool>>
Default: +pass:[true]+
+[[search.wrap]]
+=== search.wrap
+Wrap around at the top and bottom of the page when advancing through text matches using `:search-next` and `:search-prev`.
+
+Type: <<types,Bool>>
+
+Default: +pass:[true]+
+
+On QtWebEngine, this setting requires Qt 5.14 or newer.
+
[[session.default_name]]
=== session.default_name
Name of the session to save by default.
@@ -3307,14 +3626,6 @@ On QtWebEngine, this setting requires Qt 5.8 or newer.
On QtWebKit, this setting is unavailable.
-[[statusbar.hide]]
-=== statusbar.hide
-Hide the statusbar unless a message is shown.
-
-Type: <<types,Bool>>
-
-Default: +pass:[false]+
-
[[statusbar.padding]]
=== statusbar.padding
Padding (in pixels) for the statusbar.
@@ -3341,6 +3652,20 @@ Valid values:
Default: +pass:[bottom]+
+[[statusbar.show]]
+=== statusbar.show
+When to show the statusbar.
+
+Type: <<types,String>>
+
+Valid values:
+
+ * +always+: Always show the statusbar.
+ * +never+: Always hide the statusbar.
+ * +in-mode+: Show the statusbar when in modes other than normal mode.
+
+Default: +pass:[always]+
+
[[statusbar.widgets]]
=== statusbar.widgets
List of widgets displayed in the statusbar.
@@ -3776,8 +4101,28 @@ Default: +pass:[false]+
[[url.searchengines]]
=== url.searchengines
Search engines which can be used via the address bar.
-Maps a search engine name (such as `DEFAULT`, or `ddg`) to a URL with a `{}` placeholder. The placeholder will be replaced by the search term, use `{{` and `}}` for literal `{`/`}` signs.
-The search engine named `DEFAULT` is used when `url.auto_search` is turned on and something else than a URL was entered to be opened. Other search engines can be used by prepending the search engine name to the search term, e.g. `:open google qutebrowser`.
+
+Maps a search engine name (such as `DEFAULT`, or `ddg`) to a URL with a
+`{}` placeholder. The placeholder will be replaced by the search term, use
+`{{` and `}}` for literal `{`/`}` braces.
+
+The following further placeholds are defined to configure how special
+characters in the search terms are replaced by safe characters (called
+'quoting'):
+
+* `{}` and `{semiquoted}` quote everything except slashes; this is the most
+ sensible choice for almost all search engines (for the search term
+ `slash/and&amp` this placeholder expands to `slash/and%26amp`).
+* `{quoted}` quotes all characters (for `slash/and&amp` this placeholder
+ expands to `slash%2Fand%26amp`).
+* `{unquoted}` quotes nothing (for `slash/and&amp` this placeholder
+ expands to `slash/and&amp`).
+
+The search engine named `DEFAULT` is used when `url.auto_search` is turned
+on and something else than a URL was entered to be opened. Other search
+engines can be used by prepending the search engine name to the search
+term, e.g. `:open google qutebrowser`.
+
Type: <<types,Dict>>
@@ -3917,6 +4262,7 @@ Lists with duplicate flags are invalid. Each item is checked against the valid v
When setting from a string, pass a json-like list, e.g. `["one", "two"]`.
|ListOrValue|A list of values, or a single value.
+|LogLevel|A logging level.
|NewTabPosition|How new tabs are positioned.
|Padding|Setting for paddings around elements.
|Perc|A percentage.
@@ -3929,9 +4275,6 @@ A value can be in one of the following formats: * `#RGB`/`#RRGGBB`/`#RRRGGGBBB`/
|QtColor|A color value.
A value can be in one of the following formats: * `#RGB`/`#RRGGBB`/`#RRRGGGBBB`/`#RRRRGGGGBBBB` * An SVG color name as specified in http://www.w3.org/TR/SVG/types.html#ColorKeywords[the W3C specification]. * transparent (no color) * `rgb(r, g, b)` / `rgba(r, g, b, a)` (values 0-255 or percentages) * `hsv(h, s, v)` / `hsva(h, s, v, a)` (values 0-255, hue 0-359)
-|QtFont|A font family, with optional style/weight/size.
-
-* Style: `normal`/`italic`/`oblique` * Weight: `normal`, `bold`, `100`..`900` * Size: _number_ `px`/`pt`
|Regex|A regular expression.
When setting from `config.py`, both a string or a `re.compile(...)` object are valid.
diff --git a/doc/install.asciidoc b/doc/install.asciidoc
index 1e72b24fc..4355abc7b 100644
--- a/doc/install.asciidoc
+++ b/doc/install.asciidoc
@@ -256,7 +256,7 @@ mailinglist] to get notified on new releases). You can install a newer version
without uninstalling the older one.
The binary release ships with a QtWebEngine built without proprietary codec
-support. To get support for e.g. h264/h265 videos, you'll need to build
+support. To get support for e.g. h264/mp4 videos, you'll need to build
QtWebEngine from source yourself with support for that enabled.
https://chocolatey.org/packages/qutebrowser[Chocolatey package]
@@ -299,7 +299,7 @@ https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser-announce[qutebrows
mailinglist] to get notified on new releases).
The binary release ships with a QtWebEngine built without proprietary codec
-support. To get support for e.g. h264/h265 videos, you'll need to build
+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
diff --git a/doc/qutebrowser.1.asciidoc b/doc/qutebrowser.1.asciidoc
index 52ed64d3e..8dae3eaef 100644
--- a/doc/qutebrowser.1.asciidoc
+++ b/doc/qutebrowser.1.asciidoc
@@ -67,7 +67,7 @@ show it.
=== debug arguments
*-l* '{critical,error,warning,info,debug,vdebug}', *--loglevel* '{critical,error,warning,info,debug,vdebug}'::
- Set loglevel
+ Override the configured console loglevel
*--logfilter* 'LOGFILTER'::
Comma-separated list of things to be logged to the debug log on stdout.
diff --git a/doc/stacktrace.asciidoc b/doc/stacktrace.asciidoc
index f38c54940..4dc327e0e 100644
--- a/doc/stacktrace.asciidoc
+++ b/doc/stacktrace.asciidoc
@@ -34,6 +34,27 @@ is available in the repositories:
# apt-get install python3-pyqt5-dbg python3-pyqt5.qtwebkit-dbg python3-dbg libqt5webkit5-dbg
----
+Fedora
+^^^^^^
+
+For Fedora you first need to install the dnf/yum-utils:
+
+----
+# dnf install dnf-utils
+----
+
+Or:
+
+----
+# yum install yum-utils
+----
+
+Then install the needed debuginfo packages:
+
+----
+# debuginfo-install python3 qt5-qtwebengine python3-qt5-webengine python3-qt5-base python-qt5 python3-qt5 python3-qt5-webkit
+----
+
Archlinux
^^^^^^^^^
diff --git a/misc/org.qutebrowser.qutebrowser.appdata.xml b/misc/org.qutebrowser.qutebrowser.appdata.xml
index d7c8162c0..f02fcb00d 100644
--- a/misc/org.qutebrowser.qutebrowser.appdata.xml
+++ b/misc/org.qutebrowser.qutebrowser.appdata.xml
@@ -44,6 +44,10 @@
</content_rating>
<releases>
<!-- Add new releases here -->
+<release version="1.12.0" date="2020-06-01"/>
+<release version="1.11.1" date="2020-05-07"/>
+<release version="1.11.0" date="2020-04-27"/>
+<release version="1.10.2" date="2020-04-17"/>
<release version="1.10.1" date="2020-02-15"/>
<release version="1.10.0" date="2020-02-02"/>
<release version="1.9.0" date="2020-01-08"/>
diff --git a/misc/requirements/requirements-check-manifest.txt b/misc/requirements/requirements-check-manifest.txt
index 8b18ce2a4..4cc00982d 100644
--- a/misc/requirements/requirements-check-manifest.txt
+++ b/misc/requirements/requirements-check-manifest.txt
@@ -1,5 +1,5 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-check-manifest==0.41
-pep517==0.8.1
-toml==0.10.0
+check-manifest==0.42
+pep517==0.8.2
+toml==0.10.1
diff --git a/misc/requirements/requirements-codecov.txt b/misc/requirements/requirements-codecov.txt
index 8961ca2be..7e869803a 100644
--- a/misc/requirements/requirements-codecov.txt
+++ b/misc/requirements/requirements-codecov.txt
@@ -1,9 +1,9 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-certifi==2019.11.28
+certifi==2020.4.5.2
chardet==3.0.4
-codecov==2.0.16
-coverage==5.0.3
+codecov==2.1.4
+coverage==5.1
idna==2.9
requests==2.23.0
-urllib3==1.25.8
+urllib3==1.25.9
diff --git a/misc/requirements/requirements-dev.txt b/misc/requirements/requirements-dev.txt
index 6f796740c..cf4d246f4 100644
--- a/misc/requirements/requirements-dev.txt
+++ b/misc/requirements/requirements-dev.txt
@@ -1,27 +1,26 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
bump2version==1.0.0
-certifi==2019.11.28
+certifi==2020.4.5.2
cffi==1.14.0
chardet==3.0.4
colorama==0.4.3
-cryptography==2.8
+cryptography==2.9.2
cssutils==1.0.2
github3.py==1.3.0
hunter==3.1.3
idna==2.9
jwcrypto==0.7
-lxml==4.5.0
manhole==1.6.0
-packaging==20.3
+packaging==20.4
pycparser==2.20
Pympler==0.8
-pyparsing==2.4.6
-PyQt-builder==1.2.0
+pyparsing==2.4.7
+PyQt-builder==1.4.0
python-dateutil==2.8.1
requests==2.23.0
-sip==5.1.1
-six==1.14.0
-toml==0.10.0
+sip==5.3.0
+six==1.15.0
+toml==0.10.1
uritemplate==3.0.1
-urllib3==1.25.8
+urllib3==1.25.9
diff --git a/misc/requirements/requirements-dev.txt-raw b/misc/requirements/requirements-dev.txt-raw
index f75a837af..71e19f502 100644
--- a/misc/requirements/requirements-dev.txt-raw
+++ b/misc/requirements/requirements-dev.txt-raw
@@ -4,5 +4,4 @@ pympler
github3.py
bump2version
requests
-lxml
pyqt-builder
diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt
index 8edadcb3f..0cd0df369 100644
--- a/misc/requirements/requirements-flake8.txt
+++ b/misc/requirements/requirements-flake8.txt
@@ -1,11 +1,10 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
attrs==19.3.0
-entrypoints==0.3
-flake8==3.7.9
+flake8==3.8.2
flake8-bugbear==20.1.4
-flake8-builtins==1.4.2
-flake8-comprehensions==3.2.2
+flake8-builtins==1.5.3
+flake8-comprehensions==3.2.3
flake8-copyright==0.2.2
flake8-debugger==3.2.1
flake8-deprecated==1.3
@@ -14,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.0.0
+flake8-tidy-imports==4.1.0
flake8-tuple==0.4.1
mccabe==0.6.1
-pep8-naming==0.9.1
-pycodestyle==2.5.0
+pep8-naming==0.10.0
+pycodestyle==2.6.0
pydocstyle==5.0.2
-pyflakes==2.1.1
-six==1.14.0
+pyflakes==2.2.0
+six==1.15.0
snowballstemmer==2.0.0
diff --git a/misc/requirements/requirements-mypy.txt b/misc/requirements/requirements-mypy.txt
index 243ec140b..7759f96b8 100644
--- a/misc/requirements/requirements-mypy.txt
+++ b/misc/requirements/requirements-mypy.txt
@@ -1,9 +1,7 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-mypy==0.770
+mypy==0.780
mypy-extensions==0.4.3
-# PyQt5==5.11.3
-# PyQt5-sip==4.19.19
--e git+https://github.com/qutebrowser/PyQt5-stubs.git@wip#egg=PyQt5_stubs
+-e git+https://github.com/stlehmann/PyQt5-stubs.git@master#egg=PyQt5_stubs
typed-ast==1.4.1
-typing-extensions==3.7.4.1
+typing-extensions==3.7.4.2
diff --git a/misc/requirements/requirements-mypy.txt-raw b/misc/requirements/requirements-mypy.txt-raw
index 92a35ab74..777b288ba 100644
--- a/misc/requirements/requirements-mypy.txt-raw
+++ b/misc/requirements/requirements-mypy.txt-raw
@@ -1,6 +1,5 @@
mypy
--e git+https://github.com/qutebrowser/PyQt5-stubs.git@wip#egg=PyQt5-stubs
+-e git+https://github.com/stlehmann/PyQt5-stubs.git@master#egg=PyQt5-stubs
# remove @commit-id for scm installs
-#@ replace: @.*# @wip#
-#@ ignore: PyQt5, PyQt5-sip
+#@ replace: @.*# @master#
diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt
index 2b7977bfc..db2eb7a02 100644
--- a/misc/requirements/requirements-pip.txt
+++ b/misc/requirements/requirements-pip.txt
@@ -1,8 +1,8 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-appdirs==1.4.3
-packaging==20.3
-pyparsing==2.4.6
-setuptools==46.0.0
-six==1.14.0
+appdirs==1.4.4
+packaging==20.4
+pyparsing==2.4.7
+setuptools==47.1.1
+six==1.15.0
wheel==0.34.2
diff --git a/misc/requirements/requirements-pyinstaller.txt b/misc/requirements/requirements-pyinstaller.txt
index 431673cce..d6fa5bc82 100644
--- a/misc/requirements/requirements-pyinstaller.txt
+++ b/misc/requirements/requirements-pyinstaller.txt
@@ -1,4 +1,4 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
altgraph==0.17
--e git+https://github.com/pyinstaller/pyinstaller.git@develop#egg=PyInstaller
+-e git+https://github.com/pyinstaller/pyinstaller.git@develop#egg=pyinstaller
diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt
index 7b543b830..cb4892f9c 100644
--- a/misc/requirements/requirements-pylint.txt
+++ b/misc/requirements/requirements-pylint.txt
@@ -1,23 +1,23 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-astroid==2.3.3
-certifi==2019.11.28
+astroid==2.3.3 # rq.filter: < 2.4
+certifi==2020.4.5.2
cffi==1.14.0
chardet==3.0.4
-cryptography==2.8
+cryptography==2.9.2
github3.py==1.3.0
idna==2.9
isort==4.3.21
jwcrypto==0.7
-lazy-object-proxy==1.4.3
+lazy-object-proxy==1.5.0
mccabe==0.6.1
pycparser==2.20
-pylint==2.4.4
+pylint==2.4.4 # rq.filter: < 2.5
python-dateutil==2.8.1
./scripts/dev/pylint_checkers
requests==2.23.0
-six==1.14.0
+six==1.15.0
typed-ast==1.4.1 ; python_version<"3.8"
uritemplate==3.0.1
-urllib3==1.25.8
+urllib3==1.25.9
wrapt==1.12.1
diff --git a/misc/requirements/requirements-pylint.txt-raw b/misc/requirements/requirements-pylint.txt-raw
index 8a1d36ffc..8e88c128d 100644
--- a/misc/requirements/requirements-pylint.txt-raw
+++ b/misc/requirements/requirements-pylint.txt-raw
@@ -1,4 +1,4 @@
-pylint
+pylint<2.5
./scripts/dev/pylint_checkers
requests
github3.py
@@ -6,3 +6,5 @@ github3.py
# fix qute-pylint location
#@ replace: qute-pylint==.* ./scripts/dev/pylint_checkers
#@ markers: typed-ast python_version<"3.8"
+#@ filter: pylint < 2.5
+#@ filter: astroid < 2.4
diff --git a/misc/requirements/requirements-pyqt-5.12.txt b/misc/requirements/requirements-pyqt-5.12.txt
index 51d019f6f..9b458cd98 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.7.1
+PyQt5-sip==12.8.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 e80cf79ee..7c07eac3d 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.7.1
+PyQt5-sip==12.8.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 a0107262e..c82acedb0 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.1 # rq.filter: < 5.15
-PyQt5-sip==12.7.1
+PyQt5==5.14.2 # rq.filter: < 5.15
+PyQt5-sip==12.8.0
PyQtWebEngine==5.14.0 # rq.filter: < 5.15
diff --git a/misc/requirements/requirements-pyqt-5.15.txt b/misc/requirements/requirements-pyqt-5.15.txt
new file mode 100644
index 000000000..c21b7b742
--- /dev/null
+++ b/misc/requirements/requirements-pyqt-5.15.txt
@@ -0,0 +1,5 @@
+# This file is automatically generated by scripts/dev/recompile_requirements.py
+
+PyQt5==5.15.0 # rq.filter: < 6
+PyQt5-sip==12.8.0
+PyQtWebEngine==5.15.0 # rq.filter: < 6
diff --git a/misc/requirements/requirements-pyqt-5.15.txt-raw b/misc/requirements/requirements-pyqt-5.15.txt-raw
new file mode 100644
index 000000000..c9eeb9fb7
--- /dev/null
+++ b/misc/requirements/requirements-pyqt-5.15.txt-raw
@@ -0,0 +1,4 @@
+#@ filter: PyQt5 < 6
+#@ filter: PyQtWebEngine < 6
+PyQt5 >= 5.15, < 6
+PyQtWebEngine >= 5.15, < 6
diff --git a/misc/requirements/requirements-pyqt.txt b/misc/requirements/requirements-pyqt.txt
index b0aedce2b..74d86e8d5 100644
--- a/misc/requirements/requirements-pyqt.txt
+++ b/misc/requirements/requirements-pyqt.txt
@@ -1,5 +1,5 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-PyQt5==5.14.1
-PyQt5-sip==12.7.1
-PyQtWebEngine==5.14.0
+PyQt5==5.15.0
+PyQt5-sip==12.8.0
+PyQtWebEngine==5.15.0
diff --git a/misc/requirements/requirements-sphinx.txt b/misc/requirements/requirements-sphinx.txt
index b1935ded3..5851b8b72 100644
--- a/misc/requirements/requirements-sphinx.txt
+++ b/misc/requirements/requirements-sphinx.txt
@@ -2,25 +2,25 @@
alabaster==0.7.12
Babel==2.8.0
-certifi==2019.11.28
+certifi==2020.4.5.2
chardet==3.0.4
docutils==0.16
idna==2.9
imagesize==1.2.0
-Jinja2==2.11.1
+Jinja2==2.11.2
MarkupSafe==1.1.1
-packaging==20.3
+packaging==20.4
Pygments==2.6.1
-pyparsing==2.4.6
-pytz==2019.3
+pyparsing==2.4.7
+pytz==2020.1
requests==2.23.0
-six==1.14.0
+six==1.15.0
snowballstemmer==2.0.0
-Sphinx==2.4.4
+Sphinx==3.1.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==1.0.3
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.4
-urllib3==1.25.8
+urllib3==1.25.9
diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt
index 3d4e1aff1..ed4596a82 100644
--- a/misc/requirements/requirements-tests.txt
+++ b/misc/requirements/requirements-tests.txt
@@ -1,47 +1,47 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
attrs==19.3.0
-beautifulsoup4==4.8.2
+beautifulsoup4==4.9.1
cheroot==8.3.0
-click==7.1.1
+click==7.1.2
# colorama==0.4.3
-coverage==5.0.3
-EasyProcess==0.2.10
-Flask==1.1.1
+coverage==5.1
+EasyProcess==0.3
+Flask==1.1.2
glob2==0.7
hunter==3.1.3
-hypothesis==5.6.0
+hypothesis==5.16.0
itsdangerous==1.1.0
-jaraco.functools==3.0.0 ; python_version>="3.6"
-# Jinja2==2.11.1
-Mako==1.1.2
+jaraco.functools==3.0.1 ; python_version>="3.6"
+# Jinja2==2.11.2
+Mako==1.1.3
manhole==1.6.0
# MarkupSafe==1.1.1
-more-itertools==8.2.0
-packaging==20.3
+more-itertools==8.3.0
+packaging==20.4
parse==1.15.0
parse-type==0.5.2
pluggy==0.13.1
py==1.8.1
py-cpuinfo==5.0.0
Pygments==2.6.1
-pyparsing==2.4.6
-pytest==5.4.1
-pytest-bdd==3.2.1
+pyparsing==2.4.7
+pytest==5.4.3
+pytest-bdd==3.4.0
pytest-benchmark==3.2.3
-pytest-cov==2.8.1
+pytest-cov==2.9.0
pytest-instafail==0.4.1.post0
-pytest-mock==2.0.0
+pytest-mock==3.1.1
pytest-qt==3.3.0
pytest-repeat==0.8.0
pytest-rerunfailures==9.0
pytest-travis-fold==1.3.0
pytest-xvfb==1.2.0
-PyVirtualDisplay==0.2.5
-six==1.14.0
-sortedcontainers==2.1.0
-soupsieve==2.0
-vulture==1.3
-wcwidth==0.1.8
-Werkzeug==1.0.0
+PyVirtualDisplay==0.2.5 # rq.filter: < 1.0
+six==1.15.0
+sortedcontainers==2.2.2
+soupsieve==2.0.1
+vulture==1.5
+wcwidth==0.2.4
+Werkzeug==1.0.1
jaraco.functools==2.0; python_version<"3.6" # rq.filter: <= 2.0
diff --git a/misc/requirements/requirements-tests.txt-raw b/misc/requirements/requirements-tests.txt-raw
index 1b972ba18..d5a20dea3 100644
--- a/misc/requirements/requirements-tests.txt-raw
+++ b/misc/requirements/requirements-tests.txt-raw
@@ -2,7 +2,6 @@ beautifulsoup4
cheroot
coverage
Flask
-hunter
hypothesis
pytest
pytest-bdd
@@ -11,13 +10,24 @@ pytest-cov
pytest-instafail
pytest-mock
pytest-qt
-pytest-repeat
pytest-rerunfailures
-pytest-travis-fold
pytest-xvfb
+# https://github.com/The-Compiler/pytest-xvfb/issues/22
+PyVirtualDisplay < 1.0
+
+## optional:
+# To test :debug-trace, gets skipped if hunter is not installed
+hunter
+# To test scripts/dev/run_vulture.py which is not part of the release tarball
vulture
+# For colored pytest output (though also a direct qutebrowser dependency))
pygments
+# Output folding on Travis
+pytest-travis-fold
+# --repeat switch (used to manually repeat tests)
+pytest-repeat
#@ markers: jaraco.functools python_version>="3.6"
#@ add: jaraco.functools==2.0; python_version<"3.6" # rq.filter: <= 2.0
#@ ignore: Jinja2, MarkupSafe, colorama
+#@ filter: PyVirtualDisplay < 1.0
diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt
index c4fdcac7f..dd288088d 100644
--- a/misc/requirements/requirements-tox.txt
+++ b/misc/requirements/requirements-tox.txt
@@ -1,15 +1,15 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-appdirs==1.4.3
+appdirs==1.4.4
distlib==0.3.0
filelock==3.0.12
-packaging==20.3
+packaging==20.4
pluggy==0.13.1
py==1.8.1
-pyparsing==2.4.6
-six==1.14.0
-toml==0.10.0
-tox==3.14.5
+pyparsing==2.4.7
+six==1.15.0
+toml==0.10.1
+tox==3.15.2
tox-pip-version==0.0.7
tox-venv==0.4.0
-virtualenv==20.0.10
+virtualenv==20.0.21
diff --git a/misc/requirements/requirements-vulture.txt b/misc/requirements/requirements-vulture.txt
index 501dcd973..32d36560b 100644
--- a/misc/requirements/requirements-vulture.txt
+++ b/misc/requirements/requirements-vulture.txt
@@ -1,3 +1,3 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-vulture==1.3
+vulture==1.5
diff --git a/misc/userscripts/getbib b/misc/userscripts/getbib
index 22af7a8f9..33c516904 100755
--- a/misc/userscripts/getbib
+++ b/misc/userscripts/getbib
@@ -14,7 +14,6 @@ https://ocefpaf.github.io/python4oceanographers/blog/2014/05/19/doi2bibtex/
import os
import sys
-import shutil
import re
from collections import Counter
from urllib import parse as url_parse
diff --git a/misc/userscripts/password_fill b/misc/userscripts/password_fill
index 7d4be0467..4ebeebdc5 100755
--- a/misc/userscripts/password_fill
+++ b/misc/userscripts/password_fill
@@ -358,11 +358,13 @@ cat <<EOF
if (isVisible(input) && (input.type == "text" || input.type == "email")) {
input.focus();
input.value = "$(javascript_escape "${username}")";
+ input.dispatchEvent(new Event('change'));
input.blur();
}
if (input.type == "password") {
input.focus();
input.value = "$(javascript_escape "${password}")";
+ input.dispatchEvent(new Event('change'));
input.blur();
}
}
diff --git a/misc/userscripts/qute-bitwarden b/misc/userscripts/qute-bitwarden
index f6212d35a..d5c4b1e2d 100755
--- a/misc/userscripts/qute-bitwarden
+++ b/misc/userscripts/qute-bitwarden
@@ -27,6 +27,9 @@ USAGE = """The domain of the site has to be in the name of the Bitwarden entry,
"websites/github.com". The login information is inserted by emulating key events using qutebrowser's fake-key command in this manner:
[USERNAME]<Tab>[PASSWORD], which is compatible with almost all login forms.
+If enabled, with the `--totp` flag, it will also move the TOTP code to the
+clipboard, much like the Firefox add-on.
+
You must log into Bitwarden CLI using `bw login` prior to use of this script.
The session key will be stored using keyctl for the number of seconds passed to
the --auto-lock option.
@@ -34,18 +37,17 @@ the --auto-lock option.
To use in qutebrowser, run: `spawn --userscript qute-bitwarden`
"""
-EPILOG = """Dependencies: tldextract (Python 3 module), Bitwarden CLI (1.7.4 is
-known to work but older versions may well also work)
+EPILOG = """Dependencies: tldextract (Python 3 module), pyperclip (optional
+Python module, used for TOTP codes), Bitwarden CLI (1.7.4 is known to work
+but older versions may well also work)
WARNING: The login details are viewable as plaintext in qutebrowser's debug log
(qute://log) and might be shared if you decide to submit a crash report!"""
import argparse
import enum
-import fnmatch
import functools
import os
-import re
import shlex
import subprocess
import sys
@@ -62,6 +64,8 @@ argument_parser.add_argument('--dmenu-invocation', '-d', default='rofi -dmenu -i
help='Invocation used to execute a dmenu-provider')
argument_parser.add_argument('--no-insert-mode', '-n', dest='insert_mode', action='store_false',
help="Don't automatically enter insert mode")
+argument_parser.add_argument('--totp', '-t', action='store_true',
+ help="Copy TOTP key to clipboard")
argument_parser.add_argument('--io-encoding', '-i', default='UTF-8',
help='Encoding used to communicate with subprocesses')
argument_parser.add_argument('--merge-candidates', '-m', action='store_true',
@@ -73,6 +77,8 @@ group.add_argument('--username-only', '-e',
action='store_true', help='Only insert username')
group.add_argument('--password-only', '-w',
action='store_true', help='Only insert password')
+group.add_argument('--totp-only', '-T',
+ action='store_true', help='Only insert totp code')
stderr = functools.partial(print, file=sys.stderr)
@@ -158,6 +164,26 @@ def pass_(domain, encoding, auto_lock):
return out
+def get_totp_code(selection_id, domain_name, encoding, auto_lock):
+ session_key = get_session_key(auto_lock)
+ process = subprocess.run(
+ ['bw', 'get', 'totp', '--session', session_key, selection_id],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+ err = process.stderr.decode(encoding).strip()
+ if err:
+ # domain_name instead of selection_id to make it more user-friendly
+ msg = 'Bitwarden CLI returned for {:s} - {:s}'.format(domain_name, err)
+ stderr(msg)
+ return '[]'
+
+ out = process.stdout.decode(encoding).strip()
+
+ return out
+
+
def dmenu(items, invocation, encoding):
command = shlex.split(invocation)
process = subprocess.run(command, input='\n'.join(
@@ -227,11 +253,22 @@ def main(arguments):
username = selection['login']['username']
password = selection['login']['password']
+ totp = selection['login']['totp']
if arguments.username_only:
fake_key_raw(username)
elif arguments.password_only:
fake_key_raw(password)
+ elif arguments.totp_only:
+ # No point in moving it to the clipboard in this case
+ fake_key_raw(
+ get_totp_code(
+ selection['id'],
+ selection['name'],
+ arguments.io_encoding,
+ arguments.auto_lock
+ )
+ )
else:
# Enter username and password using fake-key and <Tab> (which seems to work almost universally), then switch
# back into insert-mode, so the form can be directly submitted by
@@ -243,6 +280,20 @@ def main(arguments):
if arguments.insert_mode:
qute_command('enter-mode insert')
+ # If it finds a TOTP code, it copies it to the clipboard,
+ # which is the same behaviour as the Firefox add-on.
+ if not arguments.totp_only and totp and arguments.totp:
+ # The import is done here, to make pyperclip an optional dependency
+ import pyperclip
+ pyperclip.copy(
+ get_totp_code(
+ selection['id'],
+ selection['name'],
+ arguments.io_encoding,
+ arguments.auto_lock
+ )
+ )
+
return ExitCodes.SUCCESS
diff --git a/misc/userscripts/qute-lastpass b/misc/userscripts/qute-lastpass
index 6845a4cda..e58f4c817 100755
--- a/misc/userscripts/qute-lastpass
+++ b/misc/userscripts/qute-lastpass
@@ -39,10 +39,8 @@ you decide to submit a crash report!"""
import argparse
import enum
-import fnmatch
import functools
import os
-import re
import shlex
import subprocess
import sys
diff --git a/misc/userscripts/readability-js b/misc/userscripts/readability-js
index efd086ce0..6a1a96c84 100755
--- a/misc/userscripts/readability-js
+++ b/misc/userscripts/readability-js
@@ -46,10 +46,20 @@ const HEADER = `
line-height: 1.2;
}
</style>
+ <!-- This icon is licensed under the Mozilla Public License 2.0 (available at: https://www.mozilla.org/en-US/MPL/2.0/).
+ The original icon can be found here: https://dxr.mozilla.org/mozilla-central/source/browser/themes/shared/reader/readerMode.svg -->
+ <link rel="shortcut icon" href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjRweCIgaGVpZ2h0PSI2NHB4IiB2ZXJzaW9uPSIxLjEiI
+ HZpZXdCb3g9IjAgMCA2NCA2NCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgZmlsbD0iI2ZmZiI+CjxwYXRoIGQ9Im01MiAwaC00
+ MGMtNC40MiAwLTggMy41OC04IDh2NDhjMCA0LjQyIDMuNTggOCA4IDhoNDBjNC40MiAwIDgtMy41OCA4LTh2LTQ4YzAtNC40Mi0zLjU4LTgtOC04em0wIDU
+ yYzAgMi4yMS0xLjc5IDQtNCA0aC0zMmMtMi4yMSAwLTQtMS43OS00LTR2LTQwYzAtMi4yMSAxLjc5LTQgNC00aDMyYzIuMjEgMCA0IDEuNzkgNCA0em0tMT
+ AtMzZoLTIwYy0xLjExIDAtMiAwLjg5NS0yIDJzMC44OTUgMiAyIDJoMjBjMS4xMSAwIDItMC44OTUgMi0ycy0wLjg5NS0yLTItMnptMCA4aC0yMGMtMS4xM
+ SAwLTIgMC44OTUtMiAyczAuODk1IDIgMiAyaDIwYzEuMTEgMCAyLTAuODk1IDItMnMtMC44OTUtMi0yLTJ6bTAgOGgtMjBjLTEuMTEgMC0yIDAuODk1LTIg
+ MnMwLjg5NSAyIDIgMmgyMGMxLjExIDAgMi0wLjg5NSAyLTJzLTAuODk1LTItMi0yem0tMTIgOGgtOGMtMS4xMSAwLTIgMC44OTUtMiAyczAuODk1IDIgMiA
+ yaDhjMS4xMSAwIDItMC44OTUgMi0ycy0wLjg5NS0yLTItMnoiIGZpbGw9IiNmZmYiLz4KPC9nPgo8L3N2Zz4K"/>
</head>`;
const scriptsDir = path.join(process.env.QUTE_DATA_DIR, 'userscripts');
const tmpFile = path.join(scriptsDir, '/readability.html');
-const domOpts = {url: process.env.QUTE_URL, contentType: "text/html; charset=utf-8"}
+const domOpts = {url: process.env.QUTE_URL, contentType: "text/html; charset=utf-8"};
if (!fs.existsSync(scriptsDir)){
fs.mkdirSync(scriptsDir);
@@ -66,6 +76,6 @@ JSDOM.fromFile(process.env.QUTE_HTML, domOpts).then(dom => {
return 1;
}
// Success
- qute.open(['-t', tmpFile]);
+ qute.open(['-t', '-r', tmpFile]);
})
});
diff --git a/misc/userscripts/tor_identity b/misc/userscripts/tor_identity
index 93b6d4136..1631a0b94 100755
--- a/misc/userscripts/tor_identity
+++ b/misc/userscripts/tor_identity
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-# Copyright 2018 jnphilipp <mail@jnphilipp.org>
+# Copyright 2018-2020 J. Nathanael Philipp (jnphilipp) <nathanael@philipp.land>
#
# This file is part of qutebrowser.
#
@@ -30,6 +30,8 @@
import os
import sys
+from argparse import ArgumentParser
+
try:
from stem import Signal
from stem.control import Controller
@@ -41,12 +43,19 @@ except ImportError:
print('Failed to import stem.')
-password = sys.argv[1]
-with Controller.from_port(port=9051) as controller:
- controller.authenticate(password)
- controller.signal(Signal.NEWNYM)
- if os.getenv('QUTE_FIFO'):
- with open(os.environ['QUTE_FIFO'], 'w') as f:
- f.write('message-info "Tor identity changed."')
- else:
- print('Tor identity changed.')
+if __name__ == '__main__':
+ parser = ArgumentParser(prog='tor_identity')
+ parser.add_argument('-c', '--control-port', default=9051,
+ help='Tor control port (default 9051).')
+ parser.add_argument('-p', '--password', type=str, default=None,
+ help='Tor control port password.')
+ args = parser.parse_args()
+
+ with Controller.from_port(port=args.control_port) as controller:
+ controller.authenticate(args.password)
+ controller.signal(Signal.NEWNYM)
+ if os.getenv('QUTE_FIFO'):
+ with open(os.environ['QUTE_FIFO'], 'w') as f:
+ f.write('message-info "Tor identity changed."')
+ else:
+ print('Tor identity changed.')
diff --git a/pytest.ini b/pytest.ini
index be751d793..8c6b7853b 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -30,6 +30,7 @@ markers =
fake_os: Fake utils.is_* to a fake operating system
unicode_locale: Tests which need an unicode locale to work
qtwebkit6021_xfail: Tests which would fail on WebKit version 602.1
+ js_headers: Sets JS headers dynamically on QtWebEngine (unsupported on some versions)
qt_log_level_fail = WARNING
qt_log_ignore =
^SpellCheck: .*
@@ -71,5 +72,5 @@ xfail_strict = true
filterwarnings =
error
# See https://github.com/HypothesisWorks/hypothesis/issues/2370
- ignore:.*, but function-scoped fixtures:hypothesis.errors.HypothesisDeprecationWarning
+ ignore:.*which is reset between function calls but not between test cases generated by:hypothesis.errors.HypothesisDeprecationWarning
faulthandler_timeout = 90
diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py
index cb50504af..147606f42 100644
--- a/qutebrowser/__init__.py
+++ b/qutebrowser/__init__.py
@@ -26,7 +26,7 @@ __copyright__ = "Copyright 2014-2020 Florian Bruhin (The Compiler)"
__license__ = "GPL"
__maintainer__ = __author__
__email__ = "mail@qutebrowser.org"
-__version__ = "1.10.1"
+__version__ = "1.12.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 1498cc051..5d74991c1 100644
--- a/qutebrowser/api/cmdutils.py
+++ b/qutebrowser/api/cmdutils.py
@@ -208,12 +208,12 @@ class argument: # noqa: N801,N806 pylint: disable=invalid-name
raise ValueError("{} has no argument {}!".format(funcname,
self._argname))
if not hasattr(func, 'qute_args'):
- func.qute_args = {} # type: ignore
- elif func.qute_args is None: # type: ignore
+ func.qute_args = {} # type: ignore[attr-defined]
+ elif func.qute_args is None: # type: ignore[attr-defined]
raise ValueError("@cmdutils.argument got called above (after) "
"@cmdutils.register for {}!".format(funcname))
arginfo = command.ArgInfo(**self._kwargs)
- func.qute_args[self._argname] = arginfo # type: ignore
+ func.qute_args[self._argname] = arginfo # type: ignore[attr-defined]
return func
diff --git a/qutebrowser/app.py b/qutebrowser/app.py
index 445593706..c90de481e 100644
--- a/qutebrowser/app.py
+++ b/qutebrowser/app.py
@@ -96,7 +96,7 @@ def run(args):
q_app.setApplicationVersion(qutebrowser.__version__)
if args.version:
- print(version.version())
+ print(version.version_info())
sys.exit(usertypes.Exit.ok)
quitter.init(args)
@@ -159,13 +159,13 @@ def init(*, args: argparse.Namespace) -> None:
eventfilter.init()
log.init.debug("Connecting signals...")
- q_app.focusChanged.connect(on_focus_changed) # type: ignore
+ q_app.focusChanged.connect(on_focus_changed)
_process_args(args)
for scheme in ['http', 'https', 'qute']:
QDesktopServices.setUrlHandler(
- scheme, open_desktopservices_url) # type: ignore
+ scheme, open_desktopservices_url)
log.init.debug("Init done!")
crashsignal.crash_handler.raise_crashdlg()
@@ -173,7 +173,6 @@ def init(*, args: argparse.Namespace) -> None:
def _init_icon():
"""Initialize the icon of qutebrowser."""
- icon = QIcon()
fallback_icon = QIcon()
for size in [16, 24, 32, 48, 64, 96, 128, 256, 512]:
filename = ':/icons/qutebrowser-{size}x{size}.png'.format(size=size)
@@ -334,8 +333,12 @@ def _open_special_pages(args):
'qute://warning/webkit'),
('old-qt-warning-shown',
- not qtutils.version_check('5.9'),
+ not qtutils.version_check('5.11'),
'qute://warning/old-qt'),
+
+ ('session-warning-shown',
+ qtutils.version_check('5.15', compiled=False),
+ 'qute://warning/sessions'),
]
for state, condition, url in pages:
@@ -370,12 +373,23 @@ def open_desktopservices_url(url):
tabbed_browser.tabopen(url)
+# This is effectively a @config.change_filter
+# Howerver, logging is initialized too early to use that annotation
+def _on_config_changed(name: str) -> None:
+ if name.startswith('logging.'):
+ log.init_from_config(config.val)
+
+
def _init_modules(*, args):
"""Initialize all 'modules' which need to be initialized.
Args:
args: The argparse namespace.
"""
+ log.init.debug("Initializing logging from config...")
+ log.init_from_config(config.val)
+ config.instance.changed.connect(_on_config_changed)
+
log.init.debug("Initializing save manager...")
save_manager = savemanager.SaveManager(q_app)
objreg.register('save-manager', save_manager)
@@ -471,7 +485,9 @@ class Application(QApplication):
self._last_focus_object = None
qt_args = configinit.qt_args(args)
- log.init.debug("Qt arguments: {}, based on {}".format(qt_args, args))
+ log.init.debug("Commandline args: {}".format(sys.argv[1:]))
+ log.init.debug("Parsed: {}".format(args))
+ log.init.debug("Qt arguments: {}".format(qt_args[1:]))
super().__init__(qt_args)
objects.args = args
@@ -479,7 +495,7 @@ class Application(QApplication):
log.init.debug("Initializing application...")
self.launch_time = datetime.datetime.now()
- self.focusObjectChanged.connect( # type: ignore
+ self.focusObjectChanged.connect( # type: ignore[attr-defined]
self.on_focus_object_changed)
self.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index 5a0f8cc27..b42ee1dac 100644
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -21,8 +21,8 @@
import enum
import itertools
-import typing
import functools
+import typing
import attr
from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QUrl, QObject, QSizeF, Qt,
@@ -71,7 +71,7 @@ def create(win_id: int,
mode_manager = modeman.instance(win_id)
if objects.backend == usertypes.Backend.QtWebEngine:
from qutebrowser.browser.webengine import webenginetab
- tab_class = webenginetab.WebEngineTab
+ tab_class = webenginetab.WebEngineTab # type: typing.Type[AbstractTab]
else:
from qutebrowser.browser.webkit import webkittab
tab_class = webkittab.WebKitTab
@@ -317,6 +317,7 @@ class AbstractSearch(QObject):
def search(self, text: str, *,
ignore_case: usertypes.IgnoreCase = usertypes.IgnoreCase.never,
reverse: bool = False,
+ wrap: bool = True,
result_cb: _Callback = None) -> None:
"""Find the given text on the page.
@@ -324,6 +325,7 @@ class AbstractSearch(QObject):
text: The text to search for.
ignore_case: Search case-insensitively.
reverse: Reverse search direction.
+ wrap: Allow wrapping at the top or bottom of the page.
result_cb: Called with a bool indicating whether a match was found.
"""
raise NotImplementedError
@@ -425,27 +427,36 @@ class AbstractZoom(QObject):
self._set_factor_internal(self._zoom_factor)
+class SelectionState(enum.Enum):
+
+ """Possible states of selection in caret mode.
+
+ NOTE: Names need to line up with SelectionState in caret.js!
+ """
+
+ none = 1
+ normal = 2
+ line = 3
+
+
class AbstractCaret(QObject):
"""Attribute ``caret`` of AbstractTab for caret browsing."""
#: Signal emitted when the selection was toggled.
- #: (argument - whether the selection is now active)
- selection_toggled = pyqtSignal(bool)
+ selection_toggled = pyqtSignal(SelectionState)
#: Emitted when a ``follow_selection`` action is done.
follow_selected_done = pyqtSignal()
def __init__(self,
- tab: 'AbstractTab',
mode_manager: modeman.ModeManager,
parent: QWidget = None) -> None:
super().__init__(parent)
- self._tab = tab
self._widget = typing.cast(QWidget, None)
- self.selection_enabled = False
self._mode_manager = mode_manager
mode_manager.entered.connect(self._on_mode_entered)
mode_manager.left.connect(self._on_mode_left)
+ # self._tab is set by subclasses so mypy knows its concrete type.
def _on_mode_entered(self, mode: usertypes.KeyMode) -> None:
raise NotImplementedError
@@ -498,7 +509,7 @@ class AbstractCaret(QObject):
def move_to_end_of_document(self) -> None:
raise NotImplementedError
- def toggle_selection(self) -> None:
+ def toggle_selection(self, line: bool = False) -> None:
raise NotImplementedError
def drop_selection(self) -> None:
@@ -540,7 +551,7 @@ class AbstractScroller(QObject):
@pyqtSlot()
def _log_scroll_pos_change(self) -> None:
- log.webview.vdebug( # type: ignore
+ log.webview.vdebug( # type: ignore[attr-defined]
"Scroll position changed to {}".format(self.pos_px()))
def _init_widget(self, widget: QWidget) -> None:
@@ -687,9 +698,9 @@ class AbstractElements:
[typing.Optional['webelem.AbstractWebElement']], None]
_ErrorCallback = typing.Callable[[Exception], None]
- def __init__(self, tab: 'AbstractTab') -> None:
+ def __init__(self) -> None:
self._widget = typing.cast(QWidget, None)
- self._tab = tab
+ # self._tab is set by subclasses so mypy knows its concrete type.
def find_css(self, selector: str,
callback: _MultiCallback,
@@ -824,6 +835,15 @@ class AbstractTabPrivate:
def shutdown(self) -> None:
raise NotImplementedError
+ def run_js_sync(self, code: str) -> None:
+ """Run javascript sync.
+
+ Result will be returned when running JS is complete.
+ This is only implemented for QtWebKit.
+ For QtWebEngine, always raises UnsupportedOperationError.
+ """
+ raise NotImplementedError
+
class AbstractTab(QWidget):
@@ -867,8 +887,18 @@ class AbstractTab(QWidget):
# arg 1: The exit code.
renderer_process_terminated = pyqtSignal(TerminationStatus, int)
- def __init__(self, *, win_id: int, private: bool,
+ # Hosts for which a certificate error happened. Shared between all tabs.
+ #
+ # Note that we remember hosts here, without scheme/port:
+ # QtWebEngine/Chromium also only remembers hostnames, and certificates are
+ # for a given hostname anyways.
+ _insecure_hosts = set() # type: typing.Set[str]
+
+ def __init__(self, *, win_id: int,
+ mode_manager: modeman.ModeManager,
+ private: bool,
parent: QWidget = None) -> None:
+ utils.unused(mode_manager) # needed for mypy
self.is_private = private
self.win_id = win_id
self.tab_id = next(tab_id_gen)
@@ -884,7 +914,6 @@ class AbstractTab(QWidget):
self._layout = miscwidgets.WrapperLayout(self)
self._widget = typing.cast(QWidget, None)
self._progress = 0
- self._has_ssl_errors = False
self._load_status = usertypes.LoadStatus.none
self._tab_event_filter = eventfilter.TabEventFilter(
self, parent=self)
@@ -946,7 +975,7 @@ class AbstractTab(QWidget):
log.webview.warning("Unable to find event target!")
return
- evt.posted = True
+ evt.posted = True # type: ignore[attr-defined]
QApplication.postEvent(recipient, evt)
def navigation_blocked(self) -> bool:
@@ -971,7 +1000,6 @@ class AbstractTab(QWidget):
@pyqtSlot()
def _on_load_started(self) -> None:
self._progress = 0
- self._has_ssl_errors = False
self.data.viewing_source = False
self._set_load_status(usertypes.LoadStatus.loading)
self.load_started.emit()
@@ -1030,15 +1058,19 @@ class AbstractTab(QWidget):
Needs to be called by subclasses to trigger a load status update, e.g.
as a response to a loadFinished signal.
"""
- if ok and not self._has_ssl_errors:
- if self.url().scheme() == 'https':
- self._set_load_status(usertypes.LoadStatus.success_https)
- else:
- self._set_load_status(usertypes.LoadStatus.success)
- elif ok:
- self._set_load_status(usertypes.LoadStatus.warn)
+ url = self.url()
+ is_https = url.scheme() == 'https'
+
+ if not ok:
+ loadstatus = usertypes.LoadStatus.error
+ elif is_https and url.host() in self._insecure_hosts:
+ loadstatus = usertypes.LoadStatus.warn
+ elif is_https:
+ loadstatus = usertypes.LoadStatus.success_https
else:
- self._set_load_status(usertypes.LoadStatus.error)
+ loadstatus = usertypes.LoadStatus.success
+
+ self._set_load_status(loadstatus)
@pyqtSlot()
def _on_history_trigger(self) -> None:
@@ -1126,7 +1158,8 @@ class AbstractTab(QWidget):
def __repr__(self) -> str:
try:
qurl = self.url()
- url = qurl.toDisplayString(QUrl.EncodeUnicode) # type: ignore
+ url = qurl.toDisplayString(
+ QUrl.EncodeUnicode) # type: ignore[arg-type]
except (AttributeError, RuntimeError) as exc:
url = '<{}>'.format(exc.__class__.__name__)
else:
diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py
index cf79672ca..78ed6c383 100644
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -157,7 +157,7 @@ class CommandDispatcher:
else:
return None
- def _tab_focus_stack(self, mode: str, *, show_error=True):
+ def _tab_focus_stack(self, mode: str, *, show_error: bool = True) -> None:
"""Select the tab which was last focused."""
tab_deque = self._tabbed_browser.tab_deque
cur_tab = self._cntwidget()
@@ -308,8 +308,9 @@ class CommandDispatcher:
urls = self._parse_url_input(url)
for i, cur_url in enumerate(urls):
- if secure:
+ if secure and cur_url.scheme() == 'http':
cur_url.setScheme('https')
+
if not window and i > 0:
tab = False
bg = True
@@ -452,7 +453,7 @@ class CommandDispatcher:
@cmdutils.argument('win_id', completion=miscmodels.window)
@cmdutils.argument('count', value=cmdutils.Value.count)
def tab_give(self, win_id: int = None, keep: bool = False,
- count: int = None) -> None:
+ count: int = None, private: bool = False) -> None:
"""Give the current tab to a new or existing window if win_id given.
If no win_id is given, the tab will get detached into a new window.
@@ -461,6 +462,7 @@ class CommandDispatcher:
win_id: The window ID of the window to give the current tab to.
keep: If given, keep the old tab around.
count: Overrides win_id (index starts at 1 for win_id=0).
+ private: If the tab should be detached into a private instance.
"""
if config.val.tabs.tabs_are_windows:
raise cmdutils.CommandError("Can't give tabs when using "
@@ -478,7 +480,7 @@ class CommandDispatcher:
"only one tab")
tabbed_browser = self._new_tabbed_browser(
- private=self._tabbed_browser.is_private)
+ private=private or self._tabbed_browser.is_private)
else:
if win_id not in objreg.window_registry:
raise cmdutils.CommandError(
@@ -487,6 +489,10 @@ class CommandDispatcher:
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=win_id)
+ if private and not tabbed_browser.is_private:
+ raise cmdutils.CommandError(
+ "The window with id {} is not private".format(win_id))
+
tabbed_browser.tabopen(self._current_url())
if not keep:
self._tabbed_browser.close_tab(self._current_widget(),
@@ -645,12 +651,13 @@ class CommandDispatcher:
def _yank_url(self, what):
"""Helper method for yank() to get the URL to copy."""
- assert what in ['url', 'pretty-url', 'markdown'], what
- flags = QUrl.RemovePassword
+ assert what in ['url', 'pretty-url'], what
+
if what == 'pretty-url':
- flags |= QUrl.DecodeReserved # type: ignore
+ flags = QUrl.RemovePassword | QUrl.DecodeReserved
else:
- flags |= QUrl.FullyEncoded # type: ignore
+ flags = QUrl.RemovePassword | QUrl.FullyEncoded
+
url = QUrl(self._current_url())
url_query = QUrlQuery()
url_query_str = urlutils.query_string(url)
@@ -661,12 +668,11 @@ class CommandDispatcher:
if key in config.val.url.yank_ignored_parameters:
url_query.removeQueryItem(key)
url.setQuery(url_query)
- return url.toString(flags) # type: ignore
+ return url.toString(flags) # type: ignore[arg-type]
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('what', choices=['selection', 'url', 'pretty-url',
- 'title', 'domain', 'markdown',
- 'inline'])
+ 'title', 'domain', 'inline'])
def yank(self, what='url', inline=None,
sel=False, keep=False, quiet=False):
"""Yank (copy) something to the clipboard or primary selection.
@@ -679,8 +685,6 @@ class CommandDispatcher:
- `title`: The current page's title.
- `domain`: The current scheme, domain, and port number.
- `selection`: The selection under the cursor.
- - `markdown`: Yank title and URL in markdown format
- (deprecated, use `:yank inline [{title}]({url})` instead).
- `inline`: Yank the text contained in the 'inline' argument.
sel: Use the primary selection instead of the clipboard.
@@ -712,14 +716,6 @@ class CommandDispatcher:
caret = self._current_widget().caret
caret.selection(callback=_selection_callback)
return
- elif what == 'markdown':
- message.warning(":yank markdown is deprecated, use `:yank inline "
- "[{title}]({url})` instead.")
- idx = self._current_index()
- title = self._tabbed_browser.widget.page_title(idx)
- url = self._yank_url(what)
- s = '[{}]({})'.format(title, url)
- what = 'markdown URL' # For printing
else: # pragma: no cover
raise ValueError("Invalid value {!r} for `what'.".format(what))
@@ -909,7 +905,8 @@ class CommandDispatcher:
tabbed_browser.widget.setCurrentWidget(tab)
@cmdutils.register(instance='command-dispatcher', scope='window')
- @cmdutils.argument('index', choices=['last', 'stack-next', 'stack-prev'])
+ @cmdutils.argument('index', choices=['last', 'stack-next', 'stack-prev'],
+ completion=miscmodels.tab_focus)
@cmdutils.argument('count', value=cmdutils.Value.count)
def tab_focus(self, index: typing.Union[str, int] = None,
count: int = None, no_last: bool = False) -> None:
@@ -1046,7 +1043,8 @@ class CommandDispatcher:
if userscript:
def _selection_callback(s):
try:
- runner = self._run_userscript(s, cmd, args, verbose, count)
+ runner = self._run_userscript(
+ s, cmd, args, verbose, output_messages, count)
runner.finished.connect(_on_proc_finished)
except cmdutils.CommandError as e:
message.error(str(e))
@@ -1072,13 +1070,15 @@ class CommandDispatcher:
proc.start(cmd, args)
proc.finished.connect(_on_proc_finished)
- def _run_userscript(self, selection, cmd, args, verbose, count):
+ def _run_userscript(self, selection, cmd, args, verbose, output_messages,
+ count):
"""Run a userscript given as argument.
Args:
cmd: The userscript to run.
args: Arguments to pass to the userscript.
verbose: Show notifications when the command started/exited.
+ output_messages: Show the output as messages.
count: Exposed to the userscript.
"""
env = {
@@ -1105,7 +1105,8 @@ class CommandDispatcher:
try:
runner = userscripts.run_async(
- tab, cmd, *args, win_id=self._win_id, env=env, verbose=verbose)
+ tab, cmd, *args, win_id=self._win_id, env=env, verbose=verbose,
+ output_messages=output_messages)
except userscripts.Error as e:
raise cmdutils.CommandError(e)
return runner
@@ -1383,24 +1384,40 @@ class CommandDispatcher:
self._open(url, tab, bg, window)
@cmdutils.register(instance='command-dispatcher', scope='window')
- def messages(self, level='info', plain=False, tab=False, bg=False,
- window=False):
+ @cmdutils.argument('logfilter', flag='f')
+ def messages(self, level='info', *, plain=False, tab=False, bg=False,
+ window=False, logfilter=None):
"""Show a log of past messages.
Args:
level: Include messages with `level` or higher severity.
Valid values: vdebug, debug, info, warning, error, critical.
plain: Whether to show plaintext (as opposed to html).
+ logfilter: A comma-separated filter string of logging categories.
+ If the filter string starts with an exclamation mark, it
+ is negated.
tab: Open in a new tab.
bg: Open in a background tab.
window: Open in a new window.
"""
if level.upper() not in log.LOG_LEVELS:
raise cmdutils.CommandError("Invalid log level {}!".format(level))
+
+ query = QUrlQuery()
+ query.addQueryItem('level', level)
if plain:
- url = QUrl('qute://plainlog?level={}'.format(level))
- else:
- url = QUrl('qute://log?level={}'.format(level))
+ query.addQueryItem('plain', typing.cast(str, None))
+
+ if logfilter:
+ try:
+ log.LogFilter.parse(logfilter)
+ except log.InvalidLogFilterError as e:
+ raise cmdutils.CommandError(e)
+ query.addQueryItem('logfilter', logfilter)
+
+ url = QUrl('qute://log')
+ url.setQuery(query)
+
self._open(url, tab, bg, window)
def _open_editor_cb(self, elem):
@@ -1503,6 +1520,7 @@ class CommandDispatcher:
options = {
'ignore_case': config.val.search.ignore_case,
'reverse': reverse,
+ 'wrap': config.val.search.wrap,
}
self._tabbed_browser.search_text = text
diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py
index d2991c40e..54445f011 100644
--- a/qutebrowser/browser/downloads.py
+++ b/qutebrowser/browser/downloads.py
@@ -860,7 +860,7 @@ class AbstractDownloadManager(QObject):
self.data_changed.emit(-1)
@pyqtSlot(str, QUrl)
- def _on_pdfjs_requested(self, filename: str, original_url: QUrl):
+ def _on_pdfjs_requested(self, filename: str, original_url: QUrl) -> None:
"""Open PDF.js when a download requests it."""
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window='last-focused')
diff --git a/qutebrowser/browser/downloadview.py b/qutebrowser/browser/downloadview.py
index b16b44218..66416030d 100644
--- a/qutebrowser/browser/downloadview.py
+++ b/qutebrowser/browser/downloadview.py
@@ -105,7 +105,7 @@ class DownloadView(QListView):
def __repr__(self):
model = self.model()
if model is None:
- count = 'None' # type: ignore
+ count = 'None' # type: ignore[unreachable]
else:
count = model.rowCount()
return utils.get_repr(self, count=count)
@@ -132,7 +132,10 @@ class DownloadView(QListView):
item.open_file()
item.remove()
- def _get_menu_actions(self, item) -> _ActionListType:
+ def _get_menu_actions(
+ self,
+ item: downloads.AbstractDownloadItem
+ ) -> _ActionListType:
"""Get the available context menu actions for a given DownloadItem.
Args:
diff --git a/qutebrowser/browser/eventfilter.py b/qutebrowser/browser/eventfilter.py
index cb38cfbc6..9e93fd13f 100644
--- a/qutebrowser/browser/eventfilter.py
+++ b/qutebrowser/browser/eventfilter.py
@@ -108,8 +108,15 @@ class TabEventFilter(QObject):
self._check_insertmode_on_release = False
def _handle_mouse_press(self, e):
- """Handle pressing of a mouse button."""
- is_rocker_gesture = (config.val.input.rocker_gestures and
+ """Handle pressing of a mouse button.
+
+ Args:
+ e: The QMouseEvent.
+
+ Return:
+ True if the event should be filtered, False otherwise.
+ """
+ is_rocker_gesture = (config.val.input.mouse.rocker_gestures and
e.buttons() == Qt.LeftButton | Qt.RightButton)
if e.button() in [Qt.XButton1, Qt.XButton2] or is_rocker_gesture:
@@ -129,7 +136,14 @@ class TabEventFilter(QObject):
return False
def _handle_mouse_release(self, _e):
- """Handle releasing of a mouse button."""
+ """Handle releasing of a mouse button.
+
+ Args:
+ e: The QMouseEvent.
+
+ Return:
+ True if the event should be filtered, False otherwise.
+ """
# We want to make sure we check the focus element after the WebView is
# updated completely.
QTimer.singleShot(0, self._mouserelease_insertmode)
@@ -140,27 +154,39 @@ class TabEventFilter(QObject):
Args:
e: The QWheelEvent.
+
+ Return:
+ True if the event should be filtered, False otherwise.
"""
if self._ignore_wheel_event:
# See https://github.com/qutebrowser/qutebrowser/issues/395
self._ignore_wheel_event = False
return True
- if e.modifiers() & Qt.ControlModifier:
- mode = modeman.instance(self._tab.win_id).mode
+ # Don't allow scrolling while hinting
+ mode = modeman.instance(self._tab.win_id).mode
+ if mode == usertypes.KeyMode.hint:
+ return True
+
+ elif e.modifiers() & Qt.ControlModifier:
if mode == usertypes.KeyMode.passthrough:
return False
divider = config.val.zoom.mouse_divider
if divider == 0:
- return False
+ # Disable mouse zooming
+ return True
+
factor = self._tab.zoom.factor() + (e.angleDelta().y() / divider)
if factor < 0:
- return False
+ return True
+
perc = int(100 * factor)
message.info("Zoom level: {}%".format(perc), replace=True)
self._tab.zoom.set_factor(factor)
- elif e.modifiers() & Qt.ShiftModifier:
+ return True
+ elif (e.modifiers() & Qt.ShiftModifier and
+ not qtutils.version_check('5.9', compiled=False)):
if e.angleDelta().y() > 0:
self._tab.scroller.left()
else:
@@ -170,16 +196,30 @@ class TabEventFilter(QObject):
return False
def _handle_context_menu(self, _e):
- """Suppress context menus if rocker gestures are turned on."""
- return config.val.input.rocker_gestures
+ """Suppress context menus if rocker gestures are turned on.
+
+ Args:
+ e: The QContextMenuEvent.
+
+ Return:
+ True if the event should be filtered, False otherwise.
+ """
+ return config.val.input.mouse.rocker_gestures
def _handle_key_release(self, e):
"""Ignore repeated key release events going to the website.
WORKAROUND for https://bugreports.qt.io/browse/QTBUG-77208
+
+ Args:
+ e: The QKeyEvent.
+
+ Return:
+ True if the event should be filtered, False otherwise.
"""
return (e.isAutoRepeat() and
- qtutils.version_check('5.10') and
+ qtutils.version_check('5.10', compiled=False) and
+ not qtutils.version_check('5.14', compiled=False) and
objects.backend == usertypes.Backend.QtWebEngine)
def _mousepress_insertmode_cb(self, elem):
@@ -232,7 +272,15 @@ class TabEventFilter(QObject):
Args:
e: The QMouseEvent.
+
+ Return:
+ True if the event should be filtered, False otherwise.
"""
+ if (not config.val.input.mouse.back_forward_buttons and
+ e.button() in [Qt.XButton1, Qt.XButton2]):
+ # Back and forward on mice are disabled
+ return
+
if e.button() in [Qt.XButton1, Qt.LeftButton]:
# Back button on mice which have it, or rocker gesture
if self._tab.history.can_go_back():
@@ -247,7 +295,11 @@ class TabEventFilter(QObject):
message.error("At end of history.")
def eventFilter(self, obj, event):
- """Filter events going to a QWeb(Engine)View."""
+ """Filter events going to a QWeb(Engine)View.
+
+ Return:
+ True if the event should be filtered, False otherwise.
+ """
evtype = event.type()
if evtype not in self._handlers:
return False
diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py
index 08a850fc8..ba4aaac51 100644
--- a/qutebrowser/browser/hints.py
+++ b/qutebrowser/browser/hints.py
@@ -235,7 +235,7 @@ class HintActions:
flags = QUrl.FullyEncoded | QUrl.RemovePassword
if url.scheme() == 'mailto':
flags |= QUrl.RemoveScheme
- urlstr = url.toString(flags) # type: ignore
+ urlstr = url.toString(flags) # type: ignore[arg-type]
new_content = urlstr
@@ -256,14 +256,15 @@ class HintActions:
def run_cmd(self, url: QUrl, context: HintContext) -> None:
"""Run the command based on a hint URL."""
- urlstr = url.toString(QUrl.FullyEncoded) # type: ignore
+ urlstr = url.toString(QUrl.FullyEncoded) # type: ignore[arg-type]
args = context.get_args(urlstr)
commandrunner = runners.CommandRunner(self._win_id)
commandrunner.run_safely(' '.join(args))
def preset_cmd_text(self, url: QUrl, context: HintContext) -> None:
"""Preset a commandline text based on a hint URL."""
- urlstr = url.toDisplayString(QUrl.FullyEncoded) # type: ignore
+ flags = QUrl.FullyEncoded
+ urlstr = url.toDisplayString(flags) # type: ignore[arg-type]
args = context.get_args(urlstr)
text = ' '.join(args)
if text[0] not in modeparsers.STARTCHARS:
@@ -308,7 +309,8 @@ class HintActions:
}
url = elem.resolve_url(context.baseurl)
if url is not None:
- env['QUTE_URL'] = url.toString(QUrl.FullyEncoded) # type: ignore
+ flags = QUrl.FullyEncoded
+ env['QUTE_URL'] = url.toString(flags) # type: ignore[arg-type]
try:
userscripts.run_async(context.tab, cmd, *args, win_id=self._win_id,
@@ -328,7 +330,7 @@ class HintActions:
context: The HintContext to use.
"""
urlstr = url.toString(
- QUrl.FullyEncoded | QUrl.RemovePassword) # type: ignore
+ QUrl.FullyEncoded | QUrl.RemovePassword) # type: ignore[arg-type]
args = context.get_args(urlstr)
commandrunner = runners.CommandRunner(self._win_id)
commandrunner.run_safely('spawn ' + ' '.join(args))
@@ -893,7 +895,7 @@ class HintManager(QObject):
if self._context.hint_mode == 'number':
# renumber filtered hints
- strings = self._hint_strings(visible)
+ strings = self._hint_strings([label.elem for label in visible])
self._context.labels = {}
for label, string in zip(visible, strings):
label.update_text('', string)
diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py
index d5165d364..cf0c1a59a 100644
--- a/qutebrowser/browser/history.py
+++ b/qutebrowser/browser/history.py
@@ -57,7 +57,7 @@ class HistoryProgress:
self._progress.setMinimumDuration(500)
self._progress.setLabelText(text)
self._progress.setMaximum(maximum)
- self._progress.setCancelButton(None) # type: ignore
+ self._progress.setCancelButton(None)
self._progress.show()
QApplication.processEvents()
diff --git a/qutebrowser/browser/inspector.py b/qutebrowser/browser/inspector.py
index 02e416f41..e7c8e2a7f 100644
--- a/qutebrowser/browser/inspector.py
+++ b/qutebrowser/browser/inspector.py
@@ -83,7 +83,7 @@ class AbstractWebInspector(QWidget):
def closeEvent(self, e):
"""Save the geometry when closed."""
- data = bytes(self.saveGeometry())
+ data = self.saveGeometry().data()
geom = base64.b64encode(data).decode('ASCII')
configfiles.state['geometry']['inspector'] = geom
diff --git a/qutebrowser/browser/navigate.py b/qutebrowser/browser/navigate.py
index db467aa56..11be02c67 100644
--- a/qutebrowser/browser/navigate.py
+++ b/qutebrowser/browser/navigate.py
@@ -162,8 +162,8 @@ def _find_prevnext(prev, elems):
# pylint: disable=bad-config-option
for regex in getattr(config.val.hints, option):
# pylint: enable=bad-config-option
- log.hints.vdebug("== Checking regex '{}'." # type: ignore
- .format(regex.pattern))
+ log.hints.vdebug( # type: ignore[attr-defined]
+ "== Checking regex '{}'.".format(regex.pattern))
for e in elems:
text = str(e)
if not text:
@@ -173,8 +173,8 @@ def _find_prevnext(prev, elems):
regex.pattern, text))
return e
else:
- log.hints.vdebug("No match on '{}'!" # type: ignore
- .format(text))
+ log.hints.vdebug( # type: ignore[attr-defined]
+ "No match on '{}'!".format(text))
return None
diff --git a/qutebrowser/browser/network/pac.py b/qutebrowser/browser/network/pac.py
index e330abf77..6ae01c7d8 100644
--- a/qutebrowser/browser/network/pac.py
+++ b/qutebrowser/browser/network/pac.py
@@ -65,7 +65,9 @@ def _js_slot(*args):
# pylint: disable=protected-access
return self._error_con.callAsConstructor([e])
# pylint: enable=protected-access
- return pyqtSlot(*args, result=QJSValue)(new_method)
+
+ deco = pyqtSlot(*args, result=QJSValue) # type: ignore[arg-type]
+ return deco(new_method)
return _decorator
@@ -215,10 +217,10 @@ class PACResolver:
if from_file:
string_flags = QUrl.PrettyDecoded
else:
- string_flags = QUrl.RemoveUserInfo # type: ignore
+ string_flags = QUrl.RemoveUserInfo # type: ignore[assignment]
if query.url().scheme() == 'https':
- string_flags |= QUrl.RemovePath # type: ignore
- string_flags |= QUrl.RemoveQuery # type: ignore
+ string_flags |= QUrl.RemovePath # type: ignore[assignment]
+ string_flags |= QUrl.RemoveQuery # type: ignore[assignment]
result = self._resolver.call([query.url().toString(string_flags),
query.peerHostName()])
@@ -266,7 +268,8 @@ class PACFetcher(QObject):
"""Fetch the proxy from the remote URL."""
assert self._manager is not None
self._reply = self._manager.get(QNetworkRequest(self._pac_url))
- self._reply.finished.connect(self._finish) # type: ignore
+ self._reply.finished.connect( # type: ignore[attr-defined]
+ self._finish)
@pyqtSlot()
def _finish(self):
diff --git a/qutebrowser/browser/network/proxy.py b/qutebrowser/browser/network/proxy.py
index 6bfd74d36..18d2f060b 100644
--- a/qutebrowser/browser/network/proxy.py
+++ b/qutebrowser/browser/network/proxy.py
@@ -19,8 +19,6 @@
"""Handling of proxies."""
-import typing
-
from PyQt5.QtCore import QUrl, pyqtSlot
from PyQt5.QtNetwork import QNetworkProxy, QNetworkProxyFactory
@@ -54,7 +52,8 @@ def _warn_for_pac():
@pyqtSlot()
def shutdown():
- QNetworkProxyFactory.setApplicationProxyFactory(None) # type: ignore
+ QNetworkProxyFactory.setApplicationProxyFactory(
+ None) # type: ignore[arg-type]
class ProxyFactory(QNetworkProxyFactory):
@@ -73,6 +72,18 @@ class ProxyFactory(QNetworkProxyFactory):
else:
return None
+ def _set_capabilities(self, proxy):
+ if proxy.type() == QNetworkProxy.NoProxy:
+ return
+
+ capabilities = proxy.capabilities()
+ lookup_cap = QNetworkProxy.HostNameLookupCapability
+ if config.val.content.proxy_dns_requests:
+ capabilities |= lookup_cap
+ else:
+ capabilities &= ~lookup_cap
+ proxy.setCapabilities(capabilities)
+
def queryProxy(self, query):
"""Get the QNetworkProxies for a query.
@@ -91,18 +102,13 @@ class ProxyFactory(QNetworkProxyFactory):
elif isinstance(proxy, pac.PACFetcher):
if objects.backend == usertypes.Backend.QtWebEngine:
# Looks like query.url() is always invalid on QtWebEngine...
- proxies = [urlutils.proxy_from_url(QUrl('direct://'))]
+ proxy = urlutils.proxy_from_url(QUrl('direct://'))
+ assert not isinstance(proxy, pac.PACFetcher)
+ proxies = [proxy]
else:
proxies = proxy.resolve(query)
else:
proxies = [proxy]
- for p in proxies:
- if p.type() != QNetworkProxy.NoProxy:
- capabilities = p.capabilities()
- lookup_cap = QNetworkProxy.HostNameLookupCapability
- if config.val.content.proxy_dns_requests:
- capabilities |= lookup_cap # type: ignore
- else:
- capabilities &= ~lookup_cap # type: ignore
- p.setCapabilities(capabilities)
+ for proxy in proxies:
+ self._set_capabilities(proxy)
return proxies
diff --git a/qutebrowser/browser/pdfjs.py b/qutebrowser/browser/pdfjs.py
index 7c696ac0b..0c6d02501 100644
--- a/qutebrowser/browser/pdfjs.py
+++ b/qutebrowser/browser/pdfjs.py
@@ -30,6 +30,19 @@ from qutebrowser.misc import objects
from qutebrowser.config import config
+_SYSTEM_PATHS = [
+ # Debian pdf.js-common
+ # Arch Linux pdfjs (AUR)
+ '/usr/share/pdf.js/',
+ # Flatpak (Flathub)
+ '/app/share/pdf.js/',
+ # Arch Linux pdf.js (AUR)
+ '/usr/share/javascript/pdf.js/',
+ # Debian libjs-pdf
+ '/usr/share/javascript/pdf/',
+]
+
+
class PDFJSNotFound(Exception):
"""Raised when no pdf.js installation is found.
@@ -84,6 +97,9 @@ def _generate_pdfjs_script(filename):
url_query.addQueryItem('filename', filename)
url.setQuery(url_query)
+ js_url = javascript.to_js(
+ url.toString(QUrl.FullyEncoded)) # type: ignore[arg-type]
+
return jinja.js_environment.from_string("""
document.addEventListener("DOMContentLoaded", function() {
if (typeof window.PDFJS !== 'undefined') {
@@ -105,7 +121,7 @@ def _generate_pdfjs_script(filename):
viewer.open({{ url }});
});
""").render(
- url=javascript.to_js(url.toString(QUrl.FullyEncoded)), # type: ignore
+ url=js_url,
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-70420
disable_create_object_url=(
not qtutils.version_check('5.12') and
@@ -127,16 +143,7 @@ def get_pdfjs_res_and_path(path):
content = None
file_path = None
- system_paths = [
- # Debian pdf.js-common
- # Arch Linux pdfjs (AUR)
- '/usr/share/pdf.js/',
- # Flatpak (Flathub)
- '/app/share/pdf.js/',
- # Arch Linux pdf.js (AUR)
- '/usr/share/javascript/pdf.js/',
- # Debian libjs-pdf
- '/usr/share/javascript/pdf/',
+ system_paths = _SYSTEM_PATHS + [
# fallback
os.path.join(standarddir.data(), 'pdfjs'),
# hardcoded fallback for --temp-basedir
@@ -221,6 +228,7 @@ def is_available():
"""Return true if a pdfjs installation is available."""
try:
get_pdfjs_res('build/pdf.js')
+ get_pdfjs_res('web/viewer.html')
except PDFJSNotFound:
return False
else:
@@ -243,7 +251,7 @@ def get_main_url(filename: str, original_url: QUrl) -> QUrl:
query = QUrlQuery()
query.addQueryItem('filename', filename) # read from our JS
query.addQueryItem('file', '') # to avoid pdfjs opening the default PDF
- urlstr = original_url.toString(QUrl.FullyEncoded) # type: ignore
+ urlstr = original_url.toString(QUrl.FullyEncoded) # type: ignore[arg-type]
query.addQueryItem('source', urlstr)
url.setQuery(query)
return url
diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py
index 3bc057796..b661f533d 100644
--- a/qutebrowser/browser/qutescheme.py
+++ b/qutebrowser/browser/qutescheme.py
@@ -39,7 +39,7 @@ try:
import secrets
except ImportError:
# New in Python 3.6
- secrets = None # type: ignore
+ secrets = None # type: ignore[assignment]
from PyQt5.QtCore import QUrlQuery, QUrl, qVersion
@@ -47,7 +47,7 @@ import qutebrowser
from qutebrowser.browser import pdfjs, downloads, history
from qutebrowser.config import config, configdata, configexc, configdiff
from qutebrowser.utils import (version, utils, jinja, log, message, docutils,
- objreg, urlutils)
+ objreg, urlutils, standarddir)
from qutebrowser.qt import sip
@@ -98,8 +98,8 @@ class Redirect(Exception):
# Return value: (mimetype, data) (encoded as utf-8 if a str is returned)
-_Handler = TypeVar('_Handler',
- bound=Callable[[QUrl], Tuple[str, Union[str, bytes]]])
+_HandlerRet = Tuple[str, Union[str, bytes]]
+_Handler = TypeVar('_Handler', bound=Callable[[QUrl], _HandlerRet])
class add_handler: # noqa: N801,N806 pylint: disable=invalid-name
@@ -125,7 +125,7 @@ class add_handler: # noqa: N801,N806 pylint: disable=invalid-name
return self._function(*args, **kwargs)
-def data_for_url(url):
+def data_for_url(url: QUrl) -> typing.Tuple[str, bytes]:
"""Get the data to show for the given URL.
Args:
@@ -134,8 +134,9 @@ def data_for_url(url):
Return:
A (mimetype, data) tuple.
"""
- norm_url = url.adjusted(QUrl.NormalizePathSegments |
- QUrl.StripTrailingSlash)
+ norm_url = url.adjusted(
+ QUrl.NormalizePathSegments | # type: ignore[arg-type]
+ QUrl.StripTrailingSlash)
if norm_url != url:
raise Redirect(norm_url)
@@ -181,7 +182,7 @@ def data_for_url(url):
@add_handler('bookmarks')
-def qute_bookmarks(_url):
+def qute_bookmarks(_url: QUrl) -> _HandlerRet:
"""Handler for qute://bookmarks. Display all quickmarks / bookmarks."""
bookmarks = sorted(objreg.get('bookmark-manager').marks.items(),
key=lambda x: x[1]) # Sort by title
@@ -196,7 +197,7 @@ def qute_bookmarks(_url):
@add_handler('tabs')
-def qute_tabs(_url):
+def qute_tabs(_url: QUrl) -> _HandlerRet:
"""Handler for qute://tabs. Display information about all open tabs."""
tabs = collections.defaultdict(
list) # type: typing.Dict[str, typing.List[typing.Tuple[str, str]]]
@@ -217,7 +218,10 @@ def qute_tabs(_url):
return 'text/html', src
-def history_data(start_time, offset=None):
+def history_data(
+ start_time: float,
+ offset: int = None
+) -> typing.Sequence[typing.Dict[str, typing.Union[str, int]]]:
"""Return history data.
Arguments:
@@ -240,7 +244,7 @@ def history_data(start_time, offset=None):
@add_handler('history')
-def qute_history(url):
+def qute_history(url: QUrl) -> _HandlerRet:
"""Handler for qute://history. Display and serve history."""
if url.path() == '/data':
q_offset = QUrlQuery(url).queryItemValue("offset")
@@ -266,7 +270,7 @@ def qute_history(url):
@add_handler('javascript')
-def qute_javascript(url):
+def qute_javascript(url: QUrl) -> _HandlerRet:
"""Handler for qute://javascript.
Return content of file given as query parameter.
@@ -280,14 +284,14 @@ def qute_javascript(url):
@add_handler('pyeval')
-def qute_pyeval(_url):
+def qute_pyeval(_url: QUrl) -> _HandlerRet:
"""Handler for qute://pyeval."""
src = jinja.render('pre.html', title='pyeval', content=pyeval_output)
return 'text/html', src
@add_handler('spawn-output')
-def qute_spawn_output(_url):
+def qute_spawn_output(_url: QUrl) -> _HandlerRet:
"""Handler for qute://spawn-output."""
src = jinja.render('pre.html', title='spawn output', content=spawn_output)
return 'text/html', src
@@ -298,57 +302,60 @@ def qute_spawn_output(_url):
def qute_version(_url):
"""Handler for qute://version."""
src = jinja.render('version.html', title='Version info',
- version=version.version(),
+ version=version.version_info(),
copyright=qutebrowser.__copyright__)
return 'text/html', src
-@add_handler('plainlog')
-def qute_plainlog(url):
- """Handler for qute://plainlog.
+@add_handler('log')
+def qute_log(url: QUrl) -> _HandlerRet:
+ """Handler for qute://log.
+
+ There are three query parameters:
- An optional query parameter specifies the minimum log level to print.
+ - level: The minimum log level to print.
For example, qute://log?level=warning prints warnings and errors.
Level can be one of: vdebug, debug, info, warning, error, critical.
+
+ - plain: If given (and not 'false'), plaintext is shown.
+
+ - logfilter: A filter string like the --logfilter commandline argument
+ accepts.
"""
+ query = QUrlQuery(url)
+ plain = (query.hasQueryItem('plain') and
+ query.queryItemValue('plain').lower() != 'false')
+
if log.ram_handler is None:
- text = "Log output was disabled."
+ content = "Log output was disabled." if plain else None
else:
- level = QUrlQuery(url).queryItemValue('level')
+ level = query.queryItemValue('level')
if not level:
level = 'vdebug'
- text = log.ram_handler.dump_log(html=False, level=level)
- src = jinja.render('pre.html', title='log', content=text)
- return 'text/html', src
+ filter_str = query.queryItemValue('logfilter')
-@add_handler('log')
-def qute_log(url):
- """Handler for qute://log.
+ try:
+ logfilter = (log.LogFilter.parse(filter_str, only_debug=False)
+ if filter_str else None)
+ except log.InvalidLogFilterError as e:
+ raise UrlInvalidError(e)
- An optional query parameter specifies the minimum log level to print.
- For example, qute://log?level=warning prints warnings and errors.
- Level can be one of: vdebug, debug, info, warning, error, critical.
- """
- if log.ram_handler is None:
- html_log = None
- else:
- level = QUrlQuery(url).queryItemValue('level')
- if not level:
- level = 'vdebug'
- html_log = log.ram_handler.dump_log(html=True, level=level)
+ content = log.ram_handler.dump_log(html=not plain,
+ level=level, logfilter=logfilter)
- src = jinja.render('log.html', title='log', content=html_log)
+ template = 'pre.html' if plain else 'log.html'
+ src = jinja.render(template, title='log', content=content)
return 'text/html', src
@add_handler('gpl')
-def qute_gpl(_url):
+def qute_gpl(_url: QUrl) -> _HandlerRet:
"""Handler for qute://gpl. Return HTML content as string."""
return 'text/html', utils.read_file('html/license.html')
-def _asciidoc_fallback_path(html_path):
+def _asciidoc_fallback_path(html_path: str) -> typing.Optional[str]:
"""Fall back to plaintext asciidoc if the HTML is unavailable."""
path = html_path.replace('.html', '.asciidoc')
try:
@@ -358,7 +365,7 @@ def _asciidoc_fallback_path(html_path):
@add_handler('help')
-def qute_help(url):
+def qute_help(url: QUrl) -> _HandlerRet:
"""Handler for qute://help."""
urlpath = url.path()
if not urlpath or urlpath == '/':
@@ -407,7 +414,7 @@ def qute_help(url):
return 'text/html', data
-def _qute_settings_set(url):
+def _qute_settings_set(url: QUrl) -> _HandlerRet:
"""Handler for qute://settings/set."""
query = QUrlQuery(url)
option = query.queryItemValue('option', QUrl.FullyDecoded)
@@ -429,7 +436,7 @@ def _qute_settings_set(url):
@add_handler('settings')
-def qute_settings(url):
+def qute_settings(url: QUrl) -> _HandlerRet:
"""Handler for qute://settings. View/change qute configuration."""
global csrf_token
@@ -457,7 +464,7 @@ def qute_settings(url):
@add_handler('bindings')
-def qute_bindings(_url):
+def qute_bindings(_url: QUrl) -> _HandlerRet:
"""Handler for qute://bindings. View keybindings."""
bindings = {}
defaults = config.val.bindings.default
@@ -475,7 +482,7 @@ def qute_bindings(_url):
@add_handler('back')
-def qute_back(url):
+def qute_back(url: QUrl) -> _HandlerRet:
"""Handler for qute://back.
Simple page to free ram / lazy load a site, goes back on focusing the tab.
@@ -487,7 +494,7 @@ def qute_back(url):
@add_handler('configdiff')
-def qute_configdiff(url):
+def qute_configdiff(url: QUrl) -> _HandlerRet:
"""Handler for qute://configdiff."""
if url.path() == '/old':
try:
@@ -502,7 +509,7 @@ def qute_configdiff(url):
@add_handler('pastebin-version')
-def qute_pastebin_version(_url):
+def qute_pastebin_version(_url: QUrl) -> _HandlerRet:
"""Handler that pastebins the version string."""
version.pastebin_version()
return 'text/plain', b'Paste called.'
@@ -515,7 +522,7 @@ def _pdf_path(filename: str) -> str:
@add_handler('pdfjs')
-def qute_pdfjs(url: QUrl):
+def qute_pdfjs(url: QUrl) -> _HandlerRet:
"""Handler for qute://pdfjs.
Return the pdf.js viewer or redirect to original URL if the file does not
@@ -566,7 +573,7 @@ def qute_pdfjs(url: QUrl):
@add_handler('warning')
-def qute_warning(url):
+def qute_warning(url: QUrl) -> _HandlerRet:
"""Handler for qute://warning."""
path = url.path()
if path == '/old-qt':
@@ -576,6 +583,11 @@ def qute_warning(url):
elif path == '/webkit':
src = jinja.render('warning-webkit.html',
title='QtWebKit backend warning')
+ elif path == '/sessions':
+ src = jinja.render('warning-sessions.html',
+ title='Qt 5.15 sessions warning',
+ datadir=standarddir.data(),
+ sep=os.sep)
else:
raise NotFoundError("Invalid warning page {}".format(path))
return 'text/html', src
diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py
index 4fbede419..715487def 100644
--- a/qutebrowser/browser/shared.py
+++ b/qutebrowser/browser/shared.py
@@ -27,7 +27,8 @@ import typing
from PyQt5.QtCore import QUrl
from qutebrowser.config import config
-from qutebrowser.utils import usertypes, message, log, objreg, jinja, utils
+from qutebrowser.utils import (usertypes, message, log, objreg, jinja, utils,
+ qtutils)
from qutebrowser.mainwindow import mainwindow
@@ -160,7 +161,7 @@ def ignore_certificate_errors(url, errors, abort_on):
True if the error should be ignored, False otherwise.
"""
ssl_strict = config.instance.get('content.ssl_strict', url=url)
- log.webview.debug("Certificate errors {!r}, strict {}".format(
+ log.network.debug("Certificate errors {!r}, strict {}".format(
errors, ssl_strict))
for error in errors:
@@ -186,7 +187,7 @@ def ignore_certificate_errors(url, errors, abort_on):
ignore = False
return ignore
elif ssl_strict is False:
- log.webview.debug("ssl_strict is False, only warning about errors")
+ log.network.debug("ssl_strict is False, only warning about errors")
for err in errors:
# FIXME we might want to use warn here (non-fatal error)
# https://github.com/qutebrowser/qutebrowser/issues/114
@@ -285,8 +286,13 @@ def get_user_stylesheet(searching=False):
with open(filename, 'r', encoding='utf-8') as f:
css += f.read()
- if (config.val.scrolling.bar == 'never' or
- config.val.scrolling.bar == 'when-searching' and not searching):
+ setting = config.val.scrolling.bar
+ overlay_bar_available = (qtutils.version_check('5.11', compiled=False) and
+ not utils.is_mac)
+ if setting == 'overlay' and not overlay_bar_available:
+ setting = 'when-searching'
+
+ if setting == 'never' or setting == 'when-searching' and not searching:
css += '\nhtml > ::-webkit-scrollbar { width: 0px; height: 0px; }'
return css
diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py
index ba3c1e226..a35549571 100644
--- a/qutebrowser/browser/webelem.py
+++ b/qutebrowser/browser/webelem.py
@@ -392,7 +392,7 @@ class AbstractWebElement(collections.abc.MutableMapping):
window.show()
# FIXME:typing Why can't mypy determine the type of
# window.tabbed_browser?
- window.tabbed_browser.tabopen(url) # type: ignore
+ window.tabbed_browser.tabopen(url) # type: ignore[has-type]
else:
raise ValueError("Unknown ClickTarget {}".format(click_target))
diff --git a/qutebrowser/browser/webengine/cookies.py b/qutebrowser/browser/webengine/cookies.py
index af743c3d1..697082830 100644
--- a/qutebrowser/browser/webengine/cookies.py
+++ b/qutebrowser/browser/webengine/cookies.py
@@ -20,21 +20,40 @@
"""Filter for QtWebEngine cookies."""
from qutebrowser.config import config
-from qutebrowser.utils import utils, qtutils
+from qutebrowser.utils import utils, qtutils, log
+from qutebrowser.misc import objects
+@utils.prevent_exceptions(False) # Runs in I/O thread
def _accept_cookie(request):
"""Check whether the given cookie should be accepted."""
- accept = config.val.content.cookies.accept
+ url = request.firstPartyUrl
+ if not url.isValid():
+ url = None
+
+ if qtutils.version_check('5.11.3', compiled=False):
+ third_party = request.thirdParty
+ else:
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-71393
+ third_party = (request.thirdParty and
+ not request.firstPartyUrl.isEmpty())
+
+ accept = config.instance.get('content.cookies.accept',
+ url=url)
+
+ if 'log-cookies' in objects.debug_flags:
+ first_party_str = ("<unknown>" if not request.firstPartyUrl.isValid()
+ else request.firstPartyUrl.toDisplayString())
+ origin_str = ("<unknown>" if not request.origin.isValid()
+ else request.origin.toDisplayString())
+ log.network.debug('Cookie from origin {} on {} (third party: {}) '
+ '-> applying setting {}'
+ .format(origin_str, first_party_str, third_party,
+ accept))
+
if accept == 'all':
return True
elif accept in ['no-3rdparty', 'no-unknown-3rdparty']:
- if qtutils.version_check('5.11.3', compiled=False):
- third_party = request.thirdParty
- else:
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-71393
- third_party = (request.thirdParty and
- not request.firstPartyUrl.isEmpty())
return not third_party
elif accept == 'never':
return False
diff --git a/qutebrowser/browser/webengine/interceptor.py b/qutebrowser/browser/webengine/interceptor.py
index 7d455d4c3..d4dcb522f 100644
--- a/qutebrowser/browser/webengine/interceptor.py
+++ b/qutebrowser/browser/webengine/interceptor.py
@@ -154,7 +154,7 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor):
info.resourceType())
navigation_type_str = debug.qenum_key(QWebEngineUrlRequestInfo,
info.navigationType())
- log.webview.debug("{} {}, first-party {}, resource {}, "
+ log.network.debug("{} {}, first-party {}, resource {}, "
"navigation {}".format(
bytes(info.requestMethod()).decode('ascii'),
info.requestUrl().toDisplayString(),
@@ -164,7 +164,7 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor):
url = info.requestUrl()
first_party = info.firstPartyUrl()
if not url.isValid():
- log.webview.debug("Ignoring invalid intercepted URL: {}".format(
+ log.network.debug("Ignoring invalid intercepted URL: {}".format(
url.errorString()))
return
@@ -173,7 +173,7 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor):
try:
resource_type = self._resource_types[info.resourceType()]
except KeyError:
- log.webview.warning(
+ log.network.warning(
"Resource type {} not found in RequestInterceptor dict."
.format(debug.qenum_key(QWebEngineUrlRequestInfo,
info.resourceType())))
@@ -184,7 +184,7 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor):
if (first_party != QUrl('qute://settings/') or
info.resourceType() !=
QWebEngineUrlRequestInfo.ResourceTypeXhr):
- log.webview.warning("Blocking malicious request from {} to {}"
+ log.network.warning("Blocking malicious request from {} to {}"
.format(first_party.toDisplayString(),
url.toDisplayString()))
info.block(True)
diff --git a/qutebrowser/browser/webengine/tabhistory.py b/qutebrowser/browser/webengine/tabhistory.py
index 2690b6dfd..f630e8873 100644
--- a/qutebrowser/browser/webengine/tabhistory.py
+++ b/qutebrowser/browser/webengine/tabhistory.py
@@ -26,6 +26,13 @@ from PyQt5.QtCore import QByteArray, QDataStream, QIODevice, QUrl
from qutebrowser.utils import qtutils
+# kHistoryStreamVersion = 3 was originally set when history serializing was
+# implemented in QtWebEngine:
+# https://codereview.qt-project.org/c/qt/qtwebengine/+/81529
+#
+# Qt 5.14 added version 4 which also serializes favicons:
+# https://codereview.qt-project.org/c/qt/qtwebengine/+/279407
+# However, we don't care about those, so let's keep it at 3.
HISTORY_STREAM_VERSION = 3
@@ -36,32 +43,63 @@ def _serialize_item(item, stream):
item: The WebHistoryItem to write.
stream: The QDataStream to write to.
"""
- ### Thanks to Otter Browser:
- ### https://github.com/OtterBrowser/otter-browser/blob/v0.9.10/src/modules/backends/web/qtwebengine/QtWebEngineWebWidget.cpp#L1210
- ### src/core/web_contents_adapter.cpp serializeNavigationHistory
+ # Thanks to Otter Browser:
+ # https://github.com/OtterBrowser/otter-browser/blob/v1.0.01/src/modules/backends/web/qtwebengine/QtWebEnginePage.cpp#L260
+ #
+ # Relevant QtWebEngine source:
+ # src/core/web_contents_adapter.cpp serializeNavigationHistory
+ #
+ # Sample data:
+ # [TabHistoryItem(active=True,
+ # original_url=QUrl('file:///home/florian/proj/qutebrowser/git/tests/end2end/data/numbers/1.txt'),
+ # title='1.txt',
+ # url=QUrl('file:///home/florian/proj/qutebrowser/git/tests/end2end/data/numbers/1.txt'),
+ # user_data={'zoom': 1.0, 'scroll-pos': QPoint()})]
+
## toQt(entry->GetVirtualURL());
+ # \x00\x00\x00Jfile:///home/florian/proj/qutebrowser/git/tests/end2end/data/numbers/1.txt
qtutils.serialize_stream(stream, item.url)
+
## toQt(entry->GetTitle());
+ # \x00\x00\x00\n\x001\x00.\x00t\x00x\x00t
stream.writeQString(item.title)
+
## QByteArray(encodedPageState.data(), encodedPageState.size());
+ # \xff\xff\xff\xff
qtutils.serialize_stream(stream, QByteArray())
+
## static_cast<qint32>(entry->GetTransitionType());
# chromium/ui/base/page_transition_types.h
+ # \x00\x00\x00\x00
stream.writeInt32(0) # PAGE_TRANSITION_LINK
+
## entry->GetHasPostData();
+ # \x00
stream.writeBool(False)
+
## toQt(entry->GetReferrer().url);
+ # \xff\xff\xff\xff
qtutils.serialize_stream(stream, QUrl())
+
## static_cast<qint32>(entry->GetReferrer().policy);
# chromium/third_party/WebKit/public/platform/WebReferrerPolicy.h
+ # \x00\x00\x00\x00
stream.writeInt32(0) # WebReferrerPolicyAlways
+
## toQt(entry->GetOriginalRequestURL());
+ # \x00\x00\x00Jfile:///home/florian/proj/qutebrowser/git/tests/end2end/data/numbers/1.txt
qtutils.serialize_stream(stream, item.original_url)
+
## entry->GetIsOverridingUserAgent();
+ # \x00
stream.writeBool(False)
+
## static_cast<qint64>(entry->GetTimestamp().ToInternalValue());
+ # \x00\x00\x00\x00^\x97$\xe7
stream.writeInt64(int(time.time()))
+
## entry->GetHttpStatusCode();
+ # \x00\x00\x00\xc8
stream.writeInt(200)
@@ -102,12 +140,13 @@ def serialize(items):
current_idx = -1
### src/core/web_contents_adapter.cpp serializeNavigationHistory
+ # sample data:
# kHistoryStreamVersion
- stream.writeInt(HISTORY_STREAM_VERSION)
+ stream.writeInt(HISTORY_STREAM_VERSION) # \x00\x00\x00\x03
# count
- stream.writeInt(len(items))
+ stream.writeInt(len(items)) # \x00\x00\x00\x01
# currentIndex
- stream.writeInt(current_idx)
+ stream.writeInt(current_idx) # \x00\x00\x00\x00
for item in items:
_serialize_item(item, stream)
diff --git a/qutebrowser/browser/webengine/webenginedownloads.py b/qutebrowser/browser/webengine/webenginedownloads.py
index 2e0129605..80266a1c8 100644
--- a/qutebrowser/browser/webengine/webenginedownloads.py
+++ b/qutebrowser/browser/webengine/webenginedownloads.py
@@ -24,7 +24,7 @@ import os.path
import urllib
import functools
-from PyQt5.QtCore import pyqtSlot, Qt, QUrl
+from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QObject
from PyQt5.QtWebEngineWidgets import QWebEngineDownloadItem
from qutebrowser.browser import downloads, pdfjs
@@ -39,16 +39,18 @@ class DownloadItem(downloads.AbstractDownloadItem):
_qt_item: The wrapped item.
"""
- def __init__(self, qt_item: QWebEngineDownloadItem, parent=None):
+ def __init__(self, qt_item: QWebEngineDownloadItem,
+ parent: QObject = None) -> None:
super().__init__(parent)
self._qt_item = qt_item
- qt_item.downloadProgress.connect( # type: ignore
+ qt_item.downloadProgress.connect( # type: ignore[attr-defined]
self.stats.on_download_progress)
- qt_item.stateChanged.connect(self._on_state_changed) # type: ignore
+ qt_item.stateChanged.connect( # type: ignore[attr-defined]
+ self._on_state_changed)
# Ensure wrapped qt_item is deleted manually when the wrapper object
# is deleted. See https://github.com/qutebrowser/qutebrowser/issues/3373
- self.destroyed.connect(self._qt_item.deleteLater) # type: ignore
+ self.destroyed.connect(self._qt_item.deleteLater)
def _is_page_download(self):
"""Check if this item is a page (i.e. mhtml) download."""
@@ -93,7 +95,8 @@ class DownloadItem(downloads.AbstractDownloadItem):
"{}".format(state_name))
def _do_die(self):
- self._qt_item.downloadProgress.disconnect() # type: ignore
+ progress_signal = self._qt_item.downloadProgress
+ progress_signal.disconnect() # type: ignore[attr-defined]
if self._qt_item.state() != QWebEngineDownloadItem.DownloadInterrupted:
self._qt_item.cancel()
diff --git a/qutebrowser/browser/webengine/webengineelem.py b/qutebrowser/browser/webengine/webengineelem.py
index ffaaf346c..d765483fe 100644
--- a/qutebrowser/browser/webengine/webengineelem.py
+++ b/qutebrowser/browser/webengine/webengineelem.py
@@ -248,7 +248,7 @@ class WebEngineElement(webelem.AbstractWebElement):
# (it does so with a 0ms QTimer...)
# This is also used in Qt's tests:
# https://github.com/qt/qtwebengine/commit/5e572e88efa7ba7c2b9138ec19e606d3e345ac90
- QApplication.processEvents( # type: ignore
+ QApplication.processEvents( # type: ignore[call-overload]
QEventLoop.ExcludeSocketNotifiers |
QEventLoop.ExcludeUserInputEvents)
diff --git a/qutebrowser/browser/webengine/webenginequtescheme.py b/qutebrowser/browser/webengine/webenginequtescheme.py
index a1526b777..879f8aeca 100644
--- a/qutebrowser/browser/webengine/webenginequtescheme.py
+++ b/qutebrowser/browser/webengine/webenginequtescheme.py
@@ -23,10 +23,10 @@ from PyQt5.QtCore import QBuffer, QIODevice, QUrl
from PyQt5.QtWebEngineCore import (QWebEngineUrlSchemeHandler,
QWebEngineUrlRequestJob)
try:
- from PyQt5.QtWebEngineCore import QWebEngineUrlScheme # type: ignore
+ from PyQt5.QtWebEngineCore import QWebEngineUrlScheme
except ImportError:
# Added in Qt 5.12
- QWebEngineUrlScheme = None
+ QWebEngineUrlScheme = None # type: ignore[misc, assignment]
from qutebrowser.browser import qutescheme
from qutebrowser.utils import log, qtutils
@@ -86,9 +86,9 @@ class QuteSchemeHandler(QWebEngineUrlSchemeHandler):
return True
if initiator.isValid() and initiator.scheme() != 'qute':
- log.misc.warning("Blocking malicious request from {} to {}".format(
- initiator.toDisplayString(),
- request_url.toDisplayString()))
+ log.network.warning("Blocking malicious request from {} to {}"
+ .format(initiator.toDisplayString(),
+ request_url.toDisplayString()))
job.fail(QWebEngineUrlRequestJob.RequestDenied)
return False
@@ -119,7 +119,7 @@ class QuteSchemeHandler(QWebEngineUrlSchemeHandler):
assert url.scheme() == 'qute'
- log.misc.debug("Got request for {}".format(url.toDisplayString()))
+ log.network.debug("Got request for {}".format(url.toDisplayString()))
try:
mimetype, data = qutescheme.data_for_url(url)
except qutescheme.Error as e:
@@ -136,14 +136,14 @@ class QuteSchemeHandler(QWebEngineUrlSchemeHandler):
QWebEngineUrlRequestJob.RequestFailed,
}
exctype = type(e)
- log.misc.error("{} while handling qute://* URL".format(
+ log.network.error("{} while handling qute://* URL".format(
exctype.__name__))
job.fail(errors[exctype])
except qutescheme.Redirect as e:
qtutils.ensure_valid(e.url)
job.redirect(e.url)
else:
- log.misc.debug("Returning {} data".format(mimetype))
+ log.network.debug("Returning {} data".format(mimetype))
# We can't just use the QBuffer constructor taking a QByteArray,
# because that somehow segfaults...
@@ -165,6 +165,7 @@ def init():
if QWebEngineUrlScheme is not None:
assert not QWebEngineUrlScheme.schemeByName(b'qute').name()
scheme = QWebEngineUrlScheme(b'qute')
- scheme.setFlags(QWebEngineUrlScheme.LocalScheme |
- QWebEngineUrlScheme.LocalAccessAllowed)
+ scheme.setFlags(
+ QWebEngineUrlScheme.LocalScheme | # type: ignore[arg-type]
+ QWebEngineUrlScheme.LocalAccessAllowed)
QWebEngineUrlScheme.registerScheme(scheme)
diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py
index a1c1a40f9..b7e67e379 100644
--- a/qutebrowser/browser/webengine/webenginesettings.py
+++ b/qutebrowser/browser/webengine/webenginesettings.py
@@ -36,7 +36,7 @@ from qutebrowser.browser.webengine import spell, webenginequtescheme
from qutebrowser.config import config, websettings
from qutebrowser.config.websettings import AttributeInfo as Attr
from qutebrowser.utils import (utils, standarddir, qtutils, message, log,
- urlmatch)
+ urlmatch, usertypes)
# The default QWebEngineProfile
default_profile = typing.cast(QWebEngineProfile, None)
@@ -76,6 +76,10 @@ class _SettingsWrapper:
for settings in self._settings:
settings.setDefaultTextEncoding(encoding)
+ def setUnknownUrlSchemePolicy(self, policy):
+ for settings in self._settings:
+ settings.setUnknownUrlSchemePolicy(policy)
+
def testAttribute(self, attribute):
return self._settings[0].testAttribute(attribute)
@@ -88,6 +92,9 @@ class _SettingsWrapper:
def defaultTextEncoding(self):
return self._settings[0].defaultTextEncoding()
+ def unknownUrlSchemePolicy(self):
+ return self._settings[0].unknownUrlSchemePolicy()
+
class WebEngineSettings(websettings.AbstractSettings):
@@ -151,6 +158,19 @@ class WebEngineSettings(websettings.AbstractSettings):
'fonts.web.family.fantasy': QWebEngineSettings.FantasyFont,
}
+ # Only Qt >= 5.11 support UnknownUrlSchemePolicy
+ try:
+ _UNKNOWN_URL_SCHEME_POLICY = {
+ 'disallow':
+ QWebEngineSettings.DisallowUnknownUrlSchemes,
+ 'allow-from-user-interaction':
+ QWebEngineSettings.AllowUnknownUrlSchemesFromUserInteraction,
+ 'allow-all':
+ QWebEngineSettings.AllowAllUnknownUrlSchemes,
+ }
+ except AttributeError:
+ _UNKNOWN_URL_SCHEME_POLICY = None
+
# Mapping from WebEngineSettings::initDefaults in
# qtwebengine/src/core/web_engine_settings.cpp
_FONT_TO_QFONT = {
@@ -162,6 +182,33 @@ class WebEngineSettings(websettings.AbstractSettings):
QWebEngineSettings.FantasyFont: QFont.Fantasy,
}
+ def set_unknown_url_scheme_policy(
+ self, policy: typing.Union[str, usertypes.Unset]) -> bool:
+ """Set the UnknownUrlSchemePolicy to use.
+
+ Return:
+ True if there was a change, False otherwise.
+ """
+ old_value = self._settings.unknownUrlSchemePolicy()
+ if isinstance(policy, usertypes.Unset):
+ self._settings.resetUnknownUrlSchemePolicy()
+ new_value = self._settings.unknownUrlSchemePolicy()
+ else:
+ new_value = self._UNKNOWN_URL_SCHEME_POLICY[policy]
+ self._settings.setUnknownUrlSchemePolicy(new_value)
+ return old_value != new_value
+
+ def _update_setting(self, setting, value):
+ if setting == 'content.unknown_url_scheme_policy':
+ if self._UNKNOWN_URL_SCHEME_POLICY:
+ return self.set_unknown_url_scheme_policy(value)
+ return False
+ return super()._update_setting(setting, value)
+
+ def init_settings(self):
+ super().init_settings()
+ self.update_setting('content.unknown_url_scheme_policy')
+
def __init__(self, settings):
super().__init__(settings)
# Attributes which don't exist in all Qt versions.
@@ -279,8 +326,13 @@ def _update_settings(option):
"""Update global settings when qwebsettings changed."""
global_settings.update_setting(option)
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-75884
+ # (note this isn't actually fixed properly before Qt 5.15)
+ header_bug_fixed = (not qtutils.version_check('5.12', compiled=False) or
+ qtutils.version_check('5.15', compiled=False))
+
if option in ['content.headers.user_agent',
- 'content.headers.accept_language']:
+ 'content.headers.accept_language'] and header_bug_fixed:
default_profile.setter.set_http_headers()
if private_profile:
private_profile.setter.set_http_headers()
@@ -315,7 +367,8 @@ def _init_profiles():
default_profile = QWebEngineProfile.defaultProfile()
init_user_agent()
- default_profile.setter = ProfileSetter(default_profile)
+ default_profile.setter = ProfileSetter( # type: ignore[attr-defined]
+ default_profile)
default_profile.setCachePath(
os.path.join(standarddir.cache(), 'webengine'))
default_profile.setPersistentStoragePath(
@@ -325,7 +378,8 @@ def _init_profiles():
if not qtutils.is_single_process():
private_profile = QWebEngineProfile()
- private_profile.setter = ProfileSetter(private_profile)
+ private_profile.setter = ProfileSetter( # type: ignore[attr-defined]
+ private_profile)
assert private_profile.isOffTheRecord()
private_profile.setter.init_profile()
@@ -354,6 +408,7 @@ def _init_site_specific_quirks():
'https://accounts.google.com/*': firefox_ua,
'https://*.slack.com/*': new_chrome_ua,
'https://docs.google.com/*': firefox_ua,
+ 'https://drive.google.com/*': firefox_ua,
}
if not qtutils.version_check('5.9'):
@@ -367,9 +422,16 @@ def _init_site_specific_quirks():
def _init_devtools_settings():
"""Make sure the devtools always get images/JS permissions."""
- for setting in ['content.javascript.enabled', 'content.images']:
+ settings = [
+ ('content.javascript.enabled', True),
+ ('content.images', True)
+ ] # type: typing.List[typing.Tuple[str, typing.Any]]
+ if qtutils.version_check('5.11'):
+ settings.append(('content.cookies.accept', 'all'))
+
+ for setting, value in settings:
for pattern in ['chrome-devtools://*', 'devtools://*']:
- config.instance.set_obj(setting, True,
+ config.instance.set_obj(setting, value,
pattern=urlmatch.UrlPattern(pattern),
hide_userconfig=True)
diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py
index c130d92ea..69d6daeb4 100644
--- a/qutebrowser/browser/webengine/webenginetab.py
+++ b/qutebrowser/browser/webengine/webenginetab.py
@@ -41,6 +41,7 @@ from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory,
from qutebrowser.misc import miscwidgets, objects
from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils,
message, objreg, jinja, debug)
+from qutebrowser.keyinput import modeman
from qutebrowser.qt import sip
@@ -124,7 +125,7 @@ class WebEngineAction(browsertab.AbstractAction):
tb = objreg.get('tabbed-browser', scope='window',
window=self._tab.win_id)
urlstr = self._tab.url().toString(
- QUrl.RemoveUserInfo) # type: ignore
+ QUrl.RemoveUserInfo) # type: ignore[arg-type]
# The original URL becomes the path of a view-source: URL
# (without a host), but query/fragment should stay.
url = QUrl('view-source:' + urlstr)
@@ -156,6 +157,92 @@ class WebEnginePrinting(browsertab.AbstractPrinting):
self._widget.page().print(printer, callback)
+class _WebEngineSearchWrapHandler:
+
+ """QtWebEngine implementations related to wrapping when searching.
+
+ Attributes:
+ flag_wrap: An additional flag indicating whether the last search
+ used wrapping.
+ _active_match: The 1-based index of the currently active match
+ on the page.
+ _total_matches: The total number of search matches on the page.
+ _nowrap_available: Whether the functionality to prevent wrapping
+ is available.
+ """
+
+ def __init__(self):
+ self._active_match = 0
+ self._total_matches = 0
+ self.flag_wrap = True
+ self._nowrap_available = False
+
+ def connect_signal(self, page):
+ """Connect to the findTextFinished signal of the page.
+
+ Args:
+ page: The QtWebEnginePage to connect to this handler.
+ """
+ if not qtutils.version_check("5.14"):
+ return
+
+ try:
+ # pylint: disable=unused-import
+ from PyQt5.QtWebEngineCore import QWebEngineFindTextResult
+ except ImportError:
+ # WORKAROUND for some odd PyQt/packaging bug where the
+ # findTextResult signal is available, but QWebEngineFindTextResult
+ # is not. Seems to happen on e.g. Gentoo.
+ log.webview.warning("Could not import QWebEngineFindTextResult "
+ "despite running on Qt 5.14. You might need "
+ "to rebuild PyQtWebEngine.")
+ return
+
+ page.findTextFinished.connect(self._store_match_data)
+ self._nowrap_available = True
+
+ def _store_match_data(self, result):
+ """Store information on the last match.
+
+ The information will be checked against when wrapping is turned off.
+
+ Args:
+ result: A FindTextResult passed by the findTextFinished signal.
+ """
+ self._active_match = result.activeMatch()
+ self._total_matches = result.numberOfMatches()
+ log.webview.debug("Active search match: {}/{}"
+ .format(self._active_match, self._total_matches))
+
+ def reset_match_data(self):
+ """Reset match information.
+
+ Stale information could lead to next_result or prev_result misbehaving.
+ """
+ self._active_match = 0
+ self._total_matches = 0
+
+ def prevent_wrapping(self, *, going_up):
+ """Prevent wrapping if possible and required.
+
+ Returns True if a wrap was prevented and False if not.
+
+ Args:
+ going_up: Whether the search would scroll the page up or down.
+ """
+ if (not self._nowrap_available or
+ self.flag_wrap or self._total_matches == 0):
+ return False
+ elif going_up and self._active_match == 1:
+ message.info("Search hit TOP")
+ return True
+ elif not going_up and self._active_match == self._total_matches:
+ message.info("Search hit BOTTOM")
+ return True
+ else:
+ return False
+
+
class WebEngineSearch(browsertab.AbstractSearch):
"""QtWebEngine implementations related to searching on the page.
@@ -168,8 +255,16 @@ class WebEngineSearch(browsertab.AbstractSearch):
def __init__(self, tab, parent=None):
super().__init__(tab, parent)
- self._flags = QWebEnginePage.FindFlags(0) # type: ignore
+ self._flags = self._empty_flags()
self._pending_searches = 0
+ # The API necessary to stop wrapping was added in this version
+ self._wrap_handler = _WebEngineSearchWrapHandler()
+
+ def _empty_flags(self):
+ return QWebEnginePage.FindFlags(0) # type: ignore[call-overload]
+
+ def connect_signals(self):
+ self._wrap_handler.connect_signal(self._widget.page())
def _find(self, text, flags, callback, caller):
"""Call findText on the widget."""
@@ -207,10 +302,10 @@ class WebEngineSearch(browsertab.AbstractSearch):
callback(found)
self.finished.emit(found)
- self._widget.findText(text, flags, wrapped_callback)
+ self._widget.page().findText(text, flags, wrapped_callback)
def search(self, text, *, ignore_case=usertypes.IgnoreCase.never,
- reverse=False, result_cb=None):
+ reverse=False, wrap=True, result_cb=None):
# Don't go to next entry on duplicate search
if self.text == text and self.search_displayed:
log.webview.debug("Ignoring duplicate search request"
@@ -218,7 +313,9 @@ class WebEngineSearch(browsertab.AbstractSearch):
return
self.text = text
- self._flags = QWebEnginePage.FindFlags(0) # type: ignore
+ self._flags = self._empty_flags()
+ self._wrap_handler.reset_match_data()
+ self._wrap_handler.flag_wrap = wrap
if self._is_case_sensitive(ignore_case):
self._flags |= QWebEnginePage.FindCaseSensitively
if reverse:
@@ -230,18 +327,27 @@ class WebEngineSearch(browsertab.AbstractSearch):
if self.search_displayed:
self.cleared.emit()
self.search_displayed = False
- self._widget.findText('')
+ self._wrap_handler.reset_match_data()
+ self._widget.page().findText('')
def prev_result(self, *, result_cb=None):
# The int() here makes sure we get a copy of the flags.
- flags = QWebEnginePage.FindFlags(int(self._flags)) # type: ignore
+ flags = QWebEnginePage.FindFlags(
+ int(self._flags)) # type: ignore[call-overload]
if flags & QWebEnginePage.FindBackward:
+ if self._wrap_handler.prevent_wrapping(going_up=False):
+ return
flags &= ~QWebEnginePage.FindBackward
else:
+ if self._wrap_handler.prevent_wrapping(going_up=True):
+ return
flags |= QWebEnginePage.FindBackward
self._find(self.text, flags, result_cb, 'prev_result')
def next_result(self, *, result_cb=None):
+ going_up = self._flags & QWebEnginePage.FindBackward
+ if self._wrap_handler.prevent_wrapping(going_up=going_up):
+ return
self._find(self.text, self._flags, result_cb, 'next_result')
@@ -249,6 +355,13 @@ class WebEngineCaret(browsertab.AbstractCaret):
"""QtWebEngine implementations related to moving the cursor/selection."""
+ def __init__(self,
+ tab: 'WebEngineTab',
+ mode_manager: modeman.ModeManager,
+ parent: QWidget = None) -> None:
+ super().__init__(mode_manager, parent)
+ self._tab = tab
+
def _flags(self):
"""Get flags to pass to JS."""
flags = set()
@@ -282,7 +395,10 @@ class WebEngineCaret(browsertab.AbstractCaret):
if enabled is None:
log.webview.debug("Ignoring selection status None")
return
- self.selection_toggled.emit(enabled)
+ if enabled:
+ self.selection_toggled.emit(browsertab.SelectionState.normal)
+ else:
+ self.selection_toggled.emit(browsertab.SelectionState.none)
@pyqtSlot(usertypes.KeyMode)
def _on_mode_left(self, mode):
@@ -337,8 +453,9 @@ class WebEngineCaret(browsertab.AbstractCaret):
def move_to_end_of_document(self):
self._js_call('moveToEndOfDocument')
- def toggle_selection(self):
- self._js_call('toggleSelection', callback=self.selection_toggled.emit)
+ def toggle_selection(self, line=False):
+ self._js_call('toggleSelection', line,
+ callback=self._toggle_sel_translate)
def drop_selection(self):
self._js_call('dropSelection')
@@ -413,6 +530,13 @@ class WebEngineCaret(browsertab.AbstractCaret):
code = javascript.assemble('caret', command, *args)
self._tab.run_js_async(code, callback)
+ def _toggle_sel_translate(self, state_str):
+ if state_str is None:
+ message.error("Error toggling caret selection")
+ return
+ state = browsertab.SelectionState[state_str]
+ self.selection_toggled.emit(state)
+
class WebEngineScroller(browsertab.AbstractScroller):
@@ -558,6 +682,16 @@ class WebEngineHistoryPrivate(browsertab.AbstractHistoryPrivate):
qtutils.deserialize(data, self._history)
def load_items(self, items):
+ if qtutils.version_check('5.15', compiled=False):
+ # WORKAROUND for https://github.com/qutebrowser/qutebrowser/issues/5359
+ if items:
+ url = items[-1].url
+ if ((url.scheme(), url.host()) == ('qute', 'back') and
+ len(items) >= 2):
+ url = items[-2].url
+ self._tab.load_url(url)
+ return
+
if items:
self._tab.before_load_started.emit(items[-1].url)
@@ -620,6 +754,10 @@ class WebEngineElements(browsertab.AbstractElements):
"""QtWebEngine implemementations related to elements on the page."""
+ def __init__(self, tab: 'WebEngineTab') -> None:
+ super().__init__()
+ self._tab = tab
+
def _js_cb_multiple(self, callback, error_cb, js_elems):
"""Handle found elements coming from JS and call the real callback.
@@ -708,10 +846,15 @@ class WebEngineAudio(browsertab.AbstractAudio):
config.instance.changed.connect(self._on_config_changed)
def set_muted(self, muted: bool, override: bool = False) -> None:
+ was_muted = self.is_muted()
self._overridden = override
assert self._widget is not None
page = self._widget.page()
page.setAudioMuted(muted)
+ if was_muted != muted and qtutils.version_check('5.15'):
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-85118
+ # so that the tab title at least updates the muted indicator
+ self.muted_changed.emit(muted)
def is_muted(self):
page = self._widget.page()
@@ -723,7 +866,7 @@ class WebEngineAudio(browsertab.AbstractAudio):
@pyqtSlot(QUrl)
def _on_url_changed(self, url):
- if self._overridden:
+ if self._overridden or not url.isValid():
return
mute = config.instance.get('content.mute', url=url)
self.set_muted(mute)
@@ -814,9 +957,11 @@ class _WebEnginePermissions(QObject):
self._tab.data.fullscreen = on
self._tab.fullscreen_requested.emit(on)
if on:
- notification = miscwidgets.FullscreenNotification(self._widget)
- notification.show()
- notification.set_timeout(3000)
+ timeout = config.val.content.fullscreen.overlay_timeout
+ if timeout != 0:
+ notification = miscwidgets.FullscreenNotification(self._widget)
+ notification.set_timeout(timeout)
+ notification.show()
@pyqtSlot(QUrl, 'QWebEnginePage::Feature')
def _on_feature_permission_requested(self, url, feature):
@@ -829,9 +974,18 @@ class _WebEnginePermissions(QObject):
page.setFeaturePermission, url, feature,
QWebEnginePage.PermissionDeniedByUser)
+ permission_str = debug.qenum_key(QWebEnginePage, feature)
+
+ if not url.isValid():
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-85116
+ log.webview.warning("Ignoring feature permission {} for invalid "
+ "URL {}".format(permission_str, url))
+ deny_permission()
+ return
+
if feature not in self._options:
log.webview.error("Unhandled feature permission {}".format(
- debug.qenum_key(QWebEnginePage, feature)))
+ permission_str))
deny_permission()
return
@@ -1097,19 +1251,31 @@ class _WebEngineScripts(QObject):
"""Add site-specific quirk scripts.
NOTE: This isn't implemented for Qt 5.7 because of different UserScript
- semantics there. We only have a quirk for WhatsApp Web right now. It
- looks like that quirk isn't needed for Qt < 5.13.
+ semantics there. The WhatsApp Web quirk isn't needed for Qt < 5.13.
+ The globalthis_quirk would be, but let's not keep such old QtWebEngine
+ versions on life support.
"""
if not config.val.content.site_specific_quirks:
return
page_scripts = self._widget.page().scripts()
-
- for filename in ['whatsapp_web_quirk']:
+ quirks = [
+ (
+ 'whatsapp_web_quirk',
+ QWebEngineScript.DocumentReady,
+ QWebEngineScript.ApplicationWorld,
+ ),
+ ]
+ if not qtutils.version_check('5.13'):
+ quirks.append(('globalthis_quirk',
+ QWebEngineScript.DocumentCreation,
+ QWebEngineScript.MainWorld))
+
+ for filename, injection_point, world in quirks:
script = QWebEngineScript()
script.setName(filename)
- script.setWorldId(QWebEngineScript.ApplicationWorld)
- script.setInjectionPoint(QWebEngineScript.DocumentReady)
+ script.setWorldId(world)
+ script.setInjectionPoint(injection_point)
src = utils.read_file("javascript/{}.user.js".format(filename))
script.setSourceCode(src)
page_scripts.insert(script)
@@ -1136,6 +1302,9 @@ class WebEngineTabPrivate(browsertab.AbstractTabPrivate):
self._tab.action.exit_fullscreen()
self._widget.shutdown()
+ def run_js_sync(self, code):
+ raise browsertab.UnsupportedOperationError
+
class WebEngineTab(browsertab.AbstractTab):
@@ -1149,7 +1318,10 @@ class WebEngineTab(browsertab.AbstractTab):
abort_questions = pyqtSignal()
def __init__(self, *, win_id, mode_manager, private, parent=None):
- super().__init__(win_id=win_id, private=private, parent=parent)
+ super().__init__(win_id=win_id,
+ mode_manager=mode_manager,
+ private=private,
+ parent=parent)
widget = webview.WebEngineView(tabdata=self.data, win_id=win_id,
private=private)
self.history = WebEngineHistory(tab=self)
@@ -1301,7 +1473,7 @@ class WebEngineTab(browsertab.AbstractTab):
title_url = QUrl(url)
title_url.setScheme('')
title_url_str = title_url.toDisplayString(
- QUrl.RemoveScheme) # type: ignore
+ QUrl.RemoveScheme) # type: ignore[arg-type]
if title == title_url_str.strip('/'):
title = ""
@@ -1323,12 +1495,15 @@ class WebEngineTab(browsertab.AbstractTab):
title="Proxy authentication required", text=msg,
mode=usertypes.PromptMode.user_pwd,
abort_on=[self.abort_questions], url=urlstr)
+
if answer is not None:
authenticator.setUser(answer.user)
authenticator.setPassword(answer.password)
else:
try:
- sip.assign(authenticator, QAuthenticator()) # type: ignore
+ sip.assign( # type: ignore[attr-defined]
+ authenticator,
+ QAuthenticator())
except AttributeError:
self._show_error_page(url, "Proxy authentication required")
@@ -1349,7 +1524,8 @@ class WebEngineTab(browsertab.AbstractTab):
if not netrc_success and answer is None:
log.network.debug("Aborting auth")
try:
- sip.assign(authenticator, QAuthenticator()) # type: ignore
+ sip.assign( # type: ignore[attr-defined]
+ authenticator, QAuthenticator())
except AttributeError:
# WORKAROUND for
# https://www.riverbankcomputing.com/pipermail/pyqt/2016-December/038400.html
@@ -1454,19 +1630,19 @@ class WebEngineTab(browsertab.AbstractTab):
@pyqtSlot(certificateerror.CertificateErrorWrapper)
def _on_ssl_errors(self, error):
- self._has_ssl_errors = True
-
url = error.url()
- log.webview.debug("Certificate error: {}".format(error))
+ self._insecure_hosts.add(url.host())
+
+ log.network.debug("Certificate error: {}".format(error))
if error.is_overridable():
error.ignore = shared.ignore_certificate_errors(
url, [error], abort_on=[self.abort_questions])
else:
- log.webview.error("Non-overridable certificate error: "
+ log.network.error("Non-overridable certificate error: "
"{}".format(error))
- log.webview.debug("ignore {}, URL {}, requested {}".format(
+ log.network.debug("ignore {}, URL {}, requested {}".format(
error.ignore, url, self.url(requested=True)))
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-56207
@@ -1619,7 +1795,7 @@ class WebEngineTab(browsertab.AbstractTab):
try:
# pylint: disable=unused-import
- from PyQt5.QtWebEngineWidgets import ( # type: ignore
+ from PyQt5.QtWebEngineWidgets import (
QWebEngineClientCertificateSelection)
except ImportError:
pass
@@ -1638,10 +1814,13 @@ class WebEngineTab(browsertab.AbstractTab):
page.loadFinished.connect(self._on_load_finished)
self.before_load_started.connect(self._on_before_load_started)
- self.shutting_down.connect(self.abort_questions) # type: ignore
- self.load_started.connect(self.abort_questions) # type: ignore
+ self.shutting_down.connect(
+ self.abort_questions) # type: ignore[arg-type]
+ self.load_started.connect(
+ self.abort_questions) # type: ignore[arg-type]
# pylint: disable=protected-access
self.audio._connect_signals()
+ self.search.connect_signals()
self._permissions.connect_signals()
self._scripts.connect_signals()
diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py
index c41c61f7f..9f2984f8d 100644
--- a/qutebrowser/browser/webengine/webview.py
+++ b/qutebrowser/browser/webengine/webview.py
@@ -19,6 +19,8 @@
"""The main browser widget for QtWebEngine."""
+import typing
+
from PyQt5.QtCore import pyqtSignal, QUrl, PYQT_VERSION
from PyQt5.QtGui import QPalette
from PyQt5.QtWidgets import QWidget
@@ -66,20 +68,26 @@ class WebEngineView(QWebEngineView):
However, it sometimes isn't, so we use this as a WORKAROUND for
https://bugreports.qt.io/browse/QTBUG-68727
- This got introduced in Qt 5.11.0 and fixed in 5.12.0.
+ The above bug got introduced in Qt 5.11.0 and fixed in 5.12.0.
"""
- if 'lost-focusproxy' not in objects.debug_flags:
- proxy = self.focusProxy()
- if proxy is not None:
- return proxy
+ proxy = self.focusProxy() # type: typing.Optional[QWidget]
+
+ if 'lost-focusproxy' in objects.debug_flags:
+ proxy = None
+
+ if (proxy is not None or
+ not qtutils.version_check('5.11', compiled=False) or
+ qtutils.version_check('5.12', compiled=False)):
+ return proxy
# We don't want e.g. a QMenu.
rwhv_class = 'QtWebEngineCore::RenderWidgetHostViewQtDelegateWidget'
children = [c for c in self.findChildren(QWidget)
if c.isVisible() and c.inherits(rwhv_class)]
- log.webview.debug("Found possibly lost focusProxy: {}"
- .format(children))
+ if children:
+ log.webview.debug("Found possibly lost focusProxy: {}"
+ .format(children))
return children[-1] if children else None
@@ -216,8 +224,7 @@ class WebEnginePage(QWebEnginePage):
self.shutting_down],
escape_msg=escape_msg)
except shared.CallSuper:
- return super().javaScriptPrompt( # type: ignore
- url, js_msg, default)
+ return super().javaScriptPrompt(url, js_msg, default)
def javaScriptAlert(self, url, js_msg):
"""Override javaScriptAlert to use qutebrowser prompts."""
diff --git a/qutebrowser/browser/webkit/cookies.py b/qutebrowser/browser/webkit/cookies.py
index e51350331..4b2070f1d 100644
--- a/qutebrowser/browser/webkit/cookies.py
+++ b/qutebrowser/browser/webkit/cookies.py
@@ -25,8 +25,8 @@ from PyQt5.QtNetwork import QNetworkCookie, QNetworkCookieJar
from PyQt5.QtCore import pyqtSignal, QDateTime
from qutebrowser.config import config
-from qutebrowser.utils import utils, standarddir, objreg
-from qutebrowser.misc import lineparser
+from qutebrowser.utils import utils, standarddir, objreg, log
+from qutebrowser.misc import lineparser, objects
cookie_jar = None
@@ -56,7 +56,13 @@ class RAMCookieJar(QNetworkCookieJar):
Return:
True if one or more cookies are set for 'url', otherwise False.
"""
- if config.val.content.cookies.accept == 'never':
+ accept = config.instance.get('content.cookies.accept', url=url)
+
+ if 'log-cookies' in objects.debug_flags:
+ log.network.debug('Cookie on {} -> applying setting {}'
+ .format(url.toDisplayString(), accept))
+
+ if accept == 'never':
return False
else:
self.changed.emit()
@@ -89,7 +95,8 @@ class CookieJar(RAMCookieJar):
"""Parse cookies from lineparser and store them."""
cookies = [] # type: typing.Sequence[QNetworkCookie]
for line in self._lineparser:
- cookies += QNetworkCookie.parseCookies(line)
+ line_cookies = QNetworkCookie.parseCookies(line)
+ cookies += line_cookies # type: ignore[operator]
self.setAllCookies(cookies)
def purge_old_cookies(self):
@@ -98,7 +105,8 @@ class CookieJar(RAMCookieJar):
# http://doc.qt.io/qt-5/qtwebkitexamples-webkitwidgets-browser-cookiejar-cpp.html
now = QDateTime.currentDateTime()
cookies = [c for c in self.allCookies()
- if c.isSessionCookie() or c.expirationDate() >= now]
+ if c.isSessionCookie() or
+ c.expirationDate() >= now] # type: ignore[operator]
self.setAllCookies(cookies)
def save(self):
diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py
index 66b5132c4..0f5063cfb 100644
--- a/qutebrowser/browser/webkit/network/networkmanager.py
+++ b/qutebrowser/browser/webkit/network/networkmanager.py
@@ -105,8 +105,9 @@ def _is_secure_cipher(cipher):
def init():
"""Disable insecure SSL ciphers on old Qt versions."""
default_ciphers = QSslSocket.defaultCiphers()
- log.init.debug("Default Qt ciphers: {}".format(
- ', '.join(c.name() for c in default_ciphers)))
+ log.init.vdebug( # type: ignore[attr-defined]
+ "Default Qt ciphers: {}".format(
+ ', '.join(c.name() for c in default_ciphers)))
good_ciphers = []
bad_ciphers = []
@@ -116,9 +117,10 @@ def init():
else:
bad_ciphers.append(cipher)
- log.init.debug("Disabling bad ciphers: {}".format(
- ', '.join(c.name() for c in bad_ciphers)))
- QSslSocket.setDefaultCiphers(good_ciphers)
+ if bad_ciphers:
+ log.init.debug("Disabling bad ciphers: {}".format(
+ ', '.join(c.name() for c in bad_ciphers)))
+ QSslSocket.setDefaultCiphers(good_ciphers)
_SavedErrorsType = typing.MutableMapping[urlutils.HostTupleType,
@@ -169,14 +171,15 @@ class NetworkManager(QNetworkAccessManager):
}
self._set_cookiejar()
self._set_cache()
- self.sslErrors.connect(self.on_ssl_errors) # type: ignore
+ self.sslErrors.connect( # type: ignore[attr-defined]
+ self.on_ssl_errors)
self._rejected_ssl_errors = collections.defaultdict(
list) # type: _SavedErrorsType
self._accepted_ssl_errors = collections.defaultdict(
list) # type: _SavedErrorsType
- self.authenticationRequired.connect( # type: ignore
+ self.authenticationRequired.connect( # type: ignore[attr-defined]
self.on_authentication_required)
- self.proxyAuthenticationRequired.connect( # type: ignore
+ self.proxyAuthenticationRequired.connect( # type: ignore[attr-defined]
self.on_proxy_authentication_required)
self.netrc_used = False
@@ -235,7 +238,7 @@ class NetworkManager(QNetworkAccessManager):
errors: A list of errors.
"""
errors = [certificateerror.CertificateErrorWrapper(e) for e in errors]
- log.webview.debug("Certificate errors: {!r}".format(
+ log.network.debug("Certificate errors: {!r}".format(
' / '.join(str(err) for err in errors)))
try:
host_tpl = urlutils.host_tuple(
@@ -251,7 +254,7 @@ class NetworkManager(QNetworkAccessManager):
is_rejected = set(errors).issubset(
self._rejected_ssl_errors[host_tpl])
- log.webview.debug("Already accepted: {} / "
+ log.network.debug("Already accepted: {} / "
"rejected {}".format(is_accepted, is_rejected))
if is_rejected:
@@ -424,7 +427,7 @@ class NetworkManager(QNetworkAccessManager):
if 'log-requests' in objects.debug_flags:
operation = debug.qenum_key(QNetworkAccessManager, op)
operation = operation.replace('Operation', '').upper()
- log.webview.debug("{} {}, first-party {}".format(
+ log.network.debug("{} {}, first-party {}".format(
operation,
req.url().toDisplayString(),
current_url.toDisplayString()))
diff --git a/qutebrowser/browser/webkit/network/networkreply.py b/qutebrowser/browser/webkit/network/networkreply.py
index ff2c1ece5..182292ac1 100644
--- a/qutebrowser/browser/webkit/network/networkreply.py
+++ b/qutebrowser/browser/webkit/network/networkreply.py
@@ -59,12 +59,15 @@ class FixedDataNetworkReply(QNetworkReply):
# For some reason, a segfault will be triggered if these lambdas aren't
# there.
# pylint: disable=unnecessary-lambda
- QTimer.singleShot(0, lambda:
- self.metaDataChanged.emit()) # type: ignore
- QTimer.singleShot(0, lambda:
- self.readyRead.emit()) # type: ignore
- QTimer.singleShot(0, lambda:
- self.finished.emit()) # type: ignore
+ QTimer.singleShot(
+ 0,
+ lambda: self.metaDataChanged.emit()) # type: ignore[attr-defined]
+ QTimer.singleShot(
+ 0,
+ lambda: self.readyRead.emit()) # type: ignore[attr-defined]
+ QTimer.singleShot(
+ 0,
+ lambda: self.finished.emit()) # type: ignore[attr-defined]
@pyqtSlot()
def abort(self):
@@ -120,9 +123,9 @@ class ErrorNetworkReply(QNetworkReply):
self.setOpenMode(QIODevice.ReadOnly)
self.setError(error, errorstring)
QTimer.singleShot(0, lambda:
- self.error.emit(error)) # type: ignore
+ self.error.emit(error)) # type: ignore[attr-defined]
QTimer.singleShot(0, lambda:
- self.finished.emit()) # type: ignore
+ self.finished.emit()) # type: ignore[attr-defined]
def abort(self):
"""Do nothing since it's a fake reply."""
@@ -150,7 +153,7 @@ class RedirectNetworkReply(QNetworkReply):
super().__init__(parent)
self.setAttribute(QNetworkRequest.RedirectionTargetAttribute, new_url)
QTimer.singleShot(0, lambda:
- self.finished.emit()) # type: ignore
+ self.finished.emit()) # type: ignore[attr-defined]
def abort(self):
"""Called when there's e.g. a redirection limit."""
diff --git a/qutebrowser/browser/webkit/network/webkitqutescheme.py b/qutebrowser/browser/webkit/network/webkitqutescheme.py
index 782bcc94a..0dce98765 100644
--- a/qutebrowser/browser/webkit/network/webkitqutescheme.py
+++ b/qutebrowser/browser/webkit/network/webkitqutescheme.py
@@ -48,7 +48,7 @@ def handler(request, operation, current_url):
if ((url.scheme(), url.host(), url.path()) ==
('qute', 'settings', '/set')):
if current_url != QUrl('qute://settings/'):
- log.webview.warning("Blocking malicious request from {} to {}"
+ log.network.warning("Blocking malicious request from {} to {}"
.format(current_url.toDisplayString(),
url.toDisplayString()))
return networkreply.ErrorNetworkReply(
diff --git a/qutebrowser/browser/webkit/webkitelem.py b/qutebrowser/browser/webkit/webkitelem.py
index 1000a9965..de4fbd860 100644
--- a/qutebrowser/browser/webkit/webkitelem.py
+++ b/qutebrowser/browser/webkit/webkitelem.py
@@ -175,6 +175,11 @@ class WebKitElement(webelem.AbstractWebElement):
self._elem.parent())
if elem is None or elem.isNull():
return None
+
+ if typing.TYPE_CHECKING:
+ # pylint: disable=used-before-assignment
+ assert isinstance(self._tab, webkittab.WebKitTab)
+
return WebKitElement(elem, tab=self._tab)
def _rect_on_view_js(self) -> typing.Optional[QRect]:
@@ -189,7 +194,7 @@ class WebKitElement(webelem.AbstractWebElement):
return None
text = utils.compact_text(self._elem.toOuterXml(), 500)
- log.webelem.vdebug( # type: ignore
+ log.webelem.vdebug( # type: ignore[attr-defined]
"Client rectangles of element '{}': {}".format(text, rects))
for i in range(int(rects.get("length", 0))):
diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py
index 4aa0abcf5..1e9276265 100644
--- a/qutebrowser/browser/webkit/webkittab.py
+++ b/qutebrowser/browser/webkit/webkittab.py
@@ -25,6 +25,7 @@ import xml.etree.ElementTree
from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QPoint, QTimer, QSizeF, QSize
from PyQt5.QtGui import QIcon
+from PyQt5.QtWidgets import QWidget
from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame
from PyQt5.QtWebKit import QWebSettings
from PyQt5.QtPrintSupport import QPrinter
@@ -33,6 +34,7 @@ from qutebrowser.browser import browsertab, shared
from qutebrowser.browser.webkit import (webview, tabhistory, webkitelem,
webkitsettings)
from qutebrowser.utils import qtutils, usertypes, utils, log, debug
+from qutebrowser.keyinput import modeman
from qutebrowser.qt import sip
@@ -85,7 +87,10 @@ class WebKitSearch(browsertab.AbstractSearch):
def __init__(self, tab, parent=None):
super().__init__(tab, parent)
- self._flags = QWebPage.FindFlags(0) # type: ignore
+ self._flags = self._empty_flags()
+
+ def _empty_flags(self):
+ return QWebPage.FindFlags(0) # type: ignore[call-overload]
def _call_cb(self, callback, found, text, flags, caller):
"""Call the given callback if it's non-None.
@@ -125,7 +130,7 @@ class WebKitSearch(browsertab.AbstractSearch):
self._widget.findText('', QWebPage.HighlightAllOccurrences)
def search(self, text, *, ignore_case=usertypes.IgnoreCase.never,
- reverse=False, result_cb=None):
+ reverse=False, wrap=True, result_cb=None):
# Don't go to next entry on duplicate search
if self.text == text and self.search_displayed:
log.webview.debug("Ignoring duplicate search request"
@@ -137,11 +142,13 @@ class WebKitSearch(browsertab.AbstractSearch):
self.text = text
self.search_displayed = True
- self._flags = QWebPage.FindWrapsAroundDocument
+ self._flags = self._empty_flags()
if self._is_case_sensitive(ignore_case):
self._flags |= QWebPage.FindCaseSensitively
if reverse:
self._flags |= QWebPage.FindBackward
+ if wrap:
+ self._flags |= QWebPage.FindWrapsAroundDocument
# We actually search *twice* - once to highlight everything, then again
# to get a mark so we can navigate.
found = self._widget.findText(text, self._flags)
@@ -157,7 +164,8 @@ class WebKitSearch(browsertab.AbstractSearch):
def prev_result(self, *, result_cb=None):
self.search_displayed = True
# The int() here makes sure we get a copy of the flags.
- flags = QWebPage.FindFlags(int(self._flags)) # type: ignore
+ flags = QWebPage.FindFlags(
+ int(self._flags)) # type: ignore[call-overload]
if flags & QWebPage.FindBackward:
flags &= ~QWebPage.FindBackward
else:
@@ -170,13 +178,24 @@ class WebKitCaret(browsertab.AbstractCaret):
"""QtWebKit implementations related to moving the cursor/selection."""
+ def __init__(self,
+ tab: 'WebKitTab',
+ mode_manager: modeman.ModeManager,
+ parent: QWidget = None) -> None:
+ super().__init__(mode_manager, parent)
+ self._tab = tab
+ self._selection_state = browsertab.SelectionState.none
+
@pyqtSlot(usertypes.KeyMode)
def _on_mode_entered(self, mode):
if mode != usertypes.KeyMode.caret:
return
- self.selection_enabled = self._widget.hasSelection()
- self.selection_toggled.emit(self.selection_enabled)
+ if self._widget.hasSelection():
+ self._selection_state = browsertab.SelectionState.normal
+ else:
+ self._selection_state = browsertab.SelectionState.none
+ self.selection_toggled.emit(self._selection_state)
settings = self._widget.settings()
settings.setAttribute(QWebSettings.CaretBrowsingEnabled, True)
@@ -191,7 +210,7 @@ class WebKitCaret(browsertab.AbstractCaret):
#
# Note: We can't use hasSelection() here, as that's always
# true in caret mode.
- if not self.selection_enabled:
+ if self._selection_state is browsertab.SelectionState.none:
self._widget.page().currentFrame().evaluateJavaScript(
utils.read_file('javascript/position_caret.js'))
@@ -199,151 +218,189 @@ class WebKitCaret(browsertab.AbstractCaret):
def _on_mode_left(self, _mode):
settings = self._widget.settings()
if settings.testAttribute(QWebSettings.CaretBrowsingEnabled):
- if self.selection_enabled and self._widget.hasSelection():
+ if (self._selection_state is not browsertab.SelectionState.none and
+ self._widget.hasSelection()):
# Remove selection if it exists
self._widget.triggerPageAction(QWebPage.MoveToNextChar)
settings.setAttribute(QWebSettings.CaretBrowsingEnabled, False)
- self.selection_enabled = False
+ self._selection_state = browsertab.SelectionState.none
def move_to_next_line(self, count=1):
- if not self.selection_enabled:
- act = QWebPage.MoveToNextLine
- else:
+ if self._selection_state is not browsertab.SelectionState.none:
act = QWebPage.SelectNextLine
+ else:
+ act = QWebPage.MoveToNextLine
for _ in range(count):
self._widget.triggerPageAction(act)
+ if self._selection_state is browsertab.SelectionState.line:
+ self._select_line_to_end()
def move_to_prev_line(self, count=1):
- if not self.selection_enabled:
- act = QWebPage.MoveToPreviousLine
- else:
+ if self._selection_state is not browsertab.SelectionState.none:
act = QWebPage.SelectPreviousLine
+ else:
+ act = QWebPage.MoveToPreviousLine
for _ in range(count):
self._widget.triggerPageAction(act)
+ if self._selection_state is browsertab.SelectionState.line:
+ self._select_line_to_start()
def move_to_next_char(self, count=1):
- if not self.selection_enabled:
- act = QWebPage.MoveToNextChar
- else:
+ if self._selection_state is browsertab.SelectionState.normal:
act = QWebPage.SelectNextChar
+ elif self._selection_state is browsertab.SelectionState.line:
+ return
+ else:
+ act = QWebPage.MoveToNextChar
for _ in range(count):
self._widget.triggerPageAction(act)
def move_to_prev_char(self, count=1):
- if not self.selection_enabled:
- act = QWebPage.MoveToPreviousChar
- else:
+ if self._selection_state is browsertab.SelectionState.normal:
act = QWebPage.SelectPreviousChar
+ elif self._selection_state is browsertab.SelectionState.line:
+ return
+ else:
+ act = QWebPage.MoveToPreviousChar
for _ in range(count):
self._widget.triggerPageAction(act)
def move_to_end_of_word(self, count=1):
- if not self.selection_enabled:
- act = [QWebPage.MoveToNextWord]
- if utils.is_windows: # pragma: no cover
- act.append(QWebPage.MoveToPreviousChar)
- else:
+ if self._selection_state is browsertab.SelectionState.normal:
act = [QWebPage.SelectNextWord]
if utils.is_windows: # pragma: no cover
act.append(QWebPage.SelectPreviousChar)
+ elif self._selection_state is browsertab.SelectionState.line:
+ return
+ else:
+ act = [QWebPage.MoveToNextWord]
+ if utils.is_windows: # pragma: no cover
+ act.append(QWebPage.MoveToPreviousChar)
for _ in range(count):
for a in act:
self._widget.triggerPageAction(a)
def move_to_next_word(self, count=1):
- if not self.selection_enabled:
- act = [QWebPage.MoveToNextWord]
- if not utils.is_windows: # pragma: no branch
- act.append(QWebPage.MoveToNextChar)
- else:
+ if self._selection_state is browsertab.SelectionState.normal:
act = [QWebPage.SelectNextWord]
if not utils.is_windows: # pragma: no branch
act.append(QWebPage.SelectNextChar)
+ elif self._selection_state is browsertab.SelectionState.line:
+ return
+ else:
+ act = [QWebPage.MoveToNextWord]
+ if not utils.is_windows: # pragma: no branch
+ act.append(QWebPage.MoveToNextChar)
for _ in range(count):
for a in act:
self._widget.triggerPageAction(a)
def move_to_prev_word(self, count=1):
- if not self.selection_enabled:
- act = QWebPage.MoveToPreviousWord
- else:
+ if self._selection_state is browsertab.SelectionState.normal:
act = QWebPage.SelectPreviousWord
+ elif self._selection_state is browsertab.SelectionState.line:
+ return
+ else:
+ act = QWebPage.MoveToPreviousWord
for _ in range(count):
self._widget.triggerPageAction(act)
def move_to_start_of_line(self):
- if not self.selection_enabled:
- act = QWebPage.MoveToStartOfLine
- else:
+ if self._selection_state is browsertab.SelectionState.normal:
act = QWebPage.SelectStartOfLine
+ elif self._selection_state is browsertab.SelectionState.line:
+ return
+ else:
+ act = QWebPage.MoveToStartOfLine
self._widget.triggerPageAction(act)
def move_to_end_of_line(self):
- if not self.selection_enabled:
- act = QWebPage.MoveToEndOfLine
- else:
+ if self._selection_state is browsertab.SelectionState.normal:
act = QWebPage.SelectEndOfLine
+ elif self._selection_state is browsertab.SelectionState.line:
+ return
+ else:
+ act = QWebPage.MoveToEndOfLine
self._widget.triggerPageAction(act)
def move_to_start_of_next_block(self, count=1):
- if not self.selection_enabled:
- act = [QWebPage.MoveToNextLine,
- QWebPage.MoveToStartOfBlock]
- else:
+ if self._selection_state is not browsertab.SelectionState.none:
act = [QWebPage.SelectNextLine,
QWebPage.SelectStartOfBlock]
+ else:
+ act = [QWebPage.MoveToNextLine,
+ QWebPage.MoveToStartOfBlock]
for _ in range(count):
for a in act:
self._widget.triggerPageAction(a)
+ if self._selection_state is browsertab.SelectionState.line:
+ self._select_line_to_end()
def move_to_start_of_prev_block(self, count=1):
- if not self.selection_enabled:
- act = [QWebPage.MoveToPreviousLine,
- QWebPage.MoveToStartOfBlock]
- else:
+ if self._selection_state is not browsertab.SelectionState.none:
act = [QWebPage.SelectPreviousLine,
QWebPage.SelectStartOfBlock]
+ else:
+ act = [QWebPage.MoveToPreviousLine,
+ QWebPage.MoveToStartOfBlock]
for _ in range(count):
for a in act:
self._widget.triggerPageAction(a)
+ if self._selection_state is browsertab.SelectionState.line:
+ self._select_line_to_start()
def move_to_end_of_next_block(self, count=1):
- if not self.selection_enabled:
- act = [QWebPage.MoveToNextLine,
- QWebPage.MoveToEndOfBlock]
- else:
+ if self._selection_state is not browsertab.SelectionState.none:
act = [QWebPage.SelectNextLine,
QWebPage.SelectEndOfBlock]
+ else:
+ act = [QWebPage.MoveToNextLine,
+ QWebPage.MoveToEndOfBlock]
for _ in range(count):
for a in act:
self._widget.triggerPageAction(a)
+ if self._selection_state is browsertab.SelectionState.line:
+ self._select_line_to_end()
def move_to_end_of_prev_block(self, count=1):
- if not self.selection_enabled:
- act = [QWebPage.MoveToPreviousLine, QWebPage.MoveToEndOfBlock]
- else:
+ if self._selection_state is not browsertab.SelectionState.none:
act = [QWebPage.SelectPreviousLine, QWebPage.SelectEndOfBlock]
+ else:
+ act = [QWebPage.MoveToPreviousLine, QWebPage.MoveToEndOfBlock]
for _ in range(count):
for a in act:
self._widget.triggerPageAction(a)
+ if self._selection_state is browsertab.SelectionState.line:
+ self._select_line_to_start()
def move_to_start_of_document(self):
- if not self.selection_enabled:
- act = QWebPage.MoveToStartOfDocument
- else:
+ if self._selection_state is not browsertab.SelectionState.none:
act = QWebPage.SelectStartOfDocument
+ else:
+ act = QWebPage.MoveToStartOfDocument
self._widget.triggerPageAction(act)
+ if self._selection_state is browsertab.SelectionState.line:
+ self._select_line()
def move_to_end_of_document(self):
- if not self.selection_enabled:
- act = QWebPage.MoveToEndOfDocument
- else:
+ if self._selection_state is not browsertab.SelectionState.none:
act = QWebPage.SelectEndOfDocument
+ else:
+ act = QWebPage.MoveToEndOfDocument
self._widget.triggerPageAction(act)
- def toggle_selection(self):
- self.selection_enabled = not self.selection_enabled
- self.selection_toggled.emit(self.selection_enabled)
+ def toggle_selection(self, line=False):
+ if line:
+ self._selection_state = browsertab.SelectionState.line
+ self._select_line()
+ self.reverse_selection()
+ self._select_line()
+ self.reverse_selection()
+ elif self._selection_state is not browsertab.SelectionState.normal:
+ self._selection_state = browsertab.SelectionState.normal
+ else:
+ self._selection_state = browsertab.SelectionState.none
+ self.selection_toggled.emit(self._selection_state)
def drop_selection(self):
self._widget.triggerPageAction(QWebPage.MoveToNextChar)
@@ -360,6 +417,32 @@ class WebKitCaret(browsertab.AbstractCaret):
);
}""")
+ def _select_line(self):
+ self._widget.triggerPageAction(QWebPage.SelectStartOfLine)
+ self.reverse_selection()
+ self._widget.triggerPageAction(QWebPage.SelectEndOfLine)
+ self.reverse_selection()
+
+ def _select_line_to_end(self):
+ # direction of selection (if anchor is to the left or right
+ # of focus) has to be checked before moving selection
+ # to the end of line
+ if self._js_selection_left_to_right():
+ self._widget.triggerPageAction(QWebPage.SelectEndOfLine)
+
+ def _select_line_to_start(self):
+ if not self._js_selection_left_to_right():
+ self._widget.triggerPageAction(QWebPage.SelectStartOfLine)
+
+ def _js_selection_left_to_right(self):
+ """Return True iff the selection's direction is left to right."""
+ return self._tab.private_api.run_js_sync("""
+ var sel = window.getSelection();
+ var position = sel.anchorNode.compareDocumentPosition(sel.focusNode);
+ (!position && sel.anchorOffset < sel.focusOffset ||
+ position === Node.DOCUMENT_POSITION_FOLLOWING);
+ """)
+
def _follow_selected(self, *, tab=False):
if QWebSettings.globalSettings().testAttribute(
QWebSettings.JavascriptEnabled):
@@ -391,11 +474,11 @@ class WebKitCaret(browsertab.AbstractCaret):
if selected_element is not None:
try:
- url = selected_element.attrib['href']
+ href = selected_element.attrib['href']
except KeyError:
raise browsertab.WebTabError('Anchor element without '
'href!')
- url = self._tab.url().resolved(QUrl(url))
+ url = self._tab.url().resolved(QUrl(href))
if tab:
self._tab.new_tab_requested.emit(url)
else:
@@ -580,6 +663,10 @@ class WebKitElements(browsertab.AbstractElements):
"""QtWebKit implemementations related to elements on the page."""
+ def __init__(self, tab: 'WebKitTab') -> None:
+ super().__init__()
+ self._tab = tab
+
def find_css(self, selector, callback, error_cb, *, only_visible=False):
utils.unused(error_cb)
mainframe = self._widget.page().mainFrame()
@@ -691,13 +778,21 @@ class WebKitTabPrivate(browsertab.AbstractTabPrivate):
def shutdown(self):
self._widget.shutdown()
+ def run_js_sync(self, code):
+ document_element = self._widget.page().mainFrame().documentElement()
+ result = document_element.evaluateJavaScript(code)
+ return result
+
class WebKitTab(browsertab.AbstractTab):
"""A QtWebKit tab in the browser."""
def __init__(self, *, win_id, mode_manager, private, parent=None):
- super().__init__(win_id=win_id, private=private, parent=parent)
+ super().__init__(win_id=win_id,
+ mode_manager=mode_manager,
+ private=private,
+ parent=parent)
widget = webview.WebView(win_id=win_id, tab_id=self.tab_id,
private=private, tab=self)
if private:
@@ -749,8 +844,7 @@ class WebKitTab(browsertab.AbstractTab):
def run_js_async(self, code, callback=None, *, world=None):
if world is not None and world != usertypes.JsWorld.jseval:
log.webview.warning("Ignoring world ID {}".format(world))
- document_element = self._widget.page().mainFrame().documentElement()
- result = document_element.evaluateJavaScript(code)
+ result = self.private_api.run_js_sync(code)
if callback is not None:
callback(result)
@@ -847,9 +941,9 @@ class WebKitTab(browsertab.AbstractTab):
if navigation.is_main_frame:
self.settings.update_for_url(navigation.url)
- @pyqtSlot()
- def _on_ssl_errors(self):
- self._has_ssl_errors = True
+ @pyqtSlot('QNetworkReply*')
+ def _on_ssl_errors(self, reply):
+ self._insecure_hosts.add(reply.url().host())
def _connect_signals(self):
view = self._widget
diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py
index d8a61a041..9055bff24 100644
--- a/qutebrowser/browser/webkit/webpage.py
+++ b/qutebrowser/browser/webkit/webpage.py
@@ -21,6 +21,7 @@
import html
import functools
+import typing
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QUrl, QPoint
from PyQt5.QtGui import QDesktopServices
@@ -77,22 +78,24 @@ class BrowserPage(QWebPage):
self.setNetworkAccessManager(self._networkmanager)
self.setForwardUnsupportedContent(True)
self.reloading.connect(self._networkmanager.clear_rejected_ssl_errors)
- self.printRequested.connect( # type: ignore
+ self.printRequested.connect( # type: ignore[attr-defined]
self.on_print_requested)
- self.downloadRequested.connect( # type: ignore
+ self.downloadRequested.connect( # type: ignore[attr-defined]
self.on_download_requested)
- self.unsupportedContent.connect( # type: ignore
+ self.unsupportedContent.connect( # type: ignore[attr-defined]
self.on_unsupported_content)
- self.loadStarted.connect(self.on_load_started) # type: ignore
- self.featurePermissionRequested.connect( # type: ignore
+ self.loadStarted.connect( # type: ignore[attr-defined]
+ self.on_load_started)
+ self.featurePermissionRequested.connect( # type: ignore[attr-defined]
self._on_feature_permission_requested)
- self.saveFrameStateRequested.connect( # type: ignore
+ self.saveFrameStateRequested.connect( # type: ignore[attr-defined]
self.on_save_frame_state_requested)
- self.restoreFrameStateRequested.connect( # type: ignore
+ self.restoreFrameStateRequested.connect( # type: ignore[attr-defined]
self.on_restore_frame_state_requested)
- self.loadFinished.connect( # type: ignore
+ self.loadFinished.connect( # type: ignore[attr-defined]
functools.partial(self._inject_userjs, self.mainFrame()))
- self.frameCreated.connect(self._connect_userjs_signals) # type: ignore
+ self.frameCreated.connect( # type: ignore[attr-defined]
+ self._connect_userjs_signals)
@pyqtSlot('QWebFrame*')
def _connect_userjs_signals(self, frame):
@@ -205,8 +208,10 @@ class BrowserPage(QWebPage):
suggested_file = ""
if info.suggestedFileNames:
suggested_file = info.suggestedFileNames[0]
+
files.fileNames, _ = QFileDialog.getOpenFileNames(
- None, None, suggested_file) # type: ignore
+ None, None, suggested_file) # type: ignore[arg-type]
+
return True
def shutdown(self):
@@ -348,11 +353,11 @@ class BrowserPage(QWebPage):
self.setFeaturePermission, frame, feature,
QWebPage.PermissionDeniedByUser)
- url = frame.url().adjusted(QUrl.RemoveUserInfo |
- QUrl.RemovePath |
- QUrl.RemoveQuery |
- QUrl.RemoveFragment)
-
+ url = frame.url().adjusted(typing.cast(QUrl.FormattingOptions,
+ QUrl.RemoveUserInfo |
+ QUrl.RemovePath |
+ QUrl.RemoveQuery |
+ QUrl.RemoveFragment))
question = shared.feature_permission(
url=url,
option=options[feature], msg=messages[feature],
@@ -411,6 +416,8 @@ class BrowserPage(QWebPage):
def userAgentForUrl(self, url):
"""Override QWebPage::userAgentForUrl to customize the user agent."""
+ if not url.isValid():
+ url = None
return websettings.user_agent(url)
def supportsExtension(self, ext):
diff --git a/qutebrowser/browser/webkit/webview.py b/qutebrowser/browser/webkit/webview.py
index e200e4b6a..6706848dd 100644
--- a/qutebrowser/browser/webkit/webview.py
+++ b/qutebrowser/browser/webkit/webview.py
@@ -87,7 +87,8 @@ class WebView(QWebView):
stylesheet.set_register(self)
def __repr__(self):
- urlstr = self.url().toDisplayString(QUrl.EncodeUnicode) # type: ignore
+ flags = QUrl.EncodeUnicode
+ urlstr = self.url().toDisplayString(flags) # type: ignore[arg-type]
url = utils.elide(urlstr, 100)
return utils.get_repr(self, tab_id=self._tab_id, url=url)
@@ -97,7 +98,7 @@ class WebView(QWebView):
# Copied from:
# https://code.google.com/p/webscraping/source/browse/webkit.py#325
try:
- self.setPage(None) # type: ignore
+ self.setPage(None) # type: ignore[arg-type]
except RuntimeError:
# It seems sometimes Qt has already deleted the QWebView and we
# get: RuntimeError: wrapped C/C++ object of type WebView has been
@@ -180,9 +181,9 @@ class WebView(QWebView):
This is not needed for QtWebEngine, so it's in here.
"""
menu = self.page().createStandardContextMenu()
- self.shutting_down.connect(menu.close) # type: ignore
+ self.shutting_down.connect(menu.close) # type: ignore[arg-type]
mm = modeman.instance(self.win_id)
- mm.entered.connect(menu.close) # type: ignore
+ mm.entered.connect(menu.close) # type: ignore[arg-type]
menu.exec_(e.globalPos())
def showEvent(self, e):
diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py
index 44d639e0a..2672fcd68 100644
--- a/qutebrowser/commands/command.py
+++ b/qutebrowser/commands/command.py
@@ -244,7 +244,7 @@ class Command:
args = self._param_to_argparse_args(param, is_bool)
callsig = debug_utils.format_call(self.parser.add_argument, args,
kwargs, full=False)
- log.commands.vdebug( # type: ignore
+ log.commands.vdebug( # type: ignore[attr-defined]
'Adding arg {} of type {} -> {}'
.format(param.name, typ, callsig))
self.parser.add_argument(*args, **kwargs)
@@ -334,8 +334,8 @@ class Command:
Args:
param: The inspect.Parameter to look at.
"""
- arginfo = self.get_arg_info(param)
- if arginfo.value:
+ arg_info = self.get_arg_info(param)
+ if arg_info.value:
# Filled values are passed 1:1
return None
elif param.kind in [inspect.Parameter.VAR_POSITIONAL,
@@ -409,7 +409,8 @@ class Command:
if hasattr(typing, 'UnionMeta'):
# Python 3.5.2
# pylint: disable=no-member,useless-suppression
- is_union = isinstance(typ, typing.UnionMeta) # type: ignore
+ is_union = isinstance(
+ typ, typing.UnionMeta) # type: ignore[attr-defined]
else:
is_union = getattr(typ, '__origin__', None) is typing.Union
@@ -575,7 +576,7 @@ class Command:
def register(self):
"""Register this command in objects.commands."""
- log.commands.vdebug( # type: ignore
+ log.commands.vdebug( # type: ignore[attr-defined]
"Registering command {} (from {}:{})".format(
self.name, self.handler.__module__, self.handler.__qualname__))
if self.name in objects.commands:
diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py
index 2537e1d9c..76ae1d64f 100644
--- a/qutebrowser/commands/runners.py
+++ b/qutebrowser/commands/runners.py
@@ -94,7 +94,7 @@ def _init_variable_replacements() -> typing.Mapping[str, _ReplacementFunction]:
modified_key = '{' + key + '}'
# x = modified_key is to avoid binding x as a closure
replacements[modified_key] = (
- lambda _, x=modified_key: x) # type: ignore
+ lambda _, x=modified_key: x) # type: ignore[misc]
return replacements
@@ -332,7 +332,7 @@ class CommandRunner(AbstractCommandRunner):
self._win_id = win_id
@contextlib.contextmanager
- def _handle_error(self, safely) -> typing.Iterator[None]:
+ def _handle_error(self, safely: bool) -> typing.Iterator[None]:
"""Show exceptions as errors if safely=True is given."""
try:
yield
diff --git a/qutebrowser/commands/userscripts.py b/qutebrowser/commands/userscripts.py
index 9d9ce3c52..485161600 100644
--- a/qutebrowser/commands/userscripts.py
+++ b/qutebrowser/commands/userscripts.py
@@ -31,6 +31,7 @@ from qutebrowser.commands import runners
from qutebrowser.config import websettings
from qutebrowser.misc import guiprocess
from qutebrowser.browser import downloads
+from qutebrowser.qt import sip
class _QtFIFOReader(QObject):
@@ -59,8 +60,10 @@ class _QtFIFOReader(QObject):
fd = os.open(filepath, os.O_RDWR | os.O_NONBLOCK)
# pylint: enable=no-member,useless-suppression
self._fifo = os.fdopen(fd, 'r')
- self._notifier = QSocketNotifier(fd, QSocketNotifier.Read, self)
- self._notifier.activated.connect(self.read_line) # type: ignore
+ self._notifier = QSocketNotifier(typing.cast(sip.voidptr, fd),
+ QSocketNotifier.Read, self)
+ self._notifier.activated.connect( # type: ignore[attr-defined]
+ self.read_line)
@pyqtSlot()
def read_line(self):
@@ -148,7 +151,8 @@ class _BaseUserscriptRunner(QObject):
log.procs.debug("Both text/HTML stored, kicking off userscript!")
self._run_process(*self._args, **self._kwargs)
- def _run_process(self, cmd, *args, env=None, verbose=False):
+ def _run_process(self, cmd, *args, env=None, verbose=False,
+ output_messages=False):
"""Start the given command.
Args:
@@ -156,15 +160,16 @@ class _BaseUserscriptRunner(QObject):
*args: The arguments to hand to the command
env: A dictionary of environment variables to add.
verbose: Show notifications when the command started/exited.
+ output_messages: Show the output as messages.
"""
assert self._filepath is not None
self._env['QUTE_FIFO'] = self._filepath
if env is not None:
self._env.update(env)
- self._proc = guiprocess.GUIProcess('userscript',
- additional_env=self._env,
- verbose=verbose, parent=self)
+ self._proc = guiprocess.GUIProcess(
+ 'userscript', additional_env=self._env,
+ output_messages=output_messages, verbose=verbose, parent=self)
self._proc.finished.connect(self.on_proc_finished)
self._proc.error.connect(self.on_proc_error)
self._proc.start(cmd, args)
@@ -254,14 +259,15 @@ class _POSIXUserscriptRunner(_BaseUserscriptRunner):
self._filepath = tempfile.mktemp(prefix='qutebrowser-userscript-',
dir=standarddir.runtime())
# pylint: disable=no-member,useless-suppression
- os.mkfifo(self._filepath)
+ os.mkfifo(self._filepath, mode=0o600)
# pylint: enable=no-member,useless-suppression
except OSError as e:
+ self._filepath = None # Make sure it's not used
message.error("Error while creating FIFO: {}".format(e))
return
self._reader = _QtFIFOReader(self._filepath)
- self._reader.got_line.connect(self.got_cmd) # type: ignore
+ self._reader.got_line.connect(self.got_cmd) # type: ignore[arg-type]
@pyqtSlot()
def on_proc_finished(self):
@@ -398,7 +404,8 @@ def _lookup_path(cmd):
raise NotFoundError(cmd, directories)
-def run_async(tab, cmd, *args, win_id, env, verbose=False):
+def run_async(tab, cmd, *args, win_id, env, verbose=False,
+ output_messages=False):
"""Run a userscript after dumping page html/source.
Raises:
@@ -413,15 +420,15 @@ def run_async(tab, cmd, *args, win_id, env, verbose=False):
win_id: The window id the userscript is executed in.
env: A dictionary of variables to add to the process environment.
verbose: Show notifications when the command started/exited.
+ output_messages: Show the output as messages.
"""
- tabbed_browser = objreg.get('tabbed-browser', scope='window',
- window=win_id)
- commandrunner = runners.CommandRunner(win_id, parent=tabbed_browser)
+ tb = objreg.get('tabbed-browser', scope='window', window=win_id)
+ commandrunner = runners.CommandRunner(win_id, parent=tb)
if utils.is_posix:
- runner = _POSIXUserscriptRunner(tabbed_browser)
+ runner = _POSIXUserscriptRunner(tb) # type: _BaseUserscriptRunner
elif utils.is_windows: # pragma: no cover
- runner = _WindowsUserscriptRunner(tabbed_browser)
+ runner = _WindowsUserscriptRunner(tb)
else: # pragma: no cover
raise UnsupportedError
@@ -451,7 +458,8 @@ def run_async(tab, cmd, *args, win_id, env, verbose=False):
runner.finished.connect(commandrunner.deleteLater)
runner.finished.connect(runner.deleteLater)
- runner.prepare_run(cmd_path, *args, env=env, verbose=verbose)
+ runner.prepare_run(cmd_path, *args, env=env, verbose=verbose,
+ output_messages=output_messages)
tab.dump_async(runner.store_html)
tab.dump_async(runner.store_text, plain=True)
return runner
diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py
index b80c81654..11e7c96d8 100644
--- a/qutebrowser/completion/completiondelegate.py
+++ b/qutebrowser/completion/completiondelegate.py
@@ -290,7 +290,7 @@ class CompletionItemDelegate(QStyledItemDelegate):
size = self._style.sizeFromContents(QStyle.CT_ItemViewItem, self._opt,
docsize, self._opt.widget)
qtutils.ensure_valid(size)
- return size + QSize(10, 3)
+ return size + QSize(10, 3) # type: ignore[operator]
def paint(self, painter, option, index):
"""Override the QStyledItemDelegate paint function.
diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py
index 4187ee28e..26fbcdf4f 100644
--- a/qutebrowser/completion/completionwidget.py
+++ b/qutebrowser/completion/completionwidget.py
@@ -278,7 +278,7 @@ class CompletionView(QTreeView):
selmodel.setCurrentIndex(
idx,
- QItemSelectionModel.ClearAndSelect | # type: ignore
+ QItemSelectionModel.ClearAndSelect | # type: ignore[arg-type]
QItemSelectionModel.Rows)
# if the last item is focused, try to fetch more
@@ -424,4 +424,8 @@ class CompletionView(QTreeView):
if not index.isValid():
raise cmdutils.CommandError("No item selected!")
text = self.model().data(index)
+
+ if not utils.supports_selection():
+ sel = False
+
utils.set_clipboard(text, selection=sel)
diff --git a/qutebrowser/completion/models/completionmodel.py b/qutebrowser/completion/models/completionmodel.py
index d28ca8907..e5f28e0e7 100644
--- a/qutebrowser/completion/models/completionmodel.py
+++ b/qutebrowser/completion/models/completionmodel.py
@@ -183,11 +183,13 @@ class CompletionModel(QAbstractItemModel):
# WORKAROUND:
# layoutChanged is broken in PyQt 5.7.1, so we must use metaObject
# https://www.riverbankcomputing.com/pipermail/pyqt/2017-January/038483.html
- self.metaObject().invokeMethod(self, # type: ignore
- "layoutAboutToBeChanged")
+ meta = self.metaObject()
+ meta.invokeMethod(self, # type: ignore[misc, call-overload]
+ "layoutAboutToBeChanged")
for cat in self._categories:
cat.set_pattern(pattern)
- self.metaObject().invokeMethod(self, "layoutChanged") # type: ignore
+ meta.invokeMethod(self, # type: ignore[misc, call-overload]
+ "layoutChanged")
def first_item(self):
"""Return the index of the first child (non-category) in the model."""
diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py
index 14f9a1163..5ce9c56d2 100644
--- a/qutebrowser/completion/models/miscmodels.py
+++ b/qutebrowser/completion/models/miscmodels.py
@@ -97,11 +97,13 @@ def session(*, info=None): # pylint: disable=unused-argument
return model
-def _buffer(skip_win_id=None):
+def _buffer(*, win_id_filter=lambda _win_id: True, add_win_id=True):
"""Helper to get the completion model for buffer/other_buffer.
Args:
- skip_win_id: The id of the window to skip, or None to include all.
+ win_id_filter: A filter function for window IDs to include.
+ Should return True for all included windows.
+ add_win_id: Whether to add the window ID to the completion items.
"""
def delete_buffer(data):
"""Close the selected tab."""
@@ -117,8 +119,9 @@ def _buffer(skip_win_id=None):
windows = [] # type: typing.List[typing.Tuple[str, str, str]]
for win_id in objreg.window_registry:
- if skip_win_id is not None and win_id == skip_win_id:
+ if not win_id_filter(win_id):
continue
+
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=win_id)
if tabbed_browser.shutting_down:
@@ -126,14 +129,18 @@ def _buffer(skip_win_id=None):
tabs = [] # type: typing.List[typing.Tuple[str, str, str]]
for idx in range(tabbed_browser.widget.count()):
tab = tabbed_browser.widget.widget(idx)
- tabs.append(("{}/{}".format(win_id, idx + 1),
+ tab_str = ("{}/{}".format(win_id, idx + 1) if add_win_id
+ else str(idx + 1))
+ tabs.append((tab_str,
tab.url().toDisplayString(),
tabbed_browser.widget.page_title(idx)))
+
if tabs_are_windows:
windows += tabs
else:
+ title = str(win_id) if add_win_id else "Tabs"
cat = listcategory.ListCategory(
- str(win_id), tabs, delete_func=delete_buffer, sort=False)
+ title, tabs, delete_func=delete_buffer, sort=False)
model.add_category(cat)
if tabs_are_windows:
@@ -157,7 +164,22 @@ def other_buffer(*, info):
Used for the tab-take command.
"""
- return _buffer(skip_win_id=info.win_id)
+ return _buffer(win_id_filter=lambda win_id: win_id != info.win_id)
+
+
+def tab_focus(*, info):
+ """A model to complete on open tabs in the current window."""
+ model = _buffer(win_id_filter=lambda win_id: win_id == info.win_id,
+ add_win_id=False)
+
+ special = [
+ ("last", "Focus the last-focused tab"),
+ ("stack-next", "Go forward through a stack of focused tabs"),
+ ("stack-prev", "Go backward through a stack of focused tabs"),
+ ]
+ model.add_category(listcategory.ListCategory("Special", special))
+
+ return model
def window(*, info):
diff --git a/qutebrowser/completion/models/urlmodel.py b/qutebrowser/completion/models/urlmodel.py
index 3e9b26aaa..ff83a598a 100644
--- a/qutebrowser/completion/models/urlmodel.py
+++ b/qutebrowser/completion/models/urlmodel.py
@@ -21,6 +21,9 @@
import typing
+if typing.TYPE_CHECKING:
+ from PyQt5.QtCore import QAbstractItemModel
+
from qutebrowser.completion.models import (completionmodel, listcategory,
histcategory)
from qutebrowser.browser import history
@@ -74,7 +77,7 @@ def url(*, info):
if k != 'DEFAULT']
# pylint: enable=bad-config-option
categories = config.val.completion.open_categories
- models = {}
+ models = {} # type: typing.Dict[str, QAbstractItemModel]
if searchengines and 'searchengines' in categories:
models['searchengines'] = listcategory.ListCategory(
diff --git a/qutebrowser/components/adblock.py b/qutebrowser/components/adblock.py
index 37e89f760..79f1d80d7 100644
--- a/qutebrowser/components/adblock.py
+++ b/qutebrowser/components/adblock.py
@@ -33,7 +33,7 @@ from qutebrowser.api import (cmdutils, hook, config, message, downloads,
interceptor, apitypes, qtutils)
-logger = logging.getLogger('misc')
+logger = logging.getLogger('network')
_host_blocker = typing.cast('HostBlocker', None)
@@ -139,8 +139,8 @@ class HostBlocker:
"""Block the given request if necessary."""
if self._is_blocked(request_url=info.request_url,
first_party_url=info.first_party_url):
- logger.info("Request to {} blocked by host blocker."
- .format(info.request_url.host()))
+ logger.debug("Request to {} blocked by host blocker."
+ .format(info.request_url.host()))
info.block()
def _read_hosts_line(self, raw_line: bytes) -> typing.Set[str]:
diff --git a/qutebrowser/components/caretcommands.py b/qutebrowser/components/caretcommands.py
index 173653bd9..966b193de 100644
--- a/qutebrowser/components/caretcommands.py
+++ b/qutebrowser/components/caretcommands.py
@@ -185,9 +185,13 @@ def move_to_end_of_document(tab: apitypes.Tab) -> None:
@cmdutils.register(modes=[cmdutils.KeyMode.caret])
@cmdutils.argument('tab', value=cmdutils.Value.cur_tab)
-def toggle_selection(tab: apitypes.Tab) -> None:
- """Toggle caret selection mode."""
- tab.caret.toggle_selection()
+def toggle_selection(tab: apitypes.Tab, line: bool = False) -> None:
+ """Toggle caret selection mode.
+
+ Args:
+ line: Enables line-selection.
+ """
+ tab.caret.toggle_selection(line)
@cmdutils.register(modes=[cmdutils.KeyMode.caret])
diff --git a/qutebrowser/components/misccommands.py b/qutebrowser/components/misccommands.py
index e3fecab5a..b8c4b98b4 100644
--- a/qutebrowser/components/misccommands.py
+++ b/qutebrowser/components/misccommands.py
@@ -71,8 +71,10 @@ def _print_preview(tab: apitypes.Tab) -> None:
tab.printing.check_preview_support()
diag = QPrintPreviewDialog(tab)
diag.setAttribute(Qt.WA_DeleteOnClose)
- diag.setWindowFlags(diag.windowFlags() | Qt.WindowMaximizeButtonHint |
- Qt.WindowMinimizeButtonHint)
+ diag.setWindowFlags(
+ diag.windowFlags() | # type: ignore[operator, arg-type]
+ Qt.WindowMaximizeButtonHint |
+ Qt.WindowMinimizeButtonHint)
diag.paintRequested.connect(functools.partial(
tab.printing.to_printer, callback=print_callback))
diag.exec_()
diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py
index a38001e75..007b44404 100644
--- a/qutebrowser/config/config.py
+++ b/qutebrowser/config/config.py
@@ -206,7 +206,7 @@ class KeyConfig:
'mode'.format(key, mode))
self._validate(key, mode)
- log.keyboard.vdebug( # type: ignore
+ log.keyboard.vdebug( # type: ignore[attr-defined]
"Adding binding {} -> {} in mode {}.".format(key, command, mode))
bindings = self._config.get_mutable_obj('bindings.commands')
diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py
index 1d6bb563c..20702be10 100644
--- a/qutebrowser/config/configcommands.py
+++ b/qutebrowser/config/configcommands.py
@@ -262,6 +262,23 @@ class ConfigCommands:
self._config.unset(option, save_yaml=not temp)
@cmdutils.register(instance='config-commands')
+ @cmdutils.argument('win_id', value=cmdutils.Value.win_id)
+ def config_diff(self, win_id: int, old: bool = False) -> None:
+ """Show all customized options.
+
+ Args:
+ old: Show difference for the pre-v1.0 files
+ (qutebrowser.conf/keys.conf).
+ """
+ url = QUrl('qute://configdiff')
+ if old:
+ url.setPath('/old')
+
+ tabbed_browser = objreg.get('tabbed-browser',
+ scope='window', window=win_id)
+ tabbed_browser.load_url(url, newtab=False)
+
+ @cmdutils.register(instance='config-commands')
@cmdutils.argument('option', completion=configmodel.list_option)
def config_list_add(self, option: str, value: str,
temp: bool = False) -> None:
@@ -443,9 +460,9 @@ class ConfigCommands:
if filename is None:
filename = standarddir.config_py()
else:
+ filename = os.path.expanduser(filename)
if not os.path.isabs(filename):
filename = os.path.join(standarddir.config(), filename)
- filename = os.path.expanduser(filename)
if os.path.exists(filename) and not force:
raise cmdutils.CommandError("{} already exists - use --force to "
diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml
index 7f214181d..c029d7a5a 100644
--- a/qutebrowser/config/configdata.yml
+++ b/qutebrowser/config/configdata.yml
@@ -48,6 +48,16 @@ search.incremental:
default: True
desc: Find text on a page incrementally, renewing the search for each typed character.
+search.wrap:
+ type: Bool
+ default: True
+ backend:
+ QtWebEngine: Qt 5.14
+ QtWebKit: true
+ desc: >-
+ Wrap around at the top and bottom of the page when advancing through text matches
+ using `:search-next` and `:search-prev`.
+
new_instance_open_target:
type:
name: String
@@ -341,6 +351,7 @@ content.cookies.accept:
backend:
QtWebKit: true
QtWebEngine: Qt 5.11
+ supports_pattern: true
type:
name: String
valid_values:
@@ -351,7 +362,16 @@ content.cookies.accept:
a cookie is already set for the domain. On QtWebEngine, this is the
same as no-3rdparty."
- never: "Don't accept cookies at all."
- desc: Which cookies to accept.
+ desc: >-
+ Which cookies to accept.
+
+ With QtWebEngine, this setting also controls other features with tracking
+ capabilities similar to those of cookies; including IndexedDB, DOM storage,
+ filesystem API, service workers, and AppCache.
+
+ Note that with QtWebKit, only `all` and `never` are supported as per-domain
+ values. Setting `no-3rdparty` or `no-unknown-3rdparty` per-domain on
+ QtWebKit will have the same effect as `all`.
content.cookies.store:
default: true
@@ -370,12 +390,46 @@ content.default_encoding:
The encoding must be a string describing an encoding such as _utf-8_,
_iso-8859-1_, etc.
+content.unknown_url_scheme_policy:
+ type:
+ name: String
+ valid_values:
+ - disallow: "Disallows all navigation requests to URLs with unknown
+ schemes."
+ - allow-from-user-interaction: "Allows navigation requests to URLs with
+ unknown schemes that are issued from user-interaction (like a
+ mouse-click), whereas other navigation requests (for example from
+ JavaScript) are suppressed."
+ - allow-all: "Allows all navigation requests to URLs with unknown
+ schemes."
+ default: allow-from-user-interaction
+ backend:
+ QtWebEngine: Qt 5.11
+ QtWebKit: false
+ supports_pattern: true
+ desc: >-
+ How navigation requests to URLs with unknown schemes are handled.
+
content.windowed_fullscreen:
+ renamed: content.fullscreen.window
+
+content.fullscreen.window:
type: Bool
default: false
desc: >-
Limit fullscreen to the browser window (does not expand to fill the screen).
+content.fullscreen.overlay_timeout:
+ type:
+ name: Int
+ minval: 0
+ maxval: maxint
+ default: 3000
+ desc: >-
+ Set fullscreen notification overlay timeout in milliseconds.
+
+ If set to 0, no overlay will be displayed.
+
content.desktop_capture:
type: BoolAsk
default: ask
@@ -390,7 +444,7 @@ content.developer_extras:
deleted: true
content.dns_prefetch:
- default: false
+ default: true
type: Bool
backend:
QtWebKit: true
@@ -508,11 +562,11 @@ content.headers.user_agent:
completions:
# See https://techblog.willshouse.com/2012/01/03/most-common-user-agents/
- - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
- like Gecko) Chrome/79.0.3945.117 Safari/537.36"
- - Chrome 79 Win10
+ like Gecko) Chrome/81.0.4044.129 Safari/537.36"
+ - Chrome 80 Win10
- - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like
- Gecko) Chrome/79.0.3945.117 Safari/537.36"
- - Chrome 79 Linux
+ Gecko) Chrome/81.0.4044.138 Safari/537.36"
+ - Chrome 80 Linux
supports_pattern: true
desc: |
User agent to send.
@@ -531,7 +585,9 @@ content.headers.user_agent:
The default value is equal to the unchanged user agent of
QtWebKit/QtWebEngine.
- Note that the value read from JavaScript is always the global value.
+ Note that the value read from JavaScript is always the global value. With
+ QtWebEngine between 5.12 and 5.14 (inclusive), changing the value exposed
+ to JavaScript requires a restart.
content.host_blocking.enabled:
renamed: content.blocking.hosts.enabled
@@ -1120,6 +1176,22 @@ hints.border:
type: String
desc: CSS border value for hints.
+hints.padding:
+ default:
+ top: 0
+ bottom: 0
+ left: 3
+ right: 3
+ type: Padding
+ desc: Padding (in pixels) for hints.
+
+hints.radius:
+ default: 3
+ type:
+ name: Int
+ minval: 0
+ desc: Rounding radius (in pixels) for the edges of hints.
+
hints.chars:
default: asdfghjkl
type:
@@ -1338,6 +1410,19 @@ input.links_included_in_focus_chain:
supports_pattern: true
desc: Include hyperlinks in the keyboard focus chain when tabbing.
+input.mouse.back_forward_buttons:
+ default: true
+ type: Bool
+ desc: Enable back and forward buttons on the mouse.
+
+input.mouse.rocker_gestures:
+ default: false
+ type: Bool
+ desc: >-
+ Enable Opera-like mouse rocker gestures.
+
+ This disables the context menu.
+
input.partial_timeout:
default: 5000
type:
@@ -1351,12 +1436,7 @@ input.partial_timeout:
cleared after this time.
input.rocker_gestures:
- default: false
- type: Bool
- desc: >-
- Enable Opera-like mouse rocker gestures.
-
- This disables the context menu.
+ renamed: input.mouse.rocker_gestures
input.spatial_navigation:
default: false
@@ -1423,7 +1503,7 @@ messages.unfocused:
prompt.filebrowser:
type: Bool
default: true
- desc: Show a filebrowser in upload/download prompts.
+ desc: Show a filebrowser in download prompts.
prompt.radius:
type:
@@ -1442,8 +1522,12 @@ scrolling.bar:
- never: Never show the scrollbar.
- when-searching: Show the scrollbar when searching for text in the
webpage. With the QtWebKit backend, this is equal to `never`.
- default: when-searching
- desc: When to show the scrollbar.
+ - overlay: Show an overlay scrollbar. With Qt < 5.11 or on macOS, this is
+ unavailable and equal to `when-searching`; with the QtWebKit
+ backend, this is equal to `never`. Enabling/disabling overlay
+ scrollbars requires a restart.
+ default: overlay
+ desc: When/how to show the scrollbar.
scrolling.smooth:
type: Bool
@@ -1518,10 +1602,15 @@ spellcheck.languages:
## statusbar
-statusbar.hide:
- type: Bool
- default: false
- desc: Hide the statusbar unless a message is shown.
+statusbar.show:
+ default: always
+ type:
+ name: String
+ valid_values:
+ - always: Always show the statusbar.
+ - never: Always hide the statusbar.
+ - in-mode: Show the statusbar when in modes other than normal mode.
+ desc: When to show the statusbar.
statusbar.padding:
type: Padding
@@ -1912,17 +2001,29 @@ url.searchengines:
name: String
forbidden: ' '
valtype: SearchEngineUrl
- desc: >-
+ desc: |
Search engines which can be used via the address bar.
Maps a search engine name (such as `DEFAULT`, or `ddg`) to a URL with a
`{}` placeholder. The placeholder will be replaced by the search term, use
- `{{` and `}}` for literal `{`/`}` signs.
+ `{{` and `}}` for literal `{`/`}` braces.
+
+ The following further placeholds are defined to configure how special
+ characters in the search terms are replaced by safe characters (called
+ 'quoting'):
+
+ * `{}` and `{semiquoted}` quote everything except slashes; this is the most
+ sensible choice for almost all search engines (for the search term
+ `slash/and&amp` this placeholder expands to `slash/and%26amp`).
+ * `{quoted}` quotes all characters (for `slash/and&amp` this placeholder
+ expands to `slash%2Fand%26amp`).
+ * `{unquoted}` quotes nothing (for `slash/and&amp` this placeholder
+ expands to `slash/and&amp`).
The search engine named `DEFAULT` is used when `url.auto_search` is turned
on and something else than a URL was entered to be opened. Other search
engines can be used by prepending the search engine name to the search
- term, e.g. `:open google qutebrowser`.
+ term, e.g. `:open google qutebrowser`.
url.start_pages:
type:
@@ -2155,6 +2256,26 @@ colors.contextmenu.selected.fg:
If set to null, the Qt default is used.
+colors.contextmenu.disabled.bg:
+ type:
+ name: QssColor
+ none_ok: true
+ default: null
+ desc: >-
+ Background color of disabled items in the context menu.
+
+ If set to null, the Qt default is used.
+
+colors.contextmenu.disabled.fg:
+ type:
+ name: QssColor
+ none_ok: true
+ default: null
+ desc: >-
+ Foreground color of disabled items in the context menu.
+
+ If set to null, the Qt default is used.
+
colors.downloads.bar.bg:
default: black
type: QssColor
@@ -2539,6 +2660,169 @@ colors.webpage.prefers_color_scheme_dark:
QtWebEngine: Qt 5.14
QtWebKit: false
+## dark mode
+
+colors.webpage.darkmode.enabled:
+ default: false
+ type: Bool
+ desc: >-
+ Render all web contents using a dark theme.
+
+ Example configurations from Chromium's `chrome://flags`:
+
+
+ - "With simple HSL/CIELAB/RGB-based inversion": Set
+ `colors.webpage.darkmode.algorithm` accordingly.
+
+ - "With selective image inversion": Set
+ `colors.webpage.darkmode.policy.images` to `smart`.
+
+ - "With selective inversion of non-image elements": Set
+ `colors.webpage.darkmode.threshold.text` to 150 and
+ `colors.webpage.darkmode.threshold.background` to 205.
+
+ - "With selective inversion of everything": Combines the two variants
+ above.
+ restart: true
+ backend:
+ QtWebEngine: Qt 5.14
+ QtWebKit: false
+
+colors.webpage.darkmode.algorithm:
+ default: lightness-cielab
+ desc: "Which algorithm to use for modifying how colors are rendered with
+ darkmode."
+ type:
+ name: String
+ valid_values:
+ - lightness-cielab: Modify colors by converting them to CIELAB color
+ space and inverting the L value.
+ - lightness-hsl: Modify colors by converting them to the HSL color space
+ and inverting the lightness (i.e. the "L" in HSL).
+ - brightness-rgb: Modify colors by subtracting each of r, g, and b from
+ their maximum value.
+ # kSimpleInvertForTesting is not exposed, as it's equivalent to
+ # kInvertBrightness without gamma correction, and only available for
+ # Chromium's automated tests
+ restart: true
+ backend:
+ QtWebEngine: Qt 5.14
+ QtWebKit: false
+
+colors.webpage.darkmode.contrast:
+ default: 0.0
+ type:
+ name: Float
+ minval: -1.0
+ maxval: 1.0
+ desc: >-
+ Contrast for dark mode.
+
+ This only has an effect when `colors.webpage.darkmode.algorithm` is set to
+ `lightness-hsl` or `brightness-rgb`.
+ restart: true
+ backend:
+ QtWebEngine: Qt 5.14
+ QtWebKit: false
+
+colors.webpage.darkmode.policy.images:
+ default: never
+ type:
+ name: String
+ valid_values:
+ - always: Apply dark mode filter to all images.
+ - never: Never apply dark mode filter to any images.
+ - smart: Apply dark mode based on image content.
+ desc: >-
+ Which images to apply dark mode to.
+
+ WARNING: On Qt 5.15.0, this setting can cause frequent renderer process
+ crashes due to a
+ https://codereview.qt-project.org/c/qt/qtwebengine-chromium/+/304211[bug in Qt].
+ restart: true
+ backend:
+ QtWebEngine: Qt 5.14
+ QtWebKit: false
+
+colors.webpage.darkmode.policy.page:
+ default: smart
+ type:
+ name: String
+ valid_values:
+ - always: Apply dark mode filter to all frames, regardless of content.
+ - smart: Apply dark mode filter to frames based on background color.
+ desc: Which pages to apply dark mode to.
+ restart: true
+ backend:
+ QtWebEngine: Qt 5.14
+ QtWebKit: false
+
+colors.webpage.darkmode.threshold.text:
+ default: 256
+ type:
+ name: Int
+ minval: 0
+ maxval: 256
+ desc: >-
+ Threshold for inverting text with dark mode.
+
+ Text colors with brightness below this threshold will be inverted, and
+ above it will be left as in the original, non-dark-mode page. Set to 256
+ to always invert text color or to 0 to never invert text color.
+ restart: true
+ backend:
+ QtWebEngine: Qt 5.14
+ QtWebKit: false
+
+colors.webpage.darkmode.threshold.background:
+ default: 0
+ type:
+ name: Int
+ minval: 0
+ maxval: 256
+ desc: >-
+ Threshold for inverting background elements with dark mode.
+
+ Background elements with brightness above this threshold will be inverted,
+ and below it will be left as in the original, non-dark-mode page. Set to
+ 256 to never invert the color or to 0 to always invert it.
+
+ Note: This behavior is the opposite of
+ `colors.webpage.darkmode.threshold.text`!
+ restart: true
+ backend:
+ QtWebEngine: Qt 5.14
+ QtWebKit: false
+
+colors.webpage.darkmode.grayscale.all:
+ default: false
+ type: Bool
+ desc: >-
+ Render all colors as grayscale.
+
+ This only has an effect when `colors.webpage.darkmode.algorithm` is set to
+ `lightness-hsl` or `brightness-rgb`.
+ restart: true
+ backend:
+ QtWebEngine: Qt 5.14
+ QtWebKit: false
+
+colors.webpage.darkmode.grayscale.images:
+ default: 0.0
+ type:
+ name: Float
+ minval: 0.0
+ maxval: 1.0
+ desc: >-
+ Desaturation factor for images in dark mode.
+
+ If set to 0, images are left as-is. If set to 1, images are completely
+ grayscale. Values between 0 and 1 desaturate the colors accordingly.
+ restart: true
+ backend:
+ QtWebEngine: Qt 5.14
+ QtWebKit: false
+
# emacs: '
## fonts
@@ -2593,7 +2877,7 @@ fonts.contextmenu:
fonts.debug_console:
default: default_size default_family
- type: QtFont
+ type: Font
desc: Font used for the debugging console.
fonts.downloads:
@@ -2636,10 +2920,15 @@ fonts.statusbar:
type: Font
desc: Font used in the statusbar.
-fonts.tabs:
+fonts.tabs.selected:
default: default_size default_family
- type: QtFont
- desc: Font used in the tab bar.
+ type: Font
+ desc: Font used for selected tabs.
+
+fonts.tabs.unselected:
+ default: default_size default_family
+ type: Font
+ desc: Font used for unselected tabs.
fonts.web.family.standard:
default: ''
@@ -2730,6 +3019,7 @@ bindings.key_mappings:
<Ctrl-6>: <Ctrl-^>
<Ctrl-M>: <Return>
<Ctrl-J>: <Return>
+ <Ctrl-I>: <Tab>
<Shift-Return>: <Return>
<Enter>: <Return>
<Shift-Enter>: <Return>
@@ -2827,6 +3117,7 @@ bindings.default:
N: search-prev
i: enter-mode insert
v: enter-mode caret
+ V: enter-mode caret ;; toggle-selection --line
"`": enter-mode set_mark
"'": enter-mode jump_mark
yy: yank
@@ -2930,6 +3221,12 @@ bindings.default:
tIH: config-cycle -p -u *://*.{url:host}/* content.images ;; reload
tiu: config-cycle -p -t -u {url} content.images ;; reload
tIu: config-cycle -p -u {url} content.images ;; reload
+ tch: config-cycle -p -t -u *://{url:host}/* content.cookies.accept all no-3rdparty never ;; reload
+ tCh: config-cycle -p -u *://{url:host}/* content.cookies.accept all no-3rdparty never ;; reload
+ tcH: config-cycle -p -t -u *://*.{url:host}/* content.cookies.accept all no-3rdparty never ;; reload
+ tCH: config-cycle -p -u *://*.{url:host}/* content.cookies.accept all no-3rdparty never ;; reload
+ tcu: config-cycle -p -t -u {url} content.cookies.accept all no-3rdparty never ;; reload
+ tCu: config-cycle -p -u {url} content.cookies.accept all no-3rdparty never ;; reload
insert:
<Ctrl-E>: open-editor
<Shift-Ins>: insert-text -- {primary}
@@ -3008,6 +3305,7 @@ bindings.default:
<Escape>: leave-mode
caret:
v: toggle-selection
+ V: toggle-selection --line
<Space>: toggle-selection
<Ctrl-Space>: drop-selection
c: enter-mode normal
@@ -3145,3 +3443,18 @@ bindings.commands:
* register: Entered when qutebrowser is waiting for a register name/key for
commands like `:set-mark`.
+
+## logging
+
+logging.level.ram:
+ default: debug
+ type: LogLevel
+ desc:
+ Level for in-memory logs.
+
+logging.level.console:
+ default: info
+ type: LogLevel
+ desc: >-
+ Level for console (stdout/stderr) logs.
+ Ignored if the `--loglevel` or `--debug` CLI flags are used.
diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py
index 63cab9377..a2c4db3f2 100644
--- a/qutebrowser/config/configfiles.py
+++ b/qutebrowser/config/configfiles.py
@@ -161,7 +161,7 @@ class YamlConfig(QObject):
# Instead, create a config.py - see :help for details.
""".lstrip('\n')))
- utils.yaml_dump(data, f) # type: ignore
+ utils.yaml_dump(data, f)
def _pop_object(self,
yaml_data: typing.Any,
@@ -319,7 +319,7 @@ class YamlMigrations(QObject):
self._migrate_font_replacements()
self._migrate_bool('tabs.favicons.show', 'always', 'never')
- self._migrate_bool('scrolling.bar', 'always', 'when-searching')
+ self._migrate_bool('scrolling.bar', 'always', 'overlay')
self._migrate_bool('qt.force_software_rendering',
'software-opengl', 'none')
self._migrate_renamed_bool(
@@ -332,6 +332,11 @@ class YamlMigrations(QObject):
new_name='tabs.mode_on_change',
true_value='persist',
false_value='normal')
+ self._migrate_renamed_bool(
+ old_name='statusbar.hide',
+ new_name='statusbar.show',
+ true_value='never',
+ false_value='always')
for setting in ['tabs.title.format',
'tabs.title.format_pinned',
@@ -340,6 +345,10 @@ class YamlMigrations(QObject):
r'(?<!{)\{title\}(?!})',
r'{current_title}')
+ self._migrate_to_multiple('fonts.tabs',
+ ('fonts.tabs.selected',
+ 'fonts.tabs.unselected'))
+
# content.headers.user_agent can't be empty to get the default anymore.
setting = 'content.headers.user_agent'
self._migrate_none(setting, configdata.DATA[setting].default)
@@ -400,7 +409,7 @@ class YamlMigrations(QObject):
except KeyError:
continue
- if not isinstance(opt.typ, configtypes.Font):
+ if not isinstance(opt.typ, configtypes.FontBase):
continue
for scope, val in self._settings[name].items():
@@ -446,6 +455,19 @@ class YamlMigrations(QObject):
self._settings[name][scope] = value
self.changed.emit()
+ def _migrate_to_multiple(self, old_name: str,
+ new_names: typing.Iterable[str]) -> None:
+ if old_name not in self._settings:
+ return
+
+ for new_name in new_names:
+ self._settings[new_name] = {}
+ for scope, val in self._settings[old_name].items():
+ self._settings[new_name][scope] = val
+
+ del self._settings[old_name]
+ self.changed.emit()
+
def _migrate_string_value(self, name: str,
source: str,
target: str) -> None:
@@ -530,14 +552,10 @@ class ConfigAPI:
with self._handle_error('binding', key):
seq = keyutils.KeySequence.parse(key)
if command is None:
- text = ("Unbinding commands with config.bind('{key}', None) "
- "is deprecated. Use config.unbind('{key}') instead."
- .format(key=key))
- self.errors.append(configexc.ConfigErrorDesc(
- "While unbinding '{}'".format(key), text))
- self._keyconfig.unbind(seq, mode=mode)
- else:
- self._keyconfig.bind(seq, command, mode=mode)
+ raise configexc.Error("Can't bind {key} to None (maybe you "
+ "want to use config.unbind('{key}') "
+ "instead?)".format(key=key))
+ self._keyconfig.bind(seq, command, mode=mode)
def unbind(self, key: str, mode: str = 'normal') -> None:
"""Unbind a key from a command, with an optional key mode."""
@@ -607,6 +625,17 @@ class ConfigPyWriter:
def _gen_header(self) -> typing.Iterator[str]:
"""Generate the initial header of the config."""
yield self._line("# Autogenerated config.py")
+ yield self._line("#")
+
+ note = ("NOTE: config.py is intended for advanced users who are "
+ "comfortable with manually migrating the config file on "
+ "qutebrowser upgrades. If you prefer, you can also configure "
+ "qutebrowser using the :set/:bind/:config-* commands without "
+ "having to write a config.py file.")
+ for line in textwrap.wrap(note):
+ yield self._line("# {}".format(line))
+
+ yield self._line("#")
yield self._line("# Documentation:")
yield self._line("# qute://help/configuring.html")
yield self._line("# qute://help/settings.html")
@@ -696,8 +725,8 @@ def read_config_py(filename: str, raising: bool = False) -> None:
basename = os.path.basename(filename)
module = types.ModuleType('config')
- module.config = api # type: ignore
- module.c = container # type: ignore
+ module.config = api # type: ignore[attr-defined]
+ module.c = container # type: ignore[attr-defined]
module.__file__ = filename
try:
diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py
index 351030789..98aa69257 100644
--- a/qutebrowser/config/configinit.py
+++ b/qutebrowser/config/configinit.py
@@ -30,7 +30,7 @@ from qutebrowser.api import config as configapi
from qutebrowser.config import (config, configdata, configfiles, configtypes,
configexc, configcommands, stylesheet)
from qutebrowser.utils import (objreg, usertypes, log, standarddir, message,
- qtutils)
+ qtutils, utils)
from qutebrowser.config import configcache
from qutebrowser.misc import msgbox, objects, savemanager
@@ -121,11 +121,11 @@ def _update_font_defaults(setting: str) -> None:
if setting not in {'fonts.default_family', 'fonts.default_size'}:
return
- configtypes.Font.set_defaults(config.val.fonts.default_family,
- config.val.fonts.default_size)
+ configtypes.FontBase.set_defaults(config.val.fonts.default_family,
+ config.val.fonts.default_size)
for name, opt in configdata.DATA.items():
- if not isinstance(opt.typ, configtypes.Font):
+ if not isinstance(opt.typ, configtypes.FontBase):
continue
value = config.instance.get_obj(name)
@@ -165,8 +165,8 @@ def late_init(save_manager: savemanager.SaveManager) -> None:
_init_errors = None
- configtypes.Font.set_defaults(config.val.fonts.default_family,
- config.val.fonts.default_size)
+ configtypes.FontBase.set_defaults(config.val.fonts.default_family,
+ config.val.fonts.default_size)
config.instance.changed.connect(_update_font_defaults)
config.instance.init_save_manager(save_manager)
@@ -199,6 +199,90 @@ def qt_args(namespace: argparse.Namespace) -> typing.List[str]:
return argv
+def _darkmode_settings() -> typing.Iterator[typing.Tuple[str, str]]:
+ """Get necessary blink settings to configure dark mode for QtWebEngine."""
+ if not config.val.colors.webpage.darkmode.enabled:
+ return
+
+ # Mapping from a colors.webpage.darkmode.algorithm setting value to
+ # Chromium's DarkModeInversionAlgorithm enum values.
+ algorithms = {
+ # 0: kOff (not exposed)
+ # 1: kSimpleInvertForTesting (not exposed)
+ 'brightness-rgb': 2, # kInvertBrightness
+ 'lightness-hsl': 3, # kInvertLightness
+ 'lightness-cielab': 4, # kInvertLightnessLAB
+ }
+
+ # Mapping from a colors.webpage.darkmode.policy.images setting value to
+ # Chromium's DarkModeImagePolicy enum values.
+ image_policies = {
+ 'always': 0, # kFilterAll
+ 'never': 1, # kFilterNone
+ 'smart': 2, # kFilterSmart
+ }
+
+ # Mapping from a colors.webpage.darkmode.policy.page setting value to
+ # Chromium's DarkModePagePolicy enum values.
+ page_policies = {
+ 'always': 0, # kFilterAll
+ 'smart': 1, # kFilterByBackground
+ }
+
+ bools = {
+ True: 'true',
+ False: 'false',
+ }
+
+ _setting_description_type = typing.Tuple[
+ str, # qutebrowser option name
+ str, # darkmode setting name
+ # Mapping from the config value to a string (or something convertable
+ # to a string) which gets passed to Chromium.
+ typing.Optional[typing.Mapping[typing.Any, typing.Union[str, int]]],
+ ]
+ if qtutils.version_check('5.15', compiled=False):
+ settings = [
+ ('enabled', 'Enabled', bools),
+ ('algorithm', 'InversionAlgorithm', algorithms),
+ ] # type: typing.List[_setting_description_type]
+ mandatory_setting = 'enabled'
+ else:
+ settings = [
+ ('algorithm', '', algorithms),
+ ]
+ mandatory_setting = 'algorithm'
+
+ settings += [
+ ('contrast', 'Contrast', None),
+ ('policy.images', 'ImagePolicy', image_policies),
+ ('policy.page', 'PagePolicy', page_policies),
+ ('threshold.text', 'TextBrightnessThreshold', None),
+ ('threshold.background', 'BackgroundBrightnessThreshold', None),
+ ('grayscale.all', 'Grayscale', bools),
+ ('grayscale.images', 'ImageGrayscale', None),
+ ]
+
+ for setting, key, mapping in settings:
+ # To avoid blowing up the commandline length, we only pass modified
+ # settings to Chromium, as our defaults line up with Chromium's.
+ # However, we always pass enabled/algorithm to make sure dark mode gets
+ # actually turned on.
+ value = config.instance.get(
+ 'colors.webpage.darkmode.' + setting,
+ fallback=setting == mandatory_setting)
+ if isinstance(value, usertypes.Unset):
+ continue
+
+ if mapping is not None:
+ value = mapping[value]
+
+ # FIXME: This is "forceDarkMode" starting with Chromium 83
+ prefix = 'darkMode'
+
+ yield prefix + key, str(value)
+
+
def _qtwebengine_args(namespace: argparse.Namespace) -> typing.Iterator[str]:
"""Get the QtWebEngine arguments to use based on the config."""
if not qtutils.version_check('5.11', compiled=False):
@@ -224,6 +308,11 @@ def _qtwebengine_args(namespace: argparse.Namespace) -> typing.Iterator[str]:
yield '--enable-logging'
yield '--v=1'
+ blink_settings = list(_darkmode_settings())
+ if blink_settings:
+ yield '--blink-settings=' + ','.join('{}={}'.format(k, v)
+ for k, v in blink_settings)
+
settings = {
'qt.force_software_rendering': {
'software-opengl': None,
@@ -271,6 +360,24 @@ def _qtwebengine_args(namespace: argparse.Namespace) -> typing.Iterator[str]:
False: '--autoplay-policy=user-gesture-required',
}
+ if qtutils.version_check('5.11', compiled=False) and not utils.is_mac:
+ # There are two additional flags in Chromium:
+ #
+ # - OverlayScrollbarFlashAfterAnyScrollUpdate
+ # - OverlayScrollbarFlashWhenMouseEnter
+ #
+ # We don't expose/activate those, but the changes they introduce are
+ # quite subtle: The former seems to show the scrollbar handle even if
+ # there was a 0px scroll (though no idea how that can happen...). The
+ # latter flashes *all* scrollbars when a scrollable area was entered,
+ # which doesn't seem to make much sense.
+ settings['scrolling.bar'] = {
+ 'always': None,
+ 'never': None,
+ 'when-searching': None,
+ 'overlay': '--enable-features=OverlayScrollbar',
+ }
+
if qtutils.version_check('5.14'):
settings['colors.webpage.prefers_color_scheme_dark'] = {
True: '--force-dark-mode',
diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py
index befc43806..e798498fc 100644
--- a/qutebrowser/config/configtypes.py
+++ b/qutebrowser/config/configtypes.py
@@ -56,15 +56,16 @@ import typing
import attr
import yaml
from PyQt5.QtCore import QUrl, Qt
-from PyQt5.QtGui import QColor, QFont, QFontDatabase
+from PyQt5.QtGui import QColor, QFontDatabase
from PyQt5.QtWidgets import QTabWidget, QTabBar, QApplication
from PyQt5.QtNetwork import QNetworkProxy
from qutebrowser.misc import objects, debugcachestats
from qutebrowser.config import configexc, configutils
from qutebrowser.utils import (standarddir, utils, qtutils, urlutils, urlmatch,
- usertypes)
+ usertypes, log)
from qutebrowser.keyinput import keyutils
+from qutebrowser.browser.network import pac
class _SystemProxy:
@@ -81,7 +82,8 @@ BOOLEAN_STATES = {'1': True, 'yes': True, 'true': True, 'on': True,
_Completions = typing.Optional[typing.Iterable[typing.Tuple[str, str]]]
_StrUnset = typing.Union[str, usertypes.Unset]
-_StrUnsetNone = typing.Union[None, str, usertypes.Unset]
+_UnsetNone = typing.Union[None, usertypes.Unset]
+_StrUnsetNone = typing.Union[str, _UnsetNone]
class ValidValues:
@@ -702,8 +704,10 @@ class Bool(BaseType):
super().__init__(none_ok)
self.valid_values = ValidValues('true', 'false', generate_docs=False)
- def to_py(self, value: typing.Optional[bool]) -> typing.Optional[bool]:
+ def to_py(self,
+ value: typing.Union[bool, str, None]) -> typing.Optional[bool]:
self._basic_py_validation(value, bool)
+ assert not isinstance(value, str)
return value
def from_str(self, value: str) -> typing.Optional[bool]:
@@ -733,15 +737,15 @@ class BoolAsk(Bool):
super().__init__(none_ok)
self.valid_values = ValidValues('true', 'false', 'ask')
- def to_py(self, # type: ignore
+ def to_py(self, # type: ignore[override]
value: typing.Union[bool, str]) -> typing.Union[bool, str, None]:
# basic validation unneeded if it's == 'ask' and done by Bool if we
# call super().to_py
if isinstance(value, str) and value.lower() == 'ask':
return 'ask'
- return super().to_py(value) # type: ignore
+ return super().to_py(value)
- def from_str(self, # type: ignore
+ def from_str(self, # type: ignore[override]
value: str) -> typing.Union[bool, str, None]:
# basic validation unneeded if it's == 'ask' and done by Bool if we
# call super().from_str
@@ -794,11 +798,14 @@ class _Numeric(BaseType): # pylint: disable=abstract-method
assert isinstance(bound, (int, float)), bound
return bound
- def _validate_bounds(self, value: typing.Union[None, int, float],
+ def _validate_bounds(self,
+ value: typing.Union[int, float, _UnsetNone],
suffix: str = '') -> None:
"""Validate self.minval and self.maxval."""
if value is None:
return
+ elif isinstance(value, usertypes.Unset):
+ return
elif self.minval is not None and value < self.minval:
raise configexc.ValidationError(
value, "must be {}{} or bigger!".format(self.minval, suffix))
@@ -834,7 +841,10 @@ class Int(_Numeric):
self.to_py(intval)
return intval
- def to_py(self, value: typing.Optional[int]) -> typing.Optional[int]:
+ def to_py(
+ self,
+ value: typing.Union[int, _UnsetNone]
+ ) -> typing.Union[int, _UnsetNone]:
self._basic_py_validation(value, int)
self._validate_bounds(value)
return value
@@ -858,8 +868,8 @@ class Float(_Numeric):
def to_py(
self,
- value: typing.Union[None, int, float],
- ) -> typing.Union[None, int, float]:
+ value: typing.Union[int, float, _UnsetNone],
+ ) -> typing.Union[int, float, _UnsetNone]:
self._basic_py_validation(value, (int, float))
self._validate_bounds(value)
return value
@@ -871,8 +881,8 @@ class Perc(_Numeric):
def to_py(
self,
- value: typing.Union[None, float, int, str, usertypes.Unset]
- ) -> typing.Union[None, float, int, usertypes.Unset]:
+ value: typing.Union[float, int, str, _UnsetNone]
+ ) -> typing.Union[float, int, _UnsetNone]:
self._basic_py_validation(value, (float, int, str))
if isinstance(value, usertypes.Unset):
return value
@@ -1067,8 +1077,7 @@ class QtColor(BaseType):
except ValueError:
raise configexc.ValidationError(val, "must be a valid color value")
- def to_py(self, value: _StrUnset) -> typing.Union[usertypes.Unset,
- None, QColor]:
+ def to_py(self, value: _StrUnset) -> typing.Union[_UnsetNone, QColor]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
@@ -1146,14 +1155,9 @@ class QssColor(BaseType):
return value
-class Font(BaseType):
+class FontBase(BaseType):
- """A font family, with optional style/weight/size.
-
- * Style: `normal`/`italic`/`oblique`
- * Weight: `normal`, `bold`, `100`..`900`
- * Size: _number_ `px`/`pt`
- """
+ """Base class for Font/FontFamily."""
# Gets set when the config is initialized.
default_family = None # type: str
@@ -1229,6 +1233,19 @@ class Font(BaseType):
cls.default_family = families.to_str(quote=True)
cls.default_size = default_size
+ def to_py(self, value: typing.Any) -> typing.Any:
+ raise NotImplementedError
+
+
+class Font(FontBase):
+
+ """A font family, with optional style/weight/size.
+
+ * Style: `normal`/`italic`/`oblique`
+ * Weight: `normal`, `bold`, `100`..`900`
+ * Size: _number_ `px`/`pt`
+ """
+
def to_py(self, value: _StrUnset) -> _StrUnsetNone:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@@ -1251,7 +1268,7 @@ class Font(BaseType):
return value
-class FontFamily(Font):
+class FontFamily(FontBase):
"""A Qt font family."""
@@ -1275,97 +1292,6 @@ class FontFamily(Font):
return value
-class QtFont(Font):
-
- """A Font which gets converted to a QFont."""
-
- __doc__ = Font.__doc__ # for src2asciidoc.py
-
- def _parse_families(self, family_str: str) -> configutils.FontFamilies:
- if family_str == 'default_family' and self.default_family is not None:
- family_str = self.default_family
-
- return configutils.FontFamilies.from_str(family_str)
-
- def _set_style(self, font: QFont, match: typing.Match) -> None:
- style = match.group('style')
- style_map = {
- 'normal': QFont.StyleNormal,
- 'italic': QFont.StyleItalic,
- 'oblique': QFont.StyleOblique,
- }
- if style:
- font.setStyle(style_map[style])
- else:
- font.setStyle(QFont.StyleNormal)
-
- def _set_weight(self, font: QFont, match: typing.Match) -> None:
- weight = match.group('weight')
- namedweight = match.group('namedweight')
- weight_map = {
- 'normal': QFont.Normal,
- 'bold': QFont.Bold,
- }
- if namedweight:
- font.setWeight(weight_map[namedweight])
- elif weight:
- # based on qcssparser.cpp:setFontWeightFromValue
- font.setWeight(min(int(weight) // 8, 99))
- else:
- font.setWeight(QFont.Normal)
-
- def _set_size(self, font: QFont, match: typing.Match) -> None:
- size = match.group('size')
- if size:
- if size == 'default_size':
- size = self.default_size
-
- if size is None:
- # initial validation before default_size is set up.
- pass
- elif size.lower().endswith('pt'):
- font.setPointSizeF(float(size[:-2]))
- elif size.lower().endswith('px'):
- font.setPixelSize(int(size[:-2]))
- else:
- # This should never happen as the regex only lets pt/px
- # through.
- raise ValueError("Unexpected size unit in {!r}!".format(
- size)) # pragma: no cover
-
- def _set_families(self, font: QFont, match: typing.Match) -> None:
- family_str = match.group('family')
- families = self._parse_families(family_str)
- if hasattr(font, 'setFamilies'):
- # Added in Qt 5.13
- font.setFamily(families.family) # type: ignore
- font.setFamilies(list(families))
- else: # pragma: no cover
- font.setFamily(families.to_str(quote=False))
-
- def to_py(self, value: _StrUnset) -> typing.Union[usertypes.Unset,
- None, QFont]:
- self._basic_py_validation(value, str)
- if isinstance(value, usertypes.Unset):
- return value
- elif not value:
- return None
-
- match = self.font_regex.fullmatch(value)
- if not match: # pragma: no cover
- # This should never happen, as the regex always matches everything
- # as family.
- raise configexc.ValidationError(value, "must be a valid font")
-
- font = QFont()
- self._set_style(font, match)
- self._set_weight(font, match)
- self._set_size(font, match)
- self._set_families(font, match)
-
- return font
-
-
class Regex(BaseType):
"""A regular expression.
@@ -1423,7 +1349,7 @@ class Regex(BaseType):
def to_py(
self,
value: typing.Union[str, typing.Pattern[str], usertypes.Unset]
- ) -> typing.Union[usertypes.Unset, None, typing.Pattern[str]]:
+ ) -> typing.Union[_UnsetNone, typing.Pattern[str]]:
"""Get a compiled regex from either a string or a regex object."""
self._basic_py_validation(value, (str, self._regex_type))
if isinstance(value, usertypes.Unset):
@@ -1514,7 +1440,7 @@ class Dict(BaseType):
def to_py(
self,
- value: typing.Union[typing.Dict, usertypes.Unset, None]
+ value: typing.Union[typing.Dict, _UnsetNone]
) -> typing.Union[typing.Dict, usertypes.Unset]:
self._basic_py_validation(value, dict)
if isinstance(value, usertypes.Unset):
@@ -1713,7 +1639,7 @@ class Proxy(BaseType):
def to_py(
self,
value: _StrUnset
- ) -> typing.Union[usertypes.Unset, None, QNetworkProxy, _SystemProxy]:
+ ) -> typing.Union[_UnsetNone, QNetworkProxy, _SystemProxy, pac.PACFetcher]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
@@ -1760,22 +1686,22 @@ class SearchEngineUrl(BaseType):
elif not value:
return None
- if not ('{}' in value or '{0}' in value):
+ if not re.search('{(|0|semiquoted|unquoted|quoted)}', value):
raise configexc.ValidationError(value, "must contain \"{}\"")
try:
- value.format("")
+ format_keys = {
+ 'quoted': "",
+ 'unquoted': "",
+ 'semiquoted': "",
+ }
+ value.format("", **format_keys)
except (KeyError, IndexError):
raise configexc.ValidationError(
value, "may not contain {...} (use {{ and }} for literal {/})")
except ValueError as e:
raise configexc.ValidationError(value, str(e))
- url = QUrl(value.replace('{}', 'foobar'))
- if not url.isValid():
- raise configexc.ValidationError(
- value, "invalid url, {}".format(url.errorString()))
-
return value
@@ -1783,7 +1709,7 @@ class FuzzyUrl(BaseType):
"""A URL which gets interpreted as search if needed."""
- def to_py(self, value: _StrUnset) -> _StrUnsetNone:
+ def to_py(self, value: _StrUnset) -> typing.Union[QUrl, _UnsetNone]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
@@ -1819,9 +1745,9 @@ class Padding(Dict):
fixed_keys=['top', 'bottom', 'left', 'right'],
none_ok=none_ok)
- def to_py( # type: ignore
+ def to_py( # type: ignore[override]
self,
- value: typing.Union[usertypes.Unset, typing.Dict, None],
+ value: typing.Union[typing.Dict, _UnsetNone],
) -> typing.Union[usertypes.Unset, PaddingValues]:
d = super().to_py(value)
if isinstance(d, usertypes.Unset):
@@ -1893,10 +1819,7 @@ class Url(BaseType):
"""A URL as a string."""
- def to_py(
- self,
- value: _StrUnset
- ) -> typing.Union[usertypes.Unset, None, QUrl]:
+ def to_py(self, value: _StrUnset) -> typing.Union[_UnsetNone, QUrl]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
@@ -1999,6 +1922,16 @@ class NewTabPosition(String):
('last', "At the end."))
+class LogLevel(String):
+
+ """A logging level."""
+
+ def __init__(self, none_ok: bool = False) -> None:
+ super().__init__(none_ok=none_ok)
+ self.valid_values = ValidValues(*[level.lower()
+ for level in log.LOG_LEVELS])
+
+
class Key(BaseType):
"""A name of a key."""
@@ -2010,7 +1943,7 @@ class Key(BaseType):
def to_py(
self,
value: _StrUnset
- ) -> typing.Union[usertypes.Unset, None, keyutils.KeySequence]:
+ ) -> typing.Union[_UnsetNone, keyutils.KeySequence]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
@@ -2034,7 +1967,7 @@ class UrlPattern(BaseType):
def to_py(
self,
value: _StrUnset
- ) -> typing.Union[usertypes.Unset, None, urlmatch.UrlPattern]:
+ ) -> typing.Union[_UnsetNone, urlmatch.UrlPattern]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py
index bfc460bea..3f7823772 100644
--- a/qutebrowser/config/configutils.py
+++ b/qutebrowser/config/configutils.py
@@ -28,7 +28,7 @@ import operator
from PyQt5.QtCore import QUrl
-from qutebrowser.utils import utils, urlmatch, usertypes
+from qutebrowser.utils import utils, urlmatch, usertypes, qtutils
from qutebrowser.config import configexc
if typing.TYPE_CHECKING:
@@ -151,7 +151,7 @@ class Values:
return bool(self._vmap)
def _check_pattern_support(
- self, arg: typing.Optional[urlmatch.UrlPattern]) -> None:
+ self, arg: typing.Union[urlmatch.UrlPattern, QUrl, None]) -> None:
"""Make sure patterns are supported if one was given."""
if arg is not None and not self.opt.supports_pattern:
raise configexc.NoPatternError(self.opt.name)
@@ -223,6 +223,7 @@ class Values:
self._check_pattern_support(url)
if url is None:
return self._get_fallback(fallback)
+ qtutils.ensure_valid(url)
candidates = [] # type: typing.List[ScopedValue]
# Urls trailing with '.' are equivalent to non-trailing types.
diff --git a/qutebrowser/config/stylesheet.py b/qutebrowser/config/stylesheet.py
index 276d37094..10e6e4e52 100644
--- a/qutebrowser/config/stylesheet.py
+++ b/qutebrowser/config/stylesheet.py
@@ -109,7 +109,7 @@ class _StyleSheetObserver(QObject):
def register(self) -> None:
"""Do a first update and listen for more."""
qss = self._get_stylesheet()
- log.config.vdebug( # type: ignore
+ log.config.vdebug( # type: ignore[attr-defined]
"stylesheet for {}: {}".format(self._obj.__class__.__name__, qss))
self._obj.setStyleSheet(qss)
if self._update:
diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py
index 03c85a71a..007758254 100644
--- a/qutebrowser/config/websettings.py
+++ b/qutebrowser/config/websettings.py
@@ -103,6 +103,9 @@ class AbstractSettings:
def __init__(self, settings: typing.Any) -> None:
self._settings = settings
+ def _assert_not_unset(self, value: typing.Any) -> None:
+ assert value is not usertypes.UNSET
+
def set_attribute(self, name: str, value: typing.Any) -> bool:
"""Set the given QWebSettings/QWebEngineSettings attribute.
@@ -139,7 +142,7 @@ class AbstractSettings:
Return:
True if there was a change, False otherwise.
"""
- assert value is not usertypes.UNSET # type: ignore
+ self._assert_not_unset(value)
family = self._FONT_SIZES[name]
old_value = self._settings.fontSize(family)
self._settings.setFontSize(family, value)
@@ -154,7 +157,7 @@ class AbstractSettings:
Return:
True if there was a change, False otherwise.
"""
- assert value is not usertypes.UNSET # type: ignore
+ self._assert_not_unset(value)
family = self._FONT_FAMILIES[name]
if value is None:
font = QFont()
@@ -172,7 +175,7 @@ class AbstractSettings:
Return:
True if there was a change, False otherwise.
"""
- assert encoding is not usertypes.UNSET # type: ignore
+ self._assert_not_unset(encoding)
old_value = self._settings.defaultTextEncoding()
self._settings.setDefaultTextEncoding(encoding)
return old_value != encoding
@@ -253,6 +256,10 @@ def _format_user_agent(template: str, backend: usertypes.Backend) -> str:
def user_agent(url: QUrl = None) -> str:
+ """Get the user agent for the given URL, or the global one if URL is None.
+
+ Note that the given URL should always be valid.
+ """
template = config.instance.get('content.headers.user_agent', url=url)
return _format_user_agent(template=template, backend=objects.backend)
diff --git a/qutebrowser/extensions/loader.py b/qutebrowser/extensions/loader.py
index 928f2856c..41b9c63fd 100644
--- a/qutebrowser/extensions/loader.py
+++ b/qutebrowser/extensions/loader.py
@@ -82,8 +82,8 @@ def add_module_info(module: types.ModuleType) -> ModuleInfo:
"""Add ModuleInfo to a module (if not added yet)."""
# pylint: disable=protected-access
if not hasattr(module, '__qute_module_info'):
- module.__qute_module_info = ModuleInfo() # type: ignore
- return module.__qute_module_info # type: ignore
+ module.__qute_module_info = ModuleInfo() # type: ignore[attr-defined]
+ return module.__qute_module_info # type: ignore[attr-defined]
def load_components(*, skip_hooks: bool = False) -> None:
@@ -109,7 +109,7 @@ def _walk_normal() -> typing.Iterator[ExtensionInfo]:
for _finder, name, ispkg in pkgutil.walk_packages(
# Only packages have a __path__ attribute,
# but we're sure this is one.
- path=components.__path__, # type: ignore
+ path=components.__path__, # type: ignore[attr-defined]
prefix=components.__name__ + '.',
onerror=_on_walk_error):
if ispkg:
diff --git a/qutebrowser/html/warning-old-qt.html b/qutebrowser/html/warning-old-qt.html
index 157d50714..e5da57548 100644
--- a/qutebrowser/html/warning-old-qt.html
+++ b/qutebrowser/html/warning-old-qt.html
@@ -7,18 +7,26 @@ qute://warning/old-qt</span> to show it again at a later time.</span>
<p>You're using qutebrowser with Qt {{qt_version}}.</p>
+{% if qt_version.startswith('5.7.') %}
<p>Qt 5.7 was released in June 2016, with the 5.7.1 patch release in December
2016. It is based on Chromium 49 (March 2016) with (some) security fixes up to
-Chromium 54 (October 2016). It is also
-<a href="https://www.debian.org/releases/stable/amd64/release-notes/ch-information.en.html#browser-security">not covered</a>
-by Debian security updates.</p>
-
+Chromium 54 (October 2016).</p>
+{% elif qt_version.startswith('5.8.') %}
<p>Qt 5.8 has had various bugs, and has been unsupported (but working to some
degree) in qutebrowser for a while.</p>
+{% elif qt_version.startswith('5.9.') %}
+<p>Qt 5.9 LTS was released in May 2017 and is based on Chromium 56 (January 2017). It is a long term support release with the 5.9.9 patch release in December 2019 including (some) security fixes up to Chromium 78 (November 2019). However, its usage was found to be low, and the next LTS (Qt 5.12) was released in December 2018.</p>
+{% elif qt_version.startswith('5.10.') %}
+<p>Qt 5.10 was released in December 2017, with the 5.10.1 patch release in February
+2018. It is based on Chromium 65 (March 2018) with (some) security fixes up to
+Chromium 70 (November 2018).</p>
+{% endif %}
+
+Also, note that QtWebEngine is <a href="https://www.debian.org/releases/stable/amd64/release-notes/ch-information.en.html#browser-security">not covered</a> by Debian security updates.
<p>Because of those security issues and the maintaince burden coming with
-supporting old versions, support for Qt < 5.9 will be dropped in a future
-qutebrowser release. You might want to check
+supporting old versions, support for Qt < 5.11 will be dropped in qutebrowser
+v2.0. You might want to check
<a href="https://qutebrowser.org/doc/install.html">alternate installation methods</a>
which allow you to get a newer Qt.</p>
{% endblock %}
diff --git a/qutebrowser/html/warning-sessions.html b/qutebrowser/html/warning-sessions.html
new file mode 100644
index 000000000..0c6622df6
--- /dev/null
+++ b/qutebrowser/html/warning-sessions.html
@@ -0,0 +1,22 @@
+{% extends "styled.html" %}
+
+{% block content %}
+<h1>{{ title }}</h1>
+<span class="note">Note this warning will only appear once. Use <span class="mono">:open
+qute://warning/sessions</span> to show it again at a later time.</span>
+
+<p>You're using qutebrowser with Qt 5.15.</p>
+
+<p>Since Qt doesn't provide an API to load the history of a tab, qutebrowser relies on a reverse-engineered binary serialization format to load tab history from session files. With Qt 5.15, unfortunately that format changed (due to the underlying Chromium upgrade), in a way which makes it impossible for qutebrowser to load tab history from existing session data.</p>
+
+<p>At the time of writing (April 2020), a new session format which stores part of the needed binary data in saved sessions is <a href="https://github.com/qutebrowser/qutebrowser/issues/5359">in development</a> and is expected to be released with qutebrowser v1.13.0.</p>
+
+<p>As a stop-gap measure:</p>
+
+<ul>
+ <li>Loading a session with this release will <b>only load the most recently opened page</b> for every tab. As a result, the back/forward-history of every tab <b>will be lost</b> as soon as the session is saved again.</li>
+ <li>Due to that, the <span class="mono">session.lazy_restore</span> setting does not have any effect.</li>
+ <li>A one-time backup of the session folder has been created at <span class="mono">{{ datadir }}{{ sep }}sessions{{ sep }}before-qt-515</span>.</li>
+</ul>
+
+{% endblock %}
diff --git a/qutebrowser/html/warning-webkit.html b/qutebrowser/html/warning-webkit.html
index 7fc22903a..a46871089 100644
--- a/qutebrowser/html/warning-webkit.html
+++ b/qutebrowser/html/warning-webkit.html
@@ -10,12 +10,14 @@ qute://warning/webkit</span> to show it again at a later time.</span>
<p>While QtWebKit has gained some traction again recently, its latest release
(5.212.0 Alpha 3) is still based on an old upstream WebKit. It also lacks
various security features (process isolation/sandboxing) present in
-QtWebEngine. From the upstream release notes:</p>
-
-<blockquote>WARNING: This release is based on old WebKit revision with known
-unpatched vulnerabilities. Please use it carefully and avoid visiting untrusted
-websites and using it for transmission of sensitive data. Wait for new release
-from qtwebkit-dev branch to use it with untrusted content.</blockquote>
+QtWebEngine. From the
+<a href="https://github.com/qtwebkit/qtwebkit/releases">QtWebKit release
+notes</a>:</p>
+
+<blockquote><i>WARNING:</i> This release [of QtWebKit] is based on [an] <i>old
+WebKit revision</i> with known unpatched vulnerabilities. <i>Please use it
+carefully and avoid visiting untrusted websites and using it for
+transmission of sensitive data.</i></blockquote>
<p>It's recommended that you use QtWebEngine instead.</p>
diff --git a/qutebrowser/javascript/.eslintrc.yaml b/qutebrowser/javascript/.eslintrc.yaml
index 4fdd43854..23456e801 100644
--- a/qutebrowser/javascript/.eslintrc.yaml
+++ b/qutebrowser/javascript/.eslintrc.yaml
@@ -62,3 +62,4 @@ rules:
max-params: "off"
prefer-named-capture-group: "off"
function-call-argument-newline: "off"
+ no-negated-condition: "off"
diff --git a/qutebrowser/javascript/caret.js b/qutebrowser/javascript/caret.js
index 55ff6a8b5..d7ba88fe6 100644
--- a/qutebrowser/javascript/caret.js
+++ b/qutebrowser/javascript/caret.js
@@ -706,6 +706,18 @@ window._qutebrowser.caret = (function() {
CaretBrowsing.isCaretVisible = false;
/**
+ * Selection modes.
+ * NOTE: Values need to line up with SelectionState in browsertab.py!
+ *
+ * @type {enum}
+ */
+ CaretBrowsing.SelectionState = {
+ "NONE": "none",
+ "NORMAL": "normal",
+ "LINE": "line",
+ };
+
+ /**
* The actual caret element, an absolute-positioned flashing line.
* @type {Element}
*/
@@ -887,7 +899,11 @@ window._qutebrowser.caret = (function() {
CaretBrowsing.injectCaretStyles();
CaretBrowsing.toggle();
CaretBrowsing.initiated = true;
- CaretBrowsing.selectionEnabled = selectionRange > 0;
+ if (selectionRange > 0) {
+ CaretBrowsing.selectionState = CaretBrowsing.SelectionState.NORMAL;
+ } else {
+ CaretBrowsing.selectionState = CaretBrowsing.SelectionState.NONE;
+ }
};
/**
@@ -1145,16 +1161,45 @@ window._qutebrowser.caret = (function() {
}
};
+ CaretBrowsing.reverseSelection = () => {
+ const sel = window.getSelection();
+ sel.setBaseAndExtent(
+ sel.extentNode, sel.extentOffset, sel.baseNode,
+ sel.baseOffset
+ );
+ };
+
+ CaretBrowsing.selectLine = function() {
+ const sel = window.getSelection();
+ sel.modify("extend", "right", "lineboundary");
+ CaretBrowsing.reverseSelection();
+ sel.modify("extend", "left", "lineboundary");
+ CaretBrowsing.reverseSelection();
+ };
+
+ CaretBrowsing.updateLineSelection = function(direction, granularity) {
+ if (granularity !== "character" && granularity !== "word") {
+ window.
+ getSelection().
+ modify("extend", direction, granularity);
+ CaretBrowsing.selectLine();
+ }
+ };
+
CaretBrowsing.move = function(direction, granularity, count = 1) {
let action = "move";
- if (CaretBrowsing.selectionEnabled) {
+ if (CaretBrowsing.selectionState !== CaretBrowsing.SelectionState.NONE) {
action = "extend";
}
for (let i = 0; i < count; i++) {
- window.
- getSelection().
- modify(action, direction, granularity);
+ if (CaretBrowsing.selectionState === CaretBrowsing.SelectionState.LINE) {
+ CaretBrowsing.updateLineSelection(direction, granularity);
+ } else {
+ window.
+ getSelection().
+ modify(action, direction, granularity);
+ }
}
if (CaretBrowsing.isWindows &&
@@ -1174,7 +1219,7 @@ window._qutebrowser.caret = (function() {
CaretBrowsing.moveToBlock = function(paragraph, boundary, count = 1) {
let action = "move";
- if (CaretBrowsing.selectionEnabled) {
+ if (CaretBrowsing.selectionState !== CaretBrowsing.SelectionState.NONE) {
action = "extend";
}
for (let i = 0; i < count; i++) {
@@ -1185,6 +1230,10 @@ window._qutebrowser.caret = (function() {
window.
getSelection().
modify(action, boundary, "paragraphboundary");
+
+ if (CaretBrowsing.selectionState === CaretBrowsing.SelectionState.LINE) {
+ CaretBrowsing.selectLine();
+ }
}
};
@@ -1294,14 +1343,14 @@ window._qutebrowser.caret = (function() {
funcs.setInitialCursor = () => {
if (!CaretBrowsing.initiated) {
CaretBrowsing.setInitialCursor();
- return CaretBrowsing.selectionEnabled;
+ return CaretBrowsing.selectionState !== CaretBrowsing.SelectionState.NONE;
}
if (window.getSelection().toString().length === 0) {
positionCaret();
}
CaretBrowsing.toggle();
- return CaretBrowsing.selectionEnabled;
+ return CaretBrowsing.selectionState !== CaretBrowsing.SelectionState.NONE;
};
funcs.setFlags = (flags) => {
@@ -1399,17 +1448,22 @@ window._qutebrowser.caret = (function() {
funcs.getSelection = () => window.getSelection().toString();
- funcs.toggleSelection = () => {
- CaretBrowsing.selectionEnabled = !CaretBrowsing.selectionEnabled;
- return CaretBrowsing.selectionEnabled;
+ funcs.toggleSelection = (line) => {
+ if (line) {
+ CaretBrowsing.selectionState =
+ CaretBrowsing.SelectionState.LINE;
+ CaretBrowsing.selectLine();
+ CaretBrowsing.finishMove();
+ } else if (CaretBrowsing.selectionState !== CaretBrowsing.SelectionState.NORMAL) {
+ CaretBrowsing.selectionState = CaretBrowsing.SelectionState.NORMAL;
+ } else {
+ CaretBrowsing.selectionState = CaretBrowsing.SelectionState.NONE;
+ }
+ return CaretBrowsing.selectionState;
};
funcs.reverseSelection = () => {
- const sel = window.getSelection();
- sel.setBaseAndExtent(
- sel.extentNode, sel.extentOffset, sel.baseNode,
- sel.baseOffset
- );
+ CaretBrowsing.reverseSelection();
};
return funcs;
diff --git a/qutebrowser/javascript/globalthis_quirk.user.js b/qutebrowser/javascript/globalthis_quirk.user.js
new file mode 100644
index 000000000..03e74de3c
--- /dev/null
+++ b/qutebrowser/javascript/globalthis_quirk.user.js
@@ -0,0 +1,9 @@
+// ==UserScript==
+// @include https://www.reddit.com/*
+// @include https://open.spotify.com/*
+// ==/UserScript==
+
+// Polyfill for a failing globalThis with older Qt versions.
+
+"use strict";
+window.globalThis = window;
diff --git a/qutebrowser/javascript/pac_utils.js b/qutebrowser/javascript/pac_utils.js
index 0aba4c070..a7ac2d414 100644
--- a/qutebrowser/javascript/pac_utils.js
+++ b/qutebrowser/javascript/pac_utils.js
@@ -241,7 +241,7 @@ function timeRange() {
}
break;
default:
- throw 'timeRange: bad number of arguments'
+ throw 'timeRange: bad number of arguments';
}
}
diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py
index 536a7e6ee..dea85aede 100644
--- a/qutebrowser/keyinput/basekeyparser.py
+++ b/qutebrowser/keyinput/basekeyparser.py
@@ -26,7 +26,6 @@ import typing
import attr
from PyQt5.QtCore import pyqtSignal, QObject, Qt
from PyQt5.QtGui import QKeySequence, QKeyEvent
-from PyQt5.QtWidgets import QWidget
from qutebrowser.config import config
from qutebrowser.utils import usertypes, log, utils
@@ -97,6 +96,24 @@ class BindingTrie:
return utils.get_repr(self, children=self.children,
command=self.command)
+ def __str__(self) -> str:
+ return '\n'.join(self.string_lines(blank=True))
+
+ def string_lines(self, indent: int = 0,
+ blank: bool = False) -> typing.Sequence[str]:
+ """Get a list of strings for a pretty-printed version of this trie."""
+ lines = []
+ if self.command is not None:
+ lines.append('{}=> {}'.format(' ' * indent, self.command))
+
+ for key, child in sorted(self.children.items()):
+ lines.append('{}{}:'.format(' ' * indent, key))
+ lines.extend(child.string_lines(indent=indent+1))
+ if blank:
+ lines.append('')
+
+ return lines
+
def update(self, mapping: typing.Mapping) -> None:
"""Add data from the given mapping to the trie."""
for key in mapping:
@@ -141,23 +158,16 @@ class BaseKeyParser(QObject):
Not intended to be instantiated directly. Subclasses have to override
execute() to do whatever they want to.
- Class Attributes:
- Match: types of a match between a binding and the keystring.
- partial: No keychain matched yet, but it's still possible in the
- future.
- definitive: Keychain matches exactly.
- none: No more matches possible.
-
- do_log: Whether to log keypresses or not.
- passthrough: Whether unbound keys should be passed through with this
- handler.
- supports_count: Whether count is supported.
-
Attributes:
+ mode_name: The name of the mode in the config.
bindings: Bound key bindings
+ _mode: The usertypes.KeyMode associated with this keyparser.
_win_id: The window ID this keyparser is associated with.
_sequence: The currently entered key sequence
- _modename: The name of the input mode associated with this keyparser.
+ _do_log: Whether to log keypresses or not.
+ passthrough: Whether unbound keys should be passed through with this
+ handler.
+ _supports_count: Whether count is supported.
Signals:
keystring_updated: Emitted when the keystring is updated.
@@ -170,21 +180,31 @@ class BaseKeyParser(QObject):
keystring_updated = pyqtSignal(str)
request_leave = pyqtSignal(usertypes.KeyMode, str, bool)
- do_log = True
- passthrough = False
- supports_count = True
- def __init__(self, win_id: int, parent: QWidget = None) -> None:
+ def __init__(self, *, mode: usertypes.KeyMode,
+ win_id: int,
+ parent: QObject = None,
+ do_log: bool = True,
+ passthrough: bool = False,
+ supports_count: bool = True) -> None:
super().__init__(parent)
self._win_id = win_id
- self._modename = None
self._sequence = keyutils.KeySequence()
self._count = ''
+ self._mode = mode
+ self._do_log = do_log
+ self.passthrough = passthrough
+ self._supports_count = supports_count
self.bindings = BindingTrie()
+ self._read_config()
config.instance.changed.connect(self._on_config_changed)
def __repr__(self) -> str:
- return utils.get_repr(self)
+ return utils.get_repr(self, mode=self._mode,
+ win_id=self._win_id,
+ do_log=self._do_log,
+ passthrough=self.passthrough,
+ supports_count=self._supports_count)
def _debug_log(self, message: str) -> None:
"""Log a message to the debug log if logging is active.
@@ -192,8 +212,10 @@ class BaseKeyParser(QObject):
Args:
message: The message to log.
"""
- if self.do_log:
- log.keyboard.debug(message)
+ if self._do_log:
+ prefix = '{} for mode {}: '.format(self.__class__.__name__,
+ self._mode.name)
+ log.keyboard.debug(prefix + message)
def _match_key(self, sequence: keyutils.KeySequence) -> MatchResult:
"""Try to match a given keystring with any bound keychain.
@@ -235,7 +257,7 @@ class BaseKeyParser(QObject):
dry_run: bool) -> bool:
"""Try to match a key as count."""
txt = str(sequence[-1]) # To account for sequences changed above.
- if (txt in string.digits and self.supports_count and
+ if (txt in string.digits and self._supports_count and
not (not self._count and txt == '0')):
self._debug_log("Trying match as count")
assert len(txt) == 1, txt
@@ -320,25 +342,12 @@ class BaseKeyParser(QObject):
def _on_config_changed(self) -> None:
self._read_config()
- def _read_config(self, modename: str = None) -> None:
- """Read the configuration.
-
- Config format: key = command, e.g.:
- <Ctrl+Q> = quit
-
- Args:
- modename: Name of the mode to use.
- """
- if modename is None:
- if self._modename is None:
- raise ValueError("read_config called with no mode given, but "
- "None defined so far!")
- modename = self._modename
- else:
- self._modename = modename
+ def _read_config(self) -> None:
+ """Read the configuration."""
self.bindings = BindingTrie()
+ config_bindings = config.key_instance.get_bindings_for(self._mode.name)
- for key, cmd in config.key_instance.get_bindings_for(modename).items():
+ for key, cmd in config_bindings.items():
assert cmd
self.bindings[key] = cmd
diff --git a/qutebrowser/keyinput/eventfilter.py b/qutebrowser/keyinput/eventfilter.py
index 7916f16fe..6ef0dd201 100644
--- a/qutebrowser/keyinput/eventfilter.py
+++ b/qutebrowser/keyinput/eventfilter.py
@@ -19,6 +19,8 @@
"""Global Qt event filter which dispatches key events."""
+import typing
+
from PyQt5.QtCore import pyqtSlot, QObject, QEvent
from PyQt5.QtGui import QKeyEvent, QWindow
from PyQt5.QtWidgets import QApplication
@@ -85,19 +87,22 @@ class EventFilter(QObject):
Return:
True if the event should be filtered, False if it's passed through.
"""
+ if not isinstance(obj, QWindow):
+ # We already handled this same event at some point earlier, so
+ # we're not interested in it anymore.
+ return False
+
+ typ = event.type()
+
+ if typ not in self._handlers:
+ return False
+
+ if not self._activated:
+ return False
+
+ handler = self._handlers[typ]
try:
- if not self._activated:
- return False
- if not isinstance(obj, QWindow):
- # We already handled this same event at some point earlier, so
- # we're not interested in it anymore.
- return False
- try:
- handler = self._handlers[event.type()]
- except KeyError:
- return False
- else:
- return handler(event)
+ return handler(typing.cast(QKeyEvent, event))
except:
# If there is an exception in here and we leave the eventfilter
# activated, we'll get an infinite loop and a stack overflow.
diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py
index b738ad190..b95f4a55d 100644
--- a/qutebrowser/keyinput/keyutils.py
+++ b/qutebrowser/keyinput/keyutils.py
@@ -53,6 +53,8 @@ _MODIFIER_MAP = {
_NIL_KEY = Qt.Key(0)
+_ModifierType = typing.Union[Qt.KeyboardModifier, Qt.KeyboardModifiers]
+
def _build_special_names() -> typing.Mapping[Qt.Key, str]:
"""Build _SPECIAL_NAMES dict from the special_names_str mapping below.
@@ -167,9 +169,10 @@ def _assert_plain_key(key: Qt.Key) -> None:
assert not key & Qt.KeyboardModifierMask, hex(key)
-def _assert_plain_modifier(key: Qt.KeyboardModifier) -> None:
+def _assert_plain_modifier(key: _ModifierType) -> None:
"""Make sure this is a modifier without a key mixed in."""
- assert not key & ~Qt.KeyboardModifierMask, hex(key)
+ mask = Qt.KeyboardModifierMask
+ assert not key & ~mask, hex(key) # type: ignore[operator]
def _is_printable(key: Qt.Key) -> bool:
@@ -177,22 +180,7 @@ def _is_printable(key: Qt.Key) -> bool:
return key <= 0xff and key not in [Qt.Key_Space, _NIL_KEY]
-def is_special_hint_mode(key: Qt.Key, modifiers: Qt.KeyboardModifier) -> bool:
- """Check whether this key should clear the keychain in hint mode.
-
- When we press "s<Escape>", we don't want <Escape> to be handled as part of
- a key chain in hint mode.
- """
- _assert_plain_key(key)
- _assert_plain_modifier(modifiers)
- if is_modifier_key(key):
- return False
- return not (_is_printable(key) and
- modifiers in [Qt.ShiftModifier, Qt.NoModifier,
- Qt.KeypadModifier])
-
-
-def is_special(key: Qt.Key, modifiers: Qt.KeyboardModifier) -> bool:
+def is_special(key: Qt.Key, modifiers: _ModifierType) -> bool:
"""Check whether this key requires special key syntax."""
_assert_plain_key(key)
_assert_plain_modifier(modifiers)
@@ -244,7 +232,7 @@ def _remap_unicode(key: Qt.Key, text: str) -> Qt.Key:
def _check_valid_utf8(s: str,
- data: typing.Union[Qt.Key, Qt.KeyboardModifier]) -> None:
+ data: typing.Union[Qt.Key, _ModifierType]) -> None:
"""Make sure the given string is valid UTF-8.
Makes sure there are no chars where Qt did fall back to weird UTF-16
@@ -254,7 +242,7 @@ def _check_valid_utf8(s: str,
s.encode('utf-8')
except UnicodeEncodeError as e: # pragma: no cover
raise ValueError("Invalid encoding in 0x{:x} -> {}: {}"
- .format(data, s, e))
+ .format(int(data), s, e))
def _key_to_string(key: Qt.Key) -> str:
@@ -276,15 +264,16 @@ def _key_to_string(key: Qt.Key) -> str:
return result
-def _modifiers_to_string(modifiers: Qt.KeyboardModifier) -> str:
+def _modifiers_to_string(modifiers: _ModifierType) -> str:
"""Convert the given Qt::KeyboardModifiers to a string.
Handles Qt.GroupSwitchModifier because Qt doesn't handle that as a
modifier.
"""
_assert_plain_modifier(modifiers)
- if modifiers & Qt.GroupSwitchModifier:
- modifiers &= ~Qt.GroupSwitchModifier # type: ignore
+ altgr = Qt.GroupSwitchModifier
+ if modifiers & altgr: # type: ignore[operator]
+ modifiers &= ~altgr # type: ignore[operator, assignment]
result = 'AltGr+'
else:
result = ''
@@ -375,7 +364,7 @@ class KeyInfo:
"""
key = attr.ib() # type: Qt.Key
- modifiers = attr.ib() # type: Qt.KeyboardModifier
+ modifiers = attr.ib() # type: _ModifierType
@classmethod
def from_event(cls, e: QKeyEvent) -> 'KeyInfo':
@@ -388,7 +377,7 @@ class KeyInfo:
modifiers = e.modifiers()
_assert_plain_key(key)
_assert_plain_modifier(modifiers)
- return cls(key, modifiers)
+ return cls(key, typing.cast(Qt.KeyboardModifier, modifiers))
def __hash__(self) -> int:
"""Convert KeyInfo to int before hashing.
@@ -451,7 +440,7 @@ class KeyInfo:
return ''
text = QKeySequence(self.key).toString()
- if not self.modifiers & Qt.ShiftModifier:
+ if not self.modifiers & Qt.ShiftModifier: # type: ignore[operator]
text = text.lower()
return text
@@ -508,7 +497,7 @@ class KeySequence:
"""Iterate over KeyInfo objects."""
for key_and_modifiers in self._iter_keys():
key = Qt.Key(int(key_and_modifiers) & ~Qt.KeyboardModifierMask)
- modifiers = Qt.KeyboardModifiers( # type: ignore
+ modifiers = Qt.KeyboardModifiers( # type: ignore[call-overload]
int(key_and_modifiers) & Qt.KeyboardModifierMask)
yield KeyInfo(key=key, modifiers=modifiers)
@@ -565,7 +554,9 @@ class KeySequence:
return infos[item]
def _iter_keys(self) -> typing.Iterator[int]:
- return itertools.chain.from_iterable(self._sequences)
+ sequences = typing.cast(typing.Iterable[typing.Iterable[int]],
+ self._sequences)
+ return itertools.chain.from_iterable(sequences)
def _validate(self, keystr: str = None) -> None:
for info in self:
@@ -646,7 +637,7 @@ class KeySequence:
if (modifiers == Qt.ShiftModifier and
_is_printable(key) and
not ev.text().isupper()):
- modifiers = Qt.KeyboardModifiers()
+ modifiers = Qt.KeyboardModifiers() # type: ignore[assignment]
# On macOS, swap Ctrl and Meta back
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-51293
diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py
index 7e29ac3f5..74ab8a27c 100644
--- a/qutebrowser/keyinput/modeman.py
+++ b/qutebrowser/keyinput/modeman.py
@@ -20,7 +20,7 @@
"""Mode manager singleton which handles the current keyboard mode."""
import functools
-import typing
+from typing import Mapping, Callable, MutableMapping, Union, Set, cast
import attr
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QObject, QEvent
@@ -37,8 +37,7 @@ from qutebrowser.browser import hints
INPUT_MODES = [usertypes.KeyMode.insert, usertypes.KeyMode.passthrough]
PROMPT_MODES = [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]
-ParserDictType = typing.MutableMapping[
- usertypes.KeyMode, basekeyparser.BaseKeyParser]
+ParserDictType = MutableMapping[usertypes.KeyMode, basekeyparser.BaseKeyParser]
@attr.s(frozen=True)
@@ -69,6 +68,14 @@ class NotInModeError(Exception):
"""Exception raised when we want to leave a mode we're not in."""
+class UnavailableError(Exception):
+
+ """Exception raised when trying to access modeman before initialization.
+
+ Thrown by instance() if modeman has not been initialized yet.
+ """
+
+
def init(win_id: int, parent: QObject) -> 'ModeManager':
"""Initialize the mode manager and the keyparsers for the given win_id."""
modeman = ModeManager(win_id, parent)
@@ -95,70 +102,86 @@ def init(win_id: int, parent: QObject) -> 'ModeManager':
parent=modeman),
usertypes.KeyMode.insert:
- modeparsers.PassthroughKeyParser(
- win_id=win_id,
+ modeparsers.CommandKeyParser(
mode=usertypes.KeyMode.insert,
+ win_id=win_id,
commandrunner=commandrunner,
- parent=modeman),
+ parent=modeman,
+ passthrough=True,
+ do_log=False,
+ supports_count=False),
usertypes.KeyMode.passthrough:
- modeparsers.PassthroughKeyParser(
- win_id=win_id,
+ modeparsers.CommandKeyParser(
mode=usertypes.KeyMode.passthrough,
+ win_id=win_id,
commandrunner=commandrunner,
- parent=modeman),
+ parent=modeman,
+ passthrough=True,
+ do_log=False,
+ supports_count=False),
usertypes.KeyMode.command:
- modeparsers.PassthroughKeyParser(
- win_id=win_id,
+ modeparsers.CommandKeyParser(
mode=usertypes.KeyMode.command,
+ win_id=win_id,
commandrunner=commandrunner,
- parent=modeman),
+ parent=modeman,
+ passthrough=True,
+ do_log=False,
+ supports_count=False),
usertypes.KeyMode.prompt:
- modeparsers.PassthroughKeyParser(
- win_id=win_id,
+ modeparsers.CommandKeyParser(
mode=usertypes.KeyMode.prompt,
+ win_id=win_id,
commandrunner=commandrunner,
- parent=modeman),
+ parent=modeman,
+ passthrough=True,
+ do_log=False,
+ supports_count=False),
usertypes.KeyMode.yesno:
- modeparsers.PromptKeyParser(
+ modeparsers.CommandKeyParser(
+ mode=usertypes.KeyMode.yesno,
win_id=win_id,
commandrunner=commandrunner,
- parent=modeman),
+ parent=modeman,
+ supports_count=False),
usertypes.KeyMode.caret:
- modeparsers.CaretKeyParser(
+ modeparsers.CommandKeyParser(
+ mode=usertypes.KeyMode.caret,
win_id=win_id,
commandrunner=commandrunner,
- parent=modeman),
+ parent=modeman,
+ passthrough=True),
usertypes.KeyMode.set_mark:
modeparsers.RegisterKeyParser(
- win_id=win_id,
mode=usertypes.KeyMode.set_mark,
+ win_id=win_id,
commandrunner=commandrunner,
parent=modeman),
usertypes.KeyMode.jump_mark:
modeparsers.RegisterKeyParser(
- win_id=win_id,
mode=usertypes.KeyMode.jump_mark,
+ win_id=win_id,
commandrunner=commandrunner,
parent=modeman),
usertypes.KeyMode.record_macro:
modeparsers.RegisterKeyParser(
- win_id=win_id,
mode=usertypes.KeyMode.record_macro,
+ win_id=win_id,
commandrunner=commandrunner,
parent=modeman),
usertypes.KeyMode.run_macro:
modeparsers.RegisterKeyParser(
- win_id=win_id,
mode=usertypes.KeyMode.run_macro,
+ win_id=win_id,
commandrunner=commandrunner,
parent=modeman),
} # type: ParserDictType
@@ -169,9 +192,17 @@ def init(win_id: int, parent: QObject) -> 'ModeManager':
return modeman
-def instance(win_id: typing.Union[int, str]) -> 'ModeManager':
- """Get a modemanager object."""
- return objreg.get('mode-manager', scope='window', window=win_id)
+def instance(win_id: Union[int, str]) -> 'ModeManager':
+ """Get a modemanager object.
+
+ Raises UnavailableError if there is no instance available yet.
+ """
+ mode_manager = objreg.get('mode-manager', scope='window', window=win_id,
+ default=None)
+ if mode_manager is not None:
+ return mode_manager
+ else:
+ raise UnavailableError("ModeManager is not initialized yet.")
def enter(win_id: int,
@@ -212,10 +243,15 @@ class ModeManager(QObject):
arg1: The mode which has been left.
arg2: The new current mode.
arg3: The window ID of this mode manager.
+ keystring_updated: Emitted when the keystring was updated in any mode.
+ arg 1: The mode in which the keystring has been
+ updated.
+ arg 2: The new key string.
"""
entered = pyqtSignal(usertypes.KeyMode, int)
left = pyqtSignal(usertypes.KeyMode, usertypes.KeyMode, int)
+ keystring_updated = pyqtSignal(usertypes.KeyMode, str)
def __init__(self, win_id: int, parent: QObject = None) -> None:
super().__init__(parent)
@@ -223,7 +259,7 @@ class ModeManager(QObject):
self.parsers = {} # type: ParserDictType
self._prev_mode = usertypes.KeyMode.normal
self.mode = usertypes.KeyMode.normal
- self._releaseevents_to_pass = set() # type: typing.Set[KeyEvent]
+ self._releaseevents_to_pass = set() # type: Set[KeyEvent]
def __repr__(self) -> str:
return utils.get_repr(self, mode=self.mode)
@@ -246,9 +282,11 @@ class ModeManager(QObject):
"{}".format(curmode, utils.qualname(parser)))
match = parser.handle(event, dry_run=dry_run)
- is_non_alnum = (
- event.modifiers() not in [Qt.NoModifier, Qt.ShiftModifier] or
- not event.text().strip())
+ has_modifier = event.modifiers() not in [
+ Qt.NoModifier,
+ Qt.ShiftModifier,
+ ] # type: ignore[comparison-overlap]
+ is_non_alnum = has_modifier or not event.text().strip()
forward_unbound_keys = config.cache['input.forward_unbound_keys']
@@ -299,6 +337,8 @@ class ModeManager(QObject):
assert parser is not None
self.parsers[mode] = parser
parser.request_leave.connect(self.leave)
+ parser.keystring_updated.connect(
+ functools.partial(self.keystring_updated.emit, mode))
def enter(self, mode: usertypes.KeyMode,
reason: str = None,
@@ -416,9 +456,9 @@ class ModeManager(QObject):
QEvent.KeyRelease: self._handle_keyrelease,
QEvent.ShortcutOverride:
functools.partial(self._handle_keypress, dry_run=True),
- } # type: typing.Mapping[QEvent.Type, typing.Callable[[QEvent], bool]]
+ } # type: Mapping[QEvent.Type, Callable[[QKeyEvent], bool]]
handler = handlers[event.type()]
- return handler(event)
+ return handler(cast(QKeyEvent, event))
@cmdutils.register(instance='mode-manager', scope='window')
def clear_keychain(self) -> None:
diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py
index acac59ad5..a55639898 100644
--- a/qutebrowser/keyinput/modeparsers.py
+++ b/qutebrowser/keyinput/modeparsers.py
@@ -51,10 +51,16 @@ class CommandKeyParser(basekeyparser.BaseKeyParser):
_commandrunner: CommandRunner instance.
"""
- def __init__(self, win_id: int,
+ def __init__(self, *, mode: usertypes.KeyMode,
+ win_id: int,
commandrunner: 'runners.CommandRunner',
- parent: QObject = None) -> None:
- super().__init__(win_id, parent)
+ parent: QObject = None,
+ do_log: bool = True,
+ passthrough: bool = False,
+ supports_count: bool = True) -> None:
+ super().__init__(mode=mode, win_id=win_id, parent=parent,
+ do_log=do_log, passthrough=passthrough,
+ supports_count=supports_count)
self._commandrunner = commandrunner
def execute(self, cmdstr: str, count: int = None) -> None:
@@ -72,11 +78,11 @@ class NormalKeyParser(CommandKeyParser):
_partial_timer: Timer to clear partial keypresses.
"""
- def __init__(self, win_id: int,
+ def __init__(self, *, win_id: int,
commandrunner: 'runners.CommandRunner',
parent: QObject = None) -> None:
- super().__init__(win_id, commandrunner, parent)
- self._read_config('normal')
+ super().__init__(mode=usertypes.KeyMode.normal, win_id=win_id,
+ commandrunner=commandrunner, parent=parent)
self._partial_timer = usertypes.Timer(self, 'partial-match')
self._partial_timer.setSingleShot(True)
self._partial_timer.timeout.connect(self._clear_partial_match)
@@ -130,56 +136,7 @@ class NormalKeyParser(CommandKeyParser):
self._inhibited = False
-class PassthroughKeyParser(CommandKeyParser):
-
- """KeyChainParser which passes through normal keys.
-
- Used for insert/passthrough modes.
-
- Attributes:
- _mode: The mode this keyparser is for.
- """
-
- do_log = False
- passthrough = True
- supports_count = False
-
- def __init__(self, win_id: int,
- mode: usertypes.KeyMode,
- commandrunner: 'runners.CommandRunner',
- parent: QObject = None) -> None:
- """Constructor.
-
- Args:
- mode: The mode this keyparser is for.
- parent: Qt parent.
- warn: Whether to warn if an ignored key was bound.
- """
- super().__init__(win_id, commandrunner, parent)
- self._read_config(mode.name)
- self._mode = mode
-
- def __repr__(self) -> str:
- return utils.get_repr(self, mode=self._mode)
-
-
-class PromptKeyParser(CommandKeyParser):
-
- """KeyParser for yes/no prompts."""
-
- supports_count = False
-
- def __init__(self, win_id: int,
- commandrunner: 'runners.CommandRunner',
- parent: QObject = None) -> None:
- super().__init__(win_id, commandrunner, parent)
- self._read_config('yesno')
-
- def __repr__(self) -> str:
- return utils.get_repr(self)
-
-
-class HintKeyParser(CommandKeyParser):
+class HintKeyParser(basekeyparser.BaseKeyParser):
"""KeyChainParser for hints.
@@ -189,17 +146,20 @@ class HintKeyParser(CommandKeyParser):
_last_press: The nature of the last keypress, a LastPress member.
"""
- supports_count = False
-
- def __init__(self, win_id: int,
+ def __init__(self, *, win_id: int,
commandrunner: 'runners.CommandRunner',
hintmanager: hints.HintManager,
parent: QObject = None) -> None:
- super().__init__(win_id, commandrunner, parent)
+ super().__init__(mode=usertypes.KeyMode.hint, win_id=win_id,
+ parent=parent, supports_count=False)
+ self._command_parser = CommandKeyParser(mode=usertypes.KeyMode.hint,
+ win_id=win_id,
+ commandrunner=commandrunner,
+ parent=self,
+ supports_count=False)
self._hintmanager = hintmanager
self._filtertext = ''
self._last_press = LastPress.none
- self._read_config('hint')
self.keystring_updated.connect(self._hintmanager.handle_partial_key)
def _handle_filter_key(self, e: QKeyEvent) -> QKeySequence.SequenceMatch:
@@ -242,11 +202,14 @@ class HintKeyParser(CommandKeyParser):
if dry_run:
return super().handle(e, dry_run=True)
- if keyutils.is_special_hint_mode(Qt.Key(e.key()), e.modifiers()):
- log.keyboard.debug("Got special key, clearing keychain")
+ assert not dry_run
+
+ if (self._command_parser.handle(e, dry_run=True) !=
+ QKeySequence.NoMatch):
+ log.keyboard.debug("Handling key via command parser")
self.clear_keystring()
+ return self._command_parser.handle(e)
- assert not dry_run
match = super().handle(e)
if match == QKeySequence.PartialMatch:
@@ -271,23 +234,14 @@ class HintKeyParser(CommandKeyParser):
`self._filtertext`.
"""
self._read_config()
- self.bindings.update({keyutils.KeySequence.parse(s):
- 'follow-hint -s ' + s for s in strings})
+ self.bindings.update({keyutils.KeySequence.parse(s): s
+ for s in strings})
if not preserve_filter:
self._filtertext = ''
-
-class CaretKeyParser(CommandKeyParser):
-
- """KeyParser for caret mode."""
-
- passthrough = True
-
- def __init__(self, win_id: int,
- commandrunner: 'runners.CommandRunner',
- parent: QObject = None) -> None:
- super().__init__(win_id, commandrunner, parent)
- self._read_config('caret')
+ def execute(self, cmdstr: str, count: int = None) -> None:
+ assert count is None
+ self._hintmanager.handle_partial_key(cmdstr)
class RegisterKeyParser(CommandKeyParser):
@@ -295,19 +249,18 @@ class RegisterKeyParser(CommandKeyParser):
"""KeyParser for modes that record a register key.
Attributes:
- _mode: One of KeyMode.set_mark, KeyMode.jump_mark, KeyMode.record_macro
- and KeyMode.run_macro.
+ _register_mode: One of KeyMode.set_mark, KeyMode.jump_mark,
+ KeyMode.record_macro and KeyMode.run_macro.
"""
- supports_count = False
-
- def __init__(self, win_id: int,
+ def __init__(self, *, win_id: int,
mode: usertypes.KeyMode,
commandrunner: 'runners.CommandRunner',
parent: QObject = None) -> None:
- super().__init__(win_id, commandrunner, parent)
- self._mode = mode
- self._read_config('register')
+ super().__init__(mode=usertypes.KeyMode.register, win_id=win_id,
+ commandrunner=commandrunner, parent=parent,
+ supports_count=False)
+ self._register_mode = mode
def handle(self, e: QKeyEvent, *,
dry_run: bool = False) -> QKeySequence.SequenceMatch:
@@ -326,19 +279,20 @@ class RegisterKeyParser(CommandKeyParser):
window=self._win_id)
try:
- if self._mode == usertypes.KeyMode.set_mark:
+ if self._register_mode == usertypes.KeyMode.set_mark:
tabbed_browser.set_mark(key)
- elif self._mode == usertypes.KeyMode.jump_mark:
+ elif self._register_mode == usertypes.KeyMode.jump_mark:
tabbed_browser.jump_mark(key)
- elif self._mode == usertypes.KeyMode.record_macro:
+ elif self._register_mode == usertypes.KeyMode.record_macro:
macros.macro_recorder.record_macro(key)
- elif self._mode == usertypes.KeyMode.run_macro:
+ elif self._register_mode == usertypes.KeyMode.run_macro:
macros.macro_recorder.run_macro(self._win_id, key)
else:
- raise ValueError(
- "{} is not a valid register mode".format(self._mode))
+ raise ValueError("{} is not a valid register mode".format(
+ self._register_mode))
except cmdexc.Error as err:
message.error(str(err), stack=traceback.format_exc())
- self.request_leave.emit(self._mode, "valid register key", True)
+ self.request_leave.emit(
+ self._register_mode, "valid register key", True)
return QKeySequence.ExactMatch
diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py
index 2cdf64e1f..cf77866f2 100644
--- a/qutebrowser/mainwindow/mainwindow.py
+++ b/qutebrowser/mainwindow/mainwindow.py
@@ -102,7 +102,7 @@ def raise_window(window, alert=True):
window.setWindowState(window.windowState() | Qt.WindowActive)
window.raise_()
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-69568
- QCoreApplication.processEvents( # type: ignore
+ QCoreApplication.processEvents( # type: ignore[call-overload]
QEventLoop.ExcludeUserInputEvents | QEventLoop.ExcludeSocketNotifiers)
window.activateWindow()
@@ -157,8 +157,11 @@ class MainWindow(QWidget):
color: {{ conf.colors.hints.fg }};
font: {{ conf.fonts.hints }};
border: {{ conf.hints.border }};
- padding-left: 3px;
- padding-right: 3px;
+ border-radius: {{ conf.hints.radius }}px;
+ padding-top: {{ conf.hints.padding['top'] }}px;
+ padding-left: {{ conf.hints.padding['left'] }}px;
+ padding-right: {{ conf.hints.padding['right'] }}px;
+ padding-bottom: {{ conf.hints.padding['bottom'] }}px;
}
QMenu {
@@ -181,6 +184,15 @@ class MainWindow(QWidget):
color: {{ conf.colors.contextmenu.selected.fg }};
{% endif %}
}
+
+ QMenu::item:disabled {
+ {% if conf.colors.contextmenu.disabled.bg %}
+ background-color: {{ conf.colors.contextmenu.disabled.bg }};
+ {% endif %}
+ {% if conf.colors.contextmenu.disabled.fg %}
+ color: {{ conf.colors.contextmenu.disabled.fg }};
+ {% endif %}
+ }
"""
def __init__(self, *, private, geometry=None, parent=None):
@@ -381,7 +393,9 @@ class MainWindow(QWidget):
self._command_dispatcher,
command_only=True,
scope='window', window=self.win_id)
- self.tabbed_browser.widget.destroyed.connect( # type: ignore
+
+ widget = self.tabbed_browser.widget
+ widget.destroyed.connect(
functools.partial(objreg.delete, 'command-dispatcher',
scope='window', window=self.win_id))
@@ -406,7 +420,7 @@ class MainWindow(QWidget):
self._vbox.removeWidget(self.tabbed_browser.widget)
self._vbox.removeWidget(self._downloadview)
self._vbox.removeWidget(self.status)
- widgets = [self.tabbed_browser.widget]
+ widgets = [self.tabbed_browser.widget] # type: typing.List[QWidget]
downloads_position = config.val.downloads.position
if downloads_position == 'top':
@@ -443,7 +457,7 @@ class MainWindow(QWidget):
def _save_geometry(self):
"""Save the window geometry to the state config."""
- data = bytes(self.saveGeometry())
+ data = self.saveGeometry().data()
geom = base64.b64encode(data).decode('ASCII')
configfiles.state['geometry']['mainwindow'] = geom
@@ -488,15 +502,14 @@ class MainWindow(QWidget):
mode_manager.left.connect(self.status.on_mode_left)
mode_manager.left.connect(self.status.cmd.on_mode_left)
mode_manager.left.connect(
- message.global_bridge.mode_left) # type: ignore
+ message.global_bridge.mode_left) # type: ignore[arg-type]
# commands
- normal_parser = mode_manager.parsers[usertypes.KeyMode.normal]
- normal_parser.keystring_updated.connect(
- self.status.keystring.setText)
- self.status.cmd.got_cmd[str].connect( # type: ignore
+ mode_manager.keystring_updated.connect(
+ self.status.keystring.on_keystring_updated)
+ self.status.cmd.got_cmd[str].connect( # type: ignore[index]
self._commandrunner.run_safely)
- self.status.cmd.got_cmd[str, int].connect( # type: ignore
+ self.status.cmd.got_cmd[str, int].connect( # type: ignore[index]
self._commandrunner.run_safely)
self.status.cmd.returnPressed.connect(
self.tabbed_browser.on_cmd_return_pressed)
@@ -504,9 +517,7 @@ class MainWindow(QWidget):
self._command_dispatcher.search)
# key hint popup
- for mode, parser in mode_manager.parsers.items():
- parser.keystring_updated.connect(functools.partial(
- self._keyhint.update_keyhint, mode.name))
+ mode_manager.keystring_updated.connect(self._keyhint.update_keyhint)
# messages
message.global_bridge.show_message.connect(
@@ -571,17 +582,18 @@ class MainWindow(QWidget):
refresh_window = self.isVisible()
if hidden:
window_flags |= Qt.CustomizeWindowHint | Qt.NoDropShadowWindowHint
- self.setWindowFlags(window_flags)
+ self.setWindowFlags(typing.cast(Qt.WindowFlags, window_flags))
if refresh_window:
self.show()
@pyqtSlot(bool)
def _on_fullscreen_requested(self, on):
- if not config.val.content.windowed_fullscreen:
+ if not config.val.content.fullscreen.window:
if on:
self.state_before_fullscreen = self.windowState()
- self.setWindowState(Qt.WindowFullScreen | # type: ignore
- self.state_before_fullscreen)
+ self.setWindowState(
+ Qt.WindowFullScreen | # type: ignore[arg-type]
+ self.state_before_fullscreen) # type: ignore[operator]
elif self.isFullScreen():
self.setWindowState(self.state_before_fullscreen)
log.misc.debug('on: {}, state before fullscreen: {}'.format(
diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py
index bfe6fb812..a929d6428 100644
--- a/qutebrowser/mainwindow/prompt.py
+++ b/qutebrowser/mainwindow/prompt.py
@@ -192,12 +192,13 @@ class PromptQueue(QObject):
if blocking:
loop = qtutils.EventLoop()
self._loops.append(loop)
- loop.destroyed.connect( # type: ignore
- lambda: self._loops.remove(loop))
+ loop.destroyed.connect(lambda: self._loops.remove(loop))
question.completed.connect(loop.quit)
question.completed.connect(loop.deleteLater)
log.prompt.debug("Starting loop.exec_() for {}".format(question))
- loop.exec_(QEventLoop.ExcludeSocketNotifiers)
+ flags = typing.cast(QEventLoop.ProcessEventsFlags,
+ QEventLoop.ExcludeSocketNotifiers)
+ loop.exec_(flags)
log.prompt.debug("Ending loop.exec_() for {}".format(question))
log.prompt.debug("Restoring old question {}".format(old_question))
@@ -329,7 +330,7 @@ class PromptContainer(QWidget):
usertypes.PromptMode.alert: AlertPrompt,
}
klass = classes[question.mode]
- prompt = typing.cast(_BasePrompt, klass(question))
+ prompt = klass(question)
log.prompt.debug("Displaying prompt {}".format(prompt))
self._prompt = prompt
@@ -707,7 +708,7 @@ class FilenamePrompt(_BasePrompt):
# Nothing selected initially
self._file_view.setCurrentIndex(QModelIndex())
# The model needs to be sorted so we get the correct first/last index
- self._file_model.directoryLoaded.connect( # type: ignore
+ self._file_model.directoryLoaded.connect(
lambda: self._file_model.sort(0))
def accept(self, value=None, save=False):
@@ -755,7 +756,7 @@ class FilenamePrompt(_BasePrompt):
selmodel.setCurrentIndex(
idx,
- QItemSelectionModel.ClearAndSelect | # type: ignore
+ QItemSelectionModel.ClearAndSelect | # type: ignore[arg-type]
QItemSelectionModel.Rows)
self._insert_path(idx, clicked=False)
@@ -782,7 +783,7 @@ class DownloadFilenamePrompt(FilenamePrompt):
def __init__(self, question, parent=None):
super().__init__(question, parent)
self._file_model.setFilter(
- QDir.AllDirs | QDir.Drives | QDir.NoDot) # type: ignore
+ QDir.AllDirs | QDir.Drives | QDir.NoDot) # type: ignore[arg-type]
def accept(self, value=None, save=False):
done = super().accept(value, save)
@@ -960,5 +961,5 @@ def init():
"""Initialize global prompt objects."""
global prompt_queue
prompt_queue = PromptQueue()
- message.global_bridge.ask_question.connect( # type: ignore
+ message.global_bridge.ask_question.connect( # type: ignore[call-arg]
prompt_queue.ask_question, Qt.DirectConnection)
diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py
index b1aa4da38..f83c77db9 100644
--- a/qutebrowser/mainwindow/statusbar/bar.py
+++ b/qutebrowser/mainwindow/statusbar/bar.py
@@ -21,7 +21,8 @@
import enum
import attr
-from PyQt5.QtCore import pyqtSignal, pyqtSlot, pyqtProperty, Qt, QSize, QTimer
+from PyQt5.QtCore import (pyqtSignal, pyqtSlot, # type: ignore[attr-defined]
+ pyqtProperty, Qt, QSize, QTimer)
from PyQt5.QtWidgets import QWidget, QHBoxLayout, QStackedLayout, QSizePolicy
from qutebrowser.browser import browsertab
@@ -202,7 +203,7 @@ class StatusBar(QWidget):
@pyqtSlot(str)
def _on_config_changed(self, option):
- if option == 'statusbar.hide':
+ if option == 'statusbar.show':
self.maybe_hide()
elif option == 'statusbar.padding':
self._set_hbox_padding()
@@ -253,12 +254,26 @@ class StatusBar(QWidget):
@pyqtSlot()
def maybe_hide(self):
"""Hide the statusbar if it's configured to do so."""
+ strategy = config.val.statusbar.show
tab = self._current_tab()
- hide = config.val.statusbar.hide
- if hide or (tab is not None and tab.data.fullscreen):
+ if tab is not None and tab.data.fullscreen:
self.hide()
- else:
+ elif strategy == 'never':
+ self.hide()
+ elif strategy == 'in-mode':
+ try:
+ mode_manager = modeman.instance(self._win_id)
+ except modeman.UnavailableError:
+ self.hide()
+ else:
+ if mode_manager.mode == usertypes.KeyMode.normal:
+ self.hide()
+ else:
+ self.show()
+ elif strategy == 'always':
self.show()
+ else:
+ raise utils.Unreachable
def _set_hbox_padding(self):
padding = config.val.statusbar.padding
@@ -335,6 +350,8 @@ class StatusBar(QWidget):
def on_mode_entered(self, mode):
"""Mark certain modes in the commandline."""
mode_manager = modeman.instance(self._win_id)
+ if config.val.statusbar.show == 'in-mode':
+ self.show()
if mode_manager.parsers[mode].passthrough:
self._set_mode_text(mode.name)
if mode in [usertypes.KeyMode.insert,
@@ -349,6 +366,8 @@ class StatusBar(QWidget):
def on_mode_left(self, old_mode, new_mode):
"""Clear marked mode."""
mode_manager = modeman.instance(self._win_id)
+ if config.val.statusbar.show == 'in-mode':
+ self.hide()
if mode_manager.parsers[old_mode].passthrough:
if mode_manager.parsers[new_mode].passthrough:
self._set_mode_text(new_mode.name)
@@ -372,13 +391,17 @@ class StatusBar(QWidget):
self.maybe_hide()
assert tab.is_private == self._color_flags.private
- @pyqtSlot(bool)
- def on_caret_selection_toggled(self, selection):
+ @pyqtSlot(browsertab.SelectionState)
+ def on_caret_selection_toggled(self, selection_state):
"""Update the statusbar when entering/leaving caret selection mode."""
- log.statusbar.debug("Setting caret selection {}".format(selection))
- if selection:
+ log.statusbar.debug("Setting caret selection {}"
+ .format(selection_state))
+ if selection_state is browsertab.SelectionState.normal:
self._set_mode_text("caret selection")
self._color_flags.caret = ColorFlags.CaretMode.selection
+ elif selection_state is browsertab.SelectionState.line:
+ self._set_mode_text("caret line selection")
+ self._color_flags.caret = ColorFlags.CaretMode.selection
else:
self._set_mode_text("caret")
self._color_flags.caret = ColorFlags.CaretMode.on
diff --git a/qutebrowser/mainwindow/statusbar/command.py b/qutebrowser/mainwindow/statusbar/command.py
index 7ead6936a..ebd9d3921 100644
--- a/qutebrowser/mainwindow/statusbar/command.py
+++ b/qutebrowser/mainwindow/statusbar/command.py
@@ -72,8 +72,9 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit):
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Ignored)
self.cursorPositionChanged.connect(
- self.update_completion) # type: ignore
- self.textChanged.connect(self.update_completion) # type: ignore
+ self.update_completion) # type: ignore[arg-type]
+ self.textChanged.connect(
+ self.update_completion) # type: ignore[arg-type]
self.textChanged.connect(self.updateGeometry)
self.textChanged.connect(self._incremental_search)
@@ -148,7 +149,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit):
raise cmdutils.CommandError(
"Invalid command text '{}'.".format(text))
if run_on_count and count is not None:
- self.got_cmd[str, int].emit(text, count) # type: ignore
+ self.got_cmd[str, int].emit(text, count) # type: ignore[index]
else:
self.set_cmd_text(text)
@@ -198,7 +199,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit):
'cmd accept')
if not was_search:
- self.got_cmd[str].emit(text[1:]) # type: ignore
+ self.got_cmd[str].emit(text[1:]) # type: ignore[index]
@cmdutils.register(instance='status-command', scope='window')
def edit_command(self, run: bool = False) -> None:
diff --git a/qutebrowser/mainwindow/statusbar/keystring.py b/qutebrowser/mainwindow/statusbar/keystring.py
index a3b64892f..a64c8e0e2 100644
--- a/qutebrowser/mainwindow/statusbar/keystring.py
+++ b/qutebrowser/mainwindow/statusbar/keystring.py
@@ -19,9 +19,16 @@
"""Keychain string displayed in the statusbar."""
+from PyQt5.QtCore import pyqtSlot
+
from qutebrowser.mainwindow.statusbar import textbase
+from qutebrowser.utils import usertypes
class KeyString(textbase.TextBase):
"""Keychain string displayed in the statusbar."""
+
+ @pyqtSlot(usertypes.KeyMode, str)
+ def on_keystring_updated(self, _mode, keystr):
+ self.setText(keystr)
diff --git a/qutebrowser/mainwindow/statusbar/url.py b/qutebrowser/mainwindow/statusbar/url.py
index b48f0005f..c8300dc97 100644
--- a/qutebrowser/mainwindow/statusbar/url.py
+++ b/qutebrowser/mainwindow/statusbar/url.py
@@ -21,7 +21,8 @@
import enum
-from PyQt5.QtCore import pyqtSlot, pyqtProperty, QUrl
+from PyQt5.QtCore import (pyqtSlot, pyqtProperty, # type: ignore[attr-defined]
+ QUrl)
from qutebrowser.mainwindow.statusbar import textbase
from qutebrowser.config import stylesheet
diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py
index f5dc3277b..f9112c6ab 100644
--- a/qutebrowser/mainwindow/tabbedbrowser.py
+++ b/qutebrowser/mainwindow/tabbedbrowser.py
@@ -64,8 +64,11 @@ class TabDeque:
"""
def __init__(self) -> None:
+ size = config.val.tabs.focus_stack_size
+ if size < 0:
+ size = None
self._stack = collections.deque(
- maxlen=config.val.tabs.focus_stack_size
+ maxlen=size
) # type: typing.Deque[weakref.ReferenceType[QWidget]]
# Items that have been removed from the primary stack.
self._stack_deleted = [
@@ -97,7 +100,7 @@ class TabDeque:
self._ignore_next = True
return tab
- def next(self, cur_tab: QWidget, *, keep_overflow=True) -> QWidget:
+ def next(self, cur_tab: QWidget, *, keep_overflow: bool = True) -> QWidget:
"""Get the 'next' tab in the stack.
Throws IndexError on failure.
@@ -189,7 +192,7 @@ class TabbedBrowser(QWidget):
cur_scroll_perc_changed = pyqtSignal(int, int)
cur_load_status_changed = pyqtSignal(usertypes.LoadStatus)
cur_fullscreen_requested = pyqtSignal(bool)
- cur_caret_selection_toggled = pyqtSignal(bool)
+ cur_caret_selection_toggled = pyqtSignal(browsertab.SelectionState)
close_window = pyqtSignal()
resized = pyqtSignal('QRect')
current_tab_changed = pyqtSignal(browsertab.AbstractTab)
@@ -204,11 +207,10 @@ class TabbedBrowser(QWidget):
self._tab_insert_idx_left = 0
self._tab_insert_idx_right = -1
self.shutting_down = False
- self.widget.tabCloseRequested.connect( # type: ignore
- self.on_tab_close_requested)
- self.widget.new_tab_requested.connect(self.tabopen)
- self.widget.currentChanged.connect( # type: ignore
- self._on_current_changed)
+ self.widget.tabCloseRequested.connect(self.on_tab_close_requested)
+ self.widget.new_tab_requested.connect(
+ self.tabopen) # type: ignore[arg-type]
+ self.widget.currentChanged.connect(self._on_current_changed)
self.cur_fullscreen_requested.connect(self.widget.tabBar().maybe_hide)
self.widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
@@ -286,7 +288,7 @@ class TabbedBrowser(QWidget):
for i in range(self.widget.count()):
widget = self.widget.widget(i)
if widget is None:
- log.webview.debug( # type: ignore
+ log.webview.debug( # type: ignore[unreachable]
"Got None-widget in tabbedbrowser!")
else:
widgets.append(widget)
@@ -506,10 +508,7 @@ class TabbedBrowser(QWidget):
newtab = self.widget.widget(0)
use_current_tab = False
else:
- # FIXME:typing mypy thinks this is None due to @pyqtSlot
- newtab = typing.cast(
- browsertab.AbstractTab,
- self.tabopen(background=False, idx=entry.index))
+ newtab = self.tabopen(background=False, idx=entry.index)
newtab.history.private_api.deserialize(entry.history)
self.widget.set_tab_pinned(newtab, entry.pinned)
@@ -533,7 +532,7 @@ class TabbedBrowser(QWidget):
"""Close a tab via an index."""
tab = self.widget.widget(idx)
if tab is None:
- log.webview.debug( # type: ignore
+ log.webview.debug( # type: ignore[unreachable]
"Got invalid tab {} for index {}!".format(tab, idx))
return
self.tab_close_prompt_if_pinned(
@@ -805,7 +804,7 @@ class TabbedBrowser(QWidget):
"""Give focus to current tab if command mode was left."""
widget = self.widget.currentWidget()
if widget is None:
- return # type: ignore
+ return # type: ignore[unreachable]
if mode in [usertypes.KeyMode.command] + modeman.PROMPT_MODES:
log.modes.debug("Left status-input mode, focusing {!r}".format(
widget))
@@ -822,7 +821,7 @@ class TabbedBrowser(QWidget):
return
tab = self.widget.widget(idx)
if tab is None:
- log.webview.debug( # type: ignore
+ log.webview.debug( # type: ignore[unreachable]
"on_current_changed got called with invalid index {}"
.format(idx))
return
diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py
index ca4014b28..60006fa14 100644
--- a/qutebrowser/mainwindow/tabwidget.py
+++ b/qutebrowser/mainwindow/tabwidget.py
@@ -60,10 +60,11 @@ class TabWidget(QTabWidget):
bar = TabBar(win_id, self)
self.setStyle(TabBarStyle())
self.setTabBar(bar)
- bar.tabCloseRequested.connect(self.tabCloseRequested) # type: ignore
- bar.tabMoved.connect(functools.partial( # type: ignore
+ bar.tabCloseRequested.connect(
+ self.tabCloseRequested) # type: ignore[arg-type]
+ bar.tabMoved.connect(functools.partial(
QTimer.singleShot, 0, self.update_tab_titles))
- bar.currentChanged.connect(self._on_current_changed) # type: ignore
+ bar.currentChanged.connect(self._on_current_changed)
bar.new_tab_requested.connect(self._on_new_tab_requested)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setDocumentMode(True)
@@ -82,7 +83,8 @@ class TabWidget(QTabWidget):
position = config.val.tabs.position
selection_behavior = config.val.tabs.select_on_remove
self.setTabPosition(position)
- tabbar.vertical = position in [QTabWidget.West, QTabWidget.East]
+ tabbar.vertical = position in [ # type: ignore[attr-defined]
+ QTabWidget.West, QTabWidget.East]
tabbar.setSelectionBehaviorOnRemove(selection_behavior)
tabbar.refresh()
@@ -163,7 +165,7 @@ class TabWidget(QTabWidget):
"""Get the tab field data."""
tab = self.widget(idx)
if tab is None:
- log.misc.debug( # type: ignore
+ log.misc.debug( # type: ignore[unreachable]
"Got None-tab in get_tab_fields!")
page_title = self.page_title(idx)
@@ -330,7 +332,7 @@ class TabWidget(QTabWidget):
"""
tab = self.widget(idx)
if tab is None:
- url = QUrl() # type: ignore
+ url = QUrl() # type: ignore[unreachable]
else:
url = tab.url()
# It's possible for url to be invalid, but the caller will handle that.
@@ -350,7 +352,7 @@ class TabWidget(QTabWidget):
if config.val.tabs.tabs_are_windows:
self.window().setWindowIcon(self.window().windowIcon())
- def setTabIcon(self, idx: int, icon: QIcon):
+ def setTabIcon(self, idx: int, icon: QIcon) -> None:
"""Always show tab icons for pinned tabs in some circumstances."""
tab = typing.cast(typing.Optional[browsertab.AbstractTab],
self.widget(idx))
@@ -383,8 +385,13 @@ class TabBar(QTabBar):
STYLESHEET = """
TabBar {
+ font: {{ conf.fonts.tabs.unselected }};
background-color: {{ conf.colors.tabs.bar.bg }};
}
+
+ TabBar::tab:selected {
+ font: {{ conf.fonts.tabs.selected }};
+ }
"""
new_tab_requested = pyqtSignal()
@@ -393,8 +400,6 @@ class TabBar(QTabBar):
super().__init__(parent)
self._win_id = win_id
self.setStyle(TabBarStyle())
- self._set_font()
- config.instance.changed.connect(self._on_config_changed)
self.vertical = False
self._auto_hide_timer = QTimer()
self._auto_hide_timer.setSingleShot(True)
@@ -403,6 +408,9 @@ class TabBar(QTabBar):
self.setAutoFillBackground(True)
self.drag_in_progress = False
stylesheet.set_register(self)
+ self.ensurePolished()
+ config.instance.changed.connect(self._on_config_changed)
+ self._set_icon_size()
QTimer.singleShot(0, self.maybe_hide)
def __repr__(self):
@@ -414,8 +422,9 @@ class TabBar(QTabBar):
@pyqtSlot(str)
def _on_config_changed(self, option: str) -> None:
- if option == 'fonts.tabs':
- self._set_font()
+ if option.startswith('fonts.tabs.'):
+ self.ensurePolished()
+ self._set_icon_size()
elif option == 'tabs.favicons.scale':
self._set_icon_size()
elif option == 'tabs.show_switching_delay':
@@ -431,7 +440,9 @@ class TabBar(QTabBar):
"tabs.padding",
"tabs.indicator.width",
"tabs.min_width",
- "tabs.pinned.shrink"]:
+ "tabs.pinned.shrink",
+ "fonts.tabs.selected",
+ "fonts.tabs.unselected"]:
self._minimum_tab_size_hint_helper.cache_clear()
self._minimum_tab_height.cache_clear()
@@ -504,14 +515,6 @@ class TabBar(QTabBar):
# code sets layoutDirty so it actually relayouts the tabs.
self.setIconSize(self.iconSize())
- def _set_font(self):
- """Set the tab bar font."""
- self.setFont(config.val.fonts.tabs)
- self._set_icon_size()
- # clear tab size cache
- self._minimum_tab_size_hint_helper.cache_clear()
- self._minimum_tab_height.cache_clear()
-
def _set_icon_size(self):
"""Set the tab bar favicon size."""
size = self.fontMetrics().height() - 2
@@ -544,7 +547,7 @@ class TabBar(QTabBar):
idx = self.currentIndex()
elif action == 'close-last':
idx = self.count() - 1
- self.tabCloseRequested.emit(idx) # type: ignore
+ self.tabCloseRequested.emit(idx)
return
super().mousePressEvent(e)
diff --git a/qutebrowser/misc/autoupdate.py b/qutebrowser/misc/autoupdate.py
index e51ecfb19..4838d55ed 100644
--- a/qutebrowser/misc/autoupdate.py
+++ b/qutebrowser/misc/autoupdate.py
@@ -55,7 +55,7 @@ class PyPIVersionClient(QObject):
self._client = httpclient.HTTPClient(self)
else:
self._client = client
- self._client.error.connect(self.error) # type: ignore
+ self._client.error.connect(self.error) # type: ignore[arg-type]
self._client.success.connect(self.on_client_success)
def get_version(self, package='qutebrowser'):
diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py
index 6aab87ea6..089e3191f 100644
--- a/qutebrowser/misc/backendproblem.py
+++ b/qutebrowser/misc/backendproblem.py
@@ -23,8 +23,6 @@ import os
import sys
import functools
import html
-import ctypes
-import ctypes.util
import enum
import shutil
import typing
@@ -59,7 +57,7 @@ class _Button:
text = attr.ib() # type: str
setting = attr.ib() # type: str
- value = attr.ib() # type: str
+ value = attr.ib() # type: typing.Any
default = attr.ib(default=False) # type: bool
@@ -81,19 +79,21 @@ def _error_text(because: str, text: str, backend: usertypes.Backend) -> str:
if other_backend == usertypes.Backend.QtWebKit:
warning = ("<i>Note that QtWebKit hasn't been updated since "
"July 2017 (including security updates).</i>")
+ suffix = " (not recommended)"
else:
warning = ""
+ suffix = ""
return ("<b>Failed to start with the {backend} backend!</b>"
"<p>qutebrowser tried to start with the {backend} backend but "
"failed because {because}.</p>{text}"
- "<p><b>Forcing the {other_backend.name} backend</b></p>"
+ "<p><b>Forcing the {other_backend.name} backend{suffix}</b></p>"
"<p>This forces usage of the {other_backend.name} backend by "
"setting the <i>backend = '{other_setting}'</i> option "
"(if you have a <i>config.py</i> file, you'll need to set "
"this manually). {warning}</p>".format(
backend=backend.name, because=because, text=text,
other_backend=other_backend, other_setting=other_setting,
- warning=warning))
+ warning=warning, suffix=suffix))
class _Dialog(QDialog):
@@ -167,6 +167,14 @@ class _BackendProblemChecker:
"""Check for various backend-specific issues."""
+ SOFTWARE_RENDERING_TEXT = (
+ "<p><b>Forcing software rendering</b></p>"
+ "<p>This allows you to use the newer QtWebEngine backend (based on "
+ "Chromium) but could have noticeable performance impact (depending on "
+ "your hardware). This sets the <i>qt.force_software_rendering = "
+ "'chromium'</i> option (if you have a <i>config.py</i> file, you'll "
+ "need to set this manually).</p>")
+
def __init__(self, *,
no_err_windows: bool,
save_manager: savemanager.SaveManager) -> None:
@@ -201,19 +209,10 @@ class _BackendProblemChecker:
def _nvidia_shader_workaround(self) -> None:
"""Work around QOpenGLShaderProgram issues.
- NOTE: This needs to be called before _handle_nouveau_graphics, or some
- setups will segfault in version.opengl_vendor().
-
See https://bugs.launchpad.net/ubuntu/+source/python-qt4/+bug/941826
"""
self._assert_backend(usertypes.Backend.QtWebEngine)
-
- if os.environ.get('QUTE_SKIP_LIBGL_WORKAROUND'):
- return
-
- libgl = ctypes.util.find_library("GL")
- if libgl is not None:
- ctypes.CDLL(libgl, mode=ctypes.RTLD_GLOBAL)
+ utils.libgl_workaround()
def _handle_nouveau_graphics(self) -> None:
"""Force software rendering when using the Nouveau driver.
@@ -231,7 +230,8 @@ class _BackendProblemChecker:
if qtutils.version_check('5.10', compiled=False):
return
- if version.opengl_vendor() != 'nouveau':
+ opengl_info = version.opengl_info()
+ if opengl_info is None or opengl_info.vendor != 'nouveau':
return
if (os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1' or
@@ -248,36 +248,15 @@ class _BackendProblemChecker:
self._show_dialog(
backend=usertypes.Backend.QtWebEngine,
because="you're using Nouveau graphics",
- text=("<p>There are two ways to fix this:</p>"
- "<p><b>Forcing software rendering</b></p>"
- "<p>This allows you to use the newer QtWebEngine backend "
- "(based on Chromium) but could have noticeable performance "
- "impact (depending on your hardware). This sets the "
- "<i>qt.force_software_rendering = 'chromium'</i> option "
- "(if you have a <i>config.py</i> file, you'll need to set "
- "this manually).</p>"),
+ text=("<p>There are two ways to fix this:</p>" +
+ self.SOFTWARE_RENDERING_TEXT),
buttons=[button],
)
raise utils.Unreachable
- def _handle_wayland(self) -> None:
- self._assert_backend(usertypes.Backend.QtWebEngine)
-
- if os.environ.get('QUTE_SKIP_WAYLAND_CHECK'):
- return
-
- platform = QApplication.instance().platformName()
- if platform not in ['wayland', 'wayland-egl']:
- return
-
- has_qt511 = qtutils.version_check('5.11', compiled=False)
- if has_qt511 and config.val.qt.force_software_rendering == 'chromium':
- return
-
- if qtutils.version_check('5.11.2', compiled=False):
- return
-
+ def _xwayland_options(self) -> typing.Tuple[str, typing.List[_Button]]:
+ """Get buttons/text for a possible XWayland solution."""
buttons = []
text = "<p>You can work around this in one of the following ways:</p>"
@@ -296,23 +275,86 @@ class _BackendProblemChecker:
"<p>This allows you to use the newer QtWebEngine backend "
"(based on Chromium). ")
+ return text, buttons
+
+ def _handle_wayland(self) -> None:
+ self._assert_backend(usertypes.Backend.QtWebEngine)
+
+ if os.environ.get('QUTE_SKIP_WAYLAND_CHECK'):
+ return
+
+ platform = QApplication.instance().platformName()
+ if platform not in ['wayland', 'wayland-egl']:
+ return
+
+ has_qt511 = qtutils.version_check('5.11', compiled=False)
+ if has_qt511 and config.val.qt.force_software_rendering == 'chromium':
+ return
+
+ if qtutils.version_check('5.11.2', compiled=False):
+ return
+
+ text, buttons = self._xwayland_options()
+
if has_qt511:
buttons.append(_Button("Force software rendering",
'qt.force_software_rendering',
'chromium'))
- text += ("<p><b>Forcing software rendering</b></p>"
- "<p>This allows you to use the newer QtWebEngine backend "
- "(based on Chromium) but could have noticeable "
- "performance impact (depending on your hardware). This "
- "sets the <i>qt.force_software_rendering = "
- "'chromium'</i> option (if you have a <i>config.py</i> "
- "file, you'll need to set this manually).</p>")
+ text += self.SOFTWARE_RENDERING_TEXT
self._show_dialog(backend=usertypes.Backend.QtWebEngine,
because="you're using Wayland",
text=text,
buttons=buttons)
+ def _handle_wayland_webgl(self) -> None:
+ """On older graphic hardware, WebGL on Wayland causes segfaults.
+
+ See https://github.com/qutebrowser/qutebrowser/issues/5313
+ """
+ self._assert_backend(usertypes.Backend.QtWebEngine)
+
+ if os.environ.get('QUTE_SKIP_WAYLAND_WEBGL_CHECK'):
+ return
+
+ platform = QApplication.instance().platformName()
+ if platform not in ['wayland', 'wayland-egl']:
+ return
+
+ # Only Qt 5.14 should be affected
+ if not qtutils.version_check('5.14', compiled=False):
+ return
+ if qtutils.version_check('5.15', compiled=False):
+ return
+
+ # Newer graphic hardware isn't affected
+ opengl_info = version.opengl_info()
+ if (opengl_info is None or
+ opengl_info.gles or
+ opengl_info.version is None or
+ opengl_info.version >= (4, 3)):
+ return
+
+ # If WebGL is turned off, we're fine
+ if not config.val.content.webgl:
+ return
+
+ text, buttons = self._xwayland_options()
+
+ buttons.append(_Button("Turn off WebGL (recommended)",
+ 'content.webgl',
+ False))
+ text += ("<p><b>Disable WebGL (recommended)</b></p>"
+ "This sets the <i>content.webgl = False</i> option "
+ "(if you have a <i>config.py</i> file, you'll need to "
+ "set this manually).</p>")
+
+ self._show_dialog(backend=usertypes.Backend.QtWebEngine,
+ because=("of frequent crashes with Qt 5.14 on "
+ "Wayland with older graphics hardware"),
+ text=text,
+ buttons=buttons)
+
def _try_import_backends(self) -> _BackendImports:
"""Check whether backends can be imported and return BackendImports."""
# pylint: disable=unused-import
@@ -320,8 +362,9 @@ class _BackendProblemChecker:
try:
from PyQt5 import QtWebKit
+ from PyQt5.QtWebKit import qWebKitVersion
from PyQt5 import QtWebKitWidgets
- except ImportError as e:
+ except (ImportError, ValueError) as e:
results.webkit_available = False
results.webkit_error = str(e)
else:
@@ -333,7 +376,7 @@ class _BackendProblemChecker:
try:
from PyQt5 import QtWebEngineWidgets
- except ImportError as e:
+ except (ImportError, ValueError) as e:
results.webengine_available = False
results.webengine_error = str(e)
else:
@@ -353,14 +396,23 @@ class _BackendProblemChecker:
If "fatal" is given, show an error and exit.
"""
- text = ("Could not initialize QtNetwork SSL support. If you use "
- "OpenSSL 1.1 with a PyQt package from PyPI (e.g. on Archlinux "
- "or Debian Stretch), you need to set LD_LIBRARY_PATH to the "
- "path of OpenSSL 1.0. This only affects downloads.")
-
if QSslSocket.supportsSsl():
return
+ if qtutils.version_check('5.12.4'):
+ version_text = ("If you use OpenSSL 1.0 with a PyQt package from "
+ "PyPI (e.g. on Ubuntu 16.04), you will need to "
+ "build OpenSSL 1.1 from sources and set "
+ "LD_LIBRARY_PATH accordingly.")
+ else:
+ version_text = ("If you use OpenSSL 1.1 with a PyQt package from "
+ "PyPI (e.g. on Archlinux or Debian Stretch), you "
+ "need to set LD_LIBRARY_PATH to the path of "
+ "OpenSSL 1.0 or use Qt >= 5.12.4.")
+
+ text = ("Could not initialize QtNetwork SSL support. {} This only "
+ "affects downloads and :adblock-update.".format(version_text))
+
if fatal:
errbox = msgbox.msgbox(parent=None,
title="SSL error",
@@ -485,6 +537,7 @@ class _BackendProblemChecker:
self._handle_ssl_support()
self._handle_wayland()
self._nvidia_shader_workaround()
+ self._handle_wayland_webgl()
self._handle_nouveau_graphics()
self._handle_cache_nuking()
self._handle_serviceworker_nuking()
diff --git a/qutebrowser/misc/checkpyver.py b/qutebrowser/misc/checkpyver.py
index d3829a0bf..8283dd13e 100644
--- a/qutebrowser/misc/checkpyver.py
+++ b/qutebrowser/misc/checkpyver.py
@@ -30,12 +30,12 @@ try:
except ImportError: # pragma: no cover
try:
# Python2
- from Tkinter import Tk # type: ignore
+ from Tkinter import Tk # type: ignore[import, no-redef]
import tkMessageBox as messagebox # type: ignore # noqa: N813
except ImportError:
# Some Python without Tk
- Tk = None # type: ignore
- messagebox = None # type: ignore
+ Tk = None # type: ignore[misc, assignment]
+ messagebox = None # type: ignore[assignment]
# First we check the version of Python. This code should run fine with python2
@@ -49,7 +49,7 @@ def check_python_version():
version_str = '.'.join(map(str, sys.version_info[:3]))
text = ("At least Python 3.5.2 is required to run qutebrowser, but " +
"it's running with " + version_str + ".\n")
- if (Tk and # type: ignore
+ if (Tk and # type: ignore[unreachable]
'--no-err-windows' not in sys.argv): # pragma: no cover
root = Tk()
root.withdraw()
diff --git a/qutebrowser/misc/consolewidget.py b/qutebrowser/misc/consolewidget.py
index 7e46da3c8..aed42237a 100644
--- a/qutebrowser/misc/consolewidget.py
+++ b/qutebrowser/misc/consolewidget.py
@@ -27,7 +27,7 @@ from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt
from PyQt5.QtWidgets import QTextEdit, QWidget, QVBoxLayout, QApplication
from PyQt5.QtGui import QTextCursor
-from qutebrowser.config import config
+from qutebrowser.config import stylesheet
from qutebrowser.misc import cmdhistory, miscwidgets
from qutebrowser.utils import utils, objreg
@@ -55,8 +55,6 @@ class ConsoleLineEdit(miscwidgets.CommandLineEdit):
_namespace: The local namespace of the interpreter.
"""
super().__init__(parent=parent)
- self._update_font()
- config.instance.changed.connect(self._update_font)
self._history = cmdhistory.History(parent=self)
self.returnPressed.connect(self.on_return_pressed)
@@ -106,11 +104,6 @@ class ConsoleLineEdit(miscwidgets.CommandLineEdit):
else:
super().keyPressEvent(e)
- @config.change_filter('fonts.debug_console')
- def _update_font(self):
- """Set the correct font."""
- self.setFont(config.val.fonts.debug_console)
-
class ConsoleTextEdit(QTextEdit):
@@ -120,18 +113,11 @@ class ConsoleTextEdit(QTextEdit):
super().__init__(parent)
self.setAcceptRichText(False)
self.setReadOnly(True)
- config.instance.changed.connect(self._update_font)
- self._update_font()
self.setFocusPolicy(Qt.ClickFocus)
def __repr__(self):
return utils.get_repr(self)
- @config.change_filter('fonts.debug_console')
- def _update_font(self):
- """Update font when config changed."""
- self.setFont(config.val.fonts.debug_console)
-
def append_text(self, text):
"""Append new text and scroll output to bottom.
@@ -157,6 +143,12 @@ class ConsoleWidget(QWidget):
_interpreter: The InteractiveInterpreter to execute code with.
"""
+ STYLESHEET = """
+ ConsoleWidget > ConsoleTextEdit, ConsoleWidget > ConsoleLineEdit {
+ font: {{ conf.fonts.debug_console }};
+ }
+ """
+
def __init__(self, parent=None):
super().__init__(parent)
if not hasattr(sys, 'ps1'):
@@ -182,6 +174,7 @@ class ConsoleWidget(QWidget):
self._vbox.setSpacing(0)
self._vbox.addWidget(self._output)
self._vbox.addWidget(self._lineedit)
+ stylesheet.set_register(self)
self.setLayout(self._vbox)
self._lineedit.setFocus()
self._interpreter = code.InteractiveInterpreter(namespace)
diff --git a/qutebrowser/misc/crashdialog.py b/qutebrowser/misc/crashdialog.py
index f80004a92..4387e479a 100644
--- a/qutebrowser/misc/crashdialog.py
+++ b/qutebrowser/misc/crashdialog.py
@@ -125,9 +125,13 @@ class _CrashDialog(QDialog):
self.setWindowTitle("Whoops!")
self.resize(QSize(640, 600))
self._vbox = QVBoxLayout(self)
+
http_client = httpclient.HTTPClient()
self._paste_client = pastebin.PastebinClient(http_client, self)
self._pypi_client = autoupdate.PyPIVersionClient(self)
+ self._paste_client.success.connect(self.on_paste_success)
+ self._paste_client.error.connect(self.show_error)
+
self._init_text()
self._init_contact_input()
@@ -246,7 +250,7 @@ class _CrashDialog(QDialog):
except Exception:
self._crash_info.append(("Launch time", traceback.format_exc()))
try:
- self._crash_info.append(("Version info", version.version()))
+ self._crash_info.append(("Version info", version.version_info()))
except Exception:
self._crash_info.append(("Version info", traceback.format_exc()))
try:
@@ -296,13 +300,17 @@ class _CrashDialog(QDialog):
except Exception:
log.misc.exception("Failed to save contact information!")
- def report(self):
- """Paste the crash info into the pastebin."""
+ def report(self, *, info=None, contact=None):
+ """Paste the crash info into the pastebin.
+
+ If info/contact are given as arguments, they override the values
+ entered in the dialog.
+ """
lines = []
lines.append("========== Report ==========")
- lines.append(self._info.toPlainText())
+ lines.append(info or self._info.toPlainText())
lines.append("========== Contact ==========")
- lines.append(self._contact.toPlainText())
+ lines.append(contact or self._contact.toPlainText())
lines.append("========== Debug log ==========")
lines.append(self._debug_log.toPlainText())
self._paste_text = '\n\n'.join(lines)
@@ -326,8 +334,6 @@ class _CrashDialog(QDialog):
self._btn_report.setEnabled(False)
self._btn_cancel.setEnabled(False)
self._btn_report.setText("Reporting...")
- self._paste_client.success.connect(self.on_paste_success)
- self._paste_client.error.connect(self.show_error)
self.report()
@pyqtSlot()
@@ -345,7 +351,7 @@ class _CrashDialog(QDialog):
text: The paste text to show.
"""
error_dlg = ReportErrorDialog(text, self._paste_text, self)
- error_dlg.finished.connect(self.finish) # type: ignore
+ error_dlg.finished.connect(self.finish)
error_dlg.show()
@pyqtSlot(str)
@@ -532,7 +538,7 @@ class FatalCrashDialog(_CrashDialog):
if self._chk_history.isChecked():
try:
if history.web_history is None:
- history_data = '<unavailable>' # type: ignore
+ history_data = '<unavailable>' # type: ignore[unreachable]
else:
history_data = '\n'.join(str(e) for e in
history.web_history.get_recent())
@@ -629,7 +635,7 @@ class ReportErrorDialog(QDialog):
hbox = QHBoxLayout()
hbox.addStretch()
btn = QPushButton("Close")
- btn.clicked.connect(self.close) # type: ignore
+ btn.clicked.connect(self.close) # type: ignore[arg-type]
hbox.addWidget(btn)
vbox.addLayout(hbox)
@@ -650,7 +656,7 @@ def dump_exception_info(exc, pages, cmdhist, qobjects):
print(''.join(traceback.format_exception(*exc)), file=sys.stderr)
print("\n---- Version info ----", file=sys.stderr)
try:
- print(version.version(), file=sys.stderr)
+ print(version.version_info(), file=sys.stderr)
except Exception:
traceback.print_exc()
print("\n---- Config ----", file=sys.stderr)
diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py
index 20e805c24..3f80db769 100644
--- a/qutebrowser/misc/crashsignal.py
+++ b/qutebrowser/misc/crashsignal.py
@@ -27,6 +27,7 @@ import pdb # noqa: T002
import signal
import argparse
import functools
+import threading
import faulthandler
import typing
try:
@@ -43,6 +44,7 @@ from PyQt5.QtWidgets import QApplication
from qutebrowser.api import cmdutils
from qutebrowser.misc import earlyinit, crashdialog, ipc, objects
from qutebrowser.utils import usertypes, standarddir, log, objreg, debug, utils
+from qutebrowser.qt import sip
if typing.TYPE_CHECKING:
from qutebrowser.misc import quitter
@@ -160,14 +162,25 @@ class CrashHandler(QObject):
earlyinit.init_faulthandler(self._crash_log_file)
@cmdutils.register(instance='crash-handler')
- def report(self):
- """Report a bug in qutebrowser."""
+ def report(self, info=None, contact=None):
+ """Report a bug in qutebrowser.
+
+ Args:
+ info: Information about the bug report. If given, no report dialog
+ shows up.
+ contact: Contact information for the report.
+ """
pages = self._recover_pages()
cmd_history = objreg.get('command-history')[-5:]
all_objects = debug.get_all_objects()
+
self._crash_dialog = crashdialog.ReportDialog(pages, cmd_history,
all_objects)
- self._crash_dialog.show()
+
+ if info is None:
+ self._crash_dialog.show()
+ else:
+ self._crash_dialog.report(info=info, contact=contact)
@pyqtSlot()
def shutdown(self):
@@ -183,7 +196,7 @@ class CrashHandler(QObject):
if sys.__stderr__ is not None:
faulthandler.enable(sys.__stderr__)
else:
- faulthandler.disable() # type: ignore
+ faulthandler.disable() # type: ignore[unreachable]
try:
self._crash_log_file.close()
os.remove(self._crash_log_file.name)
@@ -215,18 +228,19 @@ class CrashHandler(QObject):
all_objects = ""
return ExceptionInfo(pages, cmd_history, all_objects)
- def exception_hook(self, exctype, excvalue, tb):
- """Handle uncaught python exceptions.
+ def _handle_early_exits(self, exc):
+ """Handle some special cases for the exception hook.
- It'll try very hard to write all open tabs to a file, and then exit
- gracefully.
+ Return value:
+ True: Exception hook should be aborted.
+ False: Continue handling exception.
"""
- exc = (exctype, excvalue, tb)
+ exctype, _excvalue, tb = exc
if not self._quitter.quit_status['crash']:
log.misc.error("ARGH, there was an exception while the crash "
"dialog is already shown:", exc_info=exc)
- return
+ return True
log.misc.error("Uncaught exception", exc_info=exc)
@@ -243,6 +257,24 @@ class CrashHandler(QObject):
# pdb exit, KeyboardInterrupt, ...
sys.exit(usertypes.Exit.exception)
+ if threading.current_thread() != threading.main_thread():
+ log.misc.error("Ignoring exception outside of main thread... "
+ "Please report this as a bug.")
+ return True
+
+ return False
+
+ def exception_hook(self, exctype, excvalue, tb):
+ """Handle uncaught python exceptions.
+
+ It'll try very hard to write all open tabs to a file, and then exit
+ gracefully.
+ """
+ exc = (exctype, excvalue, tb)
+
+ if self._handle_early_exits(exc):
+ return
+
self._quitter.quit_status['crash'] = False
info = self._get_exception_info()
@@ -331,9 +363,10 @@ class SignalHandler(QObject):
for fd in [read_fd, write_fd]:
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
- self._notifier = QSocketNotifier(read_fd, QSocketNotifier.Read,
+ self._notifier = QSocketNotifier(typing.cast(sip.voidptr, read_fd),
+ QSocketNotifier.Read,
self)
- self._notifier.activated.connect( # type: ignore
+ self._notifier.activated.connect( # type: ignore[attr-defined]
self.handle_signal_wakeup)
self._orig_wakeup_fd = signal.set_wakeup_fd(write_fd)
# pylint: enable=import-error,no-member,useless-suppression
diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py
index 99ddd3701..c02b2f03c 100644
--- a/qutebrowser/misc/earlyinit.py
+++ b/qutebrowser/misc/earlyinit.py
@@ -38,7 +38,7 @@ import datetime
try:
import tkinter
except ImportError:
- tkinter = None # type: ignore
+ tkinter = None # type: ignore[assignment]
# NOTE: No qutebrowser or PyQt import should be done here, as some early
# initialization needs to take place before that!
@@ -251,7 +251,8 @@ def configure_pyqt():
from PyQt5 import QtCore
QtCore.pyqtRemoveInputHook()
try:
- QtCore.pyqt5_enable_new_onexit_scheme(True) # type: ignore
+ QtCore.pyqt5_enable_new_onexit_scheme( # type: ignore[attr-defined]
+ True)
except AttributeError:
# Added in PyQt 5.13 somewhere, going to be the default in 5.14
pass
@@ -259,7 +260,7 @@ def configure_pyqt():
from qutebrowser.qt import sip
try:
# Added in sip 4.19.4
- sip.enableoverflowchecking(True) # type: ignore
+ sip.enableoverflowchecking(True) # type: ignore[attr-defined]
except AttributeError:
pass
diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py
index 1dcc3a532..5ef451e4e 100644
--- a/qutebrowser/misc/editor.py
+++ b/qutebrowser/misc/editor.py
@@ -187,7 +187,7 @@ class ExternalEditor(QObject):
if not ok:
log.procs.error("Failed to watch path: {}"
.format(self._filename))
- self._watcher.fileChanged.connect( # type: ignore
+ self._watcher.fileChanged.connect( # type: ignore[attr-defined]
self._on_file_changed)
args = [self._sub_placeholder(arg, line, column) for arg in editor[1:]]
diff --git a/qutebrowser/misc/guiprocess.py b/qutebrowser/misc/guiprocess.py
index 7fc598191..3702715c4 100644
--- a/qutebrowser/misc/guiprocess.py
+++ b/qutebrowser/misc/guiprocess.py
@@ -62,12 +62,12 @@ class GUIProcess(QObject):
self.args = None
self._proc = QProcess(self)
- self._proc.errorOccurred.connect(self._on_error) # type: ignore
- self._proc.errorOccurred.connect(self.error) # type: ignore
- self._proc.finished.connect(self._on_finished) # type: ignore
- self._proc.finished.connect(self.finished) # type: ignore
- self._proc.started.connect(self._on_started) # type: ignore
- self._proc.started.connect(self.started) # type: ignore
+ self._proc.errorOccurred.connect(self._on_error)
+ self._proc.errorOccurred.connect(self.error) # type: ignore[arg-type]
+ self._proc.finished.connect(self._on_finished)
+ self._proc.finished.connect(self.finished) # type: ignore[arg-type]
+ self._proc.started.connect(self._on_started)
+ self._proc.started.connect(self.started) # type: ignore[arg-type]
if additional_env is not None:
procenv = QProcessEnvironment.systemEnvironment()
@@ -89,9 +89,9 @@ class GUIProcess(QObject):
code, status))
encoding = locale.getpreferredencoding(do_setlocale=False)
- stderr = bytes(self._proc.readAllStandardError()).decode(
+ stderr = self._proc.readAllStandardError().data().decode(
encoding, 'replace')
- stdout = bytes(self._proc.readAllStandardOutput()).decode(
+ stdout = self._proc.readAllStandardOutput().data().decode(
encoding, 'replace')
if self._output_messages:
@@ -163,7 +163,8 @@ class GUIProcess(QObject):
"""Convenience wrapper around QProcess::startDetached."""
log.procs.debug("Starting detached.")
self._pre_start(cmd, args)
- ok, _pid = self._proc.startDetached(cmd, args, None) # type: ignore
+ ok, _pid = self._proc.startDetached(
+ cmd, args, None) # type: ignore[call-arg]
if not ok:
message.error("Error while spawning {}".format(self._what))
diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py
index ad1d3eb56..c4cd4f792 100644
--- a/qutebrowser/misc/ipc.py
+++ b/qutebrowser/misc/ipc.py
@@ -36,7 +36,7 @@ from qutebrowser.utils import log, usertypes, error, standarddir, utils
CONNECT_TIMEOUT = 100 # timeout for connecting/disconnecting
WRITE_TIMEOUT = 1000
READ_TIMEOUT = 5000
-ATIME_INTERVAL = 60 * 60 * 3 * 1000 # 3 hours
+ATIME_INTERVAL = 5000 * 60 # 5 minutes
PROTOCOL_VERSION = 1
@@ -178,7 +178,7 @@ class IPCServer(QObject):
self._atime_timer.setTimerType(Qt.VeryCoarseTimer)
self._server = QLocalServer(self)
- self._server.newConnection.connect( # type: ignore
+ self._server.newConnection.connect( # type: ignore[attr-defined]
self.handle_connection)
self._socket = None
@@ -252,21 +252,24 @@ class IPCServer(QObject):
return
socket = self._server.nextPendingConnection()
if socket is None:
- log.ipc.debug("No new connection to handle.") # type: ignore
+ log.ipc.debug( # type: ignore[unreachable]
+ "No new connection to handle.")
return
log.ipc.debug("Client connected (socket 0x{:x}).".format(id(socket)))
self._timer.start()
self._socket = socket
- socket.readyRead.connect(self.on_ready_read) # type: ignore
+ socket.readyRead.connect( # type: ignore[attr-defined]
+ self.on_ready_read)
if socket.canReadLine():
log.ipc.debug("We can read a line immediately.")
self.on_ready_read()
- socket.error.connect(self.on_error) # type: ignore
+ socket.error.connect(self.on_error) # type: ignore[attr-defined]
if socket.error() not in [QLocalSocket.UnknownSocketError,
QLocalSocket.PeerClosedError]:
log.ipc.debug("We got an error immediately.")
self.on_error(socket.error())
- socket.disconnected.connect(self.on_disconnected) # type: ignore
+ socket.disconnected.connect( # type: ignore[attr-defined]
+ self.on_disconnected)
if socket.state() == QLocalSocket.UnconnectedState:
log.ipc.debug("Socket was disconnected immediately.")
self.on_disconnected()
@@ -394,8 +397,16 @@ class IPCServer(QObject):
if not path:
log.ipc.error("In update_atime with no server path!")
return
+
log.ipc.debug("Touching {}".format(path))
- os.utime(path)
+
+ try:
+ os.utime(path)
+ except OSError:
+ log.ipc.exception("Failed to update IPC socket, trying to "
+ "re-listen...")
+ self._server.close()
+ self.listen()
@pyqtSlot()
def shutdown(self):
diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py
index 89dac83f2..11bb14d66 100644
--- a/qutebrowser/misc/keyhintwidget.py
+++ b/qutebrowser/misc/keyhintwidget.py
@@ -82,8 +82,8 @@ class KeyHintView(QLabel):
self.update_geometry.emit()
super().showEvent(e)
- @pyqtSlot(str)
- def update_keyhint(self, modename, prefix):
+ @pyqtSlot(usertypes.KeyMode, str)
+ def update_keyhint(self, mode, prefix):
"""Show hints for the given prefix (or hide if prefix is empty).
Args:
@@ -108,7 +108,7 @@ class KeyHintView(QLabel):
cmd = objects.commands.get(cmdname)
return cmd and cmd.takes_count()
- bindings_dict = config.key_instance.get_bindings_for(modename)
+ bindings_dict = config.key_instance.get_bindings_for(mode.name)
bindings = [(k, v) for (k, v) in sorted(bindings_dict.items())
if keyutils.KeySequence.parse(prefix).matches(k) and
not blacklisted(str(k)) and
diff --git a/qutebrowser/misc/miscwidgets.py b/qutebrowser/misc/miscwidgets.py
index 69bce56f5..2d72af780 100644
--- a/qutebrowser/misc/miscwidgets.py
+++ b/qutebrowser/misc/miscwidgets.py
@@ -29,6 +29,7 @@ from PyQt5.QtGui import QValidator, QPainter
from qutebrowser.config import config
from qutebrowser.utils import utils
from qutebrowser.misc import cmdhistory
+from qutebrowser.keyinput import keyutils
class MinimalLineEditMixin:
@@ -36,7 +37,7 @@ class MinimalLineEditMixin:
"""A mixin to give a QLineEdit a minimal look and nicer repr()."""
def __init__(self):
- self.setStyleSheet( # type: ignore
+ self.setStyleSheet( # type: ignore[attr-defined]
"""
QLineEdit {
border: 0px;
@@ -45,7 +46,8 @@ class MinimalLineEditMixin:
}
"""
)
- self.setAttribute(Qt.WA_MacShowFocusRect, False) # type: ignore
+ self.setAttribute( # type: ignore[attr-defined]
+ Qt.WA_MacShowFocusRect, False)
def keyPressEvent(self, e):
"""Override keyPressEvent to paste primary selection on Shift + Ins."""
@@ -56,9 +58,9 @@ class MinimalLineEditMixin:
e.ignore()
else:
e.accept()
- self.insert(text) # type: ignore
+ self.insert(text) # type: ignore[attr-defined]
return
- super().keyPressEvent(e) # type: ignore
+ super().keyPressEvent(e) # type: ignore[misc]
def __repr__(self):
return utils.get_repr(self)
@@ -259,7 +261,7 @@ class WrapperLayout(QLayout):
widget.setParent(container)
def unwrap(self):
- self._widget.setParent(None) # type: ignore
+ self._widget.setParent(None) # type: ignore[call-overload]
self._widget.deleteLater()
@@ -326,7 +328,7 @@ class FullscreenNotification(QLabel):
self.setText("Page is now fullscreen.")
self.resize(self.sizeHint())
- if config.val.content.windowed_fullscreen:
+ if config.val.content.fullscreen.window:
geom = self.parentWidget().geometry()
else:
geom = QApplication.desktop().screenGeometry(self)
@@ -341,3 +343,26 @@ class FullscreenNotification(QLabel):
"""Hide and delete the widget."""
self.hide()
self.deleteLater()
+
+
+class KeyTesterWidget(QWidget):
+
+ """Widget displaying key presses."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setAttribute(Qt.WA_DeleteOnClose)
+ self._layout = QHBoxLayout(self)
+ self._label = QLabel(text="Waiting for keypress...")
+ self._layout.addWidget(self._label)
+
+ def keyPressEvent(self, e):
+ """Show pressed keys."""
+ lines = [
+ str(keyutils.KeyInfo.from_event(e)),
+ '',
+ 'key: 0x{:x}'.format(int(e.key())),
+ 'modifiers: 0x{:x}'.format(int(e.modifiers())),
+ 'text: {!r}'.format(e.text()),
+ ]
+ self._label.setText('\n'.join(lines))
diff --git a/qutebrowser/misc/msgbox.py b/qutebrowser/misc/msgbox.py
index 89ade19e6..f06eccd92 100644
--- a/qutebrowser/misc/msgbox.py
+++ b/qutebrowser/misc/msgbox.py
@@ -60,7 +60,7 @@ def msgbox(parent, title, text, *, icon, buttons=QMessageBox.Ok,
box.setIcon(icon)
box.setStandardButtons(buttons)
if on_finished is not None:
- box.finished.connect(on_finished) # type: ignore
+ box.finished.connect(on_finished)
if plain_text:
box.setTextFormat(Qt.PlainText)
elif plain_text is not None:
diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py
index 1bcd42baf..52a66b1a0 100644
--- a/qutebrowser/misc/sessions.py
+++ b/qutebrowser/misc/sessions.py
@@ -24,6 +24,8 @@ import os.path
import itertools
import urllib
import typing
+import glob
+import shutil
from PyQt5.QtCore import QUrl, QObject, QPoint, QTimer, pyqtSlot
from PyQt5.QtWidgets import QApplication
@@ -59,6 +61,16 @@ def init(parent=None):
parent: The parent to use for the SessionManager.
"""
base_path = os.path.join(standarddir.data(), 'sessions')
+
+ # WORKAROUND for https://github.com/qutebrowser/qutebrowser/issues/5359
+ backup_path = os.path.join(base_path, 'before-qt-515')
+ if (os.path.exists(base_path) and
+ not os.path.exists(backup_path) and
+ qtutils.version_check('5.15', compiled=False)):
+ os.mkdir(backup_path)
+ for filename in glob.glob(os.path.join(base_path, '*.yml')):
+ shutil.copy(filename, backup_path)
+
try:
os.mkdir(base_path)
except FileExistsError:
@@ -311,10 +323,11 @@ class SessionManager(QObject):
else:
data = self._save_all(only_window=only_window,
with_private=with_private)
- log.sessions.vdebug("Saving data: {}".format(data)) # type: ignore
+ log.sessions.vdebug( # type: ignore[attr-defined]
+ "Saving data: {}".format(data))
try:
with qtutils.savefile_open(path) as f:
- utils.yaml_dump(data, f) # type: ignore
+ utils.yaml_dump(data, f)
except (OSError, UnicodeEncodeError, yaml.YAMLError) as e:
raise SessionError(e)
@@ -574,7 +587,7 @@ def session_save(name: ArgType = default, *,
@cmdutils.register()
@cmdutils.argument('name', completion=miscmodels.session)
-def session_delete(name, *, force: bool = False) -> None:
+def session_delete(name: str, *, force: bool = False) -> None:
"""Delete a session.
Args:
diff --git a/qutebrowser/misc/split.py b/qutebrowser/misc/split.py
index dc46a28d7..b08d34cae 100644
--- a/qutebrowser/misc/split.py
+++ b/qutebrowser/misc/split.py
@@ -138,7 +138,8 @@ def split(s, keep=False):
out = []
spaces = ""
- log.shlexer.vdebug("{!r} -> {!r}".format(s, tokens)) # type: ignore
+ log.shlexer.vdebug( # type: ignore[attr-defined]
+ "{!r} -> {!r}".format(s, tokens))
for t in tokens:
if t.isspace():
diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py
index 39208819d..8f9265419 100644
--- a/qutebrowser/misc/sql.py
+++ b/qutebrowser/misc/sql.py
@@ -190,7 +190,8 @@ class Query:
raise BugError("Cannot iterate inactive query")
rec = self.query.record()
fields = [rec.fieldName(i) for i in range(rec.count())]
- rowtype = collections.namedtuple('ResultRow', fields) # type: ignore
+ rowtype = collections.namedtuple( # type: ignore[misc]
+ 'ResultRow', fields)
while self.query.next():
rec = self.query.record()
diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py
index 328e77531..05fea9501 100644
--- a/qutebrowser/misc/utilcmds.py
+++ b/qutebrowser/misc/utilcmds.py
@@ -24,6 +24,7 @@
import functools
import os
import traceback
+import typing
from PyQt5.QtCore import QUrl
from PyQt5.QtWidgets import QApplication
@@ -34,7 +35,7 @@ from qutebrowser.keyinput import modeman
from qutebrowser.commands import runners
from qutebrowser.api import cmdutils
from qutebrowser.misc import ( # pylint: disable=unused-import
- consolewidget, debugcachestats, objects)
+ consolewidget, debugcachestats, objects, miscwidgets)
from qutebrowser.utils.version import pastebin_version
from qutebrowser.qt import sip
@@ -224,24 +225,6 @@ def log_capacity(capacity: int) -> None:
@cmdutils.register(debug=True)
-@cmdutils.argument('level', choices=sorted(
- (level.lower() for level in log.LOG_LEVELS),
- key=lambda e: log.LOG_LEVELS[e.upper()]))
-def debug_log_level(level: str) -> None:
- """Change the log level for console logging.
-
- Args:
- level: The log level to set.
- """
- if log.console_handler is None:
- raise cmdutils.CommandError("No log.console_handler. Not attached "
- "to a console?")
-
- log.change_console_formatter(log.LOG_LEVELS[level.upper()])
- log.console_handler.setLevel(log.LOG_LEVELS[level.upper()])
-
-
-@cmdutils.register(debug=True)
def debug_log_filter(filters: str) -> None:
"""Change the log filter for console logging.
@@ -253,16 +236,12 @@ def debug_log_filter(filters: str) -> None:
raise cmdutils.CommandError("No log.console_filter. Not attached "
"to a console?")
- if filters.strip().lower() == 'none':
- log.console_filter.names = None
- return
-
- if not set(filters.split(',')).issubset(log.LOGGER_NAMES):
- raise cmdutils.CommandError("filters: Invalid value {} - expected one "
- "of: {}".format(
- filters, ', '.join(log.LOGGER_NAMES)))
+ try:
+ new_filter = log.LogFilter.parse(filters)
+ except log.InvalidLogFilterError as e:
+ raise cmdutils.CommandError(e)
- log.console_filter.names = filters.split(',')
+ log.console_filter.update_from(new_filter)
@cmdutils.register()
@@ -293,3 +272,17 @@ def version(win_id: int, paste: bool = False) -> None:
if paste:
pastebin_version()
+
+
+_keytester_widget = None # type: typing.Optional[miscwidgets.KeyTesterWidget]
+
+
+@cmdutils.register(debug=True)
+def debug_keytester() -> None:
+ """Show a keytester widget."""
+ global _keytester_widget
+ if _keytester_widget and _keytester_widget.isVisible():
+ _keytester_widget.close()
+ else:
+ _keytester_widget = miscwidgets.KeyTesterWidget()
+ _keytester_widget.show()
diff --git a/qutebrowser/qt.py b/qutebrowser/qt.py
index cc6197982..5b44530bb 100644
--- a/qutebrowser/qt.py
+++ b/qutebrowser/qt.py
@@ -25,4 +25,4 @@
try:
from PyQt5 import sip
except ImportError:
- import sip # type: ignore
+ import sip # type: ignore[import, no-redef]
diff --git a/qutebrowser/qutebrowser.py b/qutebrowser/qutebrowser.py
index 129e8f745..8765f5217 100644
--- a/qutebrowser/qutebrowser.py
+++ b/qutebrowser/qutebrowser.py
@@ -96,7 +96,7 @@ def get_argparser():
debug = parser.add_argument_group('debug arguments')
debug.add_argument('-l', '--loglevel', dest='loglevel',
- help="Set loglevel", default='info',
+ help="Override the configured console loglevel",
choices=['critical', 'error', 'warning', 'info',
'debug', 'vdebug'])
debug.add_argument('--logfilter', type=logfilter_error,
@@ -150,12 +150,11 @@ def logfilter_error(logfilter):
logfilter: A comma separated list of logger names.
"""
from qutebrowser.utils import log
- if set(logfilter.lstrip('!').split(',')).issubset(log.LOGGER_NAMES):
- return logfilter
- else:
- raise argparse.ArgumentTypeError(
- "filters: Invalid value {} - expected a list of: {}".format(
- logfilter, ', '.join(log.LOGGER_NAMES)))
+ try:
+ log.LogFilter.parse(logfilter)
+ except log.InvalidLogFilterError as e:
+ raise argparse.ArgumentTypeError(e)
+ return logfilter
def debug_flag_error(flag):
@@ -167,14 +166,16 @@ def debug_flag_error(flag):
no-sql-history: Don't store history items.
no-scroll-filtering: Process all scrolling updates.
log-requests: Log all network requests.
+ log-cookies: Log cookies in cookie filter.
log-scroll-pos: Log all scrolling changes.
stack: Enable Chromium stack logging.
chromium: Enable Chromium logging.
werror: Turn Python warnings into errors.
"""
valid_flags = ['debug-exit', 'pdb-postmortem', 'no-sql-history',
- 'no-scroll-filtering', 'log-requests', 'lost-focusproxy',
- 'log-scroll-pos', 'stack', 'chromium', 'werror']
+ 'no-scroll-filtering', 'log-requests', 'log-cookies',
+ 'lost-focusproxy', 'log-scroll-pos', 'stack', 'chromium',
+ 'werror']
if flag in valid_flags:
return flag
diff --git a/qutebrowser/utils/debug.py b/qutebrowser/utils/debug.py
index 395f41a1b..66cfeed9e 100644
--- a/qutebrowser/utils/debug.py
+++ b/qutebrowser/utils/debug.py
@@ -31,6 +31,7 @@ from PyQt5.QtCore import Qt, QEvent, QMetaMethod, QObject, pyqtSignal
from PyQt5.QtWidgets import QApplication
from qutebrowser.utils import log, utils, qtutils, objreg
+from qutebrowser.qt import sip
def log_events(klass: typing.Type) -> typing.Type:
@@ -69,7 +70,7 @@ def log_signals(obj: QObject) -> QObject:
meta_method = metaobj.method(i)
qtutils.ensure_valid(meta_method)
if meta_method.methodType() == QMetaMethod.Signal:
- name = bytes(meta_method.name()).decode('ascii')
+ name = meta_method.name().data().decode('ascii')
if name != 'destroyed':
signal = getattr(obj, name)
try:
@@ -79,7 +80,7 @@ def log_signals(obj: QObject) -> QObject:
pass
if inspect.isclass(obj):
- old_init = obj.__init__ # type: ignore
+ old_init = obj.__init__ # type: ignore[misc]
@functools.wraps(old_init)
def new_init(self: typing.Any,
@@ -89,7 +90,7 @@ def log_signals(obj: QObject) -> QObject:
old_init(self, *args, **kwargs)
connect_log_slot(self)
- obj.__init__ = new_init # type: ignore
+ obj.__init__ = new_init # type: ignore[misc]
else:
connect_log_slot(obj)
@@ -97,7 +98,7 @@ def log_signals(obj: QObject) -> QObject:
def qenum_key(base: typing.Type,
- value: int,
+ value: typing.Union[int, sip.simplewrapper],
add_base: bool = False,
klass: typing.Type = None) -> str:
"""Convert a Qt Enum value to its key as a string.
@@ -121,7 +122,7 @@ def qenum_key(base: typing.Type,
try:
idx = base.staticMetaObject.indexOfEnumerator(klass.__name__)
meta_enum = base.staticMetaObject.enumerator(idx)
- ret = meta_enum.valueToKey(int(value))
+ ret = meta_enum.valueToKey(int(value)) # type: ignore[arg-type]
except AttributeError:
ret = None
@@ -131,7 +132,7 @@ def qenum_key(base: typing.Type,
ret = name
break
else:
- ret = '0x{:04x}'.format(int(value))
+ ret = '0x{:04x}'.format(int(value)) # type: ignore[arg-type]
if add_base and hasattr(base, '__name__'):
return '.'.join([base.__name__, ret])
@@ -140,7 +141,7 @@ def qenum_key(base: typing.Type,
def qflags_key(base: typing.Type,
- value: int,
+ value: typing.Union[int, sip.simplewrapper],
add_base: bool = False,
klass: typing.Type = None) -> str:
"""Convert a Qt QFlags value to its keys as string.
@@ -174,7 +175,7 @@ def qflags_key(base: typing.Type,
bits = []
names = []
mask = 0x01
- value = int(value)
+ value = int(value) # type: ignore[arg-type]
while mask <= value:
if value & mask:
bits.append(mask)
@@ -182,7 +183,8 @@ def qflags_key(base: typing.Type,
for bit in bits:
# We have to re-convert to an enum type here or we'll sometimes get an
# empty string back.
- names.append(qenum_key(base, klass(bit), add_base))
+ enum_value = klass(bit) # type: ignore[call-arg]
+ names.append(qenum_key(base, enum_value, add_base))
return '|'.join(names)
@@ -208,14 +210,14 @@ def signal_name(sig: pyqtSignal) -> str:
# sig.signal == '2signal1'
# sig.signal == '2signal2(QString,QString)'
m = re.fullmatch(r'[0-9]+(?P<name>.*)\(.*\)',
- sig.signal) # type: ignore
+ sig.signal) # type: ignore[attr-defined]
elif hasattr(sig, 'signatures'):
# Unbound signal, PyQt >= 5.11
# Examples:
# sig.signatures == ('signal1()',)
# sig.signatures == ('signal2(QString,QString)',)
m = re.fullmatch(r'(?P<name>.*)\(.*\)',
- sig.signatures[0]) # type: ignore
+ sig.signatures[0]) # type: ignore[attr-defined]
else: # pragma: no cover
# Unbound signal, PyQt < 5.11
# Examples:
diff --git a/qutebrowser/utils/error.py b/qutebrowser/utils/error.py
index cfc3c3f5a..4cba06a10 100644
--- a/qutebrowser/utils/error.py
+++ b/qutebrowser/utils/error.py
@@ -63,6 +63,7 @@ def handle_fatal_exc(exc: BaseException,
]
log.misc.exception('\n'.join(lines))
else:
+ log.misc.exception("Fatal exception:")
if pre_text:
msg_text = '{}: {}'.format(pre_text, exc)
else:
diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py
index a7bb1f686..78663645d 100644
--- a/qutebrowser/utils/jinja.py
+++ b/qutebrowser/utils/jinja.py
@@ -112,7 +112,7 @@ class Environment(jinja2.Environment):
"""
image = utils.resource_filename(path)
url = QUrl.fromLocalFile(image)
- urlstr = url.toString(QUrl.FullyEncoded) # type: ignore
+ urlstr = url.toString(QUrl.FullyEncoded) # type: ignore[arg-type]
return urlstr
def _data_url(self, path: str) -> str:
@@ -156,11 +156,11 @@ def template_config_variables(template: str) -> typing.FrozenSet[str]:
# For example it's ['ab', 'c', 'd'] for 'conf.d.c.ab'.
attrlist = [] # type: typing.List[str]
while isinstance(node, jinja2.nodes.Getattr):
- attrlist.append(node.attr) # type: ignore
- node = node.node # type: ignore
+ attrlist.append(node.attr) # type: ignore[attr-defined]
+ node = node.node # type: ignore[attr-defined]
if isinstance(node, jinja2.nodes.Name):
- if node.name == 'conf': # type: ignore
+ if node.name == 'conf': # type: ignore[attr-defined]
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 922981511..197f594f9 100644
--- a/qutebrowser/utils/log.py
+++ b/qutebrowser/utils/log.py
@@ -41,6 +41,9 @@ try:
except ImportError:
colorama = None
+if typing.TYPE_CHECKING:
+ from qutebrowser.config import config as configmodule
+
_log_inited = False
_args = None
@@ -81,10 +84,10 @@ LOG_COLORS = {
# mypy doesn't know about this, so we need to ignore it.
VDEBUG_LEVEL = 9
logging.addLevelName(VDEBUG_LEVEL, 'VDEBUG')
-logging.VDEBUG = VDEBUG_LEVEL # type: ignore
+logging.VDEBUG = VDEBUG_LEVEL # type: ignore[attr-defined]
LOG_LEVELS = {
- 'VDEBUG': logging.VDEBUG, # type: ignore
+ 'VDEBUG': logging.VDEBUG, # type: ignore[attr-defined]
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARNING': logging.WARNING,
@@ -109,7 +112,7 @@ def vdebug(self: logging.Logger,
# pylint: enable=protected-access
-logging.Logger.vdebug = vdebug # type: ignore
+logging.Logger.vdebug = vdebug # type: ignore[attr-defined]
# The different loggers used.
@@ -176,7 +179,7 @@ def stub(suffix: str = '') -> None:
def init_log(args: argparse.Namespace) -> None:
"""Init loggers based on the argparse namespace passed."""
- level = args.loglevel.upper()
+ level = (args.loglevel or "info").upper()
try:
numeric_level = getattr(logging, level)
except AttributeError:
@@ -190,16 +193,7 @@ def init_log(args: argparse.Namespace) -> None:
root = logging.getLogger()
global console_filter
if console is not None:
- if not args.logfilter:
- negate = False
- names = None
- elif args.logfilter.startswith('!'):
- negate = True
- names = args.logfilter[1:].split(',')
- else:
- negate = False
- names = args.logfilter.split(',')
- console_filter = LogFilter(names, negate)
+ console_filter = LogFilter.parse(args.logfilter)
console.addFilter(console_filter)
root.addHandler(console)
if ram is not None:
@@ -216,7 +210,7 @@ def init_log(args: argparse.Namespace) -> None:
root.setLevel(logging.NOTSET)
logging.captureWarnings(True)
_init_py_warnings()
- QtCore.qInstallMessageHandler(qt_message_handler) # type: ignore
+ QtCore.qInstallMessageHandler(qt_message_handler)
_log_inited = True
@@ -278,7 +272,7 @@ def _init_handlers(
level, color, force_color, json_logging)
if sys.stderr is None:
- console_handler = None # type: ignore
+ console_handler = None # type: ignore[unreachable]
else:
strip = False if force_color else None
if use_colorama:
@@ -293,7 +287,7 @@ def _init_handlers(
ram_handler = None
else:
ram_handler = RAMHandler(capacity=ram_capacity)
- ram_handler.setLevel(logging.NOTSET)
+ ram_handler.setLevel(logging.DEBUG)
ram_handler.setFormatter(ram_fmt)
ram_handler.html_formatter = html_fmt
@@ -337,14 +331,17 @@ def _init_formatters(
use_colors=False)
html_formatter = HTMLFormatter(EXTENDED_FMT_HTML, DATEFMT,
log_colors=LOG_COLORS)
+
+ use_colorama = False
+
if sys.stderr is None:
- return None, ram_formatter, html_formatter, False # type: ignore
+ console_formatter = None # type: ignore[unreachable]
+ return console_formatter, ram_formatter, html_formatter, use_colorama
if json_logging:
json_formatter = JSONFormatter()
- return json_formatter, ram_formatter, html_formatter, False
+ return json_formatter, ram_formatter, html_formatter, use_colorama
- use_colorama = False
color_supported = os.name == 'posix' or colorama
if color_supported and (sys.stderr.isatty() or force_color) and color:
@@ -481,13 +478,13 @@ def qt_message_handler(msg_type: QtCore.QtMsgType,
level = qt_to_logging[msg_type]
if context.function is None:
- func = 'none' # type: ignore
+ func = 'none' # type: ignore[unreachable]
elif ':' in context.function:
func = '"{}"'.format(context.function)
else:
func = context.function
- if (context.category is None or # type: ignore
+ if (context.category is None or # type: ignore[unreachable]
context.category == 'default'):
name = 'qt'
else:
@@ -523,6 +520,36 @@ def hide_qt_warning(pattern: str, logger: str = 'qt') -> typing.Iterator[None]:
logger_obj.removeFilter(log_filter)
+def init_from_config(conf: 'configmodule.ConfigContainer') -> None:
+ """Initialize logging settings from the config.
+
+ init_log is called before the config module is initialized, so config-based
+ initialization cannot be performed there.
+
+ Args:
+ conf: The global ConfigContainer.
+ This is passed rather than accessed via the module to avoid a
+ cyclic import.
+ """
+ assert _args is not None
+ if _args.debug:
+ init.debug("--debug flag overrides log configs")
+ return
+ if ram_handler:
+ ramlevel = conf.logging.level.ram
+ init.debug("Configuring RAM loglevel to %s", ramlevel)
+ ram_handler.setLevel(LOG_LEVELS[ramlevel.upper()])
+ if console_handler:
+ consolelevel = conf.logging.level.console
+ if _args.loglevel:
+ init.debug("--loglevel flag overrides logging.level.console")
+ else:
+ init.debug("Configuring console loglevel to %s", consolelevel)
+ level = LOG_LEVELS[consolelevel.upper()]
+ console_handler.setLevel(level)
+ change_console_formatter(level)
+
+
class QtWarningFilter(logging.Filter):
"""Filter to filter Qt warnings.
@@ -541,6 +568,17 @@ class QtWarningFilter(logging.Filter):
return do_log
+class InvalidLogFilterError(Exception):
+
+ """Raised when an invalid filter string is passed to LogFilter.parse()."""
+
+ def __init__(self, names: typing.Set[str]):
+ invalid = names - set(LOGGER_NAMES)
+ super().__init__("Invalid log category {} - valid categories: {}"
+ .format(', '.join(sorted(invalid)),
+ ', '.join(LOGGER_NAMES)))
+
+
class LogFilter(logging.Filter):
"""Filter to filter log records based on the commandline argument.
@@ -549,30 +587,58 @@ class LogFilter(logging.Filter):
comma-separated list instead.
Attributes:
- names: A list of record names to filter.
- negated: Whether names is a list of records to log or to suppress.
+ names: A set of logging names to allow.
+ negated: Whether names is a set of names to log or to suppress.
+ only_debug: Only filter debug logs, always show anything more important
+ than debug.
"""
- def __init__(self, names: typing.Optional[typing.Iterable[str]],
- negate: bool = False) -> None:
+ def __init__(self, names: typing.Set[str], *, negated: bool = False,
+ only_debug: bool = True) -> None:
super().__init__()
self.names = names
- self.negated = negate
+ self.negated = negated
+ self.only_debug = only_debug
+
+ @classmethod
+ def parse(cls, filter_str: typing.Optional[str], *,
+ only_debug: bool = True) -> 'LogFilter':
+ """Parse a log filter from a string."""
+ if filter_str is None or filter_str == 'none':
+ names = set()
+ negated = False
+ else:
+ filter_str = filter_str.lower()
+
+ if filter_str.startswith('!'):
+ negated = True
+ filter_str = filter_str[1:]
+ else:
+ negated = False
+
+ names = {e.strip() for e in filter_str.split(',')}
+
+ if not names.issubset(LOGGER_NAMES):
+ raise InvalidLogFilterError(names)
+
+ return cls(names=names, negated=negated, only_debug=only_debug)
+
+ def update_from(self, other: 'LogFilter') -> None:
+ """Update this filter's properties from another filter."""
+ self.names = other.names
+ self.negated = other.negated
+ self.only_debug = other.only_debug
def filter(self, record: logging.LogRecord) -> bool:
"""Determine if the specified record is to be logged."""
- if self.names is None:
+ if not self.names:
+ # No filter
return True
- if record.levelno > logging.DEBUG:
+ elif record.levelno > logging.DEBUG and self.only_debug:
# More important than DEBUG, so we won't filter at all
return True
- for name in self.names:
- if record.name == name:
- return not self.negated
- elif not record.name.startswith(name):
- continue
- elif record.name[len(name)] == '.':
- return not self.negated
+ elif record.name.split('.')[0] in self.names:
+ return not self.negated
return self.negated
@@ -598,19 +664,26 @@ class RAMHandler(logging.Handler):
self._data = collections.deque()
def emit(self, record: logging.LogRecord) -> None:
- if record.levelno >= logging.DEBUG:
- # We don't log VDEBUG to RAM.
- self._data.append(record)
+ self._data.append(record)
- def dump_log(self, html: bool = False, level: str = 'vdebug') -> str:
+ def dump_log(self, html: bool = False, level: str = 'vdebug',
+ logfilter: LogFilter = None) -> str:
"""Dump the complete formatted log data as string.
- FIXME: We should do all the HTML formatter via jinja2.
+ FIXME: We should do all the HTML formatting via jinja2.
(probably obsolete when moving to a widget for logging,
https://github.com/qutebrowser/qutebrowser/issues/34
+
+ Args:
+ html: Produce HTML rather than plaintext output.
+ level: The minimal loglevel to show.
+ logfilter: A LogFilter instance used to filter log lines.
"""
minlevel = LOG_LEVELS.get(level.upper(), VDEBUG_LEVEL)
+ if logfilter is None:
+ logfilter = LogFilter(set())
+
if html:
assert self.html_formatter is not None
fmt = self.html_formatter.format
@@ -621,7 +694,8 @@ class RAMHandler(logging.Handler):
try:
lines = [fmt(record)
for record in self._data
- if record.levelno >= minlevel]
+ if record.levelno >= minlevel and
+ logfilter.filter(record)]
finally:
self.release()
return '\n'.join(lines)
@@ -692,9 +766,10 @@ class HTMLFormatter(logging.Formatter):
record_clone.__dict__.update(self._colordict)
if record_clone.levelname in self._log_colors:
color = self._log_colors[record_clone.levelname]
- record_clone.log_color = self._colordict[color] # type: ignore
+ color_str = self._colordict[color]
+ record_clone.log_color = color_str # type: ignore[attr-defined]
else:
- record_clone.log_color = '' # type: ignore
+ record_clone.log_color = '' # type: ignore[attr-defined]
for field in ['msg', 'filename', 'funcName', 'levelname', 'module',
'name', 'pathname', 'processName', 'threadName']:
data = str(getattr(record_clone, field))
diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py
index 3413b5e0e..2754d87e7 100644
--- a/qutebrowser/utils/message.py
+++ b/qutebrowser/utils/message.py
@@ -25,7 +25,7 @@
import traceback
import typing
-from PyQt5.QtCore import pyqtSignal, QObject, QUrl
+from PyQt5.QtCore import pyqtSignal, QObject
from qutebrowser.utils import usertypes, log, utils
@@ -88,7 +88,7 @@ def _build_question(title: str,
mode: usertypes.PromptMode,
default: typing.Union[None, bool, str] = None,
abort_on: typing.Iterable[pyqtSignal] = (),
- url: QUrl = None,
+ url: str = None,
option: bool = None) -> usertypes.Question:
"""Common function for ask/ask_async."""
question = usertypes.Question()
diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py
index fa6097586..908caac6a 100644
--- a/qutebrowser/utils/objreg.py
+++ b/qutebrowser/utils/objreg.py
@@ -86,7 +86,7 @@ class ObjectRegistry(collections.UserDict):
if isinstance(obj, QObject):
func = functools.partial(self.on_destroyed, name)
- obj.destroyed.connect(func) # type: ignore
+ obj.destroyed.connect(func)
self._partial_objs[name] = func
super().__setitem__(name, obj)
@@ -170,9 +170,7 @@ def _get_tab_registry(win_id: _WindowTab,
if window is None or not hasattr(window, 'win_id'):
raise RegistryUnavailableError('tab')
win_id = window.win_id
- elif win_id is not None:
- window = window_registry[win_id]
- else:
+ elif win_id is None:
raise TypeError("window is None with scope tab!")
if tab_id == 'current':
diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py
index eb123e634..04db1f0cb 100644
--- a/qutebrowser/utils/qtutils.py
+++ b/qutebrowser/utils/qtutils.py
@@ -17,8 +17,6 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-# FIXME:typing Can we have less "# type: ignore" in here?
-
"""Misc. utilities related to Qt.
Module attributes:
@@ -37,14 +35,14 @@ import typing
import pkg_resources
from PyQt5.QtCore import (qVersion, QEventLoop, QDataStream, QByteArray,
- QIODevice, QSaveFile, QT_VERSION_STR,
- PYQT_VERSION_STR, QFileDevice, QObject)
+ QIODevice, QFileDevice, QSaveFile, QT_VERSION_STR,
+ PYQT_VERSION_STR, QObject, QUrl)
from PyQt5.QtGui import QColor
from PyQt5.QtWidgets import QApplication
try:
from PyQt5.QtWebKit import qWebKitVersion
except ImportError: # pragma: no cover
- qWebKitVersion = None # type: ignore # noqa: N816
+ qWebKitVersion = None # type: ignore[assignment] # noqa: N816
from qutebrowser.misc import objects
from qutebrowser.utils import usertypes
@@ -63,23 +61,27 @@ MINVALS = {
class QtOSError(OSError):
- """An OSError triggered by a QFileDevice.
+ """An OSError triggered by a QIODevice.
Attributes:
qt_errno: The error attribute of the given QFileDevice, if applicable.
"""
- def __init__(self, dev: QFileDevice, msg: str = None) -> None:
+ def __init__(self, dev: QIODevice, msg: str = None) -> None:
if msg is None:
msg = dev.errorString()
+ self.qt_errno = None # type: typing.Optional[QFileDevice.FileError]
+ if isinstance(dev, QFileDevice):
+ msg = self._init_filedev(dev, msg)
+
super().__init__(msg)
- self.qt_errno = None # type: typing.Optional[QFileDevice.FileError]
- try:
- self.qt_errno = dev.error()
- except AttributeError:
- pass
+ def _init_filedev(self, dev: QFileDevice, msg: str) -> str:
+ self.qt_errno = dev.error()
+ filename = dev.fileName()
+ msg += ": {!r}".format(filename)
+ return msg
def version_check(version: str,
@@ -152,7 +154,16 @@ def check_overflow(arg: int, ctype: str, fatal: bool = True) -> int:
return arg
-def ensure_valid(obj: QObject) -> None:
+if typing.TYPE_CHECKING:
+ class Validatable(typing.Protocol):
+
+ """An object with an isValid() method (e.g. QUrl)."""
+
+ def isValid(self) -> bool:
+ ...
+
+
+def ensure_valid(obj: 'Validatable') -> None:
"""Ensure a Qt object with an .isValid() method is valid."""
if not obj.isValid():
raise QtValueError(obj)
@@ -172,7 +183,10 @@ def check_qdatastream(stream: QDataStream) -> None:
raise OSError(status_to_str[stream.status()])
-def serialize(obj: QObject) -> QByteArray:
+_QtSerializableType = typing.Union[QObject, QByteArray, QUrl]
+
+
+def serialize(obj: _QtSerializableType) -> QByteArray:
"""Serialize an object into a QByteArray."""
data = QByteArray()
stream = QDataStream(data, QIODevice.WriteOnly)
@@ -180,23 +194,25 @@ def serialize(obj: QObject) -> QByteArray:
return data
-def deserialize(data: QByteArray, obj: QObject) -> None:
+def deserialize(data: QByteArray, obj: _QtSerializableType) -> None:
"""Deserialize an object from a QByteArray."""
stream = QDataStream(data, QIODevice.ReadOnly)
deserialize_stream(stream, obj)
-def serialize_stream(stream: QDataStream, obj: QObject) -> None:
+def serialize_stream(stream: QDataStream, obj: _QtSerializableType) -> None:
"""Serialize an object into a QDataStream."""
+ # pylint: disable=pointless-statement
check_qdatastream(stream)
- stream << obj # pylint: disable=pointless-statement
+ stream << obj # type: ignore[operator]
check_qdatastream(stream)
-def deserialize_stream(stream: QDataStream, obj: QObject) -> None:
+def deserialize_stream(stream: QDataStream, obj: _QtSerializableType) -> None:
"""Deserialize a QDataStream into an object."""
+ # pylint: disable=pointless-statement
check_qdatastream(stream)
- stream >> obj # pylint: disable=pointless-statement
+ stream >> obj # type: ignore[operator]
check_qdatastream(stream)
@@ -205,7 +221,7 @@ def savefile_open(
filename: str,
binary: bool = False,
encoding: str = 'utf-8'
-) -> typing.Iterator[typing.Union['PyQIODevice', io.TextIOWrapper]]:
+) -> typing.Iterator[typing.IO]:
"""Context manager to easily use a QSaveFile."""
f = QSaveFile(filename)
cancelled = False
@@ -214,12 +230,12 @@ def savefile_open(
if not open_ok:
raise QtOSError(f)
+ dev = typing.cast(typing.BinaryIO, PyQIODevice(f))
+
if binary:
- new_f = PyQIODevice(
- f) # type: typing.Union[PyQIODevice, io.TextIOWrapper]
+ new_f = dev # type: typing.IO
else:
- new_f = io.TextIOWrapper(PyQIODevice(f), # type: ignore
- encoding=encoding)
+ new_f = io.TextIOWrapper(dev, encoding=encoding)
yield new_f
@@ -335,28 +351,34 @@ class PyQIODevice(io.BufferedIOBase):
def readable(self) -> bool:
return self.dev.isReadable()
- def readline(self, size: int = -1) -> QByteArray:
+ def readline(self, size: int = -1) -> bytes:
self._check_open()
self._check_readable()
if size < 0:
qt_size = 0 # no maximum size
elif size == 0:
- return QByteArray()
+ return b''
else:
qt_size = size + 1 # Qt also counts the NUL byte
+ buf = None # type: typing.Union[QByteArray, bytes, None]
if self.dev.canReadLine():
buf = self.dev.readLine(qt_size)
+ elif size < 0:
+ buf = self.dev.readAll()
else:
- if size < 0:
- buf = self.dev.readAll()
- else:
- buf = self.dev.read(size)
+ buf = self.dev.read(size)
if buf is None:
raise QtOSError(self.dev)
- return buf # type: ignore
+
+ if isinstance(buf, QByteArray):
+ # The type (bytes or QByteArray) seems to depend on what data we
+ # feed in...
+ buf = buf.data()
+
+ return buf
def seekable(self) -> bool:
return not self.dev.isSequential()
@@ -369,26 +391,33 @@ class PyQIODevice(io.BufferedIOBase):
def writable(self) -> bool:
return self.dev.isWritable()
- def write(self, data: str) -> int: # type: ignore
+ def write(self, data: typing.Union[bytes, bytearray]) -> int:
self._check_open()
self._check_writable()
- num = self.dev.write(data) # type: ignore
+ num = self.dev.write(data)
if num == -1 or num < len(data):
raise QtOSError(self.dev)
return num
- def read(self, size: typing.Optional[int] = None) -> QByteArray:
+ def read(self, size: typing.Optional[int] = None) -> bytes:
self._check_open()
self._check_readable()
+ buf = None # type: typing.Union[QByteArray, bytes, None]
if size in [None, -1]:
buf = self.dev.readAll()
else:
- buf = self.dev.read(size) # type: ignore
+ assert size is not None
+ buf = self.dev.read(size)
if buf is None:
raise QtOSError(self.dev)
+ if isinstance(buf, QByteArray):
+ # The type (bytes or QByteArray) seems to depend on what data we
+ # feed in...
+ buf = buf.data()
+
return buf
@@ -396,9 +425,9 @@ class QtValueError(ValueError):
"""Exception which gets raised by ensure_valid."""
- def __init__(self, obj: QObject) -> None:
+ def __init__(self, obj: 'Validatable') -> None:
try:
- self.reason = obj.errorString()
+ self.reason = obj.errorString() # type: ignore[attr-defined]
except AttributeError:
self.reason = None
err = "{} is not valid".format(obj)
@@ -420,12 +449,13 @@ class EventLoop(QEventLoop):
def exec_(
self,
- flags: QEventLoop.ProcessEventsFlag = QEventLoop.AllEvents
+ flags: QEventLoop.ProcessEventsFlags =
+ typing.cast(QEventLoop.ProcessEventsFlags, QEventLoop.AllEvents)
) -> int:
"""Override exec_ to raise an exception when re-running."""
if self._executing:
raise AssertionError("Eventloop is already running!")
self._executing = True
- status = super().exec_(flags) # type: ignore
+ status = super().exec_(flags)
self._executing = False
return status
diff --git a/qutebrowser/utils/standarddir.py b/qutebrowser/utils/standarddir.py
index ce5a20634..8e5a91c30 100644
--- a/qutebrowser/utils/standarddir.py
+++ b/qutebrowser/utils/standarddir.py
@@ -68,7 +68,7 @@ def _unset_organization() -> typing.Iterator[None]:
qapp = QApplication.instance()
if qapp is not None:
orgname = qapp.organizationName()
- qapp.setOrganizationName(None) # type: ignore
+ qapp.setOrganizationName(None) # type: ignore[arg-type]
try:
yield
finally:
@@ -314,6 +314,9 @@ def _create(path: str) -> None:
0700. If the destination directory exists already the permissions
should not be changed.
"""
+ if APPNAME == 'qute_test' and path.startswith('/home'): # pragma: no cover
+ raise Exception("Trying to create directory inside /home during "
+ "tests, this should not happen.")
os.makedirs(path, 0o700, exist_ok=True)
diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py
index b0013c195..7c8cec7a5 100644
--- a/qutebrowser/utils/urlutils.py
+++ b/qutebrowser/utils/urlutils.py
@@ -116,13 +116,18 @@ def _get_search_url(txt: str) -> QUrl:
engine = 'DEFAULT'
if term:
template = config.val.url.searchengines[engine]
+ semiquoted_term = urllib.parse.quote(term)
quoted_term = urllib.parse.quote(term, safe='')
- url = qurl_from_user_input(template.format(quoted_term))
+ evaluated = template.format(semiquoted_term,
+ unquoted=term,
+ quoted=quoted_term,
+ semiquoted=semiquoted_term)
+ url = qurl_from_user_input(evaluated)
else:
url = qurl_from_user_input(config.val.url.searchengines[engine])
- url.setPath(None) # type: ignore
- url.setFragment(None) # type: ignore
- url.setQuery(None) # type: ignore
+ url.setPath(None) # type: ignore[arg-type]
+ url.setFragment(None) # type: ignore[arg-type]
+ url.setQuery(None) # type: ignore[call-overload]
qtutils.ensure_valid(url)
return url
@@ -514,17 +519,17 @@ def encoded_url(url: QUrl) -> str:
Args:
url: The url to encode as QUrl.
"""
- return bytes(url.toEncoded()).decode('ascii')
+ return url.toEncoded().data().decode('ascii')
-def file_url(path: str) -> QUrl:
+def file_url(path: str) -> str:
"""Return a file:// url (as string) to the given local path.
Arguments:
path: The absolute path to the local file
"""
url = QUrl.fromLocalFile(path)
- return url.toString(QUrl.FullyEncoded) # type: ignore
+ return url.toString(QUrl.FullyEncoded) # type: ignore[arg-type]
def data_url(mimetype: str, data: bytes) -> QUrl:
@@ -546,13 +551,13 @@ def safe_display_string(qurl: QUrl) -> str:
"""
ensure_valid(qurl)
- host = qurl.host(QUrl.FullyEncoded) # type: ignore
+ host = qurl.host(QUrl.FullyEncoded)
if '..' in host: # pragma: no cover
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-60364
return '(unparseable URL!) {}'.format(qurl.toDisplayString())
for part in host.split('.'):
- url_host = qurl.host(QUrl.FullyDecoded) # type: ignore
+ url_host = qurl.host(QUrl.FullyDecoded)
if part.startswith('xn--') and host != url_host:
return '({}) {}'.format(host, qurl.toDisplayString())
@@ -579,7 +584,7 @@ class InvalidProxyTypeError(Exception):
super().__init__("Invalid proxy type {}!".format(typ))
-def proxy_from_url(url: QUrl) -> QNetworkProxy:
+def proxy_from_url(url: QUrl) -> typing.Union[QNetworkProxy, pac.PACFetcher]:
"""Create a QNetworkProxy from QUrl and a proxy type.
Args:
diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py
index 3df2593ad..0b6f9c219 100644
--- a/qutebrowser/utils/usertypes.py
+++ b/qutebrowser/utils/usertypes.py
@@ -91,7 +91,7 @@ class NeighborList(typing.Sequence[_T]):
self._mode = mode
self.fuzzyval = None # type: typing.Optional[int]
- def __getitem__(self, key: int) -> _T: # type: ignore
+ def __getitem__(self, key: int) -> _T: # type: ignore[override]
return self._items[key]
def __len__(self) -> int:
@@ -120,7 +120,8 @@ class NeighborList(typing.Sequence[_T]):
if items:
item = min(
items,
- key=lambda tpl: abs(self.fuzzyval - tpl[1])) # type: ignore
+ key=lambda tpl:
+ abs(self.fuzzyval - tpl[1])) # type: ignore[operator]
else:
sorted_items = sorted(enumerate(self.items), key=lambda e: e[1])
idx = 0 if offset < 0 else -1
@@ -255,6 +256,10 @@ class KeyMode(enum.Enum):
jump_mark = 10
record_macro = 11
run_macro = 12
+ # 'register' is a bit of an oddball here: It's not really a "real" mode,
+ # but it's used in the config for common bindings for
+ # set_mark/jump_mark/record_macro/run_macro.
+ register = 13
class Exit(enum.IntEnum):
diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py
index 58e89c2a1..92ca34a08 100644
--- a/qutebrowser/utils/utils.py
+++ b/qutebrowser/utils/utils.py
@@ -36,6 +36,8 @@ import shlex
import glob
import mimetypes
import typing
+import ctypes
+import ctypes.util
from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QColor, QClipboard, QDesktopServices
@@ -47,7 +49,7 @@ try:
CSafeDumper as YamlDumper)
YAML_C_EXT = True
except ImportError: # pragma: no cover
- from yaml import (SafeLoader as YamlLoader, # type: ignore
+ from yaml import (SafeLoader as YamlLoader, # type: ignore[misc]
SafeDumper as YamlDumper)
YAML_C_EXT = False
@@ -324,7 +326,7 @@ class FakeIOStream(io.TextIOBase):
def __init__(self, write_func: typing.Callable[[str], int]) -> None:
super().__init__()
- self.write = write_func # type: ignore
+ self.write = write_func # type: ignore[assignment]
@contextlib.contextmanager
@@ -338,16 +340,16 @@ def fake_io(write_func: typing.Callable[[str], int]) -> typing.Iterator[None]:
old_stderr = sys.stderr
fake_stderr = FakeIOStream(write_func)
fake_stdout = FakeIOStream(write_func)
- sys.stderr = fake_stderr # type: ignore
- sys.stdout = fake_stdout # type: ignore
+ sys.stderr = fake_stderr # type: ignore[assignment]
+ sys.stdout = fake_stdout # type: ignore[assignment]
try:
yield
finally:
# If the code we did run did change sys.stdout/sys.stderr, we leave it
# unchanged. Otherwise, we reset it.
- if sys.stdout is fake_stdout: # type: ignore
+ if sys.stdout is fake_stdout: # type: ignore[comparison-overlap]
sys.stdout = old_stdout
- if sys.stderr is fake_stderr: # type: ignore
+ if sys.stderr is fake_stderr: # type: ignore[comparison-overlap]
sys.stderr = old_stderr
@@ -776,3 +778,16 @@ def ceil_log(number: int, base: int) -> int:
result += 1
accum *= base
return result
+
+
+def libgl_workaround() -> None:
+ """Work around QOpenGLShaderProgram issues, especially for Nvidia.
+
+ See https://bugs.launchpad.net/ubuntu/+source/python-qt4/+bug/941826
+ """
+ if os.environ.get('QUTE_SKIP_LIBGL_WORKAROUND'):
+ return
+
+ libgl = ctypes.util.find_library("GL")
+ if libgl is not None: # pragma: no branch
+ ctypes.CDLL(libgl, mode=ctypes.RTLD_GLOBAL)
diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py
index bc766f332..3ad1eb155 100644
--- a/qutebrowser/utils/version.py
+++ b/qutebrowser/utils/version.py
@@ -31,6 +31,7 @@ import enum
import datetime
import getpass
import typing
+import functools
import attr
import pkg_resources
@@ -43,7 +44,7 @@ from PyQt5.QtWidgets import QApplication
try:
from PyQt5.QtWebKit import qWebKitVersion
except ImportError: # pragma: no cover
- qWebKitVersion = None # type: ignore # noqa: N816
+ qWebKitVersion = None # type: ignore[assignment] # noqa: N816
import qutebrowser
from qutebrowser.utils import log, utils, standarddir, usertypes, message
@@ -54,7 +55,7 @@ from qutebrowser.config import config
try:
from qutebrowser.browser.webengine import webenginesettings
except ImportError: # pragma: no cover
- webenginesettings = None # type: ignore
+ webenginesettings = None # type: ignore[assignment]
@attr.s
@@ -90,7 +91,7 @@ def distribution() -> typing.Optional[DistributionInfo]:
with open(filename, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
- if (not line) or line.startswith('#'):
+ if (not line) or line.startswith('#') or '=' not in line:
continue
k, v = line.split("=", maxsplit=1)
info[k] = v.strip('"')
@@ -160,6 +161,14 @@ def _git_str() -> typing.Optional[str]:
return None
+def _call_git(gitpath: str, *args: str) -> str:
+ """Call a git subprocess."""
+ return subprocess.run(
+ ['git'] + list(args),
+ cwd=gitpath, check=True,
+ stdout=subprocess.PIPE).stdout.decode('UTF-8').strip()
+
+
def _git_str_subprocess(gitpath: str) -> typing.Optional[str]:
"""Try to get the git commit ID and timestamp by calling git.
@@ -173,15 +182,11 @@ def _git_str_subprocess(gitpath: str) -> typing.Optional[str]:
return None
try:
# https://stackoverflow.com/questions/21017300/21017394#21017394
- commit_hash = subprocess.run(
- ['git', 'describe', '--match=NeVeRmAtCh', '--always', '--dirty'],
- cwd=gitpath, check=True,
- stdout=subprocess.PIPE).stdout.decode('UTF-8').strip()
- date = subprocess.run(
- ['git', 'show', '-s', '--format=%ci', 'HEAD'],
- cwd=gitpath, check=True,
- stdout=subprocess.PIPE).stdout.decode('UTF-8').strip()
- return '{} ({})'.format(commit_hash, date)
+ commit_hash = _call_git(gitpath, 'describe', '--match=NeVeRmAtCh',
+ '--always', '--dirty')
+ date = _call_git(gitpath, 'show', '-s', '--format=%ci', 'HEAD')
+ branch = _call_git(gitpath, 'rev-parse', '--abbrev-ref', 'HEAD')
+ return '{} on {} ({})'.format(commit_hash, branch, date)
except (subprocess.CalledProcessError, OSError):
return None
@@ -234,7 +239,7 @@ def _module_versions() -> typing.Sequence[str]:
for modname, attributes in modules.items():
try:
module = importlib.import_module(modname)
- except ImportError:
+ except (ImportError, ValueError):
text = '{}: no'.format(modname)
else:
for name in attributes:
@@ -288,7 +293,7 @@ def _os_info() -> typing.Sequence[str]:
versioninfo = ''
else:
versioninfo = '.'.join(info_tpl)
- osver = ', '.join([e for e in [release, versioninfo, machine] if e])
+ osver = ', '.join(e for e in [release, versioninfo, machine] if e)
elif utils.is_posix:
osver = ' '.join(platform.uname())
else:
@@ -344,7 +349,7 @@ def _chromium_version() -> str:
Qt 5.9: Chromium 56
(LTS) 56.0.2924.122 (2017-01-25)
- 5.9.8: Security fixes up to 72.0.3626.121 (2019-03-01)
+ 5.9.9: Security fixes up to 78.0.3904.108 (2019-11-18)
Qt 5.10: Chromium 61
61.0.3163.140 (2017-09-05)
@@ -356,7 +361,7 @@ def _chromium_version() -> str:
Qt 5.12: Chromium 69
(LTS) 69.0.3497.113 (2018-09-27)
- 5.12.7: Security fixes up to 79.0.3945.130 (2020-01-16)
+ 5.12.8: Security fixes up to 80.0.3987.149 (2020-03-18)
Qt 5.13: Chromium 73
73.0.3683.105 (~2019-02-28)
@@ -367,13 +372,14 @@ def _chromium_version() -> str:
5.14.2: Security fixes up to 80.0.3987.132 (2020-03-03)
Qt 5.15: Chromium 80
- 80.0.3987.136 (~2020-03-09)
+ 80.0.3987.163 (2020-04-02)
+ 5.15.0: Security fixes up to 81.0.4044.138 (2020-05-05)
Also see https://www.chromium.org/developers/calendar
and https://chromereleases.googleblog.com/
"""
if webenginesettings is None:
- return 'unavailable' # type: ignore
+ return 'unavailable' # type: ignore[unreachable]
if webenginesettings.parsed_user_agent is None:
webenginesettings.init_user_agent()
@@ -411,7 +417,7 @@ def _config_py_loaded() -> str:
return "no config.py was loaded"
-def version() -> str:
+def version_info() -> str:
"""Return a string with various version information."""
lines = ["qutebrowser v{}".format(qutebrowser.__version__)]
gitver = _git_str()
@@ -442,6 +448,8 @@ def version() -> str:
if qapp:
style = qapp.style()
lines.append('Style: {}'.format(style.metaObject().className()))
+ lines.append('Platform plugin: {}'.format(qapp.platformName()))
+ lines.append('OpenGL: {}'.format(opengl_info()))
importpath = os.path.dirname(os.path.abspath(qutebrowser.__file__))
@@ -485,7 +493,65 @@ def version() -> str:
return '\n'.join(lines)
-def opengl_vendor() -> typing.Optional[str]: # pragma: no cover
+@attr.s
+class OpenGLInfo:
+
+ """Information about the OpenGL setup in use."""
+
+ # If we're using OpenGL ES. If so, no further information is available.
+ gles = attr.ib(False) # type: bool
+
+ # The name of the vendor. Examples:
+ # - nouveau
+ # - "Intel Open Source Technology Center", "Intel", "Intel Inc."
+ vendor = attr.ib(None) # type: typing.Optional[str]
+
+ # The OpenGL version as a string. See tests for examples.
+ version_str = attr.ib(None) # type: typing.Optional[str]
+
+ # The parsed version as a (major, minor) tuple of ints
+ version = attr.ib(None) # type: typing.Optional[typing.Tuple[int, ...]]
+
+ # The vendor specific information following the version number
+ vendor_specific = attr.ib(None) # type: typing.Optional[str]
+
+ def __str__(self) -> str:
+ if self.gles:
+ return 'OpenGL ES'
+ return '{}, {}'.format(self.vendor, self.version_str)
+
+ @classmethod
+ def parse(cls, *, vendor: str, version: str) -> 'OpenGLInfo':
+ """Parse OpenGL version info from a string.
+
+ The arguments should be the strings returned by OpenGL for GL_VENDOR
+ and GL_VERSION, respectively.
+
+ According to the OpenGL reference, the version string should have the
+ following format:
+
+ <major>.<minor>[.<release>] <vendor-specific info>
+ """
+ if ' ' not in version:
+ log.misc.warning("Failed to parse OpenGL version (missing space): "
+ "{}".format(version))
+ return cls(vendor=vendor, version_str=version)
+
+ num_str, vendor_specific = version.split(' ', maxsplit=1)
+
+ try:
+ parsed_version = tuple(int(i) for i in num_str.split('.'))
+ except ValueError:
+ log.misc.warning("Failed to parse OpenGL version (parsing int): "
+ "{}".format(version))
+ return cls(vendor=vendor, version_str=version)
+
+ return cls(vendor=vendor, version_str=version,
+ version=parsed_version, vendor_specific=vendor_specific)
+
+
+@functools.lru_cache(maxsize=1)
+def opengl_info() -> typing.Optional[OpenGLInfo]: # pragma: no cover
"""Get the OpenGL vendor used.
This returns a string such as 'nouveau' or
@@ -494,10 +560,14 @@ def opengl_vendor() -> typing.Optional[str]: # pragma: no cover
"""
assert QApplication.instance()
- override = os.environ.get('QUTE_FAKE_OPENGL_VENDOR')
+ # Some setups can segfault in here if we don't do this.
+ utils.libgl_workaround()
+
+ override = os.environ.get('QUTE_FAKE_OPENGL')
if override is not None:
log.init.debug("Using override {}".format(override))
- return override
+ vendor, version = override.split(', ', maxsplit=1)
+ return OpenGLInfo.parse(vendor=vendor, version=version)
old_context = typing.cast(typing.Optional[QOpenGLContext],
QOpenGLContext.currentContext())
@@ -520,7 +590,7 @@ def opengl_vendor() -> typing.Optional[str]: # pragma: no cover
try:
if ctx.isOpenGLES():
# Can't use versionFunctions there
- return None
+ return OpenGLInfo(gles=True)
vp = QOpenGLVersionProfile()
vp.setVersion(2, 0)
@@ -535,7 +605,10 @@ def opengl_vendor() -> typing.Optional[str]: # pragma: no cover
log.init.debug("Getting version functions failed!")
return None
- return vf.glGetString(vf.GL_VENDOR)
+ vendor = vf.glGetString(vf.GL_VENDOR)
+ version = vf.glGetString(vf.GL_VERSION)
+
+ return OpenGLInfo.parse(vendor=vendor, version=version)
finally:
ctx.doneCurrent()
if old_context and old_surface:
@@ -578,5 +651,5 @@ def pastebin_version(pbclient: pastebin.PastebinClient = None) -> None:
pbclient.paste(getpass.getuser(),
"qute version info {}".format(qutebrowser.__version__),
- version(),
+ version_info(),
private=True)
diff --git a/requirements.txt b/requirements.txt
index 17c5e67ce..0aea570d5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,8 +4,8 @@ adblock==0.2.0
attrs==19.3.0
colorama==0.4.3
cssutils==1.0.2
-Jinja2==2.11.1
+Jinja2==2.11.2
MarkupSafe==1.1.1
Pygments==2.6.1
pyPEG2==2.15.2
-PyYAML==5.3
+PyYAML==5.3.1
diff --git a/scripts/asciidoc2html.py b/scripts/asciidoc2html.py
index ceac1ff41..e7e7f680b 100755
--- a/scripts/asciidoc2html.py
+++ b/scripts/asciidoc2html.py
@@ -20,21 +20,24 @@
"""Generate the html documentation based on the asciidoc files."""
+from typing import List, Optional
import re
import os
import os.path
import sys
import subprocess
-import glob
import shutil
import tempfile
import argparse
import io
+import pathlib
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from scripts import utils
+DOC_DIR = pathlib.Path("qutebrowser/html/doc")
+
class AsciiDoc:
@@ -42,31 +45,32 @@ class AsciiDoc:
FILES = ['faq', 'changelog', 'contributing', 'quickstart', 'userscripts']
- def __init__(self, asciidoc, website):
- self._cmd = None
+ def __init__(self,
+ asciidoc: Optional[List[str]],
+ website: Optional[str]) -> None:
+ self._cmd = None # type: Optional[List[str]]
self._asciidoc = asciidoc
self._website = website
- self._homedir = None
- self._themedir = None
- self._tempdir = None
+ self._homedir = None # type: Optional[pathlib.Path]
+ self._themedir = None # type: Optional[pathlib.Path]
+ self._tempdir = None # type: Optional[pathlib.Path]
self._failed = False
- def prepare(self):
+ def prepare(self) -> None:
"""Get the asciidoc command and create the homedir to use."""
self._cmd = self._get_asciidoc_cmd()
- self._homedir = tempfile.mkdtemp()
- self._themedir = os.path.join(
- self._homedir, '.asciidoc', 'themes', 'qute')
- self._tempdir = os.path.join(self._homedir, 'tmp')
- os.makedirs(self._tempdir)
- os.makedirs(self._themedir)
-
- def cleanup(self):
+ self._homedir = pathlib.Path(tempfile.mkdtemp())
+ self._themedir = self._homedir / '.asciidoc' / 'themes' / 'qute'
+ self._tempdir = self._homedir / 'tmp'
+ self._tempdir.mkdir(parents=True)
+ self._themedir.mkdir(parents=True)
+
+ def cleanup(self) -> None:
"""Clean up the temporary home directory for asciidoc."""
if self._homedir is not None and not self._failed:
shutil.rmtree(self._homedir)
- def build(self):
+ def build(self) -> None:
"""Build either the website or the docs."""
if self._website:
self._build_website()
@@ -74,14 +78,12 @@ class AsciiDoc:
self._build_docs()
self._copy_images()
- def _build_docs(self):
+ def _build_docs(self) -> None:
"""Render .asciidoc files to .html sites."""
- files = [('doc/{}.asciidoc'.format(f),
- 'qutebrowser/html/doc/{}.html'.format(f))
- for f in self.FILES]
- for src in glob.glob('doc/help/*.asciidoc'):
- name, _ext = os.path.splitext(os.path.basename(src))
- dst = 'qutebrowser/html/doc/{}.html'.format(name)
+ files = [(pathlib.Path('doc/{}.asciidoc'.format(f)),
+ DOC_DIR / (f + ".html")) for f in self.FILES]
+ for src in pathlib.Path('doc/help/').glob('*.asciidoc'):
+ dst = DOC_DIR / (src.stem + ".html")
files.append((src, dst))
# patch image links to use local copy
@@ -94,8 +96,8 @@ class AsciiDoc:
asciidoc_args = ['-a', 'source-highlighter=pygments']
for src, dst in files:
- src_basename = os.path.basename(src)
- modified_src = os.path.join(self._tempdir, src_basename)
+ assert self._tempdir is not None # for mypy
+ modified_src = self._tempdir / src.name
with open(modified_src, 'w', encoding='utf-8') as modified_f, \
open(src, 'r', encoding='utf-8') as f:
for line in f:
@@ -104,34 +106,26 @@ class AsciiDoc:
modified_f.write(line)
self.call(modified_src, dst, *asciidoc_args)
- def _copy_images(self):
+ def _copy_images(self) -> None:
"""Copy image files to qutebrowser/html/doc."""
print("Copying files...")
- dst_path = os.path.join('qutebrowser', 'html', 'doc', 'img')
- try:
- os.mkdir(dst_path)
- except FileExistsError:
- pass
+ dst_path = DOC_DIR / 'img'
+ dst_path.mkdir(exist_ok=True)
for filename in ['cheatsheet-big.png', 'cheatsheet-small.png']:
- src = os.path.join('doc', 'img', filename)
- dst = os.path.join(dst_path, filename)
+ src = pathlib.Path('doc') / 'img' / filename
+ dst = dst_path / filename
shutil.copy(src, dst)
- def _build_website_file(self, root, filename):
+ def _build_website_file(self, root: pathlib.Path, filename: str) -> None:
"""Build a single website file."""
- src = os.path.join(root, filename)
- src_basename = os.path.basename(src)
- parts = [self._website[0]]
- dirname = os.path.dirname(src)
- if dirname:
- parts.append(os.path.relpath(os.path.dirname(src)))
- parts.append(
- os.extsep.join((os.path.splitext(src_basename)[0],
- 'html')))
- dst = os.path.join(*parts)
- os.makedirs(os.path.dirname(dst), exist_ok=True)
-
- modified_src = os.path.join(self._tempdir, src_basename)
+ src = root / filename
+ assert self._website is not None # for mypy
+ dst = pathlib.Path(self._website)
+ dst = dst / src.parent.relative_to('.') / (src.stem + ".html")
+ dst.parent.mkdir(exist_ok=True)
+
+ assert self._tempdir is not None # for mypy
+ modified_src = self._tempdir / src.name
shutil.copy('www/header.asciidoc', modified_src)
outfp = io.StringIO()
@@ -187,25 +181,24 @@ class AsciiDoc:
'-a', 'source-highlighter=pygments']
self.call(modified_src, dst, *asciidoc_args)
- def _build_website(self):
+ def _build_website(self) -> None:
"""Prepare and build the website."""
- theme_file = os.path.abspath(os.path.join('www', 'qute.css'))
+ theme_file = (pathlib.Path('www') / 'qute.css').resolve()
+ assert self._themedir is not None # for mypy
shutil.copy(theme_file, self._themedir)
- outdir = self._website[0]
+ assert self._website is not None # for mypy
+ outdir = pathlib.Path(self._website)
- for root, _dirs, files in os.walk(os.getcwd()):
- for filename in files:
- basename, ext = os.path.splitext(filename)
- if (ext != '.asciidoc' or
- basename in ['header', 'OpenSans-License']):
- continue
- self._build_website_file(root, filename)
+ for item_path in pathlib.Path().rglob('*.asciidoc'):
+ if item_path.stem in ['header', 'OpenSans-License']:
+ continue
+ self._build_website_file(item_path.parent, item_path.name)
copy = {'icons': 'icons', 'doc/img': 'doc/img', 'www/media': 'media/'}
for src, dest in copy.items():
- full_dest = os.path.join(outdir, dest)
+ full_dest = outdir / dest
try:
shutil.rmtree(full_dest)
except FileNotFoundError:
@@ -214,13 +207,15 @@ class AsciiDoc:
for dst, link_name in [
('README.html', 'index.html'),
- (os.path.join('doc', 'quickstart.html'), 'quickstart.html')]:
+ ((pathlib.Path('doc') / 'quickstart.html'),
+ 'quickstart.html')]:
+ assert isinstance(dst, (str, pathlib.Path)) # for mypy
try:
- os.symlink(dst, os.path.join(outdir, link_name))
+ (outdir / link_name).symlink_to(dst)
except FileExistsError:
pass
- def _get_asciidoc_cmd(self):
+ def _get_asciidoc_cmd(self) -> List[str]:
"""Try to find out what commandline to use to invoke asciidoc."""
if self._asciidoc is not None:
return self._asciidoc
@@ -243,7 +238,7 @@ class AsciiDoc:
raise FileNotFoundError
- def call(self, src, dst, *args):
+ def call(self, src: pathlib.Path, dst: pathlib.Path, *args):
"""Call asciidoc for the given files.
Args:
@@ -251,28 +246,30 @@ class AsciiDoc:
dst: The destination .html file, or None to auto-guess.
*args: Additional arguments passed to asciidoc.
"""
- print("Calling asciidoc for {}...".format(os.path.basename(src)))
+ print("Calling asciidoc for {}...".format(src.name))
+ assert self._cmd is not None # for mypy
cmdline = self._cmd[:]
if dst is not None:
- cmdline += ['--out-file', dst]
+ cmdline += ['--out-file', str(dst)]
cmdline += args
- cmdline.append(src)
+ cmdline.append(str(src))
try:
env = os.environ.copy()
- env['HOME'] = self._homedir
+ env['HOME'] = str(self._homedir)
subprocess.run(cmdline, check=True, env=env)
except (subprocess.CalledProcessError, OSError) as e:
self._failed = True
- utils.print_col(str(e), 'red')
- print("Keeping modified sources in {}.".format(self._homedir))
+ utils.print_error(str(e))
+ print("Keeping modified sources in {}.".format(self._homedir),
+ file=sys.stderr)
sys.exit(1)
-def parse_args():
+def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser()
parser.add_argument('--website', help="Build website into a given "
- "directory.", nargs=1)
+ "directory.")
parser.add_argument('--asciidoc', help="Full path to python and "
"asciidoc.py. If not given, it's searched in PATH.",
nargs=2, required=False,
@@ -280,20 +277,17 @@ def parse_args():
return parser.parse_args()
-def run(**kwargs):
+def run(**kwargs) -> None:
"""Regenerate documentation."""
- try:
- os.mkdir('qutebrowser/html/doc')
- except FileExistsError:
- pass
+ DOC_DIR.mkdir(exist_ok=True)
asciidoc = AsciiDoc(**kwargs)
try:
asciidoc.prepare()
except FileNotFoundError:
- utils.print_col("Could not find asciidoc! Please install it, or use "
- "the --asciidoc argument to point this script to the "
- "correct python/asciidoc.py location!", 'red')
+ utils.print_error("Could not find asciidoc! Please install it, or use "
+ "the --asciidoc argument to point this script to "
+ "the correct python/asciidoc.py location!")
sys.exit(1)
try:
@@ -302,7 +296,7 @@ def run(**kwargs):
asciidoc.cleanup()
-def main(colors=False):
+def main(colors: bool = False) -> None:
"""Generate html files for the online documentation."""
utils.change_cwd()
utils.use_color = colors
diff --git a/scripts/dev/build_pyqt_wheel.py b/scripts/dev/build_pyqt_wheel.py
new file mode 100644
index 000000000..9a36c8129
--- /dev/null
+++ b/scripts/dev/build_pyqt_wheel.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2020 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+"""Build updated PyQt wheels."""
+
+import os
+import subprocess
+import argparse
+import sys
+import pathlib
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir,
+ os.pardir))
+from scripts import utils
+
+
+def find_pyqt_bundle():
+ """Try to find the pyqt-bundle executable next to the current Python.
+
+ We do this instead of using $PATH so that the script can be used via
+ .venv/bin/python.
+ """
+ bin_path = pathlib.Path(sys.executable).parent
+ path = bin_path / 'pyqt-bundle'
+
+ if not path.exists():
+ raise FileNotFoundError("Can't find pyqt-bundle at {}".format(path))
+
+ return path
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('qt_location', help='Qt compiler directory')
+ parser.add_argument('--wheels-dir', help='Directory to use for wheels',
+ default='wheels')
+ args = parser.parse_args()
+
+ old_cwd = pathlib.Path.cwd()
+
+ try:
+ pyqt_bundle = find_pyqt_bundle()
+ except FileNotFoundError as e:
+ utils.print_error(str(e))
+ sys.exit(1)
+
+ qt_dir = pathlib.Path(args.qt_location)
+ bin_dir = qt_dir / 'bin'
+ if not bin_dir.exists():
+ utils.print_error("Can't find {}".format(bin_dir))
+ sys.exit(1)
+
+ wheels_dir = pathlib.Path(args.wheels_dir).resolve()
+ wheels_dir.mkdir(exist_ok=True)
+
+ if list(wheels_dir.glob('*')):
+ utils.print_col("Wheels directory is not empty, "
+ "unexpected behavior might occur!", 'yellow')
+
+ os.chdir(wheels_dir)
+
+ utils.print_title("Downloading wheels")
+ subprocess.run([sys.executable, '-m', 'pip', 'download',
+ '--no-deps', '--only-binary', 'PyQt5,PyQtWebEngine',
+ 'PyQt5', 'PyQtWebEngine'], check=True)
+
+ utils.print_title("Patching wheels")
+ input_files = wheels_dir.glob('*.whl')
+ for wheel in input_files:
+ utils.print_subtitle(wheel.stem.split('-')[0])
+ subprocess.run([str(pyqt_bundle),
+ '--qt-dir', args.qt_location, str(wheel)],
+ check=True)
+ wheel.unlink()
+
+ print("Done, output files:")
+ for wheel in wheels_dir.glob('*.whl'):
+ print(wheel.relative_to(old_cwd))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py
index 95ce66473..2f037ac68 100755
--- a/scripts/dev/build_release.py
+++ b/scripts/dev/build_release.py
@@ -44,7 +44,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir,
import qutebrowser
from scripts import utils
-from scripts.dev import update_3rdparty
+from scripts.dev import update_3rdparty, misc_checks
def call_script(name, *args, python=sys.executable):
@@ -275,7 +275,7 @@ def build_windows():
utils.print_title("Running pyinstaller 32bit")
_maybe_remove(out_32)
- call_tox('pyinstaller', '-r', python=python_x86)
+ call_tox('pyinstaller32', '-r', python=python_x86)
shutil.move(out_pyinstaller, out_32)
utils.print_title("Running pyinstaller 64bit")
@@ -410,13 +410,21 @@ def github_upload(artifacts, tag):
for filename, mimetype, description in artifacts:
while True:
print("Uploading {}".format(filename))
+
+ basename = os.path.basename(filename)
+ assets = [asset for asset in release.assets()
+ if asset.name == basename]
+ if assets:
+ print("Assets already exist: {}".format(assets))
+ print("Press enter to continue anyways or Ctrl-C to abort.")
+ input()
+
try:
with open(filename, 'rb') as f:
- basename = os.path.basename(filename)
release.upload_asset(mimetype, basename, f, description)
except github3.exceptions.ConnectionError as e:
- utils.print_col('Failed to upload: {}'.format(e), 'red')
- print("Press Enter to retry...")
+ utils.print_error('Failed to upload: {}'.format(e))
+ print("Press Enter to retry...", file=sys.stderr)
input()
print("Retrying!")
@@ -465,6 +473,10 @@ def main():
import github3 # pylint: disable=unused-import
read_github_token()
+ if not misc_checks.check_git():
+ utils.print_error("Refusing to do a release with a dirty git tree")
+ sys.exit(1)
+
if args.no_asciidoc:
os.makedirs(os.path.join('qutebrowser', 'html', 'doc'), exist_ok=True)
else:
@@ -481,10 +493,9 @@ def main():
upload_to_pypi = True
if args.upload:
- utils.print_title("Press enter to release...")
- input()
-
version_tag = "v" + qutebrowser.__version__
+ utils.print_title("Press enter to release {}...".format(version_tag))
+ input()
github_upload(artifacts, version_tag)
if upload_to_pypi:
diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py
index 9e8f9cda4..7fa45dd90 100644
--- a/scripts/dev/check_coverage.py
+++ b/scripts/dev/check_coverage.py
@@ -202,7 +202,8 @@ PERFECT_FILES = [
('tests/unit/browser/webengine/test_spell.py',
'browser/webengine/spell.py'),
-
+ ('tests/unit/browser/webengine/test_webengine_cookies.py',
+ 'browser/webengine/cookies.py'),
]
diff --git a/scripts/dev/misc_checks.py b/scripts/dev/misc_checks.py
index 64864df8f..6bf411bba 100644
--- a/scripts/dev/misc_checks.py
+++ b/scripts/dev/misc_checks.py
@@ -83,7 +83,7 @@ def check_spelling():
"""Check commonly misspelled words."""
# Words which I often misspell
words = {'[Bb]ehaviour', '[Qq]uitted', 'Ll]ikelyhood', '[Ss]ucessfully',
- '[Oo]ccur[^rs .]', '[Ss]eperator', '[Ee]xplicitely',
+ '[Oo]ccur[^rs .!]', '[Ss]eperator', '[Ee]xplicitely',
'[Aa]uxillary', '[Aa]ccidentaly', '[Aa]mbigious', '[Ll]oosly',
'[Ii]nitialis', '[Cc]onvienence', '[Ss]imiliar', '[Uu]ncommited',
'[Rr]eproducable', '[Aa]n [Uu]ser', '[Cc]onvienience',
@@ -91,7 +91,7 @@ def check_spelling():
'[Mm]ininum', '[Rr]esett?ed', '[Rr]ecieved', '[Rr]egularily',
'[Uu]nderlaying', '[Ii]nexistant', '[Ee]lipsis', 'commiting',
'existant', '[Rr]esetted', '[Ss]imilarily', '[Ii]nformations',
- '[Aa]n [Uu][Rr][Ll]'}
+ '[Aa]n [Uu][Rr][Ll]', '[Tt]reshold'}
# Words which look better when splitted, but might need some fine tuning.
words |= {'[Ww]ebelements', '[Mm]ouseevent', '[Kk]eysequence',
diff --git a/scripts/dev/recompile_requirements.py b/scripts/dev/recompile_requirements.py
index 84cc155e3..3e7db0c9a 100644
--- a/scripts/dev/recompile_requirements.py
+++ b/scripts/dev/recompile_requirements.py
@@ -81,12 +81,18 @@ def read_comments(fobj):
'ignore': [],
'add': [],
'replace': {},
+ 'pre': False,
}
for line in fobj:
if line.startswith('#@'):
- command, args = line[2:].split(':', maxsplit=1)
- command = command.strip()
- args = args.strip()
+ if ':' in line:
+ command, args = line[2:].split(':', maxsplit=1)
+ command = command.strip()
+ args = args.strip()
+ else:
+ command = line[2:].strip()
+ args = None
+
if command == 'filter':
pkg, filt = args.split(' ', maxsplit=1)
comments['filter'][pkg] = filt
@@ -103,6 +109,8 @@ def read_comments(fobj):
comments['markers'][pkg] = markers
elif command == 'add':
comments['add'].append(args)
+ elif command == 'pre':
+ comments['pre'] = True
return comments
@@ -113,6 +121,22 @@ def get_all_names():
yield basename[len('requirements-'):-len('.txt-raw')]
+def init_venv(host_python, venv_dir, requirements, pre=False):
+ """Initialize a new virtualenv and install the given packages."""
+ subprocess.run([host_python, '-m', 'venv', venv_dir], check=True)
+
+ venv_python = os.path.join(venv_dir, 'bin', 'python')
+ subprocess.run([venv_python, '-m', 'pip',
+ 'install', '-U', 'pip'], check=True)
+
+ install_command = [venv_python, '-m', 'pip', 'install', '-r', requirements]
+ if pre:
+ install_command.append('--pre')
+ subprocess.run(install_command, check=True)
+ subprocess.run([venv_python, '-m', 'pip', 'check'], check=True)
+ return venv_python
+
+
def main():
"""Re-compile the given (or all) requirement files."""
names = sys.argv[1:] if len(sys.argv) > 1 else sorted(get_all_names())
@@ -136,22 +160,20 @@ def main():
else:
host_python = sys.executable
- with tempfile.TemporaryDirectory() as tmpdir:
- subprocess.run([host_python, '-m', 'venv', tmpdir], check=True)
+ utils.print_subtitle("Building")
- venv_python = os.path.join(tmpdir, 'bin', 'python')
- subprocess.run([venv_python, '-m', 'pip',
- 'install', '-U', 'pip'], check=True)
+ with open(filename, 'r', encoding='utf-8') as f:
+ comments = read_comments(f)
- subprocess.run([venv_python, '-m', 'pip',
- 'install', '-r', filename], check=True)
+ with tempfile.TemporaryDirectory() as tmpdir:
+ venv_python = init_venv(host_python=host_python,
+ venv_dir=tmpdir,
+ requirements=filename,
+ pre=comments['pre'])
proc = subprocess.run([venv_python, '-m', 'pip', 'freeze'],
check=True, stdout=subprocess.PIPE)
reqs = proc.stdout.decode('utf-8')
- with open(filename, 'r', encoding='utf-8') as f:
- comments = read_comments(f)
-
with open(outfile, 'w', encoding='utf-8') as f:
f.write("# This file is automatically generated by "
"scripts/dev/recompile_requirements.py\n\n")
@@ -163,6 +185,11 @@ def main():
for line in comments['add']:
f.write(line + '\n')
+ # Test resulting file
+ utils.print_subtitle("Testing")
+ with tempfile.TemporaryDirectory() as tmpdir:
+ init_venv(host_python, tmpdir, outfile)
+
if __name__ == '__main__':
main()
diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py
index b82ede3e1..70df0ebe0 100755
--- a/scripts/dev/src2asciidoc.py
+++ b/scripts/dev/src2asciidoc.py
@@ -59,6 +59,11 @@ class UsageFormatter(argparse.HelpFormatter):
argparse.HelpFormatter while copying 99% of the code :-/
"""
+ def __init__(self, prog, indent_increment=2, max_help_position=24,
+ width=200):
+ """Override __init__ to set a fixed width as default."""
+ super().__init__(prog, indent_increment, max_help_position, width)
+
def _format_usage(self, usage, actions, groups, _prefix):
"""Override _format_usage to not add the 'usage:' prefix."""
return super()._format_usage(usage, actions, groups, '')
@@ -162,7 +167,7 @@ def _get_configtypes():
inspect.isclass(e) and
# pylint: disable=protected-access
e not in [configtypes.BaseType, configtypes.MappingType,
- configtypes._Numeric] and
+ configtypes._Numeric, configtypes.FontBase] and
# pylint: enable=protected-access
issubclass(e, configtypes.BaseType))
yield from inspect.getmembers(configtypes, predicate)
diff --git a/scripts/dev/update_version.py b/scripts/dev/update_version.py
index f6cc2c27b..13bd3d776 100644
--- a/scripts/dev/update_version.py
+++ b/scripts/dev/update_version.py
@@ -52,11 +52,15 @@ if __name__ == "__main__":
parser.add_argument('bump', action="store",
choices=["major", "minor", "patch"],
help="Update release version")
+ parser.add_argument('--commands', action="store_true",
+ help="Only show commands to run post-release.")
args = parser.parse_args()
utils.change_cwd()
- bump_version(args.bump)
- show_commit()
+
+ if not args.commands:
+ bump_version(args.bump)
+ show_commit()
import qutebrowser
version = qutebrowser.__version__
diff --git a/scripts/dictcli.py b/scripts/dictcli.py
index 3676506e1..ebe4e285c 100755
--- a/scripts/dictcli.py
+++ b/scripts/dictcli.py
@@ -38,6 +38,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from qutebrowser.browser.webengine import spell
from qutebrowser.config import configdata
from qutebrowser.utils import standarddir, utils
+from scripts import utils as scriptutils
API_URL = 'https://chromium.googlesource.com/chromium/deps/hunspell_dictionaries.git/+/master/'
@@ -219,7 +220,13 @@ def install(languages):
print('Installing {}: {}'.format(lang.code, lang.name))
install_lang(lang)
except PermissionError as e:
- sys.exit(str(e))
+ msg = ("\n{}\n\nWith Qt < 5.10, you will need to run this script "
+ "as root, as dictionaries need to be installed "
+ "system-wide. If your qutebrowser uses a newer Qt version "
+ "via a virtualenv, make sure you start this script with "
+ "the virtualenv's Python.".format(e))
+ scriptutils.print_error(msg)
+ sys.exit(1)
def update(languages):
diff --git a/scripts/keytester.py b/scripts/keytester.py
index b47e28d54..027dcbc59 100644
--- a/scripts/keytester.py
+++ b/scripts/keytester.py
@@ -23,34 +23,11 @@
Use python3 -m scripts.keytester to launch it.
"""
-from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QHBoxLayout
-
-from qutebrowser.keyinput import keyutils
-
-
-class KeyWidget(QWidget):
-
- """Widget displaying key presses."""
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._layout = QHBoxLayout(self)
- self._label = QLabel(text="Waiting for keypress...")
- self._layout.addWidget(self._label)
-
- def keyPressEvent(self, e):
- """Show pressed keys."""
- lines = [
- str(keyutils.KeyInfo.from_event(e)),
- '',
- 'key: 0x{:x}'.format(int(e.key())),
- 'modifiers: 0x{:x}'.format(int(e.modifiers())),
- 'text: {!r}'.format(e.text()),
- ]
- self._label.setText('\n'.join(lines))
+from PyQt5.QtWidgets import QApplication
+from qutebrowser.misc import miscwidgets
app = QApplication([])
-w = KeyWidget()
+w = miscwidgets.KeyTesterWidget()
w.show()
app.exec_()
diff --git a/scripts/mkvenv.py b/scripts/mkvenv.py
index 6dc52b82e..5eeb90640 100644
--- a/scripts/mkvenv.py
+++ b/scripts/mkvenv.py
@@ -52,9 +52,12 @@ def parse_args() -> argparse.Namespace:
default='auto',
help="PyQt version to install.")
parser.add_argument('--pyqt-type',
- choices=['binary', 'source', 'link', 'skip'],
+ choices=['binary', 'source', 'link', 'wheels', 'skip'],
default='binary',
help="How to install PyQt/Qt.")
+ parser.add_argument('--pyqt-wheels-dir',
+ default='wheels',
+ help="Directory to get PyQt wheels from.")
parser.add_argument('--virtualenv',
action='store_true',
help="Use virtualenv instead of venv.")
@@ -65,6 +68,9 @@ def parse_args() -> argparse.Namespace:
parser.add_argument('--dev',
action='store_true',
help="Also install dev/test dependencies.")
+ parser.add_argument('--skip-docs',
+ action='store_true',
+ help="Skip doc generation.")
parser.add_argument('--tox-error',
action='store_true',
help=argparse.SUPPRESS)
@@ -95,7 +101,7 @@ def run_venv(venv_dir: pathlib.Path, executable, *args: str) -> None:
subprocess.run([str(venv_dir / subdir / executable)] +
[str(arg) for arg in args], check=True)
except subprocess.CalledProcessError as e:
- utils.print_col("Subprocess failed, exiting", 'red')
+ utils.print_error("Subprocess failed, exiting")
sys.exit(e.returncode)
@@ -106,24 +112,6 @@ def pip_install(venv_dir: pathlib.Path, *args: str) -> None:
run_venv(venv_dir, 'python', '-m', 'pip', 'install', *args)
-def show_tox_error(pyqt_type: str) -> None:
- """DIsplay an error when invoked from tox."""
- if pyqt_type == 'link':
- env = 'mkvenv'
- args = ' --pyqt-type link'
- elif pyqt_type == 'binary':
- env = 'mkvenv-pypi'
- args = ''
- else:
- raise AssertionError
-
- print()
- utils.print_col('tox -e {} is deprecated. '
- 'Please use "python3 scripts/mkvenv.py{}" instead.'
- .format(env, args), 'red')
- print()
-
-
def delete_old_venv(venv_dir: pathlib.Path) -> None:
"""Remove an existing virtualenv directory."""
if not venv_dir.exists():
@@ -137,9 +125,8 @@ def delete_old_venv(venv_dir: pathlib.Path) -> None:
]
if not any(m.exists() for m in markers):
- utils.print_col('{} does not look like a virtualenv, '
- 'cowardly refusing to remove it.'.format(venv_dir),
- 'red')
+ utils.print_error('{} does not look like a virtualenv, '
+ 'cowardly refusing to remove it.'.format(venv_dir))
sys.exit(1)
utils.print_col('$ rm -r {}'.format(venv_dir), 'blue')
@@ -154,7 +141,7 @@ def create_venv(venv_dir: pathlib.Path, use_virtualenv: bool = False) -> None:
subprocess.run([sys.executable, '-m', 'virtualenv', venv_dir],
check=True)
except subprocess.CalledProcessError as e:
- utils.print_col("virtualenv failed, exiting", 'red')
+ utils.print_error("virtualenv failed, exiting")
sys.exit(e.returncode)
else:
utils.print_col('$ python3 -m venv {}'.format(venv_dir), 'blue')
@@ -207,6 +194,14 @@ def install_pyqt_link(venv_dir: pathlib.Path) -> None:
link_pyqt.link_pyqt(sys.executable, lib_path)
+def install_pyqt_wheels(venv_dir: pathlib.Path,
+ wheels_dir: pathlib.Path) -> None:
+ """Install PyQt from the wheels/ directory."""
+ utils.print_title("Installing PyQt wheels")
+ wheels = [str(wheel) for wheel in wheels_dir.glob('*.whl')]
+ pip_install(venv_dir, *wheels)
+
+
def install_requirements(venv_dir: pathlib.Path) -> None:
"""Install qutebrowser's requirement.txt."""
utils.print_title("Installing other qutebrowser dependencies")
@@ -247,14 +242,17 @@ def main() -> None:
"""Install qutebrowser in a virtualenv.."""
args = parse_args()
venv_dir = pathlib.Path(args.venv_dir)
+ wheels_dir = pathlib.Path(args.pyqt_wheels_dir)
utils.change_cwd()
- if args.tox_error:
- show_tox_error(args.pyqt_type)
+ if (args.pyqt_version != 'auto' and
+ args.pyqt_type not in ['binary', 'source']):
+ utils.print_error('The --pyqt-version option is only available when '
+ 'installing PyQt from binary or source')
sys.exit(1)
- elif args.pyqt_type == 'link' and args.pyqt_version != 'auto':
- utils.print_col('The --pyqt-version option is not available when '
- 'linking a system-wide install.', 'red')
+ elif args.pyqt_wheels_dir != 'wheels' and args.pyqt_type != 'wheels':
+ utils.print_error('The --pyqt-wheels-dir option is only available '
+ 'when installing PyQt from wheels')
sys.exit(1)
if not args.keep:
@@ -270,6 +268,8 @@ def main() -> None:
install_pyqt_source(venv_dir, args.pyqt_version)
elif args.pyqt_type == 'link':
install_pyqt_link(venv_dir)
+ elif args.pyqt_type == 'wheels':
+ install_pyqt_wheels(venv_dir, wheels_dir)
elif args.pyqt_type == 'skip':
pass
else:
@@ -280,7 +280,8 @@ def main() -> None:
if args.dev:
install_dev_requirements(venv_dir)
- regenerate_docs(venv_dir, args.asciidoc)
+ if not args.skip_docs:
+ regenerate_docs(venv_dir, args.asciidoc)
if __name__ == '__main__':
diff --git a/scripts/setupcommon.py b/scripts/setupcommon.py
index e07096546..65e2a498a 100644
--- a/scripts/setupcommon.py
+++ b/scripts/setupcommon.py
@@ -38,6 +38,14 @@ BASEDIR = os.path.join(os.path.dirname(os.path.realpath(__file__)),
os.path.pardir)
+def _call_git(gitpath, *args):
+ """Call a git subprocess."""
+ return subprocess.run(
+ ['git'] + list(args),
+ cwd=gitpath, check=True,
+ stdout=subprocess.PIPE).stdout.decode('UTF-8').strip()
+
+
def _git_str():
"""Try to find out git version.
@@ -51,15 +59,11 @@ def _git_str():
return None
try:
# https://stackoverflow.com/questions/21017300/21017394#21017394
- commit_hash = subprocess.run(
- ['git', 'describe', '--match=NeVeRmAtCh', '--always', '--dirty'],
- cwd=BASEDIR, check=True,
- stdout=subprocess.PIPE).stdout.decode('UTF-8').strip()
- date = subprocess.run(
- ['git', 'show', '-s', '--format=%ci', 'HEAD'],
- cwd=BASEDIR, check=True,
- stdout=subprocess.PIPE).stdout.decode('UTF-8').strip()
- return '{} ({})'.format(commit_hash, date)
+ commit_hash = _call_git(BASEDIR, 'describe', '--match=NeVeRmAtCh',
+ '--always', '--dirty')
+ date = _call_git(BASEDIR, 'show', '-s', '--format=%ci', 'HEAD')
+ branch = _call_git(BASEDIR, 'rev-parse', '--abbrev-ref', 'HEAD')
+ return '{} on {} ({})'.format(commit_hash, branch, date)
except (subprocess.CalledProcessError, OSError):
return None
diff --git a/scripts/utils.py b/scripts/utils.py
index 0d405c8a6..bdf3f96fc 100644
--- a/scripts/utils.py
+++ b/scripts/utils.py
@@ -21,6 +21,7 @@
import os
import os.path
+import sys
# Import side-effects are an evil thing, but here it's okay so scripts using
@@ -58,14 +59,18 @@ def _esc(code):
return '\033[{}m'.format(code)
-def print_col(text, color):
+def print_col(text, color, file=sys.stdout):
"""Print a colorized text."""
if use_color:
fg = _esc(fg_colors[color.lower()])
reset = _esc(fg_colors['reset'])
- print(''.join([fg, text, reset]))
+ print(''.join([fg, text, reset]), file=file)
else:
- print(text)
+ print(text, file=file)
+
+
+def print_error(text):
+ print_col(text, 'red', file=sys.stderr)
def print_title(text):
diff --git a/setup.py b/setup.py
index 6b2ea0acd..7741a71b7 100755
--- a/setup.py
+++ b/setup.py
@@ -97,6 +97,7 @@ try:
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
+ 'Programming Language :: Python :: 3.8',
'Topic :: Internet',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Internet :: WWW/HTTP :: Browsers',
diff --git a/tests/conftest.py b/tests/conftest.py
index 0990e2702..e698bde74 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -25,8 +25,6 @@ import os
import sys
import warnings
import pathlib
-import ctypes
-import ctypes.util
import pytest
import hypothesis
@@ -110,7 +108,7 @@ def _apply_platform_markers(config, item):
"https://bugreports.qt.io/browse/QTBUG-60673"),
('qtwebkit6021_xfail',
pytest.mark.xfail,
- version.qWebKitVersion and # type: ignore
+ version.qWebKitVersion and # type: ignore[unreachable]
version.qWebKitVersion() == '602.1',
"Broken on WebKit 602.1")
]
@@ -255,12 +253,10 @@ def set_backend(monkeypatch, request):
monkeypatch.setattr(objects, 'backend', backend)
-@pytest.fixture(autouse=True)
+@pytest.fixture(autouse=True, scope='session')
def apply_libgl_workaround():
"""Make sure we load libGL early so QtWebEngine tests run properly."""
- libgl = ctypes.util.find_library("GL")
- if libgl is not None:
- ctypes.CDLL(libgl, mode=ctypes.RTLD_GLOBAL)
+ utils.libgl_workaround()
@pytest.fixture(autouse=True)
diff --git a/tests/end2end/conftest.py b/tests/end2end/conftest.py
index 273d8170c..f87b84a56 100644
--- a/tests/end2end/conftest.py
+++ b/tests/end2end/conftest.py
@@ -137,6 +137,11 @@ if not getattr(sys, 'frozen', False):
def pytest_collection_modifyitems(config, items):
"""Apply @qtwebengine_* markers; skip unittests with QUTE_BDD_WEBENGINE."""
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-75884
+ # (note this isn't actually fixed properly before Qt 5.15)
+ header_bug_fixed = (not qtutils.version_check('5.12', compiled=False) or
+ qtutils.version_check('5.15', compiled=False))
+
markers = [
('qtwebengine_todo', 'QtWebEngine TODO', pytest.mark.xfail,
config.webengine),
@@ -152,6 +157,9 @@ def pytest_collection_modifyitems(config, items):
config.webengine),
('qtwebengine_mac_xfail', 'Fails on macOS with QtWebEngine',
pytest.mark.xfail, config.webengine and utils.is_mac),
+ ('js_headers', 'Sets headers dynamically via JS',
+ pytest.mark.skipif,
+ config.webengine and not header_bug_fixed),
]
for item in items:
diff --git a/tests/end2end/features/caret.feature b/tests/end2end/features/caret.feature
index e540bafcb..ec45efaea 100644
--- a/tests/end2end/features/caret.feature
+++ b/tests/end2end/features/caret.feature
@@ -5,7 +5,8 @@ Feature: Caret mode
Background:
Given I open data/caret.html
- And I run :tab-only ;; enter-mode caret
+ And I run :tab-only
+ And I also run :enter-mode caret
# :yank selection
diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py
index f9df23ac9..c1e7e32ae 100644
--- a/tests/end2end/features/conftest.py
+++ b/tests/end2end/features/conftest.py
@@ -29,6 +29,7 @@ import logging
import collections
import textwrap
import subprocess
+import shutil
import pytest
import pytest_bdd as bdd
@@ -49,7 +50,7 @@ def _get_echo_exe_path():
return os.path.join(testutils.abs_datapath(), 'userscripts',
'echo.bat')
else:
- return 'echo'
+ return shutil.which("echo")
@pytest.hookimpl(hookwrapper=True)
@@ -151,6 +152,17 @@ def run_command_given(quteproc, command):
quteproc.send_cmd(command)
+@bdd.given(bdd.parsers.parse("I also run {command}"))
+def run_command_given_2(quteproc, command):
+ """Run a qutebrowser command.
+
+ Separate from the above as a hack to run two commands in a Background
+ without having to use ";;". This is needed because pytest-bdd doesn't allow
+ re-using a Given step...
+ """
+ quteproc.send_cmd(command)
+
+
@bdd.given("I have a fresh instance")
def fresh_instance(quteproc):
"""Restart qutebrowser instance for tests needing a fresh state."""
diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature
index 190e95f79..d0563a77b 100644
--- a/tests/end2end/features/hints.feature
+++ b/tests/end2end/features/hints.feature
@@ -323,8 +323,8 @@ Feature: Using hints
And I wait until data/hello.txt is loaded
And I press the key ","
# Waiting here so we don't affect the next test
- And I wait for "Releasing inhibition state of normal mode." in the log
- Then "Ignoring key ',', because the normal mode is currently inhibited." should be logged
+ And I wait for "NormalKeyParser for mode normal: Releasing inhibition state of normal mode." in the log
+ Then "NormalKeyParser for mode normal: Ignoring key ',', because the normal mode is currently inhibited." should be logged
Scenario: Turning off auto_follow_timeout
When I set hints.auto_follow_timeout to 0
diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature
index 7a26c9dda..cba16bb38 100644
--- a/tests/end2end/features/misc.feature
+++ b/tests/end2end/features/misc.feature
@@ -349,18 +349,24 @@ Feature: Various utility commands.
# This still doesn't set window.navigator.language
# See https://bugreports.qt.io/browse/QTBUG-61949
- @qtwebkit_skip
+ @qtwebkit_skip @js_headers
Scenario: Accept-Language header (JS)
When I set content.headers.accept_language to it,fr
And I run :jseval console.log(window.navigator.languages)
Then the javascript message "it,fr" should be logged
- Scenario: Setting a custom user-agent header
+ Scenario: User-agent header
When I set content.headers.user_agent to toaster
And I open headers
And I run :jseval console.log(window.navigator.userAgent)
Then the header User-Agent should be set to toaster
- And the javascript message "toaster" should be logged
+
+ @js_headers
+ Scenario: User-agent header (JS)
+ When I set content.headers.user_agent to toaster
+ And I open about:blank
+ And I run :jseval console.log(window.navigator.userAgent)
+ Then the javascript message "toaster" should be logged
## https://github.com/qutebrowser/qutebrowser/issues/1523
diff --git a/tests/end2end/features/private.feature b/tests/end2end/features/private.feature
index 35097f545..6ea9e7b33 100644
--- a/tests/end2end/features/private.feature
+++ b/tests/end2end/features/private.feature
@@ -153,7 +153,7 @@ Feature: Using private browsing
- url: http://localhost:*/data/numbers/1.txt
- url: http://localhost:*/data/numbers/2.txt
-
+ @flaky
Scenario: Saving a private session with only-active-window
When I open data/numbers/1.txt
And I open data/numbers/2.txt in a new tab
diff --git a/tests/end2end/features/qutescheme.feature b/tests/end2end/features/qutescheme.feature
index 35c110dc5..2325912c5 100644
--- a/tests/end2end/features/qutescheme.feature
+++ b/tests/end2end/features/qutescheme.feature
@@ -258,20 +258,37 @@ Feature: Special qute:// pages
And the page should contain the plaintext "the-warning-message"
And the page should contain the plaintext "the-info-message"
+ Scenario: Showing messages of category 'message'
+ When I run :message-info the-info-message
+ And I run :messages -f message
+ Then qute://log/?level=info&logfilter=message should be loaded
+ And the page should contain the plaintext "the-info-message"
+
+ Scenario: Showing messages of category 'misc'
+ When I run :message-info the-info-message
+ And I run :messages -f misc
+ Then qute://log/?level=info&logfilter=misc should be loaded
+ And the page should not contain the plaintext "the-info-message"
+
@qtwebengine_flaky
Scenario: Showing messages of an invalid level
When I run :messages cataclysmic
Then the error "Invalid log level cataclysmic!" should be shown
+ Scenario: Showing messages with an invalid category
+ When I run :messages -f invalid
+ Then the error "Invalid log category invalid - *" should be shown
+
Scenario: Using qute://log directly
When I open qute://log without waiting
# With Qt 5.9, we don't get a loaded message?
And I wait for "Changing title for idx * to 'log'" in the log
Then no crash should happen
- Scenario: Using qute://plainlog directly
- When I open qute://plainlog
- Then no crash should happen
+ # FIXME More possible tests:
+ # :message --plain
+ # Using qute://log directly with invalid category
+ # same with invalid level
# :version
diff --git a/tests/end2end/features/search.feature b/tests/end2end/features/search.feature
index 2c0aa06a5..de815a5b7 100644
--- a/tests/end2end/features/search.feature
+++ b/tests/end2end/features/search.feature
@@ -213,6 +213,56 @@ Feature: Searching on a page
# TODO: wrapping message with scrolling
# TODO: wrapping message without scrolling
+ ## wrapping prevented
+
+ @qtwebkit_skip @qt>=5.14
+ Scenario: Preventing wrapping at the top of the page with QtWebEngine
+ When I set search.ignore_case to always
+ And I set search.wrap to false
+ And I run :search --reverse foo
+ And I wait for "search found foo with flags FindBackward" in the log
+ And I run :search-next
+ And I wait for "next_result found foo with flags FindBackward" in the log
+ And I run :search-next
+ And I wait for "Search hit TOP" in the log
+ Then "foo" should be found
+
+ @qtwebkit_skip @qt>=5.14
+ Scenario: Preventing wrapping at the bottom of the page with QtWebEngine
+ When I set search.ignore_case to always
+ And I set search.wrap to false
+ And I run :search foo
+ And I wait for "search found foo" in the log
+ And I run :search-next
+ And I wait for "next_result found foo" in the log
+ And I run :search-next
+ And I wait for "Search hit BOTTOM" in the log
+ Then "Foo" should be found
+
+ @qtwebengine_skip
+ Scenario: Preventing wrapping at the top of the page with QtWebKit
+ When I set search.ignore_case to always
+ And I set search.wrap to false
+ And I run :search --reverse foo
+ And I wait for "search found foo with flags FindBackward" in the log
+ And I run :search-next
+ And I wait for "next_result found foo with flags FindBackward" in the log
+ And I run :search-next
+ And I wait for "next_result didn't find foo with flags FindBackward" in the log
+ Then the warning "Text 'foo' not found on page!" should be shown
+
+ @qtwebengine_skip
+ Scenario: Preventing wrapping at the bottom of the page with QtWebKit
+ When I set search.ignore_case to always
+ And I set search.wrap to false
+ And I run :search foo
+ And I wait for "search found foo" in the log
+ And I run :search-next
+ And I wait for "next_result found foo" in the log
+ And I run :search-next
+ And I wait for "next_result didn't find foo" in the log
+ Then the warning "Text 'foo' not found on page!" should be shown
+
## follow searched links
@skip # Too flaky
Scenario: Follow a searched link
diff --git a/tests/end2end/features/sessions.feature b/tests/end2end/features/sessions.feature
index 494feb0ba..69c58f3c3 100644
--- a/tests/end2end/features/sessions.feature
+++ b/tests/end2end/features/sessions.feature
@@ -282,6 +282,7 @@ Feature: Saving and loading sessions
Then "Saved session quiet_session." should be logged with level debug
And the session quiet_session should exist
+ @flaky
Scenario: Saving session with --only-active-window
When I open data/numbers/1.txt
And I open data/numbers/2.txt in a new tab
diff --git a/tests/end2end/features/spawn.feature b/tests/end2end/features/spawn.feature
index 5fabb044d..623bf4959 100644
--- a/tests/end2end/features/spawn.feature
+++ b/tests/end2end/features/spawn.feature
@@ -38,6 +38,14 @@ Feature: :spawn
And I run :spawn (echo-exe) {url:pretty}
Then "Executing * with args ['http://localhost:(port)/data/title with spaces.html'], userscript=False" should be logged
+ Scenario: Running :spawn with -m
+ When I run :spawn -m (echo-exe) Message 1
+ Then the message "Message 1" should be shown
+
+ Scenario: Running :spawn with -u -m
+ When I run :spawn -u -m (echo-exe) Message 2
+ Then the message "Message 2" should be shown
+
@posix
Scenario: Running :spawn with userscript
When I open data/hello.txt
diff --git a/tests/end2end/features/test_editor_bdd.py b/tests/end2end/features/test_editor_bdd.py
index edc4a9927..36719324a 100644
--- a/tests/end2end/features/test_editor_bdd.py
+++ b/tests/end2end/features/test_editor_bdd.py
@@ -98,6 +98,9 @@ class EditorPidWatcher(QObject):
else:
self._watcher.addPath(str(self._pidfile))
+ def manual_check(self):
+ return self._pidfile.check()
+
@pytest.fixture
def editor_pid_watcher(tmpdir):
@@ -143,9 +146,12 @@ def set_up_editor_wait(quteproc, tmpdir, text, editor_pid_watcher):
@bdd.when("I wait until the editor has started")
def wait_editor(qtbot, editor_pid_watcher):
if not editor_pid_watcher.has_pidfile:
- with qtbot.wait_signal(editor_pid_watcher.appeared, timeout=5000):
+ with qtbot.wait_signal(editor_pid_watcher.appeared, raising=False):
pass
+ if not editor_pid_watcher.manual_check():
+ pytest.fail("Editor pidfile failed to appear!")
+
@bdd.when(bdd.parsers.parse('I kill the waiting editor'))
def kill_editor_wait(tmpdir):
diff --git a/tests/end2end/features/test_open_bdd.py b/tests/end2end/features/test_open_bdd.py
index b1c9714bb..40d11e9a7 100644
--- a/tests/end2end/features/test_open_bdd.py
+++ b/tests/end2end/features/test_open_bdd.py
@@ -19,16 +19,27 @@
import logging
+import pytest
import pytest_bdd as bdd
bdd.scenarios('open.feature')
-def test_open_s(quteproc, ssl_server):
+@pytest.mark.parametrize('scheme', ['http://', ''])
+def test_open_s(request, quteproc, ssl_server, scheme):
"""Test :open with -s."""
quteproc.set_setting('content.ssl_strict', 'false')
- quteproc.send_cmd(':open -s http://localhost:{}/'.format(ssl_server.port))
- quteproc.mark_expected(category='message',
- loglevel=logging.ERROR,
- message="Certificate error: *")
+ quteproc.send_cmd(':open -s {}localhost:{}/'
+ .format(scheme, ssl_server.port))
+ if scheme == 'http://' or not request.config.webengine:
+ # Error is only logged on the first error with QtWebEngine
+ quteproc.mark_expected(category='message',
+ loglevel=logging.ERROR,
+ message="Certificate error: *")
quteproc.wait_for_load_finished('/', port=ssl_server.port, https=True,
load_status='warn')
+
+
+def test_open_s_non_http(quteproc, ssl_server):
+ """Test :open with -s and a qute:// page."""
+ quteproc.send_cmd(':open -s qute://version')
+ quteproc.wait_for_load_finished('qute://version')
diff --git a/tests/end2end/features/test_prompts_bdd.py b/tests/end2end/features/test_prompts_bdd.py
index e7602c5b4..98f2febbb 100644
--- a/tests/end2end/features/test_prompts_bdd.py
+++ b/tests/end2end/features/test_prompts_bdd.py
@@ -17,6 +17,8 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+import logging
+
import pytest_bdd as bdd
bdd.scenarios('prompts.feature')
@@ -71,18 +73,17 @@ def ssl_error_page(request, quteproc):
assert "Unable to load page" in content
-class AbstractCertificateErrorWrapper:
-
- """A wrapper over an SSL/certificate error."""
-
- def __init__(self, error):
- self._error = error
-
- def __str__(self):
- raise NotImplementedError
-
- def __repr__(self):
- raise NotImplementedError
-
- def is_overridable(self):
- raise NotImplementedError
+def test_certificate_error_load_status(request, quteproc, ssl_server):
+ """If we load the same page twice, we should get a 'warn' status twice."""
+ quteproc.set_setting('content.ssl_strict', 'false')
+
+ for i in range(2):
+ quteproc.open_path('/', port=ssl_server.port, https=True, wait=False,
+ new_tab=True)
+ if i == 0 or not request.config.webengine:
+ # Error is only logged on the first error with QtWebEngine
+ quteproc.mark_expected(category='message',
+ loglevel=logging.ERROR,
+ message="Certificate error: *")
+ quteproc.wait_for_load_finished('/', port=ssl_server.port, https=True,
+ load_status='warn')
diff --git a/tests/end2end/features/utilcmds.feature b/tests/end2end/features/utilcmds.feature
index 9b4eb5760..94db7c403 100644
--- a/tests/end2end/features/utilcmds.feature
+++ b/tests/end2end/features/utilcmds.feature
@@ -166,13 +166,9 @@ Feature: Miscellaneous utility commands exposed to the user.
# Other :debug-log-{level,filter} features are tested in
# unit/utils/test_log.py as using them would break end2end tests.
- Scenario: Using debug-log-level with invalid level
- When I run :debug-log-level hello
- Then the error "level: Invalid value hello - expected one of: vdebug, debug, info, warning, error, critical" should be shown
-
Scenario: Using debug-log-filter with invalid filter
When I run :debug-log-filter blah
- Then the error "filters: Invalid value blah - expected one of: statusbar, *" should be shown
+ Then the error "Invalid log category blah - valid categories: statusbar, *" should be shown
Scenario: Using debug-log-filter
When I run :debug-log-filter commands,ipc,webview
diff --git a/tests/end2end/features/yankpaste.feature b/tests/end2end/features/yankpaste.feature
index 46ef92a9c..a3264afa9 100644
--- a/tests/end2end/features/yankpaste.feature
+++ b/tests/end2end/features/yankpaste.feature
@@ -41,14 +41,6 @@ Feature: Yanking and pasting.
Then the message "Yanked title to clipboard: Test title" should be shown
And the clipboard should contain "Test title"
- Scenario: Yanking markdown URL to clipboard
- When I open data/title.html
- And I wait for regex "Changing title for idx \d to 'Test title'" in the log
- And I run :yank markdown
- Then the warning ":yank markdown is deprecated, *" should be shown
- And the message "Yanked markdown URL to clipboard: *" should be shown
- And the clipboard should contain "[Test title](http://localhost:(port)/data/title.html)"
-
Scenario: Yanking inline to clipboard
When I open data/title.html
And I run :yank inline '[[{url}][qutebrowser</3org]]'
@@ -194,10 +186,10 @@ Feature: Yanking and pasting.
http://qutebrowser.org
should not open
And I run :open -t {clipboard}
- And I wait until data/hello.txt?q=this%20url%3A%0Ahttp%3A%2F%2Fqutebrowser.org%0Ashould%20not%20open is loaded
+ And I wait until data/hello.txt?q=this%20url%3A%0Ahttp%3A//qutebrowser.org%0Ashould%20not%20open is loaded
Then the following tabs should be open:
- about:blank
- - data/hello.txt?q=this%20url%3A%0Ahttp%3A%2F%2Fqutebrowser.org%0Ashould%20not%20open (active)
+ - data/hello.txt?q=this%20url%3A%0Ahttp%3A//qutebrowser.org%0Ashould%20not%20open (active)
Scenario: Pasting multiline whose first line looks like a URI
When I set url.auto_search to naive
@@ -315,6 +307,7 @@ Feature: Yanking and pasting.
And I run :insert-text This text should be undone
And I wait for the javascript message "textarea contents: This text should be undone"
And I press the key "<Ctrl+z>"
+ And I wait for the javascript message "textarea contents: "
# Paste final text
And I run :insert-text This text should stay
# Compare
diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py
index 6222f3a6a..2e47c9e43 100644
--- a/tests/end2end/fixtures/quteprocess.py
+++ b/tests/end2end/fixtures/quteprocess.py
@@ -279,6 +279,29 @@ def is_ignored_chromium_message(line):
# https://bugreports.qt.io/browse/QTBUG-78319
'temp file failure: * : could not create temporary file: No such file '
'or directory (2)',
+
+ # Travis
+ # test_ssl_error_with_contentssl_strict__true
+ # [5306:5324:0417/151739.362362:ERROR:address_tracker_linux.cc(171)]
+ # Could not bind NETLINK socket: Address already in use (98)
+ 'Could not bind NETLINK socket: Address already in use (98)',
+
+ # Qt 5.15 with AppVeyor
+ # [2968:3108:0601/123442.125:ERROR:mf_helpers.cc(14)] Error in
+ # dxva_video_decode_accelerator_win.cc on line 517
+ 'Error in dxva_video_decode_accelerator_win.cc on line 517',
+
+ # Qt 5.15 and debug build
+ # [134188:134199:0609/132454.797229:WARNING:
+ # simple_synchronous_entry.cc(1389)]
+ # Could not open platform files for entry.
+ # [134151:134187:0609/132456.754321:ERROR:process_posix.cc(333)]
+ # Unable to terminate process 134188: No such process (3)
+ # [134151:134187:0609/132456.754414:WARNING:internal_linux.cc(64)]
+ # Failed to read /proc/134188/stat
+ 'Could not open platform files for entry.',
+ 'Unable to terminate process *: No such process (3)',
+ 'Failed to read /proc/*/stat',
]
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 3c19b86ef..08f9754db 100644
--- a/tests/end2end/fixtures/testprocess.py
+++ b/tests/end2end/fixtures/testprocess.py
@@ -74,7 +74,10 @@ def _render_log(data, *, verbose, threshold=100):
data = [str(d) for d in data]
is_exception = any('Traceback (most recent call last):' in line or
'Uncaught exception' in line for line in data)
- if len(data) > threshold and not verbose and not is_exception:
+ if (len(data) > threshold and
+ not verbose and
+ not is_exception and
+ not utils.ON_CI):
msg = '[{} lines suppressed, use -v to show]'.format(
len(data) - threshold)
data = [msg] + data[-threshold:]
diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py
index 54ea441de..839355664 100644
--- a/tests/end2end/fixtures/webserver.py
+++ b/tests/end2end/fixtures/webserver.py
@@ -63,7 +63,8 @@ class Request(testprocess.Line):
def _check_status(self):
"""Check if the http status is what we expected."""
path_to_statuses = {
- '/favicon.ico': [HTTPStatus.NOT_FOUND],
+ '/favicon.ico': [HTTPStatus.OK, HTTPStatus.PARTIAL_CONTENT],
+
'/does-not-exist': [HTTPStatus.NOT_FOUND],
'/does-not-exist-2': [HTTPStatus.NOT_FOUND],
'/404': [HTTPStatus.NOT_FOUND],
diff --git a/tests/end2end/fixtures/webserver_sub.py b/tests/end2end/fixtures/webserver_sub.py
index e34d4c295..9902ab125 100644
--- a/tests/end2end/fixtures/webserver_sub.py
+++ b/tests/end2end/fixtures/webserver_sub.py
@@ -272,6 +272,15 @@ def view_user_agent():
return flask.jsonify({'user-agent': flask.request.headers['user-agent']})
+@app.route('/favicon.ico')
+def favicon():
+ basedir = os.path.join(os.path.realpath(os.path.dirname(__file__)),
+ '..', '..', '..')
+ return flask.send_from_directory(os.path.join(basedir, 'icons'),
+ 'qutebrowser.ico',
+ mimetype='image/vnd.microsoft.icon')
+
+
@app.after_request
def log_request(response):
"""Log a webserver request."""
diff --git a/tests/end2end/fixtures/webserver_sub_ssl.py b/tests/end2end/fixtures/webserver_sub_ssl.py
index 7cd6dc92c..d3869201f 100644
--- a/tests/end2end/fixtures/webserver_sub_ssl.py
+++ b/tests/end2end/fixtures/webserver_sub_ssl.py
@@ -40,6 +40,11 @@ def hello_world():
return "Hello World via SSL!"
+@app.route('/favicon.ico')
+def favicon():
+ return webserver_sub.favicon()
+
+
@app.after_request
def log_request(response):
return webserver_sub.log_request(response)
diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py
index d61458ef3..b4a343a37 100644
--- a/tests/end2end/test_invocations.py
+++ b/tests/end2end/test_invocations.py
@@ -381,6 +381,9 @@ def test_qute_settings_persistence(short_tmpdir, request, quteproc_new):
quteproc_new.send_cmd(':jseval --world main '
'cset("search.ignore_case", "always")')
quteproc_new.wait_for(message='No output or error')
+ quteproc_new.wait_for(category='config',
+ message='Config option changed: '
+ 'search.ignore_case = always')
assert quteproc_new.get_setting('search.ignore_case') == 'always'
diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py
index 3bb2ad3e5..60a4f02ba 100644
--- a/tests/helpers/fixtures.py
+++ b/tests/helpers/fixtures.py
@@ -45,11 +45,12 @@ import helpers.stubs as stubsmod
from qutebrowser.config import (config, configdata, configtypes, configexc,
configfiles, configcache, stylesheet)
from qutebrowser.api import config as configapi
-from qutebrowser.utils import objreg, standarddir, utils, usertypes
+from qutebrowser.utils import objreg, standarddir, utils, usertypes, qtutils
from qutebrowser.browser import greasemonkey, history, qutescheme
from qutebrowser.browser.webkit import cookies, cache
from qutebrowser.misc import savemanager, sql, objects, sessions
from qutebrowser.keyinput import modeman
+from qutebrowser.qt import sip
_qute_scheme_handler = None
@@ -64,14 +65,17 @@ class WidgetContainer(QWidget):
self._qtbot = qtbot
self.vbox = QVBoxLayout(self)
qtbot.add_widget(self)
+ self._widget = None
def set_widget(self, widget):
self.vbox.addWidget(widget)
widget.container = self
+ self._widget = widget
def expose(self):
with self._qtbot.waitExposed(self):
self.show()
+ self._widget.setFocus()
@pytest.fixture
@@ -204,19 +208,23 @@ def web_tab_setup(qtbot, tab_registry, session_manager_stub,
@pytest.fixture
def webkit_tab(web_tab_setup, qtbot, cookiejar_and_cache, mode_manager,
- widget_container, webpage):
+ widget_container, download_stub, webpage):
webkittab = pytest.importorskip('qutebrowser.browser.webkit.webkittab')
tab = webkittab.WebKitTab(win_id=0, mode_manager=mode_manager,
private=False)
widget_container.set_widget(tab)
- return tab
+ yield tab
+
+ # Make sure the tab shuts itself down properly
+ tab.private_api.shutdown()
@pytest.fixture
def webengine_tab(web_tab_setup, qtbot, redirect_webengine_data,
- tabbed_browser_stubs, mode_manager, widget_container):
+ tabbed_browser_stubs, mode_manager, widget_container,
+ monkeypatch):
tabwidget = tabbed_browser_stubs[0].widget
tabwidget.current_index = 0
tabwidget.index_of = 0
@@ -227,18 +235,34 @@ def webengine_tab(web_tab_setup, qtbot, redirect_webengine_data,
tab = webenginetab.WebEngineTab(win_id=0, mode_manager=mode_manager,
private=False)
widget_container.set_widget(tab)
+
yield tab
+
# If a page is still loading here, _on_load_finished could get called
# during teardown when session_manager_stub is already deleted.
tab.stop()
+ # Make sure the tab shuts itself down properly
+ tab.private_api.shutdown()
+
+ # If we wait for the GC to clean things up, there's a segfault inside
+ # QtWebEngine sometimes (e.g. if we only run
+ # tests/unit/browser/test_caret.py).
+ # However, with Qt < 5.12, doing this here will lead to an immediate
+ # segfault...
+ monkeypatch.undo() # version_check could be patched
+ if qtutils.version_check('5.12'):
+ sip.delete(tab._widget)
+
@pytest.fixture(params=['webkit', 'webengine'])
def web_tab(request):
"""A WebKitTab/WebEngineTab."""
if request.param == 'webkit':
+ pytest.importorskip('qutebrowser.browser.webkit.webkittab')
return request.getfixturevalue('webkit_tab')
elif request.param == 'webengine':
+ pytest.importorskip('qutebrowser.browser.webengine.webenginetab')
return request.getfixturevalue('webengine_tab')
else:
raise utils.Unreachable
@@ -313,7 +337,7 @@ def config_stub(stubs, monkeypatch, configdata_init, yaml_config_stub, qapp):
monkeypatch.setattr(config, 'cache', cache)
try:
- configtypes.Font.set_defaults(None, '10pt')
+ configtypes.FontBase.set_defaults(None, '10pt')
except configexc.NoOptionError:
# Completion tests patch configdata so fonts.default_family is
# unavailable.
diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py
index ff6690da5..bc8044461 100644
--- a/tests/helpers/stubs.py
+++ b/tests/helpers/stubs.py
@@ -115,8 +115,8 @@ class FakeQApplication:
UNSET = object()
- def __init__(self, style=None, all_widgets=None, active_window=None,
- instance=UNSET, arguments=None):
+ def __init__(self, *, style=None, all_widgets=None, active_window=None,
+ instance=UNSET, arguments=None, platform_name=None):
if instance is self.UNSET:
self.instance = mock.Mock(return_value=self)
@@ -129,6 +129,7 @@ class FakeQApplication:
self.allWidgets = lambda: all_widgets
self.activeWindow = lambda: active_window
self.arguments = lambda: arguments
+ self.platformName = lambda: platform_name
class FakeNetworkReply:
@@ -256,7 +257,7 @@ class FakeWebTab(browsertab.AbstractTab):
scroll_pos_perc=(0, 0),
load_status=usertypes.LoadStatus.success,
progress=0, can_go_back=None, can_go_forward=None):
- super().__init__(win_id=0, private=False)
+ super().__init__(win_id=0, mode_manager=None, private=False)
self._load_status = load_status
self._title = title
self._url = url
@@ -614,6 +615,10 @@ class FakeDownloadManager:
self.downloads.append(download_item)
return download_item
+ def has_downloads_with_nam(self, _nam):
+ """Needed during WebView.shutdown()."""
+ return False
+
class FakeHistoryProgress:
@@ -651,3 +656,21 @@ class FakeHintManager:
def handle_partial_key(self, keystr):
self.keystr = keystr
+
+ def current_mode(self):
+ return 'letter'
+
+
+class FakeWebEngineProfile:
+
+ def __init__(self, cookie_store):
+ self.cookieStore = lambda: cookie_store
+
+
+class FakeCookieStore:
+
+ def __init__(self, has_cookie_filter):
+ self.cookie_filter = None
+ if has_cookie_filter:
+ self.setCookieFilter = (
+ lambda func: setattr(self, 'cookie_filter', func)) # noqa
diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py
index 1d3f0bfac..dd30d9921 100644
--- a/tests/helpers/utils.py
+++ b/tests/helpers/utils.py
@@ -25,6 +25,7 @@ import gzip
import pprint
import os.path
import contextlib
+import pathlib
import pytest
@@ -187,6 +188,17 @@ def nop_contextmanager():
@contextlib.contextmanager
+def change_cwd(path):
+ """Use a path as current working directory."""
+ old_cwd = pathlib.Path.cwd()
+ os.chdir(str(path))
+ try:
+ yield
+ finally:
+ os.chdir(str(old_cwd))
+
+
+@contextlib.contextmanager
def ignore_bs4_warning():
"""WORKAROUND for https://bugs.launchpad.net/beautifulsoup/+bug/1847592."""
with log.ignore_py_warnings(
diff --git a/tests/manual/mouse.html b/tests/manual/mouse.html
index eb75df44d..d1f0f7dee 100644
--- a/tests/manual/mouse.html
+++ b/tests/manual/mouse.html
@@ -10,7 +10,8 @@
<li>When clicking the link with shift, <code>tabs.background</code> should be reversed accordingly.</li>
<li>Ctrl + Mousewheel should zoom in/out</li>
<li>Back/forward keys on mouse should navigate back/forward</li>
- <li>With <code>input.rocker_gestures</code> set, no context menu should be shown, but pressing left+right/right+left buttons should navigate back/forward</li>
- <li>When setting <code>input.rocker_gestures</code> dynamically, the context menu should be hidden/shown accordingly.</li>
+ <li>If <code>input.mouse.back_forward_buttons</code> is set to <code>false</code>, those buttons should not have any effect</li>
+ <li>With <code>input.mouse.rocker_gestures</code> set, no context menu should be shown, but pressing left+right/right+left buttons should navigate back/forward</li>
+ <li>When setting <code>input.mouse.rocker_gestures</code> dynamically, the context menu should be hidden/shown accordingly.</li>
</body>
</html>
diff --git a/tests/unit/api/test_cmdutils.py b/tests/unit/api/test_cmdutils.py
index 1d2cd3f46..58643640c 100644
--- a/tests/unit/api/test_cmdutils.py
+++ b/tests/unit/api/test_cmdutils.py
@@ -140,9 +140,30 @@ class TestRegister:
@cmdutils.register()
def fun(*args):
"""Blah."""
+ assert args == ['one', 'two']
+
+ objects.commands['fun'].parser.parse_args(['one', 'two'])
+
+ def test_star_args_empty(self):
+ """Check handling of *args without any value."""
+ @cmdutils.register()
+ def fun(*args):
+ """Blah."""
+ assert not args
+
with pytest.raises(argparser.ArgumentParserError):
objects.commands['fun'].parser.parse_args([])
+ def test_star_args_type(self):
+ """Check handling of *args with a type.
+
+ This isn't implemented, so be sure we catch it.
+ """
+ with pytest.raises(AssertionError):
+ @cmdutils.register()
+ def fun(*args: int):
+ """Blah."""
+
def test_star_args_optional(self):
"""Check handling of *args withstar_args_optional."""
@cmdutils.register(star_args_optional=True)
diff --git a/tests/unit/browser/test_caret.py b/tests/unit/browser/test_caret.py
index 9b817c4ac..7d1325612 100644
--- a/tests/unit/browser/test_caret.py
+++ b/tests/unit/browser/test_caret.py
@@ -24,15 +24,19 @@ import textwrap
import pytest
from PyQt5.QtCore import QUrl
-from qutebrowser.utils import utils, qtutils, usertypes
+from qutebrowser.utils import usertypes
+from qutebrowser.browser import browsertab
@pytest.fixture
def caret(web_tab, qtbot, mode_manager):
- with qtbot.wait_signal(web_tab.load_finished):
+ web_tab.container.expose()
+
+ with qtbot.wait_signal(web_tab.load_finished, timeout=10000):
web_tab.load_url(QUrl('qute://testdata/data/caret.html'))
- mode_manager.enter(usertypes.KeyMode.caret)
+ with qtbot.wait_signal(web_tab.caret.selection_toggled):
+ mode_manager.enter(usertypes.KeyMode.caret)
return web_tab.caret
@@ -61,15 +65,21 @@ class Selection:
selection = selection.strip()
assert selection == expected
return
+ elif not selection and not expected:
+ return
self._qtbot.wait(50)
+ assert False, 'Failed to get selection!'
+
def check_multiline(self, expected, *, strip=False):
self.check(textwrap.dedent(expected).strip(), strip=strip)
- def toggle(self):
- with self._qtbot.wait_signal(self._caret.selection_toggled):
- self._caret.toggle_selection()
+ def toggle(self, *, line=False):
+ """Toggle the selection and return the new selection state."""
+ with self._qtbot.wait_signal(self._caret.selection_toggled) as blocker:
+ self._caret.toggle_selection(line=line)
+ return blocker.args[0]
@pytest.fixture
@@ -77,6 +87,18 @@ def selection(qtbot, caret):
return Selection(qtbot, caret)
+def test_toggle(caret, selection, qtbot):
+ """Make sure calling toggleSelection produces the correct callback values.
+
+ This also makes sure that the SelectionState enum in JS lines up with the
+ Python browsertab.SelectionState enum.
+ """
+ assert selection.toggle() == browsertab.SelectionState.normal
+ assert selection.toggle(line=True) == browsertab.SelectionState.line
+ assert selection.toggle() == browsertab.SelectionState.normal
+ assert selection.toggle() == browsertab.SelectionState.none
+
+
class TestDocument:
def test_selecting_entire_document(self, caret, selection):
@@ -287,17 +309,6 @@ def test_drop_selection(caret, selection):
class TestSearch:
- @pytest.fixture(autouse=True)
- def expose(self, web_tab):
- """Expose the web view if needed.
-
- With QtWebEngine 5.13 on macOS/Windows, searching fails (callback
- called with False) when the view isn't exposed.
- """
- if qtutils.version_check('5.13') and not utils.is_linux:
- web_tab.container.expose()
- web_tab.show()
-
# https://bugreports.qt.io/browse/QTBUG-60673
@pytest.mark.qtbug60673
@@ -340,15 +351,6 @@ class TestFollowSelected:
def toggle_js(self, request, config_stub):
config_stub.val.content.javascript.enabled = request.param
- @pytest.fixture(autouse=True)
- def expose(self, web_tab):
- """Expose the web view if needed.
-
- On QtWebKit, or Qt < 5.11 and > 5.12 on QtWebEngine, we need to
- show the tab for selections to work properly.
- """
- web_tab.container.expose()
-
def test_follow_selected_without_a_selection(self, qtbot, caret, selection, web_tab,
mode_manager):
caret.move_to_next_word() # Move cursor away from the link
@@ -405,3 +407,93 @@ class TestReverse:
caret.reverse_selection()
caret.move_to_start_of_line()
selection.check("one two three")
+
+
+class TestLineSelection:
+
+ def test_toggle(self, caret, selection):
+ selection.toggle(line=True)
+ selection.check("one two three")
+
+ def test_toggle_untoggle(self, caret, selection):
+ selection.toggle()
+ selection.check("")
+ selection.toggle(line=True)
+ selection.check("one two three")
+ selection.toggle()
+ selection.check("one two three")
+
+ def test_from_center(self, caret, selection):
+ caret.move_to_next_char(4)
+ selection.toggle(line=True)
+ selection.check("one two three")
+
+ def test_more_lines(self, caret, selection):
+ selection.toggle(line=True)
+ caret.move_to_next_line(2)
+ selection.check_multiline("""
+ one two three
+ eins zwei drei
+
+ four five six
+ """, strip=True)
+
+ def test_not_selecting_char(self, caret, selection):
+ selection.toggle(line=True)
+ caret.move_to_next_char()
+ selection.check("one two three")
+ caret.move_to_prev_char()
+ selection.check("one two three")
+
+ def test_selecting_prev_next_word(self, caret, selection):
+ selection.toggle(line=True)
+ caret.move_to_next_word()
+ selection.check("one two three")
+ caret.move_to_prev_word()
+ selection.check("one two three")
+
+ def test_selecting_end_word(self, caret, selection):
+ selection.toggle(line=True)
+ caret.move_to_end_of_word()
+ selection.check("one two three")
+
+ def test_selecting_prev_next_line(self, caret, selection):
+ selection.toggle(line=True)
+ caret.move_to_next_line()
+ selection.check_multiline("""
+ one two three
+ eins zwei drei
+ """, strip=True)
+ caret.move_to_prev_line()
+ selection.check("one two three")
+
+ def test_not_selecting_start_end_line(self, caret, selection):
+ selection.toggle(line=True)
+ caret.move_to_end_of_line()
+ selection.check("one two three")
+ caret.move_to_start_of_line()
+ selection.check("one two three")
+
+ def test_selecting_block(self, caret, selection):
+ selection.toggle(line=True)
+ caret.move_to_end_of_next_block()
+ selection.check_multiline("""
+ one two three
+ eins zwei drei
+ """, strip=True)
+
+ @pytest.mark.not_mac(
+ reason='https://github.com/qutebrowser/qutebrowser/issues/5459')
+ def test_selecting_start_end_document(self, caret, selection):
+ selection.toggle(line=True)
+ caret.move_to_end_of_document()
+ selection.check_multiline("""
+ one two three
+ eins zwei drei
+
+ four five six
+ vier fünf sechs
+ """, strip=True)
+
+ caret.move_to_start_of_document()
+ selection.check("one two three")
diff --git a/tests/unit/browser/test_pdfjs.py b/tests/unit/browser/test_pdfjs.py
index e95f665c7..d05ff1fc0 100644
--- a/tests/unit/browser/test_pdfjs.py
+++ b/tests/unit/browser/test_pdfjs.py
@@ -52,6 +52,15 @@ def test_generate_pdfjs_page(available, snippet, monkeypatch):
assert snippet in content
+def test_broken_installation(data_tmpdir, monkeypatch):
+ """Make sure we don't crash with a broken local installation."""
+ monkeypatch.setattr(pdfjs, '_SYSTEM_PATHS', [])
+ (data_tmpdir / 'pdfjs' / 'pdf.js').ensure() # But no viewer.html
+
+ content = pdfjs.generate_pdfjs_page('example.pdf', QUrl())
+ assert '<h1>No pdf.js installation found</h1>' in content
+
+
# Note that we got double protection, once because we use QUrl.FullyEncoded and
# because we use qutebrowser.utils.javascript.to_js. Characters like " are
# already replaced by QUrl.
diff --git a/tests/unit/browser/test_qutescheme.py b/tests/unit/browser/test_qutescheme.py
index 8f2e06148..d4a87c4f9 100644
--- a/tests/unit/browser/test_qutescheme.py
+++ b/tests/unit/browser/test_qutescheme.py
@@ -136,7 +136,7 @@ class TestHistoryHandler:
assert items
def test_qute_history_benchmark(self, web_history, benchmark, now):
- r = range(100000)
+ r = range(20000)
entries = {
'atime': [int(now - t) for t in r],
'url': ['www.x.com/{}'.format(t) for t in r],
diff --git a/tests/unit/browser/test_shared.py b/tests/unit/browser/test_shared.py
index 6e03ca6e9..8bd7a979c 100644
--- a/tests/unit/browser/test_shared.py
+++ b/tests/unit/browser/test_shared.py
@@ -19,8 +19,6 @@
import pytest
-from PyQt5.QtCore import QUrl
-
from qutebrowser.browser import shared
@@ -46,4 +44,4 @@ def test_custom_headers(config_stub, dnt, accept_language, custom_headers,
headers.custom = custom_headers
expected_items = sorted(expected.items())
- assert shared.custom_headers(QUrl()) == expected_items
+ assert shared.custom_headers(url=None) == expected_items
diff --git a/tests/unit/browser/urlmarks.py b/tests/unit/browser/test_urlmarks.py
index b795d5b10..ac86dd338 100644
--- a/tests/unit/browser/urlmarks.py
+++ b/tests/unit/browser/test_urlmarks.py
@@ -17,7 +17,9 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-"""Tests for the global page history."""
+"""Tests for bookmarks/quickmarks."""
+
+import unittest.mock
import pytest
from PyQt5.QtCore import QUrl
@@ -44,8 +46,8 @@ def test_init(bm_file, fake_save_manager):
bm = urlmarks.BookmarkManager()
fake_save_manager.add_saveable.assert_called_once_with(
'bookmark-manager',
- bm.save,
- bm.changed,
+ unittest.mock.ANY,
+ unittest.mock.ANY,
filename=str(bm_file),
)
diff --git a/tests/unit/browser/webengine/test_webengine_cookies.py b/tests/unit/browser/webengine/test_webengine_cookies.py
new file mode 100644
index 000000000..0933fa1c6
--- /dev/null
+++ b/tests/unit/browser/webengine/test_webengine_cookies.py
@@ -0,0 +1,128 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2019 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+import pytest
+from PyQt5.QtCore import QUrl
+pytest.importorskip('PyQt5.QtWebEngineCore')
+from PyQt5.QtWebEngineCore import QWebEngineCookieStore
+from PyQt5.QtWebEngineWidgets import QWebEngineProfile
+
+from qutebrowser.browser.webengine import cookies
+from qutebrowser.utils import urlmatch
+
+
+@pytest.fixture
+def filter_request():
+ try:
+ request = QWebEngineCookieStore.FilterRequest()
+ request.firstPartyUrl = QUrl('https://example.com')
+ return request
+ except AttributeError:
+ pytest.skip("FilterRequest not available")
+
+
+@pytest.fixture(autouse=True)
+def enable_cookie_logging(monkeypatch):
+ monkeypatch.setattr(cookies.objects, 'debug_flags', ['log-cookies'])
+
+
+@pytest.mark.parametrize('setting, third_party, accepted', [
+ ('all', False, True),
+ ('never', False, False),
+ ('no-3rdparty', False, True),
+ ('no-3rdparty', True, False),
+])
+def test_accept_cookie(config_stub, filter_request, setting, third_party,
+ accepted):
+ """Test that _accept_cookie respects content.cookies.accept."""
+ config_stub.val.content.cookies.accept = setting
+ filter_request.thirdParty = third_party
+ assert cookies._accept_cookie(filter_request) == accepted
+
+
+@pytest.mark.parametrize('setting, pattern_setting, third_party, accepted', [
+ ('never', 'all', False, True),
+ ('all', 'never', False, False),
+ ('no-3rdparty', 'all', True, True),
+ ('all', 'no-3rdparty', True, False),
+])
+def test_accept_cookie_with_pattern(config_stub, filter_request, setting,
+ pattern_setting, third_party, accepted):
+ """Test that _accept_cookie matches firstPartyUrl with the UrlPattern."""
+ filter_request.thirdParty = third_party
+ config_stub.set_str('content.cookies.accept', setting)
+ config_stub.set_str('content.cookies.accept', pattern_setting,
+ pattern=urlmatch.UrlPattern('https://*.example.com'))
+ assert cookies._accept_cookie(filter_request) == accepted
+
+
+@pytest.mark.parametrize('global_value', ['never', 'all'])
+def test_invalid_url(config_stub, filter_request, global_value):
+ """Make sure we fall back to the global value with invalid URLs.
+
+ This can happen when there's a cookie request from an iframe, e.g. here:
+ https://developers.google.com/youtube/youtube_player_demo
+ """
+ config_stub.val.content.cookies.accept = global_value
+ filter_request.firstPartyUrl = QUrl()
+ accepted = global_value == 'all'
+ assert cookies._accept_cookie(filter_request) == accepted
+
+
+def test_third_party_workaround(monkeypatch, config_stub, filter_request):
+ monkeypatch.setattr(cookies.qtutils, 'version_check',
+ lambda ver, compiled: False)
+ config_stub.val.content.cookies.accept = 'no-3rdparty'
+ filter_request.thirdParty = True
+ filter_request.firstPartyUrl = QUrl()
+ assert cookies._accept_cookie(filter_request)
+
+
+@pytest.mark.parametrize('enabled', [True, False])
+def test_logging(monkeypatch, config_stub, filter_request, caplog, enabled):
+ monkeypatch.setattr(cookies.objects, 'debug_flags',
+ ['log-cookies'] if enabled else [])
+ config_stub.val.content.cookies.accept = 'all'
+ caplog.clear()
+
+ cookies._accept_cookie(filter_request)
+
+ if enabled:
+ expected = ("Cookie from origin <unknown> on https://example.com "
+ "(third party: False) -> applying setting all")
+ assert caplog.messages == [expected]
+ else:
+ assert not caplog.messages
+
+
+class TestInstall:
+
+ def test_real_profile(self):
+ profile = QWebEngineProfile()
+ cookies.install_filter(profile)
+
+ @pytest.mark.parametrize('has_cookie_filter', [True, False])
+ def test_fake_profile(self, stubs, has_cookie_filter):
+ store = stubs.FakeCookieStore(has_cookie_filter=has_cookie_filter)
+ profile = stubs.FakeWebEngineProfile(cookie_store=store)
+
+ cookies.install_filter(profile)
+
+ if has_cookie_filter:
+ assert store.cookie_filter is cookies._accept_cookie
diff --git a/tests/unit/browser/webkit/test_cookies.py b/tests/unit/browser/webkit/test_cookies.py
index a43091f19..fb46a82fb 100644
--- a/tests/unit/browser/webkit/test_cookies.py
+++ b/tests/unit/browser/webkit/test_cookies.py
@@ -23,7 +23,7 @@ from PyQt5.QtCore import QUrl
import pytest
from qutebrowser.browser.webkit import cookies
-from qutebrowser.utils import usertypes
+from qutebrowser.utils import usertypes, urlmatch
from qutebrowser.misc import lineparser, objects
pytestmark = pytest.mark.usefixtures('data_tmpdir')
@@ -62,36 +62,71 @@ class LineparserSaveStub(lineparser.BaseLineParser):
return self.data[key]
-def test_set_cookies_accept(config_stub, qtbot, monkeypatch):
- """Test setCookiesFromUrl with cookies enabled."""
- monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit)
- config_stub.val.content.cookies.accept = 'all'
+class TestSetCookies:
- ram_jar = cookies.RAMCookieJar()
- cookie = QNetworkCookie(b'foo', b'bar')
- url = QUrl('http://example.com/')
- with qtbot.waitSignal(ram_jar.changed):
- assert ram_jar.setCookiesFromUrl([cookie], url)
+ @pytest.fixture
+ def cookie(self):
+ return QNetworkCookie(b'foo', b'bar')
- # assert the cookies are added correctly
- all_cookies = ram_jar.cookiesForUrl(url)
- assert len(all_cookies) == 1
- saved_cookie = all_cookies[0]
- expected = cookie.name(), cookie.value()
- assert saved_cookie.name(), saved_cookie.value() == expected
+ @pytest.fixture
+ def ram_jar(self):
+ return cookies.RAMCookieJar()
+ @pytest.fixture
+ def url(self):
+ return QUrl('http://example.com/')
-def test_set_cookies_never_accept(qtbot, config_stub, monkeypatch):
- """Test setCookiesFromUrl when cookies are not accepted."""
- monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit)
- config_stub.val.content.cookies.accept = 'never'
+ @pytest.fixture(autouse=True)
+ def set_webkit_backend(self, monkeypatch):
+ monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit)
- ram_jar = cookies.RAMCookieJar()
- url = QUrl('http://example.com/')
+ def test_accept(self, config_stub, qtbot, monkeypatch,
+ cookie, ram_jar, url):
+ """Test setCookiesFromUrl with cookies enabled."""
+ config_stub.val.content.cookies.accept = 'all'
- with qtbot.assertNotEmitted(ram_jar.changed):
- assert not ram_jar.setCookiesFromUrl('test', url)
- assert not ram_jar.cookiesForUrl(url)
+ with qtbot.waitSignal(ram_jar.changed):
+ assert ram_jar.setCookiesFromUrl([cookie], url)
+
+ # assert the cookies are added correctly
+ all_cookies = ram_jar.cookiesForUrl(url)
+ assert len(all_cookies) == 1
+ saved_cookie = all_cookies[0]
+ expected = cookie.name(), cookie.value()
+ assert saved_cookie.name(), saved_cookie.value() == expected
+
+ def test_never_accept(self, qtbot, config_stub, monkeypatch,
+ cookie, ram_jar, url):
+ """Test setCookiesFromUrl when cookies are not accepted."""
+ config_stub.val.content.cookies.accept = 'never'
+
+ with qtbot.assertNotEmitted(ram_jar.changed):
+ assert not ram_jar.setCookiesFromUrl([cookie], url)
+ assert not ram_jar.cookiesForUrl(url)
+
+ def test_per_url(self, config_stub, qtbot, monkeypatch,
+ cookie, ram_jar, url):
+ config_stub.val.content.cookies.accept = 'all'
+ config_stub.set_str('content.cookies.accept', 'never',
+ pattern=urlmatch.UrlPattern('http://example.com'))
+
+ org_url = QUrl('http://example.org/')
+
+ with qtbot.waitSignal(ram_jar.changed):
+ assert ram_jar.setCookiesFromUrl([cookie], org_url)
+ assert ram_jar.cookiesForUrl(org_url)
+
+ with qtbot.assertNotEmitted(ram_jar.changed):
+ assert not ram_jar.setCookiesFromUrl([cookie], url)
+ assert not ram_jar.cookiesForUrl(url)
+
+ def test_logging(self, monkeypatch, caplog, config_stub,
+ cookie, ram_jar, url):
+ monkeypatch.setattr(objects, 'debug_flags', ['log-cookies'])
+ ram_jar.setCookiesFromUrl([cookie], url)
+
+ expected = "Cookie on http://example.com/ -> applying setting all"
+ assert caplog.messages == [expected]
def test_cookie_jar_init(config_stub, fake_save_manager):
@@ -159,3 +194,11 @@ def test_cookies_changed(config_stub, fake_save_manager, monkeypatch, qtbot,
assert not jar._lineparser.saved
else:
assert jar._lineparser.data
+
+
+def test_init(qapp, config_stub, fake_save_manager):
+ assert cookies.cookie_jar is None
+ assert cookies.ram_cookie_jar is None
+ cookies.init(qapp)
+ assert isinstance(cookies.cookie_jar, cookies.CookieJar)
+ assert isinstance(cookies.ram_cookie_jar, cookies.RAMCookieJar)
diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py
index f2f3bb47b..1a2a2f0a8 100644
--- a/tests/unit/completion/test_models.py
+++ b/tests/unit/completion/test_models.py
@@ -832,6 +832,41 @@ def test_other_buffer_completion_id0(qtmodeltester, fake_web_tab,
})
+def test_tab_focus_completion(qtmodeltester, fake_web_tab, win_registry,
+ tabbed_browser_stubs, info):
+ tabbed_browser_stubs[0].widget.tabs = [
+ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0),
+ fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1),
+ fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2),
+ ]
+ tabbed_browser_stubs[1].widget.tabs = [
+ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0),
+ ]
+ info.win_id = 1
+ model = miscmodels.tab_focus(info=info)
+ model.set_pattern('')
+ qtmodeltester.check(model)
+
+ _check_completions(model, {
+ 'Tabs': [
+ ('1', 'https://wiki.archlinux.org', 'ArchWiki'),
+ ],
+ 'Special': [
+ ("last",
+ "Focus the last-focused tab",
+ None),
+
+ ("stack-next",
+ "Go forward through a stack of focused tabs",
+ None),
+
+ ("stack-prev",
+ "Go backward through a stack of focused tabs",
+ None),
+ ]
+ })
+
+
def test_window_completion(qtmodeltester, fake_web_tab, tabbed_browser_stubs,
info):
tabbed_browser_stubs[0].widget.tabs = [
diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py
index f896cf4ad..cb2608145 100644
--- a/tests/unit/config/test_configcommands.py
+++ b/tests/unit/config/test_configcommands.py
@@ -212,6 +212,16 @@ class TestSet:
commands.set(win_id=0, option='foo?')
+@pytest.mark.parametrize('old', [True, False])
+def test_diff(commands, tabbed_browser_stubs, old):
+ """Run ':config-diff'.
+
+ Should open qute://configdiff."""
+ commands.config_diff(win_id=0, old=old)
+ url = QUrl('qute://configdiff/old') if old else QUrl('qute://configdiff')
+ assert tabbed_browser_stubs[0].loaded_url == url
+
+
class TestCycle:
"""Test :config-cycle."""
@@ -625,6 +635,19 @@ class TestWritePy:
lines = confpy.read_text('utf-8').splitlines()
assert '# Autogenerated config.py' in lines
+ @pytest.mark.posix
+ def test_expanduser(self, commands, monkeypatch, tmpdir):
+ """Make sure that using a path with ~/... works correctly."""
+ home = tmpdir / 'home'
+ home.ensure(dir=True)
+ monkeypatch.setenv('HOME', str(home))
+
+ commands.config_write_py('~/config.py')
+
+ confpy = home / 'config.py'
+ lines = confpy.read_text('utf-8').splitlines()
+ assert '# Autogenerated config.py' in lines
+
def test_existing_file(self, commands, tmpdir):
confpy = tmpdir / 'config.py'
confpy.ensure()
diff --git a/tests/unit/config/test_configdata.py b/tests/unit/config/test_configdata.py
index 3dd6a588f..4ea5ffe6d 100644
--- a/tests/unit/config/test_configdata.py
+++ b/tests/unit/config/test_configdata.py
@@ -47,10 +47,20 @@ def test_data(config_stub):
# https://github.com/qutebrowser/qutebrowser/issues/3104
# For lists/dicts, don't use None as default
if isinstance(option.typ, (configtypes.Dict, configtypes.List)):
- assert option.default is not None
+ assert option.default is not None, option
# For ListOrValue, use a list as default
if isinstance(option.typ, configtypes.ListOrValue):
- assert isinstance(option.default, list)
+ assert isinstance(option.default, list), option
+
+ # Make sure floats also have floats for defaults/bounds
+ if isinstance(option.typ, configtypes.Float):
+ for value in [option.default,
+ option.typ.minval,
+ option.typ.maxval]:
+ assert value is None or isinstance(value, float), option
+
+ # No double spaces after dots
+ assert '. ' not in option.description, option
def test_init_benchmark(benchmark):
diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py
index f7512e2a6..0a3668d39 100644
--- a/tests/unit/config/test_configfiles.py
+++ b/tests/unit/config/test_configfiles.py
@@ -501,7 +501,7 @@ class TestYamlMigrations:
('tabs.favicons.show', 'always', 'always'),
('scrolling.bar', True, 'always'),
- ('scrolling.bar', False, 'when-searching'),
+ ('scrolling.bar', False, 'overlay'),
('scrolling.bar', 'always', 'always'),
('qt.force_software_rendering', True, 'software-opengl'),
@@ -578,8 +578,6 @@ class TestYamlMigrations:
@pytest.mark.parametrize('setting, old, new', [
# Font
('fonts.hints', '10pt monospace', '10pt default_family'),
- # QtFont
- ('fonts.debug_console', '10pt monospace', '10pt default_family'),
# String
('content.headers.accept_language', 'x monospace', 'x monospace'),
# Not at end of string
@@ -588,6 +586,17 @@ class TestYamlMigrations:
def test_font_replacements(self, migration_test, setting, old, new):
migration_test(setting, old, new)
+ def test_fonts_tabs(self, yaml, autoconfig):
+ val = '10pt default_family'
+ autoconfig.write({'fonts.tabs': {'global': val}})
+
+ yaml.load()
+ yaml._save()
+
+ data = autoconfig.read()
+ assert data['fonts.tabs.unselected']['global'] == val
+ assert data['fonts.tabs.selected']['global'] == val
+
class ConfPy:
@@ -810,12 +819,10 @@ class TestConfigPy:
with pytest.raises(configexc.ConfigFileErrors) as excinfo:
confpy.read()
- expected = {'normal': {'<Ctrl+q>': None}}
- assert config.instance.get_obj('bindings.commands') == expected
+ assert not config.instance.get_obj('bindings.commands')
- msg = ("While unbinding '<Ctrl+q>': Unbinding commands with "
- "config.bind('<Ctrl+q>', None) is deprecated. Use "
- "config.unbind('<Ctrl+q>') instead.")
+ msg = ("While binding '<Ctrl+q>': Can't bind <Ctrl+q> to None "
+ "(maybe you want to use config.unbind('<Ctrl+q>') instead?)")
assert len(excinfo.value.errors) == 1
assert str(excinfo.value.errors[0]) == msg
@@ -889,7 +896,9 @@ class TestConfigPy:
assert tblines[0] == "Traceback (most recent call last):"
assert tblines[-1] == "SyntaxError: invalid syntax"
assert " +" in tblines
- assert " ^" in tblines
+ # Starting with the new PEG-based parser in Python 3.9, the caret
+ # points at the location *after* the +
+ assert " ^" in tblines or " ^" in tblines
def test_unhandled_exception(self, confpy):
confpy.write("1/0")
@@ -1052,6 +1061,13 @@ class TestConfigPyWriter:
assert text == textwrap.dedent("""
# Autogenerated config.py
+ #
+ # NOTE: config.py is intended for advanced users who are comfortable
+ # with manually migrating the config file on qutebrowser upgrades. If
+ # you prefer, you can also configure qutebrowser using the
+ # :set/:bind/:config-* commands without having to write a config.py
+ # file.
+ #
# Documentation:
# qute://help/configuring.html
# qute://help/settings.html
@@ -1156,17 +1172,10 @@ class TestConfigPyWriter:
def test_empty(self):
writer = configfiles.ConfigPyWriter(options=[], bindings={},
commented=False)
- text = '\n'.join(writer._gen_lines())
- expected = textwrap.dedent("""
- # Autogenerated config.py
- # Documentation:
- # qute://help/configuring.html
- # qute://help/settings.html
-
- # Uncomment this to still load settings configured via autoconfig.yml
- # config.load_autoconfig()
- """).lstrip()
- assert text == expected
+ lines = list(writer._gen_lines())
+ assert lines[0] == '# Autogenerated config.py'
+ assert lines[-2] == '# config.load_autoconfig()'
+ assert not lines[-1]
def test_pattern(self):
opt = configdata.Option(
diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py
index 694a95437..732104513 100644
--- a/tests/unit/config/test_configinit.py
+++ b/tests/unit/config/test_configinit.py
@@ -28,7 +28,7 @@ import pytest
from qutebrowser import qutebrowser
from qutebrowser.config import (config, configexc, configfiles, configinit,
configdata, configtypes)
-from qutebrowser.utils import objreg, usertypes
+from qutebrowser.utils import objreg, usertypes, version
from helpers import utils
@@ -40,8 +40,8 @@ def init_patch(qapp, fake_save_manager, monkeypatch, config_tmpdir,
monkeypatch.setattr(config, 'key_instance', None)
monkeypatch.setattr(config, 'change_filters', [])
monkeypatch.setattr(configinit, '_init_errors', None)
- monkeypatch.setattr(configtypes.Font, 'default_family', None)
- monkeypatch.setattr(configtypes.Font, 'default_size', None)
+ monkeypatch.setattr(configtypes.FontBase, 'default_family', None)
+ monkeypatch.setattr(configtypes.FontBase, 'default_size', None)
yield
try:
objreg.delete('config-commands')
@@ -344,12 +344,10 @@ class TestLateInit:
# fonts.default_family and font settings customized
# https://github.com/qutebrowser/qutebrowser/issues/3096
([('fonts.default_family', 'Comic Sans MS'),
- ('fonts.tabs', '12pt default_family'),
('fonts.keyhint', '12pt default_family')], 12, 'Comic Sans MS'),
# as above, but with default_size
([('fonts.default_family', 'Comic Sans MS'),
('fonts.default_size', '23pt'),
- ('fonts.tabs', 'default_size default_family'),
('fonts.keyhint', 'default_size default_family')],
23, 'Comic Sans MS'),
])
@@ -381,10 +379,6 @@ class TestLateInit:
# Font
expected = '{}pt "{}"'.format(size, family)
assert config.instance.get('fonts.keyhint') == expected
- # QtFont
- font = config.instance.get('fonts.tabs')
- assert font.pointSize() == size
- assert font.family() == family
@pytest.fixture
def run_configinit(self, init_patch, fake_save_manager, args):
@@ -405,10 +399,6 @@ class TestLateInit:
assert 'fonts.keyhint' in changed_options # Font
assert config.instance.get('fonts.keyhint') == '23pt "Comic Sans MS"'
- assert 'fonts.tabs' in changed_options # QtFont
- tabs_font = config.instance.get('fonts.tabs')
- assert tabs_font.family() == 'Comic Sans MS'
- assert tabs_font.pointSize() == 23
# Font subclass, but doesn't end with "default_family"
assert 'fonts.web.family.standard' not in changed_options
@@ -478,6 +468,9 @@ class TestQtArgs:
])
def test_qt_args(self, config_stub, args, expected, parser):
"""Test commandline with no Qt arguments given."""
+ # Avoid scrollbar overlay argument
+ config_stub.val.scrolling.bar = 'never'
+
parsed = parser.parse_args(args)
assert configinit.qt_args(parsed) == expected
@@ -494,7 +487,10 @@ class TestQtArgs:
def test_with_settings(self, config_stub, parser):
parsed = parser.parse_args(['--qt-flag', 'foo'])
config_stub.val.qt.args = ['bar']
- assert configinit.qt_args(parsed) == [sys.argv[0], '--foo', '--bar']
+ args = configinit.qt_args(parsed)
+ assert args[0] == sys.argv[0]
+ for arg in ['--foo', '--bar']:
+ assert arg in args
@pytest.mark.parametrize('backend, expected', [
(usertypes.Backend.QtWebEngine, True),
@@ -541,23 +537,33 @@ class TestQtArgs:
assert '--disable-in-process-stack-traces' in args
assert '--enable-in-process-stack-traces' not in args
- @pytest.mark.parametrize('flags, expected', [
- ([], []),
- (['--debug-flag', 'chromium'], ['--enable-logging', '--v=1']),
+ @pytest.mark.parametrize('flags, added', [
+ ([], False),
+ (['--debug-flag', 'chromium'], True),
])
- def test_chromium_debug(self, monkeypatch, parser, flags, expected):
+ def test_chromium_debug(self, monkeypatch, parser, flags, added):
monkeypatch.setattr(configinit.objects, 'backend',
usertypes.Backend.QtWebEngine)
parsed = parser.parse_args(flags)
- assert configinit.qt_args(parsed) == [sys.argv[0]] + expected
+ args = configinit.qt_args(parsed)
- def test_disable_gpu(self, config_stub, monkeypatch, parser):
+ for arg in ['--enable-logging', '--v=1']:
+ assert (arg in args) == added
+
+ @pytest.mark.parametrize('config, added', [
+ ('none', False),
+ ('qt-quick', False),
+ ('software-opengl', False),
+ ('chromium', True),
+ ])
+ def test_disable_gpu(self, config, added,
+ config_stub, monkeypatch, parser):
monkeypatch.setattr(configinit.objects, 'backend',
usertypes.Backend.QtWebEngine)
- config_stub.val.qt.force_software_rendering = 'chromium'
+ config_stub.val.qt.force_software_rendering = config
parsed = parser.parse_args([])
- expected = [sys.argv[0], '--disable-gpu']
- assert configinit.qt_args(parsed) == expected
+ args = configinit.qt_args(parsed)
+ assert ('--disable-gpu' in args) == added
@utils.qt510
@pytest.mark.parametrize('new_version, autoplay, added', [
@@ -705,6 +711,155 @@ class TestQtArgs:
assert ('--force-dark-mode' in args) == added
+ @pytest.mark.parametrize('bar, new_qt, is_mac, added', [
+ # Overlay bar enabled
+ ('overlay', True, False, True),
+ # No overlay on mac
+ ('overlay', True, True, False),
+ ('overlay', False, True, False),
+ # No overlay on old Qt
+ ('overlay', False, False, False),
+ # Overlay disabled
+ ('when-searching', True, False, False),
+ ('always', True, False, False),
+ ('never', True, False, False),
+ ])
+ def test_overlay_scrollbar(self, config_stub, monkeypatch, parser,
+ bar, new_qt, is_mac, added):
+ monkeypatch.setattr(configinit.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+ monkeypatch.setattr(configinit.qtutils, 'version_check',
+ lambda version, exact=False, compiled=True:
+ new_qt)
+ monkeypatch.setattr(configinit.utils, 'is_mac', is_mac)
+
+ config_stub.val.scrolling.bar = bar
+
+ parsed = parser.parse_args([])
+ args = configinit.qt_args(parsed)
+
+ assert ('--enable-features=OverlayScrollbar' in args) == added
+
+ @utils.qt514
+ def test_blink_settings(self, config_stub, monkeypatch, parser):
+ monkeypatch.setattr(configinit.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+ monkeypatch.setattr(configinit.qtutils, 'version_check',
+ lambda version, exact=False, compiled=True:
+ True)
+
+ config_stub.val.colors.webpage.darkmode.enabled = True
+
+ parsed = parser.parse_args([])
+ args = configinit.qt_args(parsed)
+
+ assert '--blink-settings=darkModeEnabled=true' in args
+
+
+class TestDarkMode:
+
+ pytestmark = utils.qt514
+
+ @pytest.fixture(autouse=True)
+ def patch_backend(self, monkeypatch):
+ monkeypatch.setattr(configinit.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+
+ @pytest.mark.parametrize('settings, new_qt, expected', [
+ # Disabled
+ ({}, True, []),
+ ({}, False, []),
+
+ # Enabled without customization
+ (
+ {'enabled': True},
+ True,
+ [('darkModeEnabled', 'true')]
+ ),
+ (
+ {'enabled': True},
+ False,
+ [('darkMode', '4')]
+ ),
+
+ # Algorithm
+ (
+ {'enabled': True, 'algorithm': 'brightness-rgb'},
+ True,
+ [('darkModeEnabled', 'true'),
+ ('darkModeInversionAlgorithm', '2')],
+ ),
+ (
+ {'enabled': True, 'algorithm': 'brightness-rgb'},
+ False,
+ [('darkMode', '2')],
+ ),
+
+ ])
+ def test_basics(self, config_stub, monkeypatch,
+ settings, new_qt, expected):
+ for k, v in settings.items():
+ config_stub.set_obj('colors.webpage.darkmode.' + k, v)
+ monkeypatch.setattr(configinit.qtutils, 'version_check',
+ lambda version, exact=False, compiled=True:
+ new_qt)
+
+ assert list(configinit._darkmode_settings()) == expected
+
+ @pytest.mark.parametrize('setting, value, exp_key, exp_val', [
+ ('contrast', -0.5,
+ 'darkModeContrast', '-0.5'),
+ ('policy.page', 'smart',
+ 'darkModePagePolicy', '1'),
+ ('policy.images', 'smart',
+ 'darkModeImagePolicy', '2'),
+ ('threshold.text', 100,
+ 'darkModeTextBrightnessThreshold', '100'),
+ ('threshold.background', 100,
+ 'darkModeBackgroundBrightnessThreshold', '100'),
+ ('grayscale.all', True,
+ 'darkModeGrayscale', 'true'),
+ ('grayscale.images', 0.5,
+ 'darkModeImageGrayscale', '0.5'),
+ ])
+ def test_customization(self, config_stub, monkeypatch,
+ setting, value, exp_key, exp_val):
+ config_stub.val.colors.webpage.darkmode.enabled = True
+ config_stub.set_obj('colors.webpage.darkmode.' + setting, value)
+ monkeypatch.setattr(configinit.qtutils, 'version_check',
+ lambda version, exact=False, compiled=True:
+ True)
+
+ expected = [('darkModeEnabled', 'true'), (exp_key, exp_val)]
+ assert list(configinit._darkmode_settings()) == expected
+
+ def test_new_chromium(self):
+ """Fail if we encounter an unknown Chromium version.
+
+ Dark mode in Chromium currently is undergoing various changes (as it's
+ relatively recent), and Qt 5.15 is supposed to update the underlying
+ Chromium at some point.
+
+ Make this test fail deliberately with newer Chromium versions, so that
+ we can test whether dark mode still works manually, and adjust if not.
+ """
+ assert version._chromium_version() in [
+ 'unavailable', # QtWebKit
+ '77.0.3865.129', # Qt 5.14
+ '80.0.3987.163', # Qt 5.15
+ ]
+
+ def test_options(self, configdata_init):
+ """Make sure all darkmode options have the right attributes set."""
+ for name, opt in configdata.DATA.items():
+ if not name.startswith('colors.webpage.darkmode.'):
+ continue
+
+ backends = {'QtWebEngine': 'Qt 5.14', 'QtWebKit': False}
+ assert not opt.supports_pattern, name
+ assert opt.restart, name
+ assert opt.raw_backends == backends, name
+
@pytest.mark.parametrize('arg, confval, used', [
# overridden by commandline arg
diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py
index f949e1ca0..8b0c4b191 100644
--- a/tests/unit/config/test_configtypes.py
+++ b/tests/unit/config/test_configtypes.py
@@ -212,17 +212,25 @@ class TestAll:
"""
for _name, member in inspect.getmembers(configtypes, inspect.isclass):
if member in [configtypes.BaseType, configtypes.MappingType,
- configtypes._Numeric]:
+ configtypes._Numeric, configtypes.FontBase]:
pass
elif (member is configtypes.List or
member is configtypes.ListOrValue):
- yield functools.partial(member, valtype=configtypes.Int())
- yield functools.partial(member, valtype=configtypes.Url())
+ yield pytest.param(
+ functools.partial(member, valtype=configtypes.Int()),
+ id=member.__name__ + '-Int')
+ yield pytest.param(
+ functools.partial(member, valtype=configtypes.Url()),
+ id=member.__name__ + '-Url')
elif member is configtypes.Dict:
- yield functools.partial(member, keytype=configtypes.String(),
- valtype=configtypes.String())
+ yield pytest.param(
+ functools.partial(member, keytype=configtypes.String(),
+ valtype=configtypes.String()),
+ id=member.__name__)
elif member is configtypes.FormatString:
- yield functools.partial(member, fields=['a', 'b'])
+ yield pytest.param(
+ functools.partial(member, fields=['a', 'b']),
+ id=member.__name__)
elif issubclass(member, configtypes.BaseType):
yield member
@@ -247,8 +255,8 @@ class TestAll:
# For some types, we don't actually get the internal (YAML-like) value
# back from from_str(), so we can't convert it back.
- if klass in [configtypes.FuzzyUrl, configtypes.QtFont,
- configtypes.ShellCommand, configtypes.Url]:
+ if klass in [configtypes.FuzzyUrl, configtypes.ShellCommand,
+ configtypes.Url]:
return
converted = typ.to_str(val)
@@ -262,8 +270,15 @@ class TestAll:
configtypes.PercOrInt, # ditto
]:
return
- if (isinstance(typ, configtypes.ListOrValue) and
- isinstance(typ.valtype, configtypes.Int)):
+ elif (isinstance(typ, functools.partial) and
+ isinstance(typ.func, (configtypes.ListOrValue,
+ configtypes.List))):
+ # ListOrValue: "- /" -> "/"
+ # List: "- /" -> ["/"]
+ return
+ elif (isinstance(typ, configtypes.ListOrValue) and
+ isinstance(typ.valtype, configtypes.Int)):
+ # "00" -> "0"
return
assert converted == s
@@ -1032,6 +1047,10 @@ class TestInt:
converted = typ.from_str(text)
assert typ.to_str(converted) == text
+ def test_bounds_handling_unset(self, klass):
+ typ = klass(minval=1, maxval=2)
+ assert typ.to_py(usertypes.UNSET) is usertypes.UNSET
+
class TestFloat:
@@ -1362,8 +1381,6 @@ class FontDesc:
class TestFont:
- """Test Font/QtFont."""
-
TESTS = {
# (style, weight, pointsize, pixelsize, family
'"Foobar Neue"':
@@ -1411,52 +1428,17 @@ class TestFont:
font_xfail = pytest.mark.xfail(reason='FIXME: #103')
- @pytest.fixture(params=[configtypes.Font, configtypes.QtFont])
- def klass(self, request):
- return request.param
-
@pytest.fixture
- def font_class(self):
+ def klass(self):
return configtypes.Font
@pytest.fixture
- def qtfont_class(self):
- return configtypes.QtFont
+ def font_class(self):
+ return configtypes.Font
@pytest.mark.parametrize('val, desc', sorted(TESTS.items()))
def test_to_py_valid(self, klass, val, desc):
- if klass is configtypes.Font:
- expected = val
- elif klass is configtypes.QtFont:
- expected = Font.fromdesc(desc)
- assert klass().to_py(val) == expected
-
- def test_qtfont(self, qtfont_class):
- """Test QtFont's to_py."""
- value = Font(qtfont_class().to_py('10pt "Foobar Neue", Fubar'))
-
- if hasattr(value, 'families'):
- # Added in Qt 5.13
- assert value.family() == 'Foobar Neue'
- assert value.families() == ['Foobar Neue', 'Fubar']
- else:
- assert value.family() == 'Foobar Neue, Fubar'
-
- assert value.weight() == QFont.Normal
- assert value.style() == QFont.StyleNormal
-
- assert value.pointSize() == 10
-
- def test_qtfont_float(self, qtfont_class):
- """Test QtFont's to_py with a float as point size.
-
- We can't test the point size for equality as Qt seems to do some
- rounding as appropriate.
- """
- value = Font(qtfont_class().to_py('10.5pt Test'))
- assert value.family() == 'Test'
- assert value.pointSize() >= 10
- assert value.pointSize() <= 11
+ assert klass().to_py(val) == val
@pytest.mark.parametrize('val', [
pytest.param('green "Foobar Neue"', marks=font_xfail),
@@ -1475,14 +1457,8 @@ class TestFont:
klass().to_py(val)
def test_defaults_replacement(self, klass, monkeypatch):
- configtypes.Font.set_defaults(['Terminus'], '23pt')
- if klass is configtypes.Font:
- expected = '23pt Terminus'
- elif klass is configtypes.QtFont:
- desc = FontDesc(QFont.StyleNormal, QFont.Normal, 23, None,
- 'Terminus')
- expected = Font.fromdesc(desc)
- assert klass().to_py('23pt default_family') == expected
+ configtypes.FontBase.set_defaults(['Terminus'], '23pt')
+ assert klass().to_py('23pt default_family') == '23pt Terminus'
class TestFontFamily:
@@ -2003,7 +1979,6 @@ class TestSearchEngineUrl:
@pytest.mark.parametrize('val', [
'foo', # no placeholder
- ':{}', # invalid URL
'foo{bar}baz{}', # {bar} format string variable
'{1}{}', # numbered format string variable
'{{}', # invalid format syntax
diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py
index 8dfa75609..7e1a7c744 100644
--- a/tests/unit/config/test_configutils.py
+++ b/tests/unit/config/test_configutils.py
@@ -23,7 +23,7 @@ import pytest
from PyQt5.QtCore import QUrl
from qutebrowser.config import configutils, configdata, configtypes, configexc
-from qutebrowser.utils import urlmatch, usertypes
+from qutebrowser.utils import urlmatch, usertypes, qtutils
from tests.helpers import utils
@@ -145,6 +145,11 @@ def test_get_matching(values):
assert values.get_for_url(url, fallback=False) == 'example value'
+def test_get_invalid(values):
+ with pytest.raises(qtutils.QtValueError):
+ values.get_for_url(QUrl())
+
+
def test_get_unset(empty_values):
assert empty_values.get_for_url(fallback=False) is usertypes.UNSET
diff --git a/tests/unit/keyinput/conftest.py b/tests/unit/keyinput/conftest.py
index 62ffa07cf..ba7a19f79 100644
--- a/tests/unit/keyinput/conftest.py
+++ b/tests/unit/keyinput/conftest.py
@@ -21,11 +21,6 @@
import pytest
-from PyQt5.QtCore import QEvent, Qt
-from PyQt5.QtGui import QKeyEvent
-
-from qutebrowser.keyinput import keyutils
-
BINDINGS = {'prompt': {'<Ctrl-a>': 'message-info ctrla',
'a': 'message-info a',
@@ -50,14 +45,3 @@ def keyinput_bindings(config_stub, key_config_stub):
config_stub.val.bindings.default = {}
config_stub.val.bindings.commands = dict(BINDINGS)
config_stub.val.bindings.key_mappings = dict(MAPPINGS)
-
-
-@pytest.fixture
-def fake_keyevent():
- """Fixture that when called will return a mock instance of a QKeyEvent."""
- def func(key, modifiers=Qt.NoModifier, typ=QEvent.KeyPress):
- """Generate a new fake QKeyPressEvent."""
- text = keyutils.KeyInfo(key, modifiers).text()
- return QKeyEvent(QKeyEvent.KeyPress, key, modifiers, text)
-
- return func
diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py
index a5a1eb553..f8caaf1af 100644
--- a/tests/unit/keyinput/test_basekeyparser.py
+++ b/tests/unit/keyinput/test_basekeyparser.py
@@ -25,7 +25,7 @@ from PyQt5.QtCore import Qt
import pytest
from qutebrowser.keyinput import basekeyparser, keyutils
-from qutebrowser.utils import utils
+from qutebrowser.utils import utils, usertypes
# Alias because we need this a lot in here.
@@ -33,24 +33,29 @@ def keyseq(s):
return keyutils.KeySequence.parse(s)
-@pytest.fixture
-def keyparser(key_config_stub):
- """Fixture providing a BaseKeyParser supporting count/chains."""
- kp = basekeyparser.BaseKeyParser(win_id=0)
+def _create_keyparser(mode):
+ kp = basekeyparser.BaseKeyParser(mode=mode, win_id=0)
kp.execute = mock.Mock()
- yield kp
+ return kp
+
+
+@pytest.fixture
+def keyparser(key_config_stub, keyinput_bindings):
+ return _create_keyparser(usertypes.KeyMode.normal)
@pytest.fixture
-def handle_text(fake_keyevent, keyparser):
- """Helper function to handle multiple fake keypresses.
-
- Automatically uses the keyparser of the current test via the keyparser
- fixture.
- """
- def func(*args):
- for enumval in args:
- keyparser.handle(fake_keyevent(enumval))
+def prompt_keyparser(key_config_stub, keyinput_bindings):
+ return _create_keyparser(usertypes.KeyMode.prompt)
+
+
+@pytest.fixture
+def handle_text():
+ """Helper function to handle multiple fake keypresses."""
+ def func(kp, *args):
+ for key in args:
+ info = keyutils.KeyInfo(key, Qt.NoModifier)
+ kp.handle(info.to_event())
return func
@@ -60,10 +65,10 @@ class TestDebugLog:
def test_log(self, keyparser, caplog):
keyparser._debug_log('foo')
- assert caplog.messages == ['foo']
+ assert caplog.messages == ['BaseKeyParser for mode normal: foo']
def test_no_log(self, keyparser, caplog):
- keyparser.do_log = False
+ keyparser._do_log = False
keyparser._debug_log('foo')
assert not caplog.records
@@ -79,9 +84,8 @@ class TestDebugLog:
])
def test_split_count(config_stub, key_config_stub,
input_key, supports_count, count, command):
- kp = basekeyparser.BaseKeyParser(win_id=0)
- kp.supports_count = supports_count
- kp._read_config('normal')
+ kp = basekeyparser.BaseKeyParser(mode=usertypes.KeyMode.normal, win_id=0,
+ supports_count=supports_count)
for info in keyseq(input_key):
kp.handle(info.to_event())
@@ -90,80 +94,55 @@ def test_split_count(config_stub, key_config_stub,
assert kp._sequence == keyseq(command)
-@pytest.mark.usefixtures('keyinput_bindings')
-class TestReadConfig:
-
- def test_read_config_invalid(self, keyparser):
- """Test reading config without setting modename before."""
- with pytest.raises(ValueError):
- keyparser._read_config()
-
- def test_read_config_modename(self, keyparser):
- """Test reading config with _modename set."""
- keyparser._modename = 'normal'
- keyparser._read_config()
- assert keyseq('a') in keyparser.bindings
-
- def test_read_config_valid(self, keyparser):
- """Test reading config."""
- keyparser._read_config('prompt')
- assert keyseq('ccc') in keyparser.bindings
- assert keyseq('<ctrl+a>') in keyparser.bindings
- keyparser._read_config('command')
- assert keyseq('ccc') not in keyparser.bindings
- assert keyseq('<ctrl+a>') not in keyparser.bindings
- assert keyseq('foo') in keyparser.bindings
- assert keyseq('<ctrl+x>') in keyparser.bindings
-
- def test_read_config_empty_binding(self, keyparser, config_stub):
- """Make sure setting an empty binding doesn't crash."""
- keyparser._read_config('normal')
- config_stub.val.bindings.commands = {'normal': {'co': ''}}
- # The config is re-read automatically
-
- def test_read_config_modename_none(self, keyparser):
- assert keyparser._modename is None
-
- # No config set so self._modename is None
- with pytest.raises(ValueError, match="read_config called with no mode "
- "given, but None defined so far!"):
- keyparser._read_config(None)
-
- @pytest.mark.parametrize('mode, changed_mode, expected', [
- ('normal', 'normal', True), ('normal', 'command', False),
- ])
- def test_read_config(self, keyparser, key_config_stub,
- mode, changed_mode, expected):
- keyparser._read_config(mode)
- # Sanity checks
- assert keyseq('a') in keyparser.bindings
- assert keyseq('new') not in keyparser.bindings
+def test_empty_binding(keyparser, config_stub):
+ """Make sure setting an empty binding doesn't crash."""
+ config_stub.val.bindings.commands = {'normal': {'co': ''}}
+ # The config is re-read automatically
- key_config_stub.bind(keyseq('new'), 'message-info new',
- mode=changed_mode)
- assert keyseq('a') in keyparser.bindings
- assert (keyseq('new') in keyparser.bindings) == expected
+@pytest.mark.parametrize('changed_mode, expected', [
+ ('normal', True), ('command', False),
+])
+def test_read_config(keyparser, key_config_stub, changed_mode, expected):
+ keyparser._read_config()
+ # Sanity checks
+ assert keyseq('a') in keyparser.bindings
+ assert keyseq('new') not in keyparser.bindings
+ key_config_stub.bind(keyseq('new'), 'message-info new',
+ mode=changed_mode)
-class TestHandle:
+ assert keyseq('a') in keyparser.bindings
+ assert (keyseq('new') in keyparser.bindings) == expected
- @pytest.fixture(autouse=True)
- def read_config(self, keyinput_bindings, keyparser):
- keyparser._read_config('prompt')
- def test_valid_key(self, fake_keyevent, keyparser):
+class TestHandle:
+
+ def test_valid_key(self, prompt_keyparser, handle_text):
modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier
- keyparser.handle(fake_keyevent(Qt.Key_A, modifier))
- keyparser.handle(fake_keyevent(Qt.Key_X, modifier))
- keyparser.execute.assert_called_once_with('message-info ctrla', None)
- assert not keyparser._sequence
- def test_valid_key_count(self, fake_keyevent, keyparser):
+ infos = [
+ keyutils.KeyInfo(Qt.Key_A, modifier),
+ keyutils.KeyInfo(Qt.Key_X, modifier),
+ ]
+ for info in infos:
+ prompt_keyparser.handle(info.to_event())
+
+ prompt_keyparser.execute.assert_called_once_with(
+ 'message-info ctrla', None)
+ assert not prompt_keyparser._sequence
+
+ def test_valid_key_count(self, prompt_keyparser):
modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier
- keyparser.handle(fake_keyevent(Qt.Key_5))
- keyparser.handle(fake_keyevent(Qt.Key_A, modifier))
- keyparser.execute.assert_called_once_with('message-info ctrla', 5)
+
+ infos = [
+ keyutils.KeyInfo(Qt.Key_5, Qt.NoModifier),
+ keyutils.KeyInfo(Qt.Key_A, modifier),
+ ]
+ for info in infos:
+ prompt_keyparser.handle(info.to_event())
+ prompt_keyparser.execute.assert_called_once_with(
+ 'message-info ctrla', 5)
@pytest.mark.parametrize('keys', [
[(Qt.Key_B, Qt.NoModifier), (Qt.Key_C, Qt.NoModifier)],
@@ -171,106 +150,113 @@ class TestHandle:
# Only modifier
[(Qt.Key_Shift, Qt.ShiftModifier)],
])
- def test_invalid_keys(self, fake_keyevent, keyparser, keys):
+ def test_invalid_keys(self, prompt_keyparser, keys):
for key, modifiers in keys:
- keyparser.handle(fake_keyevent(key, modifiers))
- assert not keyparser.execute.called
- assert not keyparser._sequence
-
- def test_dry_run(self, fake_keyevent, keyparser):
- keyparser.handle(fake_keyevent(Qt.Key_B))
- keyparser.handle(fake_keyevent(Qt.Key_A), dry_run=True)
- assert not keyparser.execute.called
- assert keyparser._sequence
-
- def test_dry_run_count(self, fake_keyevent, keyparser):
- keyparser.handle(fake_keyevent(Qt.Key_9), dry_run=True)
- assert not keyparser._count
-
- def test_invalid_key(self, fake_keyevent, keyparser):
- keyparser.handle(fake_keyevent(Qt.Key_B))
- keyparser.handle(fake_keyevent(0x0))
- assert not keyparser._sequence
-
- def test_valid_keychain(self, handle_text, keyparser):
- # Press 'x' which is ignored because of no match
- handle_text(Qt.Key_X,
+ info = keyutils.KeyInfo(key, modifiers)
+ prompt_keyparser.handle(info.to_event())
+ assert not prompt_keyparser.execute.called
+ assert not prompt_keyparser._sequence
+
+ def test_dry_run(self, prompt_keyparser):
+ b_info = keyutils.KeyInfo(Qt.Key_B, Qt.NoModifier)
+ prompt_keyparser.handle(b_info.to_event())
+
+ a_info = keyutils.KeyInfo(Qt.Key_A, Qt.NoModifier)
+ prompt_keyparser.handle(a_info.to_event(), dry_run=True)
+
+ assert not prompt_keyparser.execute.called
+ assert prompt_keyparser._sequence
+
+ def test_dry_run_count(self, prompt_keyparser):
+ info = keyutils.KeyInfo(Qt.Key_9, Qt.NoModifier)
+ prompt_keyparser.handle(info.to_event(), dry_run=True)
+ assert not prompt_keyparser._count
+
+ def test_invalid_key(self, prompt_keyparser):
+ keys = [Qt.Key_B, 0x0]
+ for key in keys:
+ info = keyutils.KeyInfo(key, Qt.NoModifier)
+ prompt_keyparser.handle(info.to_event())
+ assert not prompt_keyparser._sequence
+
+ def test_valid_keychain(self, handle_text, prompt_keyparser):
+ handle_text(prompt_keyparser,
+ # Press 'x' which is ignored because of no match
+ Qt.Key_X,
# Then start the real chain
Qt.Key_B, Qt.Key_A)
- keyparser.execute.assert_called_with('message-info ba', None)
- assert not keyparser._sequence
+ prompt_keyparser.execute.assert_called_with('message-info ba', None)
+ assert not prompt_keyparser._sequence
@pytest.mark.parametrize('key, modifiers, number', [
(Qt.Key_0, Qt.NoModifier, 0),
(Qt.Key_1, Qt.NoModifier, 1),
(Qt.Key_1, Qt.KeypadModifier, 1),
])
- def test_number_press(self, fake_keyevent, keyparser,
+ def test_number_press(self, prompt_keyparser,
key, modifiers, number):
- keyparser.handle(fake_keyevent(key, modifiers))
+ prompt_keyparser.handle(keyutils.KeyInfo(key, modifiers).to_event())
command = 'message-info {}'.format(number)
- keyparser.execute.assert_called_once_with(command, None)
- assert not keyparser._sequence
+ prompt_keyparser.execute.assert_called_once_with(command, None)
+ assert not prompt_keyparser._sequence
@pytest.mark.parametrize('modifiers, text', [
(Qt.NoModifier, '2'),
(Qt.KeypadModifier, 'num-2'),
])
- def test_number_press_keypad(self, fake_keyevent, keyparser, config_stub,
+ def test_number_press_keypad(self, keyparser, config_stub,
modifiers, text):
"""Make sure a <Num+2> binding overrides the 2 binding."""
config_stub.val.bindings.commands = {'normal': {
'2': 'message-info 2',
'<Num+2>': 'message-info num-2'}}
- keyparser._read_config('normal')
- keyparser.handle(fake_keyevent(Qt.Key_2, modifiers))
+ keyparser.handle(keyutils.KeyInfo(Qt.Key_2, modifiers).to_event())
command = 'message-info {}'.format(text)
keyparser.execute.assert_called_once_with(command, None)
assert not keyparser._sequence
def test_umlauts(self, handle_text, keyparser, config_stub):
config_stub.val.bindings.commands = {'normal': {'ü': 'message-info ü'}}
- keyparser._read_config('normal')
- handle_text(Qt.Key_Udiaeresis)
+ handle_text(keyparser, Qt.Key_Udiaeresis)
keyparser.execute.assert_called_once_with('message-info ü', None)
- def test_mapping(self, config_stub, handle_text, keyparser):
- handle_text(Qt.Key_X)
- keyparser.execute.assert_called_once_with('message-info a', None)
+ def test_mapping(self, config_stub, handle_text, prompt_keyparser):
+ handle_text(prompt_keyparser, Qt.Key_X)
+ prompt_keyparser.execute.assert_called_once_with(
+ 'message-info a', None)
- def test_mapping_keypad(self, config_stub, fake_keyevent, keyparser):
+ def test_mapping_keypad(self, config_stub, keyparser):
"""Make sure falling back to non-numpad keys works with mappings."""
config_stub.val.bindings.commands = {'normal': {'a': 'nop'}}
config_stub.val.bindings.key_mappings = {'1': 'a'}
- keyparser._read_config('normal')
- keyparser.handle(fake_keyevent(Qt.Key_1, Qt.KeypadModifier))
+ info = keyutils.KeyInfo(Qt.Key_1, Qt.KeypadModifier)
+ keyparser.handle(info.to_event())
keyparser.execute.assert_called_once_with('nop', None)
- def test_binding_and_mapping(self, config_stub, handle_text, keyparser):
+ def test_binding_and_mapping(self, config_stub, handle_text, prompt_keyparser):
"""with a conflicting binding/mapping, the binding should win."""
- handle_text(Qt.Key_B)
- assert not keyparser.execute.called
+ handle_text(prompt_keyparser, Qt.Key_B)
+ assert not prompt_keyparser.execute.called
def test_mapping_in_key_chain(self, config_stub, handle_text, keyparser):
"""A mapping should work even as part of a keychain."""
config_stub.val.bindings.commands = {'normal':
{'aa': 'message-info aa'}}
- keyparser._read_config('normal')
- handle_text(Qt.Key_A, Qt.Key_X)
+ handle_text(keyparser, Qt.Key_A, Qt.Key_X)
keyparser.execute.assert_called_once_with('message-info aa', None)
- def test_binding_with_shift(self, keyparser, fake_keyevent):
+ def test_binding_with_shift(self, prompt_keyparser):
"""Simulate a binding which involves shift."""
for key, modifiers in [(Qt.Key_Y, Qt.NoModifier),
(Qt.Key_Shift, Qt.ShiftModifier),
(Qt.Key_Y, Qt.ShiftModifier)]:
- keyparser.handle(fake_keyevent(key, modifiers))
+ info = keyutils.KeyInfo(key, modifiers)
+ prompt_keyparser.handle(info.to_event())
- keyparser.execute.assert_called_once_with('yank -s', None)
+ prompt_keyparser.execute.assert_called_once_with('yank -s', None)
- def test_partial_before_full_match(self, keyparser, fake_keyevent,
- config_stub):
+ def test_partial_before_full_match(self, keyparser, config_stub):
"""Make sure full matches always take precedence over partial ones."""
config_stub.val.bindings.commands = {
'normal': {
@@ -278,8 +264,8 @@ class TestHandle:
'a': 'message-info foo'
}
}
- keyparser._read_config('normal')
- keyparser.handle(fake_keyevent(Qt.Key_A))
+ info = keyutils.KeyInfo(Qt.Key_A, Qt.NoModifier)
+ keyparser.handle(info.to_event())
keyparser.execute.assert_called_once_with('message-info foo', None)
@@ -287,59 +273,62 @@ class TestCount:
"""Test execute() with counts."""
- @pytest.fixture(autouse=True)
- def read_keyparser_config(self, keyinput_bindings, keyparser):
- keyparser._read_config('prompt')
-
- def test_no_count(self, handle_text, keyparser):
+ def test_no_count(self, handle_text, prompt_keyparser):
"""Test with no count added."""
- handle_text(Qt.Key_B, Qt.Key_A)
- keyparser.execute.assert_called_once_with('message-info ba', None)
- assert not keyparser._sequence
+ handle_text(prompt_keyparser, Qt.Key_B, Qt.Key_A)
+ prompt_keyparser.execute.assert_called_once_with(
+ 'message-info ba', None)
+ assert not prompt_keyparser._sequence
- def test_count_0(self, handle_text, keyparser):
- handle_text(Qt.Key_0, Qt.Key_B, Qt.Key_A)
+ def test_count_0(self, handle_text, prompt_keyparser):
+ handle_text(prompt_keyparser, Qt.Key_0, Qt.Key_B, Qt.Key_A)
calls = [mock.call('message-info 0', None),
mock.call('message-info ba', None)]
- keyparser.execute.assert_has_calls(calls)
- assert not keyparser._sequence
+ prompt_keyparser.execute.assert_has_calls(calls)
+ assert not prompt_keyparser._sequence
- def test_count_42(self, handle_text, keyparser):
- handle_text(Qt.Key_4, Qt.Key_2, Qt.Key_B, Qt.Key_A)
- keyparser.execute.assert_called_once_with('message-info ba', 42)
- assert not keyparser._sequence
+ def test_count_42(self, handle_text, prompt_keyparser):
+ handle_text(prompt_keyparser, Qt.Key_4, Qt.Key_2, Qt.Key_B, Qt.Key_A)
+ prompt_keyparser.execute.assert_called_once_with('message-info ba', 42)
+ assert not prompt_keyparser._sequence
- def test_count_42_invalid(self, handle_text, keyparser):
+ def test_count_42_invalid(self, handle_text, prompt_keyparser):
# Invalid call with ccx gets ignored
- handle_text(Qt.Key_4, Qt.Key_2, Qt.Key_C, Qt.Key_C, Qt.Key_X)
- assert not keyparser.execute.called
- assert not keyparser._sequence
+ handle_text(prompt_keyparser,
+ Qt.Key_4, Qt.Key_2, Qt.Key_C, Qt.Key_C, Qt.Key_X)
+ assert not prompt_keyparser.execute.called
+ assert not prompt_keyparser._sequence
# Valid call with ccc gets the correct count
- handle_text(Qt.Key_2, Qt.Key_3, Qt.Key_C, Qt.Key_C, Qt.Key_C)
- keyparser.execute.assert_called_once_with('message-info ccc', 23)
- assert not keyparser._sequence
+ handle_text(prompt_keyparser,
+ Qt.Key_2, Qt.Key_3, Qt.Key_C, Qt.Key_C, Qt.Key_C)
+ prompt_keyparser.execute.assert_called_once_with(
+ 'message-info ccc', 23)
+ assert not prompt_keyparser._sequence
- def test_superscript(self, handle_text, keyparser):
+ def test_superscript(self, handle_text, prompt_keyparser):
# https://github.com/qutebrowser/qutebrowser/issues/3743
- handle_text(Qt.Key_twosuperior, Qt.Key_B, Qt.Key_A)
+ handle_text(prompt_keyparser, Qt.Key_twosuperior, Qt.Key_B, Qt.Key_A)
- def test_count_keystring_update(self, qtbot, handle_text, keyparser):
+ def test_count_keystring_update(self, qtbot,
+ handle_text, prompt_keyparser):
"""Make sure the keystring is updated correctly when entering count."""
- with qtbot.waitSignals([keyparser.keystring_updated,
- keyparser.keystring_updated]) as blocker:
- handle_text(Qt.Key_4, Qt.Key_2)
+ with qtbot.waitSignals([
+ prompt_keyparser.keystring_updated,
+ prompt_keyparser.keystring_updated]) as blocker:
+ handle_text(prompt_keyparser, Qt.Key_4, Qt.Key_2)
sig1, sig2 = blocker.all_signals_and_args
assert sig1.args == ('4',)
assert sig2.args == ('42',)
- def test_numpad(self, fake_keyevent, keyparser):
+ def test_numpad(self, prompt_keyparser):
"""Make sure we can enter a count via numpad."""
for key, modifiers in [(Qt.Key_4, Qt.KeypadModifier),
(Qt.Key_2, Qt.KeypadModifier),
(Qt.Key_B, Qt.NoModifier),
(Qt.Key_A, Qt.NoModifier)]:
- keyparser.handle(fake_keyevent(key, modifiers))
- keyparser.execute.assert_called_once_with('message-info ba', 42)
+ info = keyutils.KeyInfo(key, modifiers)
+ prompt_keyparser.handle(info.to_event())
+ prompt_keyparser.execute.assert_called_once_with('message-info ba', 42)
def test_clear_keystring(qtbot, keyparser):
diff --git a/tests/unit/keyinput/test_bindingtrie.py b/tests/unit/keyinput/test_bindingtrie.py
index d7b3e4729..9a2ef10b9 100644
--- a/tests/unit/keyinput/test_bindingtrie.py
+++ b/tests/unit/keyinput/test_bindingtrie.py
@@ -21,6 +21,7 @@
import string
import itertools
+import textwrap
import pytest
@@ -45,6 +46,39 @@ def test_matches_single(entered, configured, match_type):
assert trie.matches(entered) == result
+def test_str():
+ bindings = {
+ keyutils.KeySequence.parse('a'): 'cmd-a',
+ keyutils.KeySequence.parse('ba'): 'cmd-ba',
+ keyutils.KeySequence.parse('bb'): 'cmd-bb',
+ keyutils.KeySequence.parse('cax'): 'cmd-cax',
+ keyutils.KeySequence.parse('cby'): 'cmd-cby',
+ }
+ trie = basekeyparser.BindingTrie()
+ trie.update(bindings)
+
+ expected = """
+ a:
+ => cmd-a
+
+ b:
+ a:
+ => cmd-ba
+ b:
+ => cmd-bb
+
+ c:
+ a:
+ x:
+ => cmd-cax
+ b:
+ y:
+ => cmd-cby
+ """
+
+ assert str(trie) == textwrap.dedent(expected).lstrip('\n')
+
+
@pytest.mark.parametrize('configured, expected', [
([],
# null match
diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py
index a8783f772..0df721c68 100644
--- a/tests/unit/keyinput/test_keyutils.py
+++ b/tests/unit/keyinput/test_keyutils.py
@@ -458,11 +458,11 @@ class TestKeySequence:
Qt.ControlModifier | Qt.ShiftModifier),
(Qt.ShiftModifier, Qt.ShiftModifier),
])
- def test_fake_mac(self, fake_keyevent, modifiers, expected):
+ def test_fake_mac(self, modifiers, expected):
"""Make sure Control/Meta are swapped with a simulated Mac."""
seq = keyutils.KeySequence()
- event = fake_keyevent(key=Qt.Key_A, modifiers=modifiers)
- new = seq.append_event(event)
+ info = keyutils.KeyInfo(key=Qt.Key_A, modifiers=modifiers)
+ new = seq.append_event(info.to_event())
assert new[0] == keyutils.KeyInfo(Qt.Key_A, expected)
@pytest.mark.parametrize('key', [Qt.Key_unknown, 0x0])
@@ -585,28 +585,6 @@ def test_is_printable(key, printable):
(Qt.Key_Escape, Qt.ControlModifier, True),
(Qt.Key_X, Qt.ControlModifier, True),
(Qt.Key_X, Qt.NoModifier, False),
- (Qt.Key_2, Qt.NoModifier, False),
-
- # Keypad should not reset hint keychain - see #3735
- (Qt.Key_2, Qt.KeypadModifier, False),
-
- # Modifiers should not reset hint keychain - see #4264
- (Qt.Key_Shift, Qt.ShiftModifier, False),
- (Qt.Key_Control, Qt.ControlModifier, False),
- (Qt.Key_Alt, Qt.AltModifier, False),
- (Qt.Key_Meta, Qt.MetaModifier, False),
- (Qt.Key_Mode_switch, Qt.GroupSwitchModifier, False),
-])
-def test_is_special_hint_mode(key, modifiers, special):
- assert keyutils.is_special_hint_mode(key, modifiers) == special
-
-
-@pytest.mark.parametrize('key, modifiers, special', [
- (Qt.Key_Escape, Qt.NoModifier, True),
- (Qt.Key_Escape, Qt.ShiftModifier, True),
- (Qt.Key_Escape, Qt.ControlModifier, True),
- (Qt.Key_X, Qt.ControlModifier, True),
- (Qt.Key_X, Qt.NoModifier, False),
(Qt.Key_2, Qt.KeypadModifier, True),
(Qt.Key_2, Qt.NoModifier, False),
(Qt.Key_Shift, Qt.ShiftModifier, True),
diff --git a/tests/unit/keyinput/test_modeman.py b/tests/unit/keyinput/test_modeman.py
index 89296f06b..b473294f8 100644
--- a/tests/unit/keyinput/test_modeman.py
+++ b/tests/unit/keyinput/test_modeman.py
@@ -22,12 +22,14 @@ import pytest
from PyQt5.QtCore import Qt, QObject, pyqtSignal
from qutebrowser.utils import usertypes
+from qutebrowser.keyinput import keyutils
class FakeKeyparser(QObject):
"""A fake BaseKeyParser which doesn't handle anything."""
+ keystring_updated = pyqtSignal(str)
request_leave = pyqtSignal(usertypes.KeyMode, str, bool)
def __init__(self):
@@ -51,7 +53,7 @@ def modeman(mode_manager):
(Qt.Key_A, Qt.ShiftModifier, True),
(Qt.Key_A, Qt.ShiftModifier | Qt.ControlModifier, False),
])
-def test_non_alphanumeric(key, modifiers, filtered, fake_keyevent, modeman):
+def test_non_alphanumeric(key, modifiers, filtered, modeman):
"""Make sure non-alphanumeric keys are passed through correctly."""
- evt = fake_keyevent(key=key, modifiers=modifiers)
+ evt = keyutils.KeyInfo(key=key, modifiers=modifiers).to_event()
assert modeman.handle_event(evt) == filtered
diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py
index ebaafa076..1f1bcfe11 100644
--- a/tests/unit/keyinput/test_modeparsers.py
+++ b/tests/unit/keyinput/test_modeparsers.py
@@ -46,18 +46,18 @@ class TestsNormalKeyParser:
kp = modeparsers.NormalKeyParser(win_id=0, commandrunner=commandrunner)
return kp
- def test_keychain(self, keyparser, fake_keyevent, commandrunner):
+ def test_keychain(self, keyparser, commandrunner):
"""Test valid keychain."""
# Press 'z' which is ignored because of no match
- keyparser.handle(fake_keyevent(Qt.Key_Z))
# Then start the real chain
- keyparser.handle(fake_keyevent(Qt.Key_B))
- keyparser.handle(fake_keyevent(Qt.Key_A))
+ chain = keyutils.KeySequence.parse('zba')
+ for info in chain:
+ keyparser.handle(info.to_event())
assert commandrunner.commands == [('message-info ba', None)]
assert not keyparser._sequence
def test_partial_keychain_timeout(self, keyparser, config_stub,
- fake_keyevent, qtbot, commandrunner):
+ qtbot, commandrunner):
"""Test partial keychain timeout."""
config_stub.val.input.partial_timeout = 100
timer = keyparser._partial_timer
@@ -65,7 +65,7 @@ class TestsNormalKeyParser:
# Press 'b' for a partial match.
# Then we check if the timer has been set up correctly
- keyparser.handle(fake_keyevent(Qt.Key_B))
+ keyparser.handle(keyutils.KeyInfo(Qt.Key_B, Qt.NoModifier).to_event())
assert timer.isSingleShot()
assert timer.interval() == 100
assert timer.isActive()
@@ -95,32 +95,81 @@ class TestHintKeyParser:
hintmanager=hintmanager,
commandrunner=commandrunner)
- @pytest.mark.parametrize('bindings, event1, event2, prefix, command', [
+ @pytest.mark.parametrize('bindings, keychain, prefix, hint', [
(
['aa', 'as'],
- [Qt.Key_A],
- [Qt.Key_S],
+ 'as',
'a',
- 'follow-hint -s as'
+ 'as'
),
(
['21', '22'],
- [Qt.Key_2, Qt.KeypadModifier],
- [Qt.Key_2, Qt.KeypadModifier],
+ '<Num+2><Num+2>',
'2',
- 'follow-hint -s 22'
+ '22'
+ ),
+ (
+ ['äa', 'äs'],
+ 'äs',
+ 'ä',
+ 'äs'
+ ),
+ (
+ ['не', 'на'],
+ 'не',
+ '<Н>',
+ 'не',
),
])
- def test_match(self, keyparser, fake_keyevent, commandrunner, hintmanager,
- bindings, event1, event2, prefix, command):
+ def test_match(self, keyparser, hintmanager,
+ bindings, keychain, prefix, hint):
keyparser.update_bindings(bindings)
- match = keyparser.handle(fake_keyevent(*event1))
+ seq = keyutils.KeySequence.parse(keychain)
+ assert len(seq) == 2
+
+ match = keyparser.handle(seq[0].to_event())
assert match == QKeySequence.PartialMatch
assert hintmanager.keystr == prefix
- match = keyparser.handle(fake_keyevent(*event2))
+ match = keyparser.handle(seq[1].to_event())
assert match == QKeySequence.ExactMatch
- assert not hintmanager.keystr
+ assert hintmanager.keystr == hint
- assert commandrunner.commands == [(command, None)]
+ def test_match_key_mappings(self, config_stub, keyparser, hintmanager):
+ config_stub.val.bindings.key_mappings = {'α': 'a', 'σ': 's'}
+ keyparser.update_bindings(['aa', 'as'])
+
+ seq = keyutils.KeySequence.parse('ασ')
+ assert len(seq) == 2
+
+ match = keyparser.handle(seq[0].to_event())
+ assert match == QKeySequence.PartialMatch
+ assert hintmanager.keystr == 'a'
+
+ match = keyparser.handle(seq[1].to_event())
+ assert match == QKeySequence.ExactMatch
+ assert hintmanager.keystr == 'as'
+
+ def test_command(self, keyparser, config_stub, hintmanager, commandrunner):
+ config_stub.val.bindings.commands = {
+ 'hint': {'abc': 'message-info abc'}
+ }
+
+ keyparser.update_bindings(['xabcy'])
+
+ steps = [
+ (Qt.Key_X, QKeySequence.PartialMatch, 'x'),
+ (Qt.Key_A, QKeySequence.PartialMatch, ''),
+ (Qt.Key_B, QKeySequence.PartialMatch, ''),
+ (Qt.Key_C, QKeySequence.ExactMatch, ''),
+ ]
+ for key, expected_match, keystr in steps:
+ info = keyutils.KeyInfo(key, Qt.NoModifier)
+ match = keyparser.handle(info.to_event())
+ assert match == expected_match
+ assert hintmanager.keystr == keystr
+ if key != Qt.Key_C:
+ assert not commandrunner.commands
+
+ assert commandrunner.commands == [('message-info abc', None)]
diff --git a/tests/unit/mainwindow/test_messageview.py b/tests/unit/mainwindow/test_messageview.py
index 68d3b2c56..8691bf07f 100644
--- a/tests/unit/mainwindow/test_messageview.py
+++ b/tests/unit/mainwindow/test_messageview.py
@@ -36,7 +36,7 @@ def view(qtbot, config_stub):
usertypes.MessageLevel.warning,
usertypes.MessageLevel.error])
def test_single_message(qtbot, view, level):
- with qtbot.waitExposed(view):
+ with qtbot.waitExposed(view, timeout=5000):
view.show_message(level, 'test')
assert view._messages[0].isVisible()
diff --git a/tests/unit/mainwindow/test_tabbedbrowser.py b/tests/unit/mainwindow/test_tabbedbrowser.py
new file mode 100644
index 000000000..a0f772cf9
--- /dev/null
+++ b/tests/unit/mainwindow/test_tabbedbrowser.py
@@ -0,0 +1,32 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2020 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+
+import pytest
+
+from qutebrowser.mainwindow import tabbedbrowser
+
+
+class TestTabDeque:
+
+ @pytest.mark.parametrize('size', [-1, 5])
+ def test_size_handling(self, size, config_stub):
+ config_stub.val.tabs.focus_stack_size = size
+ dq = tabbedbrowser.TabDeque()
+ dq.update_size()
diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py
index edd32b5a5..96dd558ff 100644
--- a/tests/unit/misc/test_editor.py
+++ b/tests/unit/misc/test_editor.py
@@ -20,8 +20,8 @@
"""Tests for qutebrowser.misc.editor."""
import time
+import pathlib
import os
-import os.path
import logging
from PyQt5.QtCore import QProcess
@@ -76,16 +76,16 @@ class TestFileHandling:
def test_ok(self, editor):
"""Test file handling when closing with an exit status == 0."""
editor.edit("")
- filename = editor._filename
- assert os.path.exists(filename)
- assert os.path.basename(filename).startswith('qutebrowser-editor-')
+ filename = pathlib.Path(editor._filename)
+ assert filename.exists()
+ assert filename.name.startswith('qutebrowser-editor-')
editor._proc.finished.emit(0, QProcess.NormalExit)
- assert not os.path.exists(filename)
+ assert not filename.exists()
- def test_existing_file(self, editor, tmpdir):
+ def test_existing_file(self, editor, tmp_path):
"""Test editing an existing file."""
- path = tmpdir / 'foo.txt'
- path.ensure()
+ path = tmp_path / 'foo.txt'
+ path.touch()
editor.edit_file(str(path))
editor._proc.finished.emit(0, QProcess.NormalExit)
@@ -95,62 +95,62 @@ class TestFileHandling:
def test_error(self, editor):
"""Test file handling when closing with an exit status != 0."""
editor.edit("")
- filename = editor._filename
- assert os.path.exists(filename)
+ filename = pathlib.Path(editor._filename)
+ assert filename.exists()
editor._proc._proc.exitStatus = lambda: QProcess.CrashExit
editor._proc.finished.emit(1, QProcess.NormalExit)
- assert os.path.exists(filename)
+ assert filename.exists()
- os.remove(filename)
+ filename.unlink()
def test_crash(self, editor):
"""Test file handling when closing with a crash."""
editor.edit("")
- filename = editor._filename
- assert os.path.exists(filename)
+ filename = pathlib.Path(editor._filename)
+ assert filename.exists()
editor._proc._proc.exitStatus = lambda: QProcess.CrashExit
editor._proc.error.emit(QProcess.Crashed)
editor._proc.finished.emit(0, QProcess.CrashExit)
- assert os.path.exists(filename)
+ assert filename.exists()
- os.remove(filename)
+ filename.unlink()
def test_unreadable(self, message_mock, editor, caplog, qtbot):
"""Test file handling when closing with an unreadable file."""
editor.edit("")
- filename = editor._filename
- assert os.path.exists(filename)
- os.chmod(filename, 0o277)
- if os.access(filename, os.R_OK):
+ filename = pathlib.Path(editor._filename)
+ assert filename.exists()
+ filename.chmod(0o277)
+ if os.access(str(filename), os.R_OK):
# Docker container or similar
pytest.skip("File was still readable")
with caplog.at_level(logging.ERROR):
editor._proc.finished.emit(0, QProcess.NormalExit)
- assert not os.path.exists(filename)
+ assert not filename.exists()
msg = message_mock.getmsg(usertypes.MessageLevel.error)
assert msg.text.startswith("Failed to read back edited file: ")
@pytest.fixture
- def unwritable_tmpdir(self, tmpdir):
- tmpdir.chmod(0)
- if os.access(str(tmpdir), os.W_OK):
+ def unwritable_tmp_path(self, tmp_path):
+ tmp_path.chmod(0)
+ if os.access(str(tmp_path), os.W_OK):
# Docker container or similar
pytest.skip("File was still writable")
- yield tmpdir
+ yield tmp_path
- tmpdir.chmod(0o755)
+ tmp_path.chmod(0o755)
def test_unwritable(self, monkeypatch, message_mock, editor,
- unwritable_tmpdir, caplog):
+ unwritable_tmp_path, caplog):
"""Test file handling when the initial file is not writable."""
monkeypatch.setattr(editormod.tempfile, 'tempdir',
- str(unwritable_tmpdir))
+ str(unwritable_tmp_path))
with caplog.at_level(logging.ERROR):
editor.edit("")
@@ -167,7 +167,7 @@ class TestFileHandling:
def test_backup(self, qtbot, message_mock):
editor = editormod.ExternalEditor(watch=True)
editor.edit('foo')
- with qtbot.wait_signal(editor.file_updated):
+ with qtbot.wait_signal(editor.file_updated, timeout=5000):
_update_file(editor._filename, 'bar')
editor.backup()
diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py
index 0b52584b6..95858f837 100644
--- a/tests/unit/misc/test_ipc.py
+++ b/tests/unit/misc/test_ipc.py
@@ -20,6 +20,7 @@
"""Tests for qutebrowser.misc.ipc."""
import os
+import pathlib
import getpass
import logging
import json
@@ -35,7 +36,7 @@ from PyQt5.QtTest import QSignalSpy
import qutebrowser
from qutebrowser.misc import ipc
from qutebrowser.utils import standarddir, utils
-from helpers import stubs
+from helpers import stubs, utils as testutils
pytestmark = pytest.mark.usefixtures('qapp')
@@ -83,7 +84,7 @@ def qlocalsocket(qapp):
@pytest.fixture(autouse=True)
def fake_runtime_dir(monkeypatch, short_tmpdir):
monkeypatch.setenv('XDG_RUNTIME_DIR', str(short_tmpdir))
- standarddir._init_dirs()
+ standarddir._init_runtime(args=None)
return short_tmpdir
@@ -297,10 +298,10 @@ class TestListen:
def test_permissions_posix(self, ipc_server):
ipc_server.listen()
sockfile = ipc_server._server.fullServerName()
- sockdir = os.path.dirname(sockfile)
+ sockdir = pathlib.Path(sockfile).parent
file_stat = os.stat(sockfile)
- dir_stat = os.stat(sockdir)
+ dir_stat = sockdir.stat()
# pylint: disable=no-member,useless-suppression
file_owner_ok = file_stat.st_uid == os.getuid()
@@ -319,7 +320,7 @@ class TestListen:
@pytest.mark.posix
def test_atime_update(self, qtbot, ipc_server):
- ipc_server._atime_timer.setInterval(500) # We don't want to wait 6h
+ ipc_server._atime_timer.setInterval(500) # We don't want to wait
ipc_server.listen()
old_atime = os.stat(ipc_server._server.fullServerName()).st_atime_ns
@@ -347,6 +348,25 @@ class TestListen:
ipc_server._atime_timer.timeout.disconnect(ipc_server.update_atime)
ipc_server.shutdown()
+ @pytest.mark.posix
+ def test_vanished_runtime_file(self, qtbot, caplog, ipc_server):
+ ipc_server._atime_timer.setInterval(500) # We don't want to wait
+ ipc_server.listen()
+
+ sockfile = pathlib.Path(ipc_server._server.fullServerName())
+ sockfile.unlink()
+
+ with caplog.at_level(logging.ERROR):
+ with qtbot.waitSignal(ipc_server._atime_timer.timeout,
+ timeout=2000):
+ pass
+
+ msg = 'Failed to update IPC socket, trying to re-listen...'
+ assert caplog.messages[-1] == msg
+
+ assert ipc_server._server.isListening()
+ assert sockfile.exists()
+
class TestOnError:
@@ -504,7 +524,7 @@ class TestSendToRunningInstance:
@pytest.mark.parametrize('has_cwd', [True, False])
@pytest.mark.linux(reason="Causes random trouble on Windows and macOS")
- def test_normal(self, qtbot, tmpdir, ipc_server, mocker, has_cwd):
+ def test_normal(self, qtbot, tmp_path, ipc_server, mocker, has_cwd):
ipc_server.listen()
with qtbot.assertNotEmitted(ipc_server.got_invalid_data):
@@ -512,7 +532,7 @@ class TestSendToRunningInstance:
timeout=5000) as blocker:
with qtbot.waitSignal(ipc_server.got_raw,
timeout=5000) as raw_blocker:
- with tmpdir.as_cwd():
+ with testutils.change_cwd(tmp_path):
if not has_cwd:
m = mocker.patch('qutebrowser.misc.ipc.os')
m.getcwd.side_effect = OSError
@@ -521,7 +541,7 @@ class TestSendToRunningInstance:
assert sent
- expected_cwd = str(tmpdir) if has_cwd else ''
+ expected_cwd = str(tmp_path) if has_cwd else ''
assert blocker.args == [['foo'], '', expected_cwd]
@@ -529,7 +549,7 @@ class TestSendToRunningInstance:
'version': qutebrowser.__version__,
'protocol_version': ipc.PROTOCOL_VERSION}
if has_cwd:
- raw_expected['cwd'] = str(tmpdir)
+ raw_expected['cwd'] = str(tmp_path)
assert len(raw_blocker.args) == 1
parsed = json.loads(raw_blocker.args[0].decode('utf-8'))
diff --git a/tests/unit/misc/test_keyhints.py b/tests/unit/misc/test_keyhints.py
index 2e9ea1aaf..a9f8ed311 100644
--- a/tests/unit/misc/test_keyhints.py
+++ b/tests/unit/misc/test_keyhints.py
@@ -21,6 +21,7 @@
import pytest
+from qutebrowser.utils import usertypes
from qutebrowser.misc import objects
from qutebrowser.misc.keyhintwidget import KeyHintView
@@ -57,7 +58,7 @@ def test_show_and_hide(qtbot, keyhint):
with qtbot.waitSignal(keyhint.update_geometry):
with qtbot.waitExposed(keyhint):
keyhint.show()
- keyhint.update_keyhint('normal', '')
+ keyhint.update_keyhint(usertypes.KeyMode.normal, '')
assert not keyhint.isVisible()
@@ -84,7 +85,7 @@ def test_suggestions(keyhint, config_stub):
config_stub.val.bindings.default = default_bindings
config_stub.val.bindings.commands = bindings
- keyhint.update_keyhint('normal', 'a')
+ keyhint.update_keyhint(usertypes.KeyMode.normal, 'a')
assert keyhint.text() == expected_text(
('a', 'yellow', 'a', 'message-info cmd-aa'),
('a', 'yellow', 'b', 'message-info cmd-ab'),
@@ -109,7 +110,7 @@ def test_suggestions_special(keyhint, config_stub):
config_stub.val.bindings.default = default_bindings
config_stub.val.bindings.commands = bindings
- keyhint.update_keyhint('normal', '<Ctrl+c>')
+ keyhint.update_keyhint(usertypes.KeyMode.normal, '<Ctrl+c>')
assert keyhint.text() == expected_text(
('&lt;Ctrl+c&gt;', 'yellow', 'a', 'message-info cmd-Cca'),
('&lt;Ctrl+c&gt;', 'yellow', 'c', 'message-info cmd-Ccc'),
@@ -130,7 +131,7 @@ def test_suggestions_with_count(keyhint, config_stub, monkeypatch, stubs):
config_stub.val.bindings.default = bindings
config_stub.val.bindings.commands = bindings
- keyhint.update_keyhint('normal', '2a')
+ keyhint.update_keyhint(usertypes.KeyMode.normal, '2a')
assert keyhint.text() == expected_text(
('a', 'yellow', 'b', 'bar'),
)
@@ -146,7 +147,7 @@ def test_special_bindings(keyhint, config_stub):
config_stub.val.bindings.default = {}
config_stub.val.bindings.commands = bindings
- keyhint.update_keyhint('normal', '<')
+ keyhint.update_keyhint(usertypes.KeyMode.normal, '<')
assert keyhint.text() == expected_text(
('&lt;', 'yellow', 'a', 'message-info cmd-&lt;a'),
@@ -159,7 +160,7 @@ def test_color_switch(keyhint, config_stub):
config_stub.val.colors.keyhint.suffix.fg = '#ABCDEF'
config_stub.val.bindings.default = {}
config_stub.val.bindings.commands = bindings
- keyhint.update_keyhint('normal', 'a')
+ keyhint.update_keyhint(usertypes.KeyMode.normal, 'a')
assert keyhint.text() == expected_text(('a', '#ABCDEF', 'a',
'message-info cmd-aa'))
@@ -173,7 +174,7 @@ def test_no_matches(keyhint, config_stub):
config_stub.val.bindings.default = {}
config_stub.val.bindings.commands = bindings
- keyhint.update_keyhint('normal', 'z')
+ keyhint.update_keyhint(usertypes.KeyMode.normal, 'z')
assert not keyhint.text()
assert not keyhint.isVisible()
@@ -196,7 +197,7 @@ def test_blacklist(keyhint, config_stub, blacklist, expected):
config_stub.val.bindings.default = {}
config_stub.val.bindings.commands = bindings
- keyhint.update_keyhint('normal', 'a')
+ keyhint.update_keyhint(usertypes.KeyMode.normal, 'a')
assert keyhint.text() == expected
@@ -213,6 +214,6 @@ def test_delay(qtbot, stubs, monkeypatch, config_stub, key_config_stub):
config_stub.val.bindings.commands = bindings
keyhint = KeyHintView(0, None)
- keyhint.update_keyhint('normal', 'a')
+ keyhint.update_keyhint(usertypes.KeyMode.normal, 'a')
assert timer.isSingleShot()
assert timer.interval() == interval
diff --git a/tests/unit/misc/test_lineparser.py b/tests/unit/misc/test_lineparser.py
index 9ddeaa93e..cdb16d04a 100644
--- a/tests/unit/misc/test_lineparser.py
+++ b/tests/unit/misc/test_lineparser.py
@@ -19,7 +19,7 @@
"""Tests for qutebrowser.misc.lineparser."""
-import os
+import pathlib
from unittest import mock
import pytest
@@ -66,7 +66,7 @@ class TestBaseLineParser:
lineparser._write(f, [testdata])
open_mock.assert_called_once_with(
- os.path.join(self.CONFDIR, self.FILENAME), 'rb')
+ str(pathlib.Path(self.CONFDIR) / self.FILENAME), 'rb')
open_mock().write.assert_has_calls([
mock.call(testdata),
@@ -77,30 +77,31 @@ class TestBaseLineParser:
class TestLineParser:
@pytest.fixture
- def lineparser(self, tmpdir):
+ def lineparser(self, tmp_path):
"""Fixture to get a LineParser for tests."""
- lp = lineparsermod.LineParser(str(tmpdir), 'file')
+ lp = lineparsermod.LineParser(str(tmp_path), 'file')
lp.save()
return lp
- def test_init(self, tmpdir):
+ def test_init(self, tmp_path):
"""Test if creating a line parser correctly reads its file."""
- (tmpdir / 'file').write('one\ntwo\n')
- lineparser = lineparsermod.LineParser(str(tmpdir), 'file')
+ (tmp_path / 'file').write_text('one\ntwo\n')
+ lineparser = lineparsermod.LineParser(str(tmp_path), 'file')
assert lineparser.data == ['one', 'two']
- (tmpdir / 'file').write_binary(b'\xfe\n\xff\n')
- lineparser = lineparsermod.LineParser(str(tmpdir), 'file', binary=True)
+ (tmp_path / 'file').write_bytes(b'\xfe\n\xff\n')
+ lineparser = lineparsermod.LineParser(str(tmp_path), 'file',
+ binary=True)
assert lineparser.data == [b'\xfe', b'\xff']
- def test_clear(self, tmpdir, lineparser):
+ def test_clear(self, tmp_path, lineparser):
"""Test if clear() empties its file."""
lineparser.data = ['one', 'two']
lineparser.save()
- assert (tmpdir / 'file').read() == 'one\ntwo\n'
+ assert (tmp_path / 'file').read_text() == 'one\ntwo\n'
lineparser.clear()
assert not lineparser.data
- assert (tmpdir / 'file').read() == ''
+ assert (tmp_path / 'file').read_text() == ''
def test_double_open(self, lineparser):
"""Test if save() bails on an already open file."""
@@ -109,10 +110,10 @@ class TestLineParser:
match="Refusing to double-open LineParser."):
lineparser.save()
- def test_prepare_save(self, tmpdir, lineparser):
+ def test_prepare_save(self, tmp_path, lineparser):
"""Test if save() bails when _prepare_save() returns False."""
- (tmpdir / 'file').write('pristine\n')
+ (tmp_path / 'file').write_text('pristine\n')
lineparser.data = ['changed']
lineparser._prepare_save = lambda: False
lineparser.save()
- assert (tmpdir / 'file').read() == 'pristine\n'
+ assert (tmp_path / 'file').read_text() == 'pristine\n'
diff --git a/tests/unit/misc/test_sessions.py b/tests/unit/misc/test_sessions.py
index 4aec19dc5..e052751b5 100644
--- a/tests/unit/misc/test_sessions.py
+++ b/tests/unit/misc/test_sessions.py
@@ -40,9 +40,9 @@ webengine_refactoring_xfail = pytest.mark.xfail(
@pytest.fixture
-def sess_man(tmpdir):
+def sess_man(tmp_path):
"""Fixture providing a SessionManager."""
- return sessions.SessionManager(base_path=str(tmpdir))
+ return sessions.SessionManager(base_path=str(tmp_path))
class TestInit:
@@ -57,11 +57,12 @@ class TestInit:
pass
@pytest.mark.parametrize('create_dir', [True, False])
- def test_with_standarddir(self, tmpdir, monkeypatch, create_dir):
- monkeypatch.setattr(sessions.standarddir, 'data', lambda: str(tmpdir))
- session_dir = tmpdir / 'sessions'
+ def test_with_standarddir(self, tmp_path, monkeypatch, create_dir):
+ monkeypatch.setattr(sessions.standarddir, 'data',
+ lambda: str(tmp_path))
+ session_dir = tmp_path / 'sessions'
if create_dir:
- session_dir.ensure(dir=True)
+ session_dir.mkdir()
sessions.init()
@@ -76,14 +77,14 @@ def test_did_not_load(sess_man):
class TestExists:
@pytest.mark.parametrize('absolute', [True, False])
- def test_existent(self, tmpdir, absolute):
- session_dir = tmpdir / 'sessions'
- abs_session = tmpdir / 'foo.yml'
+ def test_existent(self, tmp_path, absolute):
+ session_dir = tmp_path / 'sessions'
+ abs_session = tmp_path / 'foo.yml'
rel_session = session_dir / 'foo.yml'
- session_dir.ensure(dir=True)
- abs_session.ensure()
- rel_session.ensure()
+ session_dir.mkdir()
+ abs_session.touch()
+ rel_session.touch()
man = sessions.SessionManager(str(session_dir))
@@ -95,11 +96,11 @@ class TestExists:
assert man.exists(name)
@pytest.mark.parametrize('absolute', [True, False])
- def test_inexistent(self, tmpdir, absolute):
- man = sessions.SessionManager(str(tmpdir))
+ def test_inexistent(self, tmp_path, absolute):
+ man = sessions.SessionManager(str(tmp_path))
if absolute:
- name = str(tmpdir / 'foo')
+ name = str(tmp_path / 'foo')
else:
name = 'foo'
@@ -208,13 +209,13 @@ class TestSave:
objreg.delete('main-window', scope='window', window=0)
objreg.delete('tabbed-browser', scope='window', window=0)
- def test_no_state_config(self, sess_man, tmpdir, state_config):
- session_path = tmpdir / 'foo.yml'
+ def test_no_state_config(self, sess_man, tmp_path, state_config):
+ session_path = tmp_path / 'foo.yml'
sess_man.save(str(session_path))
assert 'session' not in state_config['general']
- def test_last_window_session_none(self, caplog, sess_man, tmpdir):
- session_path = tmpdir / 'foo.yml'
+ def test_last_window_session_none(self, caplog, sess_man, tmp_path):
+ session_path = tmp_path / 'foo.yml'
with caplog.at_level(logging.ERROR):
sess_man.save(str(session_path), last_window=True)
@@ -222,9 +223,9 @@ class TestSave:
assert caplog.messages == [msg]
assert not session_path.exists()
- def test_last_window_session(self, sess_man, tmpdir):
+ def test_last_window_session(self, sess_man, tmp_path):
sess_man.save_last_window_session()
- session_path = tmpdir / 'foo.yml'
+ session_path = tmp_path / 'foo.yml'
sess_man.save(str(session_path), last_window=True)
data = session_path.read_text('utf-8')
assert data == 'windows: []\n'
@@ -232,24 +233,24 @@ class TestSave:
@pytest.mark.parametrize('exception', [
OSError('foo'), UnicodeEncodeError('ascii', '', 0, 2, 'foo'),
yaml.YAMLError('foo')])
- def test_fake_exception(self, mocker, sess_man, tmpdir, exception):
+ def test_fake_exception(self, mocker, sess_man, tmp_path, exception):
mocker.patch('qutebrowser.misc.sessions.yaml.dump',
side_effect=exception)
with pytest.raises(sessions.SessionError, match=str(exception)):
- sess_man.save(str(tmpdir / 'foo.yml'))
+ sess_man.save(str(tmp_path / 'foo.yml'))
- assert not tmpdir.listdir()
+ assert not list(tmp_path.glob('*'))
- def test_load_next_time(self, tmpdir, state_config, sess_man):
- session_path = tmpdir / 'foo.yml'
+ def test_load_next_time(self, tmp_path, state_config, sess_man):
+ session_path = tmp_path / 'foo.yml'
sess_man.save(str(session_path), load_next_time=True)
assert state_config['general']['session'] == str(session_path)
@webengine_refactoring_xfail
- def test_utf_8_invalid(self, tmpdir, sess_man, fake_history):
+ def test_utf_8_invalid(self, tmp_path, sess_man, fake_history):
"""Make sure data containing invalid UTF8 raises SessionError."""
- session_path = tmpdir / 'foo.yml'
+ session_path = tmp_path / 'foo.yml'
fake_history([Item(QUrl('http://www.qutebrowser.org/'), '\ud800',
active=True)])
@@ -356,18 +357,18 @@ class TestLoadTab:
class TestListSessions:
- def test_no_sessions(self, tmpdir):
- sess_man = sessions.SessionManager(str(tmpdir))
+ def test_no_sessions(self, tmp_path):
+ sess_man = sessions.SessionManager(str(tmp_path))
assert not sess_man.list_sessions()
- def test_with_sessions(self, tmpdir):
- (tmpdir / 'foo.yml').ensure()
- (tmpdir / 'bar.yml').ensure()
- sess_man = sessions.SessionManager(str(tmpdir))
+ def test_with_sessions(self, tmp_path):
+ (tmp_path / 'foo.yml').touch()
+ (tmp_path / 'bar.yml').touch()
+ sess_man = sessions.SessionManager(str(tmp_path))
assert sess_man.list_sessions() == ['bar', 'foo']
- def test_with_other_files(self, tmpdir):
- (tmpdir / 'foo.yml').ensure()
- (tmpdir / 'bar.html').ensure()
- sess_man = sessions.SessionManager(str(tmpdir))
+ def test_with_other_files(self, tmp_path):
+ (tmp_path / 'foo.yml').touch()
+ (tmp_path / 'bar.html').touch()
+ sess_man = sessions.SessionManager(str(tmp_path))
assert sess_man.list_sessions() == ['foo']
diff --git a/tests/unit/misc/test_throttle.py b/tests/unit/misc/test_throttle.py
index 63babc122..0e2db3aee 100644
--- a/tests/unit/misc/test_throttle.py
+++ b/tests/unit/misc/test_throttle.py
@@ -29,7 +29,7 @@ from helpers import utils
from qutebrowser.misc import throttle
-DELAY = 300 if utils.ON_CI else 100
+DELAY = 500 if utils.ON_CI else 100
@pytest.fixture
diff --git a/tests/unit/misc/test_utilcmds.py b/tests/unit/misc/test_utilcmds.py
index 3066dcc7a..e8f651dc8 100644
--- a/tests/unit/misc/test_utilcmds.py
+++ b/tests/unit/misc/test_utilcmds.py
@@ -19,8 +19,6 @@
"""Tests for qutebrowser.misc.utilcmds."""
-import logging
-
import pytest
from PyQt5.QtCore import QUrl
@@ -42,17 +40,6 @@ def test_repeat_command_initial(mocker, mode_manager):
utilcmds.repeat_command(win_id=0)
-def test_debug_log_level(mocker):
- """Test interactive log level changing."""
- formatter_mock = mocker.patch(
- 'qutebrowser.misc.utilcmds.log.change_console_formatter')
- handler_mock = mocker.patch(
- 'qutebrowser.misc.utilcmds.log.console_handler')
- utilcmds.debug_log_level(level='debug')
- formatter_mock.assert_called_with(logging.DEBUG)
- handler_mock.setLevel.assert_called_with(logging.DEBUG)
-
-
class FakeWindow:
"""Mock class for window_only."""
diff --git a/tests/unit/scripts/test_check_coverage.py b/tests/unit/scripts/test_check_coverage.py
index bb23db512..d805eb184 100644
--- a/tests/unit/scripts/test_check_coverage.py
+++ b/tests/unit/scripts/test_check_coverage.py
@@ -19,7 +19,7 @@
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
import sys
-import os.path
+import pathlib
import pytest
@@ -216,15 +216,14 @@ def test_skipped_non_linux(covtest):
def _generate_files():
"""Get filenames from WHITELISTED_/PERFECT_FILES."""
for src_file in check_coverage.WHITELISTED_FILES:
- yield os.path.join('qutebrowser', src_file)
+ yield pathlib.Path('qutebrowser') / src_file
for test_file, src_file in check_coverage.PERFECT_FILES:
if test_file is not None:
- yield test_file
- yield os.path.join('qutebrowser', src_file)
+ yield pathlib.Path(test_file)
+ yield pathlib.Path('qutebrowser') / src_file
@pytest.mark.parametrize('filename', list(_generate_files()))
def test_files_exist(filename):
- basedir = os.path.join(os.path.dirname(check_coverage.__file__),
- os.pardir, os.pardir)
- assert os.path.exists(os.path.join(basedir, filename))
+ basedir = pathlib.Path(check_coverage.__file__).parents[2]
+ assert (basedir / filename).exists()
diff --git a/tests/unit/scripts/test_dictcli.py b/tests/unit/scripts/test_dictcli.py
index 8f02974d9..9add389d8 100644
--- a/tests/unit/scripts/test_dictcli.py
+++ b/tests/unit/scripts/test_dictcli.py
@@ -19,7 +19,8 @@
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-import py.path # pylint: disable=no-name-in-module
+import pathlib
+
import pytest
from qutebrowser.browser.webengine import spell
@@ -59,13 +60,13 @@ def configdata_init():
@pytest.fixture(autouse=True)
-def dict_tmpdir(tmpdir, monkeypatch):
- monkeypatch.setattr(spell, 'dictionary_dir', lambda: str(tmpdir))
- return tmpdir
+def dict_tmp_path(tmp_path, monkeypatch):
+ monkeypatch.setattr(spell, 'dictionary_dir', lambda: str(tmp_path))
+ return tmp_path
-def test_language(dict_tmpdir):
- (dict_tmpdir / 'pl-PL-2-0.bdic').ensure()
+def test_language(dict_tmp_path):
+ (dict_tmp_path / 'pl-PL-2-0.bdic').touch()
assert english().local_filename is None
assert polish()
@@ -82,9 +83,9 @@ def test_latest_yet():
assert dictcli.latest_yet(code2file, 'en-US', 'en-US-8-0.bdic')
-def test_available_languages(dict_tmpdir, monkeypatch):
+def test_available_languages(dict_tmp_path, monkeypatch):
for f in ['pl-PL-2-0.bdic', english().remote_filename]:
- (dict_tmpdir / f).ensure()
+ (dict_tmp_path / f).touch()
monkeypatch.setattr(dictcli, 'language_list_from_api', lambda: [
(lang.code, lang.remote_filename) for lang in langs()
])
@@ -118,27 +119,27 @@ def test_filter_languages():
dictcli.filter_languages(langs(), ['pl-PL', 'en-GB'])
-def test_install(dict_tmpdir, monkeypatch):
+def test_install(dict_tmp_path, monkeypatch):
# given
monkeypatch.setattr(
dictcli, 'download_dictionary',
- lambda _url, dest: py.path.local(dest).ensure()) # pylint: disable=no-member
+ lambda _url, dest: pathlib.Path(dest).touch())
# when
dictcli.install(langs())
# then
- installed_files = [f.basename for f in dict_tmpdir.listdir()]
+ installed_files = [f.name for f in dict_tmp_path.glob('*')]
expected_files = [lang.remote_filename for lang in langs()]
assert sorted(installed_files) == sorted(expected_files)
-def test_update(dict_tmpdir, monkeypatch):
+def test_update(dict_tmp_path, monkeypatch):
# given
monkeypatch.setattr(
dictcli, 'download_dictionary',
- lambda _url, dest: py.path.local(dest).ensure()) # pylint: disable=no-member
- (dict_tmpdir / 'pl-PL-2-0.bdic').ensure()
+ lambda _url, dest: pathlib.Path(dest).touch())
+ (dict_tmp_path / 'pl-PL-2-0.bdic').touch()
assert polish().local_version < polish().remote_version
# when
@@ -148,20 +149,20 @@ def test_update(dict_tmpdir, monkeypatch):
assert polish().local_version == polish().remote_version
-def test_remove_old(dict_tmpdir, monkeypatch):
+def test_remove_old(dict_tmp_path, monkeypatch):
# given
monkeypatch.setattr(
dictcli, 'download_dictionary',
- lambda _url, dest: py.path.local(dest).ensure()) # pylint: disable=no-member
+ lambda _url, dest: pathlib.Path(dest).touch())
for f in ['pl-PL-2-0.bdic',
polish().remote_filename,
english().remote_filename]:
- (dict_tmpdir / f).ensure()
+ (dict_tmp_path / f).touch()
# when
dictcli.remove_old(langs())
# then
- installed_files = [f.basename for f in dict_tmpdir.listdir()]
+ installed_files = [f.name for f in dict_tmp_path.glob('*')]
expected_files = [polish().remote_filename, english().remote_filename]
assert sorted(installed_files) == sorted(expected_files)
diff --git a/tests/unit/scripts/test_importer.py b/tests/unit/scripts/test_importer.py
index 950987afc..4a70ae63e 100644
--- a/tests/unit/scripts/test_importer.py
+++ b/tests/unit/scripts/test_importer.py
@@ -18,37 +18,31 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-import os
+import pathlib
import pytest
from scripts import importer
-_samples = 'tests/unit/scripts/importer_sample'
+_samples = pathlib.Path('tests/unit/scripts/importer_sample')
def qm_expected(input_format):
"""Read expected quickmark-formatted output."""
- with open(os.path.join(_samples, input_format, 'quickmarks'),
- 'r', encoding='utf-8') as f:
- return f.read()
+ return (_samples / input_format / 'quickmarks').read_text(encoding='utf-8')
def bm_expected(input_format):
"""Read expected bookmark-formatted output."""
- with open(os.path.join(_samples, input_format, 'bookmarks'),
- 'r', encoding='utf-8') as f:
- return f.read()
+ return (_samples / input_format / 'bookmarks').read_text(encoding='utf-8')
def search_expected(input_format):
"""Read expected search-formatted (config.py) output."""
- with open(os.path.join(_samples, input_format, 'config_py'),
- 'r', encoding='utf-8') as f:
- return f.read()
+ return (_samples / input_format / 'config_py').read_text(encoding='utf-8')
def sample_input(input_format):
"""Get the sample input path."""
- return os.path.join(_samples, input_format, 'input')
+ return str(_samples / input_format / 'input')
def test_opensearch_convert():
diff --git a/tests/unit/scripts/test_run_vulture.py b/tests/unit/scripts/test_run_vulture.py
index 25630b9fc..edf3451cb 100644
--- a/tests/unit/scripts/test_run_vulture.py
+++ b/tests/unit/scripts/test_run_vulture.py
@@ -23,6 +23,8 @@ import textwrap
import pytest
+from tests.helpers import utils
+
try:
from scripts.dev import run_vulture
except ImportError:
@@ -41,29 +43,29 @@ class VultureDir:
"""Fixture similar to pytest's testdir fixture for vulture.
Attributes:
- _tmpdir: The pytest tmpdir fixture.
+ _tmp_path: The pytest tmp_path fixture.
"""
- def __init__(self, tmpdir):
- self._tmpdir = tmpdir
+ def __init__(self, tmp_path):
+ self._tmp_path = tmp_path
def run(self):
"""Run vulture over all generated files and return the output."""
- files = self._tmpdir.listdir()
- assert files
- with self._tmpdir.as_cwd():
- return run_vulture.run([str(e.basename) for e in files])
+ names = [p.name for p in self._tmp_path.glob('*')]
+ assert names
+ with utils.change_cwd(self._tmp_path):
+ return run_vulture.run(names)
def makepyfile(self, **kwargs):
"""Create a python file, similar to TestDir.makepyfile."""
for filename, data in kwargs.items():
text = textwrap.dedent(data)
- (self._tmpdir / filename + '.py').write_text(text, 'utf-8')
+ (self._tmp_path / (filename + '.py')).write_text(text, 'utf-8')
@pytest.fixture
-def vultdir(tmpdir):
- return VultureDir(tmpdir)
+def vultdir(tmp_path):
+ return VultureDir(tmp_path)
def test_used(vultdir):
diff --git a/tests/unit/test_qutebrowser.py b/tests/unit/test_qutebrowser.py
new file mode 100644
index 000000000..5a792a6d2
--- /dev/null
+++ b/tests/unit/test_qutebrowser.py
@@ -0,0 +1,62 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2020 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+"""Tests for qutebrowser.qutebrowser.
+
+(Mainly commandline flag parsing)
+"""
+
+import pytest
+
+from qutebrowser import qutebrowser
+
+
+@pytest.fixture
+def parser():
+ return qutebrowser.get_argparser()
+
+
+class TestDebugFlag:
+
+ def test_valid(self, parser):
+ args = parser.parse_args(['--debug-flag', 'chromium',
+ '--debug-flag', 'stack'])
+ assert args.debug_flags == ['chromium', 'stack']
+
+ def test_invalid(self, parser, capsys):
+ with pytest.raises(SystemExit):
+ parser.parse_args(['--debug-flag', 'invalid'])
+
+ _out, err = capsys.readouterr()
+ assert 'Invalid debug flag - valid flags:' in err
+
+
+class TestLogFilter:
+
+ def test_valid(self, parser):
+ args = parser.parse_args(['--logfilter', 'misc'])
+ assert args.logfilter == 'misc'
+
+ def test_invalid(self, parser, capsys):
+ with pytest.raises(SystemExit):
+ parser.parse_args(['--logfilter', 'invalid'])
+
+ _out, err = capsys.readouterr()
+ print(err)
+ assert 'Invalid log category invalid - valid categories' in err
diff --git a/tests/unit/utils/test_error.py b/tests/unit/utils/test_error.py
index 82ca3ec55..f8847d39a 100644
--- a/tests/unit/utils/test_error.py
+++ b/tests/unit/utils/test_error.py
@@ -73,10 +73,11 @@ def test_no_err_windows(caplog, exc, name, exc_text):
('foo', 'bar', 'foo: exception\n\nbar'),
('', 'bar', 'exception\n\nbar'),
], ids=repr)
-def test_err_windows(qtbot, qapp, pre_text, post_text, expected):
+def test_err_windows(qtbot, qapp, pre_text, post_text, expected, caplog):
def err_window_check():
w = qapp.activeModalWidget()
+ assert w is not None
try:
qtbot.add_widget(w)
if not utils.is_mac:
@@ -87,7 +88,9 @@ def test_err_windows(qtbot, qapp, pre_text, post_text, expected):
finally:
w.close()
- QTimer.singleShot(0, err_window_check)
- error.handle_fatal_exc(ValueError("exception"), 'title',
- pre_text=pre_text, post_text=post_text,
- no_err_windows=False)
+ QTimer.singleShot(10, err_window_check)
+
+ with caplog.at_level(logging.ERROR):
+ error.handle_fatal_exc(ValueError("exception"), 'title',
+ pre_text=pre_text, post_text=post_text,
+ no_err_windows=False)
diff --git a/tests/unit/utils/test_log.py b/tests/unit/utils/test_log.py
index a74d81600..f73b88b2c 100644
--- a/tests/unit/utils/test_log.py
+++ b/tests/unit/utils/test_log.py
@@ -30,8 +30,10 @@ import pytest
import _pytest.logging
from PyQt5 import QtCore
+from qutebrowser import qutebrowser
from qutebrowser.utils import log
from qutebrowser.misc import utilcmds
+from qutebrowser.api import cmdutils
@pytest.fixture(autouse=True)
@@ -117,51 +119,58 @@ class TestLogFilter:
@pytest.mark.parametrize('filters, negated, category, logged', [
# Filter letting all messages through
- (None, False, 'eggs.bacon.spam', True),
- (None, False, 'eggs', True),
- (None, True, 'ham', True),
+ (set(), False, 'eggs.bacon.spam', True),
+ (set(), False, 'eggs', True),
+ (set(), True, 'ham', True),
# Matching records
- (['eggs', 'bacon'], False, 'eggs', True),
- (['eggs', 'bacon'], False, 'bacon', True),
- (['eggs.bacon'], False, 'eggs.bacon', True),
+ ({'eggs', 'bacon'}, False, 'eggs', True),
+ ({'eggs', 'bacon'}, False, 'bacon', True),
+ ({'eggs'}, False, 'eggs.fried', True),
# Non-matching records
- (['eggs', 'bacon'], False, 'spam', False),
- (['eggs'], False, 'eggsauce', False),
- (['eggs.bacon'], False, 'eggs.baconstrips', False),
- # Child loggers
- (['eggs.bacon', 'spam.ham'], False, 'eggs.bacon.spam', True),
- (['eggs.bacon', 'spam.ham'], False, 'spam.ham.salami', True),
+ ({'eggs', 'bacon'}, False, 'spam', False),
+ ({'eggs'}, False, 'eggsauce', False),
+ ({'fried'}, False, 'eggs.fried', False),
# Suppressed records
- (['eggs', 'bacon'], True, 'eggs', False),
- (['eggs', 'bacon'], True, 'bacon', False),
- (['eggs.bacon'], True, 'eggs.bacon', False),
+ ({'eggs', 'bacon'}, True, 'eggs', False),
+ ({'eggs', 'bacon'}, True, 'bacon', False),
# Non-suppressed records
- (['eggs', 'bacon'], True, 'spam', True),
- (['eggs'], True, 'eggsauce', True),
- (['eggs.bacon'], True, 'eggs.baconstrips', True),
+ ({'eggs', 'bacon'}, True, 'spam', True),
+ ({'eggs'}, True, 'eggsauce', True),
])
def test_logfilter(self, logger, filters, negated, category, logged):
"""Ensure the multi-record filtering filterer filters multiple records.
(Blame @toofar for this comment)
"""
- logfilter = log.LogFilter(filters, negated)
+ logfilter = log.LogFilter(filters, negated=negated)
record = self._make_record(logger, category)
assert logfilter.filter(record) == logged
- @pytest.mark.parametrize('category', ['eggs', 'bacon'])
- def test_debug(self, logger, category):
- """Test if messages more important than debug are never filtered."""
- logfilter = log.LogFilter(['eggs'])
- record = self._make_record(logger, category, level=logging.INFO)
- assert logfilter.filter(record)
+ def test_logfilter_benchmark(self, logger, benchmark):
+ record = self._make_record(logger, 'unfiltered')
+ filters = set(log.LOGGER_NAMES) # Extreme case
+ logfilter = log.LogFilter(filters, negated=False)
+ benchmark(lambda: logfilter.filter(record))
- @pytest.mark.parametrize('category, logged_before, logged_after', [
- ('init', True, False), ('url', False, True), ('js', False, True)])
+ @pytest.mark.parametrize('only_debug', [True, False])
+ def test_debug(self, logger, only_debug):
+ """Test if messages more important than debug are never filtered."""
+ logfilter = log.LogFilter({'eggs'}, only_debug=only_debug)
+ record = self._make_record(logger, 'bacon', level=logging.INFO)
+ assert logfilter.filter(record) == only_debug
+
+ @pytest.mark.parametrize(
+ 'category, filter_str, logged_before, logged_after', [
+ ('init', 'url,js', True, False),
+ ('url', 'url,js', False, True),
+ ('js', 'url,js', False, True),
+ ('js', 'none', False, True),
+ ]
+ )
def test_debug_log_filter_cmd(self, monkeypatch, logger, category,
- logged_before, logged_after):
+ filter_str, logged_before, logged_after):
"""Test the :debug-log-filter command handler."""
- logfilter = log.LogFilter(["init"])
+ logfilter = log.LogFilter({"init"})
monkeypatch.setattr(log, 'console_filter', logfilter)
record = self._make_record(logger, category)
@@ -170,6 +179,37 @@ class TestLogFilter:
utilcmds.debug_log_filter('url,js')
assert logfilter.filter(record) == logged_after
+ def test_debug_log_filter_cmd_invalid(self, monkeypatch):
+ logfilter = log.LogFilter(set())
+ monkeypatch.setattr(log, 'console_filter', logfilter)
+ with pytest.raises(cmdutils.CommandError,
+ match='Invalid log category blabla'):
+ utilcmds.debug_log_filter('blabla')
+
+ @pytest.mark.parametrize('filter_str, expected_names, negated', [
+ ('!js,misc', {'js', 'misc'}, True),
+ ('js,misc', {'js', 'misc'}, False),
+ ('js, misc', {'js', 'misc'}, False),
+ ('JS, Misc', {'js', 'misc'}, False),
+ (None, set(), False),
+ ('none', set(), False),
+ ])
+ def test_parsing(self, filter_str, expected_names, negated):
+ logfilter = log.LogFilter.parse(filter_str)
+ assert logfilter.names == expected_names
+ assert logfilter.negated == negated
+
+ @pytest.mark.parametrize('filter_str, invalid', [
+ ('js,!misc', '!misc'),
+ ('blabla,js,blablub', 'blabla, blablub'),
+ ])
+ def test_parsing_invalid(self, filter_str, invalid):
+ with pytest.raises(
+ log.InvalidLogFilterError,
+ match='Invalid log category {} - '
+ 'valid categories: statusbar, .*'.format(invalid)):
+ log.LogFilter.parse(filter_str)
+
@pytest.mark.parametrize('data, expected', [
# Less data
@@ -198,8 +238,9 @@ class TestInitLog:
def _get_default_args(self):
return argparse.Namespace(debug=True, loglevel='debug', color=True,
- loglines=10, logfilter="", force_color=False,
- json_logging=False, debug_flags=set())
+ loglines=10, logfilter=None,
+ force_color=False, json_logging=False,
+ debug_flags=set())
@pytest.fixture(autouse=True)
def setup(self, mocker):
@@ -215,6 +256,15 @@ class TestInitLog:
"""Fixture providing an argparse namespace for init_log."""
return self._get_default_args()
+ @pytest.fixture
+ def parser(self):
+ return qutebrowser.get_argparser()
+
+ @pytest.fixture
+ def empty_args(self, parser):
+ """Logging commandline arguments without any customization."""
+ return parser.parse_args([])
+
def test_stderr_none(self, args):
"""Test init_log with sys.stderr = None."""
old_stderr = sys.stderr
@@ -222,22 +272,6 @@ class TestInitLog:
log.init_log(args)
sys.stderr = old_stderr
- @pytest.mark.parametrize('logfilter, expected_names, negated', [
- ('!one,two', ['one', 'two'], True),
- ('one,two', ['one', 'two'], False),
- ('one,!two', ['one', '!two'], False),
- (None, None, False),
- ])
- def test_negation_parser(self, args, mocker,
- logfilter, expected_names, negated):
- """Test parsing the --logfilter argument."""
- filter_mock = mocker.patch('qutebrowser.utils.log.LogFilter',
- autospec=True)
- args.logfilter = logfilter
- log.init_log(args)
- assert filter_mock.called
- assert filter_mock.call_args[0] == (expected_names, negated)
-
def test_python_warnings(self, args, caplog):
log.init_log(args)
@@ -254,6 +288,62 @@ class TestInitLog:
with pytest.raises(PendingDeprecationWarning):
warnings.warn("test warning", PendingDeprecationWarning)
+ @pytest.mark.parametrize('cli, conf, expected', [
+ (None, 'info', logging.INFO),
+ (None, 'warning', logging.WARNING),
+ ('info', 'warning', logging.INFO),
+ ('warning', 'info', logging.WARNING),
+ ])
+ def test_init_from_config_console(self, cli, conf, expected, args,
+ config_stub):
+ args.debug = False
+ args.loglevel = cli
+ log.init_log(args)
+
+ config_stub.val.logging.level.console = conf
+ log.init_from_config(config_stub.val)
+ assert log.console_handler.level == expected
+
+ @pytest.mark.parametrize('conf, expected', [
+ ('vdebug', logging.VDEBUG),
+ ('debug', logging.DEBUG),
+ ('info', logging.INFO),
+ ('critical', logging.CRITICAL),
+ ])
+ def test_init_from_config_ram(self, conf, expected, args, config_stub):
+ args.debug = False
+ log.init_log(args)
+
+ config_stub.val.logging.level.ram = conf
+ log.init_from_config(config_stub.val)
+ assert log.ram_handler.level == expected
+
+ def test_init_from_config_consistent_default(self, config_stub, empty_args):
+ """Ensure config defaults are consistent with the builtin defaults."""
+ log.init_log(empty_args)
+
+ assert log.ram_handler.level == logging.DEBUG
+ assert log.console_handler.level == logging.INFO
+
+ log.init_from_config(config_stub.val)
+
+ assert log.ram_handler.level == logging.DEBUG
+ assert log.console_handler.level == logging.INFO
+
+ def test_init_from_config_format(self, config_stub, empty_args):
+ """If we change to the debug level, make sure the format changes."""
+ log.init_log(empty_args)
+ assert log.console_handler.formatter._fmt == log.SIMPLE_FMT
+
+ config_stub.val.logging.level.console = 'debug'
+ log.init_from_config(config_stub.val)
+ assert log.console_handler.formatter._fmt == log.EXTENDED_FMT
+
+ def test_logfilter(self, parser):
+ args = parser.parse_args(['--logfilter', 'misc'])
+ log.init_log(args)
+ assert log.console_filter.names == {'misc'}
+
class TestHideQtWarning:
diff --git a/tests/unit/utils/test_qtutils.py b/tests/unit/utils/test_qtutils.py
index 150a03f6e..81d198946 100644
--- a/tests/unit/utils/test_qtutils.py
+++ b/tests/unit/utils/test_qtutils.py
@@ -467,9 +467,9 @@ class TestSavefileOpen:
with pytest.raises(OSError) as excinfo:
with qtutils.savefile_open(str(filename)):
pass
- errors = ["Filename refers to a directory", # Qt >= 5.4
- "Commit failed!"] # older Qt versions
- assert str(excinfo.value) in errors
+
+ msg = "Filename refers to a directory: {!r}".format(str(filename))
+ assert str(excinfo.value) == msg
assert tmpdir.listdir() == [filename]
def test_failing_flush(self, tmpdir):
diff --git a/tests/unit/utils/test_standarddir.py b/tests/unit/utils/test_standarddir.py
index e4d8575fe..064c51b30 100644
--- a/tests/unit/utils/test_standarddir.py
+++ b/tests/unit/utils/test_standarddir.py
@@ -79,6 +79,7 @@ def test_unset_organization_no_qapp(monkeypatch):
@pytest.mark.fake_os('mac')
+@pytest.mark.posix
def test_fake_mac_config(tmpdir, monkeypatch):
"""Test standardir.config on a fake Mac."""
monkeypatch.setenv('HOME', str(tmpdir))
@@ -142,26 +143,29 @@ class TestWritableLocation:
class TestStandardDir:
- @pytest.mark.parametrize('func, varname', [
- (standarddir.data, 'XDG_DATA_HOME'),
- (standarddir.config, 'XDG_CONFIG_HOME'),
- (lambda: standarddir.config(auto=True), 'XDG_CONFIG_HOME'),
- (standarddir.cache, 'XDG_CACHE_HOME'),
- (standarddir.runtime, 'XDG_RUNTIME_DIR'),
+ @pytest.mark.parametrize('func, init_func, varname', [
+ (standarddir.data, standarddir._init_data, 'XDG_DATA_HOME'),
+ (standarddir.config, standarddir._init_config, 'XDG_CONFIG_HOME'),
+ (lambda: standarddir.config(auto=True),
+ standarddir._init_config, 'XDG_CONFIG_HOME'),
+ (standarddir.cache, standarddir._init_cache, 'XDG_CACHE_HOME'),
+ (standarddir.runtime, standarddir._init_runtime, 'XDG_RUNTIME_DIR'),
])
@pytest.mark.linux
- def test_linux_explicit(self, monkeypatch, tmpdir, func, varname):
+ def test_linux_explicit(self, monkeypatch, tmpdir,
+ func, init_func, varname):
"""Test dirs with XDG environment variables explicitly set.
Args:
func: The function to test.
+ init_func: The initialization function to call.
varname: The environment variable which should be set.
"""
monkeypatch.setenv(varname, str(tmpdir))
if varname == 'XDG_RUNTIME_DIR':
tmpdir.chmod(0o0700)
- standarddir._init_dirs()
+ init_func(args=None)
assert func() == str(tmpdir / APPNAME)
@pytest.mark.parametrize('func, subdirs', [
@@ -192,7 +196,7 @@ class TestStandardDir:
monkeypatch.setenv('XDG_RUNTIME_DIR', str(tmpdir / 'does-not-exist'))
monkeypatch.setenv('TMPDIR', str(tmpdir_env))
- standarddir._init_dirs()
+ standarddir._init_runtime(args=None)
assert standarddir.runtime() == str(tmpdir_env / APPNAME)
@pytest.mark.fake_os('windows')
@@ -370,10 +374,11 @@ class TestSystemData:
"""Test system data path."""
@pytest.mark.linux
- def test_system_datadir_exist_linux(self, monkeypatch):
+ def test_system_datadir_exist_linux(self, monkeypatch, tmpdir):
"""Test that /usr/share/qute_test is used if path exists."""
+ monkeypatch.setenv('XDG_DATA_HOME', str(tmpdir))
monkeypatch.setattr(os.path, 'exists', lambda path: True)
- standarddir._init_dirs()
+ standarddir._init_data(args=None)
assert standarddir.data(system=True) == "/usr/share/qute_test"
@pytest.mark.linux
@@ -382,7 +387,7 @@ class TestSystemData:
"""Test that system-wide path isn't used on linux if path not exist."""
fake_args.basedir = str(tmpdir)
monkeypatch.setattr(os.path, 'exists', lambda path: False)
- standarddir._init_dirs(fake_args)
+ standarddir._init_data(args=fake_args)
assert standarddir.data(system=True) == standarddir.data()
def test_system_datadir_unsupportedos(self, monkeypatch, tmpdir,
@@ -390,7 +395,7 @@ class TestSystemData:
"""Test that system-wide path is not used on non-Linux OS."""
fake_args.basedir = str(tmpdir)
monkeypatch.setattr('sys.platform', "potato")
- standarddir._init_dirs(fake_args)
+ standarddir._init_data(args=fake_args)
assert standarddir.data(system=True) == standarddir.data()
@@ -515,13 +520,15 @@ class TestMove:
@pytest.mark.parametrize('args_kind', ['basedir', 'normal', 'none'])
-def test_init(mocker, tmpdir, args_kind):
+def test_init(mocker, tmpdir, monkeypatch, args_kind):
"""Do some sanity checks for standarddir.init().
Things like _init_cachedir_tag() are tested in more detail in other tests.
"""
assert standarddir._locations == {}
+ monkeypatch.setenv('HOME', str(tmpdir))
+
m_windows = mocker.patch('qutebrowser.utils.standarddir._move_windows')
m_mac = mocker.patch('qutebrowser.utils.standarddir._move_macos')
if args_kind == 'normal':
@@ -562,11 +569,12 @@ def test_downloads_dir_not_created(monkeypatch, tmpdir):
assert not download_dir.exists()
-def test_no_qapplication(qapp, tmpdir):
+def test_no_qapplication(qapp, tmpdir, monkeypatch):
"""Make sure directories with/without QApplication are equal."""
sub_code = """
import sys
import json
+
sys.path = sys.argv[1:] # make sure we have the same python path
from PyQt5.QtWidgets import QApplication
@@ -583,11 +591,25 @@ def test_no_qapplication(qapp, tmpdir):
pyfile = tmpdir / 'sub.py'
pyfile.write_text(textwrap.dedent(sub_code), encoding='ascii')
- output = subprocess.run([sys.executable, str(pyfile)] + sys.path,
- universal_newlines=True,
- check=True, stdout=subprocess.PIPE).stdout
- sub_locations = json.loads(output)
+ for name in ['CONFIG', 'DATA', 'CACHE']:
+ monkeypatch.delenv('XDG_{}_HOME'.format(name), raising=False)
+
+ runtime_dir = tmpdir / 'runtime'
+ runtime_dir.ensure(dir=True)
+ runtime_dir.chmod(0o0700)
+ monkeypatch.setenv('XDG_RUNTIME_DIR', str(runtime_dir))
+
+ home_dir = tmpdir / 'home'
+ home_dir.ensure(dir=True)
+ monkeypatch.setenv('HOME', str(home_dir))
+
+ proc = subprocess.run([sys.executable, str(pyfile)] + sys.path,
+ universal_newlines=True,
+ check=True,
+ stdout=subprocess.PIPE)
+ sub_locations = json.loads(proc.stdout)
standarddir._init_dirs()
locations = {k.name: v for k, v in standarddir._locations.items()}
+
assert sub_locations == locations
diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py
index 0c265917d..a9f32161d 100644
--- a/tests/unit/utils/test_urlutils.py
+++ b/tests/unit/utils/test_urlutils.py
@@ -98,6 +98,8 @@ def init_config(config_stub):
'test': 'http://www.qutebrowser.org/?q={}',
'test-with-dash': 'http://www.example.org/?q={}',
'path-search': 'http://www.example.org/{}',
+ 'quoted-path': 'http://www.example.org/{quoted}',
+ 'unquoted': 'http://www.example.org/?{unquoted}',
'DEFAULT': 'http://www.example.com/?q={}',
}
@@ -286,8 +288,10 @@ def test_special_urls(url, special):
('blub testfoo', 'www.example.com', 'q=blub testfoo'),
('stripped ', 'www.example.com', 'q=stripped'),
('test-with-dash testfoo', 'www.example.org', 'q=testfoo'),
- ('test/with/slashes', 'www.example.com', 'q=test%2Fwith%2Fslashes'),
+ ('test/with/slashes', 'www.example.com', 'q=test/with/slashes'),
('test path-search', 'www.qutebrowser.org', 'q=path-search'),
+ ('slash/and&amp', 'www.example.com', 'q=slash/and%26amp'),
+ ('unquoted one=1&two=2', 'www.example.org', 'one=1&two=2'),
])
def test_get_search_url(config_stub, url, host, query, open_base_url):
"""Test _get_search_url().
@@ -303,6 +307,25 @@ def test_get_search_url(config_stub, url, host, query, open_base_url):
assert url.query() == query
+@pytest.mark.parametrize('open_base_url', [True, False])
+@pytest.mark.parametrize('url, host, path', [
+ ('path-search t/w/s', 'www.example.org', 't/w/s'),
+ ('quoted-path t/w/s', 'www.example.org', 't%2Fw%2Fs'),
+])
+def test_get_search_url_for_path_search(config_stub, url, host, path, open_base_url):
+ """Test _get_search_url_for_path_search().
+
+ Args:
+ url: The "URL" to enter.
+ host: The expected search machine host.
+ path: The expected path on that host that is requested.
+ """
+ config_stub.val.url.open_base_url = open_base_url
+ url = urlutils._get_search_url(url)
+ assert url.host() == host
+ assert url.path(options=QUrl.PrettyDecoded) == '/' + path
+
+
@pytest.mark.parametrize('url, host', [
('test', 'www.qutebrowser.org'),
('test-with-dash', 'www.example.org'),
@@ -693,6 +716,9 @@ class TestProxyFromUrl:
def test_proxy_from_url_valid(self, url, expected):
assert urlutils.proxy_from_url(QUrl(url)) == expected
+ @pytest.mark.qt_log_ignore(
+ r'^QHttpNetworkConnectionPrivate::_q_hostLookupFinished could not '
+ r'de-queue request, failed to report HostNotFoundError')
@pytest.mark.parametrize('scheme', ['pac+http', 'pac+https'])
def test_proxy_from_url_pac(self, scheme, qapp):
fetcher = urlutils.proxy_from_url(QUrl('{}://foo'.format(scheme)))
diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py
index 18abd444e..35f04201e 100644
--- a/tests/unit/utils/test_utils.py
+++ b/tests/unit/utils/test_utils.py
@@ -885,3 +885,10 @@ def test_ceil_log_invalid(number, base):
math.log(number, base)
with pytest.raises(ValueError):
utils.ceil_log(number, base)
+
+
+@pytest.mark.parametrize('skip', [True, False])
+def test_libgl_workaround(monkeypatch, skip):
+ if skip:
+ monkeypatch.setenv('QUTE_SKIP_LIBGL_WORKAROUND', '1')
+ utils.libgl_workaround() # Just make sure it doesn't crash.
diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py
index cd12f7cc2..82fee7b19 100644
--- a/tests/unit/utils/test_version.py
+++ b/tests/unit/utils/test_version.py
@@ -35,6 +35,8 @@ import datetime
import attr
import pkg_resources
import pytest
+import hypothesis
+import hypothesis.strategies
import qutebrowser
from qutebrowser.config import config
@@ -196,6 +198,15 @@ from qutebrowser.browser import pdfjs
version.DistributionInfo(
id='tux', parsed=version.Distribution.unknown,
version=None, pretty='Tux')),
+ # Invalid multi-line value
+ ("""
+ ID=tux
+ PRETTY_NAME="Multiline
+ Text"
+ """,
+ version.DistributionInfo(
+ id='tux', parsed=version.Distribution.unknown,
+ version=None, pretty='Multiline')),
])
def test_distribution(tmpdir, monkeypatch, os_release, expected):
os_release_file = tmpdir / 'os-release'
@@ -392,7 +403,7 @@ class TestGitStrSubprocess:
def test_real_git(self, git_repo):
"""Test with a real git repository."""
ret = version._git_str_subprocess(str(git_repo))
- assert ret == '6e4b65a (1970-01-01 01:00:00 +0100)'
+ assert ret == '6e4b65a on master (1970-01-01 01:00:00 +0100)'
def test_missing_dir(self, tmpdir):
"""Test with a directory which doesn't exist."""
@@ -900,7 +911,7 @@ class VersionParams:
name = attr.ib()
git_commit = attr.ib(True)
frozen = attr.ib(False)
- style = attr.ib(True)
+ qapp = attr.ib(True)
with_webkit = attr.ib(True)
known_distribution = attr.ib(True)
ssl_support = attr.ib(True)
@@ -912,15 +923,15 @@ class VersionParams:
VersionParams('normal'),
VersionParams('no-git-commit', git_commit=False),
VersionParams('frozen', frozen=True),
- VersionParams('no-style', style=False),
+ VersionParams('no-qapp', qapp=False),
VersionParams('no-webkit', with_webkit=False),
VersionParams('unknown-dist', known_distribution=False),
VersionParams('no-ssl', ssl_support=False),
VersionParams('no-autoconfig-loaded', autoconfig_loaded=False),
VersionParams('no-config-py-loaded', config_py_loaded=False),
], ids=lambda param: param.name)
-def test_version_output(params, stubs, monkeypatch, config_stub):
- """Test version.version()."""
+def test_version_info(params, stubs, monkeypatch, config_stub):
+ """Test version.version_info()."""
config.instance.config_py_loaded = params.config_py_loaded
import_path = os.path.abspath('/IMPORTPATH')
@@ -940,8 +951,9 @@ def test_version_output(params, stubs, monkeypatch, config_stub):
'platform.architecture': lambda: ('ARCHITECTURE', ''),
'_os_info': lambda: ['OS INFO 1', 'OS INFO 2'],
'_path_info': lambda: {'PATH DESC': 'PATH NAME'},
- 'QApplication': (stubs.FakeQApplication(style='STYLE')
- if params.style else
+ 'QApplication': (stubs.FakeQApplication(style='STYLE',
+ platform_name='PLATFORM')
+ if params.qapp else
stubs.FakeQApplication(instance=None)),
'QLibraryInfo.location': (lambda _loc: 'QT PATH'),
'sql.version': lambda: 'SQLITE VERSION',
@@ -949,9 +961,15 @@ def test_version_output(params, stubs, monkeypatch, config_stub):
'config.instance.yaml_loaded': params.autoconfig_loaded,
}
+ version.opengl_info.cache_clear()
+ monkeypatch.setenv('QUTE_FAKE_OPENGL', 'VENDOR, 1.0 VERSION')
+
substitutions = {
'git_commit': '\nGit commit: GIT COMMIT' if params.git_commit else '',
- 'style': '\nStyle: STYLE' if params.style else '',
+ 'style': '\nStyle: STYLE' if params.qapp else '',
+ 'platform_plugin': ('\nPlatform plugin: PLATFORM' if params.qapp
+ else ''),
+ 'opengl': '\nOpenGL: VENDOR, 1.0 VERSION' if params.qapp else '',
'qt': 'QT VERSION',
'frozen': str(params.frozen),
'import_path': import_path,
@@ -1017,7 +1035,7 @@ def test_version_output(params, stubs, monkeypatch, config_stub):
pdf.js: PDFJS VERSION
sqlite: SQLITE VERSION
QtNetwork SSL: {ssl}
- {style}
+ {style}{platform_plugin}{opengl}
Platform: PLATFORM, ARCHITECTURE{linuxdist}
Frozen: {frozen}
Imported from {import_path}
@@ -1033,13 +1051,79 @@ def test_version_output(params, stubs, monkeypatch, config_stub):
""".lstrip('\n'))
expected = template.rstrip('\n').format(**substitutions)
- assert version.version() == expected
+ assert version.version_info() == expected
+
+class TestOpenGLInfo:
+
+ @pytest.fixture(autouse=True)
+ def cache_clear(self):
+ """Clear the lru_cache between tests."""
+ version.opengl_info.cache_clear()
+
+ def test_func(self, qapp):
+ """Simply call version.opengl_info() and see if it doesn't crash."""
+ pytest.importorskip("PyQt5.QtOpenGL")
+ version.opengl_info()
+
+ def test_func_fake(self, qapp, monkeypatch):
+ monkeypatch.setenv('QUTE_FAKE_OPENGL', 'Outtel Inc., 3.0 Messiah 20.0')
+ info = version.opengl_info()
+ assert info.vendor == 'Outtel Inc.'
+ assert info.version_str == '3.0 Messiah 20.0'
+ assert info.version == (3, 0)
+ assert info.vendor_specific == 'Messiah 20.0'
+
+ @pytest.mark.parametrize('version_str, reason', [
+ ('blah', 'missing space'),
+ ('2,x blah', 'parsing int'),
+ ])
+ def test_parse_invalid(self, caplog, version_str, reason):
+ with caplog.at_level(logging.WARNING):
+ info = version.OpenGLInfo.parse(vendor="vendor",
+ version=version_str)
+
+ assert info.version is None
+ assert info.vendor_specific is None
+ assert info.vendor == 'vendor'
+ assert info.version_str == version_str
+
+ msg = "Failed to parse OpenGL version ({}): {}".format(
+ reason, version_str)
+ assert caplog.messages == [msg]
+
+ @hypothesis.given(vendor=hypothesis.strategies.text(),
+ version_str=hypothesis.strategies.text())
+ def test_parse_hypothesis(self, caplog, vendor, version_str):
+ with caplog.at_level(logging.WARNING):
+ info = version.OpenGLInfo.parse(vendor=vendor, version=version_str)
+
+ assert info.vendor == vendor
+ assert info.version_str == version_str
+ assert vendor in str(info)
+ assert version_str in str(info)
+
+ if info.version is not None:
+ reconstructed = ' '.join(['.'.join(str(part)
+ for part in info.version),
+ info.vendor_specific])
+ assert reconstructed == info.version_str
+
+ @pytest.mark.parametrize('version_str, expected', [
+ ("2.1 INTEL-10.36.26", (2, 1)),
+ ("4.6 (Compatibility Profile) Mesa 20.0.7", (4, 6)),
+ ("3.0 Mesa 20.0.7", (3, 0)),
+ ("3.0 Mesa 20.0.6", (3, 0)),
+ # Not from the wild, but can happen according to standards
+ ("3.0.2 Mesa 20.0.6", (3, 0, 2)),
+ ])
+ def test_version(self, version_str, expected):
+ info = version.OpenGLInfo.parse(vendor='vendor', version=version_str)
+ assert info.version == expected
-def test_opengl_vendor(qapp):
- """Simply call version.opengl_vendor() and see if it doesn't crash."""
- pytest.importorskip("PyQt5.QtOpenGL")
- return version.opengl_vendor()
+ def test_str_gles(self):
+ info = version.OpenGLInfo(gles=True)
+ assert str(info) == 'OpenGL ES'
@pytest.fixture
@@ -1052,7 +1136,7 @@ def pbclient(stubs):
def test_pastebin_version(pbclient, message_mock, monkeypatch, qtbot):
"""Test version.pastebin_version() sets the url."""
- monkeypatch.setattr('qutebrowser.utils.version.version',
+ monkeypatch.setattr('qutebrowser.utils.version.version_info',
lambda: "dummy")
monkeypatch.setattr('qutebrowser.utils.utils.log_clipboard', True)
@@ -1067,7 +1151,7 @@ def test_pastebin_version(pbclient, message_mock, monkeypatch, qtbot):
def test_pastebin_version_twice(pbclient, monkeypatch):
"""Test whether calling pastebin_version twice sends no data."""
- monkeypatch.setattr('qutebrowser.utils.version.version',
+ monkeypatch.setattr('qutebrowser.utils.version.version_info',
lambda: "dummy")
version.pastebin_version(pbclient)
@@ -1085,7 +1169,7 @@ def test_pastebin_version_twice(pbclient, monkeypatch):
def test_pastebin_version_error(pbclient, caplog, message_mock, monkeypatch):
"""Test version.pastebin_version() with errors."""
- monkeypatch.setattr('qutebrowser.utils.version.version',
+ monkeypatch.setattr('qutebrowser.utils.version.version_info',
lambda: "dummy")
version.pastebin_url = None
diff --git a/tox.ini b/tox.ini
index 17fde13b9..4e16742cc 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,15 +4,15 @@
# and then run "tox" from this directory.
[tox]
-envlist = py37-pyqt514-cov,misc,vulture,flake8,pylint,pyroma,check-manifest,eslint
+envlist = py37-pyqt515-cov,misc,vulture,flake8,pylint,pyroma,check-manifest,eslint
distshare = {toxworkdir}
skipsdist = true
[testenv]
setenv =
PYTEST_QT_API=pyqt5
- pyqt{,57,59,510,511,512,513,514}: LINK_PYQT_SKIP=true
- pyqt{,57,59,510,511,512,513,514}: QUTE_BDD_WEBENGINE=true
+ pyqt{,57,59,510,511,512,513,514,515}: LINK_PYQT_SKIP=true
+ pyqt{,57,59,510,511,512,513,514,515}: QUTE_BDD_WEBENGINE=true
cov: PYTEST_ADDOPTS=--cov --cov-report xml --cov-report=html --cov-report=
passenv = PYTHON DISPLAY XAUTHORITY HOME USERNAME USER CI TRAVIS XDG_* QUTE_* DOCKER QT_QUICK_BACKEND
basepython =
@@ -32,6 +32,7 @@ deps =
pyqt512: -r{toxinidir}/misc/requirements/requirements-pyqt-5.12.txt
pyqt513: -r{toxinidir}/misc/requirements/requirements-pyqt-5.13.txt
pyqt514: -r{toxinidir}/misc/requirements/requirements-pyqt-5.14.txt
+ pyqt515: -r{toxinidir}/misc/requirements/requirements-pyqt-5.15.txt
commands =
{envpython} scripts/link_pyqt.py --tox {envdir}
{envpython} -bb -m pytest {posargs:tests}
@@ -39,26 +40,6 @@ commands =
# other envs
-[testenv:mkvenv]
-basepython = {env:PYTHON:python3}
-commands = {envpython} scripts/mkvenv.py --tox-error --pyqt-type link
-usedevelop = true
-deps =
-
-# This is undocumented, but it's a common typo, so let's make it work
-[testenv:mkenv]
-basepython = {[testenv:mkvenv]basepython}
-commands = {[testenv:mkvenv]commands}
-usedevelop = {[testenv:mkvenv]usedevelop}
-deps = {[testenv:mkvenv]deps}
-
-# Virtualenv with PyQt5 from PyPI
-[testenv:mkvenv-pypi]
-basepython = {env:PYTHON:python3}
-commands = {envpython} scripts/mkvenv.py --tox-error
-usedevelop = true
-deps =
-
[testenv:misc]
ignore_errors = true
basepython = {env:PYTHON:python3}
@@ -187,6 +168,17 @@ deps =
commands =
{envbindir}/pyinstaller --noconfirm misc/qutebrowser.spec
+[testenv:pyinstaller32]
+# A copy of the pyinstaller environment above, to be used for 32-bit on Windows
+# to make sure the both installations are separated.
+# This doesn't actually do anything 32-bit specific, that part happens by
+# setting the PYTHON environment variable accordingly.
+basepython = {[testenv:pyinstaller]basepython}
+pip_version = {[testenv:pyinstaller]pip_version}
+passenv = {[testenv:pyinstaller]passenv}
+deps = {[testenv:pyinstaller]deps}
+commands = {[testenv:pyinstaller]commands}
+
[testenv:eslint]
# This is duplicated in travis_run.sh for Travis CI because we can't get tox in
# the JavaScript environment easily.
@@ -204,7 +196,6 @@ deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/misc/requirements/requirements-dev.txt
-r{toxinidir}/misc/requirements/requirements-tests.txt
- -r{toxinidir}/misc/requirements/requirements-pyqt.txt
-r{toxinidir}/misc/requirements/requirements-mypy.txt
commands =
{envpython} -m mypy qutebrowser tests {posargs}
diff --git a/www/header.asciidoc b/www/header.asciidoc
index 2f8ed6a1e..66f6f2bb3 100644
--- a/www/header.asciidoc
+++ b/www/header.asciidoc
@@ -28,9 +28,5 @@ time, your help is needed! See the
information. Depending on your sign-up date and how long you keep a certain
level, you can get qutebrowser t-shirts, stickers and more!
</p>
-<p>
-Thanks to the GitHub Sponsors Matching Fund, all donations done via GitHub
-Sponsors (up to a $5000 total) will be doubled until October 2020.
-</p>
</div>
+++