summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.appveyor.yml23
-rw-r--r--.bumpversion.cfg2
-rw-r--r--.codecov.yml8
-rw-r--r--.coveragerc6
-rw-r--r--.editorconfig2
-rw-r--r--.flake87
-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/pull_request_template.md1
-rw-r--r--.github/workflows/ci.yml227
-rw-r--r--.github/workflows/docker.yml62
-rw-r--r--.github/workflows/recompile-requirements.yml78
-rw-r--r--.gitignore13
-rw-r--r--.mypy.ini (renamed from mypy.ini)60
-rw-r--r--.pylintrc5
-rw-r--r--.pyup.yml1
-rw-r--r--.travis.yml129
-rw-r--r--.yamllint19
-rw-r--r--MANIFEST.in9
-rw-r--r--README.asciidoc80
-rw-r--r--doc/changelog.asciidoc657
-rw-r--r--doc/contributing.asciidoc16
-rw-r--r--doc/faq.asciidoc68
-rw-r--r--doc/help/commands.asciidoc150
-rw-r--r--doc/help/configuring.asciidoc51
-rw-r--r--doc/help/index.asciidoc14
-rw-r--r--doc/help/settings.asciidoc640
-rw-r--r--doc/install.asciidoc166
-rw-r--r--doc/quickstart.asciidoc27
-rw-r--r--doc/qutebrowser.1.asciidoc8
-rw-r--r--doc/stacktrace.asciidoc71
-rw-r--r--doc/userscripts.asciidoc8
-rw-r--r--misc/Makefile2
-rw-r--r--misc/apparmor/usr.bin.qutebrowser90
-rwxr-xr-xmisc/nsis/qutebrowser.nsi1
-rw-r--r--misc/org.qutebrowser.qutebrowser.appdata.xml7
-rw-r--r--misc/org.qutebrowser.qutebrowser.desktop2
-rw-r--r--misc/qutebrowser.spec7
-rw-r--r--misc/requirements/requirements-check-manifest.txt9
-rw-r--r--misc/requirements/requirements-codecov.txt9
-rw-r--r--misc/requirements/requirements-codecov.txt-raw1
-rw-r--r--misc/requirements/requirements-dev.txt36
-rw-r--r--misc/requirements/requirements-dev.txt-raw5
-rw-r--r--misc/requirements/requirements-flake8.txt23
-rw-r--r--misc/requirements/requirements-mypy.txt19
-rw-r--r--misc/requirements/requirements-mypy.txt-raw9
-rw-r--r--misc/requirements/requirements-pip.txt8
-rw-r--r--misc/requirements/requirements-pyinstaller.txt3
-rw-r--r--misc/requirements/requirements-pyinstaller.txt-raw5
-rw-r--r--misc/requirements/requirements-pylint.txt26
-rw-r--r--misc/requirements/requirements-pylint.txt-raw9
-rw-r--r--misc/requirements/requirements-pyqt-5.10.txt4
-rw-r--r--misc/requirements/requirements-pyqt-5.10.txt-raw4
-rw-r--r--misc/requirements/requirements-pyqt-5.11.txt4
-rw-r--r--misc/requirements/requirements-pyqt-5.11.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.txt2
-rw-r--r--misc/requirements/requirements-pyqt-5.15.0.txt5
-rw-r--r--misc/requirements/requirements-pyqt-5.15.0.txt-raw4
-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-5.7.txt-raw4
-rw-r--r--misc/requirements/requirements-pyqt-5.9.txt4
-rw-r--r--misc/requirements/requirements-pyqt-5.9.txt-raw4
-rw-r--r--misc/requirements/requirements-pyqt.txt6
-rw-r--r--misc/requirements/requirements-pyroma.txt2
-rw-r--r--misc/requirements/requirements-qutebrowser.txt-raw5
-rw-r--r--misc/requirements/requirements-sphinx.txt21
-rw-r--r--misc/requirements/requirements-tests-git.txt2
-rw-r--r--misc/requirements/requirements-tests.txt82
-rw-r--r--misc/requirements/requirements-tests.txt-raw30
-rw-r--r--misc/requirements/requirements-tox.txt18
-rw-r--r--misc/requirements/requirements-tox.txt-raw2
-rw-r--r--misc/requirements/requirements-vulture.txt3
-rw-r--r--misc/requirements/requirements-yamllint.txt (renamed from misc/requirements/requirements-pyqt-5.7.txt)5
-rw-r--r--misc/requirements/requirements-yamllint.txt-raw1
-rw-r--r--misc/userscripts/README.md17
-rwxr-xr-xmisc/userscripts/add-nextcloud-bookmarks173
-rwxr-xr-xmisc/userscripts/add-nextcloud-cookbook131
-rwxr-xr-xmisc/userscripts/cast2
-rwxr-xr-xmisc/userscripts/dmenu_qutebrowser5
-rwxr-xr-xmisc/userscripts/format_json2
-rwxr-xr-xmisc/userscripts/getbib1
-rwxr-xr-xmisc/userscripts/kodi111
-rwxr-xr-xmisc/userscripts/password_fill2
-rwxr-xr-xmisc/userscripts/qr8
-rwxr-xr-xmisc/userscripts/qute-bitwarden4
-rwxr-xr-xmisc/userscripts/qute-lastpass26
-rwxr-xr-xmisc/userscripts/qute-pass70
-rwxr-xr-xmisc/userscripts/qutedmenu11
-rwxr-xr-xmisc/userscripts/tor_identity29
-rw-r--r--pytest.ini20
-rw-r--r--qutebrowser/__init__.py2
-rw-r--r--qutebrowser/api/apitypes.py2
-rw-r--r--qutebrowser/api/cmdutils.py23
-rw-r--r--qutebrowser/api/config.py8
-rw-r--r--qutebrowser/api/downloads.py4
-rw-r--r--qutebrowser/api/hook.py8
-rw-r--r--qutebrowser/app.py131
-rw-r--r--qutebrowser/browser/browsertab.py288
-rw-r--r--qutebrowser/browser/commands.py264
-rw-r--r--qutebrowser/browser/downloads.py95
-rw-r--r--qutebrowser/browser/downloadview.py62
-rw-r--r--qutebrowser/browser/eventfilter.py67
-rw-r--r--qutebrowser/browser/greasemonkey.py84
-rw-r--r--qutebrowser/browser/hints.py142
-rw-r--r--qutebrowser/browser/history.py34
-rw-r--r--qutebrowser/browser/inspector.py187
-rw-r--r--qutebrowser/browser/navigate.py41
-rw-r--r--qutebrowser/browser/network/pac.py18
-rw-r--r--qutebrowser/browser/network/proxy.py36
-rw-r--r--qutebrowser/browser/pdfjs.py48
-rw-r--r--qutebrowser/browser/qtnetworkdownloads.py82
-rw-r--r--qutebrowser/browser/qutescheme.py157
-rw-r--r--qutebrowser/browser/shared.py32
-rw-r--r--qutebrowser/browser/signalfilter.py2
-rw-r--r--qutebrowser/browser/urlmarks.py5
-rw-r--r--qutebrowser/browser/webelem.py48
-rw-r--r--qutebrowser/browser/webengine/cookies.py41
-rw-r--r--qutebrowser/browser/webengine/darkmode.py305
-rw-r--r--qutebrowser/browser/webengine/interceptor.py44
-rw-r--r--qutebrowser/browser/webengine/spell.py34
-rw-r--r--qutebrowser/browser/webengine/tabhistory.py12
-rw-r--r--qutebrowser/browser/webengine/webenginedownloads.py86
-rw-r--r--qutebrowser/browser/webengine/webengineelem.py52
-rw-r--r--qutebrowser/browser/webengine/webengineinspector.py90
-rw-r--r--qutebrowser/browser/webengine/webenginequtescheme.py58
-rw-r--r--qutebrowser/browser/webengine/webenginesettings.py304
-rw-r--r--qutebrowser/browser/webengine/webenginetab.py531
-rw-r--r--qutebrowser/browser/webengine/webview.py86
-rw-r--r--qutebrowser/browser/webkit/cache.py9
-rw-r--r--qutebrowser/browser/webkit/cookies.py22
-rw-r--r--qutebrowser/browser/webkit/mhtml.py67
-rw-r--r--qutebrowser/browser/webkit/network/networkmanager.py63
-rw-r--r--qutebrowser/browser/webkit/network/networkreply.py21
-rw-r--r--qutebrowser/browser/webkit/network/webkitqutescheme.py2
-rw-r--r--qutebrowser/browser/webkit/tabhistory.py4
-rw-r--r--qutebrowser/browser/webkit/webkitelem.py73
-rw-r--r--qutebrowser/browser/webkit/webkitinspector.py12
-rw-r--r--qutebrowser/browser/webkit/webkitsettings.py6
-rw-r--r--qutebrowser/browser/webkit/webkittab.py279
-rw-r--r--qutebrowser/browser/webkit/webpage.py39
-rw-r--r--qutebrowser/browser/webkit/webview.py14
-rw-r--r--qutebrowser/commands/command.py43
-rw-r--r--qutebrowser/commands/runners.py18
-rw-r--r--qutebrowser/commands/userscripts.py28
-rw-r--r--qutebrowser/completion/completer.py38
-rw-r--r--qutebrowser/completion/completiondelegate.py27
-rw-r--r--qutebrowser/completion/completionwidget.py92
-rw-r--r--qutebrowser/completion/models/completionmodel.py19
-rw-r--r--qutebrowser/completion/models/histcategory.py6
-rw-r--r--qutebrowser/completion/models/listcategory.py19
-rw-r--r--qutebrowser/completion/models/miscmodels.py167
-rw-r--r--qutebrowser/completion/models/urlmodel.py12
-rw-r--r--qutebrowser/completion/models/util.py4
-rw-r--r--qutebrowser/components/adblock.py344
-rw-r--r--qutebrowser/components/adblockcommands.py31
-rw-r--r--qutebrowser/components/braveadblock.py294
-rw-r--r--qutebrowser/components/caretcommands.py10
-rw-r--r--qutebrowser/components/hostblock.py307
-rw-r--r--qutebrowser/components/misccommands.py50
-rw-r--r--qutebrowser/components/readlinecommands.py8
-rw-r--r--qutebrowser/components/utils/__init__.py0
-rw-r--r--qutebrowser/components/utils/blockutils.py162
-rw-r--r--qutebrowser/config/config.py50
-rw-r--r--qutebrowser/config/configcache.py6
-rw-r--r--qutebrowser/config/configcommands.py27
-rw-r--r--qutebrowser/config/configdata.py59
-rw-r--r--qutebrowser/config/configdata.yml628
-rw-r--r--qutebrowser/config/configdiff.py761
-rw-r--r--qutebrowser/config/configexc.py17
-rw-r--r--qutebrowser/config/configfiles.py200
-rw-r--r--qutebrowser/config/configinit.py154
-rw-r--r--qutebrowser/config/configtypes.py569
-rw-r--r--qutebrowser/config/configutils.py110
-rw-r--r--qutebrowser/config/qtargs.py267
-rw-r--r--qutebrowser/config/stylesheet.py8
-rw-r--r--qutebrowser/config/websettings.py132
-rw-r--r--qutebrowser/extensions/interceptors.py27
-rw-r--r--qutebrowser/extensions/loader.py40
-rw-r--r--qutebrowser/html/warning-old-qt.html24
-rw-r--r--qutebrowser/html/warning-sessions.html22
-rw-r--r--qutebrowser/html/warning-webkit.html14
-rw-r--r--qutebrowser/javascript/.eslintrc.yaml89
-rw-r--r--qutebrowser/javascript/caret.js97
-rw-r--r--qutebrowser/javascript/globalthis_quirk.user.js9
-rw-r--r--qutebrowser/javascript/object_fromentries_quirk.user.js46
-rw-r--r--qutebrowser/javascript/pac_utils.js4
-rw-r--r--qutebrowser/javascript/print.js30
-rw-r--r--qutebrowser/javascript/webelem.js69
-rw-r--r--qutebrowser/javascript/whatsapp_web_quirk.user.js4
-rw-r--r--qutebrowser/keyinput/basekeyparser.py103
-rw-r--r--qutebrowser/keyinput/eventfilter.py4
-rw-r--r--qutebrowser/keyinput/keyutils.py293
-rw-r--r--qutebrowser/keyinput/macros.py14
-rw-r--r--qutebrowser/keyinput/modeman.py130
-rw-r--r--qutebrowser/keyinput/modeparsers.py156
-rw-r--r--qutebrowser/mainwindow/mainwindow.py199
-rw-r--r--qutebrowser/mainwindow/messageview.py15
-rw-r--r--qutebrowser/mainwindow/prompt.py32
-rw-r--r--qutebrowser/mainwindow/statusbar/bar.py64
-rw-r--r--qutebrowser/mainwindow/statusbar/command.py9
-rw-r--r--qutebrowser/mainwindow/statusbar/keystring.py7
-rw-r--r--qutebrowser/mainwindow/statusbar/percentage.py4
-rw-r--r--qutebrowser/mainwindow/statusbar/text.py82
-rw-r--r--qutebrowser/mainwindow/statusbar/url.py19
-rw-r--r--qutebrowser/mainwindow/tabbedbrowser.py174
-rw-r--r--qutebrowser/mainwindow/tabwidget.py81
-rw-r--r--qutebrowser/mainwindow/windowundo.py91
-rw-r--r--qutebrowser/misc/autoupdate.py2
-rw-r--r--qutebrowser/misc/backendproblem.py198
-rw-r--r--qutebrowser/misc/checkpyver.py14
-rw-r--r--qutebrowser/misc/cmdhistory.py8
-rw-r--r--qutebrowser/misc/consolewidget.py27
-rw-r--r--qutebrowser/misc/crashdialog.py39
-rw-r--r--qutebrowser/misc/crashsignal.py73
-rw-r--r--qutebrowser/misc/debugcachestats.py8
-rw-r--r--qutebrowser/misc/earlyinit.py52
-rw-r--r--qutebrowser/misc/editor.py14
-rw-r--r--qutebrowser/misc/guiprocess.py19
-rw-r--r--qutebrowser/misc/httpclient.py15
-rw-r--r--qutebrowser/misc/ipc.py79
-rw-r--r--qutebrowser/misc/keyhintwidget.py6
-rw-r--r--qutebrowser/misc/lineparser.py4
-rw-r--r--qutebrowser/misc/miscwidgets.py267
-rw-r--r--qutebrowser/misc/msgbox.py2
-rw-r--r--qutebrowser/misc/objects.py14
-rw-r--r--qutebrowser/misc/quitter.py28
-rw-r--r--qutebrowser/misc/savemanager.py5
-rw-r--r--qutebrowser/misc/sessions.py88
-rw-r--r--qutebrowser/misc/split.py3
-rw-r--r--qutebrowser/misc/sql.py18
-rw-r--r--qutebrowser/misc/throttle.py14
-rw-r--r--qutebrowser/misc/utilcmds.py61
-rw-r--r--qutebrowser/qt.py7
-rw-r--r--qutebrowser/qutebrowser.py36
-rw-r--r--qutebrowser/utils/debug.py110
-rw-r--r--qutebrowser/utils/docutils.py26
-rw-r--r--qutebrowser/utils/error.py1
-rw-r--r--qutebrowser/utils/javascript.py6
-rw-r--r--qutebrowser/utils/jinja.py27
-rw-r--r--qutebrowser/utils/log.py228
-rw-r--r--qutebrowser/utils/message.py63
-rw-r--r--qutebrowser/utils/objreg.py80
-rw-r--r--qutebrowser/utils/qtutils.py221
-rw-r--r--qutebrowser/utils/standarddir.py126
-rw-r--r--qutebrowser/utils/urlmatch.py59
-rw-r--r--qutebrowser/utils/urlutils.py162
-rw-r--r--qutebrowser/utils/usertypes.py208
-rw-r--r--qutebrowser/utils/utils.py322
-rw-r--r--qutebrowser/utils/version.py399
-rw-r--r--requirements.txt9
-rwxr-xr-xscripts/asciidoc2html.py220
-rw-r--r--scripts/dev/build_pyqt_wheel.py34
-rwxr-xr-xscripts/dev/build_release.py64
-rw-r--r--scripts/dev/check_coverage.py193
-rwxr-xr-xscripts/dev/check_doc_changes.py20
-rw-r--r--scripts/dev/ci/backtrace.sh (renamed from scripts/dev/ci/travis_backtrace.sh)8
-rw-r--r--scripts/dev/ci/docker/Dockerfile.j229
-rw-r--r--scripts/dev/ci/docker/README.md9
-rw-r--r--scripts/dev/ci/docker/generate.py45
-rw-r--r--scripts/dev/ci/problemmatchers.py228
-rw-r--r--scripts/dev/ci/travis_install.sh75
-rw-r--r--scripts/dev/ci/travis_run.sh37
-rw-r--r--scripts/dev/misc_checks.py268
-rw-r--r--scripts/dev/pylint_checkers/qute_pylint/config.py25
-rw-r--r--scripts/dev/recompile_requirements.py484
-rw-r--r--scripts/dev/run_shellcheck.sh39
-rwxr-xr-xscripts/dev/run_vulture.py12
-rwxr-xr-xscripts/dev/src2asciidoc.py24
-rw-r--r--scripts/dev/ua_fetch.py70
-rw-r--r--scripts/dev/update_version.py23
-rwxr-xr-xscripts/dictcli.py33
-rw-r--r--scripts/hostblock_blame.py6
-rwxr-xr-xscripts/importer.py51
-rw-r--r--scripts/keytester.py29
-rw-r--r--scripts/mkvenv.py299
-rwxr-xr-xscripts/open_url_in_instance.sh2
-rw-r--r--scripts/setupcommon.py22
-rw-r--r--scripts/utils.py31
-rwxr-xr-xsetup.py10
-rw-r--r--tests/conftest.py83
-rw-r--r--tests/end2end/conftest.py19
-rw-r--r--tests/end2end/data/adblock/simple1
-rw-r--r--tests/end2end/data/blocking/external_logo.html15
-rw-r--r--tests/end2end/data/blocking/qutebrowser-adblock1
-rw-r--r--tests/end2end/data/blocking/qutebrowser-hosts1
-rw-r--r--tests/end2end/data/brave-adblock/LICENSE318
-rw-r--r--tests/end2end/data/brave-adblock/README.md12
-rw-r--r--tests/end2end/data/brave-adblock/generate.py95
-rw-r--r--tests/end2end/data/brave-adblock/ublock-matches.tsv.gzbin0 -> 1279186 bytes
-rw-r--r--tests/end2end/data/data_link.html3
-rw-r--r--tests/end2end/data/easylist.txt.gzbin0 -> 593611 bytes
-rw-r--r--tests/end2end/data/easyprivacy.txt.gzbin0 -> 144367 bytes
-rw-r--r--tests/end2end/data/editor.html3
-rw-r--r--tests/end2end/data/hints/bootstrap/bootstrap.css10278
-rw-r--r--tests/end2end/data/hints/bootstrap/checkbox.html18
-rw-r--r--tests/end2end/data/hints/input.html2
-rw-r--r--tests/end2end/data/invalid_resource.html12
-rw-r--r--tests/end2end/data/misc/xhr_headers.html29
-rw-r--r--tests/end2end/features/caret.feature3
-rw-r--r--tests/end2end/features/conftest.py29
-rw-r--r--tests/end2end/features/downloads.feature54
-rw-r--r--tests/end2end/features/editor.feature5
-rw-r--r--tests/end2end/features/hints.feature48
-rw-r--r--tests/end2end/features/history.feature8
-rw-r--r--tests/end2end/features/invoke.feature15
-rw-r--r--tests/end2end/features/javascript.feature22
-rw-r--r--tests/end2end/features/keyinput.feature55
-rw-r--r--tests/end2end/features/marks.feature12
-rw-r--r--tests/end2end/features/misc.feature70
-rw-r--r--tests/end2end/features/navigate.feature59
-rw-r--r--tests/end2end/features/private.feature73
-rw-r--r--tests/end2end/features/prompts.feature13
-rw-r--r--tests/end2end/features/qutescheme.feature29
-rw-r--r--tests/end2end/features/scroll.feature4
-rw-r--r--tests/end2end/features/sessions.feature3
-rw-r--r--tests/end2end/features/spawn.feature4
-rw-r--r--tests/end2end/features/tabs.feature165
-rw-r--r--tests/end2end/features/test_downloads_bdd.py6
-rw-r--r--tests/end2end/features/test_misc_bdd.py9
-rw-r--r--tests/end2end/features/test_open_bdd.py21
-rw-r--r--tests/end2end/features/test_prompts_bdd.py50
-rw-r--r--tests/end2end/features/test_qutescheme_bdd.py9
-rw-r--r--tests/end2end/features/test_search_bdd.py10
-rw-r--r--tests/end2end/features/urlmarks.feature2
-rw-r--r--tests/end2end/features/utilcmds.feature6
-rw-r--r--tests/end2end/features/yankpaste.feature8
-rw-r--r--tests/end2end/fixtures/quteprocess.py174
-rw-r--r--tests/end2end/fixtures/test_quteprocess.py2
-rw-r--r--tests/end2end/fixtures/testprocess.py32
-rw-r--r--tests/end2end/fixtures/webserver.py41
-rw-r--r--tests/end2end/fixtures/webserver_sub.py45
-rw-r--r--tests/end2end/templates/headers-link.html10
-rw-r--r--tests/end2end/test_adblock_e2e.py61
-rw-r--r--tests/end2end/test_insert_mode.py4
-rw-r--r--tests/end2end/test_invocations.py101
-rw-r--r--tests/end2end/test_mhtml_e2e.py4
-rw-r--r--tests/end2end/test_mkvenv.py28
-rw-r--r--tests/helpers/fixtures.py85
-rw-r--r--tests/helpers/messagemock.py11
-rw-r--r--tests/helpers/stubs.py30
-rw-r--r--tests/helpers/utils.py123
-rw-r--r--tests/manual/hints/hide_unmatched_rapid_hints.html2
-rw-r--r--tests/manual/mouse.html5
-rw-r--r--tests/unit/api/test_cmdutils.py39
-rw-r--r--tests/unit/browser/test_caret.py163
-rw-r--r--tests/unit/browser/test_downloads.py (renamed from tests/unit/browser/webkit/test_downloads.py)64
-rw-r--r--tests/unit/browser/test_hints.py13
-rw-r--r--tests/unit/browser/test_history.py63
-rw-r--r--tests/unit/browser/test_inspector.py154
-rw-r--r--tests/unit/browser/test_navigate.py49
-rw-r--r--tests/unit/browser/test_pdfjs.py45
-rw-r--r--tests/unit/browser/test_qutescheme.py5
-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_darkmode.py263
-rw-r--r--tests/unit/browser/webengine/test_spell.py59
-rw-r--r--tests/unit/browser/webengine/test_webengine_cookies.py114
-rw-r--r--tests/unit/browser/webengine/test_webenginedownloads.py55
-rw-r--r--tests/unit/browser/webengine/test_webengineinterceptor.py6
-rw-r--r--tests/unit/browser/webengine/test_webenginesettings.py74
-rw-r--r--tests/unit/browser/webengine/test_webenginetab.py3
-rw-r--r--tests/unit/browser/webkit/network/test_pac.py2
-rw-r--r--tests/unit/browser/webkit/test_cache.py7
-rw-r--r--tests/unit/browser/webkit/test_cookies.py93
-rw-r--r--tests/unit/browser/webkit/test_mhtml.py41
-rw-r--r--tests/unit/browser/webkit/test_tabhistory.py3
-rw-r--r--tests/unit/browser/webkit/test_webkitelem.py24
-rw-r--r--tests/unit/commands/test_argparser.py5
-rw-r--r--tests/unit/completion/test_completer.py7
-rw-r--r--tests/unit/completion/test_completiondelegate.py29
-rw-r--r--tests/unit/completion/test_completionwidget.py129
-rw-r--r--tests/unit/completion/test_listcategory.py11
-rw-r--r--tests/unit/completion/test_models.py244
-rw-r--r--tests/unit/components/test_adblock.py474
-rw-r--r--tests/unit/components/test_blockutils.py83
-rw-r--r--tests/unit/components/test_braveadblock.py368
-rw-r--r--tests/unit/components/test_hostblock.py567
-rw-r--r--tests/unit/config/test_configcommands.py48
-rw-r--r--tests/unit/config/test_configdata.py16
-rw-r--r--tests/unit/config/test_configfiles.py147
-rw-r--r--tests/unit/config/test_configinit.py354
-rw-r--r--tests/unit/config/test_configtypes.py198
-rw-r--r--tests/unit/config/test_configutils.py50
-rw-r--r--tests/unit/config/test_qtargs.py502
-rw-r--r--tests/unit/config/test_websettings.py4
-rw-r--r--tests/unit/javascript/conftest.py16
-rw-r--r--tests/unit/javascript/stylesheet/test_appendchild.js14
-rw-r--r--tests/unit/javascript/test_greasemonkey.py35
-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.py55
-rw-r--r--tests/unit/keyinput/test_modeman.py6
-rw-r--r--tests/unit/keyinput/test_modeparsers.py87
-rw-r--r--tests/unit/mainwindow/statusbar/test_url.py6
-rw-r--r--tests/unit/mainwindow/test_messageview.py21
-rw-r--r--tests/unit/mainwindow/test_tabbedbrowser.py32
-rw-r--r--tests/unit/mainwindow/test_tabwidget.py5
-rw-r--r--tests/unit/misc/test_checkpyver.py17
-rw-r--r--tests/unit/misc/test_earlyinit.py17
-rw-r--r--tests/unit/misc/test_editor.py69
-rw-r--r--tests/unit/misc/test_ipc.py52
-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_miscwidgets.py182
-rw-r--r--tests/unit/misc/test_sessions.py83
-rw-r--r--tests/unit/misc/test_sql.py21
-rw-r--r--tests/unit/misc/test_utilcmds.py13
-rw-r--r--tests/unit/misc/userscripts/test_qute_lastpass.py347
-rw-r--r--tests/unit/scripts/importer_sample/html/bookmarks (renamed from tests/unit/scripts/importer_sample/netscape/bookmarks)0
-rw-r--r--tests/unit/scripts/importer_sample/html/config_py (renamed from tests/unit/scripts/importer_sample/netscape/config_py)0
-rw-r--r--tests/unit/scripts/importer_sample/html/input (renamed from tests/unit/scripts/importer_sample/netscape/input)0
-rw-r--r--tests/unit/scripts/importer_sample/html/quickmarks (renamed from tests/unit/scripts/importer_sample/netscape/quickmarks)0
-rw-r--r--tests/unit/scripts/test_check_coverage.py32
-rw-r--r--tests/unit/scripts/test_dictcli.py37
-rw-r--r--tests/unit/scripts/test_importer.py42
-rw-r--r--tests/unit/scripts/test_problemmatchers.py38
-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_javascript.py79
-rw-r--r--tests/unit/utils/test_jinja.py2
-rw-r--r--tests/unit/utils/test_log.py209
-rw-r--r--tests/unit/utils/test_qtutils.py135
-rw-r--r--tests/unit/utils/test_standarddir.py210
-rw-r--r--tests/unit/utils/test_urlmatch.py173
-rw-r--r--tests/unit/utils/test_urlutils.py239
-rw-r--r--tests/unit/utils/test_utils.py267
-rw-r--r--tests/unit/utils/test_version.py264
-rw-r--r--tox.ini109
435 files changed, 29742 insertions, 10757 deletions
diff --git a/.appveyor.yml b/.appveyor.yml
deleted file mode 100644
index 0ee670b37..000000000
--- a/.appveyor.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-shallow_clone: true
-version: '{branch}-{build}'
-cache:
- - C:\projects\qutebrowser\.cache
-build: off
-
-image:
- - Visual Studio 2015 # Windows Server 2012 R2 / Windows 8
- - Visual Studio 2019 # Windows Server 2019 / Windows 10
-
-environment:
- PYTHONUNBUFFERED: 1
- PYTHON: C:\Python37-x64\python.exe
- TESTENV: py37-pyqt514
-
-install:
- - '%PYTHON% --version'
- - '%PYTHON% -m pip install -U pip'
- - '%PYTHON% -m pip install -r misc\requirements\requirements-tox.txt'
- - 'set PATH=C:\Python37-x64;%PATH'
-
-test_script:
- - '%PYTHON% -m tox -e %TESTENV%'
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 6671e388f..2e62dc15b 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 1.10.2
+current_version = 1.14.1
commit = True
message = Release v{new_version}
tag = True
diff --git a/.codecov.yml b/.codecov.yml
index 47e3c919c..8646cac9a 100644
--- a/.codecov.yml
+++ b/.codecov.yml
@@ -1,7 +1,7 @@
coverage:
status:
- project: off
- patch: off
- changes: off
+ project: false
+ patch: false
+ changes: false
-comment: off
+comment: false
diff --git a/.coveragerc b/.coveragerc
index 2ef20dd12..cb0619b80 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,5 +1,8 @@
[run]
-source = qutebrowser
+include =
+ qutebrowser/*
+ tests/*
+ scripts/*
branch = true
omit =
qutebrowser/__main__.py
@@ -15,6 +18,7 @@ exclude_lines =
raise utils\.Unreachable
if __name__ == ["']__main__["']:
if typing.TYPE_CHECKING:
+ if TYPE_CHECKING:
\.\.\.
[xml]
diff --git a/.editorconfig b/.editorconfig
index 645ced56e..6aab87c94 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -4,7 +4,7 @@ insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
-max_line_length = 79
+max_line_length = 88
indent_style = space
indent_size = 4
diff --git a/.flake8 b/.flake8
index e913647f9..7709eacaf 100644
--- a/.flake8
+++ b/.flake8
@@ -37,7 +37,9 @@ exclude = .*,__pycache__,resources.py
# (numpy-style)
# D413: Missing blank line after last section (not in pep257?)
# A003: Builtin name for class attribute (needed for overridden methods)
+# W503: like break before binary operator
# W504: line break after binary operator
+# FI15: __future__ import "generator_stop" missing
ignore =
B001,B008,B305,
E128,E226,E265,E501,E402,E266,E722,E731,
@@ -46,8 +48,9 @@ ignore =
P101,P102,P103,
D102,D103,D106,D107,D104,D105,D209,D211,D401,D402,D403,D412,D413,
A003,
- W504
-min-version = 3.4.0
+ W503, W504
+ FI15
+min-version = 3.6.0
max-complexity = 12
per-file-ignores =
qutebrowser/api/hook.py : N801
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/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..9913db341
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1 @@
+<!-- Thanks for submitting a pull request! Please pick a descriptive title (not just "issue 12345"). If there is an open issue associated to your PR, please add a line like "Closes #12345" somewhere in the PR description (outside of this comment) -->
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..4ec4b1184
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,227 @@
+name: CI
+on:
+ push:
+ branches-ignore:
+ - 'update-dependencies'
+ - 'dependabot/*'
+ pull_request:
+env:
+ PY_COLORS: "1"
+ MYPY_FORCE_TERMINAL_WIDTH: "180"
+
+jobs:
+ linters:
+ if: "!contains(github.event.head_commit.message, '[ci skip]')"
+ timeout-minutes: 10
+ runs-on: ubuntu-20.04
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - testenv: pylint
+ - testenv: flake8
+ - testenv: mypy
+ - testenv: docs
+ - testenv: vulture
+ - testenv: misc
+ - testenv: pyroma
+ - testenv: check-manifest
+ - testenv: eslint
+ - testenv: shellcheck
+ args: "-f gcc" # For problem matchers
+ - testenv: yamllint
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/cache@v2
+ with:
+ path: |
+ .mypy_cache
+ .tox
+ ~/.cache/pip
+ key: "${{ matrix.testenv }}-${{ hashFiles('misc/requirements/requirements-*.txt') }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('scripts/dev/pylint_checkers/qute_pylint/*.py') }}"
+ - uses: actions/setup-python@v2
+ with:
+ python-version: '3.8'
+ - uses: actions/setup-node@v2-beta
+ with:
+ node-version: '12.x'
+ if: "matrix.testenv == 'eslint'"
+ - name: Set up problem matchers
+ run: "python scripts/dev/ci/problemmatchers.py ${{ matrix.testenv }} ${{ runner.temp }}"
+ - name: Install dependencies
+ run: |
+ [[ ${{ matrix.testenv }} == eslint ]] && npm install -g eslint
+ [[ ${{ matrix.testenv }} == docs ]] && sudo apt-get install --no-install-recommends asciidoc
+ if [[ ${{ matrix.testenv }} == shellcheck ]]; then
+ scversion="stable"
+ bindir="$HOME/.local/bin"
+ mkdir -p "$bindir"
+ wget -qO- "https://github.com/koalaman/shellcheck/releases/download/$scversion/shellcheck-$scversion.linux.x86_64.tar.xz" | tar -xJv --strip-components 1 -C "$bindir" shellcheck-$scversion/shellcheck
+ echo "$bindir" >> "$GITHUB_PATH"
+ fi
+ python -m pip install -U pip
+ python -m pip install -U -r misc/requirements/requirements-tox.txt
+ - name: "Run ${{ matrix.testenv }}"
+ run: "tox -e ${{ matrix.testenv}} -- ${{ matrix.args }}"
+
+ tests-docker:
+ if: "!contains(github.event.head_commit.message, '[ci skip]')"
+ timeout-minutes: 30
+ runs-on: ubuntu-20.04
+ strategy:
+ fail-fast: false
+ matrix:
+ image:
+ - archlinux-webkit
+ - archlinux-webengine
+ # - archlinux-webengine-unstable
+ container:
+ image: "qutebrowser/ci:${{ matrix.image }}"
+ env:
+ QUTE_BDD_WEBENGINE: "${{ matrix.image != 'archlinux-webkit' }}"
+ DOCKER: "${{ matrix.image }}"
+ CI: true
+ PYTEST_ADDOPTS: "--color=yes"
+ volumes:
+ # Hardcoded because we can't use ${{ runner.temp }} here apparently.
+ - /home/runner/work/_temp/:/home/runner/work/_temp/
+ options: --privileged --tty
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up problem matchers
+ run: "python scripts/dev/ci/problemmatchers.py py38 ${{ runner.temp }}"
+ - run: tox -e py
+
+ tests:
+ if: "!contains(github.event.head_commit.message, '[ci skip]')"
+ timeout-minutes: 45
+ continue-on-error: "${{ matrix.experimental == true }}"
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ ### PyQt 5.12 (Python 3.6)
+ - testenv: py36-pyqt512
+ os: ubuntu-20.04
+ python: 3.6
+ ### PyQt 5.13 (Python 3.7)
+ - testenv: py37-pyqt513
+ os: ubuntu-20.04
+ python: 3.7
+ ### PyQt 5.14 (Python 3.8)
+ - testenv: py38-pyqt514
+ os: ubuntu-20.04
+ python: 3.8
+ ### PyQt 5.15.0 (Python 3.9)
+ - testenv: py39-pyqt5150
+ os: ubuntu-20.04
+ python: 3.9
+ ### PyQt 5.15 (Python 3.9, with coverage)
+ - testenv: py39-pyqt515-cov
+ os: ubuntu-20.04
+ python: 3.9
+ ### macOS: PyQt 5.15 (Python 3.7 to match PyInstaller env)
+ - testenv: py37-pyqt515
+ os: macos-10.15
+ python: 3.7
+ args: "tests/unit" # Only run unit tests on macOS
+ ### macOS Big Sur
+ - testenv: py37-pyqt515
+ os: macos-11.0
+ python: 3.7
+ args: "tests/unit" # Only run unit tests on macOS
+ ### Windows: PyQt 5.15 (Python 3.7 to match PyInstaller env)
+ - testenv: py37-pyqt515
+ os: windows-2019
+ python: 3.7
+ runs-on: "${{ matrix.os }}"
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/cache@v2
+ with:
+ path: |
+ .mypy_cache
+ .tox
+ ~/.cache/pip
+ key: "${{ matrix.testenv }}-${{ matrix.os }}-${{ matrix.python }}-${{ hashFiles('misc/requirements/requirements-*.txt') }}-${{ hashFiles('requirements.txt') }}"
+ - name: Set up Python
+ uses: actions/setup-python@v2
+ with:
+ python-version: "${{ matrix.python }}"
+ - name: Set up problem matchers
+ run: "python scripts/dev/ci/problemmatchers.py ${{ matrix.testenv }} ${{ runner.temp }}"
+ - name: Install apt dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install --no-install-recommends libyaml-dev libegl1-mesa libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0
+ if: "startsWith(matrix.os, 'ubuntu-')"
+ - name: Install dependencies
+ run: |
+ python -m pip install -U pip
+ python -m pip install -U -r misc/requirements/requirements-tox.txt
+ - name: "Run ${{ matrix.testenv }}"
+ run: "tox -e ${{ matrix.testenv}} -- ${{ matrix.args }}"
+ - name: Analyze backtraces
+ run: "bash scripts/dev/ci/backtrace.sh ${{ matrix.testenv }}"
+ if: "failure()"
+ - name: Upload coverage
+ if: "endsWith(matrix.testenv, '-cov')"
+ uses: codecov/codecov-action@v1
+ with:
+ name: "${{ matrix.testenv }}"
+
+ codeql:
+ if: "!contains(github.event.head_commit.message, '[ci skip]')"
+ timeout-minutes: 30
+ runs-on: ubuntu-20.04
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v1
+ with:
+ languages: javascript, python
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v1
+
+ irc:
+ timeout-minutes: 2
+ continue-on-error: true
+ runs-on: ubuntu-20.04
+ needs: [linters, tests, tests-docker, codeql]
+ if: "always() && github.repository_owner == 'qutebrowser'"
+ steps:
+ - name: Send success IRC notification
+ uses: Gottox/irc-message-action@v1.1
+ if: "needs.linters.result == 'success' && needs.tests.result == 'success' && needs.tests-docker.result == 'success' && needs.codeql.result == 'success'"
+ with:
+ server: chat.freenode.net
+ channel: '#qutebrowser-dev'
+ nickname: qutebrowser-bot
+ message: "[${{ github.workflow }}] \u00033Success:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})"
+ - name: Send failure IRC notification
+ uses: Gottox/irc-message-action@v1.1
+ if: "needs.linters.result == 'failure' || needs.tests.result == 'failure' || needs.tests-docker.result == 'failure' || needs.codeql.result == 'failure'"
+ with:
+ server: chat.freenode.net
+ channel: '#qutebrowser-dev'
+ nickname: qutebrowser-bot
+ message: "[${{ github.workflow }}] \u00034FAIL:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})\n
+ linters: ${{ needs.linters.result }}, tests: ${{ needs.tests.result }}, tests-docker: ${{ needs.tests-docker.result }}, codeql: ${{ needs.codeql.result }}"
+ - name: Send skipped IRC notification
+ uses: Gottox/irc-message-action@v1.1
+ if: "needs.linters.result == 'skipped' || needs.tests.result == 'skipped' || needs.tests-docker.result == 'skipped' || needs.codeql.result == 'skipped'"
+ with:
+ server: chat.freenode.net
+ channel: '#qutebrowser-dev'
+ nickname: qutebrowser-bot
+ message: "[${{ github.workflow }}] \u00038Skipped:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})"
+ - name: Send cancelled IRC notification
+ uses: Gottox/irc-message-action@v1.1
+ if: "needs.linters.result == 'cancelled' || needs.tests.result == 'cancelled' || needs.tests-docker.result == 'cancelled' || needs.codeql.result == 'cancelled'"
+ with:
+ server: chat.freenode.net
+ channel: '#qutebrowser-dev'
+ nickname: qutebrowser-bot
+ message: "[${{ github.workflow }}] \u000314Cancelled:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})\n
+ linters: ${{ needs.linters.result }}, tests: ${{ needs.tests.result }}, tests-docker: ${{ needs.tests-docker.result }}, codeql: ${{ needs.codeql.result }}"
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 000000000..06707eb3f
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,62 @@
+name: Rebuild Docker CI images
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "23 5 * * *" # daily at 5:23
+
+jobs:
+ docker:
+ if: "github.repository == 'qutebrowser/qutebrowser'"
+ runs-on: ubuntu-20.04
+ strategy:
+ matrix:
+ image:
+ - archlinux-webkit
+ - archlinux-webengine
+ - archlinux-webengine-unstable
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-python@v2
+ with:
+ python-version: '3.x'
+ - run: pip install jinja2
+ - name: Generate Dockerfile
+ run: python3 generate.py ${{ matrix.image }}
+ working-directory: scripts/dev/ci/docker/
+ - uses: docker/setup-buildx-action@v1
+ - uses: docker/login-action@v1
+ with:
+ username: qutebrowser
+ password: ${{ secrets.DOCKER_TOKEN }}
+ - uses: docker/build-push-action@v2
+ with:
+ file: scripts/dev/ci/docker/Dockerfile
+ context: .
+ tags: "qutebrowser/ci:${{ matrix.image }}"
+ push: ${{ github.ref == 'refs/heads/master' }}
+
+ irc:
+ timeout-minutes: 2
+ continue-on-error: true
+ runs-on: ubuntu-20.04
+ needs: [docker]
+ if: "always() && github.repository == 'qutebrowser/qutebrowser'"
+ steps:
+ - name: Send success IRC notification
+ uses: Gottox/irc-message-action@v1.1
+ if: "needs.docker.result == 'success'"
+ with:
+ server: chat.freenode.net
+ channel: '#qutebrowser-dev'
+ nickname: qutebrowser-bot
+ message: "[${{ github.workflow }}] \u00033Success:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})"
+ - name: Send non-success IRC notification
+ uses: Gottox/irc-message-action@v1.1
+ if: "needs.docker.result != 'success'"
+ with:
+ server: chat.freenode.net
+ channel: '#qutebrowser-dev'
+ nickname: qutebrowser-bot
+ message: "[${{ github.workflow }}] \u00034FAIL:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})\n
+ linters: ${{ needs.linters.result }}, tests: ${{ needs.tests.result }}, tests-docker: ${{ needs.tests-docker.result }}, codeql: ${{ needs.codeql.result }}"
diff --git a/.github/workflows/recompile-requirements.yml b/.github/workflows/recompile-requirements.yml
new file mode 100644
index 000000000..c939aa81d
--- /dev/null
+++ b/.github/workflows/recompile-requirements.yml
@@ -0,0 +1,78 @@
+name: Update dependencies
+
+on:
+ schedule:
+ # Every Monday at 04:05 UTC
+ # https://crontab.guru/#05_04_*_*_1
+ - cron: '05 04 * * 1'
+ workflow_dispatch:
+ inputs:
+ environment:
+ descriptions: 'Test environments to update'
+ required: false
+ default: ''
+
+jobs:
+ update:
+ if: "github.repository == 'qutebrowser/qutebrowser'"
+ timeout-minutes: 20
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Python 3.7
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.7'
+ - name: Set up Python 3.8
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.8'
+ - name: Recompile requirements
+ run: "python3 scripts/dev/recompile_requirements.py ${{ github.events.input.environments }}"
+ id: requirements
+ - name: Create pull request
+ uses: peter-evans/create-pull-request@v3
+ with:
+ committer: qutebrowser bot <bot@qutebrowser.org>
+ author: qutebrowser bot <bot@qutebrowser.org>
+ token: ${{ secrets.QUTEBROWSER_BOT_TOKEN }}
+ commit-message: Update dependencies
+ title: Update dependencies
+ body: |
+ ## Changed files
+
+ ${{ steps.requirements.outputs.changed }}
+
+ ## Version updates
+
+ ${{ steps.requirements.outputs.diff }}
+
+ ---
+
+ I'm a bot, bleep, bloop. :robot:
+
+ branch: update-dependencies
+ irc:
+ timeout-minutes: 2
+ continue-on-error: true
+ runs-on: ubuntu-latest
+ needs: [update]
+ if: "always() && github.repository == 'qutebrowser/qutebrowser'"
+ steps:
+ - name: Send success IRC notification
+ uses: Gottox/irc-message-action@v1.1
+ if: "needs.update.result == 'success'"
+ with:
+ server: chat.freenode.net
+ channel: '#qutebrowser-dev'
+ nickname: qutebrowser-bot
+ message: "[${{ github.workflow }}] \u00033Success:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})"
+ - name: Send non-success IRC notification
+ uses: Gottox/irc-message-action@v1.1
+ if: "needs.update.result != 'success'"
+ with:
+ server: chat.freenode.net
+ channel: '#qutebrowser-dev'
+ nickname: qutebrowser-bot
+ message: "[${{ github.workflow }}] \u00034FAIL:\u0003 ${{ github.ref }} https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} (@${{ github.actor }})\n
+ linters: ${{ needs.linters.result }}, tests: ${{ needs.tests.result }}, tests-docker: ${{ needs.tests-docker.result }}, codeql: ${{ needs.codeql.result }}"
diff --git a/.gitignore b/.gitignore
index 2f5c25116..31c4ca3b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,15 @@
__pycache__
*.py~
*.pyc
-*.swp
+[._]*.s[a-v][a-z]
+[._]*.sw[a-p]
+[._]s[a-rt-v][a-z]
+[._]ss[a-gi-z]
+[._]sw[a-p]
+[._]*.un~
+Session.vim
+Sessionx.vim
+*~
/build
/dist
/qutebrowser.egg-info
@@ -27,7 +35,8 @@ __pycache__
/.pytest_cache
/.testmondata
/.hypothesis
-/.mypy_cache
+/.benchmarks
+.mypy_cache
/prof
/venv
TODO
diff --git a/mypy.ini b/.mypy.ini
index 5d404f4b2..d629f012c 100644
--- a/mypy.ini
+++ b/.mypy.ini
@@ -1,26 +1,24 @@
[mypy]
-# We also need to support 3.5, but if we'd chose that here, we'd need to deal
-# with conditional imports (like secrets.py).
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
@@ -38,10 +36,6 @@ ignore_missing_imports = True
# https://bitbucket.org/birkenfeld/pygments-main/issues/1485/type-hints
ignore_missing_imports = True
-[mypy-cssutils]
-# Pretty much inactive currently
-ignore_missing_imports = True
-
[mypy-pypeg2]
# Pretty much inactive currently
ignore_missing_imports = True
@@ -65,76 +59,64 @@ disallow_subclassing_any = False
[mypy-qutebrowser.browser.browsertab]
disallow_untyped_defs = True
-disallow_incomplete_defs = True
[mypy-qutebrowser.browser.hints]
disallow_untyped_defs = True
+
+[mypy-qutebrowser.browser.inspector]
+disallow_untyped_defs = True
+
+[mypy-qutebrowser.browser.webkit.webkitinspector]
+disallow_untyped_defs = True
+
+[mypy-qutebrowser.browser.webengine.webengineinspector]
+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.browser.webengine.darkmode]
+disallow_untyped_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 1fedefb6d..f4fe8cdbb 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -46,6 +46,7 @@ disable=locally-disabled,
too-many-statements,
too-few-public-methods,
import-outside-toplevel,
+ bad-continuation # This lint disagrees with Black
[BASIC]
function-rgx=[a-z_][a-z0-9_]{2,50}$
@@ -59,7 +60,7 @@ docstring-min-length=3
no-docstring-rgx=(^_|^main$)
[FORMAT]
-max-line-length=79
+max-line-length=88
ignore-long-lines=(<?https?://|file://|^# Copyright 201\d|link:)
expected-line-ending-format=LF
@@ -74,7 +75,7 @@ valid-metaclass-classmethod-first-arg=cls
[TYPECHECK]
ignored-modules=PyQt5,PyQt5.QtWebKit
-ignored-classes=DummyBox
+ignored-classes=DummyBox,__cause__
[IMPORTS]
known-third-party=sip
diff --git a/.pyup.yml b/.pyup.yml
deleted file mode 100644
index 3fbe456dc..000000000
--- a/.pyup.yml
+++ /dev/null
@@ -1 +0,0 @@
-schedule: "every week on monday"
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 54eab192e..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,129 +0,0 @@
-dist: bionic
-language: python
-group: edge
-python: 3.8
-os: linux
-
-matrix:
- fast_finish: true
- # allow_failures:
- # - env: DOCKER=archlinux-webengine-unstable QUTE_BDD_WEBENGINE=true
- # services: docker
- include:
- ### Archlinux QtWebKit
- - env: DOCKER=archlinux-webkit
- services: docker
-
- ### Archlinux QtWebEngine
- - env: DOCKER=archlinux-webengine QUTE_BDD_WEBENGINE=true
- services: docker
-
- ### Archlinux QtWebEngine with testing/KDE-Unstable
- # - env: DOCKER=archlinux-webengine-unstable QUTE_BDD_WEBENGINE=true
- # services: docker
-
- ### PyQt 5.7.1 (Python 3.5)
- - python: 3.5
- env: TESTENV=py35-pyqt57
- dist: xenial
-
- ### PyQt 5.9 (Python 3.6)
- - python: 3.6
- env: TESTENV=py36-pyqt59
-
- ### PyQt 5.10 (Python 3.6)
- - python: 3.6
- env: TESTENV=py36-pyqt510
- addons:
- apt:
- packages:
- - xfonts-base
-
- ### PyQt 5.11 (Python 3.7)
- - python: 3.7
- env: TESTENV=py37-pyqt511
-
- ### PyQt 5.12 (Python 3.8)
- - env: TESTENV=py38-pyqt512
- # http://code.qt.io/cgit/qt/qtbase.git/commit/?id=c3a963da1f9e7b1d37e63eedded61da4fbdaaf9a
- addons:
- apt:
- packages:
- - libxkbcommon-x11-0
-
- ### 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
- addons:
- apt:
- packages:
- - libxkbcommon-x11-0
-
- ### macOS Mojave (10.14)
- - os: osx
- env: TESTENV=py37-pyqt514 OSX=mojave
- osx_image: xcode11.3
- language: generic
- python: 3.7
-
- ### macOS High Sierra (10.13)
- - os: osx
- env: TESTENV=py37-pyqt514 OSX=highsierra
- osx_image: xcode10.1
- language: generic
- python: 3.7
-
- ### pylint/flake8/mypy
- - env: TESTENV=pylint
- - env: TESTENV=flake8
- - env: TESTENV=mypy
-
- ### docs
- - env: TESTENV=docs
- addons:
- apt:
- packages:
- - asciidoc
-
- ### vulture/misc/pyroma/check-manifest
- - env: TESTENV=vulture
- - env: TESTENV=misc
- - env: TESTENV=pyroma
- - env: TESTENV=check-manifest
-
- ### eslint
- - env: TESTENV=eslint
- language: node_js
- python: null
- node_js: "lts/*"
-
- ### shellcheck
- - language: generic
- env: TESTENV=shellcheck
- services: docker
-
-cache:
- directories:
- - $HOME/.cache/pip
- - $HOME/build/qutebrowser/qutebrowser/.cache
-
-install:
- - bash scripts/dev/ci/travis_install.sh
- - ulimit -c unlimited
-
-script:
- - bash scripts/dev/ci/travis_run.sh
-
-after_success:
- - '[[ $TESTENV == *-cov ]] && codecov -e TESTENV -X gcov'
-
-after_failure:
- - bash scripts/dev/ci/travis_backtrace.sh
diff --git a/.yamllint b/.yamllint
new file mode 100644
index 000000000..8e4d4a388
--- /dev/null
+++ b/.yamllint
@@ -0,0 +1,19 @@
+extends: default
+
+ignore: |
+ /.venv/
+ /.tox/
+ /build/
+ /dist/
+
+rules:
+ document-start: disable
+ line-length:
+ max: 88
+ ignore: |
+ /.github/*.yml
+ /.github/workflows/*.yml
+ truthy:
+ # on: ...
+ ignore: |
+ /.github/workflows/*.yml
diff --git a/MANIFEST.in b/MANIFEST.in
index c8c11c32e..ed4b5e5b1 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,17 +30,19 @@ 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
+prune **/.mypy_cache
global-exclude __pycache__ *.pyc *.pyo
diff --git a/README.asciidoc b/README.asciidoc
index 9e2a61a22..42013368c 100644
--- a/README.asciidoc
+++ b/README.asciidoc
@@ -9,8 +9,7 @@ qutebrowser
// QUTE_WEB_HIDE
image:icons/qutebrowser-64x64.png[qutebrowser logo] *A keyboard-driven, vim-like browser based on PyQt5 and Qt.*
-image:https://travis-ci.org/qutebrowser/qutebrowser.svg?branch=master["Build Status", link="https://travis-ci.org/qutebrowser/qutebrowser"]
-image:https://ci.appveyor.com/api/projects/status/5pyauww2k68bbow2/branch/master?svg=true["AppVeyor build status", link="https://ci.appveyor.com/project/qutebrowser/qutebrowser"]
+image:https://github.com/qutebrowser/qutebrowser/workflows/CI/badge.svg["Build Status", link="https://github.com/qutebrowser/qutebrowser/actions?query=workflow%3ACI"]
image:https://codecov.io/github/qutebrowser/qutebrowser/coverage.svg?branch=master["coverage badge",link="https://codecov.io/github/qutebrowser/qutebrowser?branch=master"]
link:https://www.qutebrowser.org[website] | link:https://blog.qutebrowser.org[blog] | https://github.com/qutebrowser/qutebrowser/blob/master/doc/faq.asciidoc[FAQ] | https://www.qutebrowser.org/doc/contributing.html[contributing] | link:https://github.com/qutebrowser/qutebrowser/releases[releases] | https://github.com/qutebrowser/qutebrowser/blob/master/doc/install.asciidoc[installing]
@@ -45,41 +44,11 @@ See the https://github.com/qutebrowser/qutebrowser/releases[github releases
page] for available downloads and the link:doc/install.asciidoc[INSTALL] file for
detailed instructions on how to get qutebrowser running on various platforms.
-Documentation
--------------
-
-In addition to the topics mentioned in this README, the following documents are
-available:
-
-* https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-big.png[Key binding cheatsheet]: +
-image:https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-small.png["qutebrowser key binding cheatsheet",link="https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-big.png"]
-* link:doc/quickstart.asciidoc[Quick start guide]
-* https://www.shortcutfoo.com/app/dojos/qutebrowser[Free training course] to remember those key bindings
-* link:doc/faq.asciidoc[Frequently asked questions]
-* link:doc/help/configuring.asciidoc[Configuring qutebrowser]
-* link:doc/contributing.asciidoc[Contributing to qutebrowser]
-* link:doc/install.asciidoc[Installing qutebrowser]
-* link:doc/changelog.asciidoc[Change Log]
-* link:doc/stacktrace.asciidoc[Reporting segfaults]
-* link:doc/userscripts.asciidoc[How to write userscripts]
-
-Getting help
-------------
-
-You can get help in the IRC channel
-irc://irc.freenode.org/#qutebrowser[`#qutebrowser`] on
-https://freenode.net/[Freenode]
-(https://webchat.freenode.net/?channels=#qutebrowser[webchat]), or by writing a
-message to the
-https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[mailinglist] at
-mailto:qutebrowser@lists.qutebrowser.org[].
+Documentation and getting help
+------------------------------
-There's also an https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser-announce[announce-only mailinglist]
-at mailto:qutebrowser-announce@lists.qutebrowser.org[] (the announcements also
-get sent to the general qutebrowser@ list).
-
-If you're a reddit user, there's a
-https://www.reddit.com/r/qutebrowser/[/r/qutebrowser] subreddit there.
+Please see the link:doc/help/index.asciidoc[help page] for available documentation
+pages and support channels.
Contributions / Bugs
--------------------
@@ -105,9 +74,9 @@ 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.6 or newer
+* https://www.qt.io/[Qt] 5.12.0 or newer (5.12 LTS or 5.15 recommended)
+ with the following modules:
- QtCore / qtbase
- QtQuick (part of qtbase in some distributions)
- QtSQL (part of qtbase in some distributions)
@@ -119,19 +88,21 @@ The following software and libraries are required to run qutebrowser:
revision with known unpatched vulnerabilities. Please use it carefully and
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
-* https://pypi.python.org/pypi/setuptools/[pkg_resources/setuptools]
+* https://www.riverbankcomputing.com/software/pyqt/intro[PyQt] 5.12.0 or newer
+ for Python 3
+* https://pypi.python.org/pypi/setuptools/[pkg_resources/setuptools] (being
+ phased out for qutebrowser v2.0.0)
* https://fdik.org/pyPEG/[pyPEG2]
* http://jinja.pocoo.org/[jinja2]
* http://pygments.org/[pygments]
* https://github.com/yaml/pyyaml[PyYAML]
* https://www.attrs.org/[attrs]
+* https://importlib-resources.readthedocs.io/[importlib_resources] (on Python
+ 3.8 or older)
The following libraries are optional:
-* http://cthedot.de/cssutils/[cssutils] (for an improved `:download --mhtml`
- with QtWebKit).
+* https://pypi.org/project/adblock/[adblock] (for improved adblocking using ABP syntax)
* On Windows, https://pypi.python.org/pypi/colorama/[colorama] for colored log
output.
* http://asciidoc.org/[asciidoc] to generate the documentation for the `:help`
@@ -205,19 +176,19 @@ 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
~~~~~~
* 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://github.com/parkouss/webmacs/[webmacs] (Python, Emacs-like with QtWebEngine)
+* https://nyxt.atlas.engineer/[Nyxt browser] (formerly "Next browser", Lisp, Emacs-like but also offers Vim bindings, QtWebKit or GTK+/WebKit2 - note there was a http://jgkamat.gitlab.io/blog/next-rce.html[critical remote code execution] which was handled quite badly)
+* https://vieb.dev/[Vieb] (JavaScript, Electron)
* Chrome/Chromium addons:
https://vimium.github.io/[Vimium],
+ https://github.com/dcchambers/vb4c[vb4c] (fork of cVim)
* Firefox addons (based on WebExtensions):
https://github.com/tridactyl/tridactyl[Tridactyl],
https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/[Vimium-FF] (experimental),
@@ -225,7 +196,6 @@ Active
https://github.com/amedama41/vvimpulation[VVimpulation]
* Addons for Firefox and Chrome:
https://github.com/brookhong/Surfingkeys[Surfingkeys],
- https://github.com/lusakasa/saka-key[Saka Key],
https://krabby.netlify.com/[Krabby],
https://lydell.github.io/LinkHints/[Link Hints] (hinting only)
* Addons for Safari:
@@ -237,6 +207,8 @@ Inactive
* https://bitbucket.org/portix/dwb[dwb] (C, GTK+ with WebKit1,
https://bitbucket.org/portix/dwb/pull-requests/22/several-cleanups-to-increase-portability/diff[unmaintained] -
main inspiration for qutebrowser)
+* https://github.com/parkouss/webmacs/[webmacs] (Python, Emacs-like with
+ QtWebEngine, https://github.com/parkouss/webmacs/issues/137[unmaintained])
* https://sourceforge.net/p/vimprobable/wiki/Home/[vimprobable] (C, GTK+ with
WebKit1)
* https://wiki.archlinux.org/index.php?title=Jumanji[jumanji] (C, GTK+ with WebKit1,
@@ -245,15 +217,17 @@ original site is gone but the Arch Linux wiki has some data)
* https://www.uzbl.org/[uzbl] (C, GTK+ with WebKit1/WebKit2)
* https://github.com/conformal/xombrero[xombrero] (C, GTK+ with WebKit1)
* https://github.com/linkdd/cream-browser[Cream Browser] (C, GTK+ with WebKit1)
+* https://surf.suckless.org/[surf] (C, GTK+ with WebKit1/WebKit2)
* Firefox addons (not based on WebExtensions or no recent activity):
http://www.vimperator.org/[Vimperator],
http://bug.5digits.org/pentadactyl/index[Pentadactyl],
- https://github.com/akhodakivskiy/VimFx[VimFx],
+ https://github.com/akhodakivskiy/VimFx[VimFx] (seems to offer a
+ https://gir.st/blog/legacyfox.htm[hack] to run on modern Firefox releases),
https://github.com/shinglyu/QuantumVim[QuantumVim]
* Chrome/Chromium addons:
- https://chrome.google.com/webstore/detail/vichrome/gghkfhpblkcmlkmpcpgaajbbiikbhpdi?hl=en[ViChrome],
+ https://github.com/k2nr/ViChrome/[ViChrome],
https://github.com/jinzhu/vrome[Vrome],
- https://github.com/lusakasa/saka-key[Saka Key],
+ https://github.com/lusakasa/saka-key[Saka Key] (https://github.com/lusakasa/saka-key/issues/171[unmaintained]),
https://github.com/1995eaton/chromium-vim[cVim],
https://glee.github.io/[GleeBox]
diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc
index bbb166eeb..ceb5faa46 100644
--- a/doc/changelog.asciidoc
+++ b/doc/changelog.asciidoc
@@ -15,7 +15,636 @@ 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)
+v2.0.0 (unreleased)
+-------------------
+
+Major changes
+~~~~~~~~~~~~~
+
+- At least Python 3.6 is now required to run qutebrowser, support for Python
+ 3.5 is dropped. Note that Python 3.5 is
+ https://www.python.org/downloads/release/python-3510/[no longer supported
+ upstream] since September 2020.
+- At least Qt/PyQt 5.12 is now required to run qutebrowser, support for 5.7 to
+ 5.11 (inclusive) is dropped. While Debian Buster ships Qt 5.11, it's based on a
+ Chromium version from 2018 with
+ https://www.debian.org/releases/buster/amd64/release-notes/ch-information.en.html#browser-security[no Debian security support]
+ and unsupported upstream since May 2019.
+ It also has compatibility issues with various websites (GitHub, Twitch, Android
+ Developer documentation, YouTube, ...). Since no newer Debian Stable is released
+ at the time of writing, it's recommended to
+ https://github.com/qutebrowser/qutebrowser/blob/master/doc/install.asciidoc#installing-qutebrowser-with-virtualenv[install qutebrowser in a virtualenv]
+ with a newer version of Qt/PyQt.
+- New optional dependency on the Python `adblock` library, which is now used to
+ integrate Brave's Rust adblocker library, if the `adblock` module is available.
+- Windows 7 is not supported anymore by the Windows binaries.
+- The (formerly optional) `cssutils` dependency is now removed. It was only
+ needed for improved behavior in corner cases when using `:download --mhtml`
+ with the (non-default) QtWebKit backend, and as such it's unlikely anyone is
+ still relying on it. The `cssutils` project is also dead upstream, with its
+ repository being gone after Bitbucket
+ https://bitbucket.org/blog/sunsetting-mercurial-support-in-bitbucket[removed Mercurial support].
+- TODO: The former dependency on the `pkg_resources` module (part of the
+ `setuptools` project) got dropped.
+- A new dependency on the `importlib_resources` module got introduced for
+ Python versions up to and including 3.8. Note that the stdlib
+ `importlib.resources` module for Python 3.7 and 3.8 is missing the needed APIs,
+ thus requiring the backports for those versions as well.
+
+Removed
+~~~~~~~
+
+- The `--enable-webengine-inspector` flag (which was only needed for Qt 5.10 and
+ below) is now dropped. With Qt 5.11 and newer, the inspector/devtools are
+ enabled unconditionally.
+- Support for moving qutebrowser data from versions before v1.0.0 has been
+ removed.
+- The `--old` flag for `:config-diff` has been removed. It used to show
+ customized options for the old pre-v1.0 config files (in order to aid
+ migration to v1.0).
+- The `:inspector` command which was deprecated in v1.13.0 (in favor of
+ `:devtools`) is now removed.
+
+Added
+~~~~~
+
+- New settings for the ABP-based adblocker:
+ * `content.blocking.method` to decide which blocker(s) should be used.
+ * `content.blocking.adblock.lists` to configure ABP-like lists to use.
+- New `qt.environ` setting which makes it easier to set/unset environment
+ variables for qutebrowser.
+- When QtWebEngine has been updated but PyQtWebEngine hasn't yet, the dark mode
+ settings might stop working. As a (currently undocumented) escape hatch, this
+ version adds a `QUTE_DARKMODE_VARIANT=qt_515_2` environment variable which can
+ be set to get the correct behavior in (transitive) situations like this.
+- New `--desktop-file-name` commandline argument, which can be used to customize
+ the desktop filename passed to Qt (which is used to set the `app_id` on
+ Wayland).
+- New userscripts:
+ - `kodi` to play videos in Kodi
+ - `qr` to generate a QR code of the current URL
+ - `add-nextcloud-bookmarks` to create bookmarks in Nextcloud's Bookmarks app
+ - `add-nextcloud-cookbook` to add recipes to Nextcloud's Cookbook app
+
+Changed
+~~~~~~~
+
+- `config.py` files now are required to have either
+ `config.load_autoconfig(False)` (don't load `autoconfig.yml`) or
+ `config.load_autoconfig()` (do load `autoconfig.yml`) in them.
+- (TODO) Windows and macOS releases now ship Python 3.9 rather than 3.7.
+- The `colors.webpage.darkmode.*` settings are now also supported with older Qt
+ versions (Qt 5.12 and 5.13) rather than just with Qt 5.14 and above.
+- For regexes in the config (`hints.{prev,next}_regexes`), certain patterns
+ which will change meanings in future Python versions are now disallowed. This is
+ the case for character sets starting with a literal `[` or containing literal
+ character sequences `--`, `&&`, `~~`, or `||`. To avoid a warning, remove the
+ duplicate characters or escape them with a backslash.
+- If `prompt(..., "default")` is used via JS, the default text is now
+ pre-selected in the prompt shown by qutebrowser.
+- URLs such as `::1/foo` are now handled as a search term or local file rather
+ than IPv6. Use `[::1]/foo` to force parsing as IPv6 instead.
+- The `mkvenv.py` script now runs a "smoke test" after setting up the virtual
+ environment to ensure it's working as expected. If necessary, the test can be
+ skipped via a new `--skip-smoke-test` flag.
+- Both qutebrowser userscripts and Greasemonkey scripts are now additionally
+ picked up from qutebrowser's config directory (the `userscripts` and
+ `greasemonkey` subdirectories of e.g. `~/.config/qutebrowser/`) rather than only
+ the data directory (the same subdirectories of e.g.
+ `~/.local/share/qutebrowser/`).
+- The `:later` command now understands a time specification like `5m` or
+ `1h5m2s`, rather than just taking milliseconds.
+- The `importer.py` script doesn't use a browser argument anymore; instead its
+ `--input-format` switch can be used to configure the input format. The help also
+ was expanded to explain how to use it properly.
+- If `tabs.tabs_are_windows` is set, the `tabs.last_close` setting is now
+ ignored and the window is always closed when using `:close` (`d`).
+- Various host-blocking settings have been renamed to accomodate the new ABP-like
+ adblocker:
+ * `content.host_blocking.enabled` -> `content.blocking.enabled` (controlling both blockers)
+ * `content.host_blocking.whitelist` -> `content.blocking.whitelist` (controlling both blockers)
+ * `content.host_blocking.lists` -> `content.blocking.hosts.lists`
+- With the (default) QtWebEngine backend, if a custom `accept` header is set via
+ `content.headers.custom`, the custom value is now ignored for XHR
+ (`XMLHttpRequest`) requests. Instead, the sent value is now `*/*` or the header
+ set from JavaScript, as it would be if `content.headers.custom` wasn't set.
+- The `:buffer` completion now shows the underlying renderer process PID if
+ doing so is supported (QtWebEngine 5.15).
+
+Fixed
+~~~~~
+
+- With interpolated color settings (`colors.tabs.indicator.*` and
+ `colors.downloads.*`), the alpha channel is now handled correctly.
+- Fixes to userscripts:
+ * `format_json` now uses `env` in its shebang, making it work
+ correctly on systems where `bash` isn't located in `/bin`.
+ * `qute-pass` now handles the MIME output format introduced in gopass 1.10.0.
+ * `qute-lastpass` now types multiple `<` or `>` characters correctly.
+- The `:undo` completion now sorts its entries correctly (by the numerical index
+ rather than lexicographically).
+- The `completion.web_history.ignore` setting now works properly when set in
+ `config.py` (rather than via `:set`). Additionally, a `:config-source` will not
+ result in a history rebuild if the value wasn't actually changed.
+- When downloading a `data:` URL, the suggested filename is now improved and
+ contains a proper extension. Before this fix, qutebrowser would use the URL's
+ data contents as filename with QtWebEngine; or "binary blob" with the Qt network
+ stack.
+- TODO: Due to a long-standing bug in the `pkg_resources` dependency, it caused
+ qutebrowser's startup to slow down by around 150ms-1s (heavily depending on
+ the system). Since the dependency is now removed, qutebrowser's startup time
+ thus improved.
+
+v1.14.1 (2020-12-04)
+--------------------
+
+Added
+~~~~~
+
+- With v1.14.0, qutebrowser configures the main window to be transparent, so
+ that it's possible to configure a translucent tab- or statusbar. However, that
+ change introduced various issues, such as performance degradation on some
+ systems or breaking dmenu window embedding with its `-w` option. To avoid those
+ issues for people who are not using transparency, the default behavior is
+ reverted to versions before v1.14.0 in this release. A new `window.transparent`
+ setting can be set to `true` to restore the behavior of v1.14.0.
+
+Changed
+~~~~~~~
+
+- Windows and macOS releases now ship Qt 5.15.2, which is based on
+ Chromium 83.0.4103.122 with security fixes up to 86.0.4240.183. This includes
+ CVE-2020-15999 in the bundled freetype library, which is known to be exploited
+ in the wild. It also includes various other bugfixes/features compared to
+ Qt 5.15.0 included in qutebrowser v1.14.0, such as:
+ * Correct handling of AltGr on Windows
+ * Fix for `content.cookies.accept` not working properly
+ * Fixes for screen sharing (some websites are still broken until an upcoming Qt
+ 5.15.3)
+ * Support for FIDO U2F / WebAuth
+ * Fix for the unwanted creation of directories such as `databases-incognito` in
+ the home directory
+ * Proper autocompletion in the devtools console
+ * Proper signalisation of a tab's audible status (`[A]`)
+ * Fix for a hang when opening the context menu on macOS Big Sur (11.0)
+ * Hardware accelerated graphics on macOS
+
+Fixed
+~~~~~
+
+- Setting the `content.headers.referer` setting to `same-domain` (the default)
+ was supposed to truncate referers to only the host with QtWebEngine.
+ Unfortunately, this functionality broke in Qt 5.14. It works properly again
+ with this release, including a test so this won't happen again.
+- With QtWebEngine 5.15, setting the `content.headers.referer` setting to
+ `never` did still send referers. This is now fixed as well.
+- In v1.14.0, a regression was introduced, causing a crash when qutebrowser was
+ closed after opening a download with PDF.js. This is now fixed.
+- With Qt 5.12, the `Object.fromEntries` JavaScript API is unavailable (it was
+ introduced in Chromium 73, while Qt 5.12 is based on 69). This caused
+ https://www.vr.fi/en and possibly other websites to break when accessed with Qt
+ 5.12. A suitable polyfill is now included with qutebrowser if
+ `content.site_specific_quirks` is enabled (which is the default).
+- While XDG startup notifications (e.g. launch feedback via the bouncy cursor
+ in KDE Plasma) were supported ever since Qt 5.1, qutebrowser's desktop file
+ accidentally declared that it wasn't supported. This is now fixed.
+- The `dmenu_qutebrowser` and `qutedmenu` userscripts now correctly read the
+ qutebrowser sqlite history which has been in use since v1.0.0.
+- With Python 3.8+ and vertical tabs, a deprecation warning for an implicit int
+ conversion was shown. This is now fixed.
+- Ever since Qt 5.11, fetching more completion data when that data is loaded
+ lazily (such as with history) and the last visible item is selected was broken.
+ The exact reason is currently unknown, but this release adds a tenative fix.
+- When PgUp/PgDown were used to go beyond the last visible item, the above issue
+ caused a crash, which is now also fixed.
+- As a workaround for an overzealous Microsoft Defender false-positive detecting
+ a "trojan" in the (unprocessed) adblock list, `:adblock-update` now doesn't
+ cache the HTTP response anymore.
+- With the QtWebKit backend and `content.headers` set to `same-domain` (the
+ default), origins with the same domain but different schemes or ports were
+ treated as the same domain. They now are correctly treated as different domains.
+- When a URL path uses percent escapes (such as
+ `https://example.com/embedded%2Fpath`), using `:navigate up` would treat the
+ `%2F` as a path separator and replace any remaining percent escapes by their
+ unescaped equivalents. Those are now handled correctly.
+- On macOS 11.0 (Big Sur), the default monospace font name caused a parsing error, thus
+ resulting in broken styling for the completion, hints, and other UI components.
+ They now look properly again.
+- Due to a Qt bug, installing Qt/PyQt from prebuilt binaries on systems with a
+ very old `libxcb-utils` version (notably, Debian Stable, but not Ubuntu since
+ 16.04 LTS) results in a setup which fails to start. This also affects the
+ `mkvenv.py` script, which now includes a workaround for this case.
+- The `open_url_instance.sh` userscript now complains when `socat` is not
+ installed, rather than silencing the error.
+- The example AppArmor profile in `misc/` was outdated and written for the
+ older QtWebKit backend. It is now updated to serve as an useful starting
+ point with QtWebEngine.
+- When running `:devtools` on Fedora without the needed (optional) dependency
+ installed, it was suggested to install `qt5-webengine-devtools`, which does
+ not, in fact, exist. It's now correctly suggested to install
+ `qt5-qtwebengine-devtools` instead.
+- With Qt 5.15.2, lines/borders coming from the `readability-js` userscript
+ were invisible. This is now fixed by changing the border color to grey (with all
+ Qt versions).
+- Due to changes in the underlying Chromium, the
+ `colors.webpage.prefers_color_scheme_dark` setting broke with Qt 5.15.2. It now
+ works properly again.
+- A bug in the `pkg_resources` module used by qutebrowser caused deprecation
+ warnings to appear on start with Python 3.9 on some setups. Those are now
+ hidden.
+- Minor performance improvements.
+- Fix for various functionality breaking in private windows with v1.14.0,
+ after the last private window is closed. This includes:
+ * Ad blocking
+ * Downloads
+ * Site-specific quirks (e.g. for Google login)
+ * Certain settings such as `content.javascript.enabled`
+
+v1.14.0 (2020-10-15)
+--------------------
+
+Note: The QtWebEngine version bundled with the Windows/macOS
+releases is still based on Qt 5.15.0 (like with qutebrowser v1.12.0 and
+v1.13.0) rather than Qt 5.15.1 because of a
+https://bugreports.qt.io/browse/QTBUG-86752[Qt bug] causing
+frequent renderer process crashes. When Qt 5.15.2 is released
+(planned for November 3rd, 2020), a qutebrowser v1.14.x patch
+release with an updated QtWebEngine will be released.
+
+Furthermore, this release still only contains partial session support for QtWebEngine
+5.15. It's still recommended to run against Qt 5.15 due to the security patches
+contained in it -- for most users, the added workarounds seem to work out fine. A
+rewritten session support will be part of qutebrowser v2.0.0, tentatively planned for the
+end of the year or early 2021.
+
+Changed
+~~~~~~~
+
+- The `content.media_capture` setting got split up into three more fine-grained
+ settings, `content.media.audio_capture`, `.video_capture` and
+ `.audio_video_capture`. Before this change, answering "always" to a prompt
+ about e.g. audio capturing would set the `content.media_capture` setting,
+ which would also allow the same website to capture video on a future visit.
+ Now every prompt will set the appropriate setting, though existing
+ `content.media_capture` settings in `autoconfig.yml` will be migrated to set
+ all three settings. To review/change previously granted permissions, use
+ `:config-diff` and e.g.
+ `:config-unset -u example.org content.media.video_capture`.
+- The main window's (invisible) background color is now set to transparent.
+ This allows using the alpha channel in statusbar/tabbar colors to get a
+ partially transparent qutebrowser window on a setup which supports doing so.
+- If QtWebEngine is compiled with PipeWire support and libpipewire is
+ installed, qutebrowser will now support screen sharing on Wayland. Note that
+ QtWebEngine 5.15.1 is needed.
+- When `:undo` is used with a count, it now reopens the count-th to last tab
+ instead of the last one. The depth can instead be passed as an argument,
+ which is also completed.
+- The default `completion.timestamp_format` now also shows the time.
+- `:back` and `:forward` now take an optional index which is completed using
+ the current tab's history.
+- The time a website in a tab was visited is now saved/restored in sessions.
+- When attempting to download a file to a location for which there's already a
+ still-running download, a confirmation prompt is now displayed.
+- `:completion-item-focus` now understands `next-page` and `prev-page` with
+ corresponding `<PgDown>` / `<PgUp>` default bindings.
+- When the last private window is closed, all private browsing data is now cleared.
+- When `config.source(...)` is used with a `--config-py` argument given,
+ qutebrowser used to search relative files in the config basedir, leading to them
+ not being found when using a shared `config.py` for different basedirs. Instead,
+ they are now searched relative to the given `config.py` file.
+- `navigate prev` (`[[`) and `navigate next` (`]]`) now recognize links with
+ `nav-prev` and `nav-next` classes, such as those used by the Hugo static site
+ generator.
+- When `tabs.favicons` is disabled but `tabs.tabs_are_windows` is set, the
+ window icon is still set to the page's favicon now.
+- The `--asciidoc` argument to `src2asciidoc.py` and `build_release.py` now
+ only takes the path to `asciidoc.py`, using the current Python interpreter by
+ default. To configure the Python interpreter as well, use
+ `--asciidoc-python path/to/python --asciidoc path/to/asciidoc.py`
+ instead of the former
+ `--asciidoc path/to/python path/to/asciidoc.py`.
+- Dark mode (`colors.webpage.darkmode.*`) is now supported with Qt 5.15.2 (which
+ is not released yet).
+- The default for the darkmode `policy.images` setting is now set to `smart`
+ which fixes issues with e.g. formulas on Wikipedia.
+- The `readability-js` userscript now adds some CSS to improve the reader mode
+ styling in various scenarios:
+ * Images are now shrinked to the page width, similarly to what Firefox' reader
+ mode does.
+ * Some images ore now displayed as block (rather than inline) which is what
+ Firefox' reader mode does as well.
+ * Blockquotes are now styled more distinctively, again based on the Firefox
+ reader mode.
+ * Code blocks are now easier to distinguish from text and tables have visible
+ cell margins.
+- The `readability-js` userscript now supports hint userscript mode.
+
+Added
+~~~~~
+
+- New argument `strip` for `:navigate` which removes queries and
+ fragments from the current URL.
+- `:undo` now has a new `-w` / `--window` argument, which can be used to
+ restore closed windows (rather than tabs). This is bound to `U` by default.
+- `:jseval` can now take `javascript:...` URLs via a new `--url` flag.
+- New replacement `{aligned_index}` for `tabs.title.format` and `format_pinned`
+ which behaves like `{index}`, but space-pads the index based on the total
+ numbers of tabs. This can be used to get aligned tab texts with vertical
+ tabs.
+- New command `:devtools-focus` (bound to `wIf`) to toggle keyboard focus
+ between the devtools and web page.
+- The `--target` argument to qutebrowser now understands a new `private-window`
+ value, which can be used to open a private window in an existing instance
+ from the commandline.
+- The `:download-open` command now has a new `--dir` flag, which can be used to
+ open the directory containing the downloaded file. An entry to do the same
+ was also added to the context menu.
+- Messages are now wrapped when they are too long to be displayed on a single line.
+- New possible `--debug-flag` values:
+ * `wait-renderer-process` waits for a `SIGUSR1` in the renderer process so a
+ debugger can be attached.
+ * `avoid-chromium-init` allows using `--version` without needing a working
+ QtWebEngine/Chromium.
+
+Fixed
+~~~~~
+
+- A URL pattern with a `*.` host was considered valid and matched all hosts.
+ Due to keybindings like `tsH` toggling scripts for `*://*.{url:host}/*`,
+ invoking them on pages without a host (e.g. `about:blank`) could result in
+ accidentally allowing/blocking JavaScript for all pages. Such patterns are
+ now considered invalid, with existing patterns being automatically removed
+ from `autoconfig.yml`.
+- When `scrolling.bar` was set to `overlay` (the default), qutebrowser would
+ internally override any `enable-features=...` flags passed via `qt.args` or
+ `--qt-flag`. It now correctly combines existing `enable-feature` flags with
+ internal ones.
+- Elements with an inherited `contenteditable` attribute now trigger insert
+ mode and get hints assigned correctly.
+- When checkmarks, radio buttons and some other elements are styled via the
+ Bootstrap CSS framework, they now get hints correctly.
+- When the session file isn't writable when qutebrowser exits, an error is now
+ logged instead of crashing.
+- When using `-m` with the `qute-lastpass` userscript, it accidentally matched
+ URLs containing the match as substring. This is now fixed.
+- When a filename is derived from a page's title, it's now shortened to the
+ maximum filename length permitted by the filesystem.
+- `:enter-mode register` crashed since v1.13.0, it now displays an error
+ instead.
+- With the QtWebKit backend, webpage resources loading certain invalid URLs
+ could cause a crash, which is now fixed.
+- When `:config-edit` is used but no `config.py` exists yet, the file is now
+ created (and watched for changes properly) before spawning the external
+ editor.
+- When hint mode was entered from outside normal mode, the status bar was empty
+ instead of displaying the proper text. This is now fixed.
+- When entering different modes too quickly (e.g. pressing `fV`), the statusbar
+ could end up in a confusing state. This is now fixed.
+- When qutebrowser quits, running downloads are now cancelled properly.
+- The site-specific quirk for `web.whatsapp.com` has been updated to work after recent
+ changes in WhatsApp.
+- Highlighting in the completion now works properly when UTF-16 surrogate pairs (such as
+ emoji) are involved.
+- When a windowed inspector is clicked, insert mode now isn't entered anymore.
+- When `:undo` is used to re-open a tab, but `tabs.tabs_are_windows` was set between
+ closing and undoing the close, qutebrowser crashed. This is now fixed.
+- With QtWebEngine 5.15.0, setting the darkmode image policy to `smart` leads to
+ renderer process crashes. The offending setting value is now ignored with a
+ warning.
+- Fixes for the `qute-pass` userscript:
+ * With newer `gopass` versions, a deprecation notice was copied as
+ password due to `qute-pass` using it in a deprecated way.
+ * The `--password-store` argument didn't actually set
+ `PASSWORD_STORE_DIR` for `pass`, resulting in `qute-pass` finding matches but the
+ underlying `pass` not finding matching passwords.
+
+v1.13.1 (2020-07-17)
+--------------------
+
+Fixed
+~~~~~
+
+- With Qt 5.14, shared workers are now disabled. This works around a crash in
+ QtWebEngine on certain sites (like the Epic Games Store or the Unreal Engine
+ page). On older versions, you can get the same effect by doing
+ `:set qt.args "['disable-shared-workers']"` and `:restart` (or set the
+ setting in your `config.py`).
+- When a window is closed, the tab it contains are now correctly shut down
+ (closing e.g. any dialogs which are still open for those tabs).
+- The Qt 5.15 session workaround now loads the correct (rather than the last)
+ page when `:back` was used before saving a session.
+- In certain situations on Windows, qutebrowser fails to find the username of
+ the user launching qutebrowser (most likely due to a bug in the application
+ launching it). When this happens, an error is now displayed instead of
+ crashing.
+- Certain `autoconfig.yml` with an invalid structure could lead to crashes,
+ which are now fixed.
+- Generating docs with `asciidoc2html.py` (e.g. via `mkvenv.py`) now works
+ correctly without Pygments being installed system-wide.
+- Ever since Qt 5.9, when `input.mouse.rocker_gestures` was enabled, the
+ context menu still was shown when clicking the right mouse button, thus
+ preventing the rocker gestures. This is now fixed.
+- Clicking the inspector switched from existing modes (such as passthrough) to
+ normal mode since v1.13.0. Now insert mode is only entered when the inspector
+ is clicked in normal mode.
+- Pulseaudio now shows qutebrowser's audio streams as qutebrowser correctly,
+ rather than showing them as Chromium with some Qt versions.
+- If `:help` was called with a deprecated command (e.g. `:help :inspector`),
+ the help page would show despite deprecated commands not being documented.
+ This now shows an error instead.
+- The `qute-lastpass` userscript now filters out duplicate entries with
+ `--merge-candidates`.
+
+v1.13.0 (2020-06-26)
+--------------------
+
+Deprecated
+~~~~~~~~~~
+
+- The `:inspector` command is deprecated and has been replaced by a new
+ `:devtools` command (see below).
+
+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
+~~~~~~~
+
+- Changes to commands:
+ * `: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.
+ * `:message` now takes a `--logfilter` / `-f` argument, which is a list of
+ logging categories to show.
+ * `:debug-log-filter` 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).
+ * `url.auto_search` supports a new `schemeless` value which always opens a
+ search unless the given URL includes an explicit scheme.
+- New handling of bindings in hint mode which fixes various bugs and allows for
+ single-letter keybindings in hint mode.
+- The statusbar now shows partial keychains in all modes (e.g. while hinting).
+- New `t[Cc][Hh]` default bindings which work similarly to the `t[Ss][Hh]`
+ bindings for JavaScript but toggle cookie permissions.
+- The `tor_identity` userscript now takes the password via a `-p` flag and has
+ a new `-c` flag to customize the Tor control port.
+- 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.
+- New `:devtools` command (replacing `:inspector`) with various improved
+ functionality:
+ * The devtools can now be docked to the main window, by running
+ `:devtools left` (`wIh`), `bottom` (`wIj`), `top` (`wIk`) or `right`
+ (`wIl`). To show them in a new window, use `:devtools window` (`wIw`).
+ Using `:devtools` (`wi`) will open them at the last used position.
+ * The devtool window now has a "qutebrowser developer tools" window title.
+ * When a resource is opened from the devtools, it now opens in a proper
+ qutebrowser tab.
+ * On Fedora, when the `qt5-webengine-devtools` package is missing, an error
+ is now shown instead of a blank inspector window.
+ * If opened as a window, the devtools are now closed properly when the
+ associated tab is closed.
+ * When the devtools are clicked, insert mode is entered automatically.
+
+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 happens due to a Qt bug with Qt 5.15 in private browsing mode).
+- 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
@@ -32,7 +661,7 @@ Added
* `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
+ * `{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.
@@ -40,6 +669,8 @@ Added
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
@@ -48,8 +679,24 @@ Changed
Chromium. To revert to the old behavior, use `{quoted}` instead.
- The `content.windowed_fullscreen` setting got renamed to
`content.fullscreen.window`.
-- The `qute-bitwarden` userscript now has an optional `--totp` flag which can
- be used to copy TOTP codes to clipboard (requires the `pyperclip` module).
+- 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
~~~~~
@@ -785,7 +1432,7 @@ Fixed
- `qute://` pages now work properly on Qt 5.11.2
- Error when passing a substring with spaces to `:tab-take`.
-- Greasemonkey scripts which start with an UTF-8 BOM are now handled correctly.
+- Greasemonkey scripts which start with a UTF-8 BOM are now handled correctly.
- When no documentation has been generated, the plaintext documentation now can
be shown for more files such as `qute://help/userscripts.html`.
- Crash when doing initial run on Wayland without XWayland.
diff --git a/doc/contributing.asciidoc b/doc/contributing.asciidoc
index fdaf7dd37..3960dec27 100644
--- a/doc/contributing.asciidoc
+++ b/doc/contributing.asciidoc
@@ -9,7 +9,7 @@ IMPORTANT: Bandwidth for pull request review is currently quite limited. If you
want to contribute where it's most needed, please consider reviewing or testing
open pull requests.
-I `&lt;3` footnote:[Of course, that says `<3` in HTML.] contributors!
+I `&lt;3` footnote:[`<3` in HTML] contributors!
This document contains guidelines for contributing to qutebrowser, as well as
useful hints when doing so.
@@ -111,9 +111,9 @@ unittests and several linters/checkers.
Currently, the following tox environments are available:
* Tests using https://www.pytest.org[pytest]:
- - `py35`, `py36`: Run pytest for python 3.5/3.6 with the system-wide PyQt.
- - `py36-pyqt57`, ..., `py36-pyqt59`: Run pytest with the given PyQt version (`py35-*` also works).
- - `py36-pyqt59-cov`: Run with coverage support (other Python/PyQt versions work too).
+ - `py36`, `py37`, ...: Run pytest for python 3.6/3.7/... with the system-wide PyQt.
+ - `py36-pyqt512`, ..., `py36-pyqt515`: Run pytest with the given PyQt version (`py35-*` also works).
+ - `py36-pyqt515-cov`: Run with coverage support (other Python/PyQt versions work too).
* `flake8`: Run various linting checks via https://pypi.python.org/pypi/flake8[flake8].
* `vulture`: Run https://pypi.python.org/pypi/vulture[vulture] to find
unused code portions.
@@ -586,9 +586,9 @@ can be useful for debugging:
- chrome://gpuclean/ (crashes the current renderer process!)
- chrome://ppapiflashcrash/
- chrome://ppapiflashhang/
-- chrome://quota-internals/ (Qt 5.11)
-- chrome://taskscheduler-internals/ (Qt 5.11)
-- chrome://sandbox/ (Qt 5.11, Linux only)
+- chrome://quota-internals/
+- chrome://taskscheduler-internals/
+- chrome://sandbox/ (Linux only)
QtWebEngine internals
~~~~~~~~~~~~~~~~~~~~~
@@ -699,7 +699,7 @@ New PyQt release
~~~~~~~~~~~~~~~~
* See above.
-* Update `tox.ini`/`.travis.yml`/`.appveyor.yml` to test new versions.
+* Update `tox.ini`/`.github/workflows/ci.yml` to test new versions.
qutebrowser release
~~~~~~~~~~~~~~~~~~~
diff --git a/doc/faq.asciidoc b/doc/faq.asciidoc
index 1c69056c0..16a791975 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
@@ -100,6 +100,7 @@ Is there an ad blocker?::
There is a simple host-based ad blocker that takes `/etc/hosts`-like lists.
+
More advanced ad blockers can have a big impact on browsing speed and https://blog.mozilla.org/nnethercote/2014/05/14/adblock-pluss-effect-on-firefoxs-memory-usage/[RAM usage], so implementing support for AdBlock Plus-like lists is not a priority.
+
How can I get No-Script-like behavior?::
To disable JavaScript by default:
+
@@ -107,11 +108,14 @@ How can I get No-Script-like behavior?::
:set content.javascript.enabled false
----
+
-The basic command for enabling JavaScript for the current host is `tsh`.
+The basic keybinding for enabling JavaScript for the current host is `tsh`.
This will allow JavaScript execution for the current session.
Use `S` instead of `s` to make the exception permanent.
With `H` instead of `h`, subdomains are included.
-With `u` instead of `h`, only the current URL is whitelisted (not the whole host).
+With `u` instead of `h`, JavaScript is allowed for the current URL only (not the whole host).
++
+The list of domains that have been permanently granted permission to execute
+JavaScript will be written to `autoconfig.yml`.
How do I play Youtube videos with mpv?::
You can easily add a key binding to play youtube videos inside a real video
@@ -183,11 +187,9 @@ For QtWebKit:
(also see the README file for `qtwebkit-plugins`).
. Remember to install the hunspell dictionaries if you don't have them already
(most distros should have packages for this).
-
+
For QtWebEngine:
-. Make sure your versions of PyQt and Qt are 5.8 or higher.
. Use `dictcli.py` script to install dictionaries.
Run the script with `-h` for the parameter description.
. Set `spellcheck.languages` to the desired list of languages, e.g.:
@@ -204,7 +206,9 @@ Why does J move to the next (right) tab, and K to the previous (left) one?::
and qutebrowser's keybindings are designed to be compatible with dwb's.
The rationale behind it is that J is "down" in vim, and K is "up", which
corresponds nicely to "next"/"previous". It also makes much more sense with
- vertical tabs (e.g. `:set tabs.position left`).
+ vertical tabs (e.g. `:set tabs.position left`). If you prefer swapped
+ bindings, you can run `:bind J tab-prev` and `:bind K tab-next` to swap
+ them.
What's the difference between insert and passthrough mode?::
They are quite similar, but insert mode has some bindings (like `Ctrl-e` to
@@ -221,7 +225,7 @@ Why does it take longer to open a URL in qutebrowser than in chromium?::
One workaround is to use this
https://github.com/qutebrowser/qutebrowser/blob/master/scripts/open_url_in_instance.sh[script]
and place it in your $PATH with the name "qutebrowser". This
- script passes the URL via an unix socket to qutebrowser (if its
+ script passes the URL via a unix socket to qutebrowser (if its
running already) using socat which is much faster and starts a new
qutebrowser if it is not running already.
@@ -248,11 +252,6 @@ Note that there are some missing features which you may run into:
. Some scripts expect `GM_xmlhttpRequest` to ignore Cross Origin Resource
Sharing restrictions, this is currently not supported, so scripts making
requests to third party sites will often fail to function correctly.
-. If your backend is a QtWebEngine version 5.8, 5.9 or 5.10 then regular
- expressions are not supported in `@include` or `@exclude` rules. If your
- script uses them you can re-write them to use glob expressions or convert
- them to `@match` rules.
- See https://wiki.greasespot.net/Metadata_Block[the wiki] for more info.
. Any greasemonkey API function to do with adding UI elements is not currently
supported. That means context menu extentensions and background pages.
@@ -305,18 +304,43 @@ 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
described above.
+Is there a dark mode? How can I filter websites to be darker?::
+There is a total of four possible approaches to get dark websites:
++
+- The `colors.webpage.prefers_color_scheme_dark` setting tells websites that you prefer
+ a dark theme. However, this requires websites to ship an appropriate dark style sheet.
+ The setting requires a restart and QtWebEngine with at least Qt 5.14.
+- The `colors.webpage.darkmode.*` settings enable the dark mode of the underlying
+ Chromium. Those setting require a restart and QtWebEngine with at least Qt 5.14. It's
+ unfortunately not possible (due to limitations
+ https://bugs.chromium.org/p/chromium/issues/detail?id=952419[in Chromium] and/or
+ https://bugreports.qt.io/browse/QTBUG-84484[QtWebEngine]) to
+ change them dynamically or to specify a list of excluded websites.
+ There is some remaining hope to
+ https://github.com/qutebrowser/qutebrowser/issues/5542[allow for this]
+ using HTML/CSS features, but so far nobody has been able to get things to
+ work (even with Chromium) - help welcome!
+- The `content.user_stylesheets` setting allows specifying a custom CSS such as
+ https://github.com/alphapapa/solarized-everything-css/[Solarized Everything]. Despite
+ the name, the repository also offers themes other than just Solarized. This approach
+ often yields worse results compared to the above ones, but it's possible to toggle it
+ dynamically using a binding like `:bind ,d 'config-cycle content.user_stylesheets
+ ~/path/to/solarized-everything-css/css/gruvbox/gruvbox-all-sites.css ""'`
+- Finally, qutebrowser's Greasemonkey support should allow for running a
+ https://github.com/darkreader/darkreader/issues/926#issuecomment-575893299[stripped down version]
+ of the Dark Reader extension. This is mostly untested, though.
== Troubleshooting
@@ -450,7 +474,7 @@ Can you share details on the swag?::
+
image:https://qutebrowser.org/img/sponsors/swag.jpg["swag",width=300,link="https://qutebrowser.org/img/sponsors/swag.jpg"]
+
-It's planned to order more swag, depending on the exact demand. Possibilites
+It's planned to order more swag, depending on the exact demand. Possibilities
would include:
+
- qutebrowser pens (refillable)
@@ -484,15 +508,15 @@ For any privacy questions, please contact mailto:privacy@qutebrowser.org[].
=== Website
-The qutebrowser.org website does not use any cookies or trackers.
-
-However, IP addresses are currently (October 2019) logged and stored
-indefinitely. It's planned to change this soon by migrating qutebrowser.org to
-a different server.
+The qutebrowser.org website does not use any cookies or trackers. It does not
+store any logs, except in rare situations when those are explicitly (and
+temporarily) enabled to debug website issues. Even if enabled, IP addresses are
+partially redacted in the logs. As soon as debugging is finished, any logs
+are removed.
Note that some services related to qutebrowser are stored on third-party
-services such as GitHub, Travis CI or AppVeyor. By using their websites, you're
-subject to their privacy policies.
+services such as GitHub. By using their websites, you're subject to their
+privacy policies.
=== Crash reports
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc
index 80d2ca848..eb8e4925d 100644
--- a/doc/help/commands.asciidoc
+++ b/doc/help/commands.asciidoc
@@ -29,7 +29,7 @@ possible to run or bind multiple commands by separating them with `;;`.
[options="header",width="75%",cols="25%,75%"]
|==============
|Command|Description
-|<<adblock-update,adblock-update>>|Update the adblock block lists.
+|<<adblock-update,adblock-update>>|Update block lists for both the host- and the Brave ad blocker.
|<<back,back>>|Go back in the history of the current tab.
|<<bind,bind>>|Bind a key to a command.
|<<bookmark-add,bookmark-add>>|Save the current page as a bookmark, or a specific url.
@@ -44,12 +44,15 @@ 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.
|<<config-source,config-source>>|Read a config.py file.
|<<config-unset,config-unset>>|Unset an option.
|<<config-write-py,config-write-py>>|Write the current configuration to a config.py file.
+|<<devtools,devtools>>|Toggle the developer tools (web inspector).
+|<<devtools-focus,devtools-focus>>|Toggle focus between the devtools/tab.
|<<download,download>>|Download a given URL, or current page if no URL given.
|<<download-cancel,download-cancel>>|Cancel the last/[count]th download.
|<<download-clear,download-clear>>|Remove all finished downloads from the list.
@@ -71,7 +74,6 @@ possible to run or bind multiple commands by separating them with `;;`.
|<<history-clear,history-clear>>|Clear all browsing history.
|<<home,home>>|Open main startpage in current tab.
|<<insert-text,insert-text>>|Insert text at cursor position.
-|<<inspector,inspector>>|Toggle the web inspector.
|<<jseval,jseval>>|Evaluate a JavaScript string.
|<<jump-mark,jump-mark>>|Jump to the mark named by `key`.
|<<later,later>>|Execute a command after some time.
@@ -126,7 +128,7 @@ possible to run or bind multiple commands by separating them with `;;`.
|<<tab-prev,tab-prev>>|Switch to the previous tab, or switch [count] tabs back.
|<<tab-take,tab-take>>|Take a tab from another window.
|<<unbind,unbind>>|Unbind a keychain.
-|<<undo,undo>>|Re-open the last closed tab or tabs.
+|<<undo,undo>>|Re-open the last closed tab(s) or window.
|<<version,version>>|Show version information.
|<<view-source,view-source>>|Show the source of the current page in a new tab.
|<<window-only,window-only>>|Close all windows except for the current one.
@@ -137,16 +139,17 @@ possible to run or bind multiple commands by separating them with `;;`.
|==============
[[adblock-update]]
=== adblock-update
-Update the adblock block lists.
-
-This updates `~/.local/share/qutebrowser/blocked-hosts` with downloaded host lists and re-reads `~/.config/qutebrowser/blocked-hosts`.
+Update block lists for both the host- and the Brave ad blocker.
[[back]]
=== back
-Syntax: +:back [*--tab*] [*--bg*] [*--window*]+
+Syntax: +:back [*--tab*] [*--bg*] [*--window*] ['index']+
Go back in the history of the current tab.
+==== positional arguments
+* +'index'+: Which page to go back to, count takes precedence.
+
==== optional arguments
* +*-t*+, +*--tab*+: Go back in a new tab.
* +*-b*+, +*--bg*+: Go back in a background tab.
@@ -285,8 +288,7 @@ Set all settings back to their default.
[[config-cycle]]
=== config-cycle
-Syntax: +:config-cycle [*--pattern* 'pattern'] [*--temp*] [*--print*]
- 'option' ['values' ['values' ...]]+
+Syntax: +:config-cycle [*--pattern* 'pattern'] [*--temp*] [*--print*] 'option' ['values' ...]+
Cycle an option between multiple values.
@@ -328,6 +330,10 @@ Remove a key from a dict.
==== optional arguments
* +*-t*+, +*--temp*+: Remove value temporarily until qutebrowser is closed.
+[[config-diff]]
+=== config-diff
+Show all customized options.
+
[[config-edit]]
=== config-edit
Syntax: +:config-edit [*--no-source*]+
@@ -403,6 +409,20 @@ Write the current configuration to a config.py file.
* +*-f*+, +*--force*+: Force overwriting existing files.
* +*-d*+, +*--defaults*+: Write the defaults instead of values configured via :set.
+[[devtools]]
+=== devtools
+Syntax: +:devtools ['position']+
+
+Toggle the developer tools (web inspector).
+
+==== positional arguments
+* +'position'+: Where to open the devtools (right/left/top/bottom/window).
+
+
+[[devtools-focus]]
+=== devtools-focus
+Toggle focus between the devtools/tab.
+
[[download]]
=== download
Syntax: +:download [*--mhtml*] [*--dest* 'dest'] ['url']+
@@ -441,7 +461,7 @@ The index of the download to delete.
[[download-open]]
=== download-open
-Syntax: +:download-open ['cmdline']+
+Syntax: +:download-open [*--dir*] ['cmdline']+
Open the last/[count]th download.
@@ -453,6 +473,9 @@ If no specific command is given, this will use the system's default application
cmdline.
+==== optional arguments
+* +*-d*+, +*--dir*+: Whether to open the file's directory instead.
+
==== count
The index of the download to open.
@@ -542,10 +565,13 @@ Follow the selected text.
[[forward]]
=== forward
-Syntax: +:forward [*--tab*] [*--bg*] [*--window*]+
+Syntax: +:forward [*--tab*] [*--bg*] [*--window*] ['index']+
Go forward in the history of the current tab.
+==== positional arguments
+* +'index'+: Which page to go forward to, count takes precedence.
+
==== optional arguments
* +*-t*+, +*--tab*+: Go forward in a new tab.
* +*-b*+, +*--bg*+: Go forward in a background tab.
@@ -571,7 +597,7 @@ Syntax: +:greasemonkey-reload [*--force*]+
Re-read Greasemonkey scripts from disk.
-The scripts are read from a 'greasemonkey' subdirectory in qutebrowser's data directory (see `:version`).
+The scripts are read from a 'greasemonkey' subdirectory in qutebrowser's data or config directories (see `:version`).
==== optional arguments
* +*-f*+, +*--force*+: For any scripts that have required dependencies, re-download them.
@@ -597,8 +623,7 @@ Show help about a command or setting.
[[hint]]
=== hint
-Syntax: +:hint [*--mode* 'mode'] [*--add-history*] [*--rapid*] [*--first*]
- ['group'] ['target'] ['args' ['args' ...]]+
+Syntax: +:hint [*--mode* 'mode'] [*--add-history*] [*--rapid*] [*--first*] ['group'] ['target'] ['args' ...]+
Start hinting.
@@ -716,15 +741,9 @@ Insert text at cursor position.
==== note
* This command does not split arguments after the last argument and handles quotes literally.
-[[inspector]]
-=== inspector
-Toggle the web inspector.
-
-Note: Due to a bug in Qt, the inspector will show incorrect request headers in the network tab.
-
[[jseval]]
=== jseval
-Syntax: +:jseval [*--file*] [*--quiet*] [*--world* 'world'] 'js-code'+
+Syntax: +:jseval [*--file*] [*--url*] [*--quiet*] [*--world* 'world'] 'js-code'+
Evaluate a JavaScript string.
@@ -736,8 +755,18 @@ Evaluate a JavaScript string.
in qutebrowser's data dir, e.g.
`~/.local/share/qutebrowser/js`.
+* +*-u*+, +*--url*+: Interpret js-code as a `javascript:...` URL.
* +*-q*+, +*--quiet*+: Don't show resulting JS object.
-* +*-w*+, +*--world*+: Ignored on QtWebKit. On QtWebEngine, a world ID or name to run the snippet in.
+* +*-w*+, +*--world*+: Ignored on QtWebKit. On QtWebEngine, a world ID or name to run the snippet in. Predefined world names are:
+
+
+ - `main` (same world as the web page's JavaScript and
+ Greasemonkey, unless overridden via `@qute-js-world`)
+ - `application` (used for internal qutebrowser JS code,
+ should not be used via `:jseval` unless you know what
+ you're doing)
+ - `user` (currently unused)
+ - `jseval` (used for this command by default)
==== note
@@ -755,12 +784,12 @@ Jump to the mark named by `key`.
[[later]]
=== later
-Syntax: +:later 'ms' 'command'+
+Syntax: +:later 'duration' 'command'+
Execute a command after some time.
==== positional arguments
-* +'ms'+: How many milliseconds to wait.
+* +'duration'+: Duration to wait in format XhYmZs or a number for milliseconds.
* +'command'+: The command to run, with optional args.
==== note
@@ -800,7 +829,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.
@@ -813,6 +842,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
@@ -836,6 +868,7 @@ This tries to automatically click on typical _Previous Page_ or _Next Page_ link
Uses the
link:settings{outsuffix}#url.incdec_segments[url.incdec_segments]
config option.
+ - `strip`: Strip query and fragment from the current URL.
@@ -855,8 +888,7 @@ Do nothing.
[[open]]
=== open
-Syntax: +:open [*--related*] [*--bg*] [*--tab*] [*--window*] [*--secure*] [*--private*]
- ['url']+
+Syntax: +:open [*--related*] [*--bg*] [*--tab*] [*--window*] [*--secure*] [*--private*] ['url']+
Open a URL in the current/[count]th tab.
@@ -1007,8 +1039,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.
@@ -1047,7 +1086,7 @@ The count that run_with_count itself received.
[[save]]
=== save
-Syntax: +:save ['what' ['what' ...]]+
+Syntax: +:save ['what' ...]+
Save configs and state.
@@ -1186,9 +1225,7 @@ Load a session.
[[session-save]]
=== session-save
-Syntax: +:session-save [*--current*] [*--quiet*] [*--force*] [*--only-active-window*]
- [*--with-private*]
- ['name']+
+Syntax: +:session-save [*--current*] [*--quiet*] [*--force*] [*--only-active-window*] [*--with-private*] ['name']+
Save a session.
@@ -1252,9 +1289,7 @@ Set a mark at the current scroll position in the current tab.
[[spawn]]
=== spawn
-Syntax: +:spawn [*--userscript*] [*--verbose*] [*--output*] [*--output-messages*]
- [*--detach*]
- 'cmdline'+
+Syntax: +:spawn [*--userscript*] [*--verbose*] [*--output*] [*--output-messages*] [*--detach*] 'cmdline'+
Spawn an external command.
@@ -1273,7 +1308,8 @@ Note that the command is *not* run in a shell, so things like `$VAR` or `> outpu
* +*-v*+, +*--verbose*+: Show notifications when the command started/exited.
* +*-o*+, +*--output*+: Show the output in a new tab.
* +*-m*+, +*--output-messages*+: Show the output as messages.
-* +*-d*+, +*--detach*+: Whether the command should be detached from qutebrowser.
+* +*-d*+, +*--detach*+: Detach the command from qutebrowser so that it continues running when qutebrowser quits.
+
==== count
Given to userscripts as $QUTE_COUNT.
@@ -1337,7 +1373,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 +1384,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).
@@ -1443,7 +1480,20 @@ Unbind a keychain.
[[undo]]
=== undo
-Re-open the last closed tab or tabs.
+Syntax: +:undo [*--window*] ['depth']+
+
+Re-open the last closed tab(s) or window.
+
+==== positional arguments
+* +'depth'+: Same as `count` but as argument for completion, `count` takes precedence.
+
+
+==== optional arguments
+* +*-w*+, +*--window*+: Re-open the last closed window (and its tabs).
+
+==== count
+How deep in the undo stack to find the tab or tabs to re-open.
+
[[version]]
=== version
@@ -1485,8 +1535,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.
@@ -1620,7 +1668,9 @@ Syntax: +:completion-item-focus [*--history*] 'which'+
Shift the focus of the completion menu to another item.
==== positional arguments
-* +'which'+: 'next', 'prev', 'next-category', or 'prev-category'.
+* +'which'+: 'next', 'prev', 'next-category', 'prev-category',
+ 'next-page', or 'prev-page'.
+
==== optional arguments
* +*-H*+, +*--history*+: Navigate through command history if no text was typed.
@@ -1894,8 +1944,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 +1966,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 +2020,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 +2043,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/configuring.asciidoc b/doc/help/configuring.asciidoc
index 575104fc1..35a0fbb62 100644
--- a/doc/help/configuring.asciidoc
+++ b/doc/help/configuring.asciidoc
@@ -9,9 +9,7 @@ qutebrowser's config files
--------------------------
qutebrowser releases before v1.0.0 had a `qutebrowser.conf` and `keys.conf`
-file. Those are not used anymore since that release - see
-<<migrating,"Migrating older configurations">> for information on how to
-migrate to the new config.
+file. Those are not used anymore since v1.0.0.
When using `:set` and `:bind`, changes are saved to an `autoconfig.yml` file
automatically. If you don't want to have a config file which is curated by
@@ -118,7 +116,7 @@ accepted values depend on the type of the option. Commonly used are:
- Booleans: `c.completion.shrink = True`
- Integers: `c.messages.timeout = 5000`
- Dictionaries:
- * `c.headers.custom = {'X-Hello': 'World', 'X-Awesome': 'yes'}` to override
+ * `c.content.headers.custom = {'X-Hello': 'World', 'X-Awesome': 'yes'}` to override
any other values in the dictionary.
* `c.aliases['foo'] = 'message-info foo'` to add a single value.
- Lists:
@@ -394,9 +392,11 @@ Pre-built colorschemes
^^^^^^^^^^^^^^^^^^^^^^
- A collection of https://github.com/chriskempson/base16[base16] color-schemes can be found in https://github.com/theova/base16-qutebrowser[base16-qutebrowser] and used with https://github.com/AuditeMarlow/base16-manager[base16-manager].
-- Two implementations of the https://github.com/arcticicestudio/nord[Nord] colorscheme for qutebrowser exist: https://github.com/Linuus/nord-qutebrowser[Linuus], https://github.com/KnownAsDon/QuteBrowser-Nord-Theme[KnownAsDon]
+- https://gitlab.com/jjzmajic/qutewal[Pywal integration]
+- https://github.com/arcticicestudio/nord[Nord]: https://github.com/Linuus/nord-qutebrowser[Linuus], https://github.com/KnownAsDon/QuteBrowser-Nord-Theme[KnownAsDon]
- https://github.com/dracula/qutebrowser-dracula-theme[Dracula]
-- https://github.com/jjzmajic/qutewal[Pywal theme]
+- https://gitlab.com/lovetocode999/selenized-qutebrowser[Selenized]
+- https://github.com/morhetz/gruvbox[gruvbox]: https://github.com/The-Compiler/dotfiles/blob/master/qutebrowser/gruvbox.py[The-Compiler], https://gitlab.com/shaneyost/dots-popos-september-2020/-/blob/master/qutebrowser/config.py[Shane Yost]
Avoiding flake8 errors
^^^^^^^^^^^^^^^^^^^^^^
@@ -420,8 +420,8 @@ stable across qutebrowser versions):
# pylint: disable=C0111
from qutebrowser.config.configfiles import ConfigAPI # noqa: F401
from qutebrowser.config.config import ConfigContainer # noqa: F401
-config = config # type: ConfigAPI # noqa: F821 pylint: disable=E0602,C0103
-c = c # type: ConfigContainer # noqa: F821 pylint: disable=E0602,C0103
+config: ConfigAPI = config # noqa: F821 pylint: disable=E0602,C0103
+c: ConfigContainer = c # noqa: F821 pylint: disable=E0602,C0103
----
emacs-like config
@@ -437,38 +437,3 @@ Various emacs/conkeror-like keybinding configs exist:
It's also mostly possible to get rid of modal keybindings by setting
`input.insert_mode.auto_enter` to `false`, and `input.forward_unbound_keys` to
`all`.
-
-[[migrating]]
-Migrating older configurations
-------------------------------
-
-qutebrowser does no automatic migration for the new configuration. However,
-there's a special link:qute://configdiff/old[configdiff] page
-(`qute://configdiff/old`) in qutebrowser, which will show you the changes you
-did in your old configuration, compared to the old defaults.
-
-Other changes in default settings:
-
-- In v1.1.x and newer, `<Up>` and `<Down>` navigate through command history
- if no text was entered yet.
- With v1.0.x, they always navigate through command history instead of selecting
- completion items. Use `<Tab>`/`<Shift-Tab>` to cycle through the completion
- instead.
- You can get back the old behavior by doing:
-+
-----
-:bind -m command <Up> completion-item-focus prev
-:bind -m command <Down> completion-item-focus next
-----
-+
-or always navigate through command history with
-+
-----
-:bind -m command <Up> command-history-prev
-:bind -m command <Down> command-history-next
-----
-
-- The default for `completion.web_history.max_items` is now set to `-1`, showing
- an unlimited number of items in the completion for `:open` as the new
- sqlite-based completion is much faster. If the `:open` completion is too slow
- on your machine, set an appropriate limit again.
diff --git a/doc/help/index.asciidoc b/doc/help/index.asciidoc
index 7b6efa490..7424e1a65 100644
--- a/doc/help/index.asciidoc
+++ b/doc/help/index.asciidoc
@@ -6,6 +6,7 @@ Documentation
The following help pages are currently available:
+* https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-big.png[Key binding cheatsheet (hosted on GitHub)]
* link:../quickstart{outfilesuffix}[Quick start guide]
* link:../faq{outfilesuffix}[Frequently asked questions]
* link:../changelog{outfilesuffix}[Change Log]
@@ -14,6 +15,8 @@ The following help pages are currently available:
* link:settings{outfilesuffix}[Documentation of settings]
* link:../userscripts{outfilesuffix}[How to write userscripts]
* link:../contributing{outfilesuffix}[Contributing to qutebrowser]
+* link:../install{outfilesuffix}[Installing qutebrowser]
+* link:../stacktrace{outfilesuffix}[Reporting segfaults]
Getting help
------------
@@ -26,6 +29,17 @@ message to the
https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[mailinglist] at
mailto:qutebrowser@lists.qutebrowser.org[].
+There's also an https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser-announce[announce-only mailinglist]
+at mailto:qutebrowser-announce@lists.qutebrowser.org[] (the announcements also
+get sent to the general qutebrowser@ list).
+
+If you're a reddit user, there's a
+https://www.reddit.com/r/qutebrowser/[/r/qutebrowser] subreddit there.
+
+Finally, qutebrowser is using 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.
+
Bugs
----
diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc
index 5182968a6..5d48cf420 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.
@@ -126,6 +137,11 @@
|<<completion.web_history.max_items,completion.web_history.max_items>>|Number of URLs to show in the web history.
|<<confirm_quit,confirm_quit>>|Require a confirmation before quitting the application.
|<<content.autoplay,content.autoplay>>|Automatically start playing `<video>` elements.
+|<<content.blocking.adblock.lists,content.blocking.adblock.lists>>|List of URLs to ABP-style adblocking rulesets.
+|<<content.blocking.enabled,content.blocking.enabled>>|Enable the ad/host blocker
+|<<content.blocking.hosts.lists,content.blocking.hosts.lists>>|List of URLs to host blocklists for the host blocker.
+|<<content.blocking.method,content.blocking.method>>|Which method of blocking ads should be used.
+|<<content.blocking.whitelist,content.blocking.whitelist>>|A list of patterns that should always be loaded, despite being blocked by the ad-/host-blocker.
|<<content.cache.appcache,content.cache.appcache>>|Enable support for the HTML 5 web application cache feature.
|<<content.cache.maximum_pages,content.cache.maximum_pages>>|Maximum number of pages to hold in the global memory page cache.
|<<content.cache.size,content.cache.size>>|Size (in bytes) of the HTTP network cache. Null to use the default value.
@@ -144,9 +160,6 @@
|<<content.headers.do_not_track,content.headers.do_not_track>>|Value to send in the `DNT` header.
|<<content.headers.referer,content.headers.referer>>|When to send the Referer header.
|<<content.headers.user_agent,content.headers.user_agent>>|User agent to send.
-|<<content.host_blocking.enabled,content.host_blocking.enabled>>|Enable host blocking.
-|<<content.host_blocking.lists,content.host_blocking.lists>>|List of URLs of lists which contain hosts to block.
-|<<content.host_blocking.whitelist,content.host_blocking.whitelist>>|A list of patterns that should always be loaded, despite being ad-blocked.
|<<content.hyperlink_auditing,content.hyperlink_auditing>>|Enable hyperlink auditing (`<a ping>`).
|<<content.images,content.images>>|Load images automatically in web pages.
|<<content.javascript.alert,content.javascript.alert>>|Show javascript alerts.
@@ -160,7 +173,9 @@
|<<content.local_content_can_access_file_urls,content.local_content_can_access_file_urls>>|Allow locally loaded documents to access other local URLs.
|<<content.local_content_can_access_remote_urls,content.local_content_can_access_remote_urls>>|Allow locally loaded documents to access remote URLs.
|<<content.local_storage,content.local_storage>>|Enable support for HTML 5 local storage and Web SQL.
-|<<content.media_capture,content.media_capture>>|Allow websites to record audio/video.
+|<<content.media.audio_capture,content.media.audio_capture>>|Allow websites to record audio.
+|<<content.media.audio_video_capture,content.media.audio_video_capture>>|Allow websites to record audio and video.
+|<<content.media.video_capture,content.media.video_capture>>|Allow websites to record video.
|<<content.mouse_lock,content.mouse_lock>>|Allow websites to lock your mouse pointer.
|<<content.mute,content.mute>>|Automatically mute tabs.
|<<content.netrc_file,content.netrc_file>>|Netrc-file for HTTP authentication.
@@ -203,7 +218,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.
@@ -240,25 +256,29 @@
|<<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 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.environ,qt.environ>>|Additional environment variables to set.
|<<qt.force_platform,qt.force_platform>>|Force a Qt platform to use.
|<<qt.force_platformtheme,qt.force_platformtheme>>|Force a Qt platformtheme to use.
|<<qt.force_software_rendering,qt.force_software_rendering>>|Force software rendering for QtWebEngine.
|<<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.
@@ -266,9 +286,9 @@
|<<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.
@@ -298,7 +318,7 @@
|<<tabs.title.format,tabs.title.format>>|Format to use for the tab title.
|<<tabs.title.format_pinned,tabs.title.format_pinned>>|Format to use for the tab title for pinned tabs. The same placeholders like for `tabs.title.format` are defined.
|<<tabs.tooltips,tabs.tooltips>>|Show tooltips on tabs.
-|<<tabs.undo_stack_size,tabs.undo_stack_size>>|Number of close tab actions to remember, per window (-1 for no maximum).
+|<<tabs.undo_stack_size,tabs.undo_stack_size>>|Number of closed tabs (per window) and closed windows to remember for :undo (-1 for no maximum).
|<<tabs.width,tabs.width>>|Width (in pixels or as percentage of the window) of the tab bar if it's vertical.
|<<tabs.wrap,tabs.wrap>>|Wrap when changing tabs.
|<<url.auto_search,url.auto_search>>|What search to start when something else than a URL is entered.
@@ -310,6 +330,7 @@
|<<url.yank_ignored_parameters,url.yank_ignored_parameters>>|URL parameters to strip with `:yank url`.
|<<window.hide_decoration,window.hide_decoration>>|Hide the window decoration.
|<<window.title_format,window.title_format>>|Format to use for the window title. The same placeholders like for
+|<<window.transparent,window.transparent>>|Set the main window background to transparent.
|<<zoom.default,zoom.default>>|Default zoom level.
|<<zoom.levels,zoom.levels>>|Available zoom levels.
|<<zoom.mouse_divider,zoom.mouse_divider>>|Number of zoom increments to divide the mouse wheel movements to.
@@ -342,6 +363,8 @@ Default: +pass:[15000]+
[[auto_save.session]]
=== auto_save.session
Always restore open sites when qutebrowser is reopened.
+Without this option set, `:wq` (`:quit --save`) needs to be used to save open tabs (and restore them), while quitting qutebrowser in any other way will not save/restore the session.
+By default, this will save to the session which was last loaded. This behavior can be customized via the `session.default_name` setting.
Type: <<types,Bool>>
@@ -350,17 +373,18 @@ Default: +pass:[false]+
[[backend]]
=== backend
Backend to use to display websites.
-qutebrowser supports two different web rendering engines / backends, QtWebKit and QtWebEngine.
-QtWebKit was discontinued by the Qt project with Qt 5.6, but picked up as a well maintained fork: https://github.com/annulen/webkit/wiki - qutebrowser only supports the fork.
-QtWebEngine is Qt's official successor to QtWebKit. It's slightly more resource hungry than QtWebKit and has a couple of missing features in qutebrowser, but is generally the preferred choice.
+qutebrowser supports two different web rendering engines / backends, QtWebEngine and QtWebKit (not recommended).
+QtWebEngine is Qt's official successor to QtWebKit, and both the default/recommended backend. It's based on a stripped-down Chromium and regularly updated with security fixes and new features by the Qt project: https://wiki.qt.io/QtWebEngine
+QtWebKit was qutebrowser's original backend when the project was started. However, support for QtWebKit was discontinued by the Qt project with Qt 5.6 in 2016. The development of QtWebKit was picked up in an official fork: https://github.com/qtwebkit/qtwebkit - however, the project seems to have stalled again. The latest release (5.212.0 Alpha 4) from March 2020 is based on a WebKit version from 2016, with many known security vulnerabilities. Additionally, there is no process isolation and sandboxing. Due to all those issues, while support for QtWebKit is still available in qutebrowser for now, using it is strongly discouraged.
+
This setting requires a restart.
Type: <<types,String>>
Valid values:
- * +webengine+: Use QtWebEngine (based on Chromium).
- * +webkit+: Use QtWebKit (based on WebKit, similar to Safari).
+ * +webengine+: Use QtWebEngine (based on Chromium - recommended).
+ * +webkit+: Use QtWebKit (based on WebKit, similar to Safari - many known security issues!).
Default: +pass:[webengine]+
@@ -444,6 +468,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]+
@@ -487,6 +512,8 @@ Default:
* +pass:[&lt;Ctrl-Y&gt;]+: +pass:[rl-yank]+
* +pass:[&lt;Down&gt;]+: +pass:[completion-item-focus --history next]+
* +pass:[&lt;Escape&gt;]+: +pass:[leave-mode]+
+* +pass:[&lt;PgDown&gt;]+: +pass:[completion-item-focus next-page]+
+* +pass:[&lt;PgUp&gt;]+: +pass:[completion-item-focus prev-page]+
* +pass:[&lt;Return&gt;]+: +pass:[command-accept]+
* +pass:[&lt;Shift-Delete&gt;]+: +pass:[completion-item-del]+
* +pass:[&lt;Shift-Tab&gt;]+: +pass:[completion-item-focus prev]+
@@ -588,6 +615,8 @@ Default:
* +pass:[Sq]+: +pass:[open qute://bookmarks]+
* +pass:[Ss]+: +pass:[open qute://settings]+
* +pass:[T]+: +pass:[tab-focus]+
+* +pass:[U]+: +pass:[undo -w]+
+* +pass:[V]+: +pass:[enter-mode caret ;; toggle-selection --line]+
* +pass:[ZQ]+: +pass:[quit]+
* +pass:[ZZ]+: +pass:[quit --save]+
* +pass:[[[]+: +pass:[navigate prev]+
@@ -635,6 +664,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]+
@@ -644,6 +676,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]+
@@ -658,12 +693,18 @@ Default:
* +pass:[u]+: +pass:[undo]+
* +pass:[v]+: +pass:[enter-mode caret]+
* +pass:[wB]+: +pass:[set-cmd-text -s :bookmark-load -w]+
+* +pass:[wIf]+: +pass:[devtools-focus]+
+* +pass:[wIh]+: +pass:[devtools left]+
+* +pass:[wIj]+: +pass:[devtools bottom]+
+* +pass:[wIk]+: +pass:[devtools top]+
+* +pass:[wIl]+: +pass:[devtools right]+
+* +pass:[wIw]+: +pass:[devtools window]+
* +pass:[wO]+: +pass:[set-cmd-text :open -w {url:pretty}]+
* +pass:[wP]+: +pass:[open -w -- {primary}]+
* +pass:[wb]+: +pass:[set-cmd-text -s :quickmark-load -w]+
* +pass:[wf]+: +pass:[hint all window]+
* +pass:[wh]+: +pass:[back -w]+
-* +pass:[wi]+: +pass:[inspector]+
+* +pass:[wi]+: +pass:[devtools]+
* +pass:[wl]+: +pass:[forward -w]+
* +pass:[wo]+: +pass:[set-cmd-text -s :open -w]+
* +pass:[wp]+: +pass:[open -w -- {clipboard}]+
@@ -737,6 +778,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;]+
@@ -869,6 +911,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.
@@ -1511,10 +1571,167 @@ 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.
+The `lightness-cielab` value was added with QtWebEngine 5.14 and is treated like `lightness-hsl` with older QtWebEngine versions.
+
+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. Not available with Qt < 5.14.
+ * +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]+
+
+This setting is only available with the QtWebEngine backend.
+
+[[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]+
+
+This setting is only available with the QtWebEngine backend.
+
+[[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]+
+
+This setting is only available with the QtWebEngine backend.
+
+[[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]+
+
+This setting is only available with the QtWebEngine backend.
+
+[[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.
+With QtWebEngine 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. Not available with Qt 5.15.0.
+
+Default: +pass:[smart]+
+
+This setting is only available with the QtWebEngine backend.
+
+[[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.
+This setting requires a restart.
+
Type: <<types,Bool>>
Default: +pass:[false]+
@@ -1625,11 +1842,11 @@ Default: +pass:[false]+
[[completion.timestamp_format]]
=== completion.timestamp_format
Format of timestamps (e.g. for the history completion).
-See https://sqlite.org/lang_datefunc.html for allowed substitutions.
+See https://sqlite.org/lang_datefunc.html and https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior for allowed substitutions, qutebrowser uses both sqlite and Python to format its timestamps.
Type: <<types,String>>
-Default: +pass:[%Y-%m-%d]+
+Default: +pass:[%Y-%m-%d %H:%M]+
[[completion.use_best_match]]
=== completion.use_best_match
@@ -1644,6 +1861,7 @@ Default: +pass:[false]+
A list of patterns which should not be shown in the history.
This only affects the completion. Matching URLs are still saved in the history (and visible on the qute://history page), but hidden in the completion.
Changing this setting will cause the completion history to be regenerated on the next start, which will take a short while.
+
This setting requires a restart.
Type: <<types,List of UrlPattern>>
@@ -1679,7 +1897,6 @@ Default:
[[content.autoplay]]
=== content.autoplay
Automatically start playing `<video>` elements.
-Note: On Qt < 5.11, this option needs a restart and does not support URL patterns.
This setting supports URL patterns.
@@ -1687,9 +1904,92 @@ Type: <<types,Bool>>
Default: +pass:[true]+
-On QtWebEngine, this setting requires Qt 5.10 or newer.
+This setting is only available with the QtWebEngine backend.
-On QtWebKit, this setting is unavailable.
+[[content.blocking.adblock.lists]]
+=== content.blocking.adblock.lists
+List of URLs to ABP-style adblocking rulesets.
+
+Only used when Brave's ABP-style adblocker is used (see `content.blocking.method`).
+
+You can find an overview of available lists here:
+https://adblockplus.org/en/subscriptions - note that the special
+`subscribe.adblockplus.org` links aren't handled by qutebrowser, you will instead
+need to find the link to the raw `.txt` file (e.g. by extracting it from the
+`location` parameter of the subscribe URL and URL-decoding it).
+
+
+Type: <<types,List of Url>>
+
+Default:
+
+- +pass:[https://easylist.to/easylist/easylist.txt]+
+- +pass:[https://easylist.to/easylist/easyprivacy.txt]+
+
+[[content.blocking.enabled]]
+=== content.blocking.enabled
+Enable the ad/host blocker
+
+This setting supports URL patterns.
+
+Type: <<types,Bool>>
+
+Default: +pass:[true]+
+
+[[content.blocking.hosts.lists]]
+=== content.blocking.hosts.lists
+List of URLs to host blocklists for the host blocker.
+
+Only used when the simple host-blocker is used (see `content.blocking.method`).
+
+The file can be in one of the following formats:
+
+- An `/etc/hosts`-like file
+- One host per line
+- A zip-file of any of the above, with either only one file, or a file
+ named `hosts` (with any extension).
+
+It's also possible to add a local file or directory via a `file://` URL. In
+case of a directory, all files in the directory are read as adblock lists.
+
+The file `~/.config/qutebrowser/blocked-hosts` is always read if it exists.
+
+
+Type: <<types,List of Url>>
+
+Default:
+
+- +pass:[https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts]+
+
+[[content.blocking.method]]
+=== content.blocking.method
+Which method of blocking ads should be used.
+
+Support for Adblock Plus (ABP) syntax blocklists using Brave's Rust library requires
+the `adblock` Python package to be installed, which is an optional dependency of
+qutebrowser. It is required when either `adblock` or `both` are selected.
+
+
+Type: <<types,String>>
+
+Valid values:
+
+ * +auto+: Use Brave's ABP-style adblocker if available, host blocking otherwise
+ * +adblock+: Use Brave's ABP-style adblocker
+ * +hosts+: Use hosts blocking
+ * +both+: Use both hosts blocking and Brave's ABP-style adblocker
+
+Default: +pass:[auto]+
+
+[[content.blocking.whitelist]]
+=== content.blocking.whitelist
+A list of patterns that should always be loaded, despite being blocked by the ad-/host-blocker.
+Local domains are always exempt from adblocking.
+Note this whitelists otherwise blocked requests, not first-party URLs. As an example, if `example.org` loads an ad from `ads.example.org`, the whitelist entry could be `https://ads.example.org/*`. If you want to disable the adblocker on a given page, use the `content.blocking.enabled` setting with a URL pattern instead.
+
+Type: <<types,List of UrlPattern>>
+
+Default: empty
[[content.cache.appcache]]
=== content.cache.appcache
@@ -1729,6 +2029,7 @@ Default: empty
=== content.canvas_reading
Allow websites to read canvas elements.
Note this is needed for some websites to work properly.
+
This setting requires a restart.
Type: <<types,Bool>>
@@ -1740,6 +2041,11 @@ 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`.
+If this setting is used with URL patterns, the pattern gets applied to the origin/first party URL of the page making the request, not the request URL.
+
+This setting supports URL patterns.
Type: <<types,String>>
@@ -1752,12 +2058,9 @@ Valid values:
Default: +pass:[all]+
-On QtWebEngine, this setting requires Qt 5.11 or newer.
-
[[content.cookies.store]]
=== content.cookies.store
Store cookies.
-Note this option needs a restart with QtWebEngine on Qt < 5.9.
Type: <<types,Bool>>
@@ -1775,7 +2078,6 @@ Default: +pass:[iso-8859-1]+
[[content.desktop_capture]]
=== content.desktop_capture
Allow websites to share screen content.
-On Qt < 5.10, a dialog box is always displayed, even if this is set to "true".
This setting supports URL patterns.
@@ -1797,9 +2099,9 @@ This setting supports URL patterns.
Type: <<types,Bool>>
-Default: +pass:[false]+
+Default: +pass:[true]+
-On QtWebEngine, this setting requires Qt 5.12 or newer.
+This setting is only available with the QtWebEngine backend.
[[content.frame_flattening]]
=== content.frame_flattening
@@ -1884,6 +2186,7 @@ Default: +pass:[true]+
When to send the Referer header.
The Referer header tells websites from which website you were coming from when visiting them.
No restart is needed with QtWebKit.
+
This setting requires a restart.
Type: <<types,String>>
@@ -1907,14 +2210,17 @@ The following placeholders are defined:
with QtWebEngine).
* `{qt_key}`: "Qt" for QtWebKit, "QtWebEngine" for QtWebEngine.
* `{qt_version}`: The underlying Qt version.
-* `{upstream_browser_key}`: "Version" for QtWebKit, "Chrome" for QtWebEngine.
+* `{upstream_browser_key}`: "Version" for QtWebKit, "Chrome" for
+ QtWebEngine.
* `{upstream_browser_version}`: The corresponding Safari/Chrome version.
* `{qutebrowser_version}`: The currently running qutebrowser version.
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.
@@ -1923,49 +2229,6 @@ Type: <<types,FormatString>>
Default: +pass:[Mozilla/5.0 ({os_info}) AppleWebKit/{webkit_version} (KHTML, like Gecko) {qt_key}/{qt_version} {upstream_browser_key}/{upstream_browser_version} Safari/{webkit_version}]+
-[[content.host_blocking.enabled]]
-=== content.host_blocking.enabled
-Enable host blocking.
-
-This setting supports URL patterns.
-
-Type: <<types,Bool>>
-
-Default: +pass:[true]+
-
-[[content.host_blocking.lists]]
-=== content.host_blocking.lists
-List of URLs of lists which contain hosts to block.
-
-The file can be in one of the following formats:
-
-- An `/etc/hosts`-like file
-- One host per line
-- A zip-file of any of the above, with either only one file, or a file
- named `hosts` (with any extension).
-
-It's also possible to add a local file or directory via a `file://` URL. In
-case of a directory, all files in the directory are read as adblock lists.
-
-The file `~/.config/qutebrowser/blocked-hosts` is always read if it exists.
-
-
-Type: <<types,List of Url>>
-
-Default:
-
-- +pass:[https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts]+
-
-[[content.host_blocking.whitelist]]
-=== content.host_blocking.whitelist
-A list of patterns that should always be loaded, despite being ad-blocked.
-Note this whitelists blocked hosts, not first-party URLs. As an example, if `example.org` loads an ad from `ads.example.org`, the whitelisted host should be `ads.example.org`. If you want to disable the adblocker on a given page, use the `content.host_blocking.enabled` setting with a URL pattern instead.
-Local domains are always exempt from hostblocking.
-
-Type: <<types,List of UrlPattern>>
-
-Default: empty
-
[[content.hyperlink_auditing]]
=== content.hyperlink_auditing
Enable hyperlink auditing (`<a ping>`).
@@ -2099,9 +2362,45 @@ Type: <<types,Bool>>
Default: +pass:[true]+
-[[content.media_capture]]
-=== content.media_capture
-Allow websites to record audio/video.
+[[content.media.audio_capture]]
+=== content.media.audio_capture
+Allow websites to record audio.
+
+This setting supports URL patterns.
+
+Type: <<types,BoolAsk>>
+
+Valid values:
+
+ * +true+
+ * +false+
+ * +ask+
+
+Default: +pass:[ask]+
+
+This setting is only available with the QtWebEngine backend.
+
+[[content.media.audio_video_capture]]
+=== content.media.audio_video_capture
+Allow websites to record audio and video.
+
+This setting supports URL patterns.
+
+Type: <<types,BoolAsk>>
+
+Valid values:
+
+ * +true+
+ * +false+
+ * +ask+
+
+Default: +pass:[ask]+
+
+This setting is only available with the QtWebEngine backend.
+
+[[content.media.video_capture]]
+=== content.media.video_capture
+Allow websites to record video.
This setting supports URL patterns.
@@ -2133,9 +2432,7 @@ Valid values:
Default: +pass:[ask]+
-On QtWebEngine, this setting requires Qt 5.8 or newer.
-
-On QtWebKit, this setting is unavailable.
+This setting is only available with the QtWebEngine backend.
[[content.mute]]
=== content.mute
@@ -2202,9 +2499,7 @@ Valid values:
Default: +pass:[ask]+
-On QtWebEngine, this setting requires Qt 5.11 or newer.
-
-On QtWebKit, this setting is unavailable.
+This setting is only available with the QtWebEngine backend.
[[content.plugins]]
=== content.plugins
@@ -2226,7 +2521,7 @@ Type: <<types,Bool>>
Default: +pass:[true]+
-On QtWebEngine, this setting requires Qt 5.8 or newer.
+This setting is only available with the QtWebEngine backend.
[[content.private_browsing]]
=== content.private_browsing
@@ -2240,6 +2535,7 @@ Default: +pass:[false]+
=== content.proxy
Proxy to use.
In addition to the listed values, you can use a `socks://...` or `http://...` URL.
+Note that with QtWebEngine, it will take a couple of seconds until the change is applied, if this value is changed at runtime.
Type: <<types,Proxy>>
@@ -2276,13 +2572,12 @@ Valid values:
Default: +pass:[ask]+
-On QtWebEngine, this setting requires Qt 5.11 or newer.
-
-On QtWebKit, this setting is unavailable.
+This setting is only available with the QtWebEngine backend.
[[content.site_specific_quirks]]
=== content.site_specific_quirks
Enable quirks (such as faked user agent headers) needed to get specific sites to work properly.
+
This setting requires a restart.
Type: <<types,Bool>>
@@ -2321,9 +2616,7 @@ Valid values:
Default: +pass:[allow-from-user-interaction]+
-On QtWebEngine, this setting requires Qt 5.11 or newer.
-
-On QtWebKit, this setting is unavailable.
+This setting is only available with the QtWebEngine backend.
[[content.user_stylesheets]]
=== content.user_stylesheets
@@ -2346,7 +2639,7 @@ Default: +pass:[true]+
[[content.webrtc_ip_handling_policy]]
=== content.webrtc_ip_handling_policy
Which interfaces to expose via WebRTC.
-On Qt 5.10, this option doesn't work because of a Qt bug.
+
This setting requires a restart.
Type: <<types,String>>
@@ -2360,14 +2653,12 @@ Valid values:
Default: +pass:[all-interfaces]+
-On QtWebEngine, this setting requires Qt 5.9.2 or newer.
-
-On QtWebKit, this setting is unavailable.
+This setting is only available with the QtWebEngine backend.
[[content.xss_auditing]]
=== content.xss_auditing
Monitor load requests for cross-site scripting attempts.
-Suspicious scripts will be blocked and reported in the inspector's JavaScript console.
+Suspicious scripts will be blocked and reported in the devtools JavaScript console.
Note that bypasses for the XSS auditor are widely known and it can be abused for cross-site info leaks in some scenarios, see: https://www.chromium.org/developers/design-documents/xss-auditor
This setting supports URL patterns.
@@ -2507,7 +2798,7 @@ Default: empty
=== fonts.debug_console
Font used for the debugging console.
-Type: <<types,QtFont>>
+Type: <<types,Font>>
Default: +pass:[default_size default_family]+
@@ -2595,11 +2886,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]+
@@ -2867,6 +3166,7 @@ Default:
* +pass:[img]+
* +pass:[link]+
* +pass:[summary]+
+* +pass:[[contenteditable]:not([contenteditable=&quot;false&quot;])]+
* +pass:[[onclick]]+
* +pass:[[onmousedown]]+
* +pass:[[role=&quot;link&quot;]]+
@@ -2895,6 +3195,7 @@ Default:
* +pass:[input[type=&quot;url&quot;]]+
* +pass:[input[type=&quot;week&quot;]]+
* +pass:[input:not([type])]+
+* +pass:[[contenteditable]:not([contenteditable=&quot;false&quot;])]+
* +pass:[textarea]+
- +pass:[links]+:
@@ -3004,17 +3305,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.
@@ -3022,6 +3322,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.
@@ -3058,6 +3367,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.
@@ -3082,6 +3425,7 @@ Valid values:
* +tab-silent+: Open a new tab in the existing window without activating the window.
* +tab-bg-silent+: Open a new background tab in the existing window without activating the window.
* +window+: Open in a new window.
+ * +private-window+: Open in a new private window.
Default: +pass:[tab]+
@@ -3121,16 +3465,29 @@ Default: +pass:[8]+
=== qt.args
Additional arguments to pass to Qt, without leading `--`.
With QtWebEngine, some Chromium arguments (see https://peter.sh/experiments/chromium-command-line-switches/ for a list) will work.
+
This setting requires a restart.
Type: <<types,List of String>>
Default: empty
+[[qt.environ]]
+=== qt.environ
+Additional environment variables to set.
+Setting an environment variable to null/None will unset it.
+
+This setting requires a restart.
+
+Type: <<types,Dict>>
+
+Default: empty
+
[[qt.force_platform]]
=== qt.force_platform
Force a Qt platform to use.
This sets the `QT_QPA_PLATFORM` environment variable and is useful to force using the XCB plugin when running QtWebEngine on Wayland.
+
This setting requires a restart.
Type: <<types,String>>
@@ -3141,6 +3498,7 @@ Default: empty
=== qt.force_platformtheme
Force a Qt platformtheme to use.
This sets the `QT_QPA_PLATFORMTHEME` environment variable which controls dialogs like the filepicker. By default, Qt determines the platform theme based on the desktop environment.
+
This setting requires a restart.
Type: <<types,String>>
@@ -3151,6 +3509,7 @@ Default: empty
=== qt.force_software_rendering
Force software rendering for QtWebEngine.
This is needed for QtWebEngine to work with Nouveau drivers and can be useful in other scenarios related to graphic issues.
+
This setting requires a restart.
Type: <<types,String>>
@@ -3171,6 +3530,7 @@ This setting is only available with the QtWebEngine backend.
Turn on Qt HighDPI scaling.
This is equivalent to setting QT_AUTO_SCREEN_SCALE_FACTOR=1 or QT_ENABLE_HIGHDPI_SCALING=1 (Qt >= 5.14) in the environment.
It's off by default as it can cause issues with some bitmap fonts. As an alternative to this, it's possible to set font sizes and the `zoom.default` setting.
+
This setting requires a restart.
Type: <<types,Bool>>
@@ -3181,6 +3541,7 @@ Default: +pass:[false]+
=== qt.low_end_device_mode
When to use Chromium's low-end device mode.
This improves the RAM usage of renderer processes, at the expense of performance.
+
This setting requires a restart.
Type: <<types,String>>
@@ -3203,6 +3564,7 @@ See the following pages for more details:
- https://www.chromium.org/developers/design-documents/process-models
- https://doc.qt.io/qt-5/qtwebengine-features.html#process-models
+
This setting requires a restart.
Type: <<types,String>>
@@ -3219,7 +3581,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>>
@@ -3228,8 +3590,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. 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: +pass:[when-searching]+
+Default: +pass:[overlay]+
[[scrolling.smooth]]
=== scrolling.smooth
@@ -3346,17 +3709,7 @@ Valid values:
Default: empty
-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]+
+This setting is only available with the QtWebEngine backend.
[[statusbar.padding]]
=== statusbar.padding
@@ -3384,6 +3737,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.
@@ -3501,6 +3868,7 @@ Default: +pass:[3]+
[[tabs.last_close]]
=== tabs.last_close
How to behave when the last tab is closed.
+If the `tabs.tabs_are_windows` setting is set, this is ignored and the behavior is always identical to the `close` value.
Type: <<types,String>>
@@ -3709,12 +4077,14 @@ The following placeholders are defined:
* `{perc}`: Percentage as a string like `[10%]`.
* `{perc_raw}`: Raw percentage, e.g. `10`.
* `{current_title}`: Title of the current web page.
-* `{title_sep}`: The string ` - ` if a title is set, empty otherwise.
+* `{title_sep}`: The string `" - "` if a title is set, empty otherwise.
* `{index}`: Index of this tab.
+* `{aligned_index}`: Index of this tab padded with spaces to have the same
+ width.
* `{id}`: Internal tab ID of this tab.
* `{scroll_pos}`: Page scroll position.
* `{host}`: Host of the current web page.
-* `{backend}`: Either ''webkit'' or ''webengine''
+* `{backend}`: Either `webkit` or `webengine`
* `{private}`: Indicates when private mode is enabled.
* `{current_url}`: URL of the current web page.
* `{protocol}`: Protocol (http/https/...) of the current web page.
@@ -3744,7 +4114,7 @@ Default: +pass:[true]+
[[tabs.undo_stack_size]]
=== tabs.undo_stack_size
-Number of close tab actions to remember, per window (-1 for no maximum).
+Number of closed tabs (per window) and closed windows to remember for :undo (-1 for no maximum).
Type: <<types,Int>>
@@ -3777,6 +4147,7 @@ Valid values:
* +naive+: Use simple/naive check.
* +dns+: Use DNS requests (might be slow!).
* +never+: Never search automatically.
+ * +schemeless+: Always search automatically unless URL explicitly contains a scheme.
Default: +pass:[naive]+
@@ -3835,11 +4206,12 @@ characters in the search terms are replaced by safe characters (called
expands to `slash%2Fand%26amp`).
* `{unquoted}` quotes nothing (for `slash/and&amp` this placeholder
expands to `slash/and&amp`).
+* `{0}` means the same as `{}`, but can be used multiple times.
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`.
Type: <<types,Dict>>
@@ -3892,6 +4264,22 @@ Type: <<types,FormatString>>
Default: +pass:[{perc}{current_title}{title_sep}qutebrowser]+
+[[window.transparent]]
+=== window.transparent
+Set the main window background to transparent.
+
+This allows having a transparent tab- or statusbar (might require a compositor such
+as picom). However, it breaks some functionality such as dmenu embedding via its
+`-w` option. On some systems, it was additionally reported that main window
+transparency negatively affects performance.
+
+Note this setting only affects windows opened after setting it.
+
+
+Type: <<types,Bool>>
+
+Default: +pass:[false]+
+
[[zoom.default]]
=== zoom.default
Default zoom level.
@@ -3980,6 +4368,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.
@@ -3988,13 +4377,10 @@ When setting from a string, pass a json-like list, e.g. `["one", "two"]`.
|Proxy|A proxy URL, or `system`/`none`.
|QssColor|A color value supporting gradients.
-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) * A gradient as explained in http://doc.qt.io/qt-5/stylesheet-reference.html#list-of-property-types[the Qt documentation] under ``Gradient''
+A value can be in one of the following formats: * `#RGB`/`#RRGGBB`/`#AARRGGBB`/`#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) * A gradient as explained in http://doc.qt.io/qt-5/stylesheet-reference.html#list-of-property-types[the Qt documentation] under ``Gradient''
|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`
+A value can be in one of the following formats: * `#RGB`/`#RRGGBB`/`#AARRGGBB`/`#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)
|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..74b18a6cf 100644
--- a/doc/install.asciidoc
+++ b/doc/install.asciidoc
@@ -3,54 +3,74 @@ Installing qutebrowser
toc::[]
+Official vs. community-maintained
+---------------------------------
+
+Only the following releases are done by qutebrowser's maintainer directly:
+
+- Source packages in https://github.com/qutebrowser/qutebrowser/releases[this
+ GitHub repository] and on https://pypi.org/project/qutebrowser/#files[PyPI]
+- Windows and macOS prebuilt binaries in the GitHub Releases
+- The `qutebrowser-git` package in the
+ https://aur.archlinux.org/packages/qutebrowser-git/[Archlinux AUR]
+- Installing <<tox,in a virtualenv>> from the git repository.
+
+All other packaging is done by the community, so in case of outdated/broken
+packages, you will need to reach out to the respective maintainers. Note that
+some distributions (notably, Debian Stable and Ubuntu) do only update
+qutebrowser and the underlying QtWebEngine when there's a new release of the
+distribution, typically once all couple of months to years.
+
On Debian / Ubuntu
------------------
How to install qutebrowser depends a lot on the version of Debian/Ubuntu you're
running.
-Ubuntu 16.04 LTS / Linux Mint 18
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+[[ubuntu1604]]
+Debian Stretch / Ubuntu 16.04 LTS / Linux Mint 18
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Debian Stretch does have QtWebEngine packaged, but only in a very old and insecure
+version (Qt 5.7, based on a Chromium from March 2016). Furthermore, it packages Python
+3.5 which is not supported anymore since qutebrowser v2.0.0.
Ubuntu 16.04 doesn't come with an up-to-date engine (a new enough QtWebKit, or
-QtWebEngine). However, it comes with Python 3.5, so you can
-<<tox,install qutebrowser in a virtualenv>>.
+QtWebEngine) and also comes with Python 3.5.
+
+You should be able to install a newer Python (3.6+) using the
+https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa[deadsnakes PPA] or
+https://github.com/pyenv/pyenv[pyenv], and then proceed to
+<<tox,install qutebrowser in a virtualenv>>. However, this is currently untested. If you
+got this setup to work successfully, please submit a pull request to adjust these
+instructions!
-You'll need some basic libraries to use the virtualenv-installed PyQt:
+Note you'll need some basic libraries to use the virtualenv-installed PyQt:
----
-# apt install libglib2.0-0 libgl1 libfontconfig1 libx11-xcb1 libxi6 libxrender1 libdbus-1-3
+# apt install --no-install-recommends git ca-certificates python3 python3-venv asciidoc libglib2.0-0 libgl1 libfontconfig1 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxkbcommon-x11-0 libdbus-1-3 libyaml-dev gcc python3-dev
----
-Debian Stretch
-~~~~~~~~~~~~~~
-
-Debian Stretch comes with QtWebEngine in the repositories. This makes it possible
-to install qutebrowser via the Debian package.
-
-You'll need to download three packages:
+// FIXME not needed anymore?
+// libxi6 libxrender1 libegl1-mesa
-- https://packages.debian.org/sid/all/python3-pypeg2/download[PyPEG2] (a library
- used by qutebrowser which is not in the earlier repositories)
-- https://packages.debian.org/sid/all/qutebrowser/download[qutebrowser] itself
-- Either https://packages.debian.org/sid/all/qutebrowser-qtwebengine/download[qutebrowser-qtwebengine]
- or https://packages.debian.org/sid/all/qutebrowser-qtwebkit/download[qutebrowser-qtwebkit]
- (or both) depending on the backend you want to use. QtWebEngine is the
- default/recommended choice.
+Debian Buster / Ubuntu 18.04 LTS / Linux Mint 19
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-After downloading, install the packages (make sure to install all the
-downloaded qutebrowser deb files in one apt command):
+Debian Buster packages qutebrowser, but ships a very old version (v1.6.1 from March
+2019). The QtWebEngine library used for rendering web contents is also very old (Qt
+5.11, based on a Chromium from March 2018) and insecure. It is
+https://www.debian.org/releases/buster/amd64/release-notes/ch-information.en.html#browser-security[not covered]
+by Debian's security patches. It's recommended to <<tox,install qutebrowser in a
+virtualenv>> with a newer PyQt/Qt binary instead.
-----
-# apt install ./python3-pypeg2_*_all.deb
-# apt install ./qutebrowser*.deb
-----
+With Ubuntu 18.04, the situation looks similar (but worse): There, qutebrowser v1.1.1
+from January 2018 is packaged, with QtWebEngine 5.9 based on a Chromium from January
+2017. It's recommended to either upgrade to Ubuntu 20.04 LTS or <<tox,install
+qutebrowser in a virtualenv>> with a newer PyQt/Qt binary instead.
-For an update after the initial install, you only need to download/install the
-qutebrowser package.
-
-Debian Buster / Ubuntu 18.04 LTS / Linux Mint 19 (or newer)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Ubuntu 20.04 LTS / Linux Mint 20 (or newer)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
With those distributions, qutebrowser is in the official repositories, and you
can install it with apt:
@@ -62,23 +82,26 @@ can install it with apt:
Additional hints
~~~~~~~~~~~~~~~~
-- Alternatively, you can <<tox,install qutebrowser in a virtualenv>> to get a newer
- QtWebEngine version.
- If running from git, run the following to generate the documentation for the
- `:help` command:
+ `:help` command (the `mkvenv.py` script used with a virtualenv install already does
+ this for you):
+
----
-# apt install --no-install-recommends asciidoc source-highlight
+# apt install --no-install-recommends asciidoc
$ python3 scripts/asciidoc2html.py
----
-- If you prefer using QtWebKit, there's an up-to-date version available in
- https://packages.debian.org/buster/libqt5webkit5[Debian Testing].
+- If you prefer using QtWebKit, there's QtWebKit 5.212 available in
+ Ubuntu 18.04 / Debian Buster or newer. Note however that it is based on an upstream
+ WebKit from September 2016 with known security issues and no sandboxing or process
+ isolation.
- If video or sound don't work with QtWebKit, try installing the gstreamer plugins:
+
----
# apt install gstreamer1.0-plugins-{bad,base,good,ugly}
----
++
+Note those are only needed with QtWebKit, not with the (default) QtWebEngine backend.
On Fedora
---------
@@ -123,7 +146,8 @@ $ cd ..
$ rm -r qutebrowser-git
----
-or you could use an AUR helper, e.g. `yaourt -S qutebrowser-git`.
+or you could use an AUR helper like https://github.com/Jguer/yay/[yay], e.g.
+`yay -S qutebrowser-git`.
If video or sound don't work with QtWebKit, try installing the gstreamer plugins:
@@ -163,12 +187,6 @@ with:
# xbps-install qutebrowser
----
-It's currently recommended to install `python3-PyQt5-webengine` and
-`python3-PyQt5-opengl`, then start with `--backend webengine` to use the new
-backend.
-
-Since the v1.0 release, qutebrowser uses QtWebEngine by default.
-
On NixOS
--------
@@ -179,18 +197,11 @@ it with:
$ nix-env -i qutebrowser
----
-It's recommended to install `qt5.qtwebengine` and start with
-`--backend webengine` to use the new backend.
-
-Since the v1.0 release, qutebrowser uses QtWebEngine by default.
-
On openSUSE
-----------
There are prebuilt RPMs available at https://software.opensuse.org/download.html?project=network&package=qutebrowser[OBS].
-To use the QtWebEngine backend, install `libqt5-qtwebengine`.
-
On Slackware
------------
@@ -223,6 +234,19 @@ PYTHON3=yes sbopkg -i qutebrowser
If you use the dialog screen you can deselect any already-installed packages that you don't need/want to rebuild before starting the build process.
+Via Flatpak
+-----------
+
+qutebrowser is available
+https://flathub.org/apps/details/org.qutebrowser.qutebrowser[on Flathub]
+as `org.qutebrowser.qutebrowser`.
+
+WARNING: As of October 2020, the Flatpak package is severely outdated (qutebrowser
+v1.7.0 from July 2019) and, among other issues, misses fixes for a
+(low-severity) https://github.com/qutebrowser/qutebrowser/security/advisories/GHSA-4rcq-jv2f-898j[security issue].
+It's recommended to <<tox,install qutebrowser in a virtualenv>> instead, which
+is one of the officially maintained options and will always be up-to-date.
+
On FreeBSD
----------
@@ -256,7 +280,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 +323,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
@@ -319,10 +343,14 @@ qutebrowser from source.
==== Homebrew
----
-$ brew install qt5
+$ brew install qt
+(build PyQt and PyQtWebEngine from source)
$ pip3 install qutebrowser
----
+NOTE: Homebrew does not package PyQtWebEngine (Python wrappers for
+QtWebEngine), so you will need to build that from sources manually.
+
Since the v1.0 release, qutebrowser uses QtWebEngine by default.
Homebrew's builds of Qt and PyQt don't come with QtWebKit (and `--with-qtwebkit`
@@ -333,12 +361,11 @@ https://github.com/annulen/webkit/wiki/Building-QtWebKit-on-OS-X[manually].
Packagers
---------
-There are example .desktop and icon files provided. They would go in the
-standard location for your distro (`/usr/share/applications` and
-`/usr/share/pixmaps` for example).
-
-The normal `setup.py install` doesn't install these files, so you'll have to do
-it as part of the packaging process.
+qutebrowser ships with a
+https://github.com/qutebrowser/qutebrowser/blob/master/misc/Makefile[Makefile]
+intended for packagers. This installs system-wide files in a proper locations,
+so it should be preferred to the usual `setup.py install` or `pip install`
+invocation.
// The tox anchor is so that old links remain compatible.
// When switching to Sphinx, that should be changed.
@@ -374,6 +401,10 @@ $ cd qutebrowser
Installing dependencies (including Qt)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Using a Qt installed via virtualenv needs a couple of system-wide libraries.
+See the <<ubuntu1604,Ubuntu 16.04 section>> for details about which libraries
+are required.
+
Then run the install script:
----
@@ -387,9 +418,8 @@ This installs all needed Python dependencies in a `.venv` subfolder
This comes with an up-to-date Qt/PyQt including a pre-compiled QtWebEngine
binary, but has a few caveats:
-- Make sure your `python3` is Python 3.5 or newer, otherwise you'll get a "No
- matching distribution found" error. Note that qutebrowser itself also requires
- this.
+- Make sure your `python3` is Python 3.6 or newer, otherwise you'll get a "No
+ matching distribution found" error and/or qutebrowser will not run.
- It only works on 64-bit x86 systems, with other architectures you'll get the
same error.
- It comes with a QtWebEngine compiled without proprietary codec support (such
@@ -402,6 +432,11 @@ You can specify a Qt/PyQt version with the `--pyqt-version` flag, see
`mkenv.py --help` for a list of available versions. By default, the latest
version which plays well with qutebrowser is used.
+NOTE: If the Qt smoke test fails with a _"This application failed to start
+because no Qt platform plugin could be initialized."_ message, most likely a
+system-wide library is missing. Pay attention to a _QLibraryPrivate::loadPlugin
+failed on ..._ line for details.
+
Installing dependencies (system-wide Qt)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -451,5 +486,6 @@ Updating
When you updated your local copy of the code (e.g. by pulling the git repo, or
extracting a new version), the virtualenv should automatically use the updated
-code. However, dependencies won't be updated that way. Re-running `mkvenv.py`
-will recreate the virtualenv with updated dependencies.
+code. However, dependencies won't be updated that way. Thus, it's recommended
+to run `mkvenv.py --update` instead, which will run `git pull` and recreate the
+virtualenv with updated dependencies.
diff --git a/doc/quickstart.asciidoc b/doc/quickstart.asciidoc
index 8d8f21aa0..ab9298fa6 100644
--- a/doc/quickstart.asciidoc
+++ b/doc/quickstart.asciidoc
@@ -46,24 +46,25 @@ If you get stuck, you can get help in multiple ways:
* The `:help` command inside qutebrowser shows the built-in documentation.
Additionally, each command can be started with a `--help` flag to show its
help.
-* IRC channel: irc://irc.freenode.org/#qutebrowser[`#qutebrowser`] on
+* Chat via the IRC channel: irc://irc.freenode.org/#qutebrowser[`#qutebrowser`] on
http://freenode.net/[Freenode]
(https://webchat.freenode.net/?channels=#qutebrowser[webchat])
-* Mailinglist: mailto:qutebrowser@lists.qutebrowser.org[] (
-https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[subscribe])
+* On Reddit: https://www.reddit.com/r/qutebrowser/[/r/qutebrowser]
+* Via https://github.com/qutebrowser/qutebrowser/discussions[GitHub Discussions]
+* Using the mailinglist: mailto:qutebrowser@lists.qutebrowser.org[]
+(https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[subscribe])
Donating
--------
-Working on qutebrowser is a very rewarding hobby, but like (nearly) all hobbies
-it also costs some money. Namely I have to pay for the server and domain, and
-do occasional hardware upgrades footnote:[It turned out a 160 GB SSD is rather
-small - the VMs and custom Qt builds I use for testing/developing qutebrowser
-need about 100 GB of space].
+qutebrowser's primary maintainer, The-Compiler, is currently working part-time on
+qutebrowser, funded by donations.
-If you want to give me a beer or a pizza back, I'm trying to make it as easy as
-possible for you to do so. If some other way would be easier for you, please
-get in touch!
+To sustain this for a long time, your help is needed! Check 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!
-* PayPal: me@the-compiler.org
-* Bitcoin: link:bitcoin:1PMzbcetAHfpxoXww8Bj5XqquHtVvMjJtE[1PMzbcetAHfpxoXww8Bj5XqquHtVvMjJtE]
+Alternatively, there are also various options available for one-time donations, see the
+https://github.com/qutebrowser/qutebrowser/blob/master/README.asciidoc#donating[donation section]
+in the README for details.
diff --git a/doc/qutebrowser.1.asciidoc b/doc/qutebrowser.1.asciidoc
index 52ed64d3e..83e7986bc 100644
--- a/doc/qutebrowser.1.asciidoc
+++ b/doc/qutebrowser.1.asciidoc
@@ -56,18 +56,18 @@ show it.
*-R*, *--override-restore*::
Don't restore a session even if one would be restored.
-*--target* '{auto,tab,tab-bg,tab-silent,tab-bg-silent,window}'::
+*--target* '{auto,tab,tab-bg,tab-silent,tab-bg-silent,window,private-window}'::
How URLs should be opened if there is already a qutebrowser instance running.
*--backend* '{webkit,webengine}'::
Which backend to use.
-*--enable-webengine-inspector*::
- Enable the web inspector for QtWebEngine. Note that this is a SECURITY RISK and you should not visit untrusted websites with the inspector turned on. See https://bugreports.qt.io/browse/QTBUG-50725 for more details. This is not needed anymore since Qt 5.11 where the inspector is always enabled and secure.
+*--desktop-file-name* 'DESKTOP_FILE_NAME'::
+ Set the base name of the desktop entry for this application. Used to set the app_id under Wayland. See https://doc.qt.io/qt-5/qguiapplication.html#desktopFileName-prop
=== 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..3d95aa25e 100644
--- a/doc/stacktrace.asciidoc
+++ b/doc/stacktrace.asciidoc
@@ -28,10 +28,41 @@ Debian/Ubuntu/...
^^^^^^^^^^^^^^^^^
For Debian based systems (Debian, Ubuntu, Linux Mint, ...), debug information
-is available in the repositories:
+is for QtWebEngine is available in a dedicated repository. Enable that repository
+(https://wiki.debian.org/HowToGetABacktrace#Installing_the_debugging_symbols[Debian],
+https://wiki.ubuntu.com/Debug%20Symbol%20Packages[Ubuntu],
+https://www.linuxmint.com/rel_tessa_mate_whatsnew.php[Linux Mint]) and install
+the debug packages:
----
-# apt-get install python3-pyqt5-dbg python3-pyqt5.qtwebkit-dbg python3-dbg libqt5webkit5-dbg
+# apt install python3-dbg python3-pyqt5-dbg python3-pyqt5.qtwebengine-dbg libqt5webengine5-dbgsym libqt5webenginecore5-dbgsym
+----
+
+or with the QtWebKit backend:
+
+----
+# apt install python3-dbg python3-pyqt5-dbg python3-pyqt5.qtwebkit-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
@@ -95,7 +126,7 @@ First install `gdb` on your system if it's not installed already.
Then run qutebrowser directly inside gdb like this:
----
-$ gdb $(readlink -f $(which python3)) -ex 'run -m qutebrowser --debug'
+$ gdb -ex r --args $(readlink -f $(which python3)) -m qutebrowser --debug --temp-basedir
----
Note qutebrowser/gdb will take a long time to start. After you reproduce the
@@ -110,9 +141,10 @@ Program received signal SIGSEGV, Segmentation fault.
Now enter these commands at the gdb prompt:
----
+(gdb) set pagination off
+(gdb) set logging overwrite on
(gdb) set logging on
-(gdb) bt full
-# you might have to press enter a few times until you get the prompt back
+(gdb) bt
(gdb) quit
----
@@ -155,9 +187,10 @@ Getting the stack trace
Now enter these commands at the gdb prompt:
----
+(gdb) set pagination off
+(gdb) set logging overwrite on
(gdb) set logging on
(gdb) bt
-# you might have to press enter a few times until you get the prompt back
(gdb) quit
----
@@ -169,17 +202,27 @@ happened.
For Windows
-----------
-When you see the _qutebrowser.exe has stopped working_ window, do not click
+First install
+https://www.microsoft.com/en-us/download/details.aspx?id=58210[DebugDiag] from
+Microsoft.
+
+If you see the _qutebrowser.exe has stopped working_ window, do not click
"Close the program". Instead, open your task manager, there right-click on
`qutebrowser.exe` and select "Create dump file". Remember the path of the dump
file displayed there.
-Now install
-https://www.microsoft.com/en-us/download/details.aspx?id=49924[DebugDiag] from
-Microsoft, then run the *DebugDiag 2 Analysis* tool. There, check
-*CrashHangAnalysis* and add your crash dump via *Add Data files*. Then click
-*Start analysis*.
+If you do not see such a window, instead run *DebugDiag 2 Collection* while
+qutebrowser is still running. There, use *Add Rule* -> *Crash* ->
+*A specific process* and select `qutebrowser.exe`. Accept the *Advanced
+Configuration* as-is and select a location to save dump files. Finally, tell
+DebugDiag to activate the rule and reproduce the crash. After a while, a log
+file (`.txt`) and crash dump should appear in that directory.
+
+Finally, run the *DebugDiag 2 Analysis* tool. There, check *CrashHangAnalysis*
+and add your crash dump via *Add Data files*. Then click *Start analysis*.
Close the Internet Explorer which opens when it's done and use the
-folder-button at the top left to get to the reports. There find the report file
-and send it to mail@qutebrowser.org.
+folder-button at the top left to get to the reports. There, find the report
+file (as well as the logfile, if any), zip them (important, as some mail
+providers like GMail corrupt the file otherwise) and send them to
+mail@qutebrowser.org.
diff --git a/doc/userscripts.asciidoc b/doc/userscripts.asciidoc
index 2dc34402d..e97a951c4 100644
--- a/doc/userscripts.asciidoc
+++ b/doc/userscripts.asciidoc
@@ -18,7 +18,7 @@ mpv, a simple key binding to something like `:spawn mpv {url}` should suffice.
Also note userscripts need to have the executable bit set (`chmod +x`) for
qutebrowser to run them.
-To call a userscript, it needs to be stored in your data directory under
+To call a userscript, it needs to be stored in your config or data directory under
`userscripts` (for example: `~/.local/share/qutebrowser/userscripts/myscript`),
or just use an absolute path.
@@ -38,7 +38,11 @@ The following environment variables will be set when a userscript is launched:
- `QUTE_CONFIG_DIR`: Path of the directory containing qutebrowser's configuration.
- `QUTE_DATA_DIR`: Path of the directory containing qutebrowser's data.
- `QUTE_DOWNLOAD_DIR`: Path of the downloads directory.
-- `QUTE_COMMANDLINE_TEXT`: Text currently in qutebrowser's command line.
+- `QUTE_COMMANDLINE_TEXT`: Text currently in qutebrowser's command line. Note
+ this is only useful for userscripts spawned (e.g. via a keybinding) when
+ qutebrowser is still in command mode. If you want to receive arguments passed
+ to your userscript via `:spawn`, use the normal way of getting commandline
+ arguments (e.g. `$@` in bash or `sys.argv` / `argparse` / ... in Python).
In `command` mode:
diff --git a/misc/Makefile b/misc/Makefile
index 985541fdd..b916a20d5 100644
--- a/misc/Makefile
+++ b/misc/Makefile
@@ -29,7 +29,7 @@ install: man
install -Dm644 icons/qutebrowser.svg \
"$(DESTDIR)$(DATADIR)/icons/hicolor/scalable/apps/qutebrowser.svg"
install -Dm755 -t "$(DESTDIR)$(DATADIR)/qutebrowser/userscripts/" \
- $(wildcard misc/userscripts/*)
+ $(filter-out misc/userscripts/__pycache__,$(wildcard misc/userscripts/*))
install -Dm755 -t "$(DESTDIR)$(DATADIR)/qutebrowser/scripts/" \
$(filter-out scripts/__init__.py scripts/__pycache__ scripts/dev \
scripts/testbrowser scripts/asciidoc2html.py scripts/setupcommon.py \
diff --git a/misc/apparmor/usr.bin.qutebrowser b/misc/apparmor/usr.bin.qutebrowser
index b993e0058..3d27be697 100644
--- a/misc/apparmor/usr.bin.qutebrowser
+++ b/misc/apparmor/usr.bin.qutebrowser
@@ -1,41 +1,79 @@
-# AppArmor profile for qutebrowser
-# Tested on Debian jessie
-
#include <tunables/global>
profile qutebrowser /usr/{local/,}bin/qutebrowser {
-
#include <abstractions/base>
+ #include <abstractions/python>
+ #include <abstractions/audio>
+ #include <abstractions/dri-common>
+ #include <abstractions/mesa>
+ #include <abstractions/X>
+ #include <abstractions/wayland>
+ #include <abstractions/qt5>
+ #include <abstractions/fonts>
+
+ #include <abstractions/dbus-session-strict>
#include <abstractions/nameservice>
#include <abstractions/openssl>
#include <abstractions/ssl_certs>
- #include <abstractions/audio>
- #include <abstractions/fonts>
- #include <abstractions/kde>
+
+ #include <abstractions/freedesktop.org>
#include <abstractions/user-download>
- #include <abstractions/X>
+ #include <abstractions/user-tmp>
- capability dac_override,
- /usr/{local/,}bin/ r,
- /usr/{local/,}bin/qutebrowser rix,
- /usr/bin/python3.? r,
+ # not nice but required for chromium sandbox
+ capability sys_admin,
+ capability sys_chroot,
+ capability sys_ptrace,
- /usr/lib/python3/ mr,
- /usr/lib/python3/** mr,
- /usr/lib/python3.?/ r,
- /usr/lib/python3.?/** mr,
- /usr/local/lib/python3.?/** r,
+ /dev/ r,
+ /dev/video* r,
+ /etc/mime.types r,
+ /usr/bin/ r,
+ /usr/bin/ldconfig ix,
+ /usr/bin/uname ix,
+ /usr/bin/qutebrowser rix,
+ /usr/lib/qt/libexec/QtWebEngineProcess mrix,
+ /usr/share/pdf.js/** r,
+ /usr/share/qt/translations/qtwebengine_locales/* r,
+ /usr/share/qt/qtwebengine_dictionaries r,
+ /usr/share/qt/qtwebengine_dictionaries/* r,
+ /usr/share/qt/resources/* r,
- /proc/*/mounts r,
- owner /tmp/** rwkl,
- owner /run/user/*/ rw,
- owner /run/user/*/** krw,
+ owner @{HOME}/ r,
+ owner /dev/shm/.org.chromium* rw,
+ owner @{HOME}/.cache/{qtshadercache,qutebrowser}/** rwlk,
+ owner @{HOME}/.cache/qtshadercache** rwl,
+ owner @{HOME}/.config/qutebrowser/** rwlk,
+ owner @{HOME}/.local/share/.org.chromium.Chromium* rw,
+ owner @{HOME}/.local/share/mime/generic-icons r,
+ owner @{HOME}/.local/share/qutebrowser/ r,
+ owner @{HOME}/.local/share/qutebrowser/** rwkl,
+ owner @{HOME}/.pki/nssdb/* rwk,
+ owner @{HOME}/#[0-9]* rwm,
+ owner /run/user/*/qutebrowser/ rw,
+ owner /run/user/*/qutebrowser/* rw,
+ owner /run/user/*/qutebrowser*slave-socket rwl,
+ owner /run/user/*/#* rw,
- @{HOME}/.config/qutebrowser/** krw,
- @{HOME}/.local/share/qutebrowser/** krw,
- @{HOME}/.cache/qutebrowser/** krw,
- @{HOME}/.gstreamer-0.10/* r,
+ # qt/kde
+ @{PROC} r,
+ @{PROC}/sys/fs/inotify/max_user_watches r,
+ @{PROC}/sys/kernel/random/boot_id r,
+ @{PROC}/sys/kernel/core_pattern r,
+ @{PROC}/sys/kernel/yama/ptrace_scope r,
+ /sys/{class,bus}/ r,
+ /sys/bus/pci/devices/ r,
+ /sys/devices/**/{class,config,device,resource,revision,removable,uevent} r,
+ /sys/devices/**/{vendor,subsystem_device,subsystem_vendor} r,
-}
+ owner @{PROC}/@{pid}/{fd,stat,task,mounts}/ r,
+ owner @{PROC}/@{pid}/stat r,
+ owner @{PROC}/@{pid}/task/@{pid}/status r,
+ owner @{PROC}/@{pid}/{setgroups,gid_map,oom_score_adj,uid_map} rw,
+ owner @{PROC}/@{pid}/{oom_score_adj,uid_map} rw,
+
+ # allow execution of userscripts
+ /usr/share/qutebrowser/userscripts/* Ux,
+}
diff --git a/misc/nsis/qutebrowser.nsi b/misc/nsis/qutebrowser.nsi
index d9b8fbf8d..77fd373eb 100755
--- a/misc/nsis/qutebrowser.nsi
+++ b/misc/nsis/qutebrowser.nsi
@@ -1,4 +1,5 @@
# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+# encoding: iso-8859-1
#
# This file is part of qutebrowser.
#
diff --git a/misc/org.qutebrowser.qutebrowser.appdata.xml b/misc/org.qutebrowser.qutebrowser.appdata.xml
index d11824650..601093418 100644
--- a/misc/org.qutebrowser.qutebrowser.appdata.xml
+++ b/misc/org.qutebrowser.qutebrowser.appdata.xml
@@ -44,6 +44,13 @@
</content_rating>
<releases>
<!-- Add new releases here -->
+<release version="1.14.1" date="2020-12-04"/>
+<release version="1.14.0" date="2020-10-15"/>
+<release version="1.13.1" date="2020-07-17"/>
+<release version="1.13.0" date="2020-06-26"/>
+<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"/>
diff --git a/misc/org.qutebrowser.qutebrowser.desktop b/misc/org.qutebrowser.qutebrowser.desktop
index a1deb319f..cf3ee0422 100644
--- a/misc/org.qutebrowser.qutebrowser.desktop
+++ b/misc/org.qutebrowser.qutebrowser.desktop
@@ -46,7 +46,7 @@ Type=Application
Categories=Network;WebBrowser;
Exec=qutebrowser %u
Terminal=false
-StartupNotify=false
+StartupNotify=true
MimeType=text/html;text/xml;application/xhtml+xml;application/xml;application/rdf+xml;image/gif;image/jpeg;image/png;x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/qute;
Keywords=Browser
Actions=new-window;preferences;
diff --git a/misc/qutebrowser.spec b/misc/qutebrowser.spec
index 43a18b6b5..ffb17d371 100644
--- a/misc/qutebrowser.spec
+++ b/misc/qutebrowser.spec
@@ -47,6 +47,9 @@ else:
icon = None
+DEBUG = os.environ.get('PYINSTALLER_DEBUG', '').lower() in ['1', 'true']
+
+
a = Analysis(['../qutebrowser/__main__.py'],
pathex=['misc'],
binaries=None,
@@ -65,10 +68,10 @@ exe = EXE(pyz,
exclude_binaries=True,
name='qutebrowser',
icon=icon,
- debug=False,
+ debug=DEBUG,
strip=False,
upx=False,
- console=False,
+ console=DEBUG,
version='../misc/file_version_info.txt')
coll = COLLECT(exe,
a.binaries,
diff --git a/misc/requirements/requirements-check-manifest.txt b/misc/requirements/requirements-check-manifest.txt
index 8a7a45351..f2a5f00dd 100644
--- a/misc/requirements/requirements-check-manifest.txt
+++ b/misc/requirements/requirements-check-manifest.txt
@@ -1,5 +1,8 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-check-manifest==0.41
-pep517==0.8.2
-toml==0.10.0
+build==0.1.0
+check-manifest==0.45
+packaging==20.8
+pep517==0.9.1
+pyparsing==2.4.7
+toml==0.10.2
diff --git a/misc/requirements/requirements-codecov.txt b/misc/requirements/requirements-codecov.txt
deleted file mode 100644
index 3811d792f..000000000
--- a/misc/requirements/requirements-codecov.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-# This file is automatically generated by scripts/dev/recompile_requirements.py
-
-certifi==2020.4.5.1
-chardet==3.0.4
-codecov==2.0.22
-coverage==5.1
-idna==2.9
-requests==2.23.0
-urllib3==1.25.9
diff --git a/misc/requirements/requirements-codecov.txt-raw b/misc/requirements/requirements-codecov.txt-raw
deleted file mode 100644
index 15f1c729d..000000000
--- a/misc/requirements/requirements-codecov.txt-raw
+++ /dev/null
@@ -1 +0,0 @@
-codecov
diff --git a/misc/requirements/requirements-dev.txt b/misc/requirements/requirements-dev.txt
index 040587204..2dd1e96b9 100644
--- a/misc/requirements/requirements-dev.txt
+++ b/misc/requirements/requirements-dev.txt
@@ -1,27 +1,25 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-bump2version==1.0.0
-certifi==2020.4.5.1
-cffi==1.14.0
-chardet==3.0.4
-colorama==0.4.3
-cryptography==2.9
-cssutils==1.0.2
+bump2version==1.0.1
+certifi==2020.12.5
+cffi==1.14.4
+chardet==4.0.0
+colorama==0.4.4
+cryptography==3.3.1
github3.py==1.3.0
-hunter==3.1.3
-idna==2.9
-jwcrypto==0.7
-lxml==4.5.0
+hunter==3.3.1
+idna==2.10
+jwcrypto==0.8
manhole==1.6.0
-packaging==20.3
+packaging==20.8
pycparser==2.20
-Pympler==0.8
+Pympler==0.9
pyparsing==2.4.7
-PyQt-builder==1.3.2
+PyQt-builder==1.6.0
python-dateutil==2.8.1
-requests==2.23.0
-sip==5.2.0
-six==1.14.0
-toml==0.10.0
+requests==2.25.1
+sip==5.5.0
+six==1.15.0
+toml==0.10.2
uritemplate==3.0.1
-urllib3==1.25.9
+# urllib3==1.26.2
diff --git a/misc/requirements/requirements-dev.txt-raw b/misc/requirements/requirements-dev.txt-raw
index f75a837af..fd840bab1 100644
--- a/misc/requirements/requirements-dev.txt-raw
+++ b/misc/requirements/requirements-dev.txt-raw
@@ -1,8 +1,9 @@
hunter
-cssutils
pympler
github3.py
bump2version
requests
-lxml
pyqt-builder
+
+# Already included via test requirements
+#@ ignore: urllib3
diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt
index 8513c7824..d795b98c3 100644
--- a/misc/requirements/requirements-flake8.txt
+++ b/misc/requirements/requirements-flake8.txt
@@ -1,25 +1,24 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-attrs==19.3.0
-entrypoints==0.3
-flake8==3.7.9
-flake8-bugbear==20.1.4
-flake8-builtins==1.5.2
-flake8-comprehensions==3.2.2
+attrs==20.3.0
+flake8==3.8.4
+flake8-bugbear==20.11.1
+flake8-builtins==1.5.3
+flake8-comprehensions==3.3.1
flake8-copyright==0.2.2
-flake8-debugger==3.2.1
+flake8-debugger==4.0.0
flake8-deprecated==1.3
flake8-docstrings==1.5.0
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.1.0
+flake8-tidy-imports==4.2.1
flake8-tuple==0.4.1
mccabe==0.6.1
-pep8-naming==0.10.0
-pycodestyle==2.5.0
-pydocstyle==5.0.2
+pep8-naming==0.11.1
+pycodestyle==2.6.0
+pydocstyle==5.1.1
pyflakes==2.2.0
-six==1.14.0
+six==1.15.0
snowballstemmer==2.0.0
diff --git a/misc/requirements/requirements-mypy.txt b/misc/requirements/requirements-mypy.txt
index 435e5d618..4e370dfb7 100644
--- a/misc/requirements/requirements-mypy.txt
+++ b/misc/requirements/requirements-mypy.txt
@@ -1,9 +1,16 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-mypy==0.770
+diff-cover==4.0.1
+importlib-resources==4.1.1
+inflect==5.0.2
+Jinja2==2.11.2
+jinja2-pluralize==0.3.0
+lxml==4.6.2
+MarkupSafe==1.1.1
+mypy==0.790
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
-typed-ast==1.4.1
-typing-extensions==3.7.4.2
+pluggy==0.13.1
+Pygments==2.7.3
+-e git+https://github.com/stlehmann/PyQt5-stubs.git@998632b9d6771137f9665732b03eba25c8b4e920#egg=PyQt5_stubs
+typed-ast==1.4.2
+typing-extensions==3.7.4.3
diff --git a/misc/requirements/requirements-mypy.txt-raw b/misc/requirements/requirements-mypy.txt-raw
index 92a35ab74..edc4b761a 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
-
-# remove @commit-id for scm installs
-#@ replace: @.*# @wip#
-#@ ignore: PyQt5, PyQt5-sip
+lxml # For HTML reports
+diff-cover
+importlib_resources # So stubs are available even on newer Python versions
+-e git+https://github.com/stlehmann/PyQt5-stubs.git@master#egg=PyQt5-stubs
diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt
index ce2e46532..aba943496 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
+appdirs==1.4.4
+packaging==20.4
pyparsing==2.4.7
-setuptools==46.1.3
-six==1.14.0
+setuptools==47.3.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..5ddac7a6c 100644
--- a/misc/requirements/requirements-pyinstaller.txt
+++ b/misc/requirements/requirements-pyinstaller.txt
@@ -1,4 +1,5 @@
# 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
+pyinstaller==4.1
+pyinstaller-hooks-contrib==2020.11
diff --git a/misc/requirements/requirements-pyinstaller.txt-raw b/misc/requirements/requirements-pyinstaller.txt-raw
index f6cb8ce72..c313980b0 100644
--- a/misc/requirements/requirements-pyinstaller.txt-raw
+++ b/misc/requirements/requirements-pyinstaller.txt-raw
@@ -1,4 +1 @@
--e git+https://github.com/pyinstaller/pyinstaller.git@develop#egg=PyInstaller
-
-# remove @commit-id for scm installs
-#@ replace: @.*# @develop#
+PyInstaller
diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt
index 24b34ed66..c3116159e 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==2020.4.5.1
-cffi==1.14.0
-chardet==3.0.4
-cryptography==2.9
+astroid==2.3.3 # rq.filter: < 2.4
+certifi==2020.12.5
+cffi==1.14.4
+chardet==4.0.0
+cryptography==3.3.1
github3.py==1.3.0
-idna==2.9
+idna==2.10
isort==4.3.21
-jwcrypto==0.7
+jwcrypto==0.8
lazy-object-proxy==1.4.3
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
-typed-ast==1.4.1 ; python_version<"3.8"
+requests==2.25.1
+six==1.15.0
+typed-ast==1.4.2 ; python_version<"3.8"
uritemplate==3.0.1
-urllib3==1.25.9
-wrapt==1.12.1
+# urllib3==1.26.2
+wrapt==1.11.2
diff --git a/misc/requirements/requirements-pylint.txt-raw b/misc/requirements/requirements-pylint.txt-raw
index 8a1d36ffc..f72e103f1 100644
--- a/misc/requirements/requirements-pylint.txt-raw
+++ b/misc/requirements/requirements-pylint.txt-raw
@@ -1,8 +1,13 @@
-pylint
+pylint<2.5
./scripts/dev/pylint_checkers
requests
github3.py
# fix qute-pylint location
-#@ replace: qute-pylint==.* ./scripts/dev/pylint_checkers
+#@ replace: qute-pylint.* ./scripts/dev/pylint_checkers
#@ markers: typed-ast python_version<"3.8"
+#@ filter: pylint < 2.5
+#@ filter: astroid < 2.4
+
+# Already included via test requirements
+#@ ignore: urllib3
diff --git a/misc/requirements/requirements-pyqt-5.10.txt b/misc/requirements/requirements-pyqt-5.10.txt
deleted file mode 100644
index 69c3ccbd0..000000000
--- a/misc/requirements/requirements-pyqt-5.10.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-# This file is automatically generated by scripts/dev/recompile_requirements.py
-
-PyQt5==5.10.1 # rq.filter: < 5.11
-sip==4.19.8 # rq.filter: < 5
diff --git a/misc/requirements/requirements-pyqt-5.10.txt-raw b/misc/requirements/requirements-pyqt-5.10.txt-raw
deleted file mode 100644
index 4fbea8575..000000000
--- a/misc/requirements/requirements-pyqt-5.10.txt-raw
+++ /dev/null
@@ -1,4 +0,0 @@
-#@ filter: PyQt5 < 5.11
-PyQt5 >= 5.10, < 5.11
-#@ filter: sip < 5
-sip < 5
diff --git a/misc/requirements/requirements-pyqt-5.11.txt b/misc/requirements/requirements-pyqt-5.11.txt
deleted file mode 100644
index bfee87c0f..000000000
--- a/misc/requirements/requirements-pyqt-5.11.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-# This file is automatically generated by scripts/dev/recompile_requirements.py
-
-PyQt5==5.11.3 # rq.filter: < 5.12
-PyQt5-sip==4.19.19 # rq.filter: < 4.20
diff --git a/misc/requirements/requirements-pyqt-5.11.txt-raw b/misc/requirements/requirements-pyqt-5.11.txt-raw
deleted file mode 100644
index bdbe43f19..000000000
--- a/misc/requirements/requirements-pyqt-5.11.txt-raw
+++ /dev/null
@@ -1,4 +0,0 @@
-#@ filter: PyQt5 < 5.12
-PyQt5 >= 5.11, < 5.12
-
-#@ filter: PyQt5-sip < 4.20
diff --git a/misc/requirements/requirements-pyqt-5.12.txt b/misc/requirements/requirements-pyqt-5.12.txt
index b1be83265..80a700f09 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.2
+PyQt5-sip==12.8.1
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 dc2f0359a..438c600da 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.2
+PyQt5-sip==12.8.1
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 7640a8adb..d515e717f 100644
--- a/misc/requirements/requirements-pyqt-5.14.txt
+++ b/misc/requirements/requirements-pyqt-5.14.txt
@@ -1,5 +1,5 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
PyQt5==5.14.2 # rq.filter: < 5.15
-PyQt5-sip==12.7.2
+PyQt5-sip==12.8.1
PyQtWebEngine==5.14.0 # rq.filter: < 5.15
diff --git a/misc/requirements/requirements-pyqt-5.15.0.txt b/misc/requirements/requirements-pyqt-5.15.0.txt
new file mode 100644
index 000000000..b9ee53f65
--- /dev/null
+++ b/misc/requirements/requirements-pyqt-5.15.0.txt
@@ -0,0 +1,5 @@
+# This file is automatically generated by scripts/dev/recompile_requirements.py
+
+PyQt5==5.15.0 # rq.filter: == 5.15.0
+PyQt5-sip==12.8.1
+PyQtWebEngine==5.15.0 # rq.filter: == 5.15.0
diff --git a/misc/requirements/requirements-pyqt-5.15.0.txt-raw b/misc/requirements/requirements-pyqt-5.15.0.txt-raw
new file mode 100644
index 000000000..12d6adb7d
--- /dev/null
+++ b/misc/requirements/requirements-pyqt-5.15.0.txt-raw
@@ -0,0 +1,4 @@
+#@ filter: PyQt5 == 5.15.0
+#@ filter: PyQtWebEngine == 5.15.0
+PyQt5 == 5.15.0
+PyQtWebEngine == 5.15.0
diff --git a/misc/requirements/requirements-pyqt-5.15.txt b/misc/requirements/requirements-pyqt-5.15.txt
new file mode 100644
index 000000000..e791bb323
--- /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.2 # rq.filter: < 6
+PyQt5-sip==12.8.1
+PyQtWebEngine==5.15.2 # 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-5.7.txt-raw b/misc/requirements/requirements-pyqt-5.7.txt-raw
deleted file mode 100644
index 745deb4b9..000000000
--- a/misc/requirements/requirements-pyqt-5.7.txt-raw
+++ /dev/null
@@ -1,4 +0,0 @@
-#@ filter: PyQt5 < 5.8
-#@ filter: sip < 5
-PyQt5 >= 5.7, < 5.8
-sip < 5
diff --git a/misc/requirements/requirements-pyqt-5.9.txt b/misc/requirements/requirements-pyqt-5.9.txt
deleted file mode 100644
index 8f3258721..000000000
--- a/misc/requirements/requirements-pyqt-5.9.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-# This file is automatically generated by scripts/dev/recompile_requirements.py
-
-PyQt5==5.9.2 # rq.filter: < 5.10
-sip==4.19.8 # rq.filter: < 5
diff --git a/misc/requirements/requirements-pyqt-5.9.txt-raw b/misc/requirements/requirements-pyqt-5.9.txt-raw
deleted file mode 100644
index 45d4e0c10..000000000
--- a/misc/requirements/requirements-pyqt-5.9.txt-raw
+++ /dev/null
@@ -1,4 +0,0 @@
-#@ filter: PyQt5 < 5.10
-PyQt5 >= 5.9, < 5.10
-#@ filter: sip < 5
-sip < 5
diff --git a/misc/requirements/requirements-pyqt.txt b/misc/requirements/requirements-pyqt.txt
index 90febc2e7..ec6cfd810 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.2
-PyQt5-sip==12.7.2
-PyQtWebEngine==5.14.0
+PyQt5==5.15.2
+PyQt5-sip==12.8.1
+PyQtWebEngine==5.15.2
diff --git a/misc/requirements/requirements-pyroma.txt b/misc/requirements/requirements-pyroma.txt
index 6b131e155..d0568a7df 100644
--- a/misc/requirements/requirements-pyroma.txt
+++ b/misc/requirements/requirements-pyroma.txt
@@ -1,5 +1,5 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
docutils==0.16
-Pygments==2.6.1
+Pygments==2.7.3
pyroma==2.6
diff --git a/misc/requirements/requirements-qutebrowser.txt-raw b/misc/requirements/requirements-qutebrowser.txt-raw
index c66c65beb..2d527aeef 100644
--- a/misc/requirements/requirements-qutebrowser.txt-raw
+++ b/misc/requirements/requirements-qutebrowser.txt-raw
@@ -3,5 +3,8 @@ Pygments
pyPEG2
PyYAML
colorama
-cssutils
attrs
+adblock # Optional, for improved adblocking
+importlib-resources
+
+#@ markers: importlib-resources python_version<"3.9"
diff --git a/misc/requirements/requirements-sphinx.txt b/misc/requirements/requirements-sphinx.txt
index 4d2676ba7..54eb185bc 100644
--- a/misc/requirements/requirements-sphinx.txt
+++ b/misc/requirements/requirements-sphinx.txt
@@ -1,26 +1,25 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
alabaster==0.7.12
-Babel==2.8.0
-certifi==2020.4.5.1
-chardet==3.0.4
+Babel==2.9.0
+certifi==2020.12.5
+chardet==4.0.0
docutils==0.16
-idna==2.9
+idna==2.10
imagesize==1.2.0
Jinja2==2.11.2
MarkupSafe==1.1.1
-packaging==20.3
-Pygments==2.6.1
+packaging==20.8
+Pygments==2.7.3
pyparsing==2.4.7
-pytz==2019.3
-requests==2.23.0
-six==1.14.0
+pytz==2020.5
+requests==2.25.1
snowballstemmer==2.0.0
-Sphinx==3.0.2
+Sphinx==3.4.1
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.9
+urllib3==1.26.2
diff --git a/misc/requirements/requirements-tests-git.txt b/misc/requirements/requirements-tests-git.txt
index 5a32c6d68..9efbeca40 100644
--- a/misc/requirements/requirements-tests-git.txt
+++ b/misc/requirements/requirements-tests-git.txt
@@ -18,7 +18,6 @@ git+https://github.com/pytest-dev/pytest-mock.git
git+https://github.com/pytest-dev/pytest-qt.git
git+https://github.com/pytest-dev/pytest-repeat.git
git+https://github.com/pytest-dev/pytest-rerunfailures.git
-git+https://github.com/abusalimov/pytest-travis-fold.git
git+https://github.com/The-Compiler/pytest-xvfb.git
hg+https://bitbucket.org/gutworth/six
hg+https://bitbucket.org/jendrikseipp/vulture
@@ -28,7 +27,6 @@ git+https://github.com/pallets/werkzeug.git
## qutebrowser dependencies
git+https://github.com/tartley/colorama.git
-hg+https://bitbucket.org/cthedot/cssutils
git+https://github.com/pallets/jinja.git
git+https://github.com/pallets/markupsafe.git
hg+http://bitbucket.org/birkenfeld/pygments-main
diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt
index 49d43d5d6..dd5daf9ef 100644
--- a/misc/requirements/requirements-tests.txt
+++ b/misc/requirements/requirements-tests.txt
@@ -1,47 +1,63 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-attrs==19.3.0
-beautifulsoup4==4.9.0
-cheroot==8.3.0
-click==7.1.1
-# colorama==0.4.3
-coverage==5.1
-EasyProcess==0.2.10
+apipkg==1.5
+attrs==20.3.0
+beautifulsoup4==4.9.3
+certifi==2020.12.5
+chardet==4.0.0
+cheroot==8.5.1
+click==7.1.2
+# colorama==0.4.4
+coverage==5.3.1
+EasyProcess==0.3
+execnet==1.7.1
+filelock==3.0.12
Flask==1.1.2
glob2==0.7
-hunter==3.1.3
-hypothesis==5.10.1
+hunter==3.3.1
+hypothesis==5.46.0
+icdiff==1.9.1
+idna==2.10
+iniconfig==1.1.1
itsdangerous==1.1.0
-jaraco.functools==3.0.0 ; python_version>="3.6"
+jaraco.functools==3.1.0
# Jinja2==2.11.2
-Mako==1.1.2
+Mako==1.1.3
manhole==1.6.0
# MarkupSafe==1.1.1
-more-itertools==8.2.0
-packaging==20.3
-parse==1.15.0
+more-itertools==8.6.0
+packaging==20.8
+parse==1.18.0
parse-type==0.5.2
pluggy==0.13.1
-py==1.8.1
-py-cpuinfo==5.0.0
-Pygments==2.6.1
+pprintpp==0.4.0
+py==1.10.0
+py-cpuinfo==7.0.0
+Pygments==2.7.3
pyparsing==2.4.7
-pytest==5.4.1
-pytest-bdd==3.2.1
+pytest==6.2.1
+pytest-bdd==4.0.2
pytest-benchmark==3.2.3
-pytest-cov==2.8.1
-pytest-instafail==0.4.1.post0
-pytest-mock==3.1.0
+pytest-clarity==0.3.0a0
+pytest-cov==2.10.1
+pytest-forked==1.3.0
+pytest-icdiff==0.5
+pytest-instafail==0.4.2
+pytest-mock==3.4.0
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.4
-wcwidth==0.1.9
+pytest-repeat==0.9.1
+pytest-rerunfailures==9.1.1
+pytest-xdist==2.2.0
+pytest-xvfb==2.0.0
+PyVirtualDisplay==1.3.2
+requests==2.25.1
+requests-file==1.5.1
+six==1.15.0
+sortedcontainers==2.3.0
+soupsieve==2.1
+termcolor==1.1.0
+tldextract==3.1.0
+toml==0.10.2
+urllib3==1.25.11
+vulture==2.1
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..19becf94b 100644
--- a/misc/requirements/requirements-tests.txt-raw
+++ b/misc/requirements/requirements-tests.txt-raw
@@ -2,22 +2,38 @@ beautifulsoup4
cheroot
coverage
Flask
-hunter
hypothesis
pytest
pytest-bdd
pytest-benchmark
-pytest-cov
pytest-instafail
pytest-mock
pytest-qt
-pytest-repeat
pytest-rerunfailures
-pytest-travis-fold
-pytest-xvfb
+
+## 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
+# --repeat switch (used to manually repeat tests)
+pytest-repeat
+# For coverage tests
+pytest-cov
+# To avoid windows from popping up
+pytest-xvfb
+PyVirtualDisplay
+# To run on multiple cores with -n
+pytest-xdist
+# For nicer output
+pytest-icdiff
+pytest-clarity
+
+# Needed to test misc/userscripts/qute-lastpass
+tldextract
+# https://github.com/urllib3/urllib3/issues/2071
+urllib3!=1.26.0,!=1.26.1,!=1.26.2
-#@ markers: jaraco.functools python_version>="3.6"
-#@ add: jaraco.functools==2.0; python_version<"3.6" # rq.filter: <= 2.0
#@ ignore: Jinja2, MarkupSafe, colorama
diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt
index 1c8ada351..c72393868 100644
--- a/misc/requirements/requirements-tox.txt
+++ b/misc/requirements/requirements-tox.txt
@@ -1,15 +1,13 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-appdirs==1.4.3
-distlib==0.3.0
+appdirs==1.4.4
+distlib==0.3.1
filelock==3.0.12
-packaging==20.3
+packaging==20.8
pluggy==0.13.1
-py==1.8.1
+py==1.10.0
pyparsing==2.4.7
-six==1.14.0
-toml==0.10.0
-tox==3.14.6
-tox-pip-version==0.0.7
-tox-venv==0.4.0
-virtualenv==20.0.18
+six==1.15.0
+toml==0.10.2
+tox==3.20.1
+virtualenv==20.2.2
diff --git a/misc/requirements/requirements-tox.txt-raw b/misc/requirements/requirements-tox.txt-raw
index fab438034..053148f84 100644
--- a/misc/requirements/requirements-tox.txt-raw
+++ b/misc/requirements/requirements-tox.txt-raw
@@ -1,3 +1 @@
tox
-tox-venv
-tox-pip-version
diff --git a/misc/requirements/requirements-vulture.txt b/misc/requirements/requirements-vulture.txt
index c5c343f9e..e0ea82ea2 100644
--- a/misc/requirements/requirements-vulture.txt
+++ b/misc/requirements/requirements-vulture.txt
@@ -1,3 +1,4 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-vulture==1.4
+toml==0.10.2
+vulture==2.1
diff --git a/misc/requirements/requirements-pyqt-5.7.txt b/misc/requirements/requirements-yamllint.txt
index 703c95a92..1d758395c 100644
--- a/misc/requirements/requirements-pyqt-5.7.txt
+++ b/misc/requirements/requirements-yamllint.txt
@@ -1,4 +1,5 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-PyQt5==5.7.1 # rq.filter: < 5.8
-sip==4.19.8 # rq.filter: < 5
+pathspec==0.8.1
+PyYAML==5.3.1
+yamllint==1.25.0
diff --git a/misc/requirements/requirements-yamllint.txt-raw b/misc/requirements/requirements-yamllint.txt-raw
new file mode 100644
index 000000000..b2c729ca4
--- /dev/null
+++ b/misc/requirements/requirements-yamllint.txt-raw
@@ -0,0 +1 @@
+yamllint
diff --git a/misc/userscripts/README.md b/misc/userscripts/README.md
index 33f26aa6a..938dd776d 100644
--- a/misc/userscripts/README.md
+++ b/misc/userscripts/README.md
@@ -24,7 +24,7 @@ The following userscripts are included in the current directory.
- [qutedmenu](./qutedmenu): Handle open -s && open -t with bemenu.
- [readability](./readability): Executes python-readability on current page and
opens the summary as new tab.
-- [readability-js](./readability-js): Processes the current page with the readability
+- [readability-js](./readability-js): Processes the current page with the readability
library used in Firefox Reader View and opens the summary as new tab.
- [ripbang](./ripbang): Adds DuckDuckGo bang as searchengine.
- [rss](./rss): Keeps track of URLs in RSS feeds and opens new ones.
@@ -32,6 +32,11 @@ The following userscripts are included in the current directory.
- [tor_identity](./tor_identity): Change your tor identity.
- [view_in_mpv](./view_in_mpv): Views the current web page in mpv using
sensible mpv-flags.
+- [qr](./qr): Show a QR code for the current webpage via
+ [qrencode](https://fukuchi.org/works/qrencode/).
+- [kodi](./kodi): Play videos in Kodi.
+- [add-nextcloud-bookmarks](./add-nextcloud-bookmarks): Create bookmarks in Nextcloud's Bookmarks app.
+- [add-nextcloud-cookbook](./add-nextcloud-cookbook): Add recipes to Nextcloud's Cookbook app.
[castnow]: https://github.com/xat/castnow
[youtube-dl]: https://rg3.github.io/youtube-dl/
@@ -40,7 +45,7 @@ The following userscripts are included in the current directory.
The following userscripts can be found on their own repositories.
-- [qurlshare](https://github.com/sim590/qurlshare): *secure* sharing of an URL between qutebrowser
+- [qurlshare](https://github.com/sim590/qurlshare): *secure* sharing of a URL between qutebrowser
instances using a distributed hash table.
- [qutebrowser-userscripts](https://github.com/cryzed/qutebrowser-userscripts):
a small pack of userscripts.
@@ -61,6 +66,14 @@ The following userscripts can be found on their own repositories.
Emacs's org-mode to a read-later file.
- [qute-code-hint](https://github.com/LaurenceWarne/qute-code-hint): Copy code
snippets on web pages to the clipboard via hints.
+- [Qute-Translate](https://github.com/AckslD/Qute-Translate): Translate URLs or
+ selections via Google Translate.
+- [qute-snippets](https://github.com/Aledosim/qute-snippets): Bind text snippets to a keyword
+ and retrieve they when you want.
+- [doi](https://github.com/cadadr/configuration/blob/master/qutebrowser/userscripts/doi):
+ Opens DOIs on Sci-Hub.
+- [1password](https://github.com/tomoakley/dotfiles/blob/master/qutebrowser/userscripts/1password):
+ Integration with 1password on macOS.
[Zotero]: https://www.zotero.org/
[Pocket]: https://getpocket.com/
diff --git a/misc/userscripts/add-nextcloud-bookmarks b/misc/userscripts/add-nextcloud-bookmarks
new file mode 100755
index 000000000..5da887086
--- /dev/null
+++ b/misc/userscripts/add-nextcloud-bookmarks
@@ -0,0 +1,173 @@
+#!/usr/bin/env python
+
+"""
+Behavior:
+ A qutebrowser userscript that creates bookmarks in Nextcloud's Bookmarks app.
+
+Requirements:
+ requests
+
+userscript setup:
+ Optionally create ~/.config/qutebrowser/add-nextcloud-bookmarks.ini like:
+
+[nextcloud]
+HOST=https://nextcloud.example.com
+USER=username
+;PASSWORD=lamepassword
+DESCRIPTION=None
+;TAGS=just-one
+TAGS=read-me-later,added-by-qutebrowser, Another-One
+
+ If settings aren't in the configuration file, the user will be prompted during
+ bookmark creation. If DESCRIPTION and TAGS are set to None, they will be left
+ blank. If the user does not want to be prompted for a password, it is recommended
+ to set up an 'app password'. See the following for instructions:
+ https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#managing-devices # noqa: E501
+
+qutebrowser setup:
+ add bookmark via hints
+ config.bind('X', 'hint links userscript add-nextcloud-bookmarks')
+
+ add bookmark of current URL
+ config.bind('X', 'spawn --userscript add-nextcloud-bookmarks')
+
+troubleshooting:
+ Errors detected within this userscript will have an exit of 231. All other
+ exit codes will come from requests.
+"""
+
+import configparser
+from json import dumps
+from os import environ, path
+from sys import argv, exit
+
+from PyQt5.QtWidgets import QApplication, QInputDialog, QLineEdit
+from requests import get, post
+from requests.auth import HTTPBasicAuth
+
+
+def get_text(name, info):
+ """Get input from the user."""
+ _app = QApplication(argv) # noqa: F841
+ if name == "password":
+ text, ok = QInputDialog.getText(
+ None,
+ "add-nextcloud-bookmarks userscript",
+ "Please enter {}".format(info),
+ QLineEdit.Password,
+ )
+ else:
+ text, ok = QInputDialog.getText(
+ None, "add-nextcloud-bookmarks userscript", "Please enter {}".format(info)
+ )
+ if not ok:
+ message("info", "Dialog box canceled.")
+ exit(0)
+ return text
+
+
+def message(level, text):
+ """display message"""
+ with open(environ["QUTE_FIFO"], "w") as fifo:
+ fifo.write(
+ 'message-{} "add-nextcloud-bookmarks userscript: {}"\n'.format(level, text)
+ )
+ fifo.flush()
+
+
+if "QUTE_FIFO" not in environ:
+ print(
+ "This script is designed to run as a qutebrowser userscript, "
+ "not as a standalone script."
+ )
+ exit(231)
+
+if "QUTE_CONFIG_DIR" not in environ:
+ if "XDG_CONFIG_HOME" in environ:
+ QUTE_CONFIG_DIR = environ["XDG_CONFIG_HOME"] + "/qutebrowser"
+ else:
+ QUTE_CONFIG_DIR = environ["HOME"] + "/.config/qutebrowser"
+else:
+ QUTE_CONFIG_DIR = environ["QUTE_CONFIG_DIR"]
+
+config_file = QUTE_CONFIG_DIR + "/add-nextcloud-bookmarks.ini"
+if path.isfile(config_file):
+ config = configparser.ConfigParser()
+ config.read(config_file)
+ settings = dict(config.items("nextcloud"))
+else:
+ settings = {}
+
+settings_info = [
+ ("host", "host information.", "required"),
+ ("user", "username.", "required"),
+ ("password", "password.", "required"),
+ ("description", "description or leave blank", "optional"),
+ ("tags", "tags (comma separated) or leave blank", "optional"),
+]
+
+# check for settings that need user interaction and clear optional setting if need be
+for setting in settings_info:
+ if setting[0] not in settings:
+ userInput = get_text(setting[0], setting[1])
+ settings[setting[0]] = userInput
+ if setting[2] == "optional":
+ if settings[setting[0]] == "None":
+ settings[setting[0]] = ""
+
+tags = settings["tags"].split(",")
+
+QUTE_URL = environ["QUTE_URL"]
+api_url = settings["host"] + "/index.php/apps/bookmarks/public/rest/v2/bookmark"
+
+auth = HTTPBasicAuth(settings["user"], settings["password"])
+headers = {"Content-Type": "application/json"}
+params = {"url": QUTE_URL}
+
+# check if there is already a bookmark for the URL
+r = get(
+ api_url,
+ auth=auth,
+ headers=headers,
+ params=params,
+ timeout=(3.05, 27),
+)
+if r.status_code != 200:
+ message(
+ "error",
+ "Could not connect to {} with status code {}".format(
+ settings["host"], r.status_code
+ ),
+ )
+ exit(r.status_code)
+
+try:
+ r.json()["data"][0]["id"]
+except IndexError:
+ pass
+else:
+ message("info", "bookmark already exists for {}".format(QUTE_URL))
+ exit(0)
+
+if environ["QUTE_MODE"] == "hints":
+ QUTE_TITLE = QUTE_URL
+else:
+ QUTE_TITLE = environ["QUTE_TITLE"]
+
+# JSON format
+# https://nextcloud-bookmarks.readthedocs.io/en/latest/bookmark.html#create-a-bookmark
+dict = {
+ "url": QUTE_URL,
+ "title": QUTE_TITLE,
+ "description": settings["description"],
+ "tags": tags,
+}
+data = dumps(dict)
+
+r = post(api_url, data=data, headers=headers, auth=auth, timeout=(3.05, 27))
+
+if r.status_code == 200:
+ message("info", "bookmark {} added".format(QUTE_URL))
+else:
+ message("error", "something went wrong {} bookmark not added".format(QUTE_URL))
+ exit(r.status_code)
diff --git a/misc/userscripts/add-nextcloud-cookbook b/misc/userscripts/add-nextcloud-cookbook
new file mode 100755
index 000000000..a348417e0
--- /dev/null
+++ b/misc/userscripts/add-nextcloud-cookbook
@@ -0,0 +1,131 @@
+#!/usr/bin/env python
+
+"""
+Behavior:
+ A qutebrowser userscript that adds recipes to Nextcloud's Cookbook app.
+
+Requirements:
+ requests
+
+userscript setup:
+ Optionally create ~/.config/qutebrowser/add-nextcloud-cookbook.ini like:
+
+[nextcloud]
+HOST=https://nextcloud.example.com
+USER=username
+;PASSWORD=lamepassword
+
+ If settings aren't in the configuration file, the user will be prompted.
+ If the user does not want to be prompted for a password, it is recommended
+ to set up an 'app password' with 'Allow filesystem access' enabled.
+ See the following for instructions:
+ https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#managing-devices # noqa: E501
+
+qutebrowser setup:
+ add recipe via hints
+ config.bind('X', 'hint links userscript add-nextcloud-cookbook')
+
+ add recipe of current URL
+ config.bind('X', 'spawn --userscript add-nextcloud-cookbook')
+
+troubleshooting:
+ Errors detected within this userscript will have an exit of 231. All other
+ exit codes will come from requests.
+"""
+
+import configparser
+from os import environ, path
+from sys import argv, exit
+
+from PyQt5.QtWidgets import QApplication, QInputDialog, QLineEdit
+from requests import post
+from requests.auth import HTTPBasicAuth
+
+
+def get_text(name, info):
+ """Get input from the user."""
+ _app = QApplication(argv) # noqa: F841
+ if name == "password":
+ text, ok = QInputDialog.getText(
+ None,
+ "add-nextcloud-cookbook userscript",
+ "Please enter {}".format(info),
+ QLineEdit.Password,
+ )
+ else:
+ text, ok = QInputDialog.getText(
+ None, "add-nextcloud-cookbook userscript", "Please enter {}".format(info)
+ )
+ if not ok:
+ message("info", "Dialog box canceled.")
+ exit(0)
+ return text
+
+
+def message(level, text):
+ """display message"""
+ with open(environ["QUTE_FIFO"], "w") as fifo:
+ fifo.write(
+ "message-{} 'add-nextcloud-cookbook userscript: {}'\n".format(level, text)
+ )
+ fifo.flush()
+
+
+if "QUTE_FIFO" not in environ:
+ print(
+ "This script is designed to run as a qutebrowser userscript, "
+ "not as a standalone script."
+ )
+ exit(231)
+
+if "QUTE_CONFIG_DIR" not in environ:
+ if "XDG_CONFIG_HOME" in environ:
+ QUTE_CONFIG_DIR = environ["XDG_CONFIG_HOME"] + "/qutebrowser"
+ else:
+ QUTE_CONFIG_DIR = environ["HOME"] + "/.config/qutebrowser"
+else:
+ QUTE_CONFIG_DIR = environ["QUTE_CONFIG_DIR"]
+
+config_file = QUTE_CONFIG_DIR + "/add-nextcloud-cookbook.ini"
+if path.isfile(config_file):
+ config = configparser.ConfigParser()
+ config.read(config_file)
+ settings = dict(config.items("nextcloud"))
+else:
+ settings = {}
+
+settings_info = [
+ ("host", "host information.", "required"),
+ ("user", "username.", "required"),
+ ("password", "password.", "required"),
+]
+
+# check for settings that need user interaction
+for setting in settings_info:
+ if setting[0] not in settings:
+ userInput = get_text(setting[0], setting[1])
+ settings[setting[0]] = userInput
+
+api_url = settings["host"] + "/index.php/apps/cookbook/import"
+headers = {"Content-Type": "application/x-www-form-urlencoded"}
+auth = HTTPBasicAuth(settings["user"], settings["password"])
+data = "url=" + environ["QUTE_URL"]
+
+message("info", "starting to process {}".format(environ["QUTE_URL"]))
+
+r = post(api_url, data=data, headers=headers, auth=auth, timeout=(3.05, 27))
+
+if r.status_code == 200:
+ message("info", "recipe from {} added.".format(environ["QUTE_URL"]))
+ exit(0)
+elif r.status_code == 500:
+ message("warning", "Cookbook app reports {}".format(r.text))
+ exit(0)
+else:
+ message(
+ "error",
+ "Could not connect to {} with status code {}".format(
+ settings["host"], r.status_code
+ ),
+ )
+ exit(r.status_code)
diff --git a/misc/userscripts/cast b/misc/userscripts/cast
index f7b64df70..8bbf05a40 100755
--- a/misc/userscripts/cast
+++ b/misc/userscripts/cast
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
-# Behaviour
+# Behavior
# Userscript for qutebrowser which casts the url passed in $1 to the default
# ChromeCast device in the network using the program `castnow`
#
diff --git a/misc/userscripts/dmenu_qutebrowser b/misc/userscripts/dmenu_qutebrowser
index 84be1b619..57bdb805c 100755
--- a/misc/userscripts/dmenu_qutebrowser
+++ b/misc/userscripts/dmenu_qutebrowser
@@ -38,9 +38,10 @@
# (This is unnecessarily long. I use this rarely, feel free to make this script accept parameters.)
#
-[ -z "$QUTE_URL" ] && QUTE_URL='http://google.com'
-url=$(echo "$QUTE_URL" | cat - "$QUTE_CONFIG_DIR/quickmarks" "$QUTE_DATA_DIR/history" | dmenu -l 15 -p qutebrowser)
+[ -z "$QUTE_URL" ] && QUTE_URL='https://duckduckgo.com'
+
+url=$(printf "%s\n%s" "$QUTE_URL" "$(sqlite3 -separator ' ' "$QUTE_DATA_DIR/history.sqlite" 'select title, url from CompletionHistory')" | cat "$QUTE_CONFIG_DIR/quickmarks" - | dmenu -l 15 -p qutebrowser)
url=$(echo "$url" | sed -E 's/[^ ]+ +//g' | grep -E "https?:" || echo "$url")
[ -z "${url// }" ] && exit
diff --git a/misc/userscripts/format_json b/misc/userscripts/format_json
index 541408c70..8a83c25fa 100755
--- a/misc/userscripts/format_json
+++ b/misc/userscripts/format_json
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
set -euo pipefail
#
# Behavior:
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/kodi b/misc/userscripts/kodi
new file mode 100755
index 000000000..63fcc81fe
--- /dev/null
+++ b/misc/userscripts/kodi
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+#
+# Behavior:
+# A qutebrowser userscript that plays Twitch, YouTube or Vimeo videos in Kodi via its
+# API.
+#
+# Requirements:
+# awk
+# bash
+# curl
+#
+# Kodi setup:
+# Settings -> Services -> Control
+# enable 'Allow remote control via HTTP'
+# set Username and Password
+# enable 'Allow remote control from applications on this system'
+# Optional yet recommended, setup SSL within Kodi over via a proxy webserver
+#
+# userscript setup:
+# create ~/.config/qutebrowser/kodi_rc with host and authentication information like:
+#
+# HOST="http://127.0.0.1:8080"
+# or
+# HOST="https://kodi.example.com"
+#
+# AUTH="user:password"
+# or
+# AUTH="bas64authenticationinformation"
+#
+# The base64 authentication is the output of
+# `echo -ne "user:password" |base64 --wrap 0`
+# reminder base64 is not encryption
+#
+# For vim users you might want to add '# vim: set nospell filetype=bash' to the
+# kodi_rc file.
+#
+# qutebrowser setup:
+# in ~/.config/qutebrowser/config.py add something like
+#
+# to send video link via hints:
+# config.bind('X', 'hint links userscript kodi')
+# to send current URL:
+# config.bind('X', 'spawn --userscript kodi')
+#
+# troubleshooting:
+# Errors detected within this userscript with have an exit of 231. All other exit
+# codes will come from curl or awk. To test that the kodi_rc file is set up
+# correctly, run the following command. It will display a 'It works!' notification within Kodi.
+#
+# source ~/.config/qutebrowser/kodi_rc ; curl --request POST "$HOST"/jsonrpc --header "Authorization: Basic $AUTH" --header "Content-Type: application/json" --data '{"id":1,"jsonrpc":"2.0","method":"GUI.ShowNotification","params":{"title":"It works!","message":"both HOST and AUTH are correct"}}'
+#
+# In case you miss the notification in Kodi the successful response is:
+#
+# {"id":1,"jsonrpc":"2.0","result":"OK"}
+#
+# Note, curl will display errors for some problems, but not all.
+
+if [[ -z "$QUTE_FIFO" ]] ; then
+ echo "This script is designed to run as a qutebrowser userscript, not as a standalone script."
+ exit 231
+fi
+
+# configuration loading adapted from the password_fill userscript
+QUTE_CONFIG_DIR=${QUTE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/qutebrowser/}
+KODI_CONFIG=${PWFILL_CONFIG:-${QUTE_CONFIG_DIR}/kodi_rc}
+if [[ -f "$KODI_CONFIG" ]] ; then
+ # shellcheck source=/dev/null
+ source "$KODI_CONFIG"
+ if [[ -z "$HOST" || -z "$AUTH" ]] ; then
+ echo "message-error 'HOST and/or AUTH not set in $KODI_CONFIG'" > "$QUTE_FIFO"
+ exit 231
+ fi
+else
+ echo "message-error '$KODI_CONFIG not found'" > "$QUTE_FIFO"
+ exit 231
+fi
+
+# get real URL from twitter links
+if [[ "$QUTE_URL" =~ ^https:\/\/t\.co ]] ; then
+ QUTE_URL=$(curl -o /dev/null --silent --head --write-out '%{redirect_url}' "$QUTE_URL" )
+fi
+
+# regex from https://github.com/dirkjanm/firefox-send-to-xbmc/blob/master/webextension/main.js
+if [[ "$QUTE_URL" =~ ^.*twitch.tv\/([a-zA-Z0-9_]+)$ ]] ; then
+ NAME="${BASH_REMATCH[1]}"
+ JSON='{"jsonrpc":"2.0","method":"Player.Open","params":{"item":{"file":"plugin://plugin.video.twitch/?mode=play&channel_name='$NAME'"}},"id":"2"}'
+
+elif [[ "$QUTE_URL" =~ ^.*twitch.tv\/videos\/([0-9]+)$ ]] ; then
+ NAME="${BASH_REMATCH[1]}"
+ JSON='{"jsonrpc":"2.0","method":"Player.Open","params":{"item":{"file":"plugin://plugin.video.twitch/?mode=play&video_id='$NAME'"}},"id":"2"}'
+
+elif [[ "$QUTE_URL" =~ ^.*vimeo.com\/([0-9]+) ]] ; then
+ NAME="${BASH_REMATCH[1]}"
+ JSON='{"jsonrpc":"2.0","method":"Player.Open","params":{"item":{"file":"plugin://plugin.video.vimeo/play/?video_id='$NAME'"}},"id":"2"}'
+
+elif [[ "$QUTE_URL" =~ ^.*youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=([^#\&\?]*).* ]] ; then
+ NAME="${BASH_REMATCH[1]}"
+ JSON='{"jsonrpc":"2.0","method":"Player.Open","params":{"item":{"file":"plugin://plugin.video.youtube/play/?video_id='$NAME'"}},"id":"2"}'
+fi
+
+if [[ "$JSON" ]] ; then
+ curl \
+ --request POST "$HOST"/jsonrpc \
+ --header "Authorization: Basic $AUTH" \
+ --header "Content-Type: application/json" \
+ --data "$JSON" \
+ --silent > /dev/null
+else
+ URL=$(echo "$QUTE_URL" |awk -F/ '{print $3}')
+ echo "message-warning 'kodi userscript does not support this $URL'" > "$QUTE_FIFO"
+fi
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/qr b/misc/userscripts/qr
new file mode 100755
index 000000000..84215249b
--- /dev/null
+++ b/misc/userscripts/qr
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+pngfile=$(mktemp --suffix=.png)
+trap 'rm -f "$pngfile"' EXIT
+
+qrencode -t PNG -o "$pngfile" -s 10 "$QUTE_URL"
+echo ":open -t file:///$pngfile" >> "$QUTE_FIFO"
+sleep 1 # give qutebrowser time to open the file before it gets removed
diff --git a/misc/userscripts/qute-bitwarden b/misc/userscripts/qute-bitwarden
index 646b25ba7..ca9d646e4 100755
--- a/misc/userscripts/qute-bitwarden
+++ b/misc/userscripts/qute-bitwarden
@@ -46,10 +46,8 @@ WARNING: The login details are viewable as plaintext in qutebrowser's debug log
import argparse
import enum
-import fnmatch
import functools
import os
-import re
import shlex
import subprocess
import sys
@@ -283,7 +281,7 @@ def main(arguments):
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.
+ # which is the same behavior 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
diff --git a/misc/userscripts/qute-lastpass b/misc/userscripts/qute-lastpass
index 6845a4cda..c92505600 100755
--- a/misc/userscripts/qute-lastpass
+++ b/misc/userscripts/qute-lastpass
@@ -39,14 +39,14 @@ you decide to submit a crash report!"""
import argparse
import enum
-import fnmatch
import functools
+import json
import os
import re
import shlex
import subprocess
import sys
-import json
+
import tldextract
argument_parser = argparse.ArgumentParser(
@@ -82,7 +82,8 @@ def qute_command(command):
fifo.flush()
def pass_(domain, encoding):
- args = ['lpass', 'show', '-x', '-j', '-G', '.*{:s}.*'.format(domain)]
+ domain = re.escape(domain)
+ args = ['lpass', 'show', '-x', '-j', '-G', '\\b{:s}'.format(domain)]
process = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
candidates = json.loads(process.stdout.decode(encoding).strip() or '[]')
@@ -92,6 +93,7 @@ def pass_(domain, encoding):
return candidates, err
+
def dmenu(items, invocation, encoding):
command = shlex.split(invocation)
process = subprocess.run(command, input='\n'.join(
@@ -100,10 +102,14 @@ def dmenu(items, invocation, encoding):
def fake_key_raw(text):
- for character in text:
- # Escape all characters by default, space requires special handling
- sequence = '" "' if character == ' ' else '\{}'.format(character)
- qute_command('fake-key {}'.format(sequence))
+ # Escape all characters by default, space, '<' and '>' requires special handling
+ special_escapes = {
+ ' ': '" "',
+ '<': '<less>',
+ '>': '<greater>',
+ }
+ sequence = ''.join([special_escapes.get(c, '\\{}'.format(c)) for c in text])
+ qute_command(f'fake-key {sequence}')
def main(arguments):
@@ -117,6 +123,7 @@ def main(arguments):
# the registered domain name and finally: the IPv4 address if that's what
# the URL represents
candidates = []
+ seen_id = set()
for target in filter(None, [extract_result.fqdn, extract_result.registered_domain, extract_result.subdomain + extract_result.domain, extract_result.domain, extract_result.ipv4]):
target_candidates, err = pass_(target, arguments.io_encoding)
if err:
@@ -126,7 +133,10 @@ def main(arguments):
if not target_candidates:
continue
- candidates = candidates + target_candidates
+ for candidate in target_candidates:
+ if candidate["id"] not in seen_id:
+ seen_id.add(candidate["id"])
+ candidates.append(candidate)
if not arguments.merge_candidates:
break
else:
diff --git a/misc/userscripts/qute-pass b/misc/userscripts/qute-pass
index de12efa34..388a7a737 100755
--- a/misc/userscripts/qute-pass
+++ b/misc/userscripts/qute-pass
@@ -22,10 +22,16 @@ Insert login information using pass and a dmenu-compatible application (e.g. dme
demonstration can be seen here: https://i.imgur.com/KN3XuZP.gif.
"""
-USAGE = """The domain of the site has to appear as a segment in the pass path, for example: "github.com/cryzed" or
-"websites/github.com". How the username and password are determined is freely configurable using the CLI arguments. 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.
+USAGE = """The domain of the site has to appear as a segment in the pass path,
+for example: "github.com/cryzed" or "websites/github.com". How the username and
+password are determined is freely configurable using the CLI arguments. As an
+example, if you instead store the username as part of the secret (and use a
+site's name as filename), instead of the default configuration, use
+`--username-target secret` and `--username-pattern "username: (.+)"`.
+
+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 you use gopass with multiple mounts, use the CLI switch --mode gopass to switch to gopass mode.
@@ -55,11 +61,19 @@ import sys
import tldextract
+
+def expanded_path(path):
+ # Expand potential ~ in paths, since this script won't be called from a shell that does it for us
+ expanded = os.path.expanduser(path)
+ # Add trailing slash if not present
+ return os.path.join(expanded, '')
+
+
argument_parser = argparse.ArgumentParser(description=__doc__, usage=USAGE, epilog=EPILOG)
argument_parser.add_argument('url', nargs='?', default=os.getenv('QUTE_URL'))
argument_parser.add_argument('--password-store', '-p',
- default=os.getenv('PASSWORD_STORE_DIR', default=os.path.expanduser('~/.password-store')),
- help='Path to your pass password-store (only used in pass-mode)')
+ default=expanded_path(os.getenv('PASSWORD_STORE_DIR', default='~/.password-store')),
+ help='Path to your pass password-store (only used in pass-mode)', type=expanded_path)
argument_parser.add_argument('--mode', '-M', choices=['pass', 'gopass'], default="pass",
help='Select mode [gopass] to use gopass instead of the standard pass.')
argument_parser.add_argument('--username-pattern', '-u', default=r'.*/(.+)',
@@ -101,7 +115,7 @@ def qute_command(command):
fifo.flush()
-def find_pass_candidates(domain, password_store_path):
+def find_pass_candidates(domain):
candidates = []
if arguments.mode == "gopass":
@@ -111,13 +125,13 @@ def find_pass_candidates(domain, password_store_path):
if domain in password:
candidates.append(password)
else:
- for path, directories, file_names in os.walk(password_store_path, followlinks=True):
+ for path, directories, file_names in os.walk(arguments.password_store, followlinks=True):
secrets = fnmatch.filter(file_names, '*.gpg')
if not secrets:
continue
# Strip password store path prefix to get the relative pass path
- pass_path = path[len(password_store_path):]
+ pass_path = path[len(arguments.password_store):]
split_path = pass_path.split(os.path.sep)
for secret in secrets:
secret_base = os.path.splitext(secret)[0]
@@ -128,25 +142,29 @@ def find_pass_candidates(domain, password_store_path):
return candidates
-def _run_pass(pass_arguments, encoding):
+def _run_pass(pass_arguments):
# The executable is conveniently named after it's mode [pass|gopass].
pass_command = [arguments.mode]
- process = subprocess.run(pass_command + pass_arguments, stdout=subprocess.PIPE)
- return process.stdout.decode(encoding).strip()
+ env = os.environ.copy()
+ env['PASSWORD_STORE_DIR'] = arguments.password_store
+ process = subprocess.run(pass_command + pass_arguments, env=env, stdout=subprocess.PIPE)
+ return process.stdout.decode(arguments.io_encoding).strip()
-def pass_(path, encoding):
- return _run_pass([path], encoding)
+def pass_(path):
+ if arguments.mode == "gopass":
+ return _run_pass(['show', '-o', path])
+ return _run_pass(['show', path])
-def pass_otp(path, encoding):
- return _run_pass(['otp', path], encoding)
+def pass_otp(path):
+ return _run_pass(['otp', path])
-def dmenu(items, invocation, encoding):
+def dmenu(items, invocation):
command = shlex.split(invocation)
- process = subprocess.run(command, input='\n'.join(items).encode(encoding), stdout=subprocess.PIPE)
- return process.stdout.decode(encoding).strip()
+ process = subprocess.run(command, input='\n'.join(items).encode(arguments.io_encoding), stdout=subprocess.PIPE)
+ return process.stdout.decode(arguments.io_encoding).strip()
def fake_key_raw(text):
@@ -164,11 +182,6 @@ def main(arguments):
extractor = tldextract.TLDExtract(extra_suffixes=arguments.extra_url_suffixes.split(','))
extract_result = extractor(arguments.url)
- # Expand potential ~ in paths, since this script won't be called from a shell that does it for us
- password_store_path = os.path.expanduser(arguments.password_store)
- # Add trailing slash if not present
- password_store_path = os.path.join(password_store_path, '')
-
# Try to find candidates using targets in the following order: fully-qualified domain name (includes subdomains),
# the registered domain name, the IPv4 address if that's what the URL represents and finally the private domain
# (if a non-public suffix was used).
@@ -182,7 +195,7 @@ def main(arguments):
for target in filter(None, [extract_result.fqdn, extract_result.registered_domain, extract_result.ipv4, private_domain]):
attempted_targets.append(target)
- target_candidates = find_pass_candidates(target, password_store_path)
+ target_candidates = find_pass_candidates(target)
if not target_candidates:
continue
@@ -194,8 +207,7 @@ def main(arguments):
stderr('No pass candidates for URL {!r} found! (I tried {!r})'.format(arguments.url, attempted_targets))
return ExitCodes.NO_PASS_CANDIDATES
- selection = candidates.pop() if len(candidates) == 1 else dmenu(sorted(candidates), arguments.dmenu_invocation,
- arguments.io_encoding)
+ selection = candidates.pop() if len(candidates) == 1 else dmenu(sorted(candidates), arguments.dmenu_invocation)
# Nothing was selected, simply return
if not selection:
return ExitCodes.SUCCESS
@@ -203,7 +215,7 @@ def main(arguments):
# If username-target is path and user asked for username-only, we don't need to run pass
secret = None
if not (arguments.username_target == 'path' and arguments.username_only):
- secret = pass_(selection, arguments.io_encoding)
+ secret = pass_(selection)
# Match password
match = re.match(arguments.password_pattern, secret)
@@ -225,7 +237,7 @@ def main(arguments):
elif arguments.password_only:
fake_key_raw(password)
elif arguments.otp_only:
- otp = pass_otp(selection, arguments.io_encoding)
+ otp = pass_otp(selection)
fake_key_raw(otp)
else:
# Enter username and password using fake-key and <Tab> (which seems to work almost universally), then switch
diff --git a/misc/userscripts/qutedmenu b/misc/userscripts/qutedmenu
index cc5a44413..bdd0d9b27 100755
--- a/misc/userscripts/qutedmenu
+++ b/misc/userscripts/qutedmenu
@@ -6,8 +6,9 @@
# If you would like to set a custom colorscheme/font use these dirs.
# https://github.com/halfwit/dotfiles/blob/master/.config/dmenu/bemenucolors
-readonly confdir=${XDG_CONFIG_HOME:-$HOME/.config}
+
+readonly confdir=${XDG_CONFIG_HOME:-$HOME/.config}
readonly optsfile=$confdir/dmenu/bemenucolors
create_menu() {
@@ -22,15 +23,13 @@ create_menu() {
done < "$QUTE_CONFIG_DIR"/bookmarks/urls
# Finally history
- while read -r _ url; do
- printf -- '%s\n' "$url"
- done < "$QUTE_DATA_DIR"/history
+ printf -- '%s\n' "$(sqlite3 -separator ' ' "$QUTE_DATA_DIR/history.sqlite" 'select title, url from CompletionHistory')"
}
get_selection() {
opts+=(-p qutebrowser)
- #create_menu | dmenu -l 10 "${opts[@]}"
- create_menu | bemenu -l 10 "${opts[@]}"
+ create_menu | dmenu -l 10 "${opts[@]}"
+ #create_menu | bemenu -l 10 "${opts[@]}"
}
# Main
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 e85f2b298..3705a17ef 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,7 +1,14 @@
[pytest]
log_level = NOTSET
-addopts = --strict --instafail --benchmark-columns=Min,Max,Median
+addopts = --strict-markers --strict-config --instafail --benchmark-columns=Min,Max,Median
testpaths = tests
+required_plugins =
+ pytest-bdd
+ pytest-benchmark
+ pytest-instafail
+ pytest-mock
+ pytest-qt
+ pytest-rerunfailures
markers =
gui: Tests using the GUI (e.g. spawning widgets)
posix: Tests which only can run on a POSIX OS.
@@ -23,13 +30,13 @@ markers =
qtwebkit_skip: Tests not applicable with QtWebKit
qtwebengine_flaky: Tests which are flaky (and currently skipped) with QtWebEngine
qtwebengine_mac_xfail: Tests which fail on macOS with QtWebEngine
- js_prompt: Tests needing to display a javascript prompt
this: Used to mark tests during development
- no_invalid_lines: Don't fail on unparseable lines in end2end tests
- qtbug60673: Tests which are broken if the conversion from orange selection to real selection is flaky
+ no_invalid_lines: Don't fail on unparsable lines in end2end tests
fake_os: Fake utils.is_* to a fake operating system
- unicode_locale: Tests which need an unicode locale to work
+ unicode_locale: Tests which need a 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)
+ qtwebkit_pdf_imageformat_skip: Broken on QtWebKit with PDF image format plugin installed
qt_log_level_fail = WARNING
qt_log_ignore =
^SpellCheck: .*
@@ -67,9 +74,12 @@ qt_log_ignore =
^QPaintDevice: Cannot destroy paint device that is being painted
^DirectWrite: CreateFontFaceFromHDC\(\) failed .*
^Attribute Qt::AA_ShareOpenGLContexts must be set before QCoreApplication is created\.
+ ^QHttpNetworkConnectionPrivate::_q_hostLookupFinished could not de-queue request, failed to report HostNotFoundError
+ ^The available OpenGL surface format was either not version 3\.2 or higher or not a Core Profile.*
xfail_strict = true
filterwarnings =
error
# See https://github.com/HypothesisWorks/hypothesis/issues/2370
ignore:.*which is reset between function calls but not between test cases generated by:hypothesis.errors.HypothesisDeprecationWarning
+ default:Test process .* failed to terminate!:UserWarning
faulthandler_timeout = 90
diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py
index fe9d18ed9..dfb1febd4 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.2"
+__version__ = "1.14.1"
__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/apitypes.py b/qutebrowser/api/apitypes.py
index 1019c9132..f3aa969d8 100644
--- a/qutebrowser/api/apitypes.py
+++ b/qutebrowser/api/apitypes.py
@@ -21,6 +21,8 @@
# pylint: disable=unused-import
from qutebrowser.browser.browsertab import WebTabError, AbstractTab as Tab
+from qutebrowser.browser.inspector import (Position as InspectorPosition,
+ Error as InspectorError)
from qutebrowser.browser.webelem import (Error as WebElemError,
AbstractWebElement as WebElement)
from qutebrowser.utils.usertypes import ClickTarget, JsWorld
diff --git a/qutebrowser/api/cmdutils.py b/qutebrowser/api/cmdutils.py
index 1498cc051..d0d69a04c 100644
--- a/qutebrowser/api/cmdutils.py
+++ b/qutebrowser/api/cmdutils.py
@@ -45,12 +45,12 @@ Possible values:
value.
- A python enum type: All members of the enum are possible values.
- A ``typing.Union`` of multiple types above: Any of these types are valid
- values, e.g., ``typing.Union[str, int]``.
+ values, e.g., ``Union[str, int]``.
"""
import inspect
-import typing
+from typing import Any, Callable, Iterable
from qutebrowser.utils import qtutils
from qutebrowser.commands import command, cmdexc
@@ -91,8 +91,7 @@ def check_overflow(arg: int, ctype: str) -> None:
"representation.".format(ctype))
-def check_exclusive(flags: typing.Iterable[bool],
- names: typing.Iterable[str]) -> None:
+def check_exclusive(flags: Iterable[bool], names: Iterable[str]) -> None:
"""Check if only one flag is set with exclusive flags.
Raise a CommandError if not.
@@ -113,7 +112,7 @@ class register: # noqa: N801,N806 pylint: disable=invalid-name
def __init__(self, *,
instance: str = None,
name: str = None,
- **kwargs: typing.Any) -> None:
+ **kwargs: Any) -> None:
"""Save decorator arguments.
Gets called on parse-time with the decorator arguments.
@@ -128,7 +127,7 @@ class register: # noqa: N801,N806 pylint: disable=invalid-name
# The arguments to pass to Command.
self._kwargs = kwargs
- def __call__(self, func: typing.Callable) -> typing.Callable:
+ def __call__(self, func: Callable) -> Callable:
"""Register the command before running the function.
Gets called when a function should be decorated.
@@ -175,7 +174,7 @@ class argument: # noqa: N801,N806 pylint: disable=invalid-name
def foo(bar: str):
...
- For ``typing.Union`` types, the given ``choices`` are only checked if other
+ For ``Union`` types, the given ``choices`` are only checked if other
types (like ``int``) don't match.
The following arguments are supported for ``@cmdutils.argument``:
@@ -197,23 +196,23 @@ class argument: # noqa: N801,N806 pylint: disable=invalid-name
trailing underscores stripped and underscores replaced by dashes.
"""
- def __init__(self, argname: str, **kwargs: typing.Any) -> None:
+ def __init__(self, argname: str, **kwargs: Any) -> None:
self._argname = argname # The name of the argument to handle.
self._kwargs = kwargs # Valid ArgInfo members.
- def __call__(self, func: typing.Callable) -> typing.Callable:
+ def __call__(self, func: Callable) -> Callable:
funcname = func.__name__
if self._argname not in inspect.signature(func).parameters:
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/api/config.py b/qutebrowser/api/config.py
index 3b84a999c..02d48ec3a 100644
--- a/qutebrowser/api/config.py
+++ b/qutebrowser/api/config.py
@@ -19,13 +19,13 @@
"""Access to the qutebrowser configuration."""
-import typing
+from typing import cast, Any
from PyQt5.QtCore import QUrl
from qutebrowser.config import config
-#: Simplified access to config values using attribute acccess.
+#: Simplified access to config values using attribute access.
#: For example, to access the ``content.javascript.enabled`` setting,
#: you can do::
#:
@@ -35,9 +35,9 @@ from qutebrowser.config import config
#: This also supports setting configuration values::
#:
#: config.val.content.javascript.enabled = False
-val = typing.cast('config.ConfigContainer', None)
+val = cast('config.ConfigContainer', None)
-def get(name: str, url: QUrl = None) -> typing.Any:
+def get(name: str, url: QUrl = None) -> Any:
"""Get a value from the config based on a string name."""
return config.instance.get(name, url)
diff --git a/qutebrowser/api/downloads.py b/qutebrowser/api/downloads.py
index 5e5d1916a..55656c5b5 100644
--- a/qutebrowser/api/downloads.py
+++ b/qutebrowser/api/downloads.py
@@ -75,4 +75,6 @@ def download_temp(url: QUrl) -> TempDownload:
fobj.name = 'temporary: ' + url.host()
target = downloads.FileObjDownloadTarget(fobj)
download_manager = objreg.get('qtnetwork-download-manager')
- return download_manager.get(url, target=target, auto_remove=True)
+ # cache=False is set as a WORKAROUND for MS Defender thinking we're a trojan
+ # downloader when caching the hostblock list...
+ return download_manager.get(url, target=target, auto_remove=True, cache=False)
diff --git a/qutebrowser/api/hook.py b/qutebrowser/api/hook.py
index 9bd14a8a1..4eadb2a99 100644
--- a/qutebrowser/api/hook.py
+++ b/qutebrowser/api/hook.py
@@ -22,13 +22,13 @@
"""Hooks for extensions."""
import importlib
-import typing
+from typing import Callable
from qutebrowser.extensions import loader
-def _add_module_info(func: typing.Callable) -> loader.ModuleInfo:
+def _add_module_info(func: Callable) -> loader.ModuleInfo:
"""Add module info to the given function."""
module = importlib.import_module(func.__module__)
return loader.add_module_info(module)
@@ -48,7 +48,7 @@ class init:
message.info("Extension initialized.")
"""
- def __call__(self, func: typing.Callable) -> typing.Callable:
+ def __call__(self, func: Callable) -> Callable:
info = _add_module_info(func)
if info.init_hook is not None:
raise ValueError("init hook is already registered!")
@@ -86,7 +86,7 @@ class config_changed:
def __init__(self, option_filter: str = None) -> None:
self._filter = option_filter
- def __call__(self, func: typing.Callable) -> typing.Callable:
+ def __call__(self, func: Callable) -> Callable:
info = _add_module_info(func)
info.config_changed_hooks.append((self._filter, func))
return func
diff --git a/qutebrowser/app.py b/qutebrowser/app.py
index 445593706..d2e9468aa 100644
--- a/qutebrowser/app.py
+++ b/qutebrowser/app.py
@@ -39,10 +39,11 @@ blocks and spins the Qt mainloop.
import os
import sys
+import functools
import tempfile
import datetime
import argparse
-import typing
+from typing import Iterable, Optional, cast
from PyQt5.QtWidgets import QApplication, QWidget
from PyQt5.QtGui import QDesktopServices, QPixmap, QIcon
@@ -51,7 +52,8 @@ from PyQt5.QtCore import pyqtSlot, QUrl, QObject, QEvent, pyqtSignal, Qt
import qutebrowser
import qutebrowser.resources
from qutebrowser.commands import runners
-from qutebrowser.config import config, websettings, configfiles, configinit
+from qutebrowser.config import (config, websettings, configfiles, configinit,
+ qtargs)
from qutebrowser.browser import (urlmarks, history, browsertab,
qtnetworkdownloads, downloads, greasemonkey)
from qutebrowser.browser.network import proxy
@@ -59,7 +61,7 @@ from qutebrowser.browser.webkit import cookies, cache
from qutebrowser.browser.webkit.network import networkmanager
from qutebrowser.extensions import loader
from qutebrowser.keyinput import macros, eventfilter
-from qutebrowser.mainwindow import mainwindow, prompt
+from qutebrowser.mainwindow import mainwindow, prompt, windowundo
from qutebrowser.misc import (ipc, savemanager, sessions, crashsignal,
earlyinit, sql, cmdhistory, backendproblem,
objects, quitter)
@@ -72,7 +74,7 @@ from qutebrowser.misc import utilcmds
# pylint: enable=unused-import
-q_app = typing.cast(QApplication, None)
+q_app = cast(QApplication, None)
def run(args):
@@ -80,6 +82,8 @@ def run(args):
if args.temp_basedir:
args.basedir = tempfile.mkdtemp(prefix='qutebrowser-basedir-')
+ log.init.debug("Main process PID: {}".format(os.getpid()))
+
log.init.debug("Initializing directories...")
standarddir.init(args)
utils.preload_resources()
@@ -92,11 +96,12 @@ def run(args):
q_app = Application(args)
q_app.setOrganizationName("qutebrowser")
q_app.setApplicationName("qutebrowser")
- q_app.setDesktopFileName("org.qutebrowser.qutebrowser")
+ # Default DesktopFileName is org.qutebrowser.qutebrowser, set in `get_argparser()`
+ q_app.setDesktopFileName(args.desktop_file_name)
q_app.setApplicationVersion(qutebrowser.__version__)
if args.version:
- print(version.version())
+ print(version.version_info())
sys.exit(usertypes.Exit.ok)
quitter.init(args)
@@ -144,6 +149,7 @@ def init(*, args: argparse.Namespace) -> None:
quitter.instance.shutting_down.connect(QApplication.closeAllWindows)
_init_icon()
+ _init_pulseaudio()
loader.init()
loader.load_components()
@@ -159,13 +165,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 +179,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)
@@ -189,6 +194,22 @@ def _init_icon():
q_app.setWindowIcon(icon)
+def _init_pulseaudio():
+ """Set properties for PulseAudio.
+
+ WORKAROUND for https://bugreports.qt.io/browse/QTBUG-85363
+
+ Affected Qt versions:
+ - Older than 5.11 (which is unsupported)
+ - 5.14.0 to 5.15.0 (inclusive)
+
+ However, we set this on all versions so that qutebrowser's icon gets picked
+ up as well.
+ """
+ for prop in ['application.name', 'application.icon_name']:
+ os.environ['PULSE_PROP_OVERRIDE_' + prop] = 'qutebrowser'
+
+
def _process_args(args):
"""Open startpage etc. and process commandline args."""
if not args.override_restore:
@@ -196,13 +217,15 @@ def _process_args(args):
if not sessions.session_manager.did_load:
log.init.debug("Initializing main window...")
- if config.val.content.private_browsing and qtutils.is_single_process():
+ private = args.target == 'private-window'
+ if (config.val.content.private_browsing or
+ private) and qtutils.is_single_process():
err = Exception("Private windows are unavailable with "
"the single-process process model.")
error.handle_fatal_exc(err, 'Cannot start in private mode',
no_err_windows=args.no_err_windows)
sys.exit(usertypes.Exit.err_init)
- window = mainwindow.MainWindow(private=None)
+ window = mainwindow.MainWindow(private=private)
if not args.nowindow:
window.show()
q_app.setActiveWindow(window)
@@ -228,21 +251,32 @@ def process_pos_args(args, via_ipc=False, cwd=None, target_arg=None):
ipc. If the --target argument was not specified, target_arg
will be an empty string.
"""
+ new_window_target = ('private-window' if target_arg == 'private-window'
+ else 'window')
+ command_target = config.val.new_instance_open_target
+ if command_target in {'window', 'private-window'}:
+ command_target = 'tab-silent'
+
+ win_id: Optional[int] = None
+
if via_ipc and not args:
- win_id = mainwindow.get_window(via_ipc, force_window=True)
+ win_id = mainwindow.get_window(via_ipc=via_ipc,
+ target=new_window_target)
_open_startpage(win_id)
return
- win_id = None
+
for cmd in args:
if cmd.startswith(':'):
if win_id is None:
- win_id = mainwindow.get_window(via_ipc, force_tab=True)
+ win_id = mainwindow.get_window(via_ipc=via_ipc,
+ target=command_target)
log.init.debug("Startup cmd {!r}".format(cmd))
commandrunner = runners.CommandRunner(win_id)
commandrunner.run_safely(cmd[1:])
elif not cmd:
log.init.debug("Empty argument")
- win_id = mainwindow.get_window(via_ipc, force_window=True)
+ win_id = mainwindow.get_window(via_ipc=via_ipc,
+ target=new_window_target)
else:
if via_ipc and target_arg and target_arg != 'auto':
open_target = target_arg
@@ -273,7 +307,7 @@ def open_url(url, target=None, no_raise=False, via_ipc=True):
"""
target = target or config.val.new_instance_open_target
background = target in {'tab-bg', 'tab-bg-silent'}
- win_id = mainwindow.get_window(via_ipc, force_target=target,
+ win_id = mainwindow.get_window(via_ipc=via_ipc, target=target,
no_raise=no_raise)
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=win_id)
@@ -292,7 +326,7 @@ def _open_startpage(win_id=None):
If set, open the startpage in the given window.
"""
if win_id is not None:
- window_ids = [win_id] # type: typing.Iterable[int]
+ window_ids: Iterable[int] = [win_id]
else:
window_ids = objreg.window_registry
for cur_win_id in list(window_ids): # Copying as the dict could change
@@ -333,9 +367,9 @@ def _open_special_pages(args):
objects.backend == usertypes.Backend.QtWebKit,
'qute://warning/webkit'),
- ('old-qt-warning-shown',
- not qtutils.version_check('5.9'),
- 'qute://warning/old-qt'),
+ ('session-warning-shown',
+ qtutils.version_check('5.15', compiled=False),
+ 'qute://warning/sessions'),
]
for state, condition, url in pages:
@@ -364,18 +398,30 @@ def on_focus_changed(_old, new):
def open_desktopservices_url(url):
"""Handler to open a URL via QDesktopServices."""
- win_id = mainwindow.get_window(via_ipc=True, force_window=False)
+ target = config.val.new_instance_open_target
+ win_id = mainwindow.get_window(via_ipc=True, target=target)
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=win_id)
tabbed_browser.tabopen(url)
+# This is effectively a @config.change_filter
+# However, 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)
@@ -415,7 +461,6 @@ def _init_modules(*, args):
cmdhistory.init()
log.init.debug("Initializing sessions...")
sessions.init(q_app)
- quitter.instance.shutting_down.connect(sessions.shutdown)
log.init.debug("Initializing websettings...")
websettings.init(args)
@@ -439,16 +484,14 @@ def _init_modules(*, args):
cache.init(q_app)
log.init.debug("Initializing downloads...")
- download_manager = qtnetworkdownloads.DownloadManager(parent=q_app)
- objreg.register('qtnetwork-download-manager', download_manager)
+ qtnetworkdownloads.init()
log.init.debug("Initializing Greasemonkey...")
greasemonkey.init()
log.init.debug("Misc initialization...")
macros.init()
- # Init backend-specific stuff
- browsertab.init()
+ windowundo.init()
class Application(QApplication):
@@ -458,9 +501,14 @@ class Application(QApplication):
Attributes:
_args: ArgumentParser instance.
_last_focus_object: The last focused object's repr.
+
+ Signals:
+ new_window: A new window was created.
+ window_closing: A window is being closed.
"""
new_window = pyqtSignal(mainwindow.MainWindow)
+ window_closing = pyqtSignal(mainwindow.MainWindow)
def __init__(self, args):
"""Constructor.
@@ -470,8 +518,10 @@ 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))
+ qt_args = qtargs.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,9 +529,18 @@ 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)
+ self.setAttribute(Qt.AA_MacDontSwapCtrlAndMeta, True)
+
+ self.new_window.connect(self._on_new_window)
+
+ @pyqtSlot(mainwindow.MainWindow)
+ def _on_new_window(self, window):
+ window.tabbed_browser.shutting_down.connect(functools.partial(
+ self.window_closing.emit, window))
@pyqtSlot(QObject)
def on_focus_object_changed(self, obj):
@@ -493,15 +552,15 @@ class Application(QApplication):
def event(self, e):
"""Handle macOS FileOpen events."""
- if e.type() == QEvent.FileOpen:
- url = e.url()
- if url.isValid():
- open_url(url, no_raise=True)
- else:
- message.error("Invalid URL: {}".format(url.errorString()))
- else:
+ if e.type() != QEvent.FileOpen:
return super().event(e)
+ url = e.url()
+ if url.isValid():
+ open_url(url, no_raise=True)
+ else:
+ message.error("Invalid URL: {}".format(url.errorString()))
+
return True
def __repr__(self):
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index 7e8ec478f..42ad89e7c 100644
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -21,8 +21,9 @@
import enum
import itertools
-import typing
import functools
+from typing import (cast, TYPE_CHECKING, Any, Callable, Iterable, List, Optional,
+ Sequence, Set, Type, Union)
import attr
from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QUrl, QObject, QSizeF, Qt,
@@ -32,9 +33,10 @@ from PyQt5.QtWidgets import QWidget, QApplication, QDialog
from PyQt5.QtPrintSupport import QPrintDialog, QPrinter
from PyQt5.QtNetwork import QNetworkAccessManager
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from PyQt5.QtWebKit import QWebHistory
- from PyQt5.QtWebEngineWidgets import QWebEngineHistory
+ from PyQt5.QtWebKitWidgets import QWebPage
+ from PyQt5.QtWebEngineWidgets import QWebEngineHistory, QWebEnginePage
import pygments
import pygments.lexers
@@ -45,10 +47,10 @@ from qutebrowser.config import config
from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils,
urlutils, message)
from qutebrowser.misc import miscwidgets, objects, sessions
-from qutebrowser.browser import eventfilter
+from qutebrowser.browser import eventfilter, inspector
from qutebrowser.qt import sip
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.browser import webelem
from qutebrowser.browser.inspector import AbstractWebInspector
@@ -71,21 +73,16 @@ 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
- else:
+ tab_class: Type[AbstractTab] = webenginetab.WebEngineTab
+ elif objects.backend == usertypes.Backend.QtWebKit:
from qutebrowser.browser.webkit import webkittab
tab_class = webkittab.WebKitTab
+ else:
+ raise utils.Unreachable(objects.backend)
return tab_class(win_id=win_id, mode_manager=mode_manager, private=private,
parent=parent)
-def init() -> None:
- """Initialize backend-specific modules."""
- if objects.backend == usertypes.Backend.QtWebEngine:
- from qutebrowser.browser.webengine import webenginetab
- webenginetab.init()
-
-
class WebTabError(Exception):
"""Base class for various errors."""
@@ -96,13 +93,23 @@ class UnsupportedOperationError(WebTabError):
"""Raised when an operation is not supported with the given backend."""
-TerminationStatus = enum.Enum('TerminationStatus', [
- 'normal',
- 'abnormal', # non-zero exit status
- 'crashed', # e.g. segfault
- 'killed',
- 'unknown',
-])
+class TerminationStatus(enum.Enum):
+
+ """How a QtWebEngine renderer process terminated.
+
+ Also see QWebEnginePage::RenderProcessTerminationStatus
+ """
+
+ #: Unknown render process status value gotten from Qt.
+ unknown = -1
+ #: The render process terminated normally.
+ normal = 0
+ #: The render process terminated with with a non-zero exit status.
+ abnormal = 1
+ #: The render process crashed, for example because of a segmentation fault.
+ crashed = 2
+ #: The render process was killed, for example by SIGKILL or task manager kill.
+ killed = 3
@attr.s
@@ -124,20 +131,20 @@ class TabData:
fullscreen: Whether the tab has a video shown fullscreen currently.
netrc_used: Whether netrc authentication was performed.
input_mode: current input mode for the tab.
+ splitter: InspectorSplitter used to show inspector inside the tab.
"""
- keep_icon = attr.ib(False) # type: bool
- viewing_source = attr.ib(False) # type: bool
- inspector = attr.ib(None) # type: typing.Optional[AbstractWebInspector]
- open_target = attr.ib(
- usertypes.ClickTarget.normal) # type: usertypes.ClickTarget
- override_target = attr.ib(
- None) # type: typing.Optional[usertypes.ClickTarget]
- pinned = attr.ib(False) # type: bool
- fullscreen = attr.ib(False) # type: bool
- netrc_used = attr.ib(False) # type: bool
- input_mode = attr.ib(usertypes.KeyMode.normal) # type: usertypes.KeyMode
- last_navigation = attr.ib(None) # type: usertypes.NavigationRequest
+ keep_icon: bool = attr.ib(False)
+ viewing_source: bool = attr.ib(False)
+ inspector: Optional['AbstractWebInspector'] = attr.ib(None)
+ open_target: usertypes.ClickTarget = attr.ib(usertypes.ClickTarget.normal)
+ override_target: Optional[usertypes.ClickTarget] = attr.ib(None)
+ pinned: bool = attr.ib(False)
+ fullscreen: bool = attr.ib(False)
+ netrc_used: bool = attr.ib(False)
+ input_mode: usertypes.KeyMode = attr.ib(usertypes.KeyMode.normal)
+ last_navigation: usertypes.NavigationRequest = attr.ib(None)
+ splitter: miscwidgets.InspectorSplitter = attr.ib(None)
def should_show_icon(self) -> bool:
return (config.val.tabs.favicons.show == 'always' or
@@ -148,13 +155,11 @@ class AbstractAction:
"""Attribute ``action`` of AbstractTab for Qt WebActions."""
- # The class actions are defined on (QWeb{Engine,}Page)
- action_class = None # type: type
- # The type of the actions (QWeb{Engine,}Page.WebAction)
- action_base = None # type: type
+ action_class: Type[Union['QWebPage', 'QWebEnginePage']]
+ action_base: Type[Union['QWebPage.WebAction', 'QWebEnginePage.WebAction']]
def __init__(self, tab: 'AbstractTab') -> None:
- self._widget = typing.cast(QWidget, None)
+ self._widget = cast(QWidget, None)
self._tab = tab
def exit_fullscreen(self) -> None:
@@ -205,7 +210,7 @@ class AbstractPrinting:
"""Attribute ``printing`` of AbstractTab for printing the page."""
def __init__(self, tab: 'AbstractTab') -> None:
- self._widget = typing.cast(QWidget, None)
+ self._widget = cast(QWidget, None)
self._tab = tab
def check_pdf_support(self) -> None:
@@ -216,14 +221,6 @@ class AbstractPrinting:
"""
raise NotImplementedError
- def check_printer_support(self) -> None:
- """Check whether writing to a printer is supported.
-
- If it's not supported (by the current Qt version), a WebTabError is
- raised.
- """
- raise NotImplementedError
-
def check_preview_support(self) -> None:
"""Check whether showing a print preview is supported.
@@ -237,7 +234,7 @@ class AbstractPrinting:
raise NotImplementedError
def to_printer(self, printer: QPrinter,
- callback: typing.Callable[[bool], None] = None) -> None:
+ callback: Callable[[bool], None] = None) -> None:
"""Print the tab.
Args:
@@ -249,8 +246,6 @@ class AbstractPrinting:
def show_dialog(self) -> None:
"""Print with a QPrintDialog."""
- self.check_printer_support()
-
def print_callback(ok: bool) -> None:
"""Called when printing finished."""
if not ok:
@@ -289,13 +284,13 @@ class AbstractSearch(QObject):
#: Signal emitted when an existing search was cleared.
cleared = pyqtSignal()
- _Callback = typing.Callable[[bool], None]
+ _Callback = Callable[[bool], None]
def __init__(self, tab: 'AbstractTab', parent: QWidget = None):
super().__init__(parent)
self._tab = tab
- self._widget = typing.cast(QWidget, None)
- self.text = None # type: typing.Optional[str]
+ self._widget = cast(QWidget, None)
+ self.text: Optional[str] = None
self.search_displayed = False
def _is_case_sensitive(self, ignore_case: usertypes.IgnoreCase) -> bool:
@@ -358,7 +353,7 @@ class AbstractZoom(QObject):
def __init__(self, tab: 'AbstractTab', parent: QWidget = None) -> None:
super().__init__(parent)
self._tab = tab
- self._widget = typing.cast(QWidget, None)
+ self._widget = cast(QWidget, None)
# Whether zoom was changed from the default.
self._default_zoom_changed = False
self._init_neighborlist()
@@ -378,9 +373,8 @@ class AbstractZoom(QObject):
It is a NeighborList with the zoom levels."""
levels = config.val.zoom.levels
- self._neighborlist = usertypes.NeighborList(
- levels, mode=usertypes.NeighborList.Modes.edge
- ) # type: usertypes.NeighborList[float]
+ self._neighborlist: usertypes.NeighborList[float] = usertypes.NeighborList(
+ levels, mode=usertypes.NeighborList.Modes.edge)
self._neighborlist.fuzzyval = config.val.zoom.default
def apply_offset(self, offset: int) -> float:
@@ -427,13 +421,24 @@ 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 = enum.auto()
+ normal = enum.auto()
+ line = enum.auto()
+
+
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()
@@ -442,12 +447,11 @@ class AbstractCaret(QObject):
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._widget = cast(QWidget, None)
self._mode_manager = mode_manager
mode_manager.entered.connect(self._on_mode_entered)
mode_manager.left.connect(self._on_mode_left)
+ self._tab = tab
def _on_mode_entered(self, mode: usertypes.KeyMode) -> None:
raise NotImplementedError
@@ -500,13 +504,13 @@ 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:
raise NotImplementedError
- def selection(self, callback: typing.Callable[[str], None]) -> None:
+ def selection(self, callback: Callable[[str], None]) -> None:
raise NotImplementedError
def reverse_selection(self) -> None:
@@ -536,13 +540,13 @@ class AbstractScroller(QObject):
def __init__(self, tab: 'AbstractTab', parent: QWidget = None):
super().__init__(parent)
self._tab = tab
- self._widget = typing.cast(QWidget, None)
+ self._widget = cast(QWidget, None)
if 'log-scroll-pos' in objects.debug_flags:
self.perc_changed.connect(self._log_scroll_pos_change)
@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:
@@ -604,11 +608,6 @@ class AbstractHistoryPrivate:
"""Private API related to the history."""
- def __init__(self, tab: 'AbstractTab'):
- self._tab = tab
- self._history = typing.cast(
- typing.Union['QWebHistory', 'QWebEngineHistory'], None)
-
def serialize(self) -> bytes:
"""Serialize into an opaque format understood by self.deserialize."""
raise NotImplementedError
@@ -617,7 +616,7 @@ class AbstractHistoryPrivate:
"""Deserialize from a format produced by self.serialize."""
raise NotImplementedError
- def load_items(self, items: typing.Sequence) -> None:
+ def load_items(self, items: Sequence) -> None:
"""Deserialize from a list of WebHistoryItems."""
raise NotImplementedError
@@ -628,14 +627,13 @@ class AbstractHistory:
def __init__(self, tab: 'AbstractTab') -> None:
self._tab = tab
- self._history = typing.cast(
- typing.Union['QWebHistory', 'QWebEngineHistory'], None)
- self.private_api = AbstractHistoryPrivate(tab)
+ self._history = cast(Union['QWebHistory', 'QWebEngineHistory'], None)
+ self.private_api = AbstractHistoryPrivate()
def __len__(self) -> int:
raise NotImplementedError
- def __iter__(self) -> typing.Iterable:
+ def __iter__(self) -> Iterable:
raise NotImplementedError
def _check_count(self, count: int) -> None:
@@ -672,10 +670,16 @@ class AbstractHistory:
def can_go_forward(self) -> bool:
raise NotImplementedError
- def _item_at(self, i: int) -> typing.Any:
+ def _item_at(self, i: int) -> Any:
+ raise NotImplementedError
+
+ def _go_to_item(self, item: Any) -> None:
+ raise NotImplementedError
+
+ def back_items(self) -> List[Any]:
raise NotImplementedError
- def _go_to_item(self, item: typing.Any) -> None:
+ def forward_items(self) -> List[Any]:
raise NotImplementedError
@@ -683,14 +687,12 @@ class AbstractElements:
"""Finding and handling of elements on the page."""
- _MultiCallback = typing.Callable[
- [typing.Sequence['webelem.AbstractWebElement']], None]
- _SingleCallback = typing.Callable[
- [typing.Optional['webelem.AbstractWebElement']], None]
- _ErrorCallback = typing.Callable[[Exception], None]
+ _MultiCallback = Callable[[Sequence['webelem.AbstractWebElement']], None]
+ _SingleCallback = Callable[[Optional['webelem.AbstractWebElement']], None]
+ _ErrorCallback = Callable[[Exception], None]
def __init__(self, tab: 'AbstractTab') -> None:
- self._widget = typing.cast(QWidget, None)
+ self._widget = cast(QWidget, None)
self._tab = tab
def find_css(self, selector: str,
@@ -751,7 +753,7 @@ class AbstractAudio(QObject):
def __init__(self, tab: 'AbstractTab', parent: QWidget = None) -> None:
super().__init__(parent)
- self._widget = typing.cast(QWidget, None)
+ self._widget = cast(QWidget, None)
self._tab = tab
def set_muted(self, muted: bool, override: bool = False) -> None:
@@ -782,7 +784,7 @@ class AbstractTabPrivate:
def __init__(self, mode_manager: modeman.ModeManager,
tab: 'AbstractTab') -> None:
- self._widget = typing.cast(QWidget, None)
+ self._widget = cast(QWidget, None)
self._tab = tab
self._mode_manager = mode_manager
@@ -800,7 +802,7 @@ class AbstractTabPrivate:
return
def _auto_insert_mode_cb(
- elem: typing.Optional['webelem.AbstractWebElement']
+ elem: Optional['webelem.AbstractWebElement']
) -> None:
"""Called from JS after finding the focused element."""
if elem is None:
@@ -815,7 +817,7 @@ class AbstractTabPrivate:
def clear_ssl_errors(self) -> None:
raise NotImplementedError
- def networkaccessmanager(self) -> typing.Optional[QNetworkAccessManager]:
+ def networkaccessmanager(self) -> Optional[QNetworkAccessManager]:
"""Get the QNetworkAccessManager for this tab.
This is only implemented for QtWebKit.
@@ -826,6 +828,37 @@ 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
+
+ def _recreate_inspector(self) -> None:
+ """Recreate the inspector when detached to a window.
+
+ This is needed to circumvent a QtWebEngine bug (which wasn't
+ investigated further) which sometimes results in the window not
+ appearing anymore.
+ """
+ self._tab.data.inspector = None
+ self.toggle_inspector(inspector.Position.window)
+
+ def toggle_inspector(self, position: inspector.Position) -> None:
+ """Show/hide (and if needed, create) the web inspector for this tab."""
+ tabdata = self._tab.data
+ if tabdata.inspector is None:
+ tabdata.inspector = inspector.create(
+ splitter=tabdata.splitter,
+ win_id=self._tab.win_id)
+ self._tab.shutting_down.connect(tabdata.inspector.shutdown)
+ tabdata.inspector.recreate.connect(self._recreate_inspector)
+ tabdata.inspector.inspect(self._widget.page())
+ tabdata.inspector.set_position(position)
+
class AbstractTab(QWidget):
@@ -845,6 +878,8 @@ class AbstractTab(QWidget):
icon_changed = pyqtSignal(QIcon)
#: Signal emitted when a page's title changed (new title as str)
title_changed = pyqtSignal(str)
+ #: Signal emitted when this tab was pinned/unpinned (new pinned state as bool)
+ pinned_changed = pyqtSignal(bool)
#: Signal emitted when a new tab should be opened (url as QUrl)
new_tab_requested = pyqtSignal(QUrl)
#: Signal emitted when a page's URL changed (url as QUrl)
@@ -869,8 +904,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[str] = set()
+
+ 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,13 +929,12 @@ class AbstractTab(QWidget):
self.data = TabData()
self._layout = miscwidgets.WrapperLayout(self)
- self._widget = typing.cast(QWidget, None)
+ self._widget = 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)
- self.backend = None # type: typing.Optional[usertypes.Backend]
+ self.backend: Optional[usertypes.Backend] = None
# If true, this tab has been requested to be removed (or is removed).
self.pending_removal = False
@@ -902,7 +946,9 @@ class AbstractTab(QWidget):
def _set_widget(self, widget: QWidget) -> None:
# pylint: disable=protected-access
self._widget = widget
- self._layout.wrap(self, widget)
+ self.data.splitter = miscwidgets.InspectorSplitter(
+ win_id=self.win_id, main_webview=widget)
+ self._layout.wrap(self, self.data.splitter)
self.history._history = widget.history()
self.history.private_api._history = widget.history()
self.scroller._init_widget(widget)
@@ -948,7 +994,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:
@@ -973,7 +1019,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()
@@ -994,9 +1039,6 @@ class AbstractTab(QWidget):
self.data.last_navigation = navigation
if not navigation.url.isValid():
- # Also a WORKAROUND for missing IDNA 2008 support in QUrl, see
- # https://bugreports.qt.io/browse/QTBUG-60364
-
if navigation.navigation_type == navigation.Type.link_clicked:
msg = urlutils.get_errstring(navigation.url,
"Invalid link clicked")
@@ -1032,15 +1074,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:
@@ -1061,14 +1107,11 @@ class AbstractTab(QWidget):
def load_status(self) -> usertypes.LoadStatus:
return self._load_status
- def _load_url_prepare(self, url: QUrl, *,
- emit_before_load_started: bool = True) -> None:
+ def _load_url_prepare(self, url: QUrl) -> None:
qtutils.ensure_valid(url)
- if emit_before_load_started:
- self.before_load_started.emit(url)
+ self.before_load_started.emit(url)
- def load_url(self, url: QUrl, *,
- emit_before_load_started: bool = True) -> None:
+ def load_url(self, url: QUrl) -> None:
raise NotImplementedError
def reload(self, *, force: bool = False) -> None:
@@ -1088,7 +1131,7 @@ class AbstractTab(QWidget):
self.send_event(release_evt)
def dump_async(self,
- callback: typing.Callable[[str], None], *,
+ callback: Callable[[str], None], *,
plain: bool = False) -> None:
"""Dump the current page's html asynchronously.
@@ -1100,8 +1143,8 @@ class AbstractTab(QWidget):
def run_js_async(
self,
code: str,
- callback: typing.Callable[[typing.Any], None] = None, *,
- world: typing.Union[usertypes.JsWorld, int] = None
+ callback: Callable[[Any], None] = None, *,
+ world: Union[usertypes.JsWorld, int] = None
) -> None:
"""Run javascript async.
@@ -1125,10 +1168,23 @@ class AbstractTab(QWidget):
def set_html(self, html: str, base_url: QUrl = QUrl()) -> None:
raise NotImplementedError
+ def set_pinned(self, pinned: bool) -> None:
+ self.data.pinned = pinned
+ self.pinned_changed.emit(pinned)
+
+ def renderer_process_pid(self) -> Optional[int]:
+ """Get the PID of the underlying renderer process.
+
+ Returns None if the PID can't be determined or if getting the PID isn't
+ supported.
+ """
+ raise NotImplementedError
+
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 8f7717ea7..18777e250 100644
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -22,7 +22,7 @@
import os.path
import shlex
import functools
-import typing
+from typing import cast, Callable, Dict, Union
from PyQt5.QtWidgets import QApplication, QTabBar
from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QEvent, QUrlQuery
@@ -30,15 +30,15 @@ from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QEvent, QUrlQuery
from qutebrowser.commands import userscripts, runners
from qutebrowser.api import cmdutils
from qutebrowser.config import config, configdata
-from qutebrowser.browser import (urlmarks, browsertab, inspector, navigate,
- webelem, downloads)
+from qutebrowser.browser import (urlmarks, browsertab, navigate, webelem,
+ downloads)
from qutebrowser.keyinput import modeman, keyutils
from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils,
objreg, utils, standarddir, debug)
from qutebrowser.utils.usertypes import KeyMode
from qutebrowser.misc import editor, guiprocess, objects
from qutebrowser.completion.models import urlmodel, miscmodels
-from qutebrowser.mainwindow import mainwindow
+from qutebrowser.mainwindow import mainwindow, windowundo
class CommandDispatcher:
@@ -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()
@@ -170,7 +170,7 @@ class CommandDispatcher:
elif mode == "stack-next":
tab = tab_deque.next(cur_tab)
else:
- raise NotImplementedError(
+ raise utils.Unreachable(
"Missing implementation for stack mode!")
except IndexError:
if not show_error:
@@ -278,7 +278,7 @@ class CommandDispatcher:
return
to_pin = not tab.data.pinned
- self._tabbed_browser.widget.set_tab_pinned(tab, to_pin)
+ tab.set_pinned(to_pin)
@cmdutils.register(instance='command-dispatcher', name='open',
maxsplit=0, scope='window')
@@ -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
@@ -420,7 +421,8 @@ class CommandDispatcher:
newtab.data.keep_icon = True
newtab.history.private_api.deserialize(history)
newtab.zoom.set_factor(curtab.zoom.factor())
- new_tabbed_browser.widget.set_tab_pinned(newtab, curtab.data.pinned)
+
+ newtab.set_pinned(curtab.data.pinned)
return newtab
@cmdutils.register(instance='command-dispatcher', scope='window',
@@ -452,7 +454,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 +463,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 +481,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,12 +490,16 @@ 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(),
add_undo=False)
- def _back_forward(self, tab, bg, window, count, forward):
+ def _back_forward(self, tab, bg, window, count, forward, index=None):
"""Helper function for :back/:forward."""
history = self._current_widget().history
# Catch common cases before e.g. cloning tab
@@ -506,6 +513,12 @@ class CommandDispatcher:
else:
widget = self._current_widget()
+ if count is None:
+ if index is None:
+ count = 1
+ else:
+ count = abs(history.current_idx() - index)
+
try:
if forward:
widget.history.forward(count)
@@ -516,7 +529,10 @@ class CommandDispatcher:
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('count', value=cmdutils.Value.count)
- def back(self, tab=False, bg=False, window=False, count=1):
+ @cmdutils.argument('index', completion=miscmodels.back)
+ def back(self, tab: bool = False, bg: bool = False,
+ window: bool = False, count: int = None,
+ index: int = None) -> None:
"""Go back in the history of the current tab.
Args:
@@ -524,12 +540,16 @@ class CommandDispatcher:
bg: Go back in a background tab.
window: Go back in a new window.
count: How many pages to go back.
+ index: Which page to go back to, count takes precedence.
"""
- self._back_forward(tab, bg, window, count, forward=False)
+ self._back_forward(tab, bg, window, count, forward=False, index=index)
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('count', value=cmdutils.Value.count)
- def forward(self, tab=False, bg=False, window=False, count=1):
+ @cmdutils.argument('index', completion=miscmodels.forward)
+ def forward(self, tab: bool = False, bg: bool = False,
+ window: bool = False, count: int = None,
+ index: int = None) -> None:
"""Go forward in the history of the current tab.
Args:
@@ -537,12 +557,13 @@ class CommandDispatcher:
bg: Go forward in a background tab.
window: Go forward in a new window.
count: How many pages to go forward.
+ index: Which page to go forward to, count takes precedence.
"""
- self._back_forward(tab, bg, window, count, forward=True)
+ self._back_forward(tab, bg, window, count, forward=True, index=index)
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('where', choices=['prev', 'next', 'up', 'increment',
- 'decrement'])
+ 'decrement', 'strip'])
@cmdutils.argument('count', value=cmdutils.Value.count)
def navigate(self, where: str, tab: bool = False, bg: bool = False,
window: bool = False, count: int = 1) -> None:
@@ -567,6 +588,7 @@ class CommandDispatcher:
Uses the
link:settings{outsuffix}#url.incdec_segments[url.incdec_segments]
config option.
+ - `strip`: Strip query and fragment from the current URL.
tab: Open in a new tab.
bg: Open in a background tab.
@@ -578,24 +600,21 @@ class CommandDispatcher:
widget = self._current_widget()
url = self._current_url()
- handlers = {
+ handlers: Dict[str, Callable] = {
'prev': functools.partial(navigate.prevnext, prev=True),
'next': functools.partial(navigate.prevnext, prev=False),
'up': navigate.path_up,
- 'decrement': functools.partial(navigate.incdec,
- inc_or_dec='decrement'),
- 'increment': functools.partial(navigate.incdec,
- inc_or_dec='increment'),
- } # type: typing.Dict[str, typing.Callable]
+ 'strip': navigate.strip,
+ 'decrement': functools.partial(navigate.incdec, inc_or_dec='decrement'),
+ 'increment': functools.partial(navigate.incdec, inc_or_dec='increment'),
+ }
try:
if where in ['prev', 'next']:
handler = handlers[where]
handler(browsertab=widget, win_id=self._win_id, baseurl=url,
tab=tab, background=bg, window=window)
- elif where in ['up', 'increment', 'decrement']:
- if where == 'up':
- url = url.adjusted(QUrl.RemoveFragment | QUrl.RemoveQuery)
+ elif where in ['up', 'increment', 'decrement', 'strip']:
new_url = handlers[where](url, count)
self._open(new_url, tab, bg, window, related=True)
else: # pragma: no cover
@@ -645,15 +664,16 @@ 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)
+ url_query_str = url.query()
if '&' not in url_query_str and ';' in url_query_str:
url_query.setQueryDelimiters('=', ';')
url_query.setQuery(url_query_str)
@@ -661,12 +681,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 +698,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 +729,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))
@@ -784,12 +793,39 @@ class CommandDispatcher:
text="Are you sure you want to close pinned tabs?")
@cmdutils.register(instance='command-dispatcher', scope='window')
- def undo(self):
- """Re-open the last closed tab or tabs."""
+ @cmdutils.argument('count', value=cmdutils.Value.count)
+ @cmdutils.argument('depth', completion=miscmodels.undo)
+ def undo(self, window: bool = False,
+ count: int = None, depth: int = None) -> None:
+ """Re-open the last closed tab(s) or window.
+
+ Args:
+ window: Re-open the last closed window (and its tabs).
+ count: How deep in the undo stack to find the tab or tabs to
+ re-open.
+ depth: Same as `count` but as argument for completion, `count`
+ takes precedence.
+ """
+ has_depth = count is not None or depth is not None
+ if count is not None:
+ depth = count
+ elif depth is None:
+ depth = 1
+
+ if window and has_depth:
+ raise cmdutils.CommandError(
+ ":undo --window does not support a count/depth")
+
try:
- self._tabbed_browser.undo()
+ if window:
+ windowundo.instance.undo_last_window_close()
+ else:
+ self._tabbed_browser.undo(depth)
except IndexError:
- raise cmdutils.CommandError("Nothing to undo!")
+ msg = "Nothing to undo"
+ if not window and not has_depth:
+ msg += " (use :undo --window to reopen a closed window)"
+ raise cmdutils.CommandError(msg)
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('count', value=cmdutils.Value.count)
@@ -909,9 +945,10 @@ 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,
+ def tab_focus(self, index: Union[str, int] = None,
count: int = None, no_last: bool = False) -> None:
"""Select the tab given as argument/[count].
@@ -955,7 +992,7 @@ class CommandDispatcher:
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('index', choices=['+', '-'])
@cmdutils.argument('count', value=cmdutils.Value.count)
- def tab_move(self, index: typing.Union[str, int] = None,
+ def tab_move(self, index: Union[str, int] = None,
count: int = None) -> None:
"""Move the current tab according to the argument and [count].
@@ -1020,7 +1057,8 @@ class CommandDispatcher:
verbose: Show notifications when the command started/exited.
output: Show the output in a new tab.
output_messages: Show the output as messages.
- detach: Whether the command should be detached from qutebrowser.
+ detach: Detach the command from qutebrowser so that it continues
+ running when qutebrowser quits.
cmdline: The commandline to execute.
count: Given to userscripts as $QUTE_COUNT.
"""
@@ -1243,28 +1281,6 @@ class CommandDispatcher:
raise cmdutils.CommandError("Bookmark '{}' not found!".format(url))
message.info("Removed bookmark {}".format(url))
- @cmdutils.register(instance='command-dispatcher', name='inspector',
- scope='window')
- def toggle_inspector(self):
- """Toggle the web inspector.
-
- Note: Due to a bug in Qt, the inspector will show incorrect request
- headers in the network tab.
- """
- tab = self._current_widget()
- # FIXME:qtwebengine have a proper API for this
- page = tab._widget.page() # pylint: disable=protected-access
-
- try:
- if tab.data.inspector is None:
- tab.data.inspector = inspector.create()
- tab.data.inspector.inspect(page)
- tab.data.inspector.show()
- else:
- tab.data.inspector.toggle(page)
- except inspector.WebInspectorError as e:
- raise cmdutils.CommandError(e)
-
@cmdutils.register(instance='command-dispatcher', scope='window')
def download(self, url=None, *, mhtml_=False, dest=None):
"""Download a given URL, or current page if no URL given.
@@ -1289,7 +1305,7 @@ class CommandDispatcher:
if mhtml_:
raise cmdutils.CommandError("Can only download the current "
"page as mhtml.")
- url = urlutils.qurl_from_user_input(url)
+ url = QUrl.fromUserInput(url)
urlutils.raise_cmdexc_if_invalid(url)
download_manager.get(url, target=target)
elif mhtml_:
@@ -1378,6 +1394,12 @@ class CommandDispatcher:
if command not in objects.commands:
raise cmdutils.CommandError("Invalid command {}!".format(
command))
+
+ deprecated = objects.commands[command].deprecated
+ if deprecated:
+ raise cmdutils.CommandError(
+ "{} is deprecated - {}".format(command, deprecated))
+
path = 'commands.html#{}'.format(command)
elif topic in configdata.DATA:
path = 'settings.html#{}'.format(topic)
@@ -1387,24 +1409,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', 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):
@@ -1589,10 +1627,32 @@ class CommandDispatcher:
tab.search.prev_result()
tab.search.prev_result(result_cb=cb)
+ def _jseval_cb(self, out):
+ """Show the data returned from JS."""
+ if out is None:
+ # Getting the actual error (if any) seems to be difficult.
+ # The error does end up in
+ # BrowserPage.javaScriptConsoleMessage(), but
+ # distinguishing between :jseval errors and errors from the
+ # webpage is not trivial...
+ message.info('No output or error')
+ else:
+ # The output can be a string, number, dict, array, etc. But
+ # *don't* output too much data, as this will make
+ # qutebrowser hang
+ out = str(out)
+ if len(out) > 5000:
+ out = out[:5000] + ' [...trimmed...]'
+ message.info(out)
+
@cmdutils.register(instance='command-dispatcher', scope='window',
maxsplit=0, no_cmd_split=True)
- def jseval(self, js_code: str, file: bool = False, quiet: bool = False, *,
- world: typing.Union[usertypes.JsWorld, int] = None) -> None:
+ def jseval(self, js_code: str,
+ file: bool = False,
+ url: bool = False,
+ quiet: bool = False,
+ *,
+ world: Union[usertypes.JsWorld, int] = None) -> None:
"""Evaluate a JavaScript string.
Args:
@@ -1601,33 +1661,24 @@ class CommandDispatcher:
If the path is relative, the file is searched in a js/ subdir
in qutebrowser's data dir, e.g.
`~/.local/share/qutebrowser/js`.
+ url: Interpret js-code as a `javascript:...` URL.
quiet: Don't show resulting JS object.
world: Ignored on QtWebKit. On QtWebEngine, a world ID or name to
- run the snippet in.
+ run the snippet in. Predefined world names are:
+
+ - `main` (same world as the web page's JavaScript and
+ Greasemonkey, unless overridden via `@qute-js-world`)
+ - `application` (used for internal qutebrowser JS code,
+ should not be used via `:jseval` unless you know what
+ you're doing)
+ - `user` (currently unused)
+ - `jseval` (used for this command by default)
"""
+ cmdutils.check_exclusive((file, url), 'fu')
+
if world is None:
world = usertypes.JsWorld.jseval
-
- if quiet:
- jseval_cb = None
- else:
- def jseval_cb(out):
- """Show the data returned from JS."""
- if out is None:
- # Getting the actual error (if any) seems to be difficult.
- # The error does end up in
- # BrowserPage.javaScriptConsoleMessage(), but
- # distinguishing between :jseval errors and errors from the
- # webpage is not trivial...
- message.info('No output or error')
- else:
- # The output can be a string, number, dict, array, etc. But
- # *don't* output too much data, as this will make
- # qutebrowser hang
- out = str(out)
- if len(out) > 5000:
- out = out[:5000] + ' [...trimmed...]'
- message.info(out)
+ jseval_cb = None if quiet else self._jseval_cb
if file:
path = os.path.expanduser(js_code)
@@ -1639,6 +1690,11 @@ class CommandDispatcher:
js_code = f.read()
except OSError as e:
raise cmdutils.CommandError(str(e))
+ elif url:
+ try:
+ js_code = urlutils.parse_javascript_url(QUrl(js_code))
+ except urlutils.Error as e:
+ raise cmdutils.CommandError(str(e))
widget = self._current_widget()
try:
diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py
index d2991c40e..67df2e70d 100644
--- a/qutebrowser/browser/downloads.py
+++ b/qutebrowser/browser/downloads.py
@@ -28,7 +28,7 @@ import functools
import pathlib
import tempfile
import enum
-import typing
+from typing import Any, Dict, IO, List, MutableSequence, Optional, Union
from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QObject, QModelIndex,
QTimer, QAbstractListModel, QUrl)
@@ -49,7 +49,7 @@ class ModelRole(enum.IntEnum):
# Remember the last used directory
-last_used_directory = None # type: typing.Optional[str]
+last_used_directory: Optional[str] = None
# All REFRESH_INTERVAL milliseconds, speeds will be recalculated and downloads
# redrawn.
@@ -228,11 +228,12 @@ def suggested_fn_from_title(url_path, title=None):
ext_whitelist = [".html", ".htm", ".php", ""]
_, ext = os.path.splitext(url_path)
- suggested_fn = None # type: typing.Optional[str]
+ suggested_fn: Optional[str] = None
if ext.lower() in ext_whitelist and title:
- suggested_fn = utils.sanitize_filename(title)
+ suggested_fn = utils.sanitize_filename(title, shorten=True)
if not suggested_fn.lower().endswith((".html", ".htm")):
suggested_fn += ".html"
+ suggested_fn = utils.sanitize_filename(suggested_fn, shorten=True)
return suggested_fn
@@ -354,8 +355,7 @@ class DownloadItemStats(QObject):
self.speed = 0
self._last_done = 0
samples = int(self.SPEED_AVG_WINDOW * (1000 / _REFRESH_INTERVAL))
- self._speed_avg = collections.deque(
- maxlen=samples) # type: typing.MutableSequence[float]
+ self._speed_avg: MutableSequence[float] = collections.deque(maxlen=samples)
def update_speed(self):
"""Recalculate the current download speed.
@@ -426,6 +426,7 @@ class AbstractDownloadItem(QObject):
raw_headers: The headers sent by the server.
_filename: The filename of the download.
_dead: Whether the Download has _die()'d.
+ _manager: The DownloadManager which started this download.
Signals:
data_changed: The downloads metadata changed.
@@ -447,8 +448,9 @@ class AbstractDownloadItem(QObject):
remove_requested = pyqtSignal()
pdfjs_requested = pyqtSignal(str, QUrl)
- def __init__(self, parent=None):
+ def __init__(self, manager, parent=None):
super().__init__(parent)
+ self._manager = manager
self.done = False
self.stats = DownloadItemStats(self)
self.index = 0
@@ -456,12 +458,14 @@ class AbstractDownloadItem(QObject):
self.basename = '???'
self.successful = False
- self.fileobj = UnsupportedAttribute(
- ) # type: typing.Union[UnsupportedAttribute, typing.IO[bytes], None]
- self.raw_headers = UnsupportedAttribute(
- ) # type: typing.Union[UnsupportedAttribute, typing.Dict[bytes,bytes]]
+ self.fileobj: Union[
+ UnsupportedAttribute, IO[bytes], None
+ ] = UnsupportedAttribute()
+ self.raw_headers: Union[
+ UnsupportedAttribute, Dict[bytes, bytes]
+ ] = UnsupportedAttribute()
- self._filename = None # type: typing.Optional[str]
+ self._filename: Optional[str] = None
self._dead = False
def __repr__(self):
@@ -544,20 +548,18 @@ class AbstractDownloadItem(QObject):
position: The color type requested, can be 'fg' or 'bg'.
"""
assert position in ["fg", "bg"]
- # pylint: disable=bad-config-option
start = getattr(config.val.colors.downloads.start, position)
stop = getattr(config.val.colors.downloads.stop, position)
system = getattr(config.val.colors.downloads.system, position)
error = getattr(config.val.colors.downloads.error, position)
- # pylint: enable=bad-config-option
if self.error_msg is not None:
assert not self.successful
return error
elif self.stats.percentage() is None:
return start
else:
- return utils.interpolate_color(start, stop,
- self.stats.percentage(), system)
+ return qtutils.interpolate_color(
+ start, stop, self.stats.percentage(), system)
def _do_cancel(self):
"""Actual cancel implementation."""
@@ -570,6 +572,7 @@ class AbstractDownloadItem(QObject):
Args:
remove_data: Whether to remove the downloaded data.
"""
+ assert not self.done
self._do_cancel()
log.downloads.debug("cancelled")
if remove_data:
@@ -619,7 +622,7 @@ class AbstractDownloadItem(QObject):
raise NotImplementedError
@pyqtSlot()
- def open_file(self, cmdline=None):
+ def open_file(self, cmdline=None, open_dir=False):
"""Open the downloaded file.
Args:
@@ -627,12 +630,15 @@ class AbstractDownloadItem(QObject):
filename. None means to use the system's default
application or `downloads.open_dispatcher` if set. If no
`{}` is found, the filename is appended to the cmdline.
+ open_dir: Specify whether to open the file's directory instead.
"""
assert self.successful
filename = self._get_open_filename()
if filename is None: # pragma: no cover
log.downloads.error("No filename to open the download!")
return
+ if open_dir:
+ filename = os.path.dirname(filename)
# By using a singleshot timer, we ensure that we return fast. This
# is important on systems where process creation takes long, as
# otherwise the prompt might hang around and cause bugs
@@ -647,7 +653,7 @@ class AbstractDownloadItem(QObject):
"""Finish initialization based on self._filename."""
raise NotImplementedError
- def _ask_confirm_question(self, title, msg):
+ def _ask_confirm_question(self, title, msg, *, custom_yes_action=None):
"""Ask a confirmation question for the download."""
raise NotImplementedError
@@ -742,7 +748,13 @@ class AbstractDownloadItem(QObject):
last_used_directory = os.path.dirname(self._filename)
log.downloads.debug("Setting filename to {}".format(self._filename))
- if force_overwrite:
+ if self._get_conflicting_download():
+ txt = ("<b>{}</b> is already downloading. Cancel and "
+ "re-download?".format(html.escape(self._filename)))
+ self._ask_confirm_question(
+ "Cancel other download?", txt,
+ custom_yes_action=self._cancel_conflicting_download)
+ elif force_overwrite:
self._after_set_filename()
elif os.path.isfile(self._filename):
# The file already exists, so ask the user if it should be
@@ -759,6 +771,28 @@ class AbstractDownloadItem(QObject):
else:
self._after_set_filename()
+ def _conflicts_with(self, other: 'AbstractDownloadItem') -> bool:
+ """Check if this download conflicts with the other given one."""
+ return (
+ other is not self and
+ other._filename == self._filename and # pylint: disable=protected-access
+ not other.done
+ )
+
+ def _get_conflicting_download(self):
+ """Return another potential active download with the same name."""
+ for download in self._manager.downloads:
+ if self._conflicts_with(download):
+ return download
+ return None
+
+ def _cancel_conflicting_download(self):
+ """Cancel any conflicting download and call _after_set_filename."""
+ conflicting_download = self._get_conflicting_download()
+ if conflicting_download:
+ conflicting_download.cancel(remove_data=False)
+ self._after_set_filename()
+
def _open_if_successful(self, cmdline):
"""Open the downloaded file, but only if it was successful.
@@ -843,7 +877,7 @@ class AbstractDownloadManager(QObject):
def __init__(self, parent=None):
super().__init__(parent)
- self.downloads = [] # type: typing.List[AbstractDownloadItem]
+ self.downloads: List[AbstractDownloadItem] = []
self._update_timer = usertypes.Timer(self, 'download-update')
self._update_timer.timeout.connect(self._update_gui)
self._update_timer.setInterval(_REFRESH_INTERVAL)
@@ -860,7 +894,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')
@@ -943,6 +977,13 @@ class AbstractDownloadManager(QObject):
download.cancelled.connect(question.abort)
download.error.connect(question.abort)
+ @pyqtSlot()
+ def shutdown(self):
+ """Cancel all downloads when shutting down."""
+ for download in self.downloads:
+ if not download.done:
+ download.cancel(remove_data=False)
+
class DownloadModel(QAbstractListModel):
@@ -1093,7 +1134,8 @@ class DownloadModel(QAbstractListModel):
@cmdutils.register(instance='download-model', scope='window', maxsplit=0)
@cmdutils.argument('count', value=cmdutils.Value.count)
- def download_open(self, cmdline: str = None, count: int = 0) -> None:
+ def download_open(self, cmdline: str = None, count: int = 0,
+ dir_: bool = False) -> None:
"""Open the last/[count]th download.
If no specific command is given, this will use the system's default
@@ -1105,6 +1147,7 @@ class DownloadModel(QAbstractListModel):
present, the filename is automatically appended to the
cmdline.
count: The index of the download to open.
+ dir_: Whether to open the file's directory instead.
"""
try:
download = self[count - 1]
@@ -1115,7 +1158,7 @@ class DownloadModel(QAbstractListModel):
count = len(self)
raise cmdutils.CommandError("Download {} is not done!"
.format(count))
- download.open_file(cmdline)
+ download.open_file(cmdline, open_dir=dir_)
@cmdutils.register(instance='download-model', scope='window')
@cmdutils.argument('count', value=cmdutils.Value.count)
@@ -1209,7 +1252,7 @@ class DownloadModel(QAbstractListModel):
item = self[index.row()]
if role == Qt.DisplayRole:
- data = str(item) # type: typing.Any
+ data: Any = str(item)
elif role == Qt.ForegroundRole:
data = item.get_status_color('fg')
elif role == Qt.BackgroundRole:
@@ -1246,7 +1289,7 @@ class TempDownloadManager:
"""Manager to handle temporary download files.
- The downloads are downloaded to a temporary location and then openened with
+ The downloads are downloaded to a temporary location and then opened with
the system standard application. The temporary files are deleted when
qutebrowser is shutdown.
@@ -1255,7 +1298,7 @@ class TempDownloadManager:
"""
def __init__(self):
- self.files = [] # type: typing.MutableSequence[typing.IO[bytes]]
+ self.files: MutableSequence[IO[bytes]] = []
self._tmpdir = None
def cleanup(self):
diff --git a/qutebrowser/browser/downloadview.py b/qutebrowser/browser/downloadview.py
index b16b44218..0bb5d4d08 100644
--- a/qutebrowser/browser/downloadview.py
+++ b/qutebrowser/browser/downloadview.py
@@ -20,44 +20,20 @@
"""The ListView to display downloads in."""
import functools
-import typing
+from typing import Callable, MutableSequence, Tuple, Union
-from PyQt5.QtCore import pyqtSlot, QSize, Qt, QTimer
+from PyQt5.QtCore import pyqtSlot, QSize, Qt
from PyQt5.QtWidgets import QListView, QSizePolicy, QMenu, QStyleFactory
from qutebrowser.browser import downloads
from qutebrowser.config import stylesheet
from qutebrowser.utils import qtutils, utils
-from qutebrowser.qt import sip
-def update_geometry(obj):
- """Weird WORKAROUND for some weird PyQt bug (probably).
-
- This actually should be a method of DownloadView, but for some reason the
- rowsInserted/rowsRemoved signals don't get disconnected from this method
- when the DownloadView is deleted from Qt (e.g. by closing a window).
-
- Here we check if obj ("self") was deleted and just ignore the event if so.
-
- Original bug: https://github.com/qutebrowser/qutebrowser/issues/167
- Workaround bug: https://github.com/qutebrowser/qutebrowser/issues/171
- """
- def _update_geometry():
- """Actually update the geometry if the object still exists."""
- if sip.isdeleted(obj):
- return
- obj.updateGeometry()
-
- # If we don't use a singleShot QTimer, the geometry isn't updated correctly
- # and won't include the new item.
- QTimer.singleShot(0, _update_geometry)
-
-
-_ActionListType = typing.MutableSequence[
- typing.Union[
- typing.Tuple[None, None], # separator
- typing.Tuple[str, typing.Callable[[], None]],
+_ActionListType = MutableSequence[
+ Union[
+ Tuple[None, None], # separator
+ Tuple[str, Callable[[], None]],
]
]
@@ -93,9 +69,9 @@ class DownloadView(QListView):
self.setFlow(QListView.LeftToRight)
self.setSpacing(1)
self._menu = None
- model.rowsInserted.connect(functools.partial(update_geometry, self))
- model.rowsRemoved.connect(functools.partial(update_geometry, self))
- model.dataChanged.connect(functools.partial(update_geometry, self))
+ model.rowsInserted.connect(self._update_geometry)
+ model.rowsRemoved.connect(self._update_geometry)
+ model.dataChanged.connect(self._update_geometry)
self.setModel(model)
self.setWrapping(True)
self.setContextMenuPolicy(Qt.CustomContextMenu)
@@ -105,11 +81,20 @@ 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)
+ @pyqtSlot()
+ def _update_geometry(self):
+ """Wrapper to call updateGeometry.
+
+ For some reason, this is needed so that PyQt disconnects the signals and handles
+ arguments correctly. Probably a WORKAROUND for an unknown PyQt bug.
+ """
+ self.updateGeometry()
+
@pyqtSlot(bool)
def on_fullscreen_requested(self, on):
"""Hide/show the downloadview when entering/leaving fullscreen."""
@@ -132,19 +117,24 @@ 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:
item: The DownloadItem to get the actions for, or None.
"""
model = self.model()
- actions = [] # type: _ActionListType
+ actions: _ActionListType = []
if item is None:
pass
elif item.done:
if item.successful:
actions.append(("Open", item.open_file))
+ actions.append(("Open directory", functools.partial(
+ item.open_file, open_dir=True, cmdline=None)))
else:
actions.append(("Retry", item.try_retry))
actions.append(("Remove", item.remove))
diff --git a/qutebrowser/browser/eventfilter.py b/qutebrowser/browser/eventfilter.py
index c1e970c93..0195c73ad 100644
--- a/qutebrowser/browser/eventfilter.py
+++ b/qutebrowser/browser/eventfilter.py
@@ -22,7 +22,7 @@
from PyQt5.QtCore import QObject, QEvent, Qt, QTimer
from qutebrowser.config import config
-from qutebrowser.utils import message, log, usertypes, qtutils, objreg
+from qutebrowser.utils import message, log, usertypes, qtutils
from qutebrowser.misc import objects
from qutebrowser.keyinput import modeman
@@ -41,40 +41,23 @@ class ChildEventFilter(QObject):
_widget: The widget expected to send out childEvents.
"""
- def __init__(self, eventfilter, widget, win_id, parent=None):
+ def __init__(self, *, eventfilter, widget=None, parent=None):
super().__init__(parent)
self._filter = eventfilter
- assert widget is not None
self._widget = widget
- self._win_id = win_id
def eventFilter(self, obj, event):
"""Act on ChildAdded events."""
if event.type() == QEvent.ChildAdded:
child = event.child()
- log.misc.debug("{} got new child {}, installing filter".format(
- obj, child))
- assert obj is self._widget
- child.installEventFilter(self._filter)
+ log.misc.debug("{} got new child {}, installing filter"
+ .format(obj, child))
- if qtutils.version_check('5.11', compiled=False, exact=True):
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-68076
- pass_modes = [usertypes.KeyMode.command,
- usertypes.KeyMode.prompt,
- usertypes.KeyMode.yesno]
- if modeman.instance(self._win_id).mode not in pass_modes:
- tabbed_browser = objreg.get('tabbed-browser',
- scope='window',
- window=self._win_id)
- current_index = tabbed_browser.widget.currentIndex()
- try:
- widget_index = tabbed_browser.widget.indexOf(
- self._widget.parent())
- except RuntimeError:
- widget_index = -1
- if current_index == widget_index:
- QTimer.singleShot(0, self._widget.setFocus)
+ # Additional sanity check, but optional
+ if self._widget is not None:
+ assert obj is self._widget
+ child.installEventFilter(self._filter)
elif event.type() == QEvent.ChildRemoved:
child = event.child()
log.misc.debug("{}: removed child {}".format(obj, child))
@@ -101,7 +84,6 @@ class TabEventFilter(QObject):
QEvent.MouseButtonPress: self._handle_mouse_press,
QEvent.MouseButtonRelease: self._handle_mouse_release,
QEvent.Wheel: self._handle_wheel,
- QEvent.ContextMenu: self._handle_context_menu,
QEvent.KeyRelease: self._handle_key_release,
}
self._ignore_wheel_event = False
@@ -116,7 +98,7 @@ class TabEventFilter(QObject):
Return:
True if the event should be filtered, False otherwise.
"""
- is_rocker_gesture = (config.val.input.rocker_gestures and
+ 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:
@@ -162,8 +144,13 @@ class TabEventFilter(QObject):
# See https://github.com/qutebrowser/qutebrowser/issues/395
self._ignore_wheel_event = False
return True
+
+ # 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:
- mode = modeman.instance(self._tab.win_id).mode
if mode == usertypes.KeyMode.passthrough:
return False
@@ -180,27 +167,9 @@ class TabEventFilter(QObject):
message.info("Zoom level: {}%".format(perc), replace=True)
self._tab.zoom.set_factor(factor)
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:
- self._tab.scroller.right()
- return True
return False
- def _handle_context_menu(self, _e):
- """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.rocker_gestures
-
def _handle_key_release(self, e):
"""Ignore repeated key release events going to the website.
@@ -213,7 +182,6 @@ class TabEventFilter(QObject):
True if the event should be filtered, False otherwise.
"""
return (e.isAutoRepeat() and
- qtutils.version_check('5.10', compiled=False) and
not qtutils.version_check('5.14', compiled=False) and
objects.backend == usertypes.Backend.QtWebEngine)
@@ -271,6 +239,11 @@ class TabEventFilter(QObject):
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():
diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py
index 6d99a3568..9e25e49bd 100644
--- a/qutebrowser/browser/greasemonkey.py
+++ b/qutebrowser/browser/greasemonkey.py
@@ -26,25 +26,27 @@ import fnmatch
import functools
import glob
import textwrap
-import typing
+from typing import cast, List, Sequence
import attr
from PyQt5.QtCore import pyqtSignal, QObject, QUrl
from qutebrowser.utils import (log, standarddir, jinja, objreg, utils,
- javascript, urlmatch, version, usertypes,
- qtutils)
+ javascript, urlmatch, version, usertypes)
from qutebrowser.api import cmdutils
from qutebrowser.browser import downloads
from qutebrowser.misc import objects
-gm_manager = typing.cast('GreasemonkeyManager', None)
+gm_manager = cast('GreasemonkeyManager', None)
-def _scripts_dir():
+def _scripts_dirs():
"""Get the directory of the scripts."""
- return os.path.join(standarddir.data(), 'greasemonkey')
+ return [
+ os.path.join(standarddir.data(), 'greasemonkey'),
+ os.path.join(standarddir.config(), 'greasemonkey'),
+ ]
class GreasemonkeyScript:
@@ -54,10 +56,10 @@ class GreasemonkeyScript:
def __init__(self, properties, code, # noqa: C901 pragma: no mccabe
filename=None):
self._code = code
- self.includes = [] # type: typing.Sequence[str]
- self.matches = [] # type: typing.Sequence[str]
- self.excludes = [] # type: typing.Sequence[str]
- self.requires = [] # type: typing.Sequence[str]
+ self.includes: Sequence[str] = []
+ self.matches: Sequence[str] = []
+ self.excludes: Sequence[str] = []
+ self.requires: Sequence[str] = []
self.description = None
self.namespace = None
self.run_at = None
@@ -125,8 +127,7 @@ class GreasemonkeyScript:
def needs_document_end_workaround(self):
"""Check whether to force @run-at document-end.
- This needs to be done on QtWebEngine with Qt 5.12 for known-broken
- scripts.
+ This needs to be done on QtWebEngine (since Qt 5.12) for known-broken scripts.
On Qt 5.12, accessing the DOM isn't possible with "@run-at
document-start". It was documented to be impossible before, but seems
@@ -135,11 +136,11 @@ class GreasemonkeyScript:
However, some scripts do DOM access with "@run-at document-start". Fix
those by forcing them to use document-end instead.
"""
- if objects.backend != usertypes.Backend.QtWebEngine:
- return False
- elif not qtutils.version_check('5.12', compiled=False):
+ if objects.backend == usertypes.Backend.QtWebKit:
return False
+ assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend
+
broken_scripts = [
('http://userstyles.org', None),
('https://github.com/ParticleCore', 'Iridium'),
@@ -258,11 +259,10 @@ class GreasemonkeyManager(QObject):
def __init__(self, parent=None):
super().__init__(parent)
- self._run_start = [] # type: typing.List[GreasemonkeyScript]
- self._run_end = [] # type: typing.List[GreasemonkeyScript]
- self._run_idle = [] # type: typing.List[GreasemonkeyScript]
- self._in_progress_dls = [
- ] # type: typing.List[downloads.AbstractDownloadItem]
+ self._run_start: List[GreasemonkeyScript] = []
+ self._run_end: List[GreasemonkeyScript] = []
+ self._run_idle: List[GreasemonkeyScript] = []
+ self._in_progress_dls: List[downloads.AbstractDownloadItem] = []
self.load_scripts()
@@ -280,25 +280,26 @@ class GreasemonkeyManager(QObject):
self._run_end = []
self._run_idle = []
- scripts_dir = os.path.abspath(_scripts_dir())
- log.greasemonkey.debug("Reading scripts from: {}".format(scripts_dir))
- for script_filename in glob.glob(os.path.join(scripts_dir, '*.js')):
- if not os.path.isfile(script_filename):
- continue
- script_path = os.path.join(scripts_dir, script_filename)
- with open(script_path, encoding='utf-8-sig') as script_file:
- script = GreasemonkeyScript.parse(script_file.read(),
- script_filename)
- if not script.name:
- script.name = script_filename
- self.add_script(script, force)
+ for scripts_dir in _scripts_dirs():
+ scripts_dir = os.path.abspath(scripts_dir)
+ log.greasemonkey.debug("Reading scripts from: {}".format(scripts_dir))
+ for script_filename in glob.glob(os.path.join(scripts_dir, '*.js')):
+ if not os.path.isfile(script_filename):
+ continue
+ script_path = os.path.join(scripts_dir, script_filename)
+ with open(script_path, encoding='utf-8-sig') as script_file:
+ script = GreasemonkeyScript.parse(script_file.read(),
+ script_filename)
+ if not script.name:
+ script.name = script_filename
+ self.add_script(script, force)
self.scripts_reloaded.emit()
def add_script(self, script, force=False):
"""Add a GreasemonkeyScript to this manager.
Args:
- force: Fetch and overwrite any dependancies which are
+ force: Fetch and overwrite any dependencies which are
already locally cached.
"""
if script.requires:
@@ -328,7 +329,7 @@ class GreasemonkeyManager(QObject):
log.greasemonkey.debug("Loaded script: {}".format(script.name))
def _required_url_to_file_path(self, url):
- requires_dir = os.path.join(_scripts_dir(), 'requires')
+ requires_dir = os.path.join(_scripts_dirs()[0], 'requires')
if not os.path.exists(requires_dir):
os.mkdir(requires_dir)
return os.path.join(requires_dir, utils.sanitize_filename(url))
@@ -344,7 +345,7 @@ class GreasemonkeyManager(QObject):
def _add_script_with_requires(self, script, quiet=False):
"""Add a script with pending downloads to this GreasemonkeyManager.
- Specifically a script that has dependancies specified via an
+ Specifically a script that has dependencies specified via an
`@require` rule.
Args:
@@ -352,7 +353,7 @@ class GreasemonkeyManager(QObject):
quiet: True to suppress the scripts_reloaded signal after
adding `script`.
Returns: True if the script was added, False if there are still
- dependancies being downloaded.
+ dependencies being downloaded.
"""
# See if we are still waiting on any required scripts for this one
for dl in self._in_progress_dls:
@@ -429,7 +430,7 @@ def greasemonkey_reload(force=False):
"""Re-read Greasemonkey scripts from disk.
The scripts are read from a 'greasemonkey' subdirectory in
- qutebrowser's data directory (see `:version`).
+ qutebrowser's data or config directories (see `:version`).
Args:
force: For any scripts that have required dependencies,
@@ -443,7 +444,8 @@ def init():
global gm_manager
gm_manager = GreasemonkeyManager()
- try:
- os.mkdir(_scripts_dir())
- except FileExistsError:
- pass
+ for scripts_dir in _scripts_dirs():
+ try:
+ os.mkdir(scripts_dir)
+ except FileExistsError:
+ pass
diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py
index 08a850fc8..f914f3085 100644
--- a/qutebrowser/browser/hints.py
+++ b/qutebrowser/browser/hints.py
@@ -20,16 +20,17 @@
"""A HintManager to draw hints over links."""
import collections
-import typing
import functools
import os
import re
import html
import enum
from string import ascii_lowercase
+from typing import (TYPE_CHECKING, Callable, Dict, Iterable, Iterator, List, Mapping,
+ MutableSequence, Optional, Sequence, Set)
import attr
-from PyQt5.QtCore import pyqtSlot, QObject, Qt, QUrl
+from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, Qt, QUrl
from PyQt5.QtWidgets import QLabel
from qutebrowser.config import config, configexc
@@ -38,14 +39,30 @@ from qutebrowser.browser import webelem, history
from qutebrowser.commands import userscripts, runners
from qutebrowser.api import cmdutils
from qutebrowser.utils import usertypes, log, qtutils, message, objreg, utils
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.browser import browsertab
-Target = enum.Enum('Target', ['normal', 'current', 'tab', 'tab_fg', 'tab_bg',
- 'window', 'yank', 'yank_primary', 'run', 'fill',
- 'hover', 'download', 'userscript', 'spawn',
- 'delete', 'right_click'])
+class Target(enum.Enum):
+
+ """What action to take on a hint."""
+
+ normal = enum.auto()
+ current = enum.auto()
+ tab = enum.auto()
+ tab_fg = enum.auto()
+ tab_bg = enum.auto()
+ window = enum.auto()
+ yank = enum.auto()
+ yank_primary = enum.auto()
+ run = enum.auto()
+ fill = enum.auto()
+ hover = enum.auto()
+ download = enum.auto()
+ userscript = enum.auto()
+ spawn = enum.auto()
+ delete = enum.auto()
+ right_click = enum.auto()
class HintingError(Exception):
@@ -164,22 +181,22 @@ class HintContext:
group: The group of web elements to hint.
"""
- all_labels = attr.ib(attr.Factory(list)) # type: typing.List[HintLabel]
- labels = attr.ib(attr.Factory(dict)) # type: typing.Dict[str, HintLabel]
- target = attr.ib(None) # type: Target
- baseurl = attr.ib(None) # type: QUrl
- to_follow = attr.ib(None) # type: str
- rapid = attr.ib(False) # type: bool
- first_run = attr.ib(True) # type: bool
- add_history = attr.ib(False) # type: bool
- filterstr = attr.ib(None) # type: str
- args = attr.ib(attr.Factory(list)) # type: typing.List[str]
- tab = attr.ib(None) # type: browsertab.AbstractTab
- group = attr.ib(None) # type: str
- hint_mode = attr.ib(None) # type: str
- first = attr.ib(False) # type: bool
-
- def get_args(self, urlstr: str) -> typing.Sequence[str]:
+ all_labels: List[HintLabel] = attr.ib(attr.Factory(list))
+ labels: Dict[str, HintLabel] = attr.ib(attr.Factory(dict))
+ target: Target = attr.ib(None)
+ baseurl: QUrl = attr.ib(None)
+ to_follow: str = attr.ib(None)
+ rapid: bool = attr.ib(False)
+ first_run: bool = attr.ib(True)
+ add_history: bool = attr.ib(False)
+ filterstr: str = attr.ib(None)
+ args: List[str] = attr.ib(attr.Factory(list))
+ tab: 'browsertab.AbstractTab' = attr.ib(None)
+ group: str = attr.ib(None)
+ hint_mode: str = attr.ib(None)
+ first: bool = attr.ib(False)
+
+ def get_args(self, urlstr: str) -> Sequence[str]:
"""Get the arguments, with {hint-url} replaced by the given URL."""
args = []
for arg in self.args:
@@ -235,7 +252,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 +273,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 +326,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,14 +347,14 @@ 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))
-_ElemsType = typing.Sequence[webelem.AbstractWebElement]
-_HintStringsType = typing.MutableSequence[str]
+_ElemsType = Sequence[webelem.AbstractWebElement]
+_HintStringsType = MutableSequence[str]
class HintManager(QObject):
@@ -351,7 +370,7 @@ class HintManager(QObject):
_tab_id: The tab ID this HintManager is associated with.
Signals:
- See HintActions
+ set_text: Request for the statusbar to change its text.
"""
HINT_TEXTS = {
@@ -373,11 +392,13 @@ class HintManager(QObject):
Target.delete: "Delete an element",
}
+ set_text = pyqtSignal(str)
+
def __init__(self, win_id: int, parent: QObject = None) -> None:
"""Constructor."""
super().__init__(parent)
self._win_id = win_id
- self._context = None # type: typing.Optional[HintContext]
+ self._context: Optional[HintContext] = None
self._word_hinter = WordHinter()
self._actions = HintActions(win_id)
@@ -400,10 +421,8 @@ class HintManager(QObject):
for label in self._context.all_labels:
label.cleanup()
- text = self._get_text()
- message_bridge = objreg.get('message-bridge', scope='window',
- window=self._win_id)
- message_bridge.maybe_reset_text(text)
+ self.set_text.emit('')
+
self._context = None
def _hint_strings(self, elems: _ElemsType) -> _HintStringsType:
@@ -509,12 +528,10 @@ class HintManager(QObject):
Return:
A list of shuffled hint strings.
"""
- buckets = [
- [] for i in range(length)
- ] # type: typing.Sequence[_HintStringsType]
+ buckets: Sequence[_HintStringsType] = [[] for i in range(length)]
for i, hint in enumerate(hints):
buckets[i % len(buckets)].append(hint)
- result = [] # type: _HintStringsType
+ result: _HintStringsType = []
for bucket in buckets:
result += bucket
return result
@@ -539,7 +556,7 @@ class HintManager(QObject):
A hint string.
"""
base = len(chars)
- hintstr = [] # type: typing.MutableSequence[str]
+ hintstr: MutableSequence[str] = []
remainder = 0
while True:
remainder = number % base
@@ -631,12 +648,11 @@ class HintManager(QObject):
keyparser = self._get_keyparser(usertypes.KeyMode.hint)
keyparser.update_bindings(strings)
- message_bridge = objreg.get('message-bridge', scope='window',
- window=self._win_id)
- message_bridge.set_text(self._get_text())
modeman.enter(self._win_id, usertypes.KeyMode.hint,
'HintManager.start')
+ self.set_text.emit(self._get_text())
+
if self._context.first:
self._fire(strings[0])
return
@@ -768,7 +784,7 @@ class HintManager(QObject):
error_cb=lambda err: message.error(str(err)),
only_visible=True)
- def _get_hint_mode(self, mode: typing.Optional[str]) -> str:
+ def _get_hint_mode(self, mode: Optional[str]) -> str:
"""Get the hinting mode to use based on a mode argument."""
if mode is None:
return config.val.hints.mode
@@ -780,7 +796,7 @@ class HintManager(QObject):
raise cmdutils.CommandError("Invalid mode: {}".format(e))
return mode
- def current_mode(self) -> typing.Optional[str]:
+ def current_mode(self) -> Optional[str]:
"""Return the currently active hinting mode (or None otherwise)."""
if self._context is None:
return None
@@ -791,7 +807,7 @@ class HintManager(QObject):
self,
keystr: str = "",
filterstr: str = "",
- visible: typing.Mapping[str, HintLabel] = None
+ visible: Mapping[str, HintLabel] = None
) -> None:
"""Handle the auto_follow option."""
assert self._context is not None
@@ -853,7 +869,7 @@ class HintManager(QObject):
pass
self._handle_auto_follow(keystr=keystr)
- def filter_hints(self, filterstr: typing.Optional[str]) -> None:
+ def filter_hints(self, filterstr: Optional[str]) -> None:
"""Filter displayed hints according to a text.
Args:
@@ -893,7 +909,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)
@@ -1024,7 +1040,7 @@ class WordHinter:
def __init__(self) -> None:
# will be initialized on first use.
- self.words = set() # type: typing.Set[str]
+ self.words: Set[str] = set()
self.dictionary = None
def ensure_initialized(self) -> None:
@@ -1056,10 +1072,10 @@ class WordHinter:
def extract_tag_words(
self, elem: webelem.AbstractWebElement
- ) -> typing.Iterator[str]:
+ ) -> Iterator[str]:
"""Extract tag words form the given element."""
- _extractor_type = typing.Callable[[webelem.AbstractWebElement], str]
- attr_extractors = {
+ _extractor_type = Callable[[webelem.AbstractWebElement], str]
+ attr_extractors: Mapping[str, _extractor_type] = {
"alt": lambda elem: elem["alt"],
"name": lambda elem: elem["name"],
"title": lambda elem: elem["title"],
@@ -1067,7 +1083,7 @@ class WordHinter:
"src": lambda elem: elem["src"].split('/')[-1],
"href": lambda elem: elem["href"].split('/')[-1],
"text": str,
- } # type: typing.Mapping[str, _extractor_type]
+ }
extractable_attrs = collections.defaultdict(list, {
"img": ["alt", "title", "src"],
@@ -1083,8 +1099,8 @@ class WordHinter:
def tag_words_to_hints(
self,
- words: typing.Iterable[str]
- ) -> typing.Iterator[str]:
+ words: Iterable[str]
+ ) -> Iterator[str]:
"""Take words and transform them to proper hints if possible."""
for candidate in words:
if not candidate:
@@ -1095,20 +1111,20 @@ class WordHinter:
if 4 < match.end() - match.start() < 8:
yield candidate[match.start():match.end()].lower()
- def any_prefix(self, hint: str, existing: typing.Iterable[str]) -> bool:
+ def any_prefix(self, hint: str, existing: Iterable[str]) -> bool:
return any(hint.startswith(e) or e.startswith(hint) for e in existing)
def filter_prefixes(
self,
- hints: typing.Iterable[str],
- existing: typing.Iterable[str]
- ) -> typing.Iterator[str]:
+ hints: Iterable[str],
+ existing: Iterable[str]
+ ) -> Iterator[str]:
"""Filter hints which don't start with the given prefix."""
return (h for h in hints if not self.any_prefix(h, existing))
def new_hint_for(self, elem: webelem.AbstractWebElement,
- existing: typing.Iterable[str],
- fallback: typing.Iterable[str]) -> typing.Optional[str]:
+ existing: Iterable[str],
+ fallback: Iterable[str]) -> Optional[str]:
"""Return a hint for elem, not conflicting with the existing."""
new = self.tag_words_to_hints(self.extract_tag_words(elem))
new_no_prefixes = self.filter_prefixes(new, existing)
@@ -1132,7 +1148,7 @@ class WordHinter:
"""
self.ensure_initialized()
hints = []
- used_hints = set() # type: typing.Set[str]
+ used_hints: Set[str] = set()
words = iter(self.words)
for elem in elems:
hint = self.new_hint_for(elem, used_hints, words)
diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py
index d5165d364..cf944f184 100644
--- a/qutebrowser/browser/history.py
+++ b/qutebrowser/browser/history.py
@@ -22,7 +22,7 @@
import os
import time
import contextlib
-import typing
+from typing import cast, Mapping, MutableSequence
from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal
from PyQt5.QtWidgets import QProgressDialog, QApplication
@@ -35,7 +35,7 @@ from qutebrowser.misc import objects, sql
# increment to indicate that HistoryCompletion must be regenerated
_USER_VERSION = 2
-web_history = typing.cast('WebHistory', None)
+web_history = cast('WebHistory', None)
class HistoryProgress:
@@ -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()
@@ -79,7 +79,7 @@ class CompletionMetaInfo(sql.SqlTable):
"""Table containing meta-information for the completion."""
KEYS = {
- 'force_rebuild': False,
+ 'excluded_patterns': '',
}
def __init__(self, parent=None):
@@ -89,6 +89,9 @@ class CompletionMetaInfo(sql.SqlTable):
if key not in self:
self[key] = default
+ # force_rebuild is not in use anymore
+ self.delete('key', 'force_rebuild', optional=True)
+
def _check_key(self, key):
if key not in self.KEYS:
raise KeyError(key)
@@ -150,7 +153,7 @@ class WebHistory(sql.SqlTable):
'redirect': 'NOT NULL'},
parent=parent)
self._progress = progress
- # Store the last saved url to avoid duplicate immedate saves.
+ # Store the last saved url to avoid duplicate immediate saves.
self._last_url = None
self.completion = CompletionHistory(parent=self)
@@ -158,9 +161,14 @@ class WebHistory(sql.SqlTable):
if sql.Query('pragma user_version').run().value() < _USER_VERSION:
self.completion.delete_all()
- if self.metainfo['force_rebuild']:
+
+ # Get a string of all patterns
+ patterns = config.instance.get_str('completion.web_history.exclude')
+
+ # If patterns changed, update them in database and rebuild completion
+ if self.metainfo['excluded_patterns'] != patterns:
+ self.metainfo['excluded_patterns'] = patterns
self.completion.delete_all()
- self.metainfo['force_rebuild'] = False
if not self.completion:
# either the table is out-of-date or the user wiped it manually
@@ -183,18 +191,12 @@ class WebHistory(sql.SqlTable):
'ORDER BY atime desc '
'limit :limit offset :offset')
- config.instance.changed.connect(self._on_config_changed)
-
def __repr__(self):
return utils.get_repr(self, length=len(self))
def __contains__(self, url):
return self._contains_query.run(val=url).value()
- @config.change_filter('completion.web_history.exclude')
- def _on_config_changed(self):
- self.metainfo['force_rebuild'] = True
-
@contextlib.contextmanager
def _handle_sql_errors(self):
try:
@@ -208,11 +210,11 @@ class WebHistory(sql.SqlTable):
return any(pattern.matches(url) for pattern in patterns)
def _rebuild_completion(self):
- data = {
+ data: Mapping[str, MutableSequence[str]] = {
'url': [],
'title': [],
'last_atime': []
- } # type: typing.Mapping[str, typing.MutableSequence[str]]
+ }
# select the latest entry for each url
q = sql.Query('SELECT url, title, max(atime) AS atime FROM History '
'WHERE NOT redirect and url NOT LIKE "qute://back%" '
@@ -401,3 +403,5 @@ def init(parent=None):
if objects.backend == usertypes.Backend.QtWebKit: # pragma: no cover
from qutebrowser.browser.webkit import webkithistory
webkithistory.init(web_history)
+ return
+ assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend
diff --git a/qutebrowser/browser/inspector.py b/qutebrowser/browser/inspector.py
index 02e416f41..813f1ff12 100644
--- a/qutebrowser/browser/inspector.py
+++ b/qutebrowser/browser/inspector.py
@@ -21,54 +21,186 @@
import base64
import binascii
-import typing
+import enum
+from typing import cast, Optional
from PyQt5.QtWidgets import QWidget
+from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QEvent
+from PyQt5.QtGui import QCloseEvent
+from qutebrowser.browser import eventfilter
from qutebrowser.config import configfiles
-from qutebrowser.utils import log, usertypes
+from qutebrowser.utils import log, usertypes, utils
+from qutebrowser.keyinput import modeman
from qutebrowser.misc import miscwidgets, objects
-def create(parent=None):
+def create(*, splitter: 'miscwidgets.InspectorSplitter',
+ win_id: int,
+ parent: QWidget = None) -> 'AbstractWebInspector':
"""Get a WebKitInspector/WebEngineInspector.
Args:
+ splitter: InspectorSplitter where the inspector can be placed.
+ win_id: The window ID this inspector is associated with.
parent: The Qt parent to set.
"""
# Importing modules here so we don't depend on QtWebEngine without the
# argument and to avoid circular imports.
if objects.backend == usertypes.Backend.QtWebEngine:
from qutebrowser.browser.webengine import webengineinspector
- return webengineinspector.WebEngineInspector(parent)
- else:
+ return webengineinspector.WebEngineInspector(splitter, win_id, parent)
+ elif objects.backend == usertypes.Backend.QtWebKit:
from qutebrowser.browser.webkit import webkitinspector
- return webkitinspector.WebKitInspector(parent)
+ return webkitinspector.WebKitInspector(splitter, win_id, parent)
+ raise utils.Unreachable(objects.backend)
-class WebInspectorError(Exception):
+class Position(enum.Enum):
+
+ """Where the inspector is shown."""
+
+ right = enum.auto()
+ left = enum.auto()
+ top = enum.auto()
+ bottom = enum.auto()
+ window = enum.auto()
+
+
+class Error(Exception):
"""Raised when the inspector could not be initialized."""
+class _EventFilter(QObject):
+
+ """Event filter to enter insert mode when inspector was clicked.
+
+ We need to use this with a ChildEventFilter (rather than just overriding
+ mousePressEvent) for two reasons:
+
+ - For QtWebEngine, we need to listen for mouse events on its focusProxy(),
+ which can change when another page loads (which might be possible with an
+ inspector as well?)
+
+ - For QtWebKit, we need to listen for mouse events on the QWebView used by
+ the QWebInspector.
+ """
+
+ clicked = pyqtSignal()
+
+ def eventFilter(self, _obj: QObject, event: QEvent) -> bool:
+ """Translate mouse presses to a clicked signal."""
+ if event.type() == QEvent.MouseButtonPress:
+ self.clicked.emit()
+ return False
+
+
class AbstractWebInspector(QWidget):
- """A customized WebInspector which stores its geometry."""
+ """Base class for QtWebKit/QtWebEngine inspectors.
+
+ Attributes:
+ _position: position of the inspector (right/left/top/bottom/window)
+ _splitter: InspectorSplitter where the inspector can be placed.
+
+ Signals:
+ recreate: Emitted when the inspector should be recreated.
+ """
+
+ recreate = pyqtSignal()
- def __init__(self, parent=None):
+ def __init__(self, splitter: 'miscwidgets.InspectorSplitter',
+ win_id: int,
+ parent: QWidget = None) -> None:
super().__init__(parent)
- self._widget = typing.cast(QWidget, None)
+ self._widget = cast(QWidget, None)
self._layout = miscwidgets.WrapperLayout(self)
- self._load_state_geometry()
+ self._splitter = splitter
+ self._position: Optional[Position] = None
+ self._win_id = win_id
- def _set_widget(self, widget):
+ self._event_filter = _EventFilter(parent=self)
+ self._event_filter.clicked.connect(self._on_clicked)
+ self._child_event_filter = eventfilter.ChildEventFilter(
+ eventfilter=self._event_filter,
+ parent=self)
+
+ def _set_widget(self, widget: QWidget) -> None:
self._widget = widget
- self._layout.wrap(self, widget)
+ self._widget.setWindowTitle("Web Inspector")
+ self._widget.installEventFilter(self._child_event_filter)
+ self._layout.wrap(self, self._widget)
+
+ def _load_position(self) -> Position:
+ """Get the last position the inspector was in."""
+ pos = configfiles.state['inspector'].get('position', 'right')
+ return Position[pos]
+
+ def _save_position(self, position: Position) -> None:
+ """Save the last position the inspector was in."""
+ configfiles.state['inspector']['position'] = position.name
+
+ def _needs_recreate(self) -> bool:
+ """Whether the inspector needs recreation when detaching to a window.
+
+ This is done due to an unknown QtWebEngine bug which sometimes prevents
+ inspector windows from showing up.
+
+ Needs to be overridden by subclasses.
+ """
+ return False
+
+ @pyqtSlot()
+ def _on_clicked(self) -> None:
+ """Enter insert mode if a docked inspector was clicked."""
+ if self._position != Position.window:
+ modeman.enter(self._win_id, usertypes.KeyMode.insert,
+ reason='Inspector clicked', only_if_normal=True)
+
+ def set_position(self, position: Optional[Position]) -> None:
+ """Set the position of the inspector.
+
+ If the position is None, the last known position is used.
+ """
+ if position is None:
+ position = self._load_position()
+ else:
+ self._save_position(position)
+
+ if position == self._position:
+ self.toggle()
+ return
+
+ if (position == Position.window and
+ self._position is not None and
+ self._needs_recreate()):
+ # Detaching to window
+ self.recreate.emit()
+ self.shutdown()
+ return
+ elif position == Position.window:
+ self.setParent(None) # type: ignore[call-overload]
+ self._load_state_geometry()
+ else:
+ self._splitter.set_inspector(self, position)
+
+ self._position = position
- def _load_state_geometry(self):
+ self._widget.show()
+ self.show()
+
+ def toggle(self) -> None:
+ """Toggle visibility of the inspector."""
+ if self.isVisible():
+ self.hide()
+ else:
+ self.show()
+
+ def _load_state_geometry(self) -> None:
"""Load the geometry from the state file."""
try:
- data = configfiles.state['geometry']['inspector']
+ data = configfiles.state['inspector']['window']
geom = base64.b64decode(data, validate=True)
except KeyError:
# First start
@@ -77,27 +209,22 @@ class AbstractWebInspector(QWidget):
log.misc.exception("Error while reading geometry")
else:
log.init.debug("Loading geometry from {!r}".format(geom))
- ok = self.restoreGeometry(geom)
+ ok = self._widget.restoreGeometry(geom)
if not ok:
log.init.warning("Error while loading geometry.")
- def closeEvent(self, e):
+ def closeEvent(self, _e: QCloseEvent) -> None:
"""Save the geometry when closed."""
- data = bytes(self.saveGeometry())
+ data = self._widget.saveGeometry().data()
geom = base64.b64encode(data).decode('ASCII')
- configfiles.state['geometry']['inspector'] = geom
-
- self.inspect(None)
- super().closeEvent(e)
+ configfiles.state['inspector']['window'] = geom
- def inspect(self, page):
+ def inspect(self, page: QWidget) -> None:
"""Inspect the given QWeb(Engine)Page."""
raise NotImplementedError
- def toggle(self, page):
- """Show/hide the inspector."""
- if self._widget.isVisible():
- self.hide()
- else:
- self.inspect(page)
- self.show()
+ @pyqtSlot()
+ def shutdown(self) -> None:
+ """Clean up the inspector."""
+ self.close()
+ self.deleteLater()
diff --git a/qutebrowser/browser/navigate.py b/qutebrowser/browser/navigate.py
index db467aa56..f41c968c0 100644
--- a/qutebrowser/browser/navigate.py
+++ b/qutebrowser/browser/navigate.py
@@ -21,7 +21,7 @@
import re
import posixpath
-import typing
+from typing import Optional, Set
from PyQt5.QtCore import QUrl
@@ -97,9 +97,9 @@ def incdec(url, count, inc_or_dec):
window: Open the link in a new window.
"""
urlutils.ensure_valid(url)
- segments = (
+ segments: Optional[Set[str]] = (
set(config.val.url.incdec_segments)
- ) # type: typing.Optional[typing.Set[str]]
+ )
if segments is None:
segments = {'path', 'query'}
@@ -132,26 +132,41 @@ def path_up(url, count):
url: The current url.
count: The number of levels to go up in the url.
"""
- path = url.path()
+ urlutils.ensure_valid(url)
+ url = url.adjusted(QUrl.RemoveFragment | QUrl.RemoveQuery)
+ path = url.path(QUrl.FullyEncoded)
if not path or path == '/':
raise Error("Can't go up!")
for _i in range(0, min(count, path.count('/'))):
path = posixpath.join(path, posixpath.pardir)
path = posixpath.normpath(path)
- url.setPath(path)
+ url.setPath(path, QUrl.StrictMode)
return url
+def strip(url, count):
+ """Strip fragment/query from a URL."""
+ if count != 1:
+ raise Error("Count is not supported when stripping URL components")
+ urlutils.ensure_valid(url)
+ return url.adjusted(QUrl.RemoveFragment | QUrl.RemoveQuery)
+
+
def _find_prevnext(prev, elems):
"""Find a prev/next element in the given list of elements."""
- # First check for <link rel="prev(ious)|next">
+ # First check for <link rel="prev(ious)|next"> as well as
+ # e.g. <a class="nav-(prev|next)"> (Hugo)
rel_values = {'prev', 'previous'} if prev else {'next'}
+ classes = {'nav-prev'} if prev else {'nav-next'}
for e in elems:
- if e.tag_name() not in ['link', 'a'] or 'rel' not in e:
+ if e.tag_name() not in ['link', 'a']:
continue
- if set(e['rel'].split(' ')) & rel_values:
+ if 'rel' in e and set(e['rel'].split(' ')) & rel_values:
log.hints.debug("Found {!r} with rel={}".format(e, e['rel']))
return e
+ elif e.classes() & classes:
+ log.hints.debug("Found {!r} with class={}".format(e, e.classes()))
+ return e
# Then check for regular links/buttons.
elems = [e for e in elems if e.tag_name() != 'link']
@@ -159,11 +174,9 @@ def _find_prevnext(prev, elems):
if not elems:
return None
- # 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 +186,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..3b5686a03 100644
--- a/qutebrowser/browser/network/pac.py
+++ b/qutebrowser/browser/network/pac.py
@@ -21,7 +21,7 @@
import sys
import functools
-import typing
+from typing import Optional
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, QUrl
from PyQt5.QtNetwork import (QNetworkProxy, QNetworkRequest, QHostInfo,
@@ -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)
+ 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()])
@@ -249,8 +251,7 @@ class PACFetcher(QObject):
url.setScheme(url.scheme()[len(pac_prefix):])
self._pac_url = url
- self._manager = QNetworkAccessManager(
- ) # type: typing.Optional[QNetworkAccessManager]
+ self._manager: Optional[QNetworkAccessManager] = QNetworkAccessManager()
self._manager.setProxy(QNetworkProxy(QNetworkProxy.NoProxy))
self._pac = None
self._error_message = None
@@ -266,7 +267,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 160660f62..770c26aad 100644
--- a/qutebrowser/browser/network/proxy.py
+++ b/qutebrowser/browser/network/proxy.py
@@ -23,7 +23,7 @@ from PyQt5.QtCore import QUrl, pyqtSlot
from PyQt5.QtNetwork import QNetworkProxy, QNetworkProxyFactory
from qutebrowser.config import config, configtypes
-from qutebrowser.utils import message, usertypes, urlutils
+from qutebrowser.utils import message, usertypes, urlutils, utils
from qutebrowser.misc import objects
from qutebrowser.browser.network import pac
@@ -52,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):
@@ -71,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.
@@ -89,18 +102,15 @@ 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://'))]
- else:
+ proxy = urlutils.proxy_from_url(QUrl('direct://'))
+ assert not isinstance(proxy, pac.PACFetcher)
+ proxies = [proxy]
+ elif objects.backend == usertypes.Backend.QtWebKit:
proxies = proxy.resolve(query)
+ else:
+ raise utils.Unreachable(objects.backend)
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..f60061045 100644
--- a/qutebrowser/browser/pdfjs.py
+++ b/qutebrowser/browser/pdfjs.py
@@ -24,12 +24,23 @@ import os
from PyQt5.QtCore import QUrl, QUrlQuery
-from qutebrowser.utils import (utils, javascript, jinja, qtutils, usertypes,
- standarddir, log)
-from qutebrowser.misc import objects
+from qutebrowser.utils import utils, javascript, jinja, standarddir, log
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,33 +95,24 @@ 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') {
// v1.x
- {% if disable_create_object_url %}
- window.PDFJS.disableCreateObjectURL = true;
- {% endif %}
window.PDFJS.verbosity = window.PDFJS.VERBOSITY_LEVELS.info;
} else {
// v2.x
const options = window.PDFViewerApplicationOptions;
- {% if disable_create_object_url %}
- options.set('disableCreateObjectURL', true);
- {% endif %}
options.set('verbosity', pdfjsLib.VerbosityLevel.INFOS);
}
const viewer = window.PDFView || window.PDFViewerApplication;
viewer.open({{ url }});
});
- """).render(
- url=javascript.to_js(url.toString(QUrl.FullyEncoded)), # type: ignore
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-70420
- disable_create_object_url=(
- not qtutils.version_check('5.12') and
- not qtutils.version_check('5.7.1', exact=True, compiled=False) and
- objects.backend == usertypes.Backend.QtWebEngine))
+ """).render(url=js_url)
def get_pdfjs_res_and_path(path):
@@ -127,16 +129,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 +214,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 +237,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/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py
index 0b14ab50a..3aebbb6dd 100644
--- a/qutebrowser/browser/qtnetworkdownloads.py
+++ b/qutebrowser/browser/qtnetworkdownloads.py
@@ -23,14 +23,16 @@ import io
import os.path
import shutil
import functools
-import typing
+from typing import Dict, IO, Optional
import attr
from PyQt5.QtCore import pyqtSlot, pyqtSignal, QTimer, QUrl
+from PyQt5.QtWidgets import QApplication
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from qutebrowser.config import config, websettings
-from qutebrowser.utils import message, usertypes, log, urlutils, utils, debug
+from qutebrowser.utils import message, usertypes, log, urlutils, utils, debug, objreg
+from qutebrowser.misc import quitter
from qutebrowser.browser import downloads
from qutebrowser.browser.webkit import http
from qutebrowser.browser.webkit.network import networkmanager
@@ -70,7 +72,6 @@ class DownloadItem(downloads.AbstractDownloadItem):
target file.
_read_timer: A Timer which reads the QNetworkReply into self._buffer
periodically.
- _manager: The DownloadManager which started this download
_reply: The QNetworkReply associated with this download.
_autoclose: Whether to close the associated file when the download is
done.
@@ -90,12 +91,11 @@ class DownloadItem(downloads.AbstractDownloadItem):
Args:
reply: The QNetworkReply to download.
"""
- super().__init__(parent=manager)
- self.fileobj = None # type: typing.Optional[typing.IO[bytes]]
- self.raw_headers = {} # type: typing.Dict[bytes, bytes]
+ super().__init__(manager=manager, parent=manager)
+ self.fileobj: Optional[IO[bytes]] = None
+ self.raw_headers: Dict[bytes, bytes] = {}
self._autoclose = True
- self._manager = manager
self._retry_info = None
self._reply = None
self._buffer = io.BytesIO()
@@ -181,11 +181,16 @@ class DownloadItem(downloads.AbstractDownloadItem):
assert self.done
assert not self.successful
assert self._retry_info is not None
+
+ # Not calling self.cancel() here because the download is done (albeit
+ # unsuccessfully)
+ self.remove()
+ self.delete()
+
new_reply = self._retry_info.manager.get(self._retry_info.request)
new_download = self._manager.fetch(new_reply,
suggested_filename=self.basename)
self.adopt_download.emit(new_download)
- self.cancel()
def _get_open_filename(self):
filename = self._filename
@@ -206,11 +211,11 @@ class DownloadItem(downloads.AbstractDownloadItem):
def _after_set_filename(self):
self._create_fileobj()
- def _ask_confirm_question(self, title, msg):
+ def _ask_confirm_question(self, title, msg, *, custom_yes_action=None):
+ yes_action = custom_yes_action or self._after_set_filename
no_action = functools.partial(self.cancel, remove_data=False)
url = 'file://{}'.format(self._filename)
- message.confirm_async(title=title, text=msg,
- yes_action=self._after_set_filename,
+ message.confirm_async(title=title, text=msg, yes_action=yes_action,
no_action=no_action, cancel_action=no_action,
abort_on=[self.cancelled, self.error], url=url)
@@ -414,11 +419,12 @@ class DownloadManager(downloads.AbstractDownloadManager):
private=config.val.content.private_browsing, parent=self)
@pyqtSlot('QUrl')
- def get(self, url, **kwargs):
+ def get(self, url, cache=True, **kwargs):
"""Start a download with a link URL.
Args:
url: The URL to get, as QUrl
+ cache: If set to False, don't cache the response.
**kwargs: passed to get_request().
Return:
@@ -432,6 +438,9 @@ class DownloadManager(downloads.AbstractDownloadManager):
user_agent = websettings.user_agent(url)
req.setHeader(QNetworkRequest.UserAgentHeader, user_agent)
+ if not cache:
+ req.setAttribute(QNetworkRequest.CacheSaveControlAttribute, False)
+
return self.get_request(req, **kwargs)
def get_mhtml(self, tab, target):
@@ -456,6 +465,25 @@ class DownloadManager(downloads.AbstractDownloadManager):
mhtml.start_download_checked, tab=tab))
message.global_bridge.ask(question, blocking=False)
+ def _get_suggested_filename(self, request):
+ """Get the suggested filename for the given request."""
+ filename_url = request.url()
+ if request.url().scheme().lower() == 'data':
+ # We might be downloading a binary blob embedded on a page or even
+ # generated dynamically via javascript. If we happen to know where it's
+ # coming from, we can try to figure out a more sensible name than the base64
+ # content of the data.
+ origin = request.originatingObject()
+ try:
+ filename_url = origin.url()
+ except AttributeError:
+ # Raised either if origin is None or some object that doesn't
+ # have its own url. We're probably fine with a default fallback
+ # based on the data URL then.
+ pass
+
+ return urlutils.filename_from_url(filename_url, fallback='qutebrowser-download')
+
def get_request(self, request, *, target=None,
suggested_fn=None, **kwargs):
"""Start a download with a QNetworkRequest.
@@ -473,29 +501,8 @@ class DownloadManager(downloads.AbstractDownloadManager):
request.setAttribute(QNetworkRequest.CacheLoadControlAttribute,
QNetworkRequest.AlwaysNetwork)
- if suggested_fn is not None:
- pass
- elif request.url().scheme().lower() != 'data':
- suggested_fn = urlutils.filename_from_url(request.url())
- else:
- # We might be downloading a binary blob embedded on a page or even
- # generated dynamically via javascript. We try to figure out a more
- # sensible name than the base64 content of the data.
- origin = request.originatingObject()
- try:
- origin_url = origin.url()
- except AttributeError:
- # Raised either if origin is None or some object that doesn't
- # have its own url. We're probably fine with a default fallback
- # then.
- suggested_fn = 'binary blob'
- else:
- # Use the originating URL as a base for the filename (works
- # e.g. for pdf.js).
- suggested_fn = urlutils.filename_from_url(origin_url)
-
if suggested_fn is None:
- suggested_fn = 'qutebrowser-download'
+ suggested_fn = self._get_suggested_filename(request)
return self._fetch_request(request,
target=target,
@@ -578,3 +585,10 @@ class DownloadManager(downloads.AbstractDownloadManager):
if download._uses_nam(nam): # pylint: disable=protected-access
nam.adopt_download(download)
return nam.adopted_downloads
+
+
+def init():
+ """Initialize the global QtNetwork download manager."""
+ download_manager = DownloadManager(parent=QApplication.instance())
+ objreg.register('qtnetwork-download-manager', download_manager)
+ quitter.instance.shutting_down.connect(download_manager.shutdown)
diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py
index 3bc057796..cb4a9ba61 100644
--- a/qutebrowser/browser/qutescheme.py
+++ b/qutebrowser/browser/qutescheme.py
@@ -31,23 +31,16 @@ import time
import textwrap
import urllib
import collections
-import base64
-import typing
-from typing import TypeVar, Callable, Union, Tuple
+import secrets
+from typing import TypeVar, Callable, Dict, List, Optional, Union, Sequence, Tuple
-try:
- import secrets
-except ImportError:
- # New in Python 3.6
- secrets = None # type: ignore
-
-from PyQt5.QtCore import QUrlQuery, QUrl, qVersion
+from PyQt5.QtCore import QUrlQuery, QUrl
import qutebrowser
from qutebrowser.browser import pdfjs, downloads, history
-from qutebrowser.config import config, configdata, configexc, configdiff
+from qutebrowser.config import config, configdata, configexc
from qutebrowser.utils import (version, utils, jinja, log, message, docutils,
- objreg, urlutils)
+ objreg, standarddir)
from qutebrowser.qt import sip
@@ -98,8 +91,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
@@ -112,7 +105,7 @@ class add_handler: # noqa: N801,N806 pylint: disable=invalid-name
def __init__(self, name):
self._name = name
- self._function = None # type: typing.Optional[typing.Callable]
+ self._function: Optional[Callable] = None
def __call__(self, function: _Handler) -> _Handler:
self._function = function
@@ -125,7 +118,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) -> Tuple[str, bytes]:
"""Get the data to show for the given URL.
Args:
@@ -134,14 +127,15 @@ 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)
path = url.path()
host = url.host()
- query = urlutils.query_string(url)
+ query = url.query()
# A url like "qute:foo" is split as "scheme:path", not "scheme:host".
log.misc.debug("url: {}, path: {}, host {}".format(
url.toDisplayString(), path, host))
@@ -181,7 +175,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,10 +190,9 @@ 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]]]
+ tabs: Dict[str, List[Tuple[str, str]]] = collections.defaultdict(list)
for win_id, window in objreg.window_registry.items():
if sip.isdeleted(window):
continue
@@ -217,7 +210,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
+) -> Sequence[Dict[str, Union[str, int]]]:
"""Return history data.
Arguments:
@@ -240,7 +236,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 +262,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 +276,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 +294,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) -> Optional[str]:
"""Fall back to plaintext asciidoc if the HTML is unavailable."""
path = html_path.replace('.html', '.asciidoc')
try:
@@ -358,7 +357,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 +406,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 +428,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
@@ -442,12 +441,7 @@ def qute_settings(url):
# Requests to qute://settings/set should only be allowed from
# qute://settings. As an additional security precaution, we generate a CSRF
# token to use here.
- if secrets:
- csrf_token = secrets.token_urlsafe()
- else:
- # On Python < 3.6, from secrets.py
- token = base64.urlsafe_b64encode(os.urandom(32))
- csrf_token = token.rstrip(b'=').decode('ascii')
+ csrf_token = secrets.token_urlsafe()
src = jinja.render('settings.html', title='settings',
configdata=configdata,
@@ -457,7 +451,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 +469,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,22 +481,14 @@ 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:
- return 'text/html', configdiff.get_diff()
- except OSError as e:
- error = (b'Failed to read old config: ' +
- str(e.strerror).encode('utf-8'))
- return 'text/plain', error
- else:
- data = config.instance.dump_userconfig().encode('utf-8')
- return 'text/plain', data
+ data = config.instance.dump_userconfig().encode('utf-8')
+ return 'text/plain', data
@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 +501,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,16 +552,17 @@ 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':
- src = jinja.render('warning-old-qt.html',
- title='Old Qt warning',
- qt_version=qVersion())
- elif path == '/webkit':
+ if 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..26cdace56 100644
--- a/qutebrowser/browser/shared.py
+++ b/qutebrowser/browser/shared.py
@@ -22,7 +22,7 @@
import os
import html
import netrc
-import typing
+from typing import Callable, Mapping
from PyQt5.QtCore import QUrl
@@ -75,15 +75,14 @@ def authentication_required(url, authenticator, abort_on):
return answer
-def javascript_confirm(url, js_msg, abort_on, *, escape_msg=True):
+def javascript_confirm(url, js_msg, abort_on):
"""Display a javascript confirm prompt."""
log.js.debug("confirm: {}".format(js_msg))
if config.val.content.javascript.modal_dialog:
raise CallSuper
- js_msg = html.escape(js_msg) if escape_msg else js_msg
msg = 'From <b>{}</b>:<br/>{}'.format(html.escape(url.toDisplayString()),
- js_msg)
+ html.escape(js_msg))
urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded)
ans = message.ask('Javascript confirm', msg,
mode=usertypes.PromptMode.yesno,
@@ -91,7 +90,7 @@ def javascript_confirm(url, js_msg, abort_on, *, escape_msg=True):
return bool(ans)
-def javascript_prompt(url, js_msg, default, abort_on, *, escape_msg=True):
+def javascript_prompt(url, js_msg, default, abort_on):
"""Display a javascript prompt."""
log.js.debug("prompt: {}".format(js_msg))
if config.val.content.javascript.modal_dialog:
@@ -99,9 +98,8 @@ def javascript_prompt(url, js_msg, default, abort_on, *, escape_msg=True):
if not config.val.content.javascript.prompt:
return (False, "")
- js_msg = html.escape(js_msg) if escape_msg else js_msg
msg = '<b>{}</b> asks:<br/>{}'.format(html.escape(url.toDisplayString()),
- js_msg)
+ html.escape(js_msg))
urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded)
answer = message.ask('Javascript prompt', msg,
mode=usertypes.PromptMode.text,
@@ -114,7 +112,7 @@ def javascript_prompt(url, js_msg, default, abort_on, *, escape_msg=True):
return (True, answer)
-def javascript_alert(url, js_msg, abort_on, *, escape_msg=True):
+def javascript_alert(url, js_msg, abort_on):
"""Display a javascript alert."""
log.js.debug("alert: {}".format(js_msg))
if config.val.content.javascript.modal_dialog:
@@ -123,9 +121,8 @@ def javascript_alert(url, js_msg, abort_on, *, escape_msg=True):
if not config.val.content.javascript.alert:
return
- js_msg = html.escape(js_msg) if escape_msg else js_msg
msg = 'From <b>{}</b>:<br/>{}'.format(html.escape(url.toDisplayString()),
- js_msg)
+ html.escape(js_msg))
urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded)
message.ask('Javascript alert', msg, mode=usertypes.PromptMode.alert,
abort_on=abort_on, url=urlstr)
@@ -133,13 +130,13 @@ def javascript_alert(url, js_msg, abort_on, *, escape_msg=True):
# Needs to line up with the values allowed for the
# content.javascript.log setting.
-_JS_LOGMAP = {
+_JS_LOGMAP: Mapping[str, Callable[[str], None]] = {
'none': lambda arg: None,
'debug': log.js.debug,
'info': log.js.info,
'warning': log.js.warning,
'error': log.js.error,
-} # type: typing.Mapping[str, typing.Callable[[str], None]]
+}
def javascript_log_message(level, source, line, msg):
@@ -160,7 +157,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 +183,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 +282,11 @@ 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
+ if setting == 'overlay' and utils.is_mac:
+ 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/signalfilter.py b/qutebrowser/browser/signalfilter.py
index b70deb165..348a7a2ff 100644
--- a/qutebrowser/browser/signalfilter.py
+++ b/qutebrowser/browser/signalfilter.py
@@ -50,7 +50,7 @@ class SignalFilter(QObject):
"""Factory for partial _filter_signals functions.
Args:
- signal: The pyqtSignal to filter.
+ signal: The pyqtBoundSignal to filter.
tab: The WebView to create filters for.
Return:
diff --git a/qutebrowser/browser/urlmarks.py b/qutebrowser/browser/urlmarks.py
index 18fd15771..46d9d450d 100644
--- a/qutebrowser/browser/urlmarks.py
+++ b/qutebrowser/browser/urlmarks.py
@@ -30,7 +30,7 @@ import os.path
import html
import functools
import collections
-import typing
+from typing import MutableMapping
from PyQt5.QtCore import pyqtSignal, QUrl, QObject
@@ -78,8 +78,7 @@ class UrlMarkManager(QObject):
"""Initialize and read quickmarks."""
super().__init__(parent)
- self.marks = collections.OrderedDict(
- ) # type: typing.MutableMapping[str, str]
+ self.marks: MutableMapping[str, str] = collections.OrderedDict()
self._init_lineparser()
for line in self._lineparser:
diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py
index ba3c1e226..7a888daeb 100644
--- a/qutebrowser/browser/webelem.py
+++ b/qutebrowser/browser/webelem.py
@@ -19,7 +19,7 @@
"""Generic web element related code."""
-import typing
+from typing import cast, TYPE_CHECKING, Iterator, Optional, Set, Union
import collections.abc
from PyQt5.QtCore import QUrl, Qt, QEvent, QTimer, QRect, QPoint
@@ -29,11 +29,11 @@ from qutebrowser.config import config
from qutebrowser.keyinput import modeman
from qutebrowser.utils import log, usertypes, utils, qtutils, objreg
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.browser import browsertab
-JsValueType = typing.Union[int, float, str, None]
+JsValueType = Union[int, float, str, None]
class Error(Exception):
@@ -80,7 +80,7 @@ class AbstractWebElement(collections.abc.MutableMapping):
def __delitem__(self, key: str) -> None:
raise NotImplementedError
- def __iter__(self) -> typing.Iterator[str]:
+ def __iter__(self) -> Iterator[str]:
raise NotImplementedError
def __len__(self) -> int:
@@ -88,8 +88,7 @@ class AbstractWebElement(collections.abc.MutableMapping):
def __repr__(self) -> str:
try:
- html = utils.compact_text(
- self.outer_xml(), 500) # type: typing.Optional[str]
+ html: Optional[str] = utils.compact_text(self.outer_xml(), 500)
except Error:
html = None
return utils.get_repr(self, html=html)
@@ -102,8 +101,8 @@ class AbstractWebElement(collections.abc.MutableMapping):
"""Get the geometry for this element."""
raise NotImplementedError
- def classes(self) -> typing.List[str]:
- """Get a list of classes assigned to this element."""
+ def classes(self) -> Set[str]:
+ """Get a set of classes assigned to this element."""
raise NotImplementedError
def tag_name(self) -> str:
@@ -172,6 +171,18 @@ class AbstractWebElement(collections.abc.MutableMapping):
except KeyError:
return False
+ def is_content_editable_prop(self) -> bool:
+ """Get the value of this element's isContentEditable property.
+
+ The is_content_editable() method above checks for the "contenteditable"
+ HTML attribute, which does not handle inheritance. However, the actual
+ attribute value is still needed for certain cases (like strict=True).
+
+ This instead gets the isContentEditable JS property, which handles
+ inheritance.
+ """
+ raise NotImplementedError
+
def _is_editable_object(self) -> bool:
"""Check if an object-element is editable."""
if 'type' not in self:
@@ -251,6 +262,9 @@ class AbstractWebElement(collections.abc.MutableMapping):
elif tag in ['embed', 'applet']:
# Flash/Java/...
return config.val.input.insert_mode.plugins and not strict
+ elif (not strict and self.is_content_editable_prop() and
+ self.is_writable()):
+ return True
elif tag == 'object':
return self._is_editable_object() and not strict
elif tag in ['div', 'pre', 'span']:
@@ -267,7 +281,7 @@ class AbstractWebElement(collections.abc.MutableMapping):
"""Remove target from link."""
raise NotImplementedError
- def resolve_url(self, baseurl: QUrl) -> typing.Optional[QUrl]:
+ def resolve_url(self, baseurl: QUrl) -> Optional[QUrl]:
"""Resolve the URL in the element's src/href attribute.
Args:
@@ -342,16 +356,12 @@ class AbstractWebElement(collections.abc.MutableMapping):
else:
target_modifiers[usertypes.ClickTarget.tab_bg] |= Qt.ShiftModifier
- modifiers = typing.cast(Qt.KeyboardModifiers,
- target_modifiers[click_target])
+ modifiers = cast(Qt.KeyboardModifiers, target_modifiers[click_target])
events = [
- QMouseEvent(QEvent.MouseMove, pos, Qt.NoButton, Qt.NoButton,
- Qt.NoModifier),
- QMouseEvent(QEvent.MouseButtonPress, pos, button, button,
- modifiers),
- QMouseEvent(QEvent.MouseButtonRelease, pos, button, Qt.NoButton,
- modifiers),
+ QMouseEvent(QEvent.MouseMove, pos, Qt.NoButton, Qt.NoButton, Qt.NoModifier),
+ QMouseEvent(QEvent.MouseButtonPress, pos, button, button, modifiers),
+ QMouseEvent(QEvent.MouseButtonRelease, pos, button, Qt.NoButton, modifiers),
]
for evt in events:
@@ -390,9 +400,7 @@ class AbstractWebElement(collections.abc.MutableMapping):
from qutebrowser.mainwindow import mainwindow
window = mainwindow.MainWindow(private=tabbed_browser.is_private)
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)
else:
raise ValueError("Unknown ClickTarget {}".format(click_target))
diff --git a/qutebrowser/browser/webengine/cookies.py b/qutebrowser/browser/webengine/cookies.py
index af743c3d1..a5d92ef74 100644
--- a/qutebrowser/browser/webengine/cookies.py
+++ b/qutebrowser/browser/webengine/cookies.py
@@ -20,22 +20,34 @@
"""Filter for QtWebEngine cookies."""
from qutebrowser.config import config
-from qutebrowser.utils import utils, qtutils
+from qutebrowser.utils import utils, 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
+
+ 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, request.thirdParty,
+ 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
+ return not request.thirdParty
elif accept == 'never':
return False
else:
@@ -43,12 +55,5 @@ def _accept_cookie(request):
def install_filter(profile):
- """Install the cookie filter on the given profile.
-
- On Qt < 5.11, the filter isn't installed.
- """
- store = profile.cookieStore()
- try:
- store.setCookieFilter(_accept_cookie)
- except AttributeError:
- pass
+ """Install the cookie filter on the given profile."""
+ profile.cookieStore().setCookieFilter(_accept_cookie)
diff --git a/qutebrowser/browser/webengine/darkmode.py b/qutebrowser/browser/webengine/darkmode.py
new file mode 100644
index 000000000..c9d69d52d
--- /dev/null
+++ b/qutebrowser/browser/webengine/darkmode.py
@@ -0,0 +1,305 @@
+# 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/>.
+
+"""Get darkmode arguments to pass to Qt.
+
+Overview of blink setting names based on the Qt version:
+
+Qt 5.10
+-------
+
+First implementation, called "high contrast mode".
+
+- highContrastMode (kOff/kSimpleInvertForTesting/kInvertBrightness/kInvertLightness)
+- highContrastGrayscale (bool)
+- highContrastContrast (float)
+- highContractImagePolicy (kFilterAll/kFilterNone)
+
+Qt 5.11, 5.12, 5.13
+-------------------
+
+New "smart" image policy.
+
+- Mode/Grayscale/Contrast as above
+- highContractImagePolicy (kFilterAll/kFilterNone/kFilterSmart [new!])
+
+Qt 5.14
+-------
+
+Renamed to "darkMode".
+
+- darkMode (kOff/kSimpleInvertForTesting/kInvertBrightness/kInvertLightness/
+ kInvertLightnessLAB [new!])
+- darkModeGrayscale (bool)
+- darkModeContrast (float)
+- darkModeImagePolicy (kFilterAll/kFilterNone/kFilterSmart)
+- darkModePagePolicy (kFilterAll/kFilterByBackground) [new!]
+- darkModeTextBrightnessThreshold (int) [new!]
+- darkModeBackgroundBrightnessThreshold (int) [new!]
+- darkModeImageGrayscale (float) [new!]
+
+Qt 5.15.0 and 5.15.1
+--------------------
+
+"darkMode" split into "darkModeEnabled" and "darkModeInversionAlgorithm".
+
+- darkModeEnabled (bool) [new!]
+- darkModeInversionAlgorithm (kSimpleInvertForTesting/kInvertBrightness/
+ kInvertLightness/kInvertLightnessLAB)
+- Rest (except darkMode) as above.
+- NOTE: smart image policy is broken with Qt 5.15.0!
+
+Qt 5.15.2
+---------
+
+Prefix changed to "forceDarkMode".
+
+- As with Qt 5.15.0 / .1, but with "forceDarkMode" as prefix.
+"""
+
+import os
+import enum
+from typing import Any, Iterable, Iterator, Mapping, Optional, Set, Tuple, Union
+
+try:
+ from PyQt5.QtWebEngine import PYQT_WEBENGINE_VERSION
+except ImportError: # pragma: no cover
+ # Added in PyQt 5.13
+ PYQT_WEBENGINE_VERSION = None # type: ignore[assignment]
+
+from qutebrowser.config import config
+from qutebrowser.utils import usertypes, qtutils, utils, log
+
+
+class Variant(enum.Enum):
+
+ """A dark mode variant."""
+
+ qt_511_to_513 = enum.auto()
+ qt_514 = enum.auto()
+ qt_515_0 = enum.auto()
+ qt_515_1 = enum.auto()
+ qt_515_2 = enum.auto()
+
+
+# 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
+}
+# kInvertLightnessLAB is not available with Qt < 5.14
+_ALGORITHMS_BEFORE_QT_514 = _ALGORITHMS.copy()
+_ALGORITHMS_BEFORE_QT_514['lightness-cielab'] = _ALGORITHMS['lightness-hsl']
+
+# 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',
+}
+
+_DarkModeSettingsType = Iterable[
+ Tuple[
+ str, # qutebrowser option name
+ str, # darkmode setting name
+ # Mapping from the config value to a string (or something convertible
+ # to a string) which gets passed to Chromium.
+ Optional[Mapping[Any, Union[str, int]]],
+ ],
+]
+
+_DarkModeDefinitionType = Tuple[_DarkModeSettingsType, Set[str]]
+
+_QT_514_SETTINGS = [
+ ('policy.images', 'darkModeImagePolicy', _IMAGE_POLICIES),
+ ('contrast', 'darkModeContrast', None),
+ ('grayscale.all', 'darkModeGrayscale', _BOOLS),
+
+ ('policy.page', 'darkModePagePolicy', _PAGE_POLICIES),
+ ('threshold.text', 'darkModeTextBrightnessThreshold', None),
+ ('threshold.background', 'darkModeBackgroundBrightnessThreshold', None),
+ ('grayscale.images', 'darkModeImageGrayscale', None),
+]
+
+# Our defaults for policy.images are different from Chromium's, so we mark it as
+# mandatory setting - except on Qt 5.15.0 where we don't, so we don't get the
+# workaround warning below if the setting wasn't explicitly customized.
+
+_DARK_MODE_DEFINITIONS: Mapping[Variant, _DarkModeDefinitionType] = {
+ Variant.qt_515_2: ([
+ # 'darkMode' renamed to 'forceDarkMode'
+ ('enabled', 'forceDarkModeEnabled', _BOOLS),
+ ('algorithm', 'forceDarkModeInversionAlgorithm', _ALGORITHMS),
+
+ ('policy.images', 'forceDarkModeImagePolicy', _IMAGE_POLICIES),
+ ('contrast', 'forceDarkModeContrast', None),
+ ('grayscale.all', 'forceDarkModeGrayscale', _BOOLS),
+
+ ('policy.page', 'forceDarkModePagePolicy', _PAGE_POLICIES),
+ ('threshold.text', 'forceDarkModeTextBrightnessThreshold', None),
+ (
+ 'threshold.background',
+ 'forceDarkModeBackgroundBrightnessThreshold',
+ None
+ ),
+ ('grayscale.images', 'forceDarkModeImageGrayscale', None),
+ ], {'enabled', 'policy.images'}),
+
+ Variant.qt_515_1: ([
+ # 'policy.images' mandatory again
+ ('enabled', 'darkModeEnabled', _BOOLS),
+ ('algorithm', 'darkModeInversionAlgorithm', _ALGORITHMS),
+
+ ('policy.images', 'darkModeImagePolicy', _IMAGE_POLICIES),
+ ('contrast', 'darkModeContrast', None),
+ ('grayscale.all', 'darkModeGrayscale', _BOOLS),
+
+ ('policy.page', 'darkModePagePolicy', _PAGE_POLICIES),
+ ('threshold.text', 'darkModeTextBrightnessThreshold', None),
+ ('threshold.background', 'darkModeBackgroundBrightnessThreshold', None),
+ ('grayscale.images', 'darkModeImageGrayscale', None),
+ ], {'enabled', 'policy.images'}),
+
+ Variant.qt_515_0: ([
+ # 'policy.images' not mandatory because it's broken
+ ('enabled', 'darkModeEnabled', _BOOLS),
+ ('algorithm', 'darkModeInversionAlgorithm', _ALGORITHMS),
+
+ ('policy.images', 'darkModeImagePolicy', _IMAGE_POLICIES),
+ ('contrast', 'darkModeContrast', None),
+ ('grayscale.all', 'darkModeGrayscale', _BOOLS),
+
+ ('policy.page', 'darkModePagePolicy', _PAGE_POLICIES),
+ ('threshold.text', 'darkModeTextBrightnessThreshold', None),
+ ('threshold.background', 'darkModeBackgroundBrightnessThreshold', None),
+ ('grayscale.images', 'darkModeImageGrayscale', None),
+ ], {'enabled'}),
+
+ Variant.qt_514: ([
+ ('algorithm', 'darkMode', _ALGORITHMS), # new: kInvertLightnessLAB
+
+ ('policy.images', 'darkModeImagePolicy', _IMAGE_POLICIES),
+ ('contrast', 'darkModeContrast', None),
+ ('grayscale.all', 'darkModeGrayscale', _BOOLS),
+
+ ('policy.page', 'darkModePagePolicy', _PAGE_POLICIES),
+ ('threshold.text', 'darkModeTextBrightnessThreshold', None),
+ ('threshold.background', 'darkModeBackgroundBrightnessThreshold', None),
+ ('grayscale.images', 'darkModeImageGrayscale', None),
+ ], {'algorithm', 'policy.images'}),
+
+ Variant.qt_511_to_513: ([
+ ('algorithm', 'highContrastMode', _ALGORITHMS_BEFORE_QT_514),
+
+ ('policy.images', 'highContrastImagePolicy', _IMAGE_POLICIES), # new: smart
+ ('contrast', 'highContrastContrast', None),
+ ('grayscale.all', 'highContrastGrayscale', _BOOLS),
+ ], {'algorithm', 'policy.images'}),
+}
+
+
+def _variant() -> Variant:
+ """Get the dark mode variant based on the underlying Qt version."""
+ env_var = os.environ.get('QUTE_DARKMODE_VARIANT')
+ if env_var is not None:
+ try:
+ return Variant[env_var]
+ except KeyError:
+ log.init.warning(f"Ignoring invalid QUTE_DARKMODE_VARIANT={env_var}")
+
+ if PYQT_WEBENGINE_VERSION is not None:
+ # Available with Qt >= 5.13
+ if PYQT_WEBENGINE_VERSION >= 0x050f02:
+ return Variant.qt_515_2
+ elif PYQT_WEBENGINE_VERSION == 0x050f01:
+ return Variant.qt_515_1
+ elif PYQT_WEBENGINE_VERSION == 0x050f00:
+ return Variant.qt_515_0
+ elif PYQT_WEBENGINE_VERSION >= 0x050e00:
+ return Variant.qt_514
+ elif PYQT_WEBENGINE_VERSION >= 0x050d00:
+ return Variant.qt_511_to_513
+ raise utils.Unreachable(hex(PYQT_WEBENGINE_VERSION))
+
+ # If we don't have PYQT_WEBENGINE_VERSION, we're on 5.12 (or older, but 5.12 is the
+ # oldest supported version).
+ assert not qtutils.version_check( # type: ignore[unreachable]
+ '5.13', compiled=False)
+
+ return Variant.qt_511_to_513
+
+
+def settings() -> Iterator[Tuple[str, str]]:
+ """Get necessary blink settings to configure dark mode for QtWebEngine."""
+ if (qtutils.version_check('5.15.2', compiled=False) and
+ config.val.colors.webpage.prefers_color_scheme_dark):
+ # With older Qt versions, this is passed in qtargs.py as --force-dark-mode
+ # instead.
+ #
+ # With Chromium 85 (> Qt 5.15.2), the enumeration has changed in Blink and this
+ # will need to be set to '0' instead:
+ # https://chromium-review.googlesource.com/c/chromium/src/+/2232922
+ yield "preferredColorScheme", "1"
+
+ if not config.val.colors.webpage.darkmode.enabled:
+ return
+
+ variant = _variant()
+ setting_defs, mandatory_settings = _DARK_MODE_DEFINITIONS[variant]
+
+ for setting, key, mapping in setting_defs:
+ # 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 in mandatory_settings)
+ if isinstance(value, usertypes.Unset):
+ continue
+
+ if (setting == 'policy.images' and value == 'smart' and
+ variant == Variant.qt_515_0):
+ # WORKAROUND for
+ # https://codereview.qt-project.org/c/qt/qtwebengine-chromium/+/304211
+ log.init.warning("Ignoring colors.webpage.darkmode.policy.images = smart "
+ "because of Qt 5.15.0 bug")
+ continue
+
+ if mapping is not None:
+ value = mapping[value]
+
+ yield key, str(value)
diff --git a/qutebrowser/browser/webengine/interceptor.py b/qutebrowser/browser/webengine/interceptor.py
index 7d455d4c3..8804bea6e 100644
--- a/qutebrowser/browser/webengine/interceptor.py
+++ b/qutebrowser/browser/webengine/interceptor.py
@@ -25,7 +25,7 @@ from PyQt5.QtCore import QUrl, QByteArray
from PyQt5.QtWebEngineCore import (QWebEngineUrlRequestInterceptor,
QWebEngineUrlRequestInfo)
-from qutebrowser.config import websettings
+from qutebrowser.config import websettings, config
from qutebrowser.browser import shared
from qutebrowser.utils import utils, log, debug, qtutils
from qutebrowser.extensions import interceptors
@@ -39,22 +39,20 @@ class WebEngineRequest(interceptors.Request):
_WHITELISTED_REQUEST_METHODS = {QByteArray(b'GET'), QByteArray(b'HEAD')}
- _webengine_info = attr.ib(default=None) # type: QWebEngineUrlRequestInfo
+ _webengine_info: QWebEngineUrlRequestInfo = attr.ib(default=None)
#: If this request has been redirected already
- _redirected = attr.ib(init=False, default=False) # type: bool
+ _redirected: bool = attr.ib(init=False, default=False)
def redirect(self, url: QUrl) -> None:
if self._redirected:
- raise interceptors.RedirectFailedException(
- "Request already redirected.")
+ raise interceptors.RedirectException("Request already redirected.")
if self._webengine_info is None:
- raise interceptors.RedirectFailedException(
- "Request improperly initialized.")
+ raise interceptors.RedirectException("Request improperly initialized.")
# Redirecting a request that contains payload data is not allowed.
# To be safe, abort on any request not in a whitelist.
if (self._webengine_info.requestMethod()
not in self._WHITELISTED_REQUEST_METHODS):
- raise interceptors.RedirectFailedException(
+ raise interceptors.RedirectException(
"Request method does not support redirection.")
self._webengine_info.redirect(url)
self._redirected = True
@@ -129,7 +127,7 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor):
# Qt >= 5.13, GUI thread
profile.setUrlRequestInterceptor(self)
except AttributeError:
- # Qt <= 5.12, IO thread
+ # Qt 5.12, IO thread
profile.setRequestInterceptor(self)
# Gets called in the IO thread -> showing crash window will fail
@@ -154,7 +152,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 +162,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,18 +171,18 @@ 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())))
resource_type = interceptors.ResourceType.unknown
+ is_xhr = info.resourceType() == QWebEngineUrlRequestInfo.ResourceTypeXhr
+
if ((url.scheme(), url.host(), url.path()) ==
('qute', 'settings', '/set')):
- if (first_party != QUrl('qute://settings/') or
- info.resourceType() !=
- QWebEngineUrlRequestInfo.ResourceTypeXhr):
- log.webview.warning("Blocking malicious request from {} to {}"
+ if first_party != QUrl('qute://settings/') or not is_xhr:
+ log.network.warning("Blocking malicious request from {} to {}"
.format(first_party.toDisplayString(),
url.toDisplayString()))
info.block(True)
@@ -202,7 +200,21 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor):
info.block(True)
for header, value in shared.custom_headers(url=url):
+ if header.lower() == b'accept' and is_xhr:
+ # https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/setRequestHeader
+ # says: "If no Accept header has been set using this, an Accept header
+ # with the type "*/*" is sent with the request when send() is called."
+ #
+ # We shouldn't break that if someone sets a custom Accept header for
+ # normal requests.
+ continue
info.setHttpHeader(header, value)
+ # Note this is ignored before Qt 5.12.4 and 5.13.1 due to
+ # https://bugreports.qt.io/browse/QTBUG-60203 - there, we set the
+ # commandline-flag in qtargs.py instead.
+ if config.cache['content.headers.referer'] == 'never':
+ info.setHttpHeader(b'Referer', b'')
+
user_agent = websettings.user_agent(url)
info.setHttpHeader(b'User-Agent', user_agent.encode('ascii'))
diff --git a/qutebrowser/browser/webengine/spell.py b/qutebrowser/browser/webengine/spell.py
index 76f1c86a6..417eae9aa 100644
--- a/qutebrowser/browser/webengine/spell.py
+++ b/qutebrowser/browser/webengine/spell.py
@@ -24,23 +24,12 @@ import glob
import os
import os.path
import re
-import shutil
-from PyQt5.QtCore import QLibraryInfo
-from qutebrowser.utils import log, message, standarddir, qtutils
+from qutebrowser.utils import log, message, standarddir
_DICT_VERSION_RE = re.compile(r".+-(?P<version>[0-9]+-[0-9]+?)\.bdic")
-def can_use_data_path():
- """Whether the current Qt version can use a customized path.
-
- Qt >= 5.10 understands QTWEBENGINE_DICTIONARIES_PATH which means we don't
- need to put them to a fixed root-only folder.
- """
- return qtutils.version_check('5.10', compiled=False)
-
-
def version(filename):
"""Extract the version number from the dictionary file name."""
match = _DICT_VERSION_RE.fullmatch(filename)
@@ -51,13 +40,9 @@ def version(filename):
return tuple(int(n) for n in match.group('version').split('-'))
-def dictionary_dir(old=False):
+def dictionary_dir():
"""Return the path (str) to the QtWebEngine's dictionaries directory."""
- if can_use_data_path() and not old:
- datapath = standarddir.data()
- else:
- datapath = QLibraryInfo.location(QLibraryInfo.DataPath)
- return os.path.join(datapath, 'qtwebengine_dictionaries')
+ return os.path.join(standarddir.data(), 'qtwebengine_dictionaries')
def local_files(code):
@@ -91,13 +76,6 @@ def local_filename(code):
def init():
- """Initialize the dictionary path if supported."""
- if can_use_data_path():
- new_dir = dictionary_dir()
- old_dir = dictionary_dir(old=True)
- os.environ['QTWEBENGINE_DICTIONARIES_PATH'] = new_dir
- try:
- if os.path.exists(old_dir) and not os.path.exists(new_dir):
- shutil.copytree(old_dir, new_dir)
- except OSError:
- log.misc.exception("Failed to copy old dictionaries")
+ """Initialize the dictionary path."""
+ dict_dir = dictionary_dir()
+ os.environ['QTWEBENGINE_DICTIONARIES_PATH'] = dict_dir
diff --git a/qutebrowser/browser/webengine/tabhistory.py b/qutebrowser/browser/webengine/tabhistory.py
index f630e8873..9bfe1151f 100644
--- a/qutebrowser/browser/webengine/tabhistory.py
+++ b/qutebrowser/browser/webengine/tabhistory.py
@@ -19,8 +19,6 @@
"""QWebHistory serializer for QtWebEngine."""
-import time
-
from PyQt5.QtCore import QByteArray, QDataStream, QIODevice, QUrl
from qutebrowser.utils import qtutils
@@ -96,8 +94,14 @@ def _serialize_item(item, stream):
## static_cast<qint64>(entry->GetTimestamp().ToInternalValue());
# \x00\x00\x00\x00^\x97$\xe7
- stream.writeInt64(int(time.time()))
-
+ if item.last_visited is None:
+ unix_msecs = 0
+ else:
+ unix_msecs = item.last_visited.toMSecsSinceEpoch()
+ # 11644516800000 is the number of milliseconds from
+ # 1601-01-01T00:00 (Windows NT Epoch) to 1970-01-01T00:00 (UNIX Epoch)
+ nt_usecs = (unix_msecs + 11644516800000) * 1000
+ stream.writeInt64(nt_usecs)
## entry->GetHttpStatusCode();
# \x00\x00\x00\xc8
stream.writeInt(200)
diff --git a/qutebrowser/browser/webengine/webenginedownloads.py b/qutebrowser/browser/webengine/webenginedownloads.py
index 2e0129605..206f7b9ad 100644
--- a/qutebrowser/browser/webengine/webenginedownloads.py
+++ b/qutebrowser/browser/webengine/webenginedownloads.py
@@ -21,14 +21,13 @@
import re
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
-from qutebrowser.utils import debug, usertypes, message, log, qtutils
+from qutebrowser.utils import debug, usertypes, message, log, objreg, urlutils
class DownloadItem(downloads.AbstractDownloadItem):
@@ -39,16 +38,19 @@ class DownloadItem(downloads.AbstractDownloadItem):
_qt_item: The wrapped item.
"""
- def __init__(self, qt_item: QWebEngineDownloadItem, parent=None):
- super().__init__(parent)
+ def __init__(self, qt_item: QWebEngineDownloadItem,
+ manager: downloads.AbstractDownloadManager,
+ parent: QObject = None) -> None:
+ super().__init__(manager=manager, parent=manager)
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."""
@@ -81,39 +83,34 @@ class DownloadItem(downloads.AbstractDownloadItem):
self.stats.finish()
elif state == QWebEngineDownloadItem.DownloadInterrupted:
self.successful = False
- # https://bugreports.qt.io/browse/QTBUG-56839
- try:
- reason = self._qt_item.interruptReasonString()
- except AttributeError:
- # Qt < 5.9
- reason = "Download failed"
+ reason = self._qt_item.interruptReasonString()
self._die(reason)
else:
raise ValueError("_on_state_changed was called with unknown state "
"{}".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()
def _do_cancel(self):
+ state = self._qt_item.state()
+ state_name = debug.qenum_key(QWebEngineDownloadItem, state)
+ assert state not in [QWebEngineDownloadItem.DownloadCompleted,
+ QWebEngineDownloadItem.DownloadCancelled], state_name
self._qt_item.cancel()
def retry(self):
state = self._qt_item.state()
if state != QWebEngineDownloadItem.DownloadInterrupted:
log.downloads.warning(
- "Trying to retry download in state {}".format(
+ "Refusing to retry download in state {}".format(
debug.qenum_key(QWebEngineDownloadItem, state)))
return
- try:
- self._qt_item.resume()
- except AttributeError:
- raise downloads.UnsupportedOperationError(
- "Retrying downloads is unsupported with QtWebEngine on "
- "Qt/PyQt < 5.10")
+ self._qt_item.resume()
def _get_open_filename(self):
return self._filename
@@ -137,14 +134,15 @@ class DownloadItem(downloads.AbstractDownloadItem):
"state {} (not in requested state)!".format(
filename, self, state_name))
- def _ask_confirm_question(self, title, msg):
+ def _ask_confirm_question(self, title, msg, *, custom_yes_action=None):
+ yes_action = custom_yes_action or self._after_set_filename
no_action = functools.partial(self.cancel, remove_data=False)
question = usertypes.Question()
question.title = title
question.text = msg
question.url = 'file://{}'.format(self._filename)
question.mode = usertypes.PromptMode.yesno
- question.answered_yes.connect(self._after_set_filename)
+ question.answered_yes.connect(yes_action)
question.answered_no.connect(no_action)
question.cancelled.connect(no_action)
self.cancelled.connect(question.abort)
@@ -182,6 +180,26 @@ class DownloadItem(downloads.AbstractDownloadItem):
self._qt_item.accept()
+ def _get_conflicting_download(self):
+ """Return another potential active download with the same name.
+
+ webenginedownloads.DownloadItem needs to look for downloads both in its
+ manager and in qtnetwork-download-manager as both are used
+ simultaneously.
+
+ This method can be safely removed once #2328 is fixed.
+ """
+ conflicting_download = super()._get_conflicting_download()
+ if conflicting_download:
+ return conflicting_download
+
+ qtnetwork_download_manager = objreg.get(
+ 'qtnetwork-download-manager')
+ for download in qtnetwork_download_manager.downloads:
+ if self._conflicts_with(download):
+ return download
+ return None
+
def _get_suggested_filename(path):
"""Convert a path we got from chromium to a suggested filename.
@@ -208,14 +226,7 @@ def _get_suggested_filename(path):
(?=\.|$) # Begin of extension, or filename without extension
""", re.VERBOSE)
- filename = suffix_re.sub('', filename)
- if not qtutils.version_check('5.9', compiled=False):
- # https://bugreports.qt.io/browse/QTBUG-58155
- filename = urllib.parse.unquote(filename)
- # Doing basename a *second* time because there could be a %2F in
- # there...
- filename = os.path.basename(filename)
- return filename
+ return suffix_re.sub('', filename)
class DownloadManager(downloads.AbstractDownloadManager):
@@ -238,10 +249,17 @@ class DownloadManager(downloads.AbstractDownloadManager):
@pyqtSlot(QWebEngineDownloadItem)
def handle_download(self, qt_item):
"""Start a download coming from a QWebEngineProfile."""
- suggested_filename = _get_suggested_filename(qt_item.path())
+ if qt_item.url().scheme().lower() == 'data':
+ # WORKAROUND for an unknown QtWebEngine bug (?) which gives us base64 data
+ # as filename.
+ suggested_filename = urlutils.filename_from_url(
+ qt_item.url(), fallback='qutebrowser-download')
+ else:
+ suggested_filename = _get_suggested_filename(qt_item.path())
+
use_pdfjs = pdfjs.should_use_pdfjs(qt_item.mimeType(), qt_item.url())
- download = DownloadItem(qt_item)
+ download = DownloadItem(qt_item, manager=self)
self._init_item(download, auto_remove=use_pdfjs,
suggested_filename=suggested_filename)
diff --git a/qutebrowser/browser/webengine/webengineelem.py b/qutebrowser/browser/webengine/webengineelem.py
index ffaaf346c..37b9de364 100644
--- a/qutebrowser/browser/webengine/webengineelem.py
+++ b/qutebrowser/browser/webengine/webengineelem.py
@@ -17,22 +17,19 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-# FIXME:qtwebengine remove this once the stubs are gone
-# pylint: disable=unused-argument
-
"""QtWebEngine specific part of the web element API."""
-import typing
+from typing import (
+ TYPE_CHECKING, Any, Callable, Dict, Iterator, Optional, Set, Tuple, Union)
-from PyQt5.QtCore import QRect, Qt, QPoint, QEventLoop
-from PyQt5.QtGui import QMouseEvent
+from PyQt5.QtCore import QRect, QEventLoop
from PyQt5.QtWidgets import QApplication
from PyQt5.QtWebEngineWidgets import QWebEngineSettings
-from qutebrowser.utils import log, javascript, urlutils, usertypes
+from qutebrowser.utils import log, javascript, urlutils, usertypes, utils
from qutebrowser.browser import webelem
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.browser.webengine import webenginetab
@@ -40,11 +37,11 @@ class WebEngineElement(webelem.AbstractWebElement):
"""A web element for QtWebEngine, using JS under the hood."""
- def __init__(self, js_dict: typing.Dict[str, typing.Any],
+ def __init__(self, js_dict: Dict[str, Any],
tab: 'webenginetab.WebEngineTab') -> None:
super().__init__(tab)
# Do some sanity checks on the data we get from JS
- js_dict_types = {
+ js_dict_types: Dict[str, Union[type, Tuple[type, ...]]] = {
'id': int,
'text': str,
'value': (str, int, float),
@@ -53,8 +50,9 @@ class WebEngineElement(webelem.AbstractWebElement):
'class_name': str,
'rects': list,
'attributes': dict,
+ 'is_content_editable': bool,
'caret_position': (int, type(None)),
- } # type: typing.Dict[str, typing.Union[type, typing.Tuple[type,...]]]
+ }
assert set(js_dict.keys()).issubset(js_dict_types.keys())
for name, typ in js_dict_types.items():
if name in js_dict and not isinstance(js_dict[name], typ):
@@ -96,16 +94,17 @@ class WebEngineElement(webelem.AbstractWebElement):
self._js_call('set_attribute', key, val)
def __delitem__(self, key: str) -> None:
+ utils.unused(key)
log.stub()
- def __iter__(self) -> typing.Iterator[str]:
+ def __iter__(self) -> Iterator[str]:
return iter(self._js_dict['attributes'])
def __len__(self) -> int:
return len(self._js_dict['attributes'])
def _js_call(self, name: str, *args: webelem.JsValueType,
- callback: typing.Callable[[typing.Any], None] = None) -> None:
+ callback: Callable[[Any], None] = None) -> None:
"""Wrapper to run stuff from webelem.js."""
if self._tab.is_deleted():
raise webelem.OrphanedError("Tab containing element vanished")
@@ -119,9 +118,9 @@ class WebEngineElement(webelem.AbstractWebElement):
log.stub()
return QRect()
- def classes(self) -> typing.List[str]:
+ def classes(self) -> Set[str]:
"""Get a list of classes assigned to this element."""
- return self._js_dict['class_name'].split()
+ return set(self._js_dict['class_name'].split())
def tag_name(self) -> str:
"""Get the tag name of this element.
@@ -136,6 +135,9 @@ class WebEngineElement(webelem.AbstractWebElement):
"""Get the full HTML representation of this element."""
return self._js_dict['outer_xml']
+ def is_content_editable_prop(self) -> bool:
+ return self._js_dict['is_content_editable']
+
def value(self) -> webelem.JsValueType:
return self._js_dict.get('value', None)
@@ -148,7 +150,7 @@ class WebEngineElement(webelem.AbstractWebElement):
composed: bool = False) -> None:
self._js_call('dispatch_event', event, bubbles, cancelable, composed)
- def caret_position(self) -> typing.Optional[int]:
+ def caret_position(self) -> Optional[int]:
"""Get the text caret position for the current element.
If the element is not a text element, None is returned.
@@ -171,10 +173,12 @@ class WebEngineElement(webelem.AbstractWebElement):
Args:
elem_geometry: The geometry of the element, or None.
- Calling QWebElement::geometry is rather expensive so
- we want to avoid doing it twice.
- no_js: Fall back to the Python implementation
+ Ignored with QtWebEngine.
+ no_js: Fall back to the Python implementation.
+ Ignored with QtWebEngine.
"""
+ utils.unused(elem_geometry)
+ utils.unused(no_js)
rects = self._js_dict['rects']
for rect in rects:
# FIXME:qtwebengine
@@ -225,11 +229,7 @@ class WebEngineElement(webelem.AbstractWebElement):
return url.scheme() not in urlutils.WEBENGINE_SCHEMES
def _click_editable(self, click_target: usertypes.ClickTarget) -> None:
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-58515
- ev = QMouseEvent(QMouseEvent.MouseButtonPress, QPoint(0, 0),
- QPoint(0, 0), QPoint(0, 0), Qt.NoButton, Qt.NoButton,
- Qt.NoModifier, Qt.MouseEventSynthesizedBySystem)
- self._tab.send_event(ev)
+ self._tab.setFocus() # Needed as WORKAROUND on Qt 5.12
# This actually "clicks" the element by calling focus() on it in JS.
self._js_call('focus')
self._move_text_cursor()
@@ -248,11 +248,11 @@ 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)
- def reset_setting(_arg: typing.Any) -> None:
+ def reset_setting(_arg: Any) -> None:
"""Set the JavascriptCanOpenWindows setting to its old value."""
assert view is not None
try:
diff --git a/qutebrowser/browser/webengine/webengineinspector.py b/qutebrowser/browser/webengine/webengineinspector.py
index 73fa65c42..5685a9538 100644
--- a/qutebrowser/browser/webengine/webengineinspector.py
+++ b/qutebrowser/browser/webengine/webengineinspector.py
@@ -19,52 +19,76 @@
"""Customized QWebInspector for QtWebEngine."""
-import os
+import pathlib
-from PyQt5.QtCore import QUrl
-from PyQt5.QtWebEngineWidgets import QWebEngineView
+from PyQt5.QtCore import QLibraryInfo
+from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
+from PyQt5.QtWidgets import QWidget
from qutebrowser.browser import inspector
from qutebrowser.browser.webengine import webenginesettings
+from qutebrowser.misc import miscwidgets
+from qutebrowser.utils import version
+
+
+class WebEngineInspectorView(QWebEngineView):
+
+ """The QWebEngineView used for the inspector.
+
+ We don't use a qutebrowser WebEngineView because that has various
+ customization which doesn't apply to the inspector.
+ """
+
+ def createWindow(self,
+ wintype: QWebEnginePage.WebWindowType) -> QWebEngineView:
+ """Called by Qt when a page wants to create a new tab or window.
+
+ In case the user wants to open a resource in a new tab, we use the
+ createWindow handling of the main page to achieve that.
+
+ See WebEngineView.createWindow for details.
+ """
+ return self.page().inspectedPage().view().createWindow(wintype)
class WebEngineInspector(inspector.AbstractWebInspector):
- """A web inspector for QtWebEngine."""
+ """A web inspector for QtWebEngine with Qt API support."""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.port = None
- view = QWebEngineView()
+ def __init__(self, splitter: miscwidgets.InspectorSplitter,
+ win_id: int,
+ parent: QWidget = None) -> None:
+ super().__init__(splitter, win_id, parent)
+ self._check_devtools_resources()
+ view = WebEngineInspectorView()
self._settings = webenginesettings.WebEngineSettings(view.settings())
self._set_widget(view)
- def _inspect_old(self, page):
- """Set up the inspector for Qt < 5.11."""
- try:
- port = int(os.environ['QTWEBENGINE_REMOTE_DEBUGGING'])
- except KeyError:
- raise inspector.WebInspectorError(
- "QtWebEngine inspector is not enabled. See "
- "'qutebrowser --help' for details.")
-
- # We're lying about the URL here a bit, but this way, URL patterns for
- # Qt 5.11/5.12/5.13 also work in this case.
- self._settings.update_for_url(QUrl('chrome-devtools://devtools'))
-
- if page is None:
- self._widget.load(QUrl('about:blank'))
- else:
- self._widget.load(QUrl('http://localhost:{}/'.format(port)))
-
- def _inspect_new(self, page):
- """Set up the inspector for Qt >= 5.11."""
+ def _check_devtools_resources(self) -> None:
+ """Make sure that the devtools resources are available on Fedora.
+
+ Fedora packages devtools resources into its own package. If it's not
+ installed, we show a nice error instead of a blank inspector.
+ """
+ dist = version.distribution()
+ if dist is None or dist.parsed != version.Distribution.fedora:
+ return
+
+ data_path = pathlib.Path(QLibraryInfo.location(QLibraryInfo.DataPath))
+ pak = data_path / 'resources' / 'qtwebengine_devtools_resources.pak'
+ if not pak.exists():
+ raise inspector.Error("QtWebEngine devtools resources not found, "
+ "please install the qt5-qtwebengine-devtools "
+ "Fedora package.")
+
+ def inspect(self, page: QWebEnginePage) -> None: # type: ignore[override]
inspector_page = self._widget.page()
inspector_page.setInspectedPage(page)
self._settings.update_for_url(inspector_page.requestedUrl())
- def inspect(self, page):
- try:
- self._inspect_new(page)
- except AttributeError:
- self._inspect_old(page)
+ def _needs_recreate(self) -> bool:
+ """Recreate the inspector when detaching to a window.
+
+ WORKAROUND for what's likely an unknown Qt bug.
+ """
+ return True
diff --git a/qutebrowser/browser/webengine/webenginequtescheme.py b/qutebrowser/browser/webengine/webenginequtescheme.py
index a1526b777..f1eebd75d 100644
--- a/qutebrowser/browser/webengine/webenginequtescheme.py
+++ b/qutebrowser/browser/webengine/webenginequtescheme.py
@@ -21,12 +21,8 @@
from PyQt5.QtCore import QBuffer, QIODevice, QUrl
from PyQt5.QtWebEngineCore import (QWebEngineUrlSchemeHandler,
- QWebEngineUrlRequestJob)
-try:
- from PyQt5.QtWebEngineCore import QWebEngineUrlScheme # type: ignore
-except ImportError:
- # Added in Qt 5.12
- QWebEngineUrlScheme = None
+ QWebEngineUrlRequestJob,
+ QWebEngineUrlScheme)
from qutebrowser.browser import qutescheme
from qutebrowser.utils import log, qtutils
@@ -42,11 +38,6 @@ class QuteSchemeHandler(QWebEngineUrlSchemeHandler):
assert QWebEngineUrlScheme.schemeByName(b'qute') is not None
profile.installUrlSchemeHandler(b'qute', self)
- if (qtutils.version_check('5.11', compiled=False) and
- not qtutils.version_check('5.12', compiled=False)):
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-63378
- profile.installUrlSchemeHandler(b'chrome-error', self)
- profile.installUrlSchemeHandler(b'chrome-extension', self)
def _check_initiator(self, job):
"""Check whether the initiator of the job should be allowed.
@@ -60,35 +51,22 @@ class QuteSchemeHandler(QWebEngineUrlSchemeHandler):
Return:
True if the initiator is allowed, False if it was blocked.
"""
- try:
- initiator = job.initiator()
- request_url = job.requestUrl()
- except AttributeError:
- # Added in Qt 5.11
- return True
+ initiator = job.initiator()
+ request_url = job.requestUrl()
# https://codereview.qt-project.org/#/c/234849/
is_opaque = initiator == QUrl('null')
target = request_url.scheme(), request_url.host()
- if is_opaque and not qtutils.version_check('5.12'):
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-70421
- # When we don't register the qute:// scheme, all requests are
- # flagged as opaque.
- return True
-
- if (target == ('qute', 'testdata') and
- is_opaque and
- qtutils.version_check('5.12')):
- # Allow requests to qute://testdata, as this is needed in Qt 5.12
- # for all tests to work properly. No qute://testdata handler is
- # installed outside of tests.
+ if target == ('qute', 'testdata') and is_opaque:
+ # Allow requests to qute://testdata, as this is needed for all tests to work
+ # properly. No qute://testdata handler is installed outside of tests.
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
@@ -105,11 +83,6 @@ class QuteSchemeHandler(QWebEngineUrlSchemeHandler):
"""
url = job.requestUrl()
- if url.scheme() in ['chrome-error', 'chrome-extension']:
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-63378
- job.fail(QWebEngineUrlRequestJob.UrlInvalid)
- return
-
if not self._check_initiator(job):
return
@@ -119,7 +92,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 +109,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 +138,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 f02b80061..05e7b4b68 100644
--- a/qutebrowser/browser/webengine/webenginesettings.py
+++ b/qutebrowser/browser/webengine/webenginesettings.py
@@ -26,27 +26,35 @@ Module attributes:
import os
import operator
-import typing
+from typing import cast, Any, List, Optional, Tuple, Union, TYPE_CHECKING
from PyQt5.QtGui import QFont
-from PyQt5.QtWebEngineWidgets import (QWebEngineSettings, QWebEngineProfile,
- QWebEnginePage)
+from PyQt5.QtWidgets import QApplication
+from PyQt5.QtWebEngineWidgets import QWebEngineSettings, QWebEngineProfile
-from qutebrowser.browser.webengine import spell, webenginequtescheme
+from qutebrowser.browser import history
+from qutebrowser.browser.webengine import (spell, webenginequtescheme, cookies,
+ webenginedownloads)
from qutebrowser.config import config, websettings
from qutebrowser.config.websettings import AttributeInfo as Attr
-from qutebrowser.utils import (utils, standarddir, qtutils, message, log,
- urlmatch, usertypes)
+from qutebrowser.utils import (standarddir, qtutils, message, log,
+ urlmatch, usertypes, objreg)
+if TYPE_CHECKING:
+ from qutebrowser.browser.webengine import interceptor
# The default QWebEngineProfile
-default_profile = typing.cast(QWebEngineProfile, None)
+default_profile = cast(QWebEngineProfile, None)
# The QWebEngineProfile used for private (off-the-record) windows
-private_profile = None # type: typing.Optional[QWebEngineProfile]
+private_profile: Optional[QWebEngineProfile] = None
# The global WebEngineSettings object
-global_settings = typing.cast('WebEngineSettings', None)
+_global_settings = cast('WebEngineSettings', None)
parsed_user_agent = None
+_qute_scheme_handler = cast(webenginequtescheme.QuteSchemeHandler, None)
+_req_interceptor = cast('interceptor.RequestInterceptor', None)
+_download_manager = cast(webenginedownloads.DownloadManager, None)
+
class _SettingsWrapper:
@@ -55,45 +63,45 @@ class _SettingsWrapper:
For read operations, the default profile value is always used.
"""
- def __init__(self):
- self._settings = [default_profile.settings()]
+ def _settings(self):
+ yield default_profile.settings()
if private_profile:
- self._settings.append(private_profile.settings())
+ yield private_profile.settings()
def setAttribute(self, attribute, on):
- for settings in self._settings:
+ for settings in self._settings():
settings.setAttribute(attribute, on)
def setFontFamily(self, which, family):
- for settings in self._settings:
+ for settings in self._settings():
settings.setFontFamily(which, family)
def setFontSize(self, fonttype, size):
- for settings in self._settings:
+ for settings in self._settings():
settings.setFontSize(fonttype, size)
def setDefaultTextEncoding(self, encoding):
- for settings in self._settings:
+ for settings in self._settings():
settings.setDefaultTextEncoding(encoding)
def setUnknownUrlSchemePolicy(self, policy):
- for settings in self._settings:
+ for settings in self._settings():
settings.setUnknownUrlSchemePolicy(policy)
def testAttribute(self, attribute):
- return self._settings[0].testAttribute(attribute)
+ return default_profile.settings().testAttribute(attribute)
def fontSize(self, fonttype):
- return self._settings[0].fontSize(fonttype)
+ return default_profile.settings().fontSize(fonttype)
def fontFamily(self, which):
- return self._settings[0].fontFamily(which)
+ return default_profile.settings().fontFamily(which)
def defaultTextEncoding(self):
- return self._settings[0].defaultTextEncoding()
+ return default_profile.settings().defaultTextEncoding()
def unknownUrlSchemePolicy(self):
- return self._settings[0].unknownUrlSchemePolicy()
+ return default_profile.settings().unknownUrlSchemePolicy()
class WebEngineSettings(websettings.AbstractSettings):
@@ -126,8 +134,7 @@ class WebEngineSettings(websettings.AbstractSettings):
'content.desktop_capture':
Attr(QWebEngineSettings.ScreenCaptureEnabled,
converter=lambda val: True if val == 'ask' else val),
- # 'ask' is handled via the permission system,
- # or a hardcoded dialog on Qt < 5.10
+ # 'ask' is handled via the permission system
'input.spatial_navigation':
Attr(QWebEngineSettings.SpatialNavigationEnabled),
@@ -136,6 +143,16 @@ class WebEngineSettings(websettings.AbstractSettings):
'scrolling.smooth':
Attr(QWebEngineSettings.ScrollAnimatorEnabled),
+
+ 'content.print_element_backgrounds':
+ Attr(QWebEngineSettings.PrintElementBackgrounds),
+
+ 'content.autoplay':
+ Attr(QWebEngineSettings.PlaybackRequiresUserGesture,
+ converter=operator.not_),
+
+ 'content.dns_prefetch':
+ Attr(QWebEngineSettings.DnsPrefetchEnabled),
}
_FONT_SIZES = {
@@ -158,18 +175,14 @@ 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
+ _UNKNOWN_URL_SCHEME_POLICY = {
+ 'disallow':
+ QWebEngineSettings.DisallowUnknownUrlSchemes,
+ 'allow-from-user-interaction':
+ QWebEngineSettings.AllowUnknownUrlSchemesFromUserInteraction,
+ 'allow-all':
+ QWebEngineSettings.AllowAllUnknownUrlSchemes,
+ }
# Mapping from WebEngineSettings::initDefaults in
# qtwebengine/src/core/web_engine_settings.cpp
@@ -183,7 +196,7 @@ class WebEngineSettings(websettings.AbstractSettings):
}
def set_unknown_url_scheme_policy(
- self, policy: typing.Union[str, usertypes.Unset]) -> bool:
+ self, policy: Union[str, usertypes.Unset]) -> bool:
"""Set the UnknownUrlSchemePolicy to use.
Return:
@@ -200,39 +213,13 @@ class WebEngineSettings(websettings.AbstractSettings):
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 self.set_unknown_url_scheme_policy(value)
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.
- new_attributes = {
- # Qt 5.8
- 'content.print_element_backgrounds':
- ('PrintElementBackgrounds', None),
-
- # Qt 5.11
- 'content.autoplay':
- ('PlaybackRequiresUserGesture', operator.not_),
-
- # Qt 5.12
- 'content.dns_prefetch':
- ('DnsPrefetchEnabled', None),
- }
- for name, (attribute, converter) in new_attributes.items():
- try:
- value = getattr(QWebEngineSettings, attribute)
- except AttributeError:
- continue
-
- self._ATTRIBUTES[name] = Attr(value, converter=converter)
-
class ProfileSetter:
@@ -240,14 +227,33 @@ class ProfileSetter:
def __init__(self, profile):
self._profile = profile
+ self._name_to_method = {
+ 'content.cache.size': self.set_http_cache_size,
+ 'content.cookies.store': self.set_persistent_cookie_policy,
+ 'spellcheck.languages': self.set_dictionary_language,
+ }
+
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-75884
+ # (note this isn't actually fixed properly before Qt 5.15)
+ header_bug_fixed = qtutils.version_check('5.15', compiled=False)
+ if header_bug_fixed:
+ for name in ['user_agent', 'accept_language']:
+ self._name_to_method[f'content.headers.{name}'] = self.set_http_headers
+
+ def update_setting(self, name):
+ """Update a setting based on its name."""
+ try:
+ meth = self._name_to_method[name]
+ except KeyError:
+ return
+ meth()
def init_profile(self):
"""Initialize settings on the given profile."""
self.set_http_headers()
self.set_http_cache_size()
self._set_hardcoded_settings()
- if qtutils.version_check('5.8'):
- self.set_dictionary_language()
+ self.set_dictionary_language()
def _set_hardcoded_settings(self):
"""Set up settings with a fixed value."""
@@ -255,13 +261,8 @@ class ProfileSetter:
settings.setAttribute(
QWebEngineSettings.FullScreenSupportEnabled, True)
-
- try:
- settings.setAttribute(
- QWebEngineSettings.FocusOnNavigationEnabled, False)
- except AttributeError:
- # Added in Qt 5.8
- pass
+ settings.setAttribute(
+ QWebEngineSettings.FocusOnNavigationEnabled, False)
try:
settings.setAttribute(QWebEngineSettings.PdfViewerEnabled, False)
@@ -296,20 +297,21 @@ class ProfileSetter:
def set_persistent_cookie_policy(self):
"""Set the HTTP Cookie size for the given profile."""
- assert not self._profile.isOffTheRecord()
+ if self._profile.isOffTheRecord():
+ return
if config.val.content.cookies.store:
value = QWebEngineProfile.AllowPersistentCookies
else:
value = QWebEngineProfile.NoPersistentCookies
self._profile.setPersistentCookiesPolicy(value)
- def set_dictionary_language(self, warn=True):
+ def set_dictionary_language(self):
"""Load the given dictionaries."""
filenames = []
for code in config.val.spellcheck.languages or []:
local_filename = spell.local_filename(code)
if not local_filename:
- if warn:
+ if not self._profile.isOffTheRecord():
message.warning("Language {} is not installed - see "
"scripts/dictcli.py in qutebrowser's "
"sources".format(code))
@@ -324,26 +326,10 @@ class ProfileSetter:
def _update_settings(option):
"""Update global settings when qwebsettings changed."""
- global_settings.update_setting(option)
-
- if option in ['content.headers.user_agent',
- 'content.headers.accept_language']:
- default_profile.setter.set_http_headers()
- if private_profile:
- private_profile.setter.set_http_headers()
- elif option == 'content.cache.size':
- default_profile.setter.set_http_cache_size()
- if private_profile:
- private_profile.setter.set_http_cache_size()
- elif (option == 'content.cookies.store' and
- # https://bugreports.qt.io/browse/QTBUG-58650
- qtutils.version_check('5.9', compiled=False)):
- default_profile.setter.set_persistent_cookie_policy()
- # We're not touching the private profile's cookie policy.
- elif option == 'spellcheck.languages':
- default_profile.setter.set_dictionary_language()
- if private_profile:
- private_profile.setter.set_dictionary_language(warn=False)
+ _global_settings.update_setting(option)
+ default_profile.setter.update_setting(option)
+ if private_profile:
+ private_profile.setter.update_setting(option)
def _init_user_agent_str(ua):
@@ -355,32 +341,65 @@ def init_user_agent():
_init_user_agent_str(QWebEngineProfile.defaultProfile().httpUserAgent())
-def _init_profiles():
- """Init the two used QWebEngineProfiles."""
- global default_profile, private_profile
+def _init_profile(profile: QWebEngineProfile) -> None:
+ """Initialize a new QWebEngineProfile.
+
+ This currently only contains the steps which are shared between a private and a
+ non-private profile (at the moment, only the default profile).
+ """
+ profile.setter = ProfileSetter(profile) # type: ignore[attr-defined]
+ profile.setter.init_profile()
+
+ _qute_scheme_handler.install(profile)
+ _req_interceptor.install(profile)
+ _download_manager.install(profile)
+ cookies.install_filter(profile)
+
+ # Clear visited links on web history clear
+ history.web_history.history_cleared.connect(profile.clearAllVisitedLinks)
+ history.web_history.url_cleared.connect(
+ lambda url: profile.clearVisitedLinks([url]))
+
+ _global_settings.init_settings()
+
+
+def _init_default_profile():
+ """Init the default QWebEngineProfile."""
+ global default_profile
default_profile = QWebEngineProfile.defaultProfile()
+
init_user_agent()
- default_profile.setter = ProfileSetter(default_profile)
default_profile.setCachePath(
os.path.join(standarddir.cache(), 'webengine'))
default_profile.setPersistentStoragePath(
os.path.join(standarddir.data(), 'webengine'))
- default_profile.setter.init_profile()
- default_profile.setter.set_persistent_cookie_policy()
- if not qtutils.is_single_process():
- private_profile = QWebEngineProfile()
- private_profile.setter = ProfileSetter(private_profile)
- assert private_profile.isOffTheRecord()
- private_profile.setter.init_profile()
+ _init_profile(default_profile)
+
+
+def init_private_profile():
+ """Init the private QWebEngineProfile."""
+ global private_profile
+
+ if qtutils.is_single_process():
+ return
+
+ private_profile = QWebEngineProfile()
+ assert private_profile.isOffTheRecord()
+ _init_profile(private_profile)
def _init_site_specific_quirks():
+ """Add custom user-agent settings for problematic sites.
+
+ See https://github.com/qutebrowser/qutebrowser/issues/4810
+ """
if not config.val.content.site_specific_quirks:
return
+ # Please leave this here as a template for new UAs.
# default_ua = ("Mozilla/5.0 ({os_info}) "
# "AppleWebKit/{webkit_version} (KHTML, like Gecko) "
# "{qt_key}/{qt_version} "
@@ -390,22 +409,35 @@ def _init_site_specific_quirks():
"AppleWebKit/{webkit_version} (KHTML, like Gecko) "
"{upstream_browser_key}/{upstream_browser_version} "
"Safari/{webkit_version}")
- firefox_ua = "Mozilla/5.0 ({os_info}; rv:71.0) Gecko/20100101 Firefox/71.0"
new_chrome_ua = ("Mozilla/5.0 ({os_info}) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/99 "
"Safari/537.36")
+ edge_ua = ("Mozilla/5.0 ({os_info}) "
+ "AppleWebKit/{webkit_version} (KHTML, like Gecko) "
+ "{upstream_browser_key}/{upstream_browser_version} "
+ "Safari/{webkit_version} "
+ "Edg/{upstream_browser_version}")
user_agents = {
+ # Needed to avoid a ""WhatsApp works with Google Chrome 36+" error
+ # page which doesn't allow to use WhatsApp Web at all. Also see the
+ # additional JS quirk: qutebrowser/javascript/whatsapp_web_quirk.user.js
+ # https://github.com/qutebrowser/qutebrowser/issues/4445
'https://web.whatsapp.com/': no_qtwe_ua,
- 'https://accounts.google.com/*': firefox_ua,
+
+ # Needed to avoid a "you're using a browser [...] that doesn't allow us
+ # to keep your account secure" error.
+ # https://github.com/qutebrowser/qutebrowser/issues/5182
+ 'https://accounts.google.com/*': edge_ua,
+
+ # Needed because Slack adds an error which prevents using it relatively
+ # aggressively, despite things actually working fine.
+ # September 2020: Qt 5.12 works, but Qt <= 5.11 shows the error.
+ # https://github.com/qutebrowser/qutebrowser/issues/4669
'https://*.slack.com/*': new_chrome_ua,
- 'https://docs.google.com/*': firefox_ua,
}
- if not qtutils.version_check('5.9'):
- user_agents['https://www.dell.com/support/*'] = new_chrome_ua
-
for pattern, ua in user_agents.items():
config.instance.set_obj('content.headers.user_agent', ua,
pattern=urlmatch.UrlPattern(pattern),
@@ -414,29 +446,51 @@ 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: List[Tuple[str, Any]] = [
+ ('content.javascript.enabled', True),
+ ('content.images', True),
+ ('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)
-def init(args):
+def init():
"""Initialize the global QWebSettings."""
- if (args.enable_webengine_inspector and
- not hasattr(QWebEnginePage, 'setInspectedPage')): # only Qt < 5.11
- os.environ['QTWEBENGINE_REMOTE_DEBUGGING'] = str(utils.random_port())
-
webenginequtescheme.init()
spell.init()
- _init_profiles()
+ # For some reason we need to keep a reference, otherwise the scheme handler
+ # won't work...
+ # https://www.riverbankcomputing.com/pipermail/pyqt/2016-September/038075.html
+ global _qute_scheme_handler
+ app = QApplication.instance()
+ log.init.debug("Initializing qute://* handler...")
+ _qute_scheme_handler = webenginequtescheme.QuteSchemeHandler(parent=app)
+
+ global _req_interceptor
+ log.init.debug("Initializing request interceptor...")
+ from qutebrowser.browser.webengine import interceptor
+ _req_interceptor = interceptor.RequestInterceptor(parent=app)
+
+ global _download_manager
+ log.init.debug("Initializing QtWebEngine downloads...")
+ _download_manager = webenginedownloads.DownloadManager(parent=app)
+ objreg.register('webengine-download-manager', _download_manager)
+ from qutebrowser.misc import quitter
+ quitter.instance.shutting_down.connect(_download_manager.shutdown)
+
+ global _global_settings
+ _global_settings = WebEngineSettings(_SettingsWrapper())
+
+ _init_default_profile()
+ init_private_profile()
config.instance.changed.connect(_update_settings)
- global global_settings
- global_settings = WebEngineSettings(_SettingsWrapper())
- global_settings.init_settings()
-
_init_site_specific_quirks()
_init_devtools_settings()
diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py
index 8b26f2136..955be8c22 100644
--- a/qutebrowser/browser/webengine/webenginetab.py
+++ b/qutebrowser/browser/webengine/webenginetab.py
@@ -23,72 +23,23 @@ import math
import functools
import re
import html as html_utils
-import typing
+from typing import cast, Union, Optional
-from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QUrl,
- QTimer, QObject)
+from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QUrl, QObject
from PyQt5.QtNetwork import QAuthenticator
-from PyQt5.QtWidgets import QApplication, QWidget
-from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript
+from PyQt5.QtWidgets import QWidget
+from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript, QWebEngineHistory
-from qutebrowser.config import configdata, config
-from qutebrowser.browser import (browsertab, eventfilter, shared, webelem,
- history, greasemonkey)
+from qutebrowser.config import config
+from qutebrowser.browser import browsertab, eventfilter, shared, webelem, greasemonkey
from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory,
- interceptor, webenginequtescheme,
- cookies, webenginedownloads,
webenginesettings, certificateerror)
from qutebrowser.misc import miscwidgets, objects
from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils,
- message, objreg, jinja, debug)
+ message, jinja, debug)
from qutebrowser.qt import sip
-_qute_scheme_handler = None
-
-
-def init():
- """Initialize QtWebEngine-specific modules."""
- # For some reason we need to keep a reference, otherwise the scheme handler
- # won't work...
- # https://www.riverbankcomputing.com/pipermail/pyqt/2016-September/038075.html
- global _qute_scheme_handler
-
- app = QApplication.instance()
- log.init.debug("Initializing qute://* handler...")
- _qute_scheme_handler = webenginequtescheme.QuteSchemeHandler(parent=app)
- _qute_scheme_handler.install(webenginesettings.default_profile)
- if webenginesettings.private_profile:
- _qute_scheme_handler.install(webenginesettings.private_profile)
-
- log.init.debug("Initializing request interceptor...")
- req_interceptor = interceptor.RequestInterceptor(parent=app)
- req_interceptor.install(webenginesettings.default_profile)
- if webenginesettings.private_profile:
- req_interceptor.install(webenginesettings.private_profile)
-
- log.init.debug("Initializing QtWebEngine downloads...")
- download_manager = webenginedownloads.DownloadManager(parent=app)
- download_manager.install(webenginesettings.default_profile)
- if webenginesettings.private_profile:
- download_manager.install(webenginesettings.private_profile)
- objreg.register('webengine-download-manager', download_manager)
-
- log.init.debug("Initializing cookie filter...")
- cookies.install_filter(webenginesettings.default_profile)
- if webenginesettings.private_profile:
- cookies.install_filter(webenginesettings.private_profile)
-
- # Clear visited links on web history clear
- for p in [webenginesettings.default_profile,
- webenginesettings.private_profile]:
- if not p:
- continue
- history.web_history.history_cleared.connect(p.clearAllVisitedLinks)
- history.web_history.url_cleared.connect(
- lambda url, profile=p: profile.clearVisitedLinks([url]))
-
-
# Mapping worlds from usertypes.JsWorld to QWebEngineScript world IDs.
_JS_WORLD_MAP = {
usertypes.JsWorld.main: QWebEngineScript.MainWorld,
@@ -117,18 +68,7 @@ class WebEngineAction(browsertab.AbstractAction):
self._show_source_pygments()
return
- try:
- self._widget.triggerPageAction(QWebEnginePage.ViewSource)
- except AttributeError:
- # Qt < 5.8
- tb = objreg.get('tabbed-browser', scope='window',
- window=self._tab.win_id)
- urlstr = self._tab.url().toString(
- QUrl.RemoveUserInfo) # type: ignore
- # The original URL becomes the path of a view-source: URL
- # (without a host), but query/fragment should stay.
- url = QUrl('view-source:' + urlstr)
- tb.tabopen(url, background=False, related=True)
+ self._widget.triggerPageAction(QWebEnginePage.ViewSource)
class WebEnginePrinting(browsertab.AbstractPrinting):
@@ -138,11 +78,6 @@ class WebEnginePrinting(browsertab.AbstractPrinting):
def check_pdf_support(self):
pass
- def check_printer_support(self):
- if not hasattr(self._widget.page(), 'print'):
- raise browsertab.WebTabError(
- "Printing is unsupported with QtWebEngine on Qt < 5.8")
-
def check_preview_support(self):
raise browsertab.WebTabError(
"Print previews are unsupported with QtWebEngine")
@@ -182,9 +117,23 @@ class _WebEngineSearchWrapHandler:
Args:
page: The QtWebEnginePage to connect to this handler.
"""
- if qtutils.version_check("5.14"):
- page.findTextFinished.connect(self._store_match_data)
- self._nowrap_available = True
+ 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.
@@ -240,11 +189,14 @@ 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())
@@ -295,7 +247,7 @@ 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):
@@ -314,7 +266,8 @@ class WebEngineSearch(browsertab.AbstractSearch):
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
@@ -336,11 +289,11 @@ class WebEngineCaret(browsertab.AbstractCaret):
"""QtWebEngine implementations related to moving the cursor/selection."""
+ _tab: 'WebEngineTab'
+
def _flags(self):
"""Get flags to pass to JS."""
flags = set()
- if qtutils.version_check('5.7.1', compiled=False):
- flags.add('filter-prefix')
if utils.is_windows:
flags.add('windows')
return list(flags)
@@ -353,7 +306,6 @@ class WebEngineCaret(browsertab.AbstractCaret):
if self._tab.search.search_displayed:
# We are currently in search mode.
# convert the search to a blue selection so we can operate on it
- # https://bugreports.qt.io/browse/QTBUG-60673
self._tab.search.clear()
self._tab.run_js_async(
@@ -369,7 +321,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):
@@ -424,8 +379,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')
@@ -482,7 +438,6 @@ class WebEngineCaret(browsertab.AbstractCaret):
if self._tab.search.search_displayed:
# We are currently in search mode.
# let's click the link via a fake-click
- # https://bugreports.qt.io/browse/QTBUG-60673
self._tab.search.clear()
log.webview.debug("Clicking a searched link via fake key press.")
@@ -500,6 +455,20 @@ 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 self._mode_manager.mode != usertypes.KeyMode.caret:
+ # This may happen if the user switches to another mode after
+ # `:toggle-selection` is executed and before this callback function
+ # is asynchronously called.
+ log.misc.debug("Ignoring caret selection callback in {}".format(
+ self._mode_manager.mode))
+ return
+ 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):
@@ -629,22 +598,41 @@ class WebEngineHistoryPrivate(browsertab.AbstractHistoryPrivate):
"""History-related methods which are not part of the extension API."""
+ def __init__(self, tab: 'WebEngineTab') -> None:
+ self._tab = tab
+ self._history = cast(QWebEngineHistory, None)
+
def serialize(self):
- if not qtutils.version_check('5.9', compiled=False):
- # WORKAROUND for
- # https://github.com/qutebrowser/qutebrowser/issues/2289
- # Don't use the history's currentItem here, because of
- # https://bugreports.qt.io/browse/QTBUG-59599 and because it doesn't
- # contain view-source.
- scheme = self._tab.url().scheme()
- if scheme in ['view-source', 'chrome']:
- raise browsertab.WebTabError("Can't serialize special URL!")
return qtutils.serialize(self._history)
def deserialize(self, data):
qtutils.deserialize(data, self._history)
+ def _load_items_workaround(self, items):
+ """WORKAROUND for session loading not working on Qt 5.15.
+
+ Just load the current URL, see
+ https://github.com/qutebrowser/qutebrowser/issues/5359
+ """
+ if not items:
+ return
+
+ for i, item in enumerate(items):
+ if item.active:
+ cur_idx = i
+ break
+
+ url = items[cur_idx].url
+ if (url.scheme(), url.host()) == ('qute', 'back') and cur_idx >= 1:
+ url = items[cur_idx - 1].url
+
+ self._tab.load_url(url)
+
def load_items(self, items):
+ if qtutils.version_check('5.15', compiled=False):
+ self._load_items_workaround(items)
+ return
+
if items:
self._tab.before_load_started.emit(items[-1].url)
@@ -694,6 +682,12 @@ class WebEngineHistory(browsertab.AbstractHistory):
self._tab.before_load_started.emit(item.url())
self._history.goToItem(item)
+ def back_items(self):
+ return self._history.backItems(self._history.count())
+
+ def forward_items(self):
+ return self._history.forwardItems(self._history.count())
+
class WebEngineZoom(browsertab.AbstractZoom):
@@ -705,7 +699,9 @@ class WebEngineZoom(browsertab.AbstractZoom):
class WebEngineElements(browsertab.AbstractElements):
- """QtWebEngine implemementations related to elements on the page."""
+ """QtWebEngine implementations related to elements on the page."""
+
+ _tab: 'WebEngineTab'
def _js_cb_multiple(self, callback, error_cb, js_elems):
"""Handle found elements coming from JS and call the real callback.
@@ -776,7 +772,7 @@ class WebEngineElements(browsertab.AbstractElements):
class WebEngineAudio(browsertab.AbstractAudio):
- """QtWebEngine implemementations related to audio/muting.
+ """QtWebEngine implementations related to audio/muting.
Attributes:
_overridden: Whether the user toggled muting manually.
@@ -795,10 +791,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()
@@ -810,7 +811,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)
@@ -830,9 +831,12 @@ class _WebEnginePermissions(QObject):
_options = {
0: 'content.notifications',
QWebEnginePage.Geolocation: 'content.geolocation',
- QWebEnginePage.MediaAudioCapture: 'content.media_capture',
- QWebEnginePage.MediaVideoCapture: 'content.media_capture',
- QWebEnginePage.MediaAudioVideoCapture: 'content.media_capture',
+ QWebEnginePage.MediaAudioCapture: 'content.media.audio_capture',
+ QWebEnginePage.MediaVideoCapture: 'content.media.video_capture',
+ QWebEnginePage.MediaAudioVideoCapture: 'content.media.audio_video_capture',
+ QWebEnginePage.MouseLock: 'content.mouse_lock',
+ QWebEnginePage.DesktopVideoCapture: 'content.desktop_capture',
+ QWebEnginePage.DesktopAudioVideoCapture: 'content.desktop_capture',
}
_messages = {
@@ -841,42 +845,15 @@ class _WebEnginePermissions(QObject):
QWebEnginePage.MediaAudioCapture: 'record audio',
QWebEnginePage.MediaVideoCapture: 'record video',
QWebEnginePage.MediaAudioVideoCapture: 'record audio/video',
+ QWebEnginePage.MouseLock: 'hide your mouse pointer',
+ QWebEnginePage.DesktopVideoCapture: 'capture your desktop',
+ QWebEnginePage.DesktopAudioVideoCapture: 'capture your desktop and audio',
}
def __init__(self, tab, parent=None):
super().__init__(parent)
self._tab = tab
- self._widget = typing.cast(QWidget, None)
-
- try:
- self._options.update({
- QWebEnginePage.MouseLock:
- 'content.mouse_lock',
- })
- self._messages.update({
- QWebEnginePage.MouseLock:
- 'hide your mouse pointer',
- })
- except AttributeError:
- # Added in Qt 5.8
- pass
- try:
- self._options.update({
- QWebEnginePage.DesktopVideoCapture:
- 'content.desktop_capture',
- QWebEnginePage.DesktopAudioVideoCapture:
- 'content.desktop_capture',
- })
- self._messages.update({
- QWebEnginePage.DesktopVideoCapture:
- 'capture your desktop',
- QWebEnginePage.DesktopAudioVideoCapture:
- 'capture your desktop and audio',
- })
- except AttributeError:
- # Added in Qt 5.10
- pass
-
+ self._widget = cast(QWidget, None)
assert self._options.keys() == self._messages.keys()
def connect_signals(self):
@@ -887,11 +864,9 @@ class _WebEnginePermissions(QObject):
page.featurePermissionRequested.connect(
self._on_feature_permission_requested)
- if qtutils.version_check('5.11'):
- page.quotaRequested.connect(
- self._on_quota_requested)
- page.registerProtocolHandlerRequested.connect(
- self._on_register_protocol_handler_requested)
+ page.quotaRequested.connect(self._on_quota_requested)
+ page.registerProtocolHandlerRequested.connect(
+ self._on_register_protocol_handler_requested)
@pyqtSlot('QWebEngineFullScreenRequest')
def _on_fullscreen_requested(self, request):
@@ -918,14 +893,28 @@ 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
+ is_qtbug = (qtutils.version_check('5.15.0',
+ compiled=False,
+ exact=True) and
+ self._tab.is_private and
+ feature == QWebEnginePage.Notifications)
+ logger = log.webview.debug if is_qtbug else log.webview.warning
+ logger("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
if (
- hasattr(QWebEnginePage, 'DesktopVideoCapture') and
feature in [QWebEnginePage.DesktopVideoCapture,
QWebEnginePage.DesktopAudioVideoCapture] and
qtutils.version_check('5.13', compiled=False) and
@@ -987,7 +976,7 @@ class _WebEngineScripts(QObject):
def __init__(self, tab, parent=None):
super().__init__(parent)
self._tab = tab
- self._widget = typing.cast(QWidget, None)
+ self._widget = cast(QWidget, None)
self._greasemonkey = greasemonkey.gm_manager
def connect_signals(self):
@@ -1014,35 +1003,21 @@ class _WebEngineScripts(QObject):
def _inject_early_js(self, name, js_code, *,
world=QWebEngineScript.ApplicationWorld,
subframes=False):
- """Inject the given script to run early on a page load.
-
- This runs the script both on DocumentCreation and DocumentReady as on
- some internal pages, DocumentCreation will not work.
-
- That is a WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66011
- """
- scripts = self._widget.page().scripts()
- for injection in ['creation', 'ready']:
- injection_points = {
- 'creation': QWebEngineScript.DocumentCreation,
- 'ready': QWebEngineScript.DocumentReady,
- }
- script = QWebEngineScript()
- script.setInjectionPoint(injection_points[injection])
- script.setSourceCode(js_code)
- script.setWorldId(world)
- script.setRunsOnSubFrames(subframes)
- script.setName('_qute_{}_{}'.format(name, injection))
- scripts.insert(script)
+ """Inject the given script to run early on a page load."""
+ script = QWebEngineScript()
+ script.setInjectionPoint(QWebEngineScript.DocumentCreation)
+ script.setSourceCode(js_code)
+ script.setWorldId(world)
+ script.setRunsOnSubFrames(subframes)
+ script.setName(f'_qute_{name}')
+ self._widget.page().scripts().insert(script)
def _remove_early_js(self, name):
"""Remove an early QWebEngineScript."""
scripts = self._widget.page().scripts()
- for injection in ['creation', 'ready']:
- full_name = '_qute_{}_{}'.format(name, injection)
- script = scripts.findScript(full_name)
- if not script.isNull():
- scripts.remove(script)
+ script = scripts.findScript(f'_qute_{name}')
+ if not script.isNull():
+ scripts.remove(script)
def init(self):
"""Initialize global qutebrowser JavaScript."""
@@ -1052,29 +1027,14 @@ class _WebEngineScripts(QObject):
utils.read_file('javascript/webelem.js'),
utils.read_file('javascript/caret.js'),
)
- if not qtutils.version_check('5.12'):
- # WORKAROUND for Qt versions < 5.12 not exposing window.print().
- # Qt 5.12 has a printRequested() signal so we don't need this hack
- # anymore.
- self._inject_early_js('js',
- utils.read_file('javascript/print.js'),
- subframes=True,
- world=QWebEngineScript.MainWorld)
# FIXME:qtwebengine what about subframes=True?
self._inject_early_js('js', js_code, subframes=True)
self._init_stylesheet()
- # The Greasemonkey metadata block support in QtWebEngine only starts at
- # Qt 5.8. With 5.7.1, we need to inject the scripts ourselves in
- # response to urlChanged.
- if not qtutils.version_check('5.8'):
- self._tab.url_changed.connect(
- self._inject_greasemonkey_scripts_for_url)
- else:
- self._greasemonkey.scripts_reloaded.connect(
- self._inject_all_greasemonkey_scripts)
- self._inject_all_greasemonkey_scripts()
- self._inject_site_specific_quirks()
+ self._greasemonkey.scripts_reloaded.connect(
+ self._inject_all_greasemonkey_scripts)
+ self._inject_all_greasemonkey_scripts()
+ self._inject_site_specific_quirks()
def _init_stylesheet(self):
"""Initialize custom stylesheets.
@@ -1091,16 +1051,6 @@ class _WebEngineScripts(QObject):
)
self._inject_early_js('stylesheet', js_code, subframes=True)
- @pyqtSlot(QUrl)
- def _inject_greasemonkey_scripts_for_url(self, url):
- matching_scripts = self._greasemonkey.scripts_for(url)
- self._inject_greasemonkey_scripts(
- matching_scripts.start, QWebEngineScript.DocumentCreation, True)
- self._inject_greasemonkey_scripts(
- matching_scripts.end, QWebEngineScript.DocumentReady, False)
- self._inject_greasemonkey_scripts(
- matching_scripts.idle, QWebEngineScript.Deferred, False)
-
@pyqtSlot()
def _inject_all_greasemonkey_scripts(self):
scripts = self._greasemonkey.all_scripts()
@@ -1183,22 +1133,31 @@ class _WebEngineScripts(QObject):
page_scripts.insert(new_script)
def _inject_site_specific_quirks(self):
- """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.
- """
+ """Add site-specific quirk scripts."""
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))
+ quirks.append(('object_fromentries_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)
@@ -1225,6 +1184,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):
@@ -1238,7 +1200,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)
@@ -1262,7 +1227,6 @@ class WebEngineTab(browsertab.AbstractTab):
self.backend = usertypes.Backend.QtWebEngine
self._child_event_filter = None
self._saved_zoom = None
- self._reload_url = None # type: typing.Optional[QUrl]
self._scripts.init()
def _set_widget(self, widget):
@@ -1275,9 +1239,11 @@ class WebEngineTab(browsertab.AbstractTab):
fp = self._widget.focusProxy()
if fp is not None:
fp.installEventFilter(self._tab_event_filter)
+
self._child_event_filter = eventfilter.ChildEventFilter(
- eventfilter=self._tab_event_filter, widget=self._widget,
- win_id=self.win_id, parent=self)
+ eventfilter=self._tab_event_filter,
+ widget=self._widget,
+ parent=self)
self._widget.installEventFilter(self._child_event_filter)
@pyqtSlot()
@@ -1290,20 +1256,17 @@ class WebEngineTab(browsertab.AbstractTab):
self.zoom.set_factor(self._saved_zoom)
self._saved_zoom = None
- def load_url(self, url, *, emit_before_load_started=True):
+ def load_url(self, url):
"""Load the given URL in this tab.
Arguments:
url: The QUrl to load.
- emit_before_load_started: If set to False, before_load_started is
- not emitted.
"""
if sip.isdeleted(self._widget):
# https://github.com/qutebrowser/qutebrowser/issues/3896
return
self._saved_zoom = self.zoom.factor()
- self._load_url_prepare(
- url, emit_before_load_started=emit_before_load_started)
+ self._load_url_prepare(url)
self._widget.load(url)
def url(self, *, requested=False):
@@ -1320,9 +1283,9 @@ class WebEngineTab(browsertab.AbstractTab):
self._widget.page().toHtml(callback)
def run_js_async(self, code, callback=None, *, world=None):
- world_id_type = typing.Union[QWebEngineScript.ScriptWorldId, int]
+ world_id_type = Union[QWebEngineScript.ScriptWorldId, int]
if world is None:
- world_id = QWebEngineScript.ApplicationWorld # type: world_id_type
+ world_id: world_id_type = QWebEngineScript.ApplicationWorld
elif isinstance(world, int):
world_id = world
if not 0 <= world_id <= qtutils.MAX_WORLD_ID:
@@ -1350,6 +1313,14 @@ class WebEngineTab(browsertab.AbstractTab):
def title(self):
return self._widget.title()
+ def renderer_process_pid(self) -> Optional[int]:
+ page = self._widget.page()
+ try:
+ return page.renderProcessPid()
+ except AttributeError:
+ # Added in Qt 5.15
+ return None
+
def icon(self):
return self._widget.icon()
@@ -1390,7 +1361,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 = ""
@@ -1412,14 +1383,13 @@ 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
- except AttributeError:
- self._show_error_page(url, "Proxy authentication required")
+
+ if answer is None:
+ sip.assign(authenticator, QAuthenticator())
+ return
+
+ authenticator.setUser(answer.user)
+ authenticator.setPassword(answer.password)
@pyqtSlot(QUrl, 'QAuthenticator*')
def _on_authentication_required(self, url, authenticator):
@@ -1437,12 +1407,7 @@ class WebEngineTab(browsertab.AbstractTab):
url, authenticator, abort_on=[self.abort_questions])
if not netrc_success and answer is None:
log.network.debug("Aborting auth")
- try:
- sip.assign(authenticator, QAuthenticator()) # type: ignore
- except AttributeError:
- # WORKAROUND for
- # https://www.riverbankcomputing.com/pipermail/pyqt/2016-December/038400.html
- self._show_error_page(url, "Authentication required")
+ sip.assign(authenticator, QAuthenticator())
@pyqtSlot()
def _on_load_started(self):
@@ -1454,6 +1419,11 @@ class WebEngineTab(browsertab.AbstractTab):
super()._on_load_started()
self.data.netrc_used = False
+ @pyqtSlot('qint64')
+ def _on_renderer_process_pid_changed(self, pid):
+ log.webview.debug("Renderer process PID for tab {}: {}"
+ .format(self.tab_id, pid))
+
@pyqtSlot(QWebEnginePage.RenderProcessTerminationStatus, int)
def _on_render_process_terminated(self, status, exitcode):
"""Show an error when the renderer process terminated."""
@@ -1485,9 +1455,6 @@ class WebEngineTab(browsertab.AbstractTab):
WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66643
WORKAROUND for https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=882805
-
- Needs to check the page content as a WORKAROUND for
- https://bugreports.qt.io/browse/QTBUG-66661
"""
match = re.search(r'"errorCode":"([^"]*)"', html)
if match is None:
@@ -1510,7 +1477,6 @@ class WebEngineTab(browsertab.AbstractTab):
"""
super()._on_load_progress(perc)
if (perc == 100 and
- qtutils.version_check('5.10', compiled=False) and
self.load_status() != usertypes.LoadStatus.error):
self._update_load_status(ok=True)
@@ -1519,62 +1485,42 @@ class WebEngineTab(browsertab.AbstractTab):
"""QtWebEngine-specific loadFinished workarounds."""
super()._on_load_finished(ok)
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-65223
- if qtutils.version_check('5.10', compiled=False):
- if not ok:
- self._update_load_status(ok)
- else:
+ if not ok:
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-65223
self._update_load_status(ok)
- if not ok:
self.dump_async(functools.partial(
self._error_page_workaround,
self.settings.test_attribute('content.javascript.enabled')))
- if ok and self._reload_url is not None:
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656
- log.config.debug(
- "Loading {} again because of config change".format(
- self._reload_url.toDisplayString()))
- QTimer.singleShot(100, functools.partial(
- self.load_url, self._reload_url,
- emit_before_load_started=False))
- self._reload_url = None
-
@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
- show_cert_error = (
- not qtutils.version_check('5.9') and
- not error.ignore
- )
# WORKAROUND for https://codereview.qt-project.org/c/qt/qtwebengine/+/270556
show_non_overr_cert_error = (
not error.is_overridable() and (
# Affected Qt versions:
# 5.13 before 5.13.2
# 5.12 before 5.12.6
- # < 5.12
+ # < 5.12 (which is unsupported)
(qtutils.version_check('5.13') and
not qtutils.version_check('5.13.2')) or
(qtutils.version_check('5.12') and
- not qtutils.version_check('5.12.6')) or
- not qtutils.version_check('5.12')
+ not qtutils.version_check('5.12.6'))
)
)
@@ -1583,20 +1529,10 @@ class WebEngineTab(browsertab.AbstractTab):
# However, self.url() is not available yet and the requested URL
# might not match the URL we get from the error - so we just apply a
# heuristic here.
- if ((show_cert_error or show_non_overr_cert_error) and
+ if (show_non_overr_cert_error and
url.matches(self.data.last_navigation.url, QUrl.RemoveScheme)):
self._show_error_page(url, str(error))
- @pyqtSlot(QUrl)
- def _on_before_load_started(self, url):
- """If we know we're going to visit a URL soon, change the settings.
-
- This is a WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656
- """
- super()._on_before_load_started(url)
- if not qtutils.version_check('5.11.1', compiled=False):
- self.settings.update_for_url(url)
-
@pyqtSlot()
def _on_print_requested(self):
"""Slot for window.print() in JS."""
@@ -1624,38 +1560,10 @@ class WebEngineTab(browsertab.AbstractTab):
def _on_navigation_request(self, navigation):
super()._on_navigation_request(navigation)
- if navigation.url == QUrl('qute://print'):
- self._on_print_requested()
- navigation.accepted = False
-
if not navigation.accepted or not navigation.is_main_frame:
return
- settings_needing_reload = {
- 'content.plugins',
- 'content.javascript.enabled',
- 'content.javascript.can_access_clipboard',
- 'content.print_element_backgrounds',
- 'input.spatial_navigation',
- }
- assert settings_needing_reload.issubset(configdata.DATA)
-
- changed = self.settings.update_for_url(navigation.url)
- reload_needed = bool(changed & settings_needing_reload)
-
- # On Qt < 5.11, we don't don't need a reload when type == link_clicked.
- # On Qt 5.11.0, we always need a reload.
- # On Qt > 5.11.0, we never need a reload:
- # https://codereview.qt-project.org/#/c/229525/1
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656
- if qtutils.version_check('5.11.1', compiled=False):
- reload_needed = False
- elif not qtutils.version_check('5.11.0', exact=True, compiled=False):
- if navigation.navigation_type == navigation.Type.link_clicked:
- reload_needed = False
-
- if reload_needed:
- self._reload_url = navigation.url
+ self.settings.update_for_url(navigation.url)
def _on_select_client_certificate(self, selection):
"""Handle client certificates.
@@ -1702,13 +1610,11 @@ class WebEngineTab(browsertab.AbstractTab):
self._on_proxy_authentication_required)
page.contentsSizeChanged.connect(self.contents_size_changed)
page.navigation_request.connect(self._on_navigation_request)
-
- if qtutils.version_check('5.12'):
- page.printRequested.connect(self._on_print_requested)
+ page.printRequested.connect(self._on_print_requested)
try:
# pylint: disable=unused-import
- from PyQt5.QtWebEngineWidgets import ( # type: ignore
+ from PyQt5.QtWebEngineWidgets import (
QWebEngineClientCertificateSelection)
except ImportError:
pass
@@ -1726,9 +1632,14 @@ class WebEngineTab(browsertab.AbstractTab):
page.loadFinished.connect(self._restore_zoom)
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
+ try:
+ page.renderProcessPidChanged.connect(self._on_renderer_process_pid_changed)
+ except AttributeError:
+ # Added in Qt 5.15.0
+ pass
+
+ self.shutting_down.connect(self.abort_questions)
+ self.load_started.connect(self.abort_questions)
# pylint: disable=protected-access
self.audio._connect_signals()
diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py
index c41c61f7f..23af73e59 100644
--- a/qutebrowser/browser/webengine/webview.py
+++ b/qutebrowser/browser/webengine/webview.py
@@ -19,17 +19,15 @@
"""The main browser widget for QtWebEngine."""
-from PyQt5.QtCore import pyqtSignal, QUrl, PYQT_VERSION
+
+from PyQt5.QtCore import pyqtSignal, QUrl
from PyQt5.QtGui import QPalette
-from PyQt5.QtWidgets import QWidget
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
from qutebrowser.browser import shared
from qutebrowser.browser.webengine import webenginesettings, certificateerror
from qutebrowser.config import config
-from qutebrowser.utils import log, debug, usertypes, qtutils
-from qutebrowser.misc import miscwidgets, objects
-from qutebrowser.qt import sip
+from qutebrowser.utils import log, debug, usertypes
class WebEngineView(QWebEngineView):
@@ -52,36 +50,9 @@ class WebEngineView(QWebEngineView):
parent=self)
self.setPage(page)
- if qtutils.version_check('5.11', compiled=False):
- # Set a PseudoLayout as a WORKAROUND for
- # https://bugreports.qt.io/browse/QTBUG-68224
- # and other related issues.
- sip.delete(self.layout())
- self._layout = miscwidgets.PseudoLayout(self)
-
def render_widget(self):
- """Get the RenderWidgetHostViewQt for this view.
-
- Normally, this would always be the focusProxy().
- 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.
- """
- if 'lost-focusproxy' not in objects.debug_flags:
- proxy = self.focusProxy()
- if proxy is not None:
- 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))
-
- return children[-1] if children else None
+ """Get the RenderWidgetHostViewQt for this view."""
+ return self.focusProxy()
def shutdown(self):
self.page().shutdown()
@@ -143,6 +114,13 @@ class WebEngineView(QWebEngineView):
tab = shared.get_tab(self._win_id, target)
return tab._widget # pylint: disable=protected-access
+ def contextMenuEvent(self, ev):
+ """Prevent context menus when rocker gestures are enabled."""
+ if config.val.input.mouse.rocker_gestures:
+ ev.ignore()
+ return
+ super().contextMenuEvent(ev)
+
class WebEnginePage(QWebEnginePage):
@@ -192,43 +170,29 @@ class WebEnginePage(QWebEnginePage):
"""Override javaScriptConfirm to use qutebrowser prompts."""
if self._is_shutting_down:
return False
- escape_msg = qtutils.version_check('5.11', compiled=False)
try:
- return shared.javascript_confirm(url, js_msg,
- abort_on=[self.loadStarted,
- self.shutting_down],
- escape_msg=escape_msg)
+ return shared.javascript_confirm(
+ url, js_msg, abort_on=[self.loadStarted, self.shutting_down])
except shared.CallSuper:
return super().javaScriptConfirm(url, js_msg)
- if PYQT_VERSION > 0x050700:
- # WORKAROUND
- # Can't override javaScriptPrompt with older PyQt versions
- # https://www.riverbankcomputing.com/pipermail/pyqt/2016-November/038293.html
- def javaScriptPrompt(self, url, js_msg, default):
- """Override javaScriptPrompt to use qutebrowser prompts."""
- escape_msg = qtutils.version_check('5.11', compiled=False)
- if self._is_shutting_down:
- return (False, "")
- try:
- return shared.javascript_prompt(url, js_msg, default,
- abort_on=[self.loadStarted,
- self.shutting_down],
- escape_msg=escape_msg)
- except shared.CallSuper:
- return super().javaScriptPrompt( # type: ignore
- url, js_msg, default)
+ def javaScriptPrompt(self, url, js_msg, default):
+ """Override javaScriptPrompt to use qutebrowser prompts."""
+ if self._is_shutting_down:
+ return (False, "")
+ try:
+ return shared.javascript_prompt(
+ url, js_msg, default, abort_on=[self.loadStarted, self.shutting_down])
+ except shared.CallSuper:
+ return super().javaScriptPrompt(url, js_msg, default)
def javaScriptAlert(self, url, js_msg):
"""Override javaScriptAlert to use qutebrowser prompts."""
if self._is_shutting_down:
return
- escape_msg = qtutils.version_check('5.11', compiled=False)
try:
- shared.javascript_alert(url, js_msg,
- abort_on=[self.loadStarted,
- self.shutting_down],
- escape_msg=escape_msg)
+ shared.javascript_alert(
+ url, js_msg, abort_on=[self.loadStarted, self.shutting_down])
except shared.CallSuper:
super().javaScriptAlert(url, js_msg)
diff --git a/qutebrowser/browser/webkit/cache.py b/qutebrowser/browser/webkit/cache.py
index 7202ebd23..79c4e9e82 100644
--- a/qutebrowser/browser/webkit/cache.py
+++ b/qutebrowser/browser/webkit/cache.py
@@ -19,16 +19,16 @@
"""HTTP network cache."""
-import typing
+from typing import cast
import os.path
from PyQt5.QtNetwork import QNetworkDiskCache
from qutebrowser.config import config
-from qutebrowser.utils import utils, qtutils, standarddir
+from qutebrowser.utils import utils, standarddir
-diskcache = typing.cast('DiskCache', None)
+diskcache = cast('DiskCache', None)
class DiskCache(QNetworkDiskCache):
@@ -52,9 +52,6 @@ class DiskCache(QNetworkDiskCache):
size = config.val.content.cache.size
if size is None:
size = 1024 * 1024 * 50 # default from QNetworkDiskCachePrivate
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-59909
- if not qtutils.version_check('5.9', compiled=False):
- size = 0 # pragma: no cover
self.setMaximumCacheSize(size)
diff --git a/qutebrowser/browser/webkit/cookies.py b/qutebrowser/browser/webkit/cookies.py
index e51350331..9cc28cf69 100644
--- a/qutebrowser/browser/webkit/cookies.py
+++ b/qutebrowser/browser/webkit/cookies.py
@@ -19,14 +19,14 @@
"""Handling of HTTP cookies."""
-import typing
+from typing import Sequence
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()
@@ -87,9 +93,10 @@ class CookieJar(RAMCookieJar):
def parse_cookies(self):
"""Parse cookies from lineparser and store them."""
- cookies = [] # type: typing.Sequence[QNetworkCookie]
+ cookies: 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/mhtml.py b/qutebrowser/browser/webkit/mhtml.py
index abdb4b6ea..794e1b73b 100644
--- a/qutebrowser/browser/webkit/mhtml.py
+++ b/qutebrowser/browser/webkit/mhtml.py
@@ -33,7 +33,7 @@ import email.encoders
import email.mime.multipart
import email.message
import quopri
-import typing
+from typing import MutableMapping, Set, Tuple
import attr
from PyQt5.QtCore import QUrl
@@ -62,7 +62,7 @@ _CSS_URL_PATTERNS = [re.compile(x) for x in [
]]
-def _get_css_imports_regex(data):
+def _get_css_imports(data):
"""Return all assets that are referenced in the given CSS document.
The returned URLs are relative to the stylesheet's URL.
@@ -79,58 +79,6 @@ def _get_css_imports_regex(data):
return urls
-def _get_css_imports_cssutils(data, inline=False):
- """Return all assets that are referenced in the given CSS document.
-
- The returned URLs are relative to the stylesheet's URL.
-
- Args:
- data: The content of the stylesheet to scan as string.
- inline: True if the argument is an inline HTML style attribute.
- """
- try:
- import cssutils
- except (ImportError, re.error):
- # Catching re.error because cssutils in earlier releases (<= 1.0) is
- # broken on Python 3.5
- # See https://bitbucket.org/cthedot/cssutils/issues/52
- return None
-
- # We don't care about invalid CSS data, this will only litter the log
- # output with CSS errors
- parser = cssutils.CSSParser(loglevel=100,
- fetcher=lambda url: (None, ""), validate=False)
- if not inline:
- sheet = parser.parseString(data)
- return list(cssutils.getUrls(sheet))
- else:
- urls = []
- declaration = parser.parseStyle(data)
- # prop = background, color, margin, ...
- for prop in declaration:
- # value = red, 10px, url(foobar), ...
- for value in prop.propertyValue:
- if isinstance(value, cssutils.css.URIValue):
- if value.uri:
- urls.append(value.uri)
- return urls
-
-
-def _get_css_imports(data, inline=False):
- """Return all assets that are referenced in the given CSS document.
-
- The returned URLs are relative to the stylesheet's URL.
-
- Args:
- data: The content of the stylesheet to scan as string.
- inline: True if the argument is an inline HTML style attribute.
- """
- imports = _get_css_imports_cssutils(data, inline)
- if imports is None:
- imports = _get_css_imports_regex(data)
- return imports
-
-
def _check_rel(element):
"""Return true if the element's rel attribute fits our criteria.
@@ -189,7 +137,7 @@ class MHTMLWriter:
self.root_content = root_content
self.content_location = content_location
self.content_type = content_type
- self._files = {} # type: typing.MutableMapping[QUrl, _File]
+ self._files: MutableMapping[QUrl, _File] = {}
def add_file(self, location, content, content_type=None,
transfer_encoding=E_QUOPRI):
@@ -244,8 +192,7 @@ class MHTMLWriter:
return msg
-_PendingDownloadType = typing.Set[
- typing.Tuple[QUrl, downloads.AbstractDownloadItem]]
+_PendingDownloadType = Set[Tuple[QUrl, downloads.AbstractDownloadItem]]
class _Downloader:
@@ -268,7 +215,7 @@ class _Downloader:
self.target = target
self.writer = None
self.loaded_urls = {tab.url()}
- self.pending_downloads = set() # type: _PendingDownloadType
+ self.pending_downloads: _PendingDownloadType = set()
self._finished_file = False
self._used = False
@@ -332,7 +279,7 @@ class _Downloader:
for element in web_frame.findAllElements('[style]'):
element = webkitelem.WebKitElement(element, tab=self.tab)
style = element['style']
- for element_url in _get_css_imports(style, inline=True):
+ for element_url in _get_css_imports(style):
self._fetch_url(web_url.resolved(QUrl(element_url)))
# Shortcut if no assets need to be downloaded, otherwise the file would
@@ -562,7 +509,7 @@ def start_download_checked(target, tab):
return
# The default name is 'page title.mhtml'
title = tab.title()
- default_name = utils.sanitize_filename(title + '.mhtml')
+ default_name = utils.sanitize_filename(title + '.mhtml', shorten=True)
# Remove characters which cannot be expressed in the file system encoding
encoding = sys.getfilesystemencoding()
diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py
index 66b5132c4..3e393680c 100644
--- a/qutebrowser/browser/webkit/network/networkmanager.py
+++ b/qutebrowser/browser/webkit/network/networkmanager.py
@@ -21,7 +21,7 @@
import collections
import html
-import typing
+from typing import TYPE_CHECKING, Dict, MutableMapping, Optional, Sequence
import attr
from PyQt5.QtCore import (pyqtSlot, pyqtSignal, QCoreApplication, QUrl,
@@ -40,12 +40,12 @@ from qutebrowser.browser.webkit.network import (webkitqutescheme, networkreply,
filescheme)
from qutebrowser.misc import objects
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.mainwindow import prompt
HOSTBLOCK_ERROR_STRING = '%HOSTBLOCK%'
-_proxy_auth_cache = {} # type: typing.Dict[ProxyId, prompt.AuthInfo]
+_proxy_auth_cache: Dict['ProxyId', 'prompt.AuthInfo'] = {}
@attr.s(frozen=True)
@@ -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,13 +117,13 @@ 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,
- typing.Sequence[QSslError]]
+_SavedErrorsType = MutableMapping[urlutils.HostTupleType, Sequence[QSslError]]
class NetworkManager(QNetworkAccessManager):
@@ -154,10 +155,7 @@ class NetworkManager(QNetworkAccessManager):
def __init__(self, *, win_id, tab_id, private, parent=None):
log.init.debug("Initializing NetworkManager")
- with log.disable_qt_msghandler():
- # WORKAROUND for a hang when a message is printed - See:
- # http://www.riverbankcomputing.com/pipermail/pyqt/2014-November/035045.html
- super().__init__(parent)
+ super().__init__(parent)
log.init.debug("NetworkManager init done")
self.adopted_downloads = 0
self._win_id = win_id
@@ -169,14 +167,13 @@ class NetworkManager(QNetworkAccessManager):
}
self._set_cookiejar()
self._set_cache()
- self.sslErrors.connect(self.on_ssl_errors) # type: ignore
- self._rejected_ssl_errors = collections.defaultdict(
- list) # type: _SavedErrorsType
- self._accepted_ssl_errors = collections.defaultdict(
- list) # type: _SavedErrorsType
- self.authenticationRequired.connect( # type: ignore
+ self.sslErrors.connect( # type: ignore[attr-defined]
+ self.on_ssl_errors)
+ self._rejected_ssl_errors: _SavedErrorsType = collections.defaultdict(list)
+ self._accepted_ssl_errors: _SavedErrorsType = collections.defaultdict(list)
+ 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,11 +232,11 @@ 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(
- reply.url()) # type: typing.Optional[urlutils.HostTupleType]
+ host_tpl: Optional[urlutils.HostTupleType] = urlutils.host_tuple(
+ reply.url())
except ValueError:
host_tpl = None
is_accepted = False
@@ -251,7 +248,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:
@@ -368,13 +365,6 @@ class NetworkManager(QNetworkAccessManager):
# https://www.playstation.com/ for example.
pass
- # WORKAROUND for:
- # http://www.riverbankcomputing.com/pipermail/pyqt/2014-September/034806.html
- #
- # By returning False, we provoke a TypeError because of a wrong return
- # type, which does *not* trigger a segfault but invoke our return handler
- # immediately.
- @utils.prevent_exceptions(False)
def createRequest(self, op, req, outgoing_data):
"""Return a new QNetworkReply object.
@@ -393,6 +383,13 @@ class NetworkManager(QNetworkAccessManager):
req, proxy_error, QNetworkReply.UnknownProxyError,
self)
+ if not req.url().isValid():
+ log.network.debug("Ignoring invalid requested URL: {}".format(
+ req.url().errorString()))
+ return networkreply.ErrorNetworkReply(
+ req, "Invalid request URL", QNetworkReply.HostNotFoundError,
+ self)
+
for header, value in shared.custom_headers(url=req.url()):
req.setRawHeader(header, value)
@@ -424,7 +421,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/tabhistory.py b/qutebrowser/browser/webkit/tabhistory.py
index f293edacd..f0673036c 100644
--- a/qutebrowser/browser/webkit/tabhistory.py
+++ b/qutebrowser/browser/webkit/tabhistory.py
@@ -19,7 +19,7 @@
"""Utilities related to QWebHistory."""
-import typing
+from typing import Any, List, Mapping
from PyQt5.QtCore import QByteArray, QDataStream, QIODevice, QUrl
@@ -81,7 +81,7 @@ def serialize(items):
"""
data = QByteArray()
stream = QDataStream(data, QIODevice.ReadWrite)
- user_data = [] # type: typing.List[typing.Mapping[str, typing.Any]]
+ user_data: List[Mapping[str, Any]] = []
current_idx = None
diff --git a/qutebrowser/browser/webkit/webkitelem.py b/qutebrowser/browser/webkit/webkitelem.py
index 1000a9965..2f3562b7f 100644
--- a/qutebrowser/browser/webkit/webkitelem.py
+++ b/qutebrowser/browser/webkit/webkitelem.py
@@ -19,7 +19,7 @@
"""QtWebKit specific part of the web element API."""
-import typing
+from typing import cast, TYPE_CHECKING, Iterator, List, Optional, Set
from PyQt5.QtCore import QRect, Qt
from PyQt5.QtWebKit import QWebElement, QWebSettings
@@ -29,7 +29,7 @@ from qutebrowser.config import config
from qutebrowser.utils import log, utils, javascript, usertypes
from qutebrowser.browser import webelem
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.browser.webkit import webkittab
@@ -42,6 +42,8 @@ class WebKitElement(webelem.AbstractWebElement):
"""A wrapper around a QWebElement."""
+ _tab: 'webkittab.WebKitTab'
+
def __init__(self, elem: QWebElement, tab: 'webkittab.WebKitTab') -> None:
super().__init__(tab)
if isinstance(elem, self.__class__):
@@ -80,7 +82,7 @@ class WebKitElement(webelem.AbstractWebElement):
self._check_vanished()
return self._elem.hasAttribute(key)
- def __iter__(self) -> typing.Iterator[str]:
+ def __iter__(self) -> Iterator[str]:
self._check_vanished()
yield from self._elem.attributeNames()
@@ -101,9 +103,9 @@ class WebKitElement(webelem.AbstractWebElement):
self._check_vanished()
return self._elem.geometry()
- def classes(self) -> typing.List[str]:
+ def classes(self) -> Set[str]:
self._check_vanished()
- return self._elem.classes()
+ return set(self._elem.classes())
def tag_name(self) -> str:
"""Get the tag name for the current element."""
@@ -115,6 +117,12 @@ class WebKitElement(webelem.AbstractWebElement):
self._check_vanished()
return self._elem.toOuterXml()
+ def is_content_editable_prop(self) -> bool:
+ self._check_vanished()
+ val = self._elem.evaluateJavaScript('this.isContentEditable || false')
+ assert isinstance(val, bool)
+ return val
+
def value(self) -> webelem.JsValueType:
self._check_vanished()
val = self._elem.evaluateJavaScript('this.value')
@@ -168,16 +176,16 @@ class WebKitElement(webelem.AbstractWebElement):
this.dispatchEvent(event);
""".format(javascript.to_js(text)))
- def _parent(self) -> typing.Optional['WebKitElement']:
+ def _parent(self) -> Optional['WebKitElement']:
"""Get the parent element of this element."""
self._check_vanished()
- elem = typing.cast(typing.Optional[QWebElement],
- self._elem.parent())
+ elem = cast(Optional[QWebElement], self._elem.parent())
if elem is None or elem.isNull():
return None
+
return WebKitElement(elem, tab=self._tab)
- def _rect_on_view_js(self) -> typing.Optional[QRect]:
+ def _rect_on_view_js(self) -> Optional[QRect]:
"""Javascript implementation for rect_on_view."""
# FIXME:qtwebengine maybe we can reuse this?
rects = self._elem.evaluateJavaScript("this.getClientRects()")
@@ -189,7 +197,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))):
@@ -206,29 +214,32 @@ class WebKitElement(webelem.AbstractWebElement):
height *= zoom
rect = QRect(int(rect["left"]), int(rect["top"]),
int(width), int(height))
- frame = self._elem.webFrame()
+
+ frame = cast(Optional[QWebFrame], self._elem.webFrame())
while frame is not None:
# Translate to parent frames' position (scroll position
# is taken care of inside getClientRects)
rect.translate(frame.geometry().topLeft())
frame = frame.parentFrame()
+
return rect
return None
- def _rect_on_view_python(self,
- elem_geometry: typing.Optional[QRect]) -> QRect:
+ def _rect_on_view_python(self, elem_geometry: Optional[QRect]) -> QRect:
"""Python implementation for rect_on_view."""
if elem_geometry is None:
geometry = self._elem.geometry()
else:
geometry = elem_geometry
- frame = self._elem.webFrame()
rect = QRect(geometry)
+
+ frame = cast(Optional[QWebFrame], self._elem.webFrame())
while frame is not None:
rect.translate(frame.geometry().topLeft())
rect.translate(frame.scrollPosition() * -1)
- frame = frame.parentFrame()
+ frame = cast(Optional[QWebFrame], frame.parentFrame())
+
return rect
def rect_on_view(self, *, elem_geometry: QRect = None,
@@ -262,6 +273,20 @@ class WebKitElement(webelem.AbstractWebElement):
# No suitable rects found via JS, try via the QWebElement API
return self._rect_on_view_python(elem_geometry)
+ def _is_hidden_css(self) -> bool:
+ """Check if the given element is hidden via CSS."""
+ attr_values = {
+ attr: self._elem.styleProperty(attr, QWebElement.ComputedStyle)
+ for attr in ['visibility', 'display', 'opacity']
+ }
+ invisible = attr_values['visibility'] == 'hidden'
+ none_display = attr_values['display'] == 'none'
+ zero_opacity = attr_values['opacity'] == '0'
+
+ is_framework = ('ace_text-input' in self.classes() or
+ 'custom-control-input' in self.classes())
+ return invisible or none_display or (zero_opacity and not is_framework)
+
def _is_visible(self, mainframe: QWebFrame) -> bool:
"""Check if the given element is visible in the given frame.
@@ -270,16 +295,8 @@ class WebKitElement(webelem.AbstractWebElement):
the tab API.
"""
self._check_vanished()
- # CSS attributes which hide an element
- hidden_attributes = {
- 'visibility': 'hidden',
- 'display': 'none',
- 'opacity': '0',
- }
- for k, v in hidden_attributes.items():
- if (self._elem.styleProperty(k, QWebElement.ComputedStyle) == v and
- 'ace_text-input' not in self.classes()):
- return False
+ if self._is_hidden_css():
+ return False
elem_geometry = self._elem.geometry()
if not elem_geometry.isValid() and elem_geometry.x() == 0:
@@ -315,7 +332,7 @@ class WebKitElement(webelem.AbstractWebElement):
return all([visible_on_screen, visible_in_frame])
def remove_blank_target(self) -> None:
- elem = self # type: typing.Optional[WebKitElement]
+ elem: Optional[WebKitElement] = self
for _ in range(5):
if elem is None:
break
@@ -360,7 +377,7 @@ class WebKitElement(webelem.AbstractWebElement):
super()._click_fake_event(click_target)
-def get_child_frames(startframe: QWebFrame) -> typing.List[QWebFrame]:
+def get_child_frames(startframe: QWebFrame) -> List[QWebFrame]:
"""Get all children recursively of a given QWebFrame.
Loosely based on http://blog.nextgenetics.net/?e=64
@@ -374,7 +391,7 @@ def get_child_frames(startframe: QWebFrame) -> typing.List[QWebFrame]:
results = []
frames = [startframe]
while frames:
- new_frames = [] # type: typing.List[QWebFrame]
+ new_frames: List[QWebFrame] = []
for frame in frames:
results.append(frame)
new_frames += frame.childFrames()
diff --git a/qutebrowser/browser/webkit/webkitinspector.py b/qutebrowser/browser/webkit/webkitinspector.py
index b08bbea22..603a0a2bb 100644
--- a/qutebrowser/browser/webkit/webkitinspector.py
+++ b/qutebrowser/browser/webkit/webkitinspector.py
@@ -20,21 +20,25 @@
"""Customized QWebInspector for QtWebKit."""
from PyQt5.QtWebKit import QWebSettings
-from PyQt5.QtWebKitWidgets import QWebInspector
+from PyQt5.QtWebKitWidgets import QWebInspector, QWebPage
+from PyQt5.QtWidgets import QWidget
from qutebrowser.browser import inspector
+from qutebrowser.misc import miscwidgets
class WebKitInspector(inspector.AbstractWebInspector):
"""A web inspector for QtWebKit."""
- def __init__(self, parent=None):
- super().__init__(parent)
+ def __init__(self, splitter: miscwidgets.InspectorSplitter,
+ win_id: int,
+ parent: QWidget = None) -> None:
+ super().__init__(splitter, win_id, parent)
qwebinspector = QWebInspector()
self._set_widget(qwebinspector)
- def inspect(self, page):
+ def inspect(self, page: QWebPage) -> None: # type: ignore[override]
settings = QWebSettings.globalSettings()
settings.setAttribute(QWebSettings.DeveloperExtrasEnabled, True)
self._widget.setPage(page)
diff --git a/qutebrowser/browser/webkit/webkitsettings.py b/qutebrowser/browser/webkit/webkitsettings.py
index 0db1a738d..db36dc899 100644
--- a/qutebrowser/browser/webkit/webkitsettings.py
+++ b/qutebrowser/browser/webkit/webkitsettings.py
@@ -24,7 +24,7 @@ Module attributes:
constants.
"""
-import typing
+from typing import cast
import os.path
from PyQt5.QtCore import QUrl
@@ -39,7 +39,7 @@ from qutebrowser.browser import shared
# The global WebKitSettings object
-global_settings = typing.cast('WebKitSettings', None)
+global_settings = cast('WebKitSettings', None)
parsed_user_agent = None
@@ -172,7 +172,7 @@ def _init_user_agent():
parsed_user_agent = websettings.UserAgent.parse(ua)
-def init(_args):
+def init():
"""Initialize the global QWebSettings."""
cache_path = standarddir.cache()
data_path = standarddir.data()
diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py
index 4d412a38b..a9948ccce 100644
--- a/qutebrowser/browser/webkit/webkittab.py
+++ b/qutebrowser/browser/webkit/webkittab.py
@@ -22,17 +22,20 @@
import re
import functools
import xml.etree.ElementTree
+from typing import cast, Iterable, Optional
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.QtWebKit import QWebSettings, QWebHistory, QWebElement
from PyQt5.QtPrintSupport import QPrinter
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
@@ -53,6 +56,24 @@ class WebKitAction(browsertab.AbstractAction):
def show_source(self, pygments=False):
self._show_source_pygments()
+ def run_string(self, name: str) -> None:
+ """Add special cases for new API.
+
+ Those were added to QtWebKit 5.212 (which we enforce), but we don't get
+ the new API from PyQt. Thus, we'll need to use the raw numbers.
+ """
+ new_actions = {
+ # https://github.com/qtwebkit/qtwebkit/commit/a96d9ef5d24b02d996ad14ff050d0e485c9ddc97
+ 'RequestClose': QWebPage.ToggleVideoFullscreen + 1,
+ # https://github.com/qtwebkit/qtwebkit/commit/96b9ba6269a5be44343635a7aaca4a153ea0366b
+ 'Unselect': QWebPage.ToggleVideoFullscreen + 2,
+ }
+ if name in new_actions:
+ self._widget.triggerPageAction(new_actions[name])
+ return
+
+ super().run_string(name)
+
class WebKitPrinting(browsertab.AbstractPrinting):
@@ -61,9 +82,6 @@ class WebKitPrinting(browsertab.AbstractPrinting):
def check_pdf_support(self):
pass
- def check_printer_support(self):
- pass
-
def check_preview_support(self):
pass
@@ -85,7 +103,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.
@@ -137,7 +158,7 @@ class WebKitSearch(browsertab.AbstractSearch):
self.text = text
self.search_displayed = True
- self._flags = QWebPage.FindFlags(0) # type: ignore
+ self._flags = self._empty_flags()
if self._is_case_sensitive(ignore_case):
self._flags |= QWebPage.FindCaseSensitively
if reverse:
@@ -159,7 +180,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:
@@ -172,13 +194,23 @@ 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__(tab, mode_manager, parent)
+ 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)
@@ -193,7 +225,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'))
@@ -201,151 +233,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)
@@ -362,6 +432,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):
@@ -393,11 +489,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:
@@ -523,6 +619,10 @@ class WebKitHistoryPrivate(browsertab.AbstractHistoryPrivate):
"""History-related methods which are not part of the extension API."""
+ def __init__(self, tab: 'WebKitTab') -> None:
+ self._tab = tab
+ self._history = cast(QWebHistory, None)
+
def serialize(self):
return qtutils.serialize(self._history)
@@ -537,6 +637,7 @@ class WebKitHistoryPrivate(browsertab.AbstractHistoryPrivate):
qtutils.deserialize_stream(stream, self._history)
for i, data in enumerate(user_data):
self._history.itemAt(i).setUserData(data)
+
cur_data = self._history.currentItem().userData()
if cur_data is not None:
if 'zoom' in cur_data:
@@ -577,10 +678,18 @@ class WebKitHistory(browsertab.AbstractHistory):
self._tab.before_load_started.emit(item.url())
self._history.goToItem(item)
+ def back_items(self):
+ return self._history.backItems(self._history.count())
+
+ def forward_items(self):
+ return self._history.forwardItems(self._history.count())
+
class WebKitElements(browsertab.AbstractElements):
- """QtWebKit implemementations related to elements on the page."""
+ """QtWebKit implementations related to elements on the page."""
+
+ _tab: 'WebKitTab'
def find_css(self, selector, callback, error_cb, *, only_visible=False):
utils.unused(error_cb)
@@ -591,7 +700,8 @@ class WebKitElements(browsertab.AbstractElements):
elems = []
frames = webkitelem.get_child_frames(mainframe)
for f in frames:
- for elem in f.findAllElements(selector):
+ frame_elems = cast(Iterable[QWebElement], f.findAllElements(selector))
+ for elem in frame_elems:
elems.append(webkitelem.WebKitElement(elem, tab=self._tab))
if only_visible:
@@ -693,13 +803,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:
@@ -729,9 +847,8 @@ class WebKitTab(browsertab.AbstractTab):
settings = widget.settings()
settings.setAttribute(QWebSettings.PrivateBrowsingEnabled, True)
- def load_url(self, url, *, emit_before_load_started=True):
- self._load_url_prepare(
- url, emit_before_load_started=emit_before_load_started)
+ def load_url(self, url):
+ self._load_url_prepare(url)
self._widget.load(url)
def url(self, *, requested=False):
@@ -751,8 +868,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)
@@ -772,6 +888,9 @@ class WebKitTab(browsertab.AbstractTab):
def title(self):
return self._widget.title()
+ def renderer_process_pid(self) -> Optional[int]:
+ return None
+
@pyqtSlot()
def _on_history_trigger(self):
url = self.url()
@@ -849,9 +968,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..c20acb369 100644
--- a/qutebrowser/browser/webkit/webpage.py
+++ b/qutebrowser/browser/webkit/webpage.py
@@ -21,6 +21,7 @@
import html
import functools
+from typing import cast
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(cast(QUrl.FormattingOptions,
+ QUrl.RemoveUserInfo |
+ QUrl.RemovePath |
+ QUrl.RemoveQuery |
+ QUrl.RemoveFragment))
question = shared.feature_permission(
url=url,
option=options[feature], msg=messages[feature],
@@ -360,7 +365,7 @@ class BrowserPage(QWebPage):
abort_on=[self.shutting_down, self.loadStarted])
if question is not None:
- self.featurePermissionRequestCanceled.connect( # type: ignore
+ self.featurePermissionRequestCanceled.connect( # type: ignore[attr-defined]
functools.partial(self._on_feature_permission_cancelled,
question, frame, 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..428e66744 100644
--- a/qutebrowser/browser/webkit/webview.py
+++ b/qutebrowser/browser/webkit/webview.py
@@ -20,7 +20,6 @@
"""The main browser widgets."""
from PyQt5.QtCore import pyqtSignal, Qt, QUrl
-from PyQt5.QtWidgets import QStyleFactory
from PyQt5.QtWebKit import QWebSettings
from PyQt5.QtWebKitWidgets import QWebView, QWebPage
@@ -62,10 +61,6 @@ class WebView(QWebView):
def __init__(self, *, win_id, tab_id, tab, private, parent=None):
super().__init__(parent)
- if utils.is_mac:
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-42948
- # See https://github.com/qutebrowser/qutebrowser/issues/462
- self.setStyle(QStyleFactory.create('Fusion'))
# FIXME:qtwebengine this is only used to set the zoom factor from
# the QWebPage - we should get rid of it somehow (signals?)
self.tab = tab
@@ -87,7 +82,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 +93,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 +176,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)
mm = modeman.instance(self.win_id)
- mm.entered.connect(menu.close) # type: ignore
+ mm.entered.connect(menu.close)
menu.exec_(e.globalPos())
def showEvent(self, e):
diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py
index 44d639e0a..61b44d555 100644
--- a/qutebrowser/commands/command.py
+++ b/qutebrowser/commands/command.py
@@ -23,6 +23,7 @@ import inspect
import collections
import traceback
import typing
+from typing import Any, MutableMapping, MutableSequence, Tuple, Union
import attr
@@ -116,13 +117,11 @@ class Command:
self.parser.add_argument('-h', '--help', action=argparser.HelpAction,
default=argparser.SUPPRESS, nargs=0,
help=argparser.SUPPRESS)
- self.opt_args = collections.OrderedDict(
- ) # type: typing.MutableMapping[str, typing.Tuple[str, str]]
+ self.opt_args: MutableMapping[str, Tuple[str, str]] = collections.OrderedDict()
self.namespace = None
self._count = None
- self.pos_args = [
- ] # type: typing.MutableSequence[typing.Tuple[str, str]]
- self.flags_with_args = [] # type: typing.MutableSequence[str]
+ self.pos_args: MutableSequence[Tuple[str, str]] = []
+ self.flags_with_args: MutableSequence[str] = []
self._has_vararg = False
# This is checked by future @cmdutils.argument calls so they fail
@@ -244,7 +243,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 +333,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,
@@ -406,21 +405,19 @@ class Command:
raise TypeError("{}: Legacy tuple type annotation!".format(
self.name))
- if hasattr(typing, 'UnionMeta'):
- # Python 3.5.2
- # pylint: disable=no-member,useless-suppression
- is_union = isinstance(typ, typing.UnionMeta) # type: ignore
- else:
- is_union = getattr(typ, '__origin__', None) is typing.Union
+ try:
+ origin = typing.get_origin(typ) # type: ignore[attr-defined]
+ except AttributeError:
+ # typing.get_origin was added in Python 3.8
+ origin = getattr(typ, '__origin__', None)
- if is_union:
- # this is... slightly evil, I know
+ if origin is Union:
try:
- types = list(typ.__args__)
+ types = list(typing.get_args(typ)) # type: ignore[attr-defined]
except AttributeError:
- # Python 3.5.2
- types = list(typ.__union_params__)
- # pylint: enable=no-member,useless-suppression
+ # typing.get_args was added in Python 3.8
+ types = list(typ.__args__)
+
if param.default is not inspect.Parameter.empty:
types.append(type(param.default))
choices = self.get_arg_info(param).choices
@@ -496,8 +493,8 @@ class Command:
Return:
An (args, kwargs) tuple.
"""
- args = [] # type: typing.Any
- kwargs = {} # type: typing.MutableMapping[str, typing.Any]
+ args: Any = []
+ kwargs: MutableMapping[str, Any] = {}
signature = inspect.signature(self.handler)
for i, param in enumerate(signature.parameters.values()):
@@ -575,7 +572,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..c195a8be9 100644
--- a/qutebrowser/commands/runners.py
+++ b/qutebrowser/commands/runners.py
@@ -21,8 +21,8 @@
import traceback
import re
-import typing
import contextlib
+from typing import TYPE_CHECKING, Callable, Dict, Iterator, Mapping, MutableMapping
import attr
from PyQt5.QtCore import pyqtSlot, QUrl, QObject
@@ -34,9 +34,9 @@ from qutebrowser.utils import message, objreg, qtutils, usertypes, utils
from qutebrowser.misc import split, objects
from qutebrowser.keyinput import macros, modeman
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.mainwindow import tabbedbrowser
-_ReplacementFunction = typing.Callable[['tabbedbrowser.TabbedBrowser'], str]
+_ReplacementFunction = Callable[['tabbedbrowser.TabbedBrowser'], str]
last_command = {}
@@ -64,9 +64,9 @@ def _url(tabbed_browser):
raise cmdutils.CommandError(msg)
-def _init_variable_replacements() -> typing.Mapping[str, _ReplacementFunction]:
+def _init_variable_replacements() -> Mapping[str, _ReplacementFunction]:
"""Return a dict from variable replacements to fns processing them."""
- replacements = {
+ replacements: Dict[str, _ReplacementFunction] = {
'url': lambda tb: _url(tb).toString(
QUrl.FullyEncoded | QUrl.RemovePassword),
'url:pretty': lambda tb: _url(tb).toString(
@@ -88,13 +88,13 @@ def _init_variable_replacements() -> typing.Mapping[str, _ReplacementFunction]:
'title': lambda tb: tb.widget.page_title(tb.widget.currentIndex()),
'clipboard': lambda _: utils.get_clipboard(),
'primary': lambda _: utils.get_clipboard(selection=True),
- } # type: typing.Dict[str, _ReplacementFunction]
+ }
for key in list(replacements):
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
@@ -108,7 +108,7 @@ def replace_variables(win_id, arglist):
"""Utility function to replace variables like {url} in a list of args."""
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=win_id)
- values = {} # type: typing.MutableMapping[str, str]
+ values: MutableMapping[str, str] = {}
args = []
def repl_cb(matchobj):
@@ -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) -> 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 f3a706d1a..6d2c2f147 100644
--- a/qutebrowser/commands/userscripts.py
+++ b/qutebrowser/commands/userscripts.py
@@ -22,7 +22,7 @@
import os
import os.path
import tempfile
-import typing
+from typing import cast, Any, MutableMapping, Tuple
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QSocketNotifier
@@ -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(cast(sip.voidptr, fd),
+ QSocketNotifier.Read, self)
+ self._notifier.activated.connect( # type: ignore[attr-defined]
+ self.read_line)
@pyqtSlot()
def read_line(self):
@@ -114,10 +117,10 @@ class _BaseUserscriptRunner(QObject):
self._cleaned_up = False
self._filepath = None
self._proc = None
- self._env = {} # type: typing.MutableMapping[str, str]
+ self._env: MutableMapping[str, str] = {}
self._text_stored = False
self._html_stored = False
- self._args = () # type: typing.Tuple[typing.Any, ...]
+ self._args: Tuple[Any, ...] = ()
self._kwargs = {}
def store_text(self, text):
@@ -256,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)
@pyqtSlot()
def on_proc_finished(self):
@@ -391,6 +395,7 @@ def _lookup_path(cmd):
directories = [
os.path.join(standarddir.data(), "userscripts"),
os.path.join(standarddir.data(system=True), "userscripts"),
+ os.path.join(standarddir.config(), "userscripts"),
]
for directory in directories:
cmd_path = os.path.join(directory, cmd)
@@ -418,14 +423,13 @@ def run_async(tab, cmd, *args, win_id, env, verbose=False,
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: _BaseUserscriptRunner = _POSIXUserscriptRunner(tb)
elif utils.is_windows: # pragma: no cover
- runner = _WindowsUserscriptRunner(tabbed_browser)
+ runner = _WindowsUserscriptRunner(tb)
else: # pragma: no cover
raise UnsupportedError
diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py
index de75a729d..d66e3ee40 100644
--- a/qutebrowser/completion/completer.py
+++ b/qutebrowser/completion/completer.py
@@ -25,7 +25,7 @@ from PyQt5.QtCore import pyqtSlot, QObject, QTimer
from qutebrowser.config import config
from qutebrowser.commands import runners
from qutebrowser.misc import objects
-from qutebrowser.utils import log, utils, debug
+from qutebrowser.utils import log, utils, debug, objreg
from qutebrowser.completion.models import miscmodels
@@ -37,6 +37,7 @@ class CompletionInfo:
config = attr.ib()
keyconf = attr.ib()
win_id = attr.ib()
+ cur_tab = attr.ib()
class Completer(QObject):
@@ -149,7 +150,7 @@ class Completer(QObject):
parts.insert(i, '')
prefix = [x.strip() for x in parts[:i]]
center = parts[i].strip()
- # strip trailing whitepsace included as a separate token
+ # strip trailing whitespace included as a separate token
postfix = [x.strip() for x in parts[i+1:] if not x.isspace()]
log.completion.debug(
"partitioned: {} '{}' {}".format(prefix, center, postfix))
@@ -246,17 +247,28 @@ class Completer(QObject):
self._last_before_cursor = None
return
- if before_cursor != self._last_before_cursor:
- self._last_before_cursor = before_cursor
- args = (x for x in before_cursor[1:] if not x.startswith('-'))
- with debug.log_time(log.completion, 'Starting {} completion'
- .format(func.__name__)):
- info = CompletionInfo(config=config.instance,
- keyconf=config.key_instance,
- win_id=self._win_id)
- model = func(*args, info=info)
- with debug.log_time(log.completion, 'Set completion model'):
- completion.set_model(model)
+ if before_cursor == self._last_before_cursor:
+ # If the part before the cursor didn't change since the last
+ # completion, we only need to filter existing matches without
+ # having to regenerate completion results.
+ completion.set_pattern(pattern)
+ return
+
+ self._last_before_cursor = before_cursor
+
+ args = (x for x in before_cursor[1:] if not x.startswith('-'))
+ cur_tab = objreg.get('tab', scope='tab', window=self._win_id,
+ tab='current')
+
+ with debug.log_time(log.completion, 'Starting {} completion'
+ .format(func.__name__)):
+ info = CompletionInfo(config=config.instance,
+ keyconf=config.key_instance,
+ win_id=self._win_id,
+ cur_tab=cur_tab)
+ model = func(*args, info=info)
+ with debug.log_time(log.completion, 'Set completion model'):
+ completion.set_model(model)
completion.set_pattern(pattern)
diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py
index b80c81654..4e1290f82 100644
--- a/qutebrowser/completion/completiondelegate.py
+++ b/qutebrowser/completion/completiondelegate.py
@@ -26,7 +26,7 @@ import re
import html
from PyQt5.QtWidgets import QStyle, QStyleOptionViewItem, QStyledItemDelegate
-from PyQt5.QtCore import QRectF, QSize, Qt
+from PyQt5.QtCore import QRectF, QRegularExpression, QSize, Qt
from PyQt5.QtGui import (QIcon, QPalette, QTextDocument, QTextOption,
QAbstractTextDocumentLayout, QSyntaxHighlighter,
QTextCharFormat)
@@ -41,14 +41,24 @@ class _Highlighter(QSyntaxHighlighter):
super().__init__(doc)
self._format = QTextCharFormat()
self._format.setForeground(color)
- self._pattern = pattern
+ words = pattern.split()
+ words.sort(key=len, reverse=True)
+ pat = "|".join(re.escape(word) for word in words)
+ self._expression = QRegularExpression(
+ pat, QRegularExpression.CaseInsensitiveOption
+ )
+ qtutils.ensure_valid(self._expression)
def highlightBlock(self, text):
"""Override highlightBlock for custom highlighting."""
- for match in re.finditer(self._pattern, text, re.IGNORECASE):
- start, end = match.span()
- length = end - start
- self.setFormat(start, length, self._format)
+ match_iterator = self._expression.globalMatch(text)
+ while match_iterator.hasNext():
+ match = match_iterator.next()
+ self.setFormat(
+ match.capturedStart(),
+ match.capturedLength(),
+ self._format
+ )
class CompletionItemDelegate(QStyledItemDelegate):
@@ -226,12 +236,11 @@ class CompletionItemDelegate(QStyledItemDelegate):
pattern = view.pattern
columns_to_filter = index.model().columns_to_filter(index)
if index.column() in columns_to_filter and pattern:
- pat = re.escape(pattern).replace(r'\ ', r'|')
if self._opt.state & QStyle.State_Selected:
color = config.val.colors.completion.item.selected.match.fg
else:
color = config.val.colors.completion.match.fg
- _Highlighter(self._doc, pat, color)
+ _Highlighter(self._doc, pattern, color)
self._doc.setPlainText(self._opt.text)
else:
self._doc.setHtml(
@@ -290,7 +299,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..86de688a0 100644
--- a/qutebrowser/completion/completionwidget.py
+++ b/qutebrowser/completion/completionwidget.py
@@ -23,16 +23,16 @@ Defines a CompletionView which uses CompletionFiterModel and CompletionModel
subclasses to provide completions.
"""
-import typing
+from typing import TYPE_CHECKING, Optional
from PyQt5.QtWidgets import QTreeView, QSizePolicy, QStyleFactory, QWidget
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel, QSize
from qutebrowser.config import config, stylesheet
from qutebrowser.completion import completiondelegate
-from qutebrowser.utils import utils, usertypes, debug, log
+from qutebrowser.utils import utils, usertypes, debug, log, qtutils
from qutebrowser.api import cmdutils
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.mainwindow.statusbar import command
@@ -115,7 +115,7 @@ class CompletionView(QTreeView):
win_id: int,
parent: QWidget = None) -> None:
super().__init__(parent)
- self.pattern = None # type: typing.Optional[str]
+ self.pattern: Optional[str] = None
self._win_id = win_id
self._cmd = cmd
self._active = False
@@ -162,13 +162,13 @@ class CompletionView(QTreeView):
pixel_widths = [(width * perc // 100) for perc in column_widths]
delta = self.verticalScrollBar().sizeHint().width()
- if pixel_widths[-1] > delta:
- pixel_widths[-1] -= delta
- else:
- pixel_widths[-2] -= delta
+ for i, width in reversed(list(enumerate(pixel_widths))):
+ if width > delta:
+ pixel_widths[i] -= delta
+ break
for i, w in enumerate(pixel_widths):
- assert w >= 0, i
+ assert w >= 0, (i, w)
self.setColumnWidth(i, w)
def _next_idx(self, upwards):
@@ -205,6 +205,50 @@ class CompletionView(QTreeView):
raise utils.Unreachable
+ def _next_page(self, upwards):
+ """Return the index a page away from the selected index.
+
+ Args:
+ upwards: Get previous item, not next.
+
+ Return:
+ A QModelIndex.
+ """
+ old_idx = self.selectionModel().currentIndex()
+ idx = old_idx
+ model = self.model()
+
+ if not idx.isValid():
+ # No item selected yet
+ return model.last_item() if upwards else model.first_item()
+
+ # Find height of each CompletionView element
+ rect = self.visualRect(idx)
+ qtutils.ensure_valid(rect)
+ page_length = self.height() // rect.height()
+
+ # Skip one pageful, except leave one old line visible
+ offset = -(page_length - 1) if upwards else page_length - 1
+ idx = model.sibling(old_idx.row() + offset, old_idx.column(), old_idx)
+
+ # Skip category headers
+ while idx.isValid() and not idx.parent().isValid():
+ idx = self.indexAbove(idx) if upwards else self.indexBelow(idx)
+
+ if idx.isValid():
+ return idx
+
+ border_item = model.first_item() if upwards else model.last_item()
+
+ # Wrap around if we were already at the beginning/end
+ if old_idx == border_item:
+ return self._next_idx(upwards)
+
+ # Select the first/last item before wrapping around
+ if upwards:
+ self.scrollTo(border_item.parent())
+ return border_item
+
def _next_category_idx(self, upwards):
"""Get the index of the previous/next category.
@@ -238,14 +282,17 @@ class CompletionView(QTreeView):
@cmdutils.register(instance='completion',
modes=[usertypes.KeyMode.command], scope='window')
- @cmdutils.argument('which', choices=['next', 'prev', 'next-category',
- 'prev-category'])
+ @cmdutils.argument('which', choices=['next', 'prev',
+ 'next-category', 'prev-category',
+ 'next-page', 'prev-page'])
@cmdutils.argument('history', flag='H')
def completion_item_focus(self, which, history=False):
"""Shift the focus of the completion menu to another item.
Args:
- which: 'next', 'prev', 'next-category', or 'prev-category'.
+ which: 'next', 'prev',
+ 'next-category', 'prev-category',
+ 'next-page', or 'prev-page'.
history: Navigate through command history if no text was typed.
"""
if history:
@@ -266,23 +313,26 @@ class CompletionView(QTreeView):
selmodel = self.selectionModel()
indices = {
- 'next': self._next_idx(upwards=False),
- 'prev': self._next_idx(upwards=True),
- 'next-category': self._next_category_idx(upwards=False),
- 'prev-category': self._next_category_idx(upwards=True),
+ 'next': lambda: self._next_idx(upwards=False),
+ 'prev': lambda: self._next_idx(upwards=True),
+ 'next-category': lambda: self._next_category_idx(upwards=False),
+ 'prev-category': lambda: self._next_category_idx(upwards=True),
+ 'next-page': lambda: self._next_page(upwards=False),
+ 'prev-page': lambda: self._next_page(upwards=True),
}
- idx = indices[which]
+ idx = indices[which]()
if not idx.isValid():
return
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
- if idx.row() == self.model().rowCount(idx.parent()) - 1:
+ next_idx = self.indexBelow(idx)
+ if not self.visualRect(next_idx).isValid():
self.expandAll()
count = self.model().count()
@@ -424,4 +474,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..d992a44f4 100644
--- a/qutebrowser/completion/models/completionmodel.py
+++ b/qutebrowser/completion/models/completionmodel.py
@@ -19,11 +19,11 @@
"""A model that proxies access to one or more completion categories."""
-import typing
+from typing import MutableSequence
from PyQt5.QtCore import Qt, QModelIndex, QAbstractItemModel
-from qutebrowser.utils import log, qtutils
+from qutebrowser.utils import log, qtutils, utils
from qutebrowser.api import cmdutils
@@ -43,8 +43,7 @@ class CompletionModel(QAbstractItemModel):
def __init__(self, *, column_widths=(30, 70, 0), parent=None):
super().__init__(parent)
self.column_widths = column_widths
- self._categories = [
- ] # type: typing.MutableSequence[QAbstractItemModel]
+ self._categories: MutableSequence[QAbstractItemModel] = []
def _cat_from_idx(self, index):
"""Return the category pointed to by the given index.
@@ -153,8 +152,8 @@ class CompletionModel(QAbstractItemModel):
def columnCount(self, parent=QModelIndex()):
"""Override QAbstractItemModel::columnCount."""
- # pylint: disable=unused-argument
- return 3
+ utils.unused(parent)
+ return len(self.column_widths)
def canFetchMore(self, parent):
"""Override to forward the call to the categories."""
@@ -180,14 +179,10 @@ class CompletionModel(QAbstractItemModel):
pattern: The filter pattern to set.
"""
log.completion.debug("Setting completion pattern '{}'".format(pattern))
- # 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")
+ self.layoutAboutToBeChanged.emit() # type: ignore[attr-defined]
for cat in self._categories:
cat.set_pattern(pattern)
- self.metaObject().invokeMethod(self, "layoutChanged") # type: ignore
+ self.layoutChanged.emit() # type: ignore[attr-defined]
def first_item(self):
"""Return the index of the first child (non-category) in the model."""
diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py
index 464caa19e..e7ccd3505 100644
--- a/qutebrowser/completion/models/histcategory.py
+++ b/qutebrowser/completion/models/histcategory.py
@@ -19,7 +19,7 @@
"""A completion category that queries the SQL history store."""
-import typing
+from typing import Optional
from PyQt5.QtSql import QSqlQueryModel
from PyQt5.QtWidgets import QWidget
@@ -40,12 +40,12 @@ class HistoryCategory(QSqlQueryModel):
"""Create a new History completion category."""
super().__init__(parent=parent)
self.name = "History"
- self._query = None # type: typing.Optional[sql.Query]
+ self._query: Optional[sql.Query] = None
# advertise that this model filters by URL and title
self.columns_to_filter = [0, 1]
self.delete_func = delete_func
- self._empty_prefix = None # type: typing.Optional[str]
+ self._empty_prefix: Optional[str] = None
def _atime_expr(self):
"""If max_items is set, return an expression to limit the query."""
diff --git a/qutebrowser/completion/models/listcategory.py b/qutebrowser/completion/models/listcategory.py
index 78f661bc6..fbe742ddb 100644
--- a/qutebrowser/completion/models/listcategory.py
+++ b/qutebrowser/completion/models/listcategory.py
@@ -20,9 +20,9 @@
"""Completion category that uses a list of tuples as a data source."""
import re
-import typing
+from typing import Iterable, Tuple
-from PyQt5.QtCore import Qt, QSortFilterProxyModel, QRegExp
+from PyQt5.QtCore import QSortFilterProxyModel, QRegularExpression
from PyQt5.QtGui import QStandardItem, QStandardItemModel
from PyQt5.QtWidgets import QWidget
@@ -30,18 +30,13 @@ from qutebrowser.completion.models import util
from qutebrowser.utils import qtutils, log
-_ItemType = typing.Union[typing.Tuple[str],
- typing.Tuple[str, str],
- typing.Tuple[str, str, str]]
-
-
class ListCategory(QSortFilterProxyModel):
"""Expose a list of items as a category for the CompletionModel."""
def __init__(self,
name: str,
- items: typing.Iterable[_ItemType],
+ items: Iterable[Tuple[str, ...]],
sort: bool = True,
delete_func: util.DeleteFuncType = None,
parent: QWidget = None):
@@ -64,12 +59,16 @@ class ListCategory(QSortFilterProxyModel):
Args:
val: The value to set.
"""
+ if len(val) > 5000: # avoid crash on huge search terms (#5973)
+ log.completion.warning(f"Trimming {len(val)}-char pattern to 5000")
+ val = val[:5000]
self._pattern = val
val = re.sub(r' +', r' ', val) # See #1919
val = re.escape(val)
val = val.replace(r'\ ', '.*')
- rx = QRegExp(val, Qt.CaseInsensitive)
- self.setFilterRegExp(rx)
+ rx = QRegularExpression(val, QRegularExpression.CaseInsensitiveOption)
+ qtutils.ensure_valid(rx)
+ self.setFilterRegularExpression(rx)
self.invalidate()
sortcol = 0
self.sort(sortcol)
diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py
index 14f9a1163..80143aff6 100644
--- a/qutebrowser/completion/models/miscmodels.py
+++ b/qutebrowser/completion/models/miscmodels.py
@@ -19,11 +19,13 @@
"""Functions that return miscellaneous completion models."""
-import typing
+import datetime
+from typing import List, Sequence, Tuple
from qutebrowser.config import config, configdata
-from qutebrowser.utils import objreg, log
+from qutebrowser.utils import objreg, log, utils
from qutebrowser.completion.models import completionmodel, listcategory, util
+from qutebrowser.browser import inspector
def command(*, info):
@@ -37,11 +39,11 @@ def command(*, info):
def helptopic(*, info):
"""A CompletionModel filled with help topics."""
- model = completionmodel.CompletionModel()
+ model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
cmdlist = util.get_cmd_completions(info, include_aliases=False,
include_hidden=True, prefix=':')
- settings = ((opt.name, opt.description)
+ settings = ((opt.name, opt.description, info.config.get_str(opt.name))
for opt in configdata.DATA.values())
model.add_category(listcategory.ListCategory("Commands", cmdlist))
@@ -49,15 +51,16 @@ def helptopic(*, info):
return model
-def quickmark(*, info=None): # pylint: disable=unused-argument
+def quickmark(*, info=None):
"""A CompletionModel filled with all quickmarks."""
- def delete(data: typing.Sequence[str]) -> None:
+ def delete(data: Sequence[str]) -> None:
"""Delete a quickmark from the completion menu."""
name = data[0]
quickmark_manager = objreg.get('quickmark-manager')
log.completion.debug('Deleting quickmark {}'.format(name))
quickmark_manager.delete(name)
+ utils.unused(info)
model = completionmodel.CompletionModel(column_widths=(30, 70, 0))
marks = objreg.get('quickmark-manager').marks.items()
model.add_category(listcategory.ListCategory('Quickmarks', marks,
@@ -66,15 +69,16 @@ def quickmark(*, info=None): # pylint: disable=unused-argument
return model
-def bookmark(*, info=None): # pylint: disable=unused-argument
+def bookmark(*, info=None):
"""A CompletionModel filled with all bookmarks."""
- def delete(data: typing.Sequence[str]) -> None:
+ def delete(data: Sequence[str]) -> None:
"""Delete a bookmark from the completion menu."""
urlstr = data[0]
log.completion.debug('Deleting bookmark {}'.format(urlstr))
bookmark_manager = objreg.get('bookmark-manager')
bookmark_manager.delete(urlstr)
+ utils.unused(info)
model = completionmodel.CompletionModel(column_widths=(30, 70, 0))
marks = objreg.get('bookmark-manager').marks.items()
model.add_category(listcategory.ListCategory('Bookmarks', marks,
@@ -83,9 +87,10 @@ def bookmark(*, info=None): # pylint: disable=unused-argument
return model
-def session(*, info=None): # pylint: disable=unused-argument
+def session(*, info=None):
"""A CompletionModel filled with session names."""
from qutebrowser.misc import sessions
+ utils.unused(info)
model = completionmodel.CompletionModel()
try:
sess = ((name,) for name
@@ -97,11 +102,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."""
@@ -110,30 +117,41 @@ def _buffer(skip_win_id=None):
window=int(win_id))
tabbed_browser.on_tab_close_requested(int(tab_index) - 1)
- model = completionmodel.CompletionModel(column_widths=(6, 40, 54))
+ model = completionmodel.CompletionModel(column_widths=(6, 40, 46, 8))
tabs_are_windows = config.val.tabs.tabs_are_windows
# list storing all single-tabbed windows when tabs_are_windows
- windows = [] # type: typing.List[typing.Tuple[str, str, str]]
+ windows: List[Tuple[str, 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:
+ if tabbed_browser.is_shutting_down:
continue
- tabs = [] # type: typing.List[typing.Tuple[str, str, str]]
+ tabs: List[Tuple[str, 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.url().toDisplayString(),
- tabbed_browser.widget.page_title(idx)))
+ tab_str = ("{}/{}".format(win_id, idx + 1) if add_win_id
+ else str(idx + 1))
+
+ pid = tab.renderer_process_pid()
+
+ tabs.append((
+ tab_str,
+ tab.url().toDisplayString(),
+ tabbed_browser.widget.page_title(idx),
+ "" if pid is None else f"PID {pid}",
+ ))
+
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:
@@ -144,11 +162,12 @@ def _buffer(skip_win_id=None):
return model
-def buffer(*, info=None): # pylint: disable=unused-argument
+def buffer(*, info=None):
"""A model to complete on open tabs across all windows.
Used for switching the buffer command.
"""
+ utils.unused(info)
return _buffer()
@@ -157,7 +176,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):
@@ -179,3 +213,92 @@ def window(*, info):
model.add_category(listcategory.ListCategory("Windows", windows))
return model
+
+
+def inspector_position(*, info):
+ """A model for possible inspector positions."""
+ utils.unused(info)
+ model = completionmodel.CompletionModel(column_widths=(100, 0, 0))
+ positions = [(e.name,) for e in inspector.Position]
+ category = listcategory.ListCategory("Position (optional)", positions)
+ model.add_category(category)
+ return model
+
+
+def _qdatetime_to_completion_format(qdate):
+ if not qdate.isValid():
+ ts = 0
+ else:
+ ts = qdate.toMSecsSinceEpoch()
+ if ts < 0:
+ ts = 0
+ pydate = datetime.datetime.fromtimestamp(ts / 1000)
+ return pydate.strftime(config.val.completion.timestamp_format)
+
+
+def _back_forward(info, go_forward):
+ history = info.cur_tab.history
+ current_idx = history.current_idx()
+ model = completionmodel.CompletionModel(column_widths=(5, 36, 50, 9))
+
+ if go_forward:
+ start = current_idx + 1
+ items = history.forward_items()
+ else:
+ start = 0
+ items = history.back_items()
+
+ entries = [
+ (
+ str(idx),
+ entry.url().toDisplayString(),
+ entry.title(),
+ _qdatetime_to_completion_format(entry.lastVisited())
+ )
+ for idx, entry in enumerate(items, start)
+ ]
+ if not go_forward:
+ # make sure the most recent is at the top for :back
+ entries.reverse()
+
+ cat = listcategory.ListCategory("History", entries, sort=False)
+ model.add_category(cat)
+ return model
+
+
+def forward(*, info):
+ """A model to complete on history of the current tab.
+
+ Used for the :forward command.
+ """
+ return _back_forward(info, go_forward=True)
+
+
+def back(*, info):
+ """A model to complete on history of the current tab.
+
+ Used for the :back command.
+ """
+ return _back_forward(info, go_forward=False)
+
+
+def undo(*, info):
+ """A model to complete undo entries."""
+ tabbed_browser = objreg.get('tabbed-browser', scope='window',
+ window=info.win_id)
+ model = completionmodel.CompletionModel(column_widths=(6, 84, 10))
+ timestamp_format = config.val.completion.timestamp_format
+
+ entries = [
+ (
+ str(idx),
+ ', '.join(entry.url.toDisplayString() for entry in group),
+ group[-1].created_at.strftime(timestamp_format)
+ )
+ for idx, group in
+ enumerate(reversed(tabbed_browser.undo_stack), start=1)
+ ]
+
+ cat = listcategory.ListCategory("Closed tabs", entries, sort=False)
+ model.add_category(cat)
+ return model
diff --git a/qutebrowser/completion/models/urlmodel.py b/qutebrowser/completion/models/urlmodel.py
index 3e9b26aaa..1de336015 100644
--- a/qutebrowser/completion/models/urlmodel.py
+++ b/qutebrowser/completion/models/urlmodel.py
@@ -19,7 +19,9 @@
"""Function to return the url completion model for the `open` command."""
-import typing
+from typing import Dict, Sequence
+
+from PyQt5.QtCore import QAbstractItemModel
from qutebrowser.completion.models import (completionmodel, listcategory,
histcategory)
@@ -38,14 +40,14 @@ def _delete_history(data):
history.web_history.delete_url(urlstr)
-def _delete_bookmark(data: typing.Sequence[str]) -> None:
+def _delete_bookmark(data: Sequence[str]) -> None:
urlstr = data[_URLCOL]
log.completion.debug('Deleting bookmark {}'.format(urlstr))
bookmark_manager = objreg.get('bookmark-manager')
bookmark_manager.delete(urlstr)
-def _delete_quickmark(data: typing.Sequence[str]) -> None:
+def _delete_quickmark(data: Sequence[str]) -> None:
name = data[_TEXTCOL]
quickmark_manager = objreg.get('quickmark-manager')
log.completion.debug('Deleting quickmark {}'.format(name))
@@ -65,16 +67,14 @@ def url(*, info):
"""
model = completionmodel.CompletionModel(column_widths=(40, 50, 10))
- # pylint: disable=bad-config-option
quickmarks = [(url, name) for (name, url)
in objreg.get('quickmark-manager').marks.items()]
bookmarks = objreg.get('bookmark-manager').marks.items()
searchengines = [(k, v) for k, v
in sorted(config.val.url.searchengines.items())
if k != 'DEFAULT']
- # pylint: enable=bad-config-option
categories = config.val.completion.open_categories
- models = {}
+ models: Dict[str, QAbstractItemModel] = {}
if searchengines and 'searchengines' in categories:
models['searchengines'] = listcategory.ListCategory(
diff --git a/qutebrowser/completion/models/util.py b/qutebrowser/completion/models/util.py
index a0dda334a..7f4f6d19d 100644
--- a/qutebrowser/completion/models/util.py
+++ b/qutebrowser/completion/models/util.py
@@ -19,13 +19,13 @@
"""Utility functions for completion models."""
-import typing
+from typing import Callable, Sequence
from qutebrowser.utils import usertypes
from qutebrowser.misc import objects
-DeleteFuncType = typing.Callable[[typing.Sequence[str]], None]
+DeleteFuncType = Callable[[Sequence[str]], None]
def get_cmd_completions(info, include_hidden, include_aliases, prefix=''):
diff --git a/qutebrowser/components/adblock.py b/qutebrowser/components/adblock.py
deleted file mode 100644
index 2ceea2cf7..000000000
--- a/qutebrowser/components/adblock.py
+++ /dev/null
@@ -1,344 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2014-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/>.
-
-"""Functions related to ad blocking."""
-
-import os.path
-import functools
-import posixpath
-import zipfile
-import logging
-import typing
-import pathlib
-
-from PyQt5.QtCore import QUrl
-
-from qutebrowser.api import (cmdutils, hook, config, message, downloads,
- interceptor, apitypes, qtutils)
-
-
-logger = logging.getLogger('misc')
-_host_blocker = typing.cast('HostBlocker', None)
-
-
-def _guess_zip_filename(zf: zipfile.ZipFile) -> str:
- """Guess which file to use inside a zip file."""
- files = zf.namelist()
- if len(files) == 1:
- return files[0]
- else:
- for e in files:
- if posixpath.splitext(e)[0].lower() == 'hosts':
- return e
- raise FileNotFoundError("No hosts file found in zip")
-
-
-def get_fileobj(byte_io: typing.IO[bytes]) -> typing.IO[bytes]:
- """Get a usable file object to read the hosts file from."""
- byte_io.seek(0) # rewind downloaded file
- if zipfile.is_zipfile(byte_io):
- byte_io.seek(0) # rewind what zipfile.is_zipfile did
- zf = zipfile.ZipFile(byte_io)
- filename = _guess_zip_filename(zf)
- byte_io = zf.open(filename, mode='r')
- else:
- byte_io.seek(0) # rewind what zipfile.is_zipfile did
- return byte_io
-
-
-def _is_whitelisted_url(url: QUrl) -> bool:
- """Check if the given URL is on the adblock whitelist."""
- for pattern in config.val.content.host_blocking.whitelist:
- if pattern.matches(url):
- return True
- return False
-
-
-class _FakeDownload(downloads.TempDownload):
-
- """A download stub to use on_download_finished with local files."""
-
- def __init__(self, # pylint: disable=super-init-not-called
- fileobj: typing.IO[bytes]) -> None:
- self.fileobj = fileobj
- self.successful = True
-
-
-class HostBlocker:
-
- """Manage blocked hosts based from /etc/hosts-like files.
-
- Attributes:
- _blocked_hosts: A set of blocked hosts.
- _config_blocked_hosts: A set of blocked hosts from ~/.config.
- _in_progress: The DownloadItems which are currently downloading.
- _done_count: How many files have been read successfully.
- _local_hosts_file: The path to the blocked-hosts file.
- _config_hosts_file: The path to a blocked-hosts in ~/.config
- _has_basedir: Whether a custom --basedir is set.
- """
-
- def __init__(self, *, data_dir: pathlib.Path, config_dir: pathlib.Path,
- has_basedir: bool = False) -> None:
- self._has_basedir = has_basedir
- self._blocked_hosts = set() # type: typing.Set[str]
- self._config_blocked_hosts = set() # type: typing.Set[str]
- self._in_progress = [] # type: typing.List[downloads.TempDownload]
- self._done_count = 0
-
- self._local_hosts_file = str(data_dir / 'blocked-hosts')
- self.update_files()
-
- self._config_hosts_file = str(config_dir / 'blocked-hosts')
-
- def _is_blocked(self, request_url: QUrl,
- first_party_url: QUrl = None) -> bool:
- """Check whether the given request is blocked."""
- if first_party_url is not None and not first_party_url.isValid():
- first_party_url = None
-
- qtutils.ensure_valid(request_url)
-
- if not config.get('content.host_blocking.enabled',
- url=first_party_url):
- return False
-
- host = request_url.host()
- return ((host in self._blocked_hosts or
- host in self._config_blocked_hosts) and
- not _is_whitelisted_url(request_url))
-
- def filter_request(self, info: interceptor.Request) -> None:
- """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()))
- info.block()
-
- def _read_hosts_line(self, raw_line: bytes) -> typing.Set[str]:
- """Read hosts from the given line.
-
- Args:
- line: The bytes object to read.
-
- Returns:
- A set containing valid hosts found
- in the line.
- """
- if raw_line.startswith(b'#'):
- # Ignoring comments early so we don't have to care about
- # encoding errors in them
- return set()
-
- line = raw_line.decode('utf-8')
-
- # Remove comments
- hash_idx = line.find('#')
- line = line if hash_idx == -1 else line[:hash_idx]
-
- parts = line.strip().split()
- if len(parts) == 1:
- # "one host per line" format
- hosts = parts
- else:
- # /etc/hosts format
- hosts = parts[1:]
-
- filtered_hosts = set()
- for host in hosts:
- if ('.' in host and
- not host.endswith('.localdomain') and
- host != '0.0.0.0'):
- filtered_hosts.update([host])
-
- return filtered_hosts
-
- def _read_hosts_file(self, filename: str, target: typing.Set[str]) -> bool:
- """Read hosts from the given filename.
-
- Args:
- filename: The file to read.
- target: The set to store the hosts in.
-
- Return:
- True if a read was attempted, False otherwise
- """
- if not os.path.exists(filename):
- return False
-
- try:
- with open(filename, 'rb') as f:
- for line in f:
- target |= self._read_hosts_line(line)
-
- except (OSError, UnicodeDecodeError):
- logger.exception("Failed to read host blocklist!")
-
- return True
-
- def read_hosts(self) -> None:
- """Read hosts from the existing blocked-hosts file."""
- self._blocked_hosts = set()
-
- self._read_hosts_file(self._config_hosts_file,
- self._config_blocked_hosts)
-
- found = self._read_hosts_file(self._local_hosts_file,
- self._blocked_hosts)
-
- if not found:
- if (config.val.content.host_blocking.lists and
- not self._has_basedir and
- config.val.content.host_blocking.enabled):
- message.info("Run :adblock-update to get adblock lists.")
-
- def adblock_update(self) -> None:
- """Update the adblock block lists."""
- self._read_hosts_file(self._config_hosts_file,
- self._config_blocked_hosts)
- self._blocked_hosts = set()
- self._done_count = 0
- for url in config.val.content.host_blocking.lists:
- if url.scheme() == 'file':
- filename = url.toLocalFile()
- if os.path.isdir(filename):
- for entry in os.scandir(filename):
- if entry.is_file():
- self._import_local(entry.path)
- else:
- self._import_local(filename)
- else:
- download = downloads.download_temp(url)
- self._in_progress.append(download)
- download.finished.connect(
- functools.partial(self._on_download_finished, download))
-
- def _import_local(self, filename: str) -> None:
- """Adds the contents of a file to the blocklist.
-
- Args:
- filename: path to a local file to import.
- """
- try:
- fileobj = open(filename, 'rb')
- except OSError as e:
- message.error("adblock: Error while reading {}: {}".format(
- filename, e.strerror))
- return
- download = _FakeDownload(fileobj)
- self._in_progress.append(download)
- self._on_download_finished(download)
-
- def _merge_file(self, byte_io: typing.IO[bytes]) -> None:
- """Read and merge host files.
-
- Args:
- byte_io: The BytesIO object of the completed download.
- """
- error_count = 0
- line_count = 0
- try:
- f = get_fileobj(byte_io)
- except (OSError, zipfile.BadZipFile, zipfile.LargeZipFile,
- LookupError) as e:
- message.error("adblock: Error while reading {}: {} - {}".format(
- byte_io.name, e.__class__.__name__, e))
- return
-
- for line in f:
- line_count += 1
- try:
- self._blocked_hosts |= self._read_hosts_line(line)
- except UnicodeDecodeError:
- logger.error("Failed to decode: {!r}".format(line))
- error_count += 1
-
- logger.debug("{}: read {} lines".format(byte_io.name, line_count))
- if error_count > 0:
- message.error("adblock: {} read errors for {}".format(
- error_count, byte_io.name))
-
- def _on_lists_downloaded(self) -> None:
- """Install block lists after files have been downloaded."""
- with open(self._local_hosts_file, 'w', encoding='utf-8') as f:
- for host in sorted(self._blocked_hosts):
- f.write(host + '\n')
- message.info("adblock: Read {} hosts from {} sources.".format(
- len(self._blocked_hosts), self._done_count))
-
- def update_files(self) -> None:
- """Update files when the config changed."""
- if not config.val.content.host_blocking.lists:
- try:
- os.remove(self._local_hosts_file)
- except FileNotFoundError:
- pass
- except OSError as e:
- logger.exception("Failed to delete hosts file: {}".format(e))
-
- def _on_download_finished(self, download: downloads.TempDownload) -> None:
- """Check if all downloads are finished and if so, trigger reading.
-
- Arguments:
- download: The finished download.
- """
- self._in_progress.remove(download)
- if download.successful:
- self._done_count += 1
- assert not isinstance(download.fileobj,
- downloads.UnsupportedAttribute)
- assert download.fileobj is not None
- try:
- self._merge_file(download.fileobj)
- finally:
- download.fileobj.close()
- if not self._in_progress:
- try:
- self._on_lists_downloaded()
- except OSError:
- logger.exception("Failed to write host block list!")
-
-
-@cmdutils.register()
-def adblock_update() -> None:
- """Update the adblock block lists.
-
- This updates `~/.local/share/qutebrowser/blocked-hosts` with downloaded
- host lists and re-reads `~/.config/qutebrowser/blocked-hosts`.
- """
- # FIXME: As soon as we can register instances again, we should move this
- # back to the class.
- _host_blocker.adblock_update()
-
-
-@hook.config_changed('content.host_blocking.lists')
-def on_config_changed() -> None:
- _host_blocker.update_files()
-
-
-@hook.init()
-def init(context: apitypes.InitContext) -> None:
- """Initialize the host blocker."""
- global _host_blocker
- _host_blocker = HostBlocker(data_dir=context.data_dir,
- config_dir=context.config_dir,
- has_basedir=context.args.basedir is not None)
- _host_blocker.read_hosts()
- interceptor.register(_host_blocker.filter_request)
diff --git a/qutebrowser/components/adblockcommands.py b/qutebrowser/components/adblockcommands.py
new file mode 100644
index 000000000..e507a2b5c
--- /dev/null
+++ b/qutebrowser/components/adblockcommands.py
@@ -0,0 +1,31 @@
+# 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/>.
+
+"""Commands relating to ad blocking."""
+
+from qutebrowser.api import cmdutils
+from qutebrowser.components import braveadblock, hostblock
+
+
+@cmdutils.register()
+def adblock_update() -> None:
+ """Update block lists for both the host- and the Brave ad blocker."""
+ if braveadblock.ad_blocker is not None:
+ braveadblock.ad_blocker.adblock_update()
+ hostblock.host_blocker.adblock_update()
diff --git a/qutebrowser/components/braveadblock.py b/qutebrowser/components/braveadblock.py
new file mode 100644
index 000000000..340ed0fac
--- /dev/null
+++ b/qutebrowser/components/braveadblock.py
@@ -0,0 +1,294 @@
+# 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/>.
+
+"""Functions related to the Brave adblocker."""
+
+import io
+import logging
+import pathlib
+import functools
+from typing import Optional, IO
+
+from PyQt5.QtCore import QUrl
+
+from qutebrowser.api import (
+ hook,
+ config,
+ message,
+ interceptor,
+ apitypes,
+ qtutils,
+)
+from qutebrowser.api.interceptor import ResourceType
+from qutebrowser.components.utils import blockutils
+from qutebrowser.utils import version # FIXME: Move needed parts into api namespace?
+
+try:
+ import adblock
+except ImportError:
+ adblock = None # type: ignore[assignment]
+
+logger = logging.getLogger("network")
+ad_blocker: Optional["BraveAdBlocker"] = None
+
+
+def _should_be_used() -> bool:
+ """Whether the Brave adblocker should be used or not.
+
+ Here we assume the adblock dependency is satisfied.
+ """
+ return config.val.content.blocking.method in ("auto", "both", "adblock")
+
+
+def _possibly_show_missing_dependency_warning() -> None:
+ """Show missing dependency warning, if appropriate.
+
+ If the adblocking method is configured such that the Brave adblocker
+ should be used, but the optional dependency is not satisfied, we show an
+ error message.
+ """
+ adblock_info = version.MODULE_INFO["adblock"]
+
+ method = config.val.content.blocking.method
+ if method not in ("both", "adblock"):
+ return
+
+ if adblock_info.is_outdated():
+ message.warning(
+ f"Installed version {adblock_info.get_version()} of the 'adblock' "
+ f"dependency is too old. Minimum supported is {adblock_info.min_version}."
+ )
+ else:
+ assert not adblock_info.is_installed(), adblock_info
+ message.warning(
+ f"Ad blocking method is set to '{method}' but 'adblock' dependency is not "
+ "installed."
+ )
+
+
+_RESOURCE_TYPE_STRINGS = {
+ ResourceType.main_frame: "main_frame",
+ ResourceType.sub_frame: "sub_frame",
+ ResourceType.stylesheet: "stylesheet",
+ ResourceType.script: "script",
+ ResourceType.image: "image",
+ ResourceType.font_resource: "font",
+ ResourceType.sub_resource: "sub_frame",
+ ResourceType.object: "object",
+ ResourceType.media: "media",
+ ResourceType.worker: "other",
+ ResourceType.shared_worker: "other",
+ ResourceType.prefetch: "other",
+ ResourceType.favicon: "image",
+ ResourceType.xhr: "xhr",
+ ResourceType.ping: "ping",
+ ResourceType.service_worker: "other",
+ ResourceType.csp_report: "csp_report",
+ ResourceType.plugin_resource: "other",
+ ResourceType.preload_main_frame: "other",
+ ResourceType.preload_sub_frame: "other",
+ ResourceType.unknown: "other",
+ None: "",
+}
+
+
+def resource_type_to_string(resource_type: Optional[ResourceType]) -> str:
+ return _RESOURCE_TYPE_STRINGS.get(resource_type, "other")
+
+
+class BraveAdBlocker:
+
+ """Manage blocked hosts based on Brave's adblocker.
+
+ Attributes:
+ enabled: Whether to block ads or not.
+ _has_basedir: Whether a custom --basedir is set.
+ _cache_path: The path of the adblock engine cache file
+ _engine: Brave ad-blocking engine.
+ """
+
+ def __init__(self, *, data_dir: pathlib.Path, has_basedir: bool = False) -> None:
+ self.enabled = _should_be_used()
+ self._has_basedir = has_basedir
+ self._cache_path = data_dir / "adblock-cache.dat"
+ self._engine = adblock.Engine(adblock.FilterSet())
+
+ def _is_blocked(
+ self,
+ request_url: QUrl,
+ first_party_url: Optional[QUrl] = None,
+ resource_type: Optional[interceptor.ResourceType] = None,
+ ) -> bool:
+ """Check whether the given request is blocked."""
+ if not self.enabled:
+ # Do nothing if `content.blocking.method` is not set to enable the
+ # use of this adblocking module.
+ return False
+
+ if first_party_url is None or not first_party_url.isValid():
+ # FIXME: It seems that when `first_party_url` is None, every URL
+ # I try is blocked. This may have been a result of me incorrectly
+ # using the upstream library, or an upstream bug. For now we don't
+ # block any request with `first_party_url=None`.
+ return False
+
+ qtutils.ensure_valid(request_url)
+
+ if not config.get("content.blocking.enabled", url=first_party_url):
+ # Do nothing if adblocking is disabled for this site.
+ return False
+
+ result = self._engine.check_network_urls(
+ request_url.toString(),
+ first_party_url.toString(),
+ resource_type_to_string(resource_type),
+ )
+
+ if not result.matched:
+ return False
+ elif result.exception is not None and not result.important:
+ # Exception is not `None` when the blocker matched on an exception
+ # rule. Effectively this means that there was a match, but the
+ # request should not be blocked.
+ #
+ # An `important` match means that exceptions should not apply and
+ # no further checking is necessary--the request should be blocked.
+ logger.debug(
+ "Excepting %s from being blocked by %s because of %s",
+ request_url.toDisplayString(),
+ result.filter,
+ result.exception,
+ )
+ return False
+ elif blockutils.is_whitelisted_url(request_url):
+ logger.debug(
+ "Request to %s is whitelisted, thus not blocked",
+ request_url.toDisplayString(),
+ )
+ return False
+ return True
+
+ def filter_request(self, info: interceptor.Request) -> None:
+ """Block the given request if necessary."""
+ if self._is_blocked(info.request_url, info.first_party_url, info.resource_type):
+ logger.debug(
+ "Request to %s blocked by ad blocker.",
+ info.request_url.toDisplayString(),
+ )
+ info.block()
+
+ def read_cache(self) -> None:
+ """Initialize the adblocking engine from cache file."""
+ if self._cache_path.is_file():
+ logger.debug("Loading cached adblock data: %s", self._cache_path)
+ self._engine.deserialize_from_file(str(self._cache_path))
+ else:
+ if (
+ config.val.content.blocking.adblock.lists
+ and not self._has_basedir
+ and config.val.content.blocking.enabled
+ and self.enabled
+ ):
+ message.info("Run :adblock-update to get adblock lists.")
+
+ def adblock_update(self) -> blockutils.BlocklistDownloads:
+ """Update the adblock block lists."""
+ logger.info("Downloading adblock filter lists...")
+
+ filter_set = adblock.FilterSet()
+ dl = blockutils.BlocklistDownloads(config.val.content.blocking.adblock.lists)
+ dl.single_download_finished.connect(
+ functools.partial(self._on_download_finished, filter_set=filter_set)
+ )
+ dl.all_downloads_finished.connect(
+ functools.partial(self._on_lists_downloaded, filter_set=filter_set)
+ )
+ dl.initiate()
+ return dl
+
+ def _on_lists_downloaded(
+ self, done_count: int, filter_set: "adblock.FilterSet"
+ ) -> None:
+ """Install block lists after files have been downloaded."""
+ self._engine = adblock.Engine(filter_set)
+ self._engine.serialize_to_file(str(self._cache_path))
+ message.info(
+ f"braveadblock: Filters successfully read from {done_count} sources.")
+
+ def update_files(self) -> None:
+ """Update files when the config changed."""
+ if not config.val.content.blocking.adblock.lists:
+ try:
+ self._cache_path.unlink()
+ except FileNotFoundError:
+ pass
+ except OSError as e:
+ logger.exception("Failed to remove adblock cache file: %s", e)
+
+ def _on_download_finished(
+ self, fileobj: IO[bytes], filter_set: "adblock.FilterSet"
+ ) -> None:
+ """When a blocklist download finishes, add it to the given filter set.
+
+ Arguments:
+ fileobj: The finished download's contents.
+ """
+ fileobj.seek(0)
+ try:
+ with io.TextIOWrapper(fileobj, encoding="utf-8") as text_io:
+ filter_set.add_filter_list(text_io.read())
+ except UnicodeDecodeError:
+ message.info("braveadblock: Block list is not valid utf-8")
+
+
+@hook.config_changed("content.blocking.adblock.lists")
+def on_lists_changed() -> None:
+ """Remove cached blocker from disk when blocklist changes."""
+ if ad_blocker is not None:
+ ad_blocker.update_files()
+
+
+@hook.config_changed("content.blocking.method")
+def on_method_changed() -> None:
+ """When the adblocking method changes, update blocker accordingly."""
+ if ad_blocker is not None:
+ # This implies the 'adblock' dependency is satisfied
+ ad_blocker.enabled = _should_be_used()
+ else:
+ _possibly_show_missing_dependency_warning()
+
+
+@hook.init()
+def init(context: apitypes.InitContext) -> None:
+ """Initialize the Brave ad blocker."""
+ global ad_blocker
+
+ adblock_info = version.MODULE_INFO["adblock"]
+ if not adblock_info.is_usable():
+ # We want 'adblock' to be an optional dependency. If the module is
+ # not installed or is outdated, we simply keep the `ad_blocker` global at
+ # `None`.
+ _possibly_show_missing_dependency_warning()
+ return
+
+ ad_blocker = BraveAdBlocker(
+ data_dir=context.data_dir, has_basedir=context.args.basedir is not None
+ )
+ ad_blocker.read_cache()
+ interceptor.register(ad_blocker.filter_request)
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/hostblock.py b/qutebrowser/components/hostblock.py
new file mode 100644
index 000000000..e1ef88667
--- /dev/null
+++ b/qutebrowser/components/hostblock.py
@@ -0,0 +1,307 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2014-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/>.
+
+"""Functions related to host blocking."""
+
+import os.path
+import posixpath
+import zipfile
+import logging
+import pathlib
+from typing import cast, IO, Set
+
+from PyQt5.QtCore import QUrl
+
+from qutebrowser.api import (
+ hook,
+ config,
+ message,
+ interceptor,
+ apitypes,
+ qtutils,
+)
+from qutebrowser.components.utils import blockutils
+from qutebrowser.utils import version # FIXME: Move needed parts into api namespace?
+
+
+logger = logging.getLogger("network")
+host_blocker = cast("HostBlocker", None)
+
+
+def _guess_zip_filename(zf: zipfile.ZipFile) -> str:
+ """Guess which file to use inside a zip file."""
+ files = zf.namelist()
+ if len(files) == 1:
+ return files[0]
+ else:
+ for e in files:
+ if posixpath.splitext(e)[0].lower() == "hosts":
+ return e
+ raise FileNotFoundError("No hosts file found in zip")
+
+
+def get_fileobj(byte_io: IO[bytes]) -> IO[bytes]:
+ """Get a usable file object to read the hosts file from."""
+ byte_io.seek(0) # rewind downloaded file
+ if zipfile.is_zipfile(byte_io):
+ byte_io.seek(0) # rewind what zipfile.is_zipfile did
+ zf = zipfile.ZipFile(byte_io)
+ filename = _guess_zip_filename(zf)
+ byte_io = zf.open(filename, mode="r")
+ else:
+ byte_io.seek(0) # rewind what zipfile.is_zipfile did
+ return byte_io
+
+
+def _should_be_used() -> bool:
+ """Whether the hostblocker should be used or not."""
+ method = config.val.content.blocking.method
+
+ adblock_info = version.MODULE_INFO["adblock"]
+ adblock_usable = adblock_info.is_usable()
+
+ logger.debug(f"Configured adblock method {method}, adblock library usable: "
+ f"{adblock_usable}")
+ return method in ("both", "hosts") or (method == "auto" and not adblock_usable)
+
+
+class HostBlocker:
+
+ """Manage blocked hosts based from /etc/hosts-like files.
+
+ Attributes:
+ enabled: Given the current blocking method, should the host blocker be enabled?
+ _blocked_hosts: A set of blocked hosts.
+ _config_blocked_hosts: A set of blocked hosts from ~/.config.
+ _local_hosts_file: The path to the blocked-hosts file.
+ _config_hosts_file: The path to a blocked-hosts in ~/.config
+ _has_basedir: Whether a custom --basedir is set.
+ """
+
+ def __init__(
+ self,
+ *,
+ data_dir: pathlib.Path,
+ config_dir: pathlib.Path,
+ has_basedir: bool = False
+ ) -> None:
+ self.enabled = _should_be_used()
+ self._has_basedir = has_basedir
+ self._blocked_hosts: Set[str] = set()
+ self._config_blocked_hosts: Set[str] = set()
+
+ self._local_hosts_file = str(data_dir / "blocked-hosts")
+ self.update_files()
+
+ self._config_hosts_file = str(config_dir / "blocked-hosts")
+
+ def _is_blocked(self, request_url: QUrl, first_party_url: QUrl = None) -> bool:
+ """Check whether the given request is blocked."""
+ if not self.enabled:
+ return False
+
+ if first_party_url is not None and not first_party_url.isValid():
+ first_party_url = None
+
+ qtutils.ensure_valid(request_url)
+
+ if not config.get("content.blocking.enabled", url=first_party_url):
+ return False
+
+ host = request_url.host()
+ return (
+ host in self._blocked_hosts or host in self._config_blocked_hosts
+ ) and not blockutils.is_whitelisted_url(request_url)
+
+ def filter_request(self, info: interceptor.Request) -> None:
+ """Block the given request if necessary."""
+ if self._is_blocked(
+ request_url=info.request_url, first_party_url=info.first_party_url
+ ):
+ logger.debug(
+ "Request to {} blocked by host blocker.".format(info.request_url.host())
+ )
+ info.block()
+
+ def _read_hosts_line(self, raw_line: bytes) -> Set[str]:
+ """Read hosts from the given line.
+
+ Args:
+ line: The bytes object to read.
+
+ Returns:
+ A set containing valid hosts found
+ in the line.
+ """
+ if raw_line.startswith(b"#"):
+ # Ignoring comments early so we don't have to care about
+ # encoding errors in them
+ return set()
+
+ line = raw_line.decode("utf-8")
+
+ # Remove comments
+ hash_idx = line.find("#")
+ line = line if hash_idx == -1 else line[:hash_idx]
+
+ parts = line.strip().split()
+ if len(parts) == 1:
+ # "one host per line" format
+ hosts = parts
+ else:
+ # /etc/hosts format
+ hosts = parts[1:]
+
+ filtered_hosts = set()
+ for host in hosts:
+ if "." in host and not host.endswith(".localdomain") and host != "0.0.0.0":
+ filtered_hosts.update([host])
+
+ return filtered_hosts
+
+ def _read_hosts_file(self, filename: str, target: Set[str]) -> bool:
+ """Read hosts from the given filename.
+
+ Args:
+ filename: The file to read.
+ target: The set to store the hosts in.
+
+ Return:
+ True if a read was attempted, False otherwise
+ """
+ if not os.path.exists(filename):
+ return False
+
+ try:
+ with open(filename, "rb") as f:
+ for line in f:
+ target |= self._read_hosts_line(line)
+
+ except (OSError, UnicodeDecodeError):
+ logger.exception("Failed to read host blocklist!")
+
+ return True
+
+ def read_hosts(self) -> None:
+ """Read hosts from the existing blocked-hosts file."""
+ self._blocked_hosts = set()
+
+ self._read_hosts_file(self._config_hosts_file, self._config_blocked_hosts)
+
+ found = self._read_hosts_file(self._local_hosts_file, self._blocked_hosts)
+
+ if not found:
+ if (
+ config.val.content.blocking.hosts.lists
+ and not self._has_basedir
+ and config.val.content.blocking.enabled
+ and self.enabled
+ ):
+ message.info("Run :adblock-update to get adblock lists.")
+
+ def adblock_update(self) -> blockutils.BlocklistDownloads:
+ """Update the adblock block lists."""
+ self._read_hosts_file(self._config_hosts_file, self._config_blocked_hosts)
+ self._blocked_hosts = set()
+
+ blocklists = config.val.content.blocking.hosts.lists
+ dl = blockutils.BlocklistDownloads(blocklists)
+ dl.single_download_finished.connect(self._merge_file)
+ dl.all_downloads_finished.connect(self._on_lists_downloaded)
+ dl.initiate()
+ return dl
+
+ def _merge_file(self, byte_io: IO[bytes]) -> None:
+ """Read and merge host files.
+
+ Args:
+ byte_io: The BytesIO object of the completed download.
+ """
+ error_count = 0
+ line_count = 0
+ try:
+ f = get_fileobj(byte_io)
+ except (OSError, zipfile.BadZipFile, zipfile.LargeZipFile, LookupError) as e:
+ message.error(
+ "hostblock: Error while reading {}: {} - {}".format(
+ byte_io.name, e.__class__.__name__, e
+ )
+ )
+ return
+
+ for line in f:
+ line_count += 1
+ try:
+ self._blocked_hosts |= self._read_hosts_line(line)
+ except UnicodeDecodeError:
+ logger.error("Failed to decode: {!r}".format(line))
+ error_count += 1
+
+ logger.debug("{}: read {} lines".format(byte_io.name, line_count))
+ if error_count > 0:
+ message.error(
+ "hostblock: {} read errors for {}".format(error_count, byte_io.name)
+ )
+
+ def _on_lists_downloaded(self, done_count: int) -> None:
+ """Install block lists after files have been downloaded."""
+ try:
+ with open(self._local_hosts_file, "w", encoding="utf-8") as f:
+ for host in sorted(self._blocked_hosts):
+ f.write(host + "\n")
+ message.info(
+ "hostblock: Read {} hosts from {} sources.".format(
+ len(self._blocked_hosts), done_count
+ )
+ )
+ except OSError:
+ logger.exception("Failed to write host block list!")
+
+ def update_files(self) -> None:
+ """Update files when the config changed."""
+ if not config.val.content.blocking.hosts.lists:
+ try:
+ os.remove(self._local_hosts_file)
+ except FileNotFoundError:
+ pass
+ except OSError as e:
+ logger.exception("Failed to delete hosts file: {}".format(e))
+
+
+@hook.config_changed("content.blocking.hosts.lists")
+def on_lists_changed() -> None:
+ host_blocker.update_files()
+
+
+@hook.config_changed("content.blocking.method")
+def on_method_changed() -> None:
+ host_blocker.enabled = _should_be_used()
+
+
+@hook.init()
+def init(context: apitypes.InitContext) -> None:
+ """Initialize the host blocker."""
+ global host_blocker
+ host_blocker = HostBlocker(
+ data_dir=context.data_dir,
+ config_dir=context.config_dir,
+ has_basedir=context.args.basedir is not None,
+ )
+ host_blocker.read_hosts()
+ interceptor.register(host_blocker.filter_request)
diff --git a/qutebrowser/components/misccommands.py b/qutebrowser/components/misccommands.py
index e3fecab5a..f553fce3b 100644
--- a/qutebrowser/components/misccommands.py
+++ b/qutebrowser/components/misccommands.py
@@ -23,7 +23,7 @@ import os
import signal
import functools
import logging
-import typing
+from typing import Optional
try:
import hunter
@@ -35,10 +35,13 @@ from PyQt5.QtPrintSupport import QPrintPreviewDialog
from qutebrowser.api import cmdutils, apitypes, message, config
+# FIXME should be part of qutebrowser.api?
+from qutebrowser.completion.models import miscmodels
+
@cmdutils.register(name='reload')
@cmdutils.argument('tab', value=cmdutils.Value.count_tab)
-def reloadpage(tab: typing.Optional[apitypes.Tab],
+def reloadpage(tab: Optional[apitypes.Tab],
force: bool = False) -> None:
"""Reload the current/[count]th tab.
@@ -52,7 +55,7 @@ def reloadpage(tab: typing.Optional[apitypes.Tab],
@cmdutils.register()
@cmdutils.argument('tab', value=cmdutils.Value.count_tab)
-def stop(tab: typing.Optional[apitypes.Tab]) -> None:
+def stop(tab: Optional[apitypes.Tab]) -> None:
"""Stop loading in the current/[count]th tab.
Args:
@@ -71,8 +74,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_()
@@ -92,7 +97,7 @@ def _print_pdf(tab: apitypes.Tab, filename: str) -> None:
@cmdutils.register(name='print')
@cmdutils.argument('tab', value=cmdutils.Value.count_tab)
@cmdutils.argument('pdf', flag='f', metavar='file')
-def printpage(tab: typing.Optional[apitypes.Tab],
+def printpage(tab: Optional[apitypes.Tab],
preview: bool = False, *,
pdf: str = None) -> None:
"""Print the current/[count]th tab.
@@ -158,7 +163,7 @@ def insert_text(tab: apitypes.Tab, text: str) -> None:
Args:
text: The text to insert.
"""
- def _insert_text_cb(elem: typing.Optional[apitypes.WebElement]) -> None:
+ def _insert_text_cb(elem: Optional[apitypes.WebElement]) -> None:
if elem is None:
message.error("No element focused!")
return
@@ -190,7 +195,7 @@ def click_element(tab: apitypes.Tab, filter_: str, value: str, *,
target: How to open the clicked element (normal/tab/tab-bg/window).
force_event: Force generating a fake click event.
"""
- def single_cb(elem: typing.Optional[apitypes.WebElement]) -> None:
+ def single_cb(elem: Optional[apitypes.WebElement]) -> None:
"""Click a single element."""
if elem is None:
message.error("No element found with id {}!".format(value))
@@ -231,7 +236,7 @@ def debug_webaction(tab: apitypes.Tab, action: str, count: int = 1) -> None:
@cmdutils.register()
@cmdutils.argument('tab', value=cmdutils.Value.count_tab)
-def tab_mute(tab: typing.Optional[apitypes.Tab]) -> None:
+def tab_mute(tab: Optional[apitypes.Tab]) -> None:
"""Mute/Unmute the current/[count]th tab.
Args:
@@ -311,3 +316,30 @@ def debug_trace(expr: str = "") -> None:
eval('hunter.trace({})'.format(expr))
except Exception as e:
raise cmdutils.CommandError("{}: {}".format(e.__class__.__name__, e))
+
+
+@cmdutils.register()
+@cmdutils.argument('tab', value=cmdutils.Value.cur_tab)
+@cmdutils.argument('position', completion=miscmodels.inspector_position)
+def devtools(tab: apitypes.Tab,
+ position: apitypes.InspectorPosition = None) -> None:
+ """Toggle the developer tools (web inspector).
+
+ Args:
+ position: Where to open the devtools
+ (right/left/top/bottom/window).
+ """
+ try:
+ tab.private_api.toggle_inspector(position)
+ except apitypes.InspectorError as e:
+ raise cmdutils.CommandError(e)
+
+
+@cmdutils.register()
+@cmdutils.argument('tab', value=cmdutils.Value.cur_tab)
+def devtools_focus(tab: apitypes.Tab) -> None:
+ """Toggle focus between the devtools/tab."""
+ try:
+ tab.data.splitter.cycle_focus()
+ except apitypes.InspectorError as e:
+ raise cmdutils.CommandError(e)
diff --git a/qutebrowser/components/readlinecommands.py b/qutebrowser/components/readlinecommands.py
index 076bb9055..ea8f12edf 100644
--- a/qutebrowser/components/readlinecommands.py
+++ b/qutebrowser/components/readlinecommands.py
@@ -19,7 +19,7 @@
"""Bridge to provide readline-like shortcuts for QLineEdits."""
-import typing
+from typing import Iterable, Optional, MutableMapping
from PyQt5.QtWidgets import QApplication, QLineEdit
@@ -35,9 +35,9 @@ class _ReadlineBridge:
"""
def __init__(self) -> None:
- self._deleted = {} # type: typing.MutableMapping[QLineEdit, str]
+ self._deleted: MutableMapping[QLineEdit, str] = {}
- def _widget(self) -> typing.Optional[QLineEdit]:
+ def _widget(self) -> Optional[QLineEdit]:
"""Get the currently active QLineEdit."""
w = QApplication.instance().focusWidget()
if isinstance(w, QLineEdit):
@@ -86,7 +86,7 @@ class _ReadlineBridge:
def kill_line(self) -> None:
self._dispatch('end', mark=True, delete=True)
- def _rubout(self, delim: typing.Iterable[str]) -> None:
+ def _rubout(self, delim: Iterable[str]) -> None:
"""Delete backwards using the characters in delim as boundaries."""
widget = self._widget()
if widget is None:
diff --git a/qutebrowser/components/utils/__init__.py b/qutebrowser/components/utils/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/qutebrowser/components/utils/__init__.py
diff --git a/qutebrowser/components/utils/blockutils.py b/qutebrowser/components/utils/blockutils.py
new file mode 100644
index 000000000..502038f48
--- /dev/null
+++ b/qutebrowser/components/utils/blockutils.py
@@ -0,0 +1,162 @@
+# 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/>.
+
+
+"""Code that is shared between the host blocker and Brave ad blocker."""
+
+import os
+import functools
+from typing import IO, List, Optional
+
+from PyQt5.QtCore import QUrl, QObject, pyqtSignal
+
+from qutebrowser.api import downloads, message, config
+
+
+class FakeDownload(downloads.TempDownload):
+
+ """A download stub to use on_download_finished with local files."""
+
+ def __init__(self, fileobj: IO[bytes]) -> None:
+ # pylint: disable=super-init-not-called
+ self.fileobj = fileobj
+ self.successful = True
+
+
+class BlocklistDownloads(QObject):
+ """Download blocklists from the given URLs.
+
+ Attributes:
+ single_download_finished:
+ A signal that is emitted when a single download has finished. The
+ listening slot is provided with the download object.
+ all_downloads_finished:
+ A signal that is emitted when all downloads have finished. The
+ first argument is the number of items downloaded.
+ _urls: The URLs to download.
+ _in_progress: The DownloadItems which are currently downloading.
+ _done_count: How many files have been read successfully.
+ _finished_registering_downloads:
+ Used to make sure that if all the downloads finish really quickly,
+ before all of the block-lists have been added to the download
+ queue, we don't emit `single_download_finished`.
+ _started: Has the `initiate` method been called?
+ _finished: Has `all_downloads_finished` been emitted?
+ """
+
+ single_download_finished = pyqtSignal(object) # arg: the file object
+ all_downloads_finished = pyqtSignal(int) # arg: download count
+
+ def __init__(self, urls: List[QUrl], parent: Optional[QObject] = None) -> None:
+ super().__init__(parent)
+ self._urls = urls
+
+ self._in_progress: List[downloads.TempDownload] = []
+ self._done_count = 0
+ self._finished_registering_downloads = False
+ self._started = False
+ self._finished = False
+
+ def initiate(self) -> None:
+ """Initiate downloads of each url in `self._urls`."""
+ if self._started:
+ raise ValueError("This download has already been initiated")
+ self._started = True
+
+ if not self._urls:
+ self._finished = True
+ self.all_downloads_finished.emit(self._done_count)
+ return
+
+ for url in self._urls:
+ self._download_blocklist_url(url)
+ self._finished_registering_downloads = True
+
+ if not self._in_progress and not self._finished:
+ # The in-progress list is empty but we still haven't called the
+ # completion callback yet. This happens when all downloads finish
+ # before we've set `_finished_registering_dowloads` to False.
+ self._finished = True
+ self.all_downloads_finished.emit(self._done_count)
+
+ def _download_blocklist_url(self, url: QUrl) -> None:
+ """Take a blocklist url and queue it for download.
+
+ Args:
+ url: url to download
+ """
+ if url.scheme() == "file":
+ # The URL describes a local file on disk if the url scheme is
+ # "file://". We handle those as a special case.
+ filename = url.toLocalFile()
+ if os.path.isdir(filename):
+ for entry in os.scandir(filename):
+ if entry.is_file():
+ self._import_local(entry.path)
+ else:
+ self._import_local(filename)
+ else:
+ download = downloads.download_temp(url)
+ self._in_progress.append(download)
+ download.finished.connect(
+ functools.partial(self._on_download_finished, download)
+ )
+
+ def _import_local(self, filename: str) -> None:
+ """Pretend that a local file was downloaded from the internet.
+
+ Args:
+ filename: path to a local file to import.
+ """
+ try:
+ fileobj = open(filename, "rb")
+ except OSError as e:
+ message.error(
+ "blockutils: Error while reading {}: {}".format(filename, e.strerror)
+ )
+ return
+ download = FakeDownload(fileobj)
+ self._in_progress.append(download)
+ self._on_download_finished(download)
+
+ def _on_download_finished(self, download: downloads.TempDownload) -> None:
+ """Check if all downloads are finished and if so, trigger callback.
+
+ Arguments:
+ download: The finished download.
+ """
+ self._in_progress.remove(download)
+ if download.successful:
+ self._done_count += 1
+ assert not isinstance(download.fileobj, downloads.UnsupportedAttribute)
+ assert download.fileobj is not None
+ try:
+ # Call the user-provided callback
+ self.single_download_finished.emit(download.fileobj)
+ finally:
+ download.fileobj.close()
+ if not self._in_progress and self._finished_registering_downloads:
+ self._finished = True
+ self.all_downloads_finished.emit(self._done_count)
+
+
+def is_whitelisted_url(url: QUrl) -> bool:
+ """Check if the given URL is on the adblock whitelist."""
+ whitelist = config.val.content.blocking.whitelist
+ return any(pattern.matches(url) for pattern in whitelist)
diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py
index a38001e75..d8e8d612e 100644
--- a/qutebrowser/config/config.py
+++ b/qutebrowser/config/config.py
@@ -22,8 +22,8 @@
import copy
import contextlib
import functools
-import typing
-from typing import Any
+from typing import (TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Mapping,
+ MutableMapping, MutableSequence, Optional, Tuple, cast)
from PyQt5.QtCore import pyqtSignal, QObject, QUrl
@@ -32,16 +32,15 @@ from qutebrowser.utils import utils, log, urlmatch
from qutebrowser.misc import objects
from qutebrowser.keyinput import keyutils
-if typing.TYPE_CHECKING:
- from typing import Tuple, MutableMapping
+if TYPE_CHECKING:
from qutebrowser.config import configcache, configfiles
from qutebrowser.misc import savemanager
# An easy way to access the config from other code via config.val.foo
-val = typing.cast('ConfigContainer', None)
-instance = typing.cast('Config', None)
-key_instance = typing.cast('KeyConfig', None)
-cache = typing.cast('configcache.ConfigCache', None)
+val = cast('ConfigContainer', None)
+instance = cast('Config', None)
+key_instance = cast('KeyConfig', None)
+cache = cast('configcache.ConfigCache', None)
# Keeping track of all change filters to validate them later.
change_filters = []
@@ -84,7 +83,7 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name
not configdata.is_valid_prefix(self._option)):
raise configexc.NoOptionError(self._option)
- def check_match(self, option: typing.Optional[str]) -> bool:
+ def check_match(self, option: Optional[str]) -> bool:
"""Check if the given option matches the filter."""
if option is None:
# Called directly, not from a config change event.
@@ -97,7 +96,7 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name
else:
return False
- def __call__(self, func: typing.Callable) -> typing.Callable:
+ def __call__(self, func: Callable) -> Callable:
"""Filter calls to the decorated function.
Gets called when a function should be decorated.
@@ -115,7 +114,7 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name
"""
if self._function:
@functools.wraps(func)
- def func_wrapper(option: str = None) -> typing.Any:
+ def func_wrapper(option: str = None) -> Any:
"""Call the underlying function."""
if self.check_match(option):
return func()
@@ -123,8 +122,7 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name
return func_wrapper
else:
@functools.wraps(func)
- def meth_wrapper(wrapper_self: typing.Any,
- option: str = None) -> typing.Any:
+ def meth_wrapper(wrapper_self: Any, option: str = None) -> Any:
"""Call the underlying function."""
if self.check_match(option):
return func(wrapper_self)
@@ -142,7 +140,7 @@ class KeyConfig:
_config: The Config object to be used.
"""
- _ReverseBindings = typing.Dict[str, typing.MutableSequence[str]]
+ _ReverseBindings = Dict[str, MutableSequence[str]]
def __init__(self, config: 'Config') -> None:
self._config = config
@@ -154,10 +152,7 @@ class KeyConfig:
if mode not in configdata.DATA['bindings.default'].default:
raise configexc.KeybindingError("Invalid mode {}!".format(mode))
- def get_bindings_for(
- self,
- mode: str
- ) -> typing.Dict[keyutils.KeySequence, str]:
+ def get_bindings_for(self, mode: str) -> Dict[keyutils.KeySequence, str]:
"""Get the combined bindings for the given mode."""
bindings = dict(val.bindings.default[mode])
for key, binding in val.bindings.commands[mode].items():
@@ -169,7 +164,7 @@ class KeyConfig:
def get_reverse_bindings_for(self, mode: str) -> '_ReverseBindings':
"""Get a dict of commands to a list of bindings for the mode."""
- cmd_to_keys = {} # type: KeyConfig._ReverseBindings
+ cmd_to_keys: KeyConfig._ReverseBindings = {}
bindings = self.get_bindings_for(mode)
for seq, full_cmd in sorted(bindings.items()):
for cmd in full_cmd.split(';;'):
@@ -185,7 +180,7 @@ class KeyConfig:
def get_command(self,
key: keyutils.KeySequence,
mode: str,
- default: bool = False) -> typing.Optional[str]:
+ default: bool = False) -> Optional[str]:
"""Get the command for a given key (or None)."""
self._validate(key, mode)
if default:
@@ -206,7 +201,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')
@@ -278,19 +273,20 @@ class Config(QObject):
yaml_config: 'configfiles.YamlConfig',
parent: QObject = None) -> None:
super().__init__(parent)
- self._mutables = {} # type: MutableMapping[str, Tuple[Any, Any]]
+ self._mutables: MutableMapping[str, Tuple[Any, Any]] = {}
self._yaml = yaml_config
self._init_values()
self.yaml_loaded = False
self.config_py_loaded = False
+ self.warn_autoconfig = True
def _init_values(self) -> None:
"""Populate the self._values dict."""
- self._values = {} # type: typing.Mapping
+ self._values: Mapping = {}
for name, opt in configdata.DATA.items():
self._values[name] = configutils.Values(opt)
- def __iter__(self) -> typing.Iterator[configutils.Values]:
+ def __iter__(self) -> Iterator[configutils.Values]:
"""Iterate over configutils.Values items."""
yield from self._values.values()
@@ -391,7 +387,7 @@ class Config(QObject):
def get_obj_for_pattern(
self, name: str, *,
- pattern: typing.Optional[urlmatch.UrlPattern]
+ pattern: Optional[urlmatch.UrlPattern]
) -> Any:
"""Get the given setting as object (for YAML/config.py).
@@ -525,7 +521,7 @@ class Config(QObject):
Return:
The changed config part as string.
"""
- lines = [] # type: typing.List[str]
+ lines: List[str] = []
for values in sorted(self, key=lambda v: v.opt.name):
lines += values.dump()
@@ -564,7 +560,7 @@ class ConfigContainer:
pattern=self._pattern)
@contextlib.contextmanager
- def _handle_error(self, action: str, name: str) -> typing.Iterator[None]:
+ def _handle_error(self, action: str, name: str) -> Iterator[None]:
try:
yield
except configexc.Error as e:
diff --git a/qutebrowser/config/configcache.py b/qutebrowser/config/configcache.py
index 2bc45f0f8..a07a22ee5 100644
--- a/qutebrowser/config/configcache.py
+++ b/qutebrowser/config/configcache.py
@@ -20,7 +20,7 @@
"""Implementation of a basic config cache."""
-import typing
+from typing import Any, Dict
from qutebrowser.config import config
@@ -38,14 +38,14 @@ class ConfigCache:
"""
def __init__(self) -> None:
- self._cache = {} # type: typing.Dict[str, typing.Any]
+ self._cache: Dict[str, Any] = {}
config.instance.changed.connect(self._on_config_changed)
def _on_config_changed(self, attr: str) -> None:
if attr in self._cache:
del self._cache[attr]
- def __getitem__(self, attr: str) -> typing.Any:
+ def __getitem__(self, attr: str) -> Any:
try:
return self._cache[attr]
except KeyError:
diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py
index 1d6bb563c..0f57b0a03 100644
--- a/qutebrowser/config/configcommands.py
+++ b/qutebrowser/config/configcommands.py
@@ -19,9 +19,9 @@
"""Commands related to the configuration."""
-import typing
import os.path
import contextlib
+from typing import TYPE_CHECKING, Iterator, List, Optional
from PyQt5.QtCore import QUrl
@@ -32,7 +32,7 @@ from qutebrowser.config import configtypes, configexc, configfiles, configdata
from qutebrowser.misc import editor
from qutebrowser.keyinput import keyutils
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.config.config import Config, KeyConfig
@@ -47,17 +47,14 @@ class ConfigCommands:
self._keyconfig = keyconfig
@contextlib.contextmanager
- def _handle_config_error(self) -> typing.Iterator[None]:
+ def _handle_config_error(self) -> Iterator[None]:
"""Catch errors in set_command and raise CommandError."""
try:
yield
except configexc.Error as e:
raise cmdutils.CommandError(str(e))
- def _parse_pattern(
- self,
- pattern: typing.Optional[str]
- ) -> typing.Optional[urlmatch.UrlPattern]:
+ def _parse_pattern(self, pattern: Optional[str]) -> Optional[urlmatch.UrlPattern]:
"""Parse a pattern string argument to a pattern."""
if pattern is None:
return None
@@ -75,8 +72,7 @@ class ConfigCommands:
except keyutils.KeyParseError as e:
raise cmdutils.CommandError(str(e))
- def _print_value(self, option: str,
- pattern: typing.Optional[urlmatch.UrlPattern]) -> None:
+ def _print_value(self, option: str, pattern: Optional[urlmatch.UrlPattern]) -> None:
"""Print the value of the given option."""
with self._handle_config_error():
value = self._config.get_str(option, pattern=pattern)
@@ -262,6 +258,15 @@ 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) -> None:
+ """Show all customized options."""
+ url = QUrl('qute://configdiff')
+ 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,15 +448,15 @@ 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 "
"overwrite!".format(filename))
- options = [] # type: typing.List
+ options: List = []
if defaults:
options = [(None, opt, opt.default)
for _name, opt in sorted(configdata.DATA.items())]
diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py
index 290d897bd..065527bb9 100644
--- a/qutebrowser/config/configdata.py
+++ b/qutebrowser/config/configdata.py
@@ -24,8 +24,8 @@ Module attributes:
DATA: A dict of Option objects after init() has been called.
"""
-import typing
-from typing import Optional
+from typing import (Any, Dict, Iterable, List, Mapping, MutableMapping, Optional,
+ Sequence, Tuple, Union, cast)
import functools
import attr
@@ -33,10 +33,10 @@ from qutebrowser.config import configtypes
from qutebrowser.utils import usertypes, qtutils, utils
from qutebrowser.misc import debugcachestats
-DATA = typing.cast(typing.Mapping[str, 'Option'], None)
-MIGRATIONS = typing.cast('Migrations', None)
+DATA = cast(Mapping[str, 'Option'], None)
+MIGRATIONS = cast('Migrations', None)
-_BackendDict = typing.Mapping[str, typing.Union[str, bool]]
+_BackendDict = Mapping[str, Union[str, bool]]
@attr.s
@@ -47,15 +47,15 @@ class Option:
Note that this is just an option which exists, with no value associated.
"""
- name = attr.ib() # type: str
- typ = attr.ib() # type: configtypes.BaseType
- default = attr.ib() # type: typing.Any
- backends = attr.ib() # type: typing.Iterable[usertypes.Backend]
- raw_backends = attr.ib() # type: Optional[typing.Mapping[str, bool]]
- description = attr.ib() # type: str
- supports_pattern = attr.ib(default=False) # type: bool
- restart = attr.ib(default=False) # type: bool
- no_autoconfig = attr.ib(default=False) # type: bool
+ name: str = attr.ib()
+ typ: configtypes.BaseType = attr.ib()
+ default: Any = attr.ib()
+ backends: Iterable[usertypes.Backend] = attr.ib()
+ raw_backends: Optional[Mapping[str, bool]] = attr.ib()
+ description: str = attr.ib()
+ supports_pattern: bool = attr.ib(default=False)
+ restart: bool = attr.ib(default=False)
+ no_autoconfig: bool = attr.ib(default=False)
@attr.s
@@ -68,13 +68,11 @@ class Migrations:
deleted: A list of option names which have been removed.
"""
- renamed = attr.ib(
- default=attr.Factory(dict)) # type: typing.Dict[str, str]
- deleted = attr.ib(
- default=attr.Factory(list)) # type: typing.List[str]
+ renamed: Dict[str, str] = attr.ib(default=attr.Factory(dict))
+ deleted: List[str] = attr.ib(default=attr.Factory(list))
-def _raise_invalid_node(name: str, what: str, node: typing.Any) -> None:
+def _raise_invalid_node(name: str, what: str, node: Any) -> None:
"""Raise an exception for an invalid configdata YAML node.
Args:
@@ -88,14 +86,14 @@ def _raise_invalid_node(name: str, what: str, node: typing.Any) -> None:
def _parse_yaml_type(
name: str,
- node: typing.Union[str, typing.Mapping[str, typing.Any]],
+ node: Union[str, Mapping[str, Any]],
) -> configtypes.BaseType:
if isinstance(node, str):
# e.g:
# > type: Bool
# -> create the type object without any arguments
type_name = node
- kwargs = {} # type: typing.MutableMapping[str, typing.Any]
+ kwargs: MutableMapping[str, Any] = {}
elif isinstance(node, dict):
# e.g:
# > type:
@@ -136,14 +134,14 @@ def _parse_yaml_type(
def _parse_yaml_backends_dict(
name: str,
node: _BackendDict,
-) -> typing.Sequence[usertypes.Backend]:
+) -> Sequence[usertypes.Backend]:
"""Parse a dict definition for backends.
Example:
backends:
QtWebKit: true
- QtWebEngine: Qt 5.9
+ QtWebEngine: Qt 5.15
"""
str_to_backend = {
'QtWebKit': usertypes.Backend.QtWebKit,
@@ -160,14 +158,9 @@ def _parse_yaml_backends_dict(
conditionals = {
True: True,
False: False,
- 'Qt 5.8': qtutils.version_check('5.8'),
- 'Qt 5.9': qtutils.version_check('5.9'),
- 'Qt 5.9.2': qtutils.version_check('5.9.2'),
- 'Qt 5.10': qtutils.version_check('5.10'),
- 'Qt 5.11': qtutils.version_check('5.11'),
- 'Qt 5.12': qtutils.version_check('5.12'),
'Qt 5.13': qtutils.version_check('5.13'),
'Qt 5.14': qtutils.version_check('5.14'),
+ 'Qt 5.15': qtutils.version_check('5.15'),
}
for key in sorted(node.keys()):
if conditionals[node[key]]:
@@ -178,8 +171,8 @@ def _parse_yaml_backends_dict(
def _parse_yaml_backends(
name: str,
- node: typing.Union[None, str, _BackendDict],
-) -> typing.Sequence[usertypes.Backend]:
+ node: Union[None, str, _BackendDict],
+) -> Sequence[usertypes.Backend]:
"""Parse a backend node in the yaml.
It can have one of those four forms:
@@ -188,7 +181,7 @@ def _parse_yaml_backends(
- backend: QtWebEngine -> setting only available with QtWebEngine
- backend:
QtWebKit: true
- QtWebEngine: Qt 5.9
+ QtWebEngine: Qt 5.15
-> setting available based on the given conditionals.
Return:
@@ -208,7 +201,7 @@ def _parse_yaml_backends(
def _read_yaml(
yaml_data: str,
-) -> typing.Tuple[typing.Mapping[str, Option], Migrations]:
+) -> Tuple[Mapping[str, Option], Migrations]:
"""Read config data from a YAML file.
Args:
diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml
index d055b92ba..7c18c9847 100644
--- a/qutebrowser/config/configdata.yml
+++ b/qutebrowser/config/configdata.yml
@@ -45,18 +45,20 @@ search.ignore_case:
search.incremental:
type: Bool
- default: True
- desc: Find text on a page incrementally, renewing the search for each typed character.
+ default: true
+ desc: >-
+ Find text on a page incrementally, renewing the search for each typed
+ character.
search.wrap:
type: Bool
- default: True
+ 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`.
+ 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:
@@ -70,6 +72,7 @@ new_instance_open_target:
- tab-bg-silent: Open a new background tab in the existing window without
activating the window.
- window: Open in a new window.
+ - private-window: Open in a new private window.
default: tab
desc: >-
How to open links in an existing instance if a new one is launched.
@@ -115,23 +118,30 @@ backend:
type:
name: String
valid_values:
- - webengine: Use QtWebEngine (based on Chromium).
- - webkit: Use QtWebKit (based on WebKit, similar to Safari).
+ - webengine: "Use QtWebEngine (based on Chromium - recommended)."
+ - webkit: "Use QtWebKit (based on WebKit, similar to Safari - many known security
+ issues!)."
default: webengine
restart: true
desc: >-
Backend to use to display websites.
qutebrowser supports two different web rendering engines / backends,
- QtWebKit and QtWebEngine.
+ QtWebEngine and QtWebKit (not recommended).
- QtWebKit was discontinued by the Qt project with Qt 5.6, but picked up as a
- well maintained fork: https://github.com/annulen/webkit/wiki - qutebrowser
- only supports the fork.
+ QtWebEngine is Qt's official successor to QtWebKit, and both the default/recommended
+ backend. It's based on a stripped-down Chromium and regularly updated with security
+ fixes and new features by the Qt project: https://wiki.qt.io/QtWebEngine
- QtWebEngine is Qt's official successor to QtWebKit. It's slightly more
- resource hungry than QtWebKit and has a couple of missing features in
- qutebrowser, but is generally the preferred choice.
+ QtWebKit was qutebrowser's original backend when the project was started. However,
+ support for QtWebKit was discontinued by the Qt project with Qt 5.6 in 2016. The
+ development of QtWebKit was picked up in an official fork:
+ https://github.com/qtwebkit/qtwebkit - however, the project seems to have stalled
+ again. The latest release (5.212.0 Alpha 4) from March 2020 is based on a WebKit
+ version from 2016, with many known security vulnerabilities. Additionally, there is
+ no process isolation and sandboxing. Due to all those issues, while support for
+ QtWebKit is still available in qutebrowser for now, using it is strongly
+ discouraged.
## qt
@@ -153,6 +163,21 @@ qt.args:
https://peter.sh/experiments/chromium-command-line-switches/ for a list)
will work.
+qt.environ:
+ type:
+ name: Dict
+ keytype: String
+ valtype:
+ name: String
+ none_ok: true
+ none_ok: true
+ default: {}
+ restart: true
+ desc: >-
+ Additional environment variables to set.
+
+ Setting an environment variable to null/None will unset it.
+
force_software_rendering:
renamed: qt.force_software_rendering
@@ -275,23 +300,26 @@ auto_save.interval:
auto_save.session:
type: Bool
default: false
- desc: Always restore open sites when qutebrowser is reopened.
+ desc: >-
+ Always restore open sites when qutebrowser is reopened.
+
+ Without this option set, `:wq` (`:quit --save`) needs to be used to save
+ open tabs (and restore them), while quitting qutebrowser in any other way
+ will not save/restore the session.
+
+ By default, this will save to the session which was last loaded. This
+ behavior can be customized via the `session.default_name` setting.
## content
content.autoplay:
default: true
type: Bool
- backend:
- QtWebEngine: Qt 5.10
- QtWebKit: false
+ backend: QtWebEngine
supports_pattern: true
desc: >-
Automatically start playing `<video>` elements.
- Note: On Qt < 5.11, this option needs a restart and does not support URL
- patterns.
-
content.cache.size:
default: null
type:
@@ -348,9 +376,7 @@ content.cache.appcache:
content.cookies.accept:
default: all
- backend:
- QtWebKit: true
- QtWebEngine: Qt 5.11
+ supports_pattern: true
type:
name: String
valid_values:
@@ -361,15 +387,24 @@ 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`.
+
+ If this setting is used with URL patterns, the pattern gets applied to the
+ origin/first party URL of the page making the request, not the request URL.
content.cookies.store:
default: true
type: Bool
- desc: >-
- Store cookies.
-
- Note this option needs a restart with QtWebEngine on Qt < 5.9.
+ desc: Store cookies.
content.default_encoding:
type: String
@@ -393,9 +428,7 @@ content.unknown_url_scheme_policy:
- allow-all: "Allows all navigation requests to URLs with unknown
schemes."
default: allow-from-user-interaction
- backend:
- QtWebEngine: Qt 5.11
- QtWebKit: false
+ backend: QtWebEngine
supports_pattern: true
desc: >-
How navigation requests to URLs with unknown schemes are handled.
@@ -424,21 +457,15 @@ content.desktop_capture:
type: BoolAsk
default: ask
supports_pattern: true
- desc: >-
- Allow websites to share screen content.
-
- On Qt < 5.10, a dialog box is always displayed, even if this is set to
- "true".
+ desc: Allow websites to share screen content.
content.developer_extras:
deleted: true
content.dns_prefetch:
- default: false
+ default: true
type: Bool
- backend:
- QtWebKit: true
- QtWebEngine: Qt 5.12
+ backend: QtWebEngine
supports_pattern: true
desc: Try to pre-fetch DNS entries to speed up browsing.
@@ -471,9 +498,7 @@ content.mouse_lock:
default: ask
type: BoolAsk
supports_pattern: true
- backend:
- QtWebKit: false
- QtWebEngine: Qt 5.8
+ backend: QtWebEngine
desc: Allow websites to lock your mouse pointer.
content.headers.accept_language:
@@ -551,12 +576,20 @@ content.headers.user_agent:
- qutebrowser_version
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
+ #
+ # To update the following list of user agents, run the script
+ # 'ua_fetch.py'
+ # Vim-protip: Place your cursor below this comment and run
+ # :r!python scripts/dev/ua_fetch.py
- - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like
- Gecko) Chrome/79.0.3945.117 Safari/537.36"
- - Chrome 79 Linux
+ Gecko) Chrome/86.0.4240.75 Safari/537.36"
+ - Chrome 86 Linux
+ - - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
+ like Gecko) Chrome/86.0.4240.75 Safari/537.36"
+ - Chrome 86 Win10
+ - - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36
+ (KHTML, like Gecko) Chrome/86.0.4240.80 Safari/537.36"
+ - Chrome 86 macOS
supports_pattern: true
desc: |
User agent to send.
@@ -568,22 +601,34 @@ content.headers.user_agent:
with QtWebEngine).
* `{qt_key}`: "Qt" for QtWebKit, "QtWebEngine" for QtWebEngine.
* `{qt_version}`: The underlying Qt version.
- * `{upstream_browser_key}`: "Version" for QtWebKit, "Chrome" for QtWebEngine.
+ * `{upstream_browser_key}`: "Version" for QtWebKit, "Chrome" for
+ QtWebEngine.
* `{upstream_browser_version}`: The corresponding Safari/Chrome version.
* `{qutebrowser_version}`: The currently running qutebrowser version.
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.enabled
+
+content.host_blocking.lists:
+ renamed: content.blocking.hosts.lists
+
+content.host_blocking.whitelist:
+ renamed: content.blocking.whitelist
+
+content.blocking.enabled:
default: true
supports_pattern: true
type: Bool
- desc: Enable host blocking.
+ desc: Enable the ad/host blocker
-content.host_blocking.lists:
+content.blocking.hosts.lists:
default:
- "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
type:
@@ -591,7 +636,9 @@ content.host_blocking.lists:
valtype: Url
none_ok: true
desc: |
- List of URLs of lists which contain hosts to block.
+ List of URLs to host blocklists for the host blocker.
+
+ Only used when the simple host-blocker is used (see `content.blocking.method`).
The file can be in one of the following formats:
@@ -605,22 +652,59 @@ content.host_blocking.lists:
The file `~/.config/qutebrowser/blocked-hosts` is always read if it exists.
-content.host_blocking.whitelist:
+content.blocking.method:
+ default: auto
+ type:
+ name: String
+ valid_values:
+ - auto: "Use Brave's ABP-style adblocker if available, host blocking
+ otherwise"
+ - adblock: Use Brave's ABP-style adblocker
+ - hosts: Use hosts blocking
+ - both: Use both hosts blocking and Brave's ABP-style adblocker
+ desc: |
+ Which method of blocking ads should be used.
+
+ Support for Adblock Plus (ABP) syntax blocklists using Brave's Rust library requires
+ the `adblock` Python package to be installed, which is an optional dependency of
+ qutebrowser. It is required when either `adblock` or `both` are selected.
+
+content.blocking.adblock.lists:
+ default:
+ - "https://easylist.to/easylist/easylist.txt"
+ - "https://easylist.to/easylist/easyprivacy.txt"
+ type:
+ name: List
+ valtype: Url
+ none_ok: true
+ desc: |
+ List of URLs to ABP-style adblocking rulesets.
+
+ Only used when Brave's ABP-style adblocker is used (see `content.blocking.method`).
+
+ You can find an overview of available lists here:
+ https://adblockplus.org/en/subscriptions - note that the special
+ `subscribe.adblockplus.org` links aren't handled by qutebrowser, you will instead
+ need to find the link to the raw `.txt` file (e.g. by extracting it from the
+ `location` parameter of the subscribe URL and URL-decoding it).
+
+content.blocking.whitelist:
default: []
type:
name: List
valtype: UrlPattern
none_ok: true
desc: >-
- A list of patterns that should always be loaded, despite being ad-blocked.
+ A list of patterns that should always be loaded, despite being blocked by the
+ ad-/host-blocker.
- Note this whitelists blocked hosts, not first-party URLs. As an example, if
- `example.org` loads an ad from `ads.example.org`, the whitelisted host
- should be `ads.example.org`. If you want to disable the adblocker on a
- given page, use the `content.host_blocking.enabled` setting with a URL
- pattern instead.
+ Local domains are always exempt from adblocking.
- Local domains are always exempt from hostblocking.
+ Note this whitelists otherwise blocked requests, not first-party URLs. As
+ an example, if `example.org` loads an ad from `ads.example.org`, the
+ whitelist entry could be `https://ads.example.org/*`. If you want to
+ disable the adblocker on a given page, use the
+ `content.blocking.enabled` setting with a URL pattern instead.
content.hyperlink_auditing:
default: false
@@ -726,12 +810,26 @@ content.local_storage:
supports_pattern: true
desc: Enable support for HTML 5 local storage and Web SQL.
-content.media_capture:
+content.media.audio_capture:
+ default: ask
+ type: BoolAsk
+ supports_pattern: true
+ backend: QtWebEngine
+ desc: Allow websites to record audio.
+
+content.media.audio_video_capture:
default: ask
type: BoolAsk
supports_pattern: true
backend: QtWebEngine
- desc: Allow websites to record audio/video.
+ desc: Allow websites to record audio and video.
+
+content.media.video_capture:
+ default: ask
+ type: BoolAsk
+ supports_pattern: true
+ backend: QtWebEngine
+ desc: Allow websites to record video.
content.netrc_file:
default: null
@@ -766,9 +864,7 @@ content.persistent_storage:
default: ask
type: BoolAsk
supports_pattern: true
- backend:
- QtWebKit: false
- QtWebEngine: Qt 5.11
+ backend: QtWebEngine
desc: Allow websites to request persistent storage quota via
`navigator.webkitPersistentStorage.requestQuota`.
@@ -781,9 +877,7 @@ content.plugins:
content.print_element_backgrounds:
type: Bool
default: true
- backend:
- QtWebKit: true
- QtWebEngine: Qt 5.8
+ backend: QtWebEngine
supports_pattern: true
desc: >-
Draw the background color and images also when the page is printed.
@@ -803,6 +897,9 @@ content.proxy:
In addition to the listed values, you can use a `socks://...` or
`http://...` URL.
+ Note that with QtWebEngine, it will take a couple of seconds until the
+ change is applied, if this value is changed at runtime.
+
content.proxy_dns_requests:
default: true
type: Bool
@@ -813,9 +910,7 @@ content.register_protocol_handler:
default: ask
type: BoolAsk
supports_pattern: true
- backend:
- QtWebKit: false
- QtWebEngine: Qt 5.11
+ backend: QtWebEngine
desc: Allow websites to register protocol handlers via
`navigator.registerProtocolHandler`.
@@ -829,7 +924,7 @@ content.user_stylesheets:
type:
name: ListOrValue
valtype: File
- none_ok: True
+ none_ok: true
default: []
desc: List of user stylesheet filenames to use.
@@ -840,7 +935,6 @@ content.webgl:
desc: Enable WebGL.
content.webrtc_ip_handling_policy:
- default: all-interfaces
type:
name: String
valid_values:
@@ -856,15 +950,11 @@ content.webrtc_ip_handling_policy:
servers unless the proxy server supports UDP. This doesn't expose
any local addresses either.
default: all-interfaces
- backend:
- QtWebKit: false
- QtWebEngine: Qt 5.9.2
+ backend: QtWebEngine
restart: true
desc: >-
Which interfaces to expose via WebRTC.
- On Qt 5.10, this option doesn't work because of a Qt bug.
-
content.xss_auditing:
type: Bool
default: false
@@ -872,7 +962,7 @@ content.xss_auditing:
desc: >-
Monitor load requests for cross-site scripting attempts.
- Suspicious scripts will be blocked and reported in the inspector's
+ Suspicious scripts will be blocked and reported in the devtools
JavaScript console.
Note that bypasses for the XSS auditor are widely known and it can be
@@ -953,11 +1043,14 @@ completion.timestamp_format:
type:
name: String
none_ok: true
- default: '%Y-%m-%d'
+ default: '%Y-%m-%d %H:%M'
desc: >-
Format of timestamps (e.g. for the history completion).
- See https://sqlite.org/lang_datefunc.html for allowed substitutions.
+ See https://sqlite.org/lang_datefunc.html and
+ https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior
+ for allowed substitutions, qutebrowser uses both sqlite and Python to
+ format its timestamps.
completion.web_history.exclude:
type:
@@ -1175,8 +1268,8 @@ hints.find_implementation:
type:
name: String
valid_values:
- - javascript: Better but slower
- - python: Slightly worse but faster
+ - javascript: Better but slower
+ - python: Slightly worse but faster
desc: Which implementation to use to find elements to hint.
hints.hide_unmatched_rapid_hints:
@@ -1255,6 +1348,7 @@ hints.selectors:
- 'img'
- 'link'
- 'summary'
+ - '[contenteditable]:not([contenteditable="false"])'
- '[onclick]'
- '[onmousedown]'
- '[role="link"]'
@@ -1293,6 +1387,7 @@ hints.selectors:
- 'input[type="url"]'
- 'input[type="week"]'
- 'input:not([type])'
+ - '[contenteditable]:not([contenteditable="false"])'
- 'textarea'
type:
name: Dict
@@ -1319,7 +1414,7 @@ hints.leave_on_load:
input.escape_quits_reporter:
type: Bool
- default: True
+ default: true
desc: Allow Escape to quit the crash reporter.
input.forward_unbound_keys:
@@ -1327,9 +1422,9 @@ input.forward_unbound_keys:
type:
name: String
valid_values:
- - all: "Forward all unbound keys."
- - auto: "Forward unbound non-alphanumeric keys."
- - none: "Don't forward any keys."
+ - all: "Forward all unbound keys."
+ - auto: "Forward unbound non-alphanumeric keys."
+ - none: "Don't forward any keys."
desc: Which unbound keys to forward to the webview in normal mode.
input.insert_mode.auto_load:
@@ -1369,6 +1464,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:
@@ -1382,12 +1490,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
@@ -1473,8 +1576,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. 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
@@ -1543,16 +1650,19 @@ spellcheck.languages:
You can check for available languages and install dictionaries using
scripts/dictcli.py. Run the script with -h/--help for instructions.
- backend:
- QtWebKit: false
- QtWebEngine: Qt 5.8
+ backend: QtWebEngine
## 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
@@ -1577,7 +1687,8 @@ statusbar.widgets:
- url: "Current page URL."
- scroll: "Percentage of the current page position like `10%`."
- scroll_raw: "Raw percentage of the current page position like `10`."
- - history: "Display an arrow when possible to go back/forward in history."
+ - history: "Display an arrow when possible to go back/forward in
+ history."
- tabs: "Current active tab, e.g. `2`."
- keypress: "Display pressed keys when composing a vi command."
- progress: "Progress bar for the current page loading."
@@ -1644,7 +1755,11 @@ tabs.last_close:
- startpage: "Load the start page."
- default-page: "Load the default page."
- close: "Close the window."
- desc: How to behave when the last tab is closed.
+ desc: >-
+ How to behave when the last tab is closed.
+
+ If the `tabs.tabs_are_windows` setting is set, this is ignored and the behavior is
+ always identical to the `close` value.
tabs.mousewheel_switching:
default: true
@@ -1692,7 +1807,7 @@ tabs.mode_on_change:
valid_values:
- persist: "Retain the current mode."
- restore: "Restore previously saved mode."
- - normal: "Always revert to normal mode."
+ - normal: "Always revert to normal mode."
desc: When switching tabs, what input mode is applied.
tabs.position:
@@ -1710,10 +1825,10 @@ tabs.show:
type:
name: String
valid_values:
- - always: Always show the tab bar.
- - never: Always hide the tab bar.
- - multiple: Hide the tab bar if only one tab is open.
- - switching: Show the tab bar when switching tabs.
+ - always: Always show the tab bar.
+ - never: Always hide the tab bar.
+ - multiple: Hide the tab bar if only one tab is open.
+ - switching: Show the tab bar when switching tabs.
desc: When to show the tab bar.
tabs.show_switching_delay:
@@ -1745,6 +1860,7 @@ tabs.title.format:
- current_title
- title_sep
- index
+ - aligned_index
- id
- scroll_pos
- host
@@ -1760,12 +1876,14 @@ tabs.title.format:
* `{perc}`: Percentage as a string like `[10%]`.
* `{perc_raw}`: Raw percentage, e.g. `10`.
* `{current_title}`: Title of the current web page.
- * `{title_sep}`: The string ` - ` if a title is set, empty otherwise.
+ * `{title_sep}`: The string `" - "` if a title is set, empty otherwise.
* `{index}`: Index of this tab.
+ * `{aligned_index}`: Index of this tab padded with spaces to have the same
+ width.
* `{id}`: Internal tab ID of this tab.
* `{scroll_pos}`: Page scroll position.
* `{host}`: Host of the current web page.
- * `{backend}`: Either ''webkit'' or ''webengine''
+ * `{backend}`: Either `webkit` or `webengine`
* `{private}`: Indicates when private mode is enabled.
* `{current_url}`: URL of the current web page.
* `{protocol}`: Protocol (http/https/...) of the current web page.
@@ -1781,6 +1899,7 @@ tabs.title.format_pinned:
- current_title
- title_sep
- index
+ - aligned_index
- id
- scroll_pos
- host
@@ -1812,11 +1931,13 @@ tabs.min_width:
minval: -1
maxval: maxint
desc: >-
- Minimum width (in pixels) of tabs (-1 for the default minimum size behavior).
+ Minimum width (in pixels) of tabs (-1 for the default minimum size
+ behavior).
This setting only applies when tabs are horizontal.
- This setting does not apply to pinned tabs, unless `tabs.pinned.shrink` is False.
+ This setting does not apply to pinned tabs, unless `tabs.pinned.shrink` is
+ False.
tabs.max_width:
default: -1
@@ -1866,9 +1987,9 @@ tabs.pinned.shrink:
desc: Shrink pinned tabs down to their contents.
tabs.pinned.frozen:
- type: Bool
- default: True
- desc: Force pinned tabs to stay at fixed URL.
+ type: Bool
+ default: true
+ desc: Force pinned tabs to stay at fixed URL.
tabs.undo_stack_size:
default: 100
@@ -1876,7 +1997,8 @@ tabs.undo_stack_size:
name: Int
minval: -1
maxval: maxint
- desc: Number of close tab actions to remember, per window (-1 for no maximum).
+ desc: Number of closed tabs (per window) and closed windows to remember for
+ :undo (-1 for no maximum).
tabs.wrap:
default: true
@@ -1909,6 +2031,8 @@ url.auto_search:
- naive: Use simple/naive check.
- dns: Use DNS requests (might be slow!).
- never: Never search automatically.
+ - schemeless: Always search automatically unless URL explicitly contains
+ a scheme.
default: naive
desc: What search to start when something else than a URL is entered.
@@ -1931,7 +2055,8 @@ url.incdec_segments:
url.open_base_url:
type: Bool
default: false
- desc: Open base URL of the searchengine if a searchengine shortcut is invoked without parameters.
+ desc: Open base URL of the searchengine if a searchengine shortcut is invoked
+ without parameters.
url.searchengines:
default:
@@ -1961,11 +2086,12 @@ url.searchengines:
expands to `slash%2Fand%26amp`).
* `{unquoted}` quotes nothing (for `slash/and&amp` this placeholder
expands to `slash/and&amp`).
+ * `{0}` means the same as `{}`, but can be used multiple times.
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:
@@ -2022,6 +2148,19 @@ window.title_format:
Format to use for the window title. The same placeholders like for
`tabs.title.format` are defined.
+window.transparent:
+ type: Bool
+ default: false
+ desc: |
+ Set the main window background to transparent.
+
+ This allows having a transparent tab- or statusbar (might require a compositor such
+ as picom). However, it breaks some functionality such as dmenu embedding via its
+ `-w` option. On some systems, it was additionally reported that main window
+ transparency negatively affects performance.
+
+ Note this setting only affects windows opened after setting it.
+
## zoom
zoom.default:
@@ -2198,6 +2337,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
@@ -2581,6 +2740,165 @@ colors.webpage.prefers_color_scheme_dark:
backend:
QtWebEngine: Qt 5.14
QtWebKit: false
+ restart: true
+
+## 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
+
+colors.webpage.darkmode.algorithm:
+ default: lightness-cielab
+ desc: >-
+ Which algorithm to use for modifying how colors are rendered with darkmode.
+
+ The `lightness-cielab` value was added with QtWebEngine 5.14 and is treated
+ like `lightness-hsl` with older QtWebEngine versions.
+ type:
+ name: String
+ valid_values:
+ - lightness-cielab: Modify colors by converting them to CIELAB color
+ space and inverting the L value. Not available with Qt < 5.14.
+ - 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
+
+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
+
+colors.webpage.darkmode.policy.images:
+ default: smart
+ 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. Not available with Qt
+ 5.15.0."
+ desc: >-
+ Which images to apply dark mode to.
+
+ With QtWebEngine 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
+
+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
+
+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: '
@@ -2591,7 +2909,7 @@ fonts.default_family:
type:
name: ListOrValue
valtype: Font
- none_ok: True
+ none_ok: true
desc: >-
Default font families to use.
@@ -2636,7 +2954,7 @@ fonts.contextmenu:
fonts.debug_console:
default: default_size default_family
- type: QtFont
+ type: Font
desc: Font used for the debugging console.
fonts.downloads:
@@ -2679,10 +2997,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: ''
@@ -2773,6 +3096,7 @@ bindings.key_mappings:
<Ctrl-6>: <Ctrl-^>
<Ctrl-M>: <Return>
<Ctrl-J>: <Return>
+ <Ctrl-I>: <Tab>
<Shift-Return>: <Return>
<Enter>: <Return>
<Shift-Enter>: <Return>
@@ -2863,6 +3187,7 @@ bindings.default:
k: scroll up
l: scroll right
u: undo
+ U: undo -w
<Ctrl-Shift-T>: undo
gg: scroll-to-perc 0
G: scroll-to-perc
@@ -2870,6 +3195,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
@@ -2911,7 +3237,13 @@ bindings.default:
gU: navigate up -t
<Ctrl-A>: navigate increment
<Ctrl-X>: navigate decrement
- wi: inspector
+ wi: devtools
+ wIh: devtools left
+ wIj: devtools bottom
+ wIk: devtools top
+ wIl: devtools right
+ wIw: devtools window
+ wIf: devtools-focus
gd: download
ad: download-cancel
cd: download-clear
@@ -2955,10 +3287,14 @@ bindings.default:
gD: tab-give
q: record-macro
"@": run-macro
- tsh: config-cycle -p -t -u *://{url:host}/* content.javascript.enabled ;; reload
- tSh: config-cycle -p -u *://{url:host}/* content.javascript.enabled ;; reload
- tsH: config-cycle -p -t -u *://*.{url:host}/* content.javascript.enabled ;; reload
- tSH: config-cycle -p -u *://*.{url:host}/* content.javascript.enabled ;; reload
+ tsh: config-cycle -p -t -u *://{url:host}/* content.javascript.enabled
+ ;; reload
+ tSh: config-cycle -p -u *://{url:host}/* content.javascript.enabled
+ ;; reload
+ tsH: config-cycle -p -t -u *://*.{url:host}/* content.javascript.enabled
+ ;; reload
+ tSH: config-cycle -p -u *://*.{url:host}/* content.javascript.enabled
+ ;; reload
tsu: config-cycle -p -t -u {url} content.javascript.enabled ;; reload
tSu: config-cycle -p -u {url} content.javascript.enabled ;; reload
tph: config-cycle -p -t -u *://{url:host}/* content.plugins ;; reload
@@ -2973,6 +3309,18 @@ 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}
@@ -2994,6 +3342,8 @@ bindings.default:
<Tab>: completion-item-focus next
<Ctrl-Tab>: completion-item-focus next-category
<Ctrl-Shift-Tab>: completion-item-focus prev-category
+ <PgDown>: completion-item-focus next-page
+ <PgUp>: completion-item-focus prev-page
<Ctrl-D>: completion-item-del
<Shift-Delete>: completion-item-del
<Ctrl-C>: completion-item-yank
@@ -3051,6 +3401,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
@@ -3188,3 +3539,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/configdiff.py b/qutebrowser/config/configdiff.py
deleted file mode 100644
index 53177134b..000000000
--- a/qutebrowser/config/configdiff.py
+++ /dev/null
@@ -1,761 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2017-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/>.
-
-"""Code to show a diff of the legacy config format."""
-
-import typing
-import difflib
-import os.path
-
-import pygments
-import pygments.lexers
-import pygments.formatters
-
-from qutebrowser.utils import standarddir
-
-
-OLD_CONF = """
-[general]
-ignore-case = smart
-startpage = https://start.duckduckgo.com
-yank-ignored-url-parameters = ref,utm_source,utm_medium,utm_campaign,utm_term,utm_content
-default-open-dispatcher =
-default-page = ${startpage}
-auto-search = naive
-auto-save-config = true
-auto-save-interval = 15000
-editor = gvim -f "{}"
-editor-encoding = utf-8
-private-browsing = false
-developer-extras = false
-print-element-backgrounds = true
-xss-auditing = false
-default-encoding = iso-8859-1
-new-instance-open-target = tab
-new-instance-open-target.window = last-focused
-log-javascript-console = debug
-save-session = false
-session-default-name =
-url-incdec-segments = path,query
-[ui]
-history-session-interval = 30
-zoom-levels = 25%,33%,50%,67%,75%,90%,100%,110%,125%,150%,175%,200%,250%,300%,400%,500%
-default-zoom = 100%
-downloads-position = top
-status-position = bottom
-message-timeout = 2000
-message-unfocused = false
-confirm-quit = never
-zoom-text-only = false
-frame-flattening = false
-user-stylesheet =
-hide-scrollbar = true
-smooth-scrolling = false
-remove-finished-downloads = -1
-hide-statusbar = false
-statusbar-padding = 1,1,0,0
-window-title-format = {perc}{title}{title_sep}qutebrowser
-modal-js-dialog = false
-hide-wayland-decoration = false
-keyhint-blacklist =
-keyhint-delay = 500
-prompt-radius = 8
-prompt-filebrowser = true
-[network]
-do-not-track = true
-accept-language = en-US,en
-referer-header = same-domain
-user-agent =
-proxy = system
-proxy-dns-requests = true
-ssl-strict = ask
-dns-prefetch = true
-custom-headers =
-netrc-file =
-[completion]
-show = always
-download-path-suggestion = path
-timestamp-format = %Y-%m-%d
-height = 50%
-cmd-history-max-items = 100
-web-history-max-items = -1
-quick-complete = true
-shrink = false
-scrollbar-width = 12
-scrollbar-padding = 2
-[input]
-timeout = 500
-partial-timeout = 5000
-insert-mode-on-plugins = false
-auto-leave-insert-mode = true
-auto-insert-mode = false
-forward-unbound-keys = auto
-spatial-navigation = false
-links-included-in-focus-chain = true
-rocker-gestures = false
-mouse-zoom-divider = 512
-[tabs]
-background-tabs = false
-select-on-remove = next
-new-tab-position = next
-new-tab-position-explicit = last
-last-close = ignore
-show = always
-show-switching-delay = 800
-wrap = true
-movable = true
-close-mouse-button = middle
-position = top
-show-favicons = true
-favicon-scale = 1.0
-width = 20%
-pinned-width = 43
-indicator-width = 3
-tabs-are-windows = false
-title-format = {index}: {title}
-title-format-pinned = {index}
-title-alignment = left
-mousewheel-tab-switching = true
-padding = 0,0,5,5
-indicator-padding = 2,2,0,4
-[storage]
-download-directory =
-prompt-download-directory = true
-remember-download-directory = true
-maximum-pages-in-cache = 0
-offline-web-application-cache = true
-local-storage = true
-cache-size =
-[content]
-allow-images = true
-allow-javascript = true
-allow-plugins = false
-webgl = true
-hyperlink-auditing = false
-geolocation = ask
-notifications = ask
-media-capture = ask
-javascript-can-open-windows-automatically = false
-javascript-can-close-windows = false
-javascript-can-access-clipboard = false
-ignore-javascript-prompt = false
-ignore-javascript-alert = false
-local-content-can-access-remote-urls = false
-local-content-can-access-file-urls = true
-cookies-accept = no-3rdparty
-cookies-store = true
-host-block-lists = https://www.malwaredomainlist.com/hostslist/hosts.txt,http://someonewhocares.org/hosts/hosts,http://winhelp2002.mvps.org/hosts.zip,http://malwaredomains.lehigh.edu/files/justdomains.zip,https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&mimetype=plaintext
-host-blocking-enabled = true
-host-blocking-whitelist = piwik.org
-enable-pdfjs = false
-[hints]
-border = 1px solid #E3BE23
-mode = letter
-chars = asdfghjkl
-min-chars = 1
-scatter = true
-uppercase = false
-dictionary = /usr/share/dict/words
-auto-follow = unique-match
-auto-follow-timeout = 0
-next-regexes = \\bnext\\b,\\bmore\\b,\\bnewer\\b,\\b[>\u2192\u226b]\\b,\\b(>>|\xbb)\\b,\\bcontinue\\b
-prev-regexes = \\bprev(ious)?\\b,\\bback\\b,\\bolder\\b,\\b[<\u2190\u226a]\\b,\\b(<<|\xab)\\b
-find-implementation = python
-hide-unmatched-rapid-hints = true
-[searchengines]
-DEFAULT = https://duckduckgo.com/?q={}
-[aliases]
-[colors]
-completion.fg = white
-completion.bg = #333333
-completion.alternate-bg = #444444
-completion.category.fg = white
-completion.category.bg = qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #888888, stop:1 #505050)
-completion.category.border.top = black
-completion.category.border.bottom = ${completion.category.border.top}
-completion.item.selected.fg = black
-completion.item.selected.bg = #e8c000
-completion.item.selected.border.top = #bbbb00
-completion.item.selected.border.bottom = ${completion.item.selected.border.top}
-completion.match.fg = #ff4444
-completion.scrollbar.fg = ${completion.fg}
-completion.scrollbar.bg = ${completion.bg}
-statusbar.fg = white
-statusbar.bg = black
-statusbar.fg.private = ${statusbar.fg}
-statusbar.bg.private = #666666
-statusbar.fg.insert = ${statusbar.fg}
-statusbar.bg.insert = darkgreen
-statusbar.fg.command = ${statusbar.fg}
-statusbar.bg.command = ${statusbar.bg}
-statusbar.fg.command.private = ${statusbar.fg.private}
-statusbar.bg.command.private = ${statusbar.bg.private}
-statusbar.fg.caret = ${statusbar.fg}
-statusbar.bg.caret = purple
-statusbar.fg.caret-selection = ${statusbar.fg}
-statusbar.bg.caret-selection = #a12dff
-statusbar.progress.bg = white
-statusbar.url.fg = ${statusbar.fg}
-statusbar.url.fg.success = white
-statusbar.url.fg.success.https = lime
-statusbar.url.fg.error = orange
-statusbar.url.fg.warn = yellow
-statusbar.url.fg.hover = aqua
-tabs.fg.odd = white
-tabs.bg.odd = grey
-tabs.fg.even = white
-tabs.bg.even = darkgrey
-tabs.fg.selected.odd = white
-tabs.bg.selected.odd = black
-tabs.fg.selected.even = ${tabs.fg.selected.odd}
-tabs.bg.selected.even = ${tabs.bg.selected.odd}
-tabs.bg.bar = #555555
-tabs.indicator.start = #0000aa
-tabs.indicator.stop = #00aa00
-tabs.indicator.error = #ff0000
-tabs.indicator.system = rgb
-hints.fg = black
-hints.bg = qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 rgba(255, 247, 133, 0.8), stop:1 rgba(255, 197, 66, 0.8))
-hints.fg.match = green
-downloads.bg.bar = black
-downloads.fg.start = white
-downloads.bg.start = #0000aa
-downloads.fg.stop = ${downloads.fg.start}
-downloads.bg.stop = #00aa00
-downloads.fg.system = rgb
-downloads.bg.system = rgb
-downloads.fg.error = white
-downloads.bg.error = red
-webpage.bg = white
-keyhint.fg = #FFFFFF
-keyhint.fg.suffix = #FFFF00
-keyhint.bg = rgba(0, 0, 0, 80%)
-messages.fg.error = white
-messages.bg.error = red
-messages.border.error = #bb0000
-messages.fg.warning = white
-messages.bg.warning = darkorange
-messages.border.warning = #d47300
-messages.fg.info = white
-messages.bg.info = black
-messages.border.info = #333333
-prompts.fg = white
-prompts.bg = darkblue
-prompts.selected.bg = #308cc6
-[fonts]
-_monospace = xos4 Terminus, Terminus, Monospace, "DejaVu Sans Mono", Monaco, "Bitstream Vera Sans Mono", "Andale Mono", "Courier New", Courier, "Liberation Mono", monospace, Fixed, Consolas, Terminal
-completion = 8pt ${_monospace}
-completion.category = bold ${completion}
-tabbar = 8pt ${_monospace}
-statusbar = 8pt ${_monospace}
-downloads = 8pt ${_monospace}
-hints = bold 13px ${_monospace}
-debug-console = 8pt ${_monospace}
-web-family-standard =
-web-family-fixed =
-web-family-serif =
-web-family-sans-serif =
-web-family-cursive =
-web-family-fantasy =
-web-size-minimum = 0
-web-size-minimum-logical = 6
-web-size-default = 16
-web-size-default-fixed = 13
-keyhint = 8pt ${_monospace}
-messages.error = 8pt ${_monospace}
-messages.warning = 8pt ${_monospace}
-messages.info = 8pt ${_monospace}
-prompts = 8pt sans-serif
-"""
-
-OLD_KEYS_CONF = """
-[!normal]
-leave-mode
- <escape>
- <ctrl-[>
-[normal]
-clear-keychain ;; search ;; fullscreen --leave
- <escape>
- <ctrl-[>
-set-cmd-text -s :open
- o
-set-cmd-text :open {url:pretty}
- go
-set-cmd-text -s :open -t
- O
-set-cmd-text :open -t -i {url:pretty}
- gO
-set-cmd-text -s :open -b
- xo
-set-cmd-text :open -b -i {url:pretty}
- xO
-set-cmd-text -s :open -w
- wo
-set-cmd-text :open -w {url:pretty}
- wO
-set-cmd-text /
- /
-set-cmd-text ?
- ?
-set-cmd-text :
- :
-open -t
- ga
- <ctrl-t>
-open -w
- <ctrl-n>
-tab-close
- d
- <ctrl-w>
-tab-close -o
- D
-tab-only
- co
-tab-focus
- T
-tab-move
- gm
-tab-move -
- gl
-tab-move +
- gr
-tab-next
- J
- <ctrl-pgdown>
-tab-prev
- K
- <ctrl-pgup>
-tab-clone
- gC
-reload
- r
- <f5>
-reload -f
- R
- <ctrl-f5>
-back
- H
- <back>
-back -t
- th
-back -w
- wh
-forward
- L
- <forward>
-forward -t
- tl
-forward -w
- wl
-fullscreen
- <f11>
-hint
- f
-hint all tab
- F
-hint all window
- wf
-hint all tab-bg
- ;b
-hint all tab-fg
- ;f
-hint all hover
- ;h
-hint images
- ;i
-hint images tab
- ;I
-hint links fill :open {hint-url}
- ;o
-hint links fill :open -t -i {hint-url}
- ;O
-hint links yank
- ;y
-hint links yank-primary
- ;Y
-hint --rapid links tab-bg
- ;r
-hint --rapid links window
- ;R
-hint links download
- ;d
-hint inputs
- ;t
-scroll left
- h
-scroll down
- j
-scroll up
- k
-scroll right
- l
-undo
- u
- <ctrl-shift-t>
-scroll-perc 0
- gg
-scroll-perc
- G
-search-next
- n
-search-prev
- N
-enter-mode insert
- i
-enter-mode caret
- v
-enter-mode set_mark
- `
-enter-mode jump_mark
- '
-yank
- yy
-yank -s
- yY
-yank title
- yt
-yank title -s
- yT
-yank domain
- yd
-yank domain -s
- yD
-yank pretty-url
- yp
-yank pretty-url -s
- yP
-open -- {clipboard}
- pp
-open -- {primary}
- pP
-open -t -- {clipboard}
- Pp
-open -t -- {primary}
- PP
-open -w -- {clipboard}
- wp
-open -w -- {primary}
- wP
-quickmark-save
- m
-set-cmd-text -s :quickmark-load
- b
-set-cmd-text -s :quickmark-load -t
- B
-set-cmd-text -s :quickmark-load -w
- wb
-bookmark-add
- M
-set-cmd-text -s :bookmark-load
- gb
-set-cmd-text -s :bookmark-load -t
- gB
-set-cmd-text -s :bookmark-load -w
- wB
-save
- sf
-set-cmd-text -s :set
- ss
-set-cmd-text -s :set -t
- sl
-set-cmd-text -s :bind
- sk
-zoom-out
- -
-zoom-in
- +
-zoom
- =
-navigate prev
- [[
-navigate next
- ]]
-navigate prev -t
- {{
-navigate next -t
- }}
-navigate up
- gu
-navigate up -t
- gU
-navigate increment
- <ctrl-a>
-navigate decrement
- <ctrl-x>
-inspector
- wi
-download
- gd
-download-cancel
- ad
-download-clear
- cd
-view-source
- gf
-set-cmd-text -s :buffer
- gt
-tab-focus last
- <ctrl-tab>
- <ctrl-6>
- <ctrl-^>
-enter-mode passthrough
- <ctrl-v>
-quit
- <ctrl-q>
- ZQ
-wq
- ZZ
-scroll-page 0 1
- <ctrl-f>
-scroll-page 0 -1
- <ctrl-b>
-scroll-page 0 0.5
- <ctrl-d>
-scroll-page 0 -0.5
- <ctrl-u>
-tab-focus 1
- <alt-1>
- g0
- g^
-tab-focus 2
- <alt-2>
-tab-focus 3
- <alt-3>
-tab-focus 4
- <alt-4>
-tab-focus 5
- <alt-5>
-tab-focus 6
- <alt-6>
-tab-focus 7
- <alt-7>
-tab-focus 8
- <alt-8>
-tab-focus -1
- <alt-9>
- g$
-home
- <ctrl-h>
-stop
- <ctrl-s>
-print
- <ctrl-alt-p>
-open qute://settings
- Ss
-follow-selected
- <return>
- <ctrl-m>
- <ctrl-j>
- <shift-return>
- <enter>
- <shift-enter>
-follow-selected -t
- <ctrl-return>
- <ctrl-enter>
-repeat-command
- .
-tab-pin
- <ctrl-p>
-record-macro
- q
-run-macro
- @
-[insert]
-open-editor
- <ctrl-e>
-insert-text {primary}
- <shift-ins>
-[hint]
-follow-hint
- <return>
- <ctrl-m>
- <ctrl-j>
- <shift-return>
- <enter>
- <shift-enter>
-hint --rapid links tab-bg
- <ctrl-r>
-hint links
- <ctrl-f>
-hint all tab-bg
- <ctrl-b>
-[passthrough]
-[command]
-command-history-prev
- <ctrl-p>
-command-history-next
- <ctrl-n>
-completion-item-focus prev
- <shift-tab>
- <up>
-completion-item-focus next
- <tab>
- <down>
-completion-item-focus next-category
- <ctrl-tab>
-completion-item-focus prev-category
- <ctrl-shift-tab>
-completion-item-del
- <ctrl-d>
-command-accept
- <return>
- <ctrl-m>
- <ctrl-j>
- <shift-return>
- <enter>
- <shift-enter>
-[prompt]
-prompt-accept
- <return>
- <ctrl-m>
- <ctrl-j>
- <shift-return>
- <enter>
- <shift-enter>
-prompt-accept yes
- y
-prompt-accept no
- n
-prompt-open-download
- <ctrl-x>
-prompt-item-focus prev
- <shift-tab>
- <up>
-prompt-item-focus next
- <tab>
- <down>
-[command,prompt]
-rl-backward-char
- <ctrl-b>
-rl-forward-char
- <ctrl-f>
-rl-backward-word
- <alt-b>
-rl-forward-word
- <alt-f>
-rl-beginning-of-line
- <ctrl-a>
-rl-end-of-line
- <ctrl-e>
-rl-unix-line-discard
- <ctrl-u>
-rl-kill-line
- <ctrl-k>
-rl-kill-word
- <alt-d>
-rl-unix-word-rubout
- <ctrl-w>
-rl-backward-kill-word
- <alt-backspace>
-rl-yank
- <ctrl-y>
-rl-delete-char
- <ctrl-?>
-rl-backward-delete-char
- <ctrl-h>
-[caret]
-toggle-selection
- v
- <space>
-drop-selection
- <ctrl-space>
-enter-mode normal
- c
-move-to-next-line
- j
-move-to-prev-line
- k
-move-to-next-char
- l
-move-to-prev-char
- h
-move-to-end-of-word
- e
-move-to-next-word
- w
-move-to-prev-word
- b
-move-to-start-of-next-block
- ]
-move-to-start-of-prev-block
- [
-move-to-end-of-next-block
- }
-move-to-end-of-prev-block
- {
-move-to-start-of-line
- 0
-move-to-end-of-line
- $
-move-to-start-of-document
- gg
-move-to-end-of-document
- G
-yank selection -s
- Y
-yank selection
- y
- <return>
- <ctrl-m>
- <ctrl-j>
- <shift-return>
- <enter>
- <shift-enter>
-scroll left
- H
-scroll down
- J
-scroll up
- K
-scroll right
- L
-"""
-
-
-def get_diff() -> str:
- """Get a HTML diff for the old config files."""
- old_conf_lines = [] # type: typing.MutableSequence[str]
- old_key_lines = [] # type: typing.MutableSequence[str]
-
- for filename, dest in [('qutebrowser.conf', old_conf_lines),
- ('keys.conf', old_key_lines)]:
- path = os.path.join(standarddir.config(), filename)
-
- with open(path, 'r', encoding='utf-8') as f:
- for line in f:
- if not line.strip() or line.startswith('#'):
- continue
- dest.append(line.rstrip())
-
- conf_delta = difflib.unified_diff(OLD_CONF.lstrip().splitlines(),
- old_conf_lines)
- key_delta = difflib.unified_diff(OLD_KEYS_CONF.lstrip().splitlines(),
- old_key_lines)
-
- conf_diff = '\n'.join(conf_delta)
- key_diff = '\n'.join(key_delta)
-
- # pylint: disable=no-member
- # WORKAROUND for https://bitbucket.org/logilab/pylint/issue/491/
- lexer = pygments.lexers.DiffLexer()
- formatter = pygments.formatters.HtmlFormatter(
- full=True, linenos='table',
- title='Diffing pre-1.0 default config with pre-1.0 modified config')
- # pylint: enable=no-member
- return pygments.highlight(conf_diff + key_diff, lexer, formatter)
diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py
index b409bc883..872f777ff 100644
--- a/qutebrowser/config/configexc.py
+++ b/qutebrowser/config/configexc.py
@@ -19,9 +19,9 @@
"""Exceptions related to config parsing."""
-import typing
-import attr
+from typing import Any, Mapping, Optional, Sequence, Union
+import attr
from qutebrowser.utils import usertypes, log
@@ -46,7 +46,7 @@ class BackendError(Error):
def __init__(
self, name: str,
backend: usertypes.Backend,
- raw_backends: typing.Optional[typing.Mapping[str, bool]]
+ raw_backends: Optional[Mapping[str, bool]]
) -> None:
if raw_backends is None or not raw_backends[backend.name]:
msg = ("The {} setting is not available with the {} backend!"
@@ -76,8 +76,7 @@ class ValidationError(Error):
msg: Additional error message.
"""
- def __init__(self, value: typing.Any,
- msg: typing.Union[str, Exception]) -> None:
+ def __init__(self, value: Any, msg: Union[str, Exception]) -> None:
super().__init__("Invalid value '{}' - {}".format(value, msg))
self.option = None
@@ -117,9 +116,9 @@ class ConfigErrorDesc:
traceback: The formatted traceback of the exception.
"""
- text = attr.ib() # type: str
- exception = attr.ib() # type: typing.Union[str, Exception]
- traceback = attr.ib(None) # type: str
+ text: str = attr.ib()
+ exception: Union[str, Exception] = attr.ib()
+ traceback: str = attr.ib(None)
def __str__(self) -> str:
if self.traceback:
@@ -141,7 +140,7 @@ class ConfigFileErrors(Error):
def __init__(self,
basename: str,
- errors: typing.Sequence[ConfigErrorDesc], *,
+ errors: Sequence[ConfigErrorDesc], *,
fatal: bool = False) -> None:
super().__init__("Errors occurred while reading {}:\n{}".format(
basename, '\n'.join(' {}'.format(e) for e in errors)))
diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py
index 63cab9377..5569174c9 100644
--- a/qutebrowser/config/configfiles.py
+++ b/qutebrowser/config/configfiles.py
@@ -27,8 +27,9 @@ import textwrap
import traceback
import configparser
import contextlib
-import typing
import re
+from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Mapping,
+ MutableMapping, Optional, cast)
import yaml
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QSettings, qVersion
@@ -39,15 +40,15 @@ from qutebrowser.config import (configexc, config, configdata, configutils,
from qutebrowser.keyinput import keyutils
from qutebrowser.utils import standarddir, utils, qtutils, log, urlmatch
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.misc import savemanager
# The StateConfig instance
-state = typing.cast('StateConfig', None)
+state = cast('StateConfig', None)
-_SettingsType = typing.Dict[str, typing.Dict[str, typing.Any]]
+_SettingsType = Dict[str, Dict[str, Any]]
class StateConfig(configparser.ConfigParser):
@@ -68,15 +69,20 @@ class StateConfig(configparser.ConfigParser):
else:
self.qt_version_changed = False
- for sect in ['general', 'geometry']:
+ for sect in ['general', 'geometry', 'inspector']:
try:
self.add_section(sect)
except configparser.DuplicateSectionError:
pass
- deleted_keys = ['fooled', 'backend-warning-shown']
- for key in deleted_keys:
- self['general'].pop(key, None)
+ deleted_keys = [
+ ('general', 'fooled'),
+ ('general', 'backend-warning-shown'),
+ ('general', 'old-qt-warning-shown'),
+ ('geometry', 'inspector'),
+ ]
+ for sect, key in deleted_keys:
+ self[sect].pop(key, None)
self['general']['qt_version'] = qt_version
self['general']['version'] = qutebrowser.__version__
@@ -113,7 +119,7 @@ class YamlConfig(QObject):
'autoconfig.yml')
self._dirty = False
- self._values = {} # type: typing.Dict[str, configutils.Values]
+ self._values: Dict[str, configutils.Values] = {}
for name, opt in configdata.DATA.items():
self._values[name] = configutils.Values(opt)
@@ -126,7 +132,7 @@ class YamlConfig(QObject):
"""
save_manager.add_saveable('yaml-config', self._save, self.changed)
- def __iter__(self) -> typing.Iterator[configutils.Values]:
+ def __iter__(self) -> Iterator[configutils.Values]:
"""Iterate over configutils.Values items."""
yield from self._values.values()
@@ -141,7 +147,7 @@ class YamlConfig(QObject):
if not self._dirty:
return
- settings = {} # type: _SettingsType
+ settings: _SettingsType = {}
for name, values in sorted(self._values.items()):
if not values:
continue
@@ -161,12 +167,9 @@ 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,
- key: str,
- typ: type) -> typing.Any:
+ def _pop_object(self, yaml_data: Any, key: str, typ: type) -> Any:
"""Get a global object from the given data."""
if not isinstance(yaml_data, dict):
desc = configexc.ConfigErrorDesc("While loading data",
@@ -220,22 +223,21 @@ class YamlConfig(QObject):
migrations.changed.connect(self._mark_changed)
migrations.migrate()
- self._validate(settings)
+ self._validate_names(settings)
self._build_values(settings)
- def _load_settings_object(self, yaml_data: typing.Any) -> '_SettingsType':
+ def _load_settings_object(self, yaml_data: Any) -> '_SettingsType':
"""Load the settings from the settings: key."""
return self._pop_object(yaml_data, 'settings', dict)
- def _load_legacy_settings_object(self,
- yaml_data: typing.Any) -> '_SettingsType':
+ def _load_legacy_settings_object(self, yaml_data: Any) -> '_SettingsType':
data = self._pop_object(yaml_data, 'global', dict)
settings = {}
for name, value in data.items():
settings[name] = {'global': value}
return settings
- def _build_values(self, settings: typing.Mapping) -> None:
+ def _build_values(self, settings: Mapping) -> None:
"""Build up self._values from the values in the given dict."""
errors = []
for name, yaml_values in settings.items():
@@ -268,7 +270,7 @@ class YamlConfig(QObject):
if errors:
raise configexc.ConfigFileErrors('autoconfig.yml', errors)
- def _validate(self, settings: _SettingsType) -> None:
+ def _validate_names(self, settings: _SettingsType) -> None:
"""Make sure all settings exist."""
unknown = []
for name in settings:
@@ -281,7 +283,7 @@ class YamlConfig(QObject):
for e in sorted(unknown)]
raise configexc.ConfigFileErrors('autoconfig.yml', errors)
- def set_obj(self, name: str, value: typing.Any, *,
+ def set_obj(self, name: str, value: Any, *,
pattern: urlmatch.UrlPattern = None) -> None:
"""Set the given setting to the given value."""
self._values[name].add(value, pattern)
@@ -319,7 +321,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 +334,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,10 +347,21 @@ class YamlMigrations(QObject):
r'(?<!{)\{title\}(?!})',
r'{current_title}')
+ self._migrate_to_multiple('fonts.tabs',
+ ('fonts.tabs.selected',
+ 'fonts.tabs.unselected'))
+
+ self._migrate_to_multiple('content.media_capture',
+ ('content.media.audio_capture',
+ 'content.media.audio_video_capture',
+ 'content.media.video_capture'))
+
# 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)
+ self._remove_empty_patterns()
+
def _migrate_configdata(self) -> None:
"""Migrate simple renamed/deleted options."""
for name in list(self._settings):
@@ -394,16 +412,19 @@ class YamlMigrations(QObject):
def _migrate_font_replacements(self) -> None:
"""Replace 'monospace' replacements by 'default_family'."""
- for name in self._settings:
+ for name, values in self._settings.items():
+ if not isinstance(values, dict):
+ continue
+
try:
opt = configdata.DATA[name]
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():
+ for scope, val in values.items():
if isinstance(val, str) and val.endswith(' monospace'):
new_val = val.replace('monospace', 'default_family')
self._settings[name][scope] = new_val
@@ -415,7 +436,11 @@ class YamlMigrations(QObject):
if name not in self._settings:
return
- for scope, val in self._settings[name].items():
+ values = self._settings[name]
+ if not isinstance(values, dict):
+ return
+
+ for scope, val in values.items():
if isinstance(val, bool):
new_value = true_value if val else false_value
self._settings[name][scope] = new_value
@@ -441,24 +466,58 @@ class YamlMigrations(QObject):
if name not in self._settings:
return
- for scope, val in self._settings[name].items():
+ values = self._settings[name]
+ if not isinstance(values, dict):
+ return
+
+ for scope, val in values.items():
if val is None:
self._settings[name][scope] = value
self.changed.emit()
+ def _migrate_to_multiple(self, old_name: str, new_names: 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:
if name not in self._settings:
return
- for scope, val in self._settings[name].items():
+ values = self._settings[name]
+ if not isinstance(values, dict):
+ return
+
+ for scope, val in values.items():
if isinstance(val, str):
new_val = re.sub(source, target, val)
if new_val != val:
self._settings[name][scope] = new_val
self.changed.emit()
+ def _remove_empty_patterns(self) -> None:
+ """Remove *. host patterns from the config.
+
+ Those used to be valid (and could be accidentally produced by using tSH
+ on about:blank), but aren't anymore.
+ """
+ scope = '*://*./*'
+ for name, values in self._settings.items():
+ if not isinstance(values, dict):
+ continue
+ if scope in values:
+ del self._settings[name][scope]
+ self.changed.emit()
+
class ConfigAPI:
@@ -479,12 +538,12 @@ class ConfigAPI:
def __init__(self, conf: config.Config, keyconfig: config.KeyConfig):
self._config = conf
self._keyconfig = keyconfig
- self.errors = [] # type: typing.List[configexc.ConfigErrorDesc]
+ self.errors: List[configexc.ConfigErrorDesc] = []
self.configdir = pathlib.Path(standarddir.config())
self.datadir = pathlib.Path(standarddir.data())
@contextlib.contextmanager
- def _handle_error(self, action: str, name: str) -> typing.Iterator[None]:
+ def _handle_error(self, action: str, name: str) -> Iterator[None]:
"""Catch config-related exceptions and save them in self.errors."""
try:
yield
@@ -504,40 +563,43 @@ class ConfigAPI:
def finalize(self) -> None:
"""Do work which needs to be done after reading config.py."""
+ if self._config.warn_autoconfig:
+ desc = configexc.ConfigErrorDesc(
+ "autoconfig loading not specified",
+ ("Your config.py should call either `config.load_autoconfig()`"
+ " (to load settings configured via the GUI) or "
+ "`config.load_autoconfig(False)` (to not do so)"))
+ self.errors.append(desc)
self._config.update_mutables()
- def load_autoconfig(self) -> None:
+ def load_autoconfig(self, load_config: bool = True) -> None:
"""Load the autoconfig.yml file which is used for :set/:bind/etc."""
- with self._handle_error('reading', 'autoconfig.yml'):
- read_autoconfig()
+ self._config.warn_autoconfig = False
+ if load_config:
+ with self._handle_error('reading', 'autoconfig.yml'):
+ read_autoconfig()
- def get(self, name: str, pattern: str = None) -> typing.Any:
+ def get(self, name: str, pattern: str = None) -> Any:
"""Get a setting value from the config, optionally with a pattern."""
with self._handle_error('getting', name):
urlpattern = urlmatch.UrlPattern(pattern) if pattern else None
return self._config.get_mutable_obj(name, pattern=urlpattern)
- def set(self, name: str, value: typing.Any, pattern: str = None) -> None:
+ def set(self, name: str, value: Any, pattern: str = None) -> None:
"""Set a setting value in the config, optionally with a pattern."""
with self._handle_error('setting', name):
urlpattern = urlmatch.UrlPattern(pattern) if pattern else None
self._config.set_obj(name, value, pattern=urlpattern)
- def bind(self, key: str,
- command: typing.Optional[str],
- mode: str = 'normal') -> None:
+ def bind(self, key: str, command: Optional[str], mode: str = 'normal') -> None:
"""Bind a key to a command, with an optional key mode."""
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."""
@@ -548,7 +610,9 @@ class ConfigAPI:
def source(self, filename: str) -> None:
"""Read the given config file from disk."""
if not os.path.isabs(filename):
- filename = str(self.configdir / filename)
+ # We don't use self.configdir here so we get the proper file when starting
+ # with a --config-py argument given.
+ filename = os.path.join(os.path.dirname(standarddir.config_py()), filename)
try:
read_config_py(filename)
@@ -556,7 +620,7 @@ class ConfigAPI:
self.errors += e.errors
@contextlib.contextmanager
- def pattern(self, pattern: str) -> typing.Iterator[config.ConfigContainer]:
+ def pattern(self, pattern: str) -> Iterator[config.ConfigContainer]:
"""Get a ConfigContainer for the given pattern."""
# We need to propagate the exception so we don't need to return
# something.
@@ -572,9 +636,8 @@ class ConfigPyWriter:
def __init__(
self,
- options: typing.List,
- bindings: typing.MutableMapping[
- str, typing.Mapping[str, typing.Optional[str]]],
+ options: List,
+ bindings: MutableMapping[str, Mapping[str, Optional[str]]],
*, commented: bool) -> None:
self._options = options
self._bindings = bindings
@@ -595,7 +658,7 @@ class ConfigPyWriter:
else:
return line
- def _gen_lines(self) -> typing.Iterator[str]:
+ def _gen_lines(self) -> Iterator[str]:
"""Generate a config.py with the given settings/bindings.
Yields individual lines.
@@ -604,9 +667,20 @@ class ConfigPyWriter:
yield from self._gen_options()
yield from self._gen_bindings()
- def _gen_header(self) -> typing.Iterator[str]:
+ def _gen_header(self) -> 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")
@@ -618,15 +692,15 @@ class ConfigPyWriter:
"still loaded.")
yield self._line("# Remove it to not load settings done via the "
"GUI.")
- yield self._line("config.load_autoconfig()")
+ yield self._line("config.load_autoconfig(True)")
yield ''
else:
- yield self._line("# Uncomment this to still load settings "
+ yield self._line("# Change the argument to True to still load settings "
"configured via autoconfig.yml")
- yield self._line("# config.load_autoconfig()")
+ yield self._line("config.load_autoconfig(False)")
yield ''
- def _gen_options(self) -> typing.Iterator[str]:
+ def _gen_options(self) -> Iterator[str]:
"""Generate the options part of the config."""
for pattern, opt, value in self._options:
if opt.name in ['bindings.commands', 'bindings.default']:
@@ -654,7 +728,7 @@ class ConfigPyWriter:
opt.name, value, str(pattern)))
yield ''
- def _gen_bindings(self) -> typing.Iterator[str]:
+ def _gen_bindings(self) -> Iterator[str]:
"""Generate the bindings part of the config."""
normal_bindings = self._bindings.pop('normal', {})
if normal_bindings:
@@ -696,8 +770,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:
@@ -755,7 +829,7 @@ def read_autoconfig() -> None:
@contextlib.contextmanager
-def saved_sys_properties() -> typing.Iterator[None]:
+def saved_sys_properties() -> Iterator[None]:
"""Save various sys properties such as sys.path and sys.modules."""
old_path = sys.path.copy()
old_modules = sys.modules.copy()
diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py
index 351030789..2951b5292 100644
--- a/qutebrowser/config/configinit.py
+++ b/qutebrowser/config/configinit.py
@@ -22,15 +22,13 @@
import argparse
import os.path
import sys
-import typing
from PyQt5.QtWidgets import QMessageBox
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)
+ configexc, configcommands, stylesheet, qtargs)
+from qutebrowser.utils import objreg, usertypes, log, standarddir, message
from qutebrowser.config import configcache
from qutebrowser.misc import msgbox, objects, savemanager
@@ -87,33 +85,7 @@ def early_init(args: argparse.Namespace) -> None:
stylesheet.init()
- _init_envvars()
-
-
-def _init_envvars() -> None:
- """Initialize environment variables which need to be set early."""
- if objects.backend == usertypes.Backend.QtWebEngine:
- software_rendering = config.val.qt.force_software_rendering
- if software_rendering == 'software-opengl':
- os.environ['QT_XCB_FORCE_SOFTWARE_OPENGL'] = '1'
- elif software_rendering == 'qt-quick':
- os.environ['QT_QUICK_BACKEND'] = 'software'
- elif software_rendering == 'chromium':
- os.environ['QT_WEBENGINE_DISABLE_NOUVEAU_WORKAROUND'] = '1'
-
- if config.val.qt.force_platform is not None:
- os.environ['QT_QPA_PLATFORM'] = config.val.qt.force_platform
- if config.val.qt.force_platformtheme is not None:
- os.environ['QT_QPA_PLATFORMTHEME'] = config.val.qt.force_platformtheme
-
- if config.val.window.hide_decoration:
- os.environ['QT_WAYLAND_DISABLE_WINDOWDECORATION'] = '1'
-
- if config.val.qt.highdpi:
- env_var = ('QT_ENABLE_HIGHDPI_SCALING'
- if qtutils.version_check('5.14', compiled=False)
- else 'QT_AUTO_SCREEN_SCALE_FACTOR')
- os.environ[env_var] = '1'
+ qtargs.init_envvars()
def _update_font_defaults(setting: str) -> None:
@@ -121,11 +93,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,119 +137,9 @@ 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)
configfiles.state.init_save_manager(save_manager)
-
-
-def qt_args(namespace: argparse.Namespace) -> typing.List[str]:
- """Get the Qt QApplication arguments based on an argparse namespace.
-
- Args:
- namespace: The argparse namespace.
-
- Return:
- The argv list to be passed to Qt.
- """
- argv = [sys.argv[0]]
-
- if namespace.qt_flag is not None:
- argv += ['--' + flag[0] for flag in namespace.qt_flag]
-
- if namespace.qt_arg is not None:
- for name, value in namespace.qt_arg:
- argv += ['--' + name, value]
-
- argv += ['--' + arg for arg in config.val.qt.args]
-
- if objects.backend == usertypes.Backend.QtWebEngine:
- argv += list(_qtwebengine_args(namespace))
-
- return argv
-
-
-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):
- # WORKAROUND equivalent to
- # https://codereview.qt-project.org/#/c/217932/
- # Needed for Qt < 5.9.5 and < 5.10.1
- yield '--disable-shared-workers'
-
- # WORKAROUND equivalent to
- # https://codereview.qt-project.org/c/qt/qtwebengine/+/256786
- # also see:
- # https://codereview.qt-project.org/c/qt/qtwebengine-chromium/+/265753
- if qtutils.version_check('5.12.3', compiled=False):
- if 'stack' in namespace.debug_flags:
- # Only actually available in Qt 5.12.5, but let's save another
- # check, as passing the option won't hurt.
- yield '--enable-in-process-stack-traces'
- else:
- if 'stack' not in namespace.debug_flags:
- yield '--disable-in-process-stack-traces'
-
- if 'chromium' in namespace.debug_flags:
- yield '--enable-logging'
- yield '--v=1'
-
- settings = {
- 'qt.force_software_rendering': {
- 'software-opengl': None,
- 'qt-quick': None,
- 'chromium': '--disable-gpu',
- 'none': None,
- },
- 'content.canvas_reading': {
- True: None,
- False: '--disable-reading-from-canvas',
- },
- 'content.webrtc_ip_handling_policy': {
- 'all-interfaces': None,
- 'default-public-and-private-interfaces':
- '--force-webrtc-ip-handling-policy='
- 'default_public_and_private_interfaces',
- 'default-public-interface-only':
- '--force-webrtc-ip-handling-policy='
- 'default_public_interface_only',
- 'disable-non-proxied-udp':
- '--force-webrtc-ip-handling-policy='
- 'disable_non_proxied_udp',
- },
- 'qt.process_model': {
- 'process-per-site-instance': None,
- 'process-per-site': '--process-per-site',
- 'single-process': '--single-process',
- },
- 'qt.low_end_device_mode': {
- 'auto': None,
- 'always': '--enable-low-end-device-mode',
- 'never': '--disable-low-end-device-mode',
- },
- 'content.headers.referer': {
- 'always': None,
- 'never': '--no-referrers',
- 'same-domain': '--reduced-referrer-granularity',
- }
- } # type: typing.Dict[str, typing.Dict[typing.Any, typing.Optional[str]]]
-
- if not qtutils.version_check('5.11'):
- # On Qt 5.11, we can control this via QWebEngineSettings
- settings['content.autoplay'] = {
- True: None,
- False: '--autoplay-policy=user-gesture-required',
- }
-
- if qtutils.version_check('5.14'):
- settings['colors.webpage.prefers_color_scheme_dark'] = {
- True: '--force-dark-mode',
- False: None,
- }
-
- for setting, args in sorted(settings.items()):
- arg = args[config.instance.get(setting)]
- if arg is not None:
- yield arg
diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py
index 3d0f5c924..6328c3140 100644
--- a/qutebrowser/config/configtypes.py
+++ b/qutebrowser/config/configtypes.py
@@ -47,24 +47,25 @@ import html
import codecs
import os.path
import itertools
-import warnings
import functools
import operator
import json
-import typing
+from typing import (Any, Callable, Dict as DictType, Iterable, Iterator,
+ List as ListType, Optional, Pattern, Sequence, Tuple, Union)
import attr
import yaml
from PyQt5.QtCore import QUrl, Qt
-from PyQt5.QtGui import QColor, QFont, QFontDatabase
-from PyQt5.QtWidgets import QTabWidget, QTabBar, QApplication
+from PyQt5.QtGui import QColor
+from PyQt5.QtWidgets import QTabWidget, QTabBar
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:
@@ -79,9 +80,10 @@ BOOLEAN_STATES = {'1': True, 'yes': True, 'true': True, 'on': True,
'0': False, 'no': False, 'false': False, 'off': False}
-_Completions = typing.Optional[typing.Iterable[typing.Tuple[str, str]]]
-_StrUnset = typing.Union[str, usertypes.Unset]
-_StrUnsetNone = typing.Union[None, str, usertypes.Unset]
+_Completions = Optional[Iterable[Tuple[str, str]]]
+_StrUnset = Union[str, usertypes.Unset]
+_UnsetNone = Union[None, usertypes.Unset]
+_StrUnsetNone = Union[str, _UnsetNone]
class ValidValues:
@@ -94,35 +96,40 @@ class ValidValues:
generate_docs: Whether to show the values in the docs.
"""
- def __init__(self,
- *values: typing.Union[str,
- typing.Dict[str, str],
- typing.Tuple[str, str]],
- generate_docs: bool = True) -> None:
+ def __init__(
+ self,
+ *values: Union[
+ str,
+ DictType[str, Optional[str]],
+ Tuple[str, Optional[str]],
+ ],
+ generate_docs: bool = True,
+ ) -> None:
if not values:
raise ValueError("ValidValues with no values makes no sense!")
- self.descriptions = {} # type: typing.Dict[str, str]
- self.values = [] # type: typing.List[str]
+ self.descriptions: DictType[str, str] = {}
+ self.values: ListType[str] = []
self.generate_docs = generate_docs
for value in values:
if isinstance(value, str):
# Value without description
- self.values.append(value)
+ val = value
+ desc = None
elif isinstance(value, dict):
# List of dicts from configdata.yml
assert len(value) == 1, value
- value, desc = list(value.items())[0]
- self.values.append(value)
- self.descriptions[value] = desc
+ val, desc = list(value.items())[0]
else:
- # (value, description) tuple
- self.values.append(value[0])
- self.descriptions[value[0]] = value[1]
+ val, desc = value
+
+ self.values.append(val)
+ if desc is not None:
+ self.descriptions[val] = desc
def __contains__(self, val: str) -> bool:
return val in self.values
- def __iter__(self) -> typing.Iterator[str]:
+ def __iter__(self) -> Iterator[str]:
return self.values.__iter__()
def __repr__(self) -> str:
@@ -149,19 +156,19 @@ class BaseType:
def __init__(self, none_ok: bool = False) -> None:
self.none_ok = none_ok
- self.valid_values = None # type: typing.Optional[ValidValues]
+ self.valid_values: Optional[ValidValues] = None
def get_name(self) -> str:
"""Get a name for the type for documentation."""
return self.__class__.__name__
- def get_valid_values(self) -> typing.Optional[ValidValues]:
+ def get_valid_values(self) -> Optional[ValidValues]:
"""Get the type's valid values for documentation."""
return self.valid_values
def _basic_py_validation(
- self, value: typing.Any,
- pytype: typing.Union[type, typing.Tuple[type, ...]]) -> None:
+ self, value: Any,
+ pytype: Union[type, Tuple[type, ...]]) -> None:
"""Do some basic validation for Python values (emptyness, type).
Arguments:
@@ -213,8 +220,7 @@ class BaseType:
raise configexc.ValidationError(
value, "may not contain unprintable chars!")
- def _validate_surrogate_escapes(self, full_value: typing.Any,
- value: typing.Any) -> None:
+ def _validate_surrogate_escapes(self, full_value: Any, value: Any) -> None:
"""Make sure the given value doesn't contain surrogate escapes.
This is used for values passed to json.dump, as it can't handle those.
@@ -240,7 +246,7 @@ class BaseType:
value,
"valid values: {}".format(', '.join(self.valid_values)))
- def from_str(self, value: str) -> typing.Any:
+ def from_str(self, value: str) -> Any:
"""Get the setting value from a string.
By default this invokes to_py() for validation and returns the
@@ -259,11 +265,11 @@ class BaseType:
return None
return value
- def from_obj(self, value: typing.Any) -> typing.Any:
+ def from_obj(self, value: Any) -> Any:
"""Get the setting value from a config.py/YAML object."""
return value
- def to_py(self, value: typing.Any) -> typing.Any:
+ def to_py(self, value: Any) -> Any:
"""Get the setting value from a Python value.
Args:
@@ -277,7 +283,7 @@ class BaseType:
"""
raise NotImplementedError
- def to_str(self, value: typing.Any) -> str:
+ def to_str(self, value: Any) -> str:
"""Get a string from the setting value.
The resulting string should be parseable again by from_str.
@@ -287,7 +293,7 @@ class BaseType:
assert isinstance(value, str), value
return value
- def to_doc(self, value: typing.Any, indent: int = 0) -> str:
+ def to_doc(self, value: Any, indent: int = 0) -> str:
"""Get a string with the given value for the documentation.
This currently uses asciidoc syntax.
@@ -309,17 +315,10 @@ class BaseType:
"""
if self.valid_values is None:
return None
- else:
- out = []
- for val in self.valid_values:
- try:
- desc = self.valid_values.descriptions[val]
- except KeyError:
- # Some values are self-explaining and don't need a
- # description.
- desc = ""
- out.append((val, desc))
- return out
+ return [
+ (val, self.valid_values.descriptions.get(val, ""))
+ for val in self.valid_values
+ ]
def __repr__(self) -> str:
return utils.get_repr(self, none_ok=self.none_ok)
@@ -330,24 +329,25 @@ class MappingType(BaseType):
"""Base class for any setting which has a mapping to the given values.
Attributes:
- MAPPING: The mapping to use.
+ MAPPING: A mapping from config values to (translated_value, docs) tuples.
"""
- MAPPING = {} # type: typing.Dict[str, typing.Any]
+ MAPPING: DictType[str, Tuple[Any, Optional[str]]] = {}
- def __init__(self, none_ok: bool = False,
- valid_values: ValidValues = None) -> None:
+ def __init__(self, none_ok: bool = False) -> None:
super().__init__(none_ok)
- self.valid_values = valid_values
+ self.valid_values = ValidValues(
+ *[(key, doc) for (key, (_val, doc)) in self.MAPPING.items()])
- def to_py(self, value: typing.Any) -> typing.Any:
+ def to_py(self, value: Any) -> Any:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
elif not value:
return None
self._validate_valid_values(value.lower())
- return self.MAPPING[value.lower()]
+ mapped, _doc = self.MAPPING[value.lower()]
+ return mapped
def __repr__(self) -> str:
return utils.get_repr(self, none_ok=self.none_ok,
@@ -490,10 +490,10 @@ class List(BaseType):
name += " of " + self.valtype.get_name()
return name
- def get_valid_values(self) -> typing.Optional[ValidValues]:
+ def get_valid_values(self) -> Optional[ValidValues]:
return self.valtype.get_valid_values()
- def from_str(self, value: str) -> typing.Optional[typing.List]:
+ def from_str(self, value: str) -> Optional[ListType]:
self._basic_str_validation(value)
if not value:
return None
@@ -508,15 +508,15 @@ class List(BaseType):
self.to_py(yaml_val)
return yaml_val
- def from_obj(self, value: typing.Optional[typing.List]) -> typing.List:
+ def from_obj(self, value: Optional[ListType]) -> ListType:
if value is None:
return []
return [self.valtype.from_obj(v) for v in value]
def to_py(
self,
- value: typing.Union[typing.List, usertypes.Unset]
- ) -> typing.Union[typing.List, usertypes.Unset]:
+ value: Union[ListType, usertypes.Unset]
+ ) -> Union[ListType, usertypes.Unset]:
self._basic_py_validation(value, list)
if isinstance(value, usertypes.Unset):
return value
@@ -531,13 +531,13 @@ class List(BaseType):
"be set!".format(self.length))
return [self.valtype.to_py(v) for v in value]
- def to_str(self, value: typing.List) -> str:
+ def to_str(self, value: ListType) -> str:
if not value:
# An empty list is treated just like None -> empty string
return ''
return json.dumps(value)
- def to_doc(self, value: typing.List, indent: int = 0) -> str:
+ def to_doc(self, value: ListType, indent: int = 0) -> str:
if not value:
return 'empty'
@@ -571,14 +571,13 @@ class ListOrValue(BaseType):
def __init__(self, valtype: BaseType, *,
none_ok: bool = False,
- **kwargs: typing.Any) -> None:
+ **kwargs: Any) -> None:
super().__init__(none_ok)
assert not isinstance(valtype, (List, ListOrValue)), valtype
self.listtype = List(valtype, none_ok=none_ok, **kwargs)
self.valtype = valtype
- def _val_and_type(self,
- value: typing.Any) -> typing.Tuple[typing.Any, BaseType]:
+ def _val_and_type(self, value: Any) -> Tuple[Any, BaseType]:
"""Get the value and type to use for to_str/to_doc/from_str."""
if isinstance(value, list):
if len(value) == 1:
@@ -591,21 +590,21 @@ class ListOrValue(BaseType):
def get_name(self) -> str:
return self.listtype.get_name() + ', or ' + self.valtype.get_name()
- def get_valid_values(self) -> typing.Optional[ValidValues]:
+ def get_valid_values(self) -> Optional[ValidValues]:
return self.valtype.get_valid_values()
- def from_str(self, value: str) -> typing.Any:
+ def from_str(self, value: str) -> Any:
try:
return self.listtype.from_str(value)
except configexc.ValidationError:
return self.valtype.from_str(value)
- def from_obj(self, value: typing.Any) -> typing.Any:
+ def from_obj(self, value: Any) -> Any:
if value is None:
return []
return value
- def to_py(self, value: typing.Any) -> typing.Any:
+ def to_py(self, value: Any) -> Any:
if isinstance(value, usertypes.Unset):
return value
@@ -614,14 +613,14 @@ class ListOrValue(BaseType):
except configexc.ValidationError:
return self.listtype.to_py(value)
- def to_str(self, value: typing.Any) -> str:
+ def to_str(self, value: Any) -> str:
if value is None:
return ''
val, typ = self._val_and_type(value)
return typ.to_str(val)
- def to_doc(self, value: typing.Any, indent: int = 0) -> str:
+ def to_doc(self, value: Any, indent: int = 0) -> str:
if value is None:
return 'empty'
@@ -640,7 +639,7 @@ class FlagList(List):
the valid values of the setting.
"""
- combinable_values = None # type: typing.Optional[typing.Sequence]
+ combinable_values: Optional[Sequence] = None
_show_valtype = False
@@ -650,15 +649,15 @@ class FlagList(List):
super().__init__(valtype=String(), none_ok=none_ok, length=length)
self.valtype.valid_values = valid_values
- def _check_duplicates(self, values: typing.List) -> None:
+ def _check_duplicates(self, values: ListType) -> None:
if len(set(values)) != len(values):
raise configexc.ValidationError(
values, "List contains duplicate values!")
def to_py(
self,
- value: typing.Union[usertypes.Unset, typing.List],
- ) -> typing.Union[usertypes.Unset, typing.List]:
+ value: Union[usertypes.Unset, ListType],
+ ) -> Union[usertypes.Unset, ListType]:
vals = super().to_py(value)
if not isinstance(vals, usertypes.Unset):
self._check_duplicates(vals)
@@ -702,11 +701,12 @@ 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: Union[bool, str, None]) -> Optional[bool]:
self._basic_py_validation(value, bool)
+ assert not isinstance(value, str)
return value
- def from_str(self, value: str) -> typing.Optional[bool]:
+ def from_str(self, value: str) -> Optional[bool]:
self._basic_str_validation(value)
if not value:
return None
@@ -716,7 +716,7 @@ class Bool(BaseType):
except KeyError:
raise configexc.ValidationError(value, "must be a boolean!")
- def to_str(self, value: typing.Optional[bool]) -> str:
+ def to_str(self, value: Optional[bool]) -> str:
mapping = {
None: '',
True: 'true',
@@ -733,23 +733,23 @@ class BoolAsk(Bool):
super().__init__(none_ok)
self.valid_values = ValidValues('true', 'false', 'ask')
- def to_py(self, # type: ignore
- value: typing.Union[bool, str]) -> typing.Union[bool, str, None]:
+ def to_py(self, # type: ignore[override]
+ value: Union[bool, str]) -> 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
- value: str) -> typing.Union[bool, str, None]:
+ def from_str(self, # type: ignore[override]
+ value: str) -> Union[bool, str, None]:
# basic validation unneeded if it's == 'ask' and done by Bool if we
# call super().from_str
if value.lower() == 'ask':
return 'ask'
return super().from_str(value)
- def to_str(self, value: typing.Union[bool, str, None]) -> str:
+ def to_str(self, value: Union[bool, str, None]) -> str:
mapping = {
None: '',
True: 'true',
@@ -782,8 +782,8 @@ class _Numeric(BaseType): # pylint: disable=abstract-method
.format(self.minval, self.maxval))
def _parse_bound(
- self, bound: typing.Union[None, str, int, float]
- ) -> typing.Union[None, int, float]:
+ self, bound: Union[None, str, int, float]
+ ) -> Union[None, int, float]:
"""Get a numeric bound from a string like 'maxint'."""
if bound == 'maxint':
return qtutils.MAXVALS['int']
@@ -794,11 +794,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: 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))
@@ -808,7 +811,7 @@ class _Numeric(BaseType): # pylint: disable=abstract-method
elif not self.zero_ok and value == 0:
raise configexc.ValidationError(value, "must not be 0!")
- def to_str(self, value: typing.Union[None, int, float]) -> str:
+ def to_str(self, value: Union[None, int, float]) -> str:
if value is None:
return ''
return str(value)
@@ -822,7 +825,7 @@ class Int(_Numeric):
"""Base class for an integer setting."""
- def from_str(self, value: str) -> typing.Optional[int]:
+ def from_str(self, value: str) -> Optional[int]:
self._basic_str_validation(value)
if not value:
return None
@@ -834,7 +837,7 @@ 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: Union[int, _UnsetNone]) -> Union[int, _UnsetNone]:
self._basic_py_validation(value, int)
self._validate_bounds(value)
return value
@@ -844,7 +847,7 @@ class Float(_Numeric):
"""Base class for a float setting."""
- def from_str(self, value: str) -> typing.Optional[float]:
+ def from_str(self, value: str) -> Optional[float]:
self._basic_str_validation(value)
if not value:
return None
@@ -858,8 +861,8 @@ class Float(_Numeric):
def to_py(
self,
- value: typing.Union[None, int, float],
- ) -> typing.Union[None, int, float]:
+ value: Union[int, float, _UnsetNone],
+ ) -> Union[int, float, _UnsetNone]:
self._basic_py_validation(value, (int, float))
self._validate_bounds(value)
return value
@@ -871,8 +874,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: Union[float, int, str, _UnsetNone]
+ ) -> Union[float, int, _UnsetNone]:
self._basic_py_validation(value, (float, int, str))
if isinstance(value, usertypes.Unset):
return value
@@ -889,7 +892,7 @@ class Perc(_Numeric):
self._validate_bounds(value, suffix='%')
return value
- def to_str(self, value: typing.Union[None, float, int, str]) -> str:
+ def to_str(self, value: Union[None, float, int, str]) -> str:
if value is None:
return ''
elif isinstance(value, str):
@@ -920,7 +923,7 @@ class PercOrInt(_Numeric):
raise ValueError("minperc ({}) needs to be <= maxperc "
"({})!".format(self.minperc, self.maxperc))
- def from_str(self, value: str) -> typing.Union[None, str, int]:
+ def from_str(self, value: str) -> Union[None, str, int]:
self._basic_str_validation(value)
if not value:
return None
@@ -937,10 +940,7 @@ class PercOrInt(_Numeric):
self.to_py(intval)
return intval
- def to_py(
- self,
- value: typing.Union[None, str, int]
- ) -> typing.Union[None, str, int]:
+ def to_py(self, value: Union[None, str, int]) -> Union[None, str, int]:
"""Expect a value like '42%' as string, or 23 as int."""
self._basic_py_validation(value, (int, str))
if value is None:
@@ -1000,20 +1000,11 @@ class ColorSystem(MappingType):
"""The color system to use for color interpolation."""
- def __init__(self, none_ok: bool = False) -> None:
- super().__init__(
- none_ok,
- valid_values=ValidValues(
- ('rgb', "Interpolate in the RGB color system."),
- ('hsv', "Interpolate in the HSV color system."),
- ('hsl', "Interpolate in the HSL color system."),
- ('none', "Don't show a gradient.")))
-
MAPPING = {
- 'rgb': QColor.Rgb,
- 'hsv': QColor.Hsv,
- 'hsl': QColor.Hsl,
- 'none': None,
+ 'rgb': (QColor.Rgb, "Interpolate in the RGB color system."),
+ 'hsv': (QColor.Hsv, "Interpolate in the HSV color system."),
+ 'hsl': (QColor.Hsl, "Interpolate in the HSL color system."),
+ 'none': (None, "Don't show a gradient."),
}
@@ -1021,19 +1012,13 @@ class IgnoreCase(MappingType):
"""Whether to search case insensitively."""
- def __init__(self, none_ok: bool = False) -> None:
- super().__init__(
- none_ok,
- valid_values=ValidValues(
- ('always', "Search case-insensitively."),
- ('never', "Search case-sensitively."),
- ('smart', ("Search case-sensitively if there are capital "
- "characters."))))
-
MAPPING = {
- 'always': usertypes.IgnoreCase.always,
- 'never': usertypes.IgnoreCase.never,
- 'smart': usertypes.IgnoreCase.smart,
+ 'always': (usertypes.IgnoreCase.always, "Search case-insensitively."),
+ 'never': (usertypes.IgnoreCase.never, "Search case-sensitively."),
+ 'smart': (
+ usertypes.IgnoreCase.smart,
+ "Search case-sensitively if there are capital characters."
+ ),
}
@@ -1043,7 +1028,7 @@ class QtColor(BaseType):
A value can be in one of the following formats:
- * `#RGB`/`#RRGGBB`/`#RRRGGGBBB`/`#RRRRGGGGBBBB`
+ * `#RGB`/`#RRGGBB`/`#AARRGGBB`/`#RRRGGGBBB`/`#RRRRGGGGBBBB`
* An SVG color name as specified in
http://www.w3.org/TR/SVG/types.html#ColorKeywords[the W3C specification].
* transparent (no color)
@@ -1067,8 +1052,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) -> Union[_UnsetNone, QColor]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
@@ -1080,12 +1064,12 @@ class QtColor(BaseType):
kind = value[:openparen]
vals = value[openparen+1:-1].split(',')
- converters = {
+ converters: DictType[str, Callable[..., QColor]] = {
'rgba': QColor.fromRgb,
'rgb': QColor.fromRgb,
'hsva': QColor.fromHsv,
'hsv': QColor.fromHsv,
- } # type: typing.Dict[str, typing.Callable[..., QColor]]
+ }
conv = converters.get(kind)
if not conv:
@@ -1115,7 +1099,7 @@ class QssColor(BaseType):
A value can be in one of the following formats:
- * `#RGB`/`#RRGGBB`/`#RRRGGGBBB`/`#RRRRGGGGBBBB`
+ * `#RGB`/`#RRGGBB`/`#AARRGGBB`/`#RRRGGGBBB`/`#RRRRGGGGBBBB`
* An SVG color name as specified in
http://www.w3.org/TR/SVG/types.html#ColorKeywords[the W3C specification].
* transparent (no color)
@@ -1146,18 +1130,13 @@ class QssColor(BaseType):
return value
-class Font(BaseType):
-
- """A font family, with optional style/weight/size.
+class FontBase(BaseType):
- * 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
- default_size = None # type: str
+ default_family: Optional[str] = None
+ default_size: Optional[str] = None
font_regex = re.compile(r"""
(
(
@@ -1175,60 +1154,33 @@ class Font(BaseType):
(?P<family>.+) # mandatory font family""", re.VERBOSE)
@classmethod
- def set_defaults(cls, default_family: typing.List[str],
- default_size: str) -> None:
+ def set_defaults(cls, default_family: ListType[str], default_size: str) -> None:
"""Make sure default_family/default_size are available.
If the given family value (fonts.default_family in the config) is
unset, a system-specific default monospace font is used.
-
- Note that (at least) three ways of getting the default monospace font
- exist:
-
- 1) f = QFont()
- f.setStyleHint(QFont.Monospace)
- print(f.defaultFamily())
-
- 2) f = QFont()
- f.setStyleHint(QFont.TypeWriter)
- print(f.defaultFamily())
-
- 3) f = QFontDatabase.systemFont(QFontDatabase.FixedFont)
- print(f.family())
-
- They yield different results depending on the OS:
-
- QFont.Monospace | QFont.TypeWriter | QFontDatabase
- ------------------------------------------------------
- Windows: Courier New | Courier New | Courier New
- Linux: DejaVu Sans Mono | DejaVu Sans Mono | monospace
- macOS: Menlo | American Typewriter | Monaco
-
- Test script: https://p.cmpl.cc/d4dfe573
-
- On Linux, it seems like both actually resolve to the same font.
-
- On macOS, "American Typewriter" looks like it indeed tries to imitate a
- typewriter, so it's not really a suitable UI font.
-
- Looking at those Wikipedia articles:
-
- https://en.wikipedia.org/wiki/Monaco_(typeface)
- https://en.wikipedia.org/wiki/Menlo_(typeface)
-
- the "right" choice isn't really obvious. Thus, let's go for the
- QFontDatabase approach here, since it's by far the simplest one.
"""
if default_family:
families = configutils.FontFamilies(default_family)
else:
- assert QApplication.instance() is not None
- font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
- families = configutils.FontFamilies([font.family()])
+ families = configutils.FontFamilies.from_system_default()
cls.default_family = families.to_str(quote=True)
cls.default_size = default_size
+ def to_py(self, value: Any) -> 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 +1203,7 @@ class Font(BaseType):
return value
-class FontFamily(Font):
+class FontFamily(FontBase):
"""A Qt font family."""
@@ -1275,97 +1227,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.
@@ -1390,40 +1251,29 @@ class Regex(BaseType):
operator.or_,
(getattr(re, flag.strip()) for flag in flags.split(' | ')))
- def _compile_regex(self, pattern: str) -> typing.Pattern[str]:
+ def _compile_regex(self, pattern: str) -> Pattern[str]:
"""Check if the given regex is valid.
- This is more complicated than it could be since there's a warning on
- invalid escapes with newer Python versions, and we want to catch that
- case and treat it as invalid.
+ Some semi-invalid regexes can also raise warnings - we also treat them as
+ invalid.
"""
- with warnings.catch_warnings(record=True) as recorded_warnings:
- warnings.simplefilter('always')
- try:
+ try:
+ with log.py_warning_filter('error', category=FutureWarning):
compiled = re.compile(pattern, self.flags)
- except re.error as e:
- raise configexc.ValidationError(
- pattern, "must be a valid regex - " + str(e))
- except RuntimeError: # pragma: no cover
- raise configexc.ValidationError(
- pattern, "must be a valid regex - recursion depth "
- "exceeded")
-
- assert recorded_warnings is not None
-
- for w in recorded_warnings:
- if (issubclass(w.category, DeprecationWarning) and
- str(w.message).startswith('bad escape')):
- raise configexc.ValidationError(
- pattern, "must be a valid regex - " + str(w.message))
- warnings.warn(w.message)
+ except (re.error, FutureWarning) as e:
+ raise configexc.ValidationError(
+ pattern, "must be a valid regex - " + str(e))
+ except RuntimeError: # pragma: no cover
+ raise configexc.ValidationError(
+ pattern, "must be a valid regex - recursion depth "
+ "exceeded")
return compiled
def to_py(
self,
- value: typing.Union[str, typing.Pattern[str], usertypes.Unset]
- ) -> typing.Union[usertypes.Unset, None, typing.Pattern[str]]:
+ value: Union[str, Pattern[str], usertypes.Unset]
+ ) -> Union[_UnsetNone, 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):
@@ -1435,8 +1285,7 @@ class Regex(BaseType):
else:
return value
- def to_str(self,
- value: typing.Union[None, str, typing.Pattern[str]]) -> str:
+ def to_str(self, value: Union[None, str, Pattern[str]]) -> str:
if value is None:
return ''
elif isinstance(value, self._regex_type):
@@ -1456,10 +1305,10 @@ class Dict(BaseType):
When setting from a string, pass a json-like dict, e.g. `{"key", "value"}`.
"""
- def __init__(self, keytype: typing.Union[String, 'Key'],
+ def __init__(self, keytype: Union[String, 'Key'],
valtype: BaseType, *,
- fixed_keys: typing.Iterable = None,
- required_keys: typing.Iterable = None,
+ fixed_keys: Iterable = None,
+ required_keys: Iterable = None,
none_ok: bool = False) -> None:
super().__init__(none_ok)
# If the keytype is not a string, we'll get problems with showing it as
@@ -1470,7 +1319,7 @@ class Dict(BaseType):
self.fixed_keys = fixed_keys
self.required_keys = required_keys
- def _validate_keys(self, value: typing.Dict) -> None:
+ def _validate_keys(self, value: DictType) -> None:
if (self.fixed_keys is not None and not
set(value.keys()).issubset(self.fixed_keys)):
raise configexc.ValidationError(
@@ -1481,7 +1330,7 @@ class Dict(BaseType):
raise configexc.ValidationError(
value, "Required keys {}".format(self.required_keys))
- def from_str(self, value: str) -> typing.Optional[typing.Dict]:
+ def from_str(self, value: str) -> Optional[DictType]:
self._basic_str_validation(value)
if not value:
return None
@@ -1496,14 +1345,14 @@ class Dict(BaseType):
self.to_py(yaml_val)
return yaml_val
- def from_obj(self, value: typing.Optional[typing.Dict]) -> typing.Dict:
+ def from_obj(self, value: Optional[DictType]) -> DictType:
if value is None:
return {}
return {self.keytype.from_obj(key): self.valtype.from_obj(val)
for key, val in value.items()}
- def _fill_fixed_keys(self, value: typing.Dict) -> typing.Dict:
+ def _fill_fixed_keys(self, value: DictType) -> DictType:
"""Fill missing fixed keys with a None-value."""
if self.fixed_keys is None:
return value
@@ -1514,8 +1363,8 @@ class Dict(BaseType):
def to_py(
self,
- value: typing.Union[typing.Dict, usertypes.Unset, None]
- ) -> typing.Union[typing.Dict, usertypes.Unset]:
+ value: Union[DictType, _UnsetNone]
+ ) -> Union[DictType, usertypes.Unset]:
self._basic_py_validation(value, dict)
if isinstance(value, usertypes.Unset):
return value
@@ -1531,13 +1380,13 @@ class Dict(BaseType):
for key, val in value.items()}
return self._fill_fixed_keys(d)
- def to_str(self, value: typing.Dict) -> str:
+ def to_str(self, value: DictType) -> str:
if not value:
# An empty Dict is treated just like None -> empty string
return ''
return json.dumps(value, sort_keys=True)
- def to_doc(self, value: typing.Dict, indent: int = 0) -> str:
+ def to_doc(self, value: DictType, indent: int = 0) -> str:
if not value:
return 'empty'
lines = ['\n']
@@ -1560,7 +1409,7 @@ class File(BaseType):
"""A file on the local filesystem."""
- def __init__(self, required: bool = True, **kwargs: typing.Any) -> None:
+ def __init__(self, required: bool = True, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.required = required
@@ -1626,7 +1475,7 @@ class FormatString(BaseType):
completions: completions to be used, or None
"""
- def __init__(self, fields: typing.Iterable[str],
+ def __init__(self, fields: Iterable[str],
none_ok: bool = False,
completions: _Completions = None) -> None:
super().__init__(none_ok)
@@ -1679,8 +1528,8 @@ class ShellCommand(List):
def to_py(
self,
- value: typing.Union[typing.List, usertypes.Unset],
- ) -> typing.Union[typing.List, usertypes.Unset]:
+ value: Union[ListType, usertypes.Unset],
+ ) -> Union[ListType, usertypes.Unset]:
py_value = super().to_py(value)
if isinstance(py_value, usertypes.Unset):
return py_value
@@ -1713,7 +1562,7 @@ class Proxy(BaseType):
def to_py(
self,
value: _StrUnset
- ) -> typing.Union[usertypes.Unset, None, QNetworkProxy, _SystemProxy]:
+ ) -> Union[_UnsetNone, QNetworkProxy, _SystemProxy, pac.PACFetcher]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
@@ -1783,7 +1632,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) -> Union[QUrl, _UnsetNone]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
@@ -1801,10 +1650,10 @@ class PaddingValues:
"""Four padding values."""
- top = attr.ib() # type: int
- bottom = attr.ib() # type: int
- left = attr.ib() # type: int
- right = attr.ib() # type: int
+ top: int = attr.ib()
+ bottom: int = attr.ib()
+ left: int = attr.ib()
+ right: int = attr.ib()
class Padding(Dict):
@@ -1819,10 +1668,10 @@ 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],
- ) -> typing.Union[usertypes.Unset, PaddingValues]:
+ value: Union[DictType, _UnsetNone],
+ ) -> Union[usertypes.Unset, PaddingValues]:
d = super().to_py(value)
if isinstance(d, usertypes.Unset):
return d
@@ -1852,33 +1701,23 @@ class Position(MappingType):
"""The position of the tab bar."""
MAPPING = {
- 'top': QTabWidget.North,
- 'bottom': QTabWidget.South,
- 'left': QTabWidget.West,
- 'right': QTabWidget.East,
+ 'top': (QTabWidget.North, None),
+ 'bottom': (QTabWidget.South, None),
+ 'left': (QTabWidget.West, None),
+ 'right': (QTabWidget.East, None),
}
- def __init__(self, none_ok: bool = False) -> None:
- super().__init__(
- none_ok,
- valid_values=ValidValues('top', 'bottom', 'left', 'right'))
-
class TextAlignment(MappingType):
"""Alignment of text."""
MAPPING = {
- 'left': Qt.AlignLeft,
- 'right': Qt.AlignRight,
- 'center': Qt.AlignCenter,
+ 'left': (Qt.AlignLeft, None),
+ 'right': (Qt.AlignRight, None),
+ 'center': (Qt.AlignCenter, None),
}
- def __init__(self, none_ok: bool = False) -> None:
- super().__init__(
- none_ok,
- valid_values=ValidValues('left', 'right', 'center'))
-
class VerticalPosition(String):
@@ -1893,10 +1732,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) -> Union[_UnsetNone, QUrl]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
@@ -1930,21 +1766,22 @@ class SelectOnRemove(MappingType):
"""Which tab to select when the focused tab is removed."""
MAPPING = {
- 'prev': QTabBar.SelectLeftTab,
- 'next': QTabBar.SelectRightTab,
- 'last-used': QTabBar.SelectPreviousTab,
+ 'prev': (
+ QTabBar.SelectLeftTab,
+ ("Select the tab which came before the closed one "
+ "(left in horizontal, above in vertical)."),
+ ),
+ 'next': (
+ QTabBar.SelectRightTab,
+ ("Select the tab which came after the closed one "
+ "(right in horizontal, below in vertical)."),
+ ),
+ 'last-used': (
+ QTabBar.SelectPreviousTab,
+ "Select the previously selected tab.",
+ ),
}
- def __init__(self, none_ok: bool = False) -> None:
- super().__init__(
- none_ok,
- valid_values=ValidValues(
- ('prev', "Select the tab which came before the closed one "
- "(left in horizontal, above in vertical)."),
- ('next', "Select the tab which came after the closed one "
- "(right in horizontal, below in vertical)."),
- ('last-used', "Select the previously selected tab.")))
-
class ConfirmQuit(FlagList):
@@ -1966,8 +1803,8 @@ class ConfirmQuit(FlagList):
def to_py(
self,
- value: typing.Union[usertypes.Unset, typing.List],
- ) -> typing.Union[typing.List, usertypes.Unset]:
+ value: Union[usertypes.Unset, ListType],
+ ) -> Union[ListType, usertypes.Unset]:
values = super().to_py(value)
if isinstance(values, usertypes.Unset):
return values
@@ -1999,6 +1836,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 +1857,7 @@ class Key(BaseType):
def to_py(
self,
value: _StrUnset
- ) -> typing.Union[usertypes.Unset, None, keyutils.KeySequence]:
+ ) -> Union[_UnsetNone, keyutils.KeySequence]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
return value
@@ -2034,7 +1881,7 @@ class UrlPattern(BaseType):
def to_py(
self,
value: _StrUnset
- ) -> typing.Union[usertypes.Unset, None, urlmatch.UrlPattern]:
+ ) -> 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..e7a60a7eb 100644
--- a/qutebrowser/config/configutils.py
+++ b/qutebrowser/config/configutils.py
@@ -21,21 +21,25 @@
"""Utilities and data structures used by various config code."""
-import typing
import collections
import itertools
import operator
+from typing import (
+ TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional, Sequence, Set, Union,
+ MutableMapping)
from PyQt5.QtCore import QUrl
+from PyQt5.QtGui import QFontDatabase
+from PyQt5.QtWidgets import QApplication
-from qutebrowser.utils import utils, urlmatch, usertypes
+from qutebrowser.utils import utils, urlmatch, usertypes, qtutils
from qutebrowser.config import configexc
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.config import configdata
-def _widened_hostnames(hostname: str) -> typing.Iterable[str]:
+def _widened_hostnames(hostname: str) -> Iterable[str]:
"""A generator for widening string hostnames.
Ex: a.c.foo -> [a.c.foo, c.foo, foo]"""
@@ -56,8 +60,8 @@ class ScopedValue:
id_gen = itertools.count(0)
- def __init__(self, value: typing.Any,
- pattern: typing.Optional[urlmatch.UrlPattern],
+ def __init__(self, value: Any,
+ pattern: Optional[urlmatch.UrlPattern],
hide_userconfig: bool = False) -> None:
self.value = value
self.pattern = pattern
@@ -90,17 +94,17 @@ class Values:
_domain_map: A mapping from hostnames to all associated ScopedValues.
"""
- _VmapKeyType = typing.Optional[urlmatch.UrlPattern]
+ _VmapKeyType = Optional[urlmatch.UrlPattern]
def __init__(self,
opt: 'configdata.Option',
- values: typing.Sequence[ScopedValue] = ()) -> None:
+ values: Sequence[ScopedValue] = ()) -> None:
self.opt = opt
- self._vmap = collections.OrderedDict() \
- # type: collections.OrderedDict[Values._VmapKeyType, ScopedValue]
+ self._vmap: MutableMapping[
+ Values._VmapKeyType, ScopedValue] = collections.OrderedDict()
# A map from domain parts to rules that fall under them.
- self._domain_map = collections.defaultdict(set) \
- # type: typing.Dict[typing.Optional[str], typing.Set[ScopedValue]]
+ self._domain_map: Dict[
+ Optional[str], Set[ScopedValue]] = collections.defaultdict(set)
for scoped in values:
self._add_scoped(scoped)
@@ -117,7 +121,7 @@ class Values:
return '\n'.join(lines)
return '{}: <unchanged>'.format(self.opt.name)
- def dump(self, include_hidden: bool = False) -> typing.Sequence[str]:
+ def dump(self, include_hidden: bool = False) -> Sequence[str]:
"""Dump all customizations for this value.
Arguments:
@@ -138,7 +142,7 @@ class Values:
return lines
- def __iter__(self) -> typing.Iterator['ScopedValue']:
+ def __iter__(self) -> Iterator['ScopedValue']:
"""Yield ScopedValue elements.
This yields in "normal" order, i.e. global and then first-set settings
@@ -151,12 +155,12 @@ class Values:
return bool(self._vmap)
def _check_pattern_support(
- self, arg: typing.Optional[urlmatch.UrlPattern]) -> None:
+ self, arg: 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)
- def add(self, value: typing.Any,
+ def add(self, value: Any,
pattern: urlmatch.UrlPattern = None, *,
hide_userconfig: bool = False) -> None:
"""Add a value with the given pattern to the list of values.
@@ -201,7 +205,7 @@ class Values:
self._vmap.clear()
self._domain_map.clear()
- def _get_fallback(self, fallback: bool) -> typing.Any:
+ def _get_fallback(self, fallback: bool) -> Any:
"""Get the fallback global/default value."""
if None in self._vmap:
return self._vmap[None].value
@@ -211,8 +215,7 @@ class Values:
else:
return usertypes.UNSET
- def get_for_url(self, url: QUrl = None, *,
- fallback: bool = True) -> typing.Any:
+ def get_for_url(self, url: QUrl = None, *, fallback: bool = True) -> Any:
"""Get a config value, falling back when needed.
This first tries to find a value matching the URL (if given).
@@ -223,8 +226,9 @@ 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]
+ candidates: List[ScopedValue] = []
# Urls trailing with '.' are equivalent to non-trailing types.
# urlutils strips them, so in order to match we will need to as well.
widened_hosts = _widened_hostnames(url.host().rstrip('.'))
@@ -246,8 +250,8 @@ class Values:
return self._get_fallback(fallback)
def get_for_pattern(self,
- pattern: typing.Optional[urlmatch.UrlPattern], *,
- fallback: bool = True) -> typing.Any:
+ pattern: Optional[urlmatch.UrlPattern], *,
+ fallback: bool = True) -> Any:
"""Get a value only if it's been overridden for the given pattern.
This is useful when showing values to the user.
@@ -271,22 +275,25 @@ class FontFamilies:
"""A list of font family names."""
- def __init__(self, families: typing.Sequence[str]) -> None:
+ def __init__(self, families: Sequence[str]) -> None:
self._families = families
self.family = families[0] if families else None
- def __iter__(self) -> typing.Iterator[str]:
+ def __iter__(self) -> Iterator[str]:
yield from self._families
+ def __len__(self) -> int:
+ return len(self._families)
+
def __repr__(self) -> str:
return utils.get_repr(self, families=self._families, constructor=True)
def __str__(self) -> str:
return self.to_str()
- def _quoted_families(self) -> typing.Iterator[str]:
+ def _quoted_families(self) -> Iterator[str]:
for f in self._families:
- needs_quoting = any(c in f for c in ', ')
+ needs_quoting = any(c in f for c in '., ')
yield '"{}"'.format(f) if needs_quoting else f
def to_str(self, *, quote: bool = True) -> str:
@@ -294,6 +301,57 @@ class FontFamilies:
return ', '.join(families)
@classmethod
+ def from_system_default(
+ cls,
+ font_type: QFontDatabase.SystemFont = QFontDatabase.FixedFont,
+ ) -> 'FontFamilies':
+ """Get a FontFamilies object for the default system font.
+
+ By default, the monospace font is returned, though via the "font_type" argument,
+ other types can be requested as well.
+
+ Note that (at least) three ways of getting the default monospace font
+ exist:
+
+ 1) f = QFont()
+ f.setStyleHint(QFont.Monospace)
+ print(f.defaultFamily())
+
+ 2) f = QFont()
+ f.setStyleHint(QFont.TypeWriter)
+ print(f.defaultFamily())
+
+ 3) f = QFontDatabase.systemFont(QFontDatabase.FixedFont)
+ print(f.family())
+
+ They yield different results depending on the OS:
+
+ QFont.Monospace | QFont.TypeWriter | QFontDatabase
+ ------------------------------------------------------
+ Windows: Courier New | Courier New | Courier New
+ Linux: DejaVu Sans Mono | DejaVu Sans Mono | monospace
+ macOS: Menlo | American Typewriter | Monaco
+
+ Test script: https://p.cmpl.cc/d4dfe573
+
+ On Linux, it seems like both actually resolve to the same font.
+
+ On macOS, "American Typewriter" looks like it indeed tries to imitate a
+ typewriter, so it's not really a suitable UI font.
+
+ Looking at those Wikipedia articles:
+
+ https://en.wikipedia.org/wiki/Monaco_(typeface)
+ https://en.wikipedia.org/wiki/Menlo_(typeface)
+
+ the "right" choice isn't really obvious. Thus, let's go for the
+ QFontDatabase approach here, since it's by far the simplest one.
+ """
+ assert QApplication.instance() is not None
+ font = QFontDatabase.systemFont(font_type)
+ return cls([font.family()])
+
+ @classmethod
def from_str(cls, family_str: str) -> 'FontFamilies':
"""Parse a CSS-like string of font families."""
families = []
diff --git a/qutebrowser/config/qtargs.py b/qutebrowser/config/qtargs.py
new file mode 100644
index 000000000..d6375f331
--- /dev/null
+++ b/qutebrowser/config/qtargs.py
@@ -0,0 +1,267 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2014-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/>.
+
+"""Get arguments to pass to Qt."""
+
+import os
+import sys
+import argparse
+from typing import Any, Dict, Iterator, List, Optional, Sequence
+
+from qutebrowser.config import config
+from qutebrowser.misc import objects
+from qutebrowser.utils import usertypes, qtutils, utils
+
+
+def qt_args(namespace: argparse.Namespace) -> List[str]:
+ """Get the Qt QApplication arguments based on an argparse namespace.
+
+ Args:
+ namespace: The argparse namespace.
+
+ Return:
+ The argv list to be passed to Qt.
+ """
+ argv = [sys.argv[0]]
+
+ if namespace.qt_flag is not None:
+ argv += ['--' + flag[0] for flag in namespace.qt_flag]
+
+ if namespace.qt_arg is not None:
+ for name, value in namespace.qt_arg:
+ argv += ['--' + name, value]
+
+ argv += ['--' + arg for arg in config.val.qt.args]
+
+ if objects.backend != usertypes.Backend.QtWebEngine:
+ assert objects.backend == usertypes.Backend.QtWebKit, objects.backend
+ return argv
+
+ feature_flags = [flag for flag in argv
+ if flag.startswith('--enable-features=')]
+ argv = [flag for flag in argv if not flag.startswith('--enable-features=')]
+ argv += list(_qtwebengine_args(namespace, feature_flags))
+
+ return argv
+
+
+def _qtwebengine_enabled_features(feature_flags: Sequence[str]) -> Iterator[str]:
+ """Get --enable-features flags for QtWebEngine.
+
+ Args:
+ feature_flags: Existing flags passed via the commandline.
+ """
+ for flag in feature_flags:
+ prefix = '--enable-features='
+ assert flag.startswith(prefix), flag
+ flag = flag[len(prefix):]
+ yield from iter(flag.split(','))
+
+ if qtutils.version_check('5.15', compiled=False) and utils.is_linux:
+ # Enable WebRTC PipeWire for screen capturing on Wayland.
+ #
+ # This is disabled in Chromium by default because of the "dialog hell":
+ # https://bugs.chromium.org/p/chromium/issues/detail?id=682122#c50
+ # https://github.com/flatpak/xdg-desktop-portal-gtk/issues/204
+ #
+ # However, we don't have Chromium's confirmation dialog in qutebrowser,
+ # so we should only get qutebrowser's permission dialog.
+ #
+ # In theory this would be supported with Qt 5.13 already, but
+ # QtWebEngine only started picking up PipeWire correctly with Qt
+ # 5.15.1. Checking for 5.15 here to pick up Archlinux' patched package
+ # as well.
+ #
+ # This only should be enabled on Wayland, but it's too early to check
+ # that, as we don't have a QApplication available at this point. Thus,
+ # just turn it on unconditionally on Linux, which shouldn't hurt.
+ yield 'WebRTCPipeWireCapturer'
+
+ if not utils.is_mac:
+ # Enable overlay scrollbars.
+ #
+ # 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.
+ if config.val.scrolling.bar == 'overlay':
+ yield 'OverlayScrollbar'
+
+ if (qtutils.version_check('5.14', compiled=False) and
+ config.val.content.headers.referer == 'same-domain'):
+ # Handling of reduced-referrer-granularity in Chromium 76+
+ # https://chromium-review.googlesource.com/c/chromium/src/+/1572699
+ #
+ # Note that this is removed entirely (and apparently the default) starting with
+ # Chromium 89 (Qt 5.15.x or 6.x):
+ # https://chromium-review.googlesource.com/c/chromium/src/+/2545444
+ yield 'ReducedReferrerGranularity'
+
+
+def _qtwebengine_args(
+ namespace: argparse.Namespace,
+ feature_flags: Sequence[str],
+) -> Iterator[str]:
+ """Get the QtWebEngine arguments to use based on the config."""
+ is_qt_514 = (qtutils.version_check('5.14', compiled=False) and
+ not qtutils.version_check('5.15', compiled=False))
+
+ if is_qt_514:
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-82105
+ yield '--disable-shared-workers'
+
+ # WORKAROUND equivalent to
+ # https://codereview.qt-project.org/c/qt/qtwebengine/+/256786
+ # also see:
+ # https://codereview.qt-project.org/c/qt/qtwebengine-chromium/+/265753
+ if qtutils.version_check('5.12.3', compiled=False):
+ if 'stack' in namespace.debug_flags:
+ # Only actually available in Qt 5.12.5, but let's save another
+ # check, as passing the option won't hurt.
+ yield '--enable-in-process-stack-traces'
+ else:
+ if 'stack' not in namespace.debug_flags:
+ yield '--disable-in-process-stack-traces'
+
+ if 'chromium' in namespace.debug_flags:
+ yield '--enable-logging'
+ yield '--v=1'
+
+ if 'wait-renderer-process' in namespace.debug_flags:
+ yield '--renderer-startup-dialog'
+
+ from qutebrowser.browser.webengine import darkmode
+ blink_settings = list(darkmode.settings())
+ if blink_settings:
+ yield '--blink-settings=' + ','.join(f'{k}={v}' for k, v in blink_settings)
+
+ enabled_features = list(_qtwebengine_enabled_features(feature_flags))
+ if enabled_features:
+ yield '--enable-features=' + ','.join(enabled_features)
+
+ yield from _qtwebengine_settings_args()
+
+
+def _qtwebengine_settings_args() -> Iterator[str]:
+ settings: Dict[str, Dict[Any, Optional[str]]] = {
+ 'qt.force_software_rendering': {
+ 'software-opengl': None,
+ 'qt-quick': None,
+ 'chromium': '--disable-gpu',
+ 'none': None,
+ },
+ 'content.canvas_reading': {
+ True: None,
+ False: '--disable-reading-from-canvas',
+ },
+ 'content.webrtc_ip_handling_policy': {
+ 'all-interfaces': None,
+ 'default-public-and-private-interfaces':
+ '--force-webrtc-ip-handling-policy='
+ 'default_public_and_private_interfaces',
+ 'default-public-interface-only':
+ '--force-webrtc-ip-handling-policy='
+ 'default_public_interface_only',
+ 'disable-non-proxied-udp':
+ '--force-webrtc-ip-handling-policy='
+ 'disable_non_proxied_udp',
+ },
+ 'qt.process_model': {
+ 'process-per-site-instance': None,
+ 'process-per-site': '--process-per-site',
+ 'single-process': '--single-process',
+ },
+ 'qt.low_end_device_mode': {
+ 'auto': None,
+ 'always': '--enable-low-end-device-mode',
+ 'never': '--disable-low-end-device-mode',
+ },
+ 'content.headers.referer': {
+ 'always': None,
+ }
+ }
+
+ if (qtutils.version_check('5.14', compiled=False) and
+ not qtutils.version_check('5.15.2', compiled=False)):
+ # In Qt 5.14 to 5.15.1, `--force-dark-mode` is used to set the
+ # preferred colorscheme. In Qt 5.15.2, this is handled by a
+ # blink-setting instead.
+ settings['colors.webpage.prefers_color_scheme_dark'] = {
+ True: '--force-dark-mode',
+ False: None,
+ }
+
+ referrer_setting = settings['content.headers.referer']
+ if qtutils.version_check('5.14', compiled=False):
+ # Starting with Qt 5.14, this is handled via --enable-features
+ referrer_setting['same-domain'] = None
+ else:
+ referrer_setting['same-domain'] = '--reduced-referrer-granularity'
+
+ can_override_referer = (
+ qtutils.version_check('5.12.4', compiled=False) and
+ not qtutils.version_check('5.13.0', compiled=False, exact=True)
+ )
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-60203
+ referrer_setting['never'] = None if can_override_referer else '--no-referrers'
+
+ for setting, args in sorted(settings.items()):
+ arg = args[config.instance.get(setting)]
+ if arg is not None:
+ yield arg
+
+
+def init_envvars() -> None:
+ """Initialize environment variables which need to be set early."""
+ if objects.backend == usertypes.Backend.QtWebEngine:
+ software_rendering = config.val.qt.force_software_rendering
+ if software_rendering == 'software-opengl':
+ os.environ['QT_XCB_FORCE_SOFTWARE_OPENGL'] = '1'
+ elif software_rendering == 'qt-quick':
+ os.environ['QT_QUICK_BACKEND'] = 'software'
+ elif software_rendering == 'chromium':
+ os.environ['QT_WEBENGINE_DISABLE_NOUVEAU_WORKAROUND'] = '1'
+ else:
+ assert objects.backend == usertypes.Backend.QtWebKit, objects.backend
+
+ if config.val.qt.force_platform is not None:
+ os.environ['QT_QPA_PLATFORM'] = config.val.qt.force_platform
+ if config.val.qt.force_platformtheme is not None:
+ os.environ['QT_QPA_PLATFORMTHEME'] = config.val.qt.force_platformtheme
+
+ if config.val.window.hide_decoration:
+ os.environ['QT_WAYLAND_DISABLE_WINDOWDECORATION'] = '1'
+
+ if config.val.qt.highdpi:
+ env_var = ('QT_ENABLE_HIGHDPI_SCALING'
+ if qtutils.version_check('5.14', compiled=False)
+ else 'QT_AUTO_SCREEN_SCALE_FACTOR')
+ os.environ[env_var] = '1'
+
+ for var, val in config.val.qt.environ.items():
+ if val is None and var in os.environ:
+ del os.environ[var]
+ elif val is not None:
+ os.environ[var] = val
diff --git a/qutebrowser/config/stylesheet.py b/qutebrowser/config/stylesheet.py
index 276d37094..3c68fc0b9 100644
--- a/qutebrowser/config/stylesheet.py
+++ b/qutebrowser/config/stylesheet.py
@@ -81,13 +81,13 @@ class _StyleSheetObserver(QObject):
if update:
self.setParent(self._obj)
if stylesheet is None:
- self._stylesheet = obj.STYLESHEET # type: str
+ self._stylesheet: str = obj.STYLESHEET
else:
self._stylesheet = stylesheet
if update:
- self._options = jinja.template_config_variables(
- self._stylesheet) # type: Optional[FrozenSet[str]]
+ self._options: Optional[FrozenSet[str]] = jinja.template_config_variables(
+ self._stylesheet)
else:
self._options = None
@@ -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..e1cb393dc 100644
--- a/qutebrowser/config/websettings.py
+++ b/qutebrowser/config/websettings.py
@@ -20,9 +20,9 @@
"""Bridge from QWeb(Engine)Settings to our own settings."""
import re
-import typing
import argparse
import functools
+from typing import Any, Callable, Dict, Optional
import attr
from PyQt5.QtCore import QUrl, pyqtSlot, qVersion
@@ -30,7 +30,7 @@ from PyQt5.QtGui import QFont
import qutebrowser
from qutebrowser.config import config
-from qutebrowser.utils import log, usertypes, urlmatch, qtutils
+from qutebrowser.utils import usertypes, urlmatch, qtutils, utils
from qutebrowser.misc import objects, debugcachestats
UNSET = object()
@@ -41,11 +41,11 @@ class UserAgent:
"""A parsed user agent."""
- os_info = attr.ib() # type: str
- webkit_version = attr.ib() # type: str
- upstream_browser_key = attr.ib() # type: str
- upstream_browser_version = attr.ib() # type: str
- qt_key = attr.ib() # type: str
+ os_info: str = attr.ib()
+ webkit_version: str = attr.ib()
+ upstream_browser_key: str = attr.ib()
+ upstream_browser_version: str = attr.ib()
+ qt_key: str = attr.ib()
@classmethod
def parse(cls, ua: str) -> 'UserAgent':
@@ -82,8 +82,7 @@ class AttributeInfo:
"""Info about a settings attribute."""
- def __init__(self, *attributes: typing.Any,
- converter: typing.Callable = None) -> None:
+ def __init__(self, *attributes: Any, converter: Callable = None) -> None:
self.attributes = attributes
if converter is None:
self.converter = lambda val: val
@@ -95,34 +94,28 @@ class AbstractSettings:
"""Abstract base class for settings set via QWeb(Engine)Settings."""
- _ATTRIBUTES = {} # type: typing.Dict[str, AttributeInfo]
- _FONT_SIZES = {} # type: typing.Dict[str, typing.Any]
- _FONT_FAMILIES = {} # type: typing.Dict[str, typing.Any]
- _FONT_TO_QFONT = {} # type: typing.Dict[typing.Any, QFont.StyleHint]
+ _ATTRIBUTES: Dict[str, AttributeInfo] = {}
+ _FONT_SIZES: Dict[str, Any] = {}
+ _FONT_FAMILIES: Dict[str, Any] = {}
+ _FONT_TO_QFONT: Dict[Any, QFont.StyleHint] = {}
- def __init__(self, settings: typing.Any) -> None:
+ def __init__(self, settings: Any) -> None:
self._settings = settings
- def set_attribute(self, name: str, value: typing.Any) -> bool:
+ def _assert_not_unset(self, value: Any) -> None:
+ assert value is not usertypes.UNSET
+
+ def set_attribute(self, name: str, value: Any) -> None:
"""Set the given QWebSettings/QWebEngineSettings attribute.
If the value is usertypes.UNSET, the value is reset instead.
-
- Return:
- True if there was a change, False otherwise.
"""
- old_value = self.test_attribute(name)
-
info = self._ATTRIBUTES[name]
for attribute in info.attributes:
if value is usertypes.UNSET:
self._settings.resetAttribute(attribute)
- new_value = self.test_attribute(name)
else:
self._settings.setAttribute(attribute, info.converter(value))
- new_value = value
-
- return old_value != new_value
def test_attribute(self, name: str) -> bool:
"""Get the value for the given attribute.
@@ -133,51 +126,33 @@ class AbstractSettings:
info = self._ATTRIBUTES[name]
return self._settings.testAttribute(info.attributes[0])
- def set_font_size(self, name: str, value: int) -> bool:
- """Set the given QWebSettings/QWebEngineSettings font size.
-
- Return:
- True if there was a change, False otherwise.
- """
- assert value is not usertypes.UNSET # type: ignore
+ def set_font_size(self, name: str, value: int) -> None:
+ """Set the given QWebSettings/QWebEngineSettings font size."""
+ self._assert_not_unset(value)
family = self._FONT_SIZES[name]
- old_value = self._settings.fontSize(family)
self._settings.setFontSize(family, value)
- return old_value != value
- def set_font_family(self, name: str, value: typing.Optional[str]) -> bool:
+ def set_font_family(self, name: str, value: Optional[str]) -> None:
"""Set the given QWebSettings/QWebEngineSettings font family.
With None (the default), QFont is used to get the default font for the
family.
-
- 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()
font.setStyleHint(self._FONT_TO_QFONT[family])
value = font.defaultFamily()
- old_value = self._settings.fontFamily(family)
self._settings.setFontFamily(family, value)
- return value != old_value
-
- def set_default_text_encoding(self, encoding: str) -> bool:
- """Set the default text encoding to use.
-
- Return:
- True if there was a change, False otherwise.
- """
- assert encoding is not usertypes.UNSET # type: ignore
- old_value = self._settings.defaultTextEncoding()
+ def set_default_text_encoding(self, encoding: str) -> None:
+ """Set the default text encoding to use."""
+ self._assert_not_unset(encoding)
self._settings.setDefaultTextEncoding(encoding)
- return old_value != encoding
- def _update_setting(self, setting: str, value: typing.Any) -> bool:
+ def _update_setting(self, setting: str, value: Any) -> bool:
"""Update the given setting/value.
Unknown settings are ignored.
@@ -186,13 +161,13 @@ class AbstractSettings:
True if there was a change, False otherwise.
"""
if setting in self._ATTRIBUTES:
- return self.set_attribute(setting, value)
+ self.set_attribute(setting, value)
elif setting in self._FONT_SIZES:
- return self.set_font_size(setting, value)
+ self.set_font_size(setting, value)
elif setting in self._FONT_FAMILIES:
- return self.set_font_family(setting, value)
+ self.set_font_family(setting, value)
elif setting == 'content.default_encoding':
- return self.set_default_text_encoding(value)
+ self.set_default_text_encoding(value)
return False
def update_setting(self, setting: str) -> None:
@@ -200,27 +175,15 @@ class AbstractSettings:
value = config.instance.get(setting)
self._update_setting(setting, value)
- def update_for_url(self, url: QUrl) -> typing.Set[str]:
- """Update settings customized for the given tab.
-
- Return:
- A set of settings which actually changed.
- """
+ def update_for_url(self, url: QUrl) -> None:
+ """Update settings customized for the given tab."""
qtutils.ensure_valid(url)
- changed_settings = set()
for values in config.instance:
if not values.opt.supports_pattern:
continue
value = values.get_for_url(url, fallback=False)
-
- changed = self._update_setting(values.opt.name, value)
- if changed:
- log.config.debug("Changed for {}: {} = {}".format(
- url.toDisplayString(), values.opt.name, value))
- changed_settings.add(values.opt.name)
-
- return changed_settings
+ self._update_setting(values.opt.name, value)
def init_settings(self) -> None:
"""Set all supported settings correctly."""
@@ -253,6 +216,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)
@@ -261,10 +228,12 @@ def init(args: argparse.Namespace) -> None:
"""Initialize all QWeb(Engine)Settings."""
if objects.backend == usertypes.Backend.QtWebEngine:
from qutebrowser.browser.webengine import webenginesettings
- webenginesettings.init(args)
- else:
+ webenginesettings.init()
+ elif objects.backend == usertypes.Backend.QtWebKit:
from qutebrowser.browser.webkit import webkitsettings
- webkitsettings.init(args)
+ webkitsettings.init()
+ else:
+ raise utils.Unreachable(objects.backend)
# Make sure special URLs always get JS support
for pattern in ['chrome://*/*', 'qute://*/*']:
@@ -273,12 +242,27 @@ def init(args: argparse.Namespace) -> None:
hide_userconfig=True)
+def clear_private_data() -> None:
+ """Clear cookies, cache and related data for private browsing sessions."""
+ if objects.backend == usertypes.Backend.QtWebEngine:
+ from qutebrowser.browser.webengine import webenginesettings
+ webenginesettings.init_private_profile()
+ elif objects.backend == usertypes.Backend.QtWebKit:
+ from qutebrowser.browser.webkit import cookies
+ assert cookies.ram_cookie_jar is not None
+ cookies.ram_cookie_jar.setAllCookies([])
+ else:
+ raise utils.Unreachable(objects.backend)
+
+
@pyqtSlot()
def shutdown() -> None:
"""Shut down QWeb(Engine)Settings."""
if objects.backend == usertypes.Backend.QtWebEngine:
from qutebrowser.browser.webengine import webenginesettings
webenginesettings.shutdown()
- else:
+ elif objects.backend == usertypes.Backend.QtWebKit:
from qutebrowser.browser.webkit import webkitsettings
webkitsettings.shutdown()
+ else:
+ raise utils.Unreachable(objects.backend)
diff --git a/qutebrowser/extensions/interceptors.py b/qutebrowser/extensions/interceptors.py
index 6c5756016..9c50346d7 100644
--- a/qutebrowser/extensions/interceptors.py
+++ b/qutebrowser/extensions/interceptors.py
@@ -19,8 +19,8 @@
"""Infrastructure for intercepting requests."""
-import typing
import enum
+from typing import Callable, List, Optional
import attr
@@ -59,32 +59,24 @@ class ResourceType(enum.Enum):
class RedirectException(Exception):
- """Raised when there was an error with redirection."""
-
-
-class RedirectFailedException(RedirectException):
"""Raised when the request was invalid, or a request was already made."""
-class RedirectUnsupportedException(RedirectException):
- """Raised when redirection is currently unsupported."""
-
-
@attr.s
class Request:
"""A request which can be intercepted/blocked."""
#: The URL of the page being shown.
- first_party_url = attr.ib() # type: typing.Optional[QUrl]
+ first_party_url: Optional[QUrl] = attr.ib()
#: The URL of the file being requested.
- request_url = attr.ib() # type: QUrl
+ request_url: QUrl = attr.ib()
- is_blocked = attr.ib(False) # type: bool
+ is_blocked: bool = attr.ib(False)
#: The resource type of the request. None if not supported on this backend.
- resource_type = attr.ib(None) # type: typing.Optional[ResourceType]
+ resource_type: Optional[ResourceType] = attr.ib(None)
def block(self) -> None:
"""Block this request."""
@@ -96,21 +88,20 @@ class Request:
Only some types of requests can be successfully redirected.
Improper use of this method can result in redirect loops.
- This method will throw a RedirectFailedException if the request was not
- possible.
+ This method will throw a RedirectException if the request was not possible.
Args:
url: The QUrl to try to redirect to.
"""
# Will be overridden if the backend supports redirection
- raise RedirectUnsupportedException("Unsupported backend.")
+ raise NotImplementedError
#: Type annotation for an interceptor function.
-InterceptorType = typing.Callable[[Request], None]
+InterceptorType = Callable[[Request], None]
-_interceptors = [] # type: typing.List[InterceptorType]
+_interceptors: List[InterceptorType] = []
def register(interceptor: InterceptorType) -> None:
diff --git a/qutebrowser/extensions/loader.py b/qutebrowser/extensions/loader.py
index 928f2856c..b6d86f517 100644
--- a/qutebrowser/extensions/loader.py
+++ b/qutebrowser/extensions/loader.py
@@ -19,12 +19,13 @@
"""Loader for qutebrowser extensions."""
-import importlib.abc
import pkgutil
import types
-import typing
import sys
import pathlib
+import importlib
+import argparse
+from typing import Callable, Iterator, List, Optional, Set, Tuple
import attr
@@ -35,9 +36,6 @@ from qutebrowser.config import config
from qutebrowser.utils import log, standarddir
from qutebrowser.misc import objects
-if typing.TYPE_CHECKING:
- import argparse
-
# ModuleInfo objects for all loaded plugins
_module_infos = []
@@ -48,9 +46,9 @@ class InitContext:
"""Context an extension gets in its init hook."""
- data_dir = attr.ib() # type: pathlib.Path
- config_dir = attr.ib() # type: pathlib.Path
- args = attr.ib() # type: argparse.Namespace
+ data_dir: pathlib.Path = attr.ib()
+ config_dir: pathlib.Path = attr.ib()
+ args: argparse.Namespace = attr.ib()
@attr.s
@@ -61,13 +59,11 @@ class ModuleInfo:
This gets used by qutebrowser.api.hook.
"""
- _ConfigChangedHooksType = typing.List[typing.Tuple[typing.Optional[str],
- typing.Callable]]
+ _ConfigChangedHooksType = List[Tuple[Optional[str], Callable]]
- skip_hooks = attr.ib(False) # type: bool
- init_hook = attr.ib(None) # type: typing.Optional[typing.Callable]
- config_changed_hooks = attr.ib(
- attr.Factory(list)) # type: _ConfigChangedHooksType
+ skip_hooks: bool = attr.ib(False)
+ init_hook: Optional[Callable] = attr.ib(None)
+ config_changed_hooks: _ConfigChangedHooksType = attr.ib(attr.Factory(list))
@attr.s
@@ -75,15 +71,15 @@ class ExtensionInfo:
"""Information about a qutebrowser extension."""
- name = attr.ib() # type: str
+ name: str = attr.ib()
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:
@@ -92,7 +88,7 @@ def load_components(*, skip_hooks: bool = False) -> None:
_load_component(info, skip_hooks=skip_hooks)
-def walk_components() -> typing.Iterator[ExtensionInfo]:
+def walk_components() -> Iterator[ExtensionInfo]:
"""Yield ExtensionInfo objects for all modules."""
if hasattr(sys, 'frozen'):
yield from _walk_pyinstaller()
@@ -104,12 +100,12 @@ def _on_walk_error(name: str) -> None:
raise ImportError("Failed to import {}".format(name))
-def _walk_normal() -> typing.Iterator[ExtensionInfo]:
+def _walk_normal() -> Iterator[ExtensionInfo]:
"""Walk extensions when not using PyInstaller."""
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:
@@ -117,7 +113,7 @@ def _walk_normal() -> typing.Iterator[ExtensionInfo]:
yield ExtensionInfo(name=name)
-def _walk_pyinstaller() -> typing.Iterator[ExtensionInfo]:
+def _walk_pyinstaller() -> Iterator[ExtensionInfo]:
"""Walk extensions when using PyInstaller.
See https://github.com/pyinstaller/pyinstaller/issues/1905
@@ -125,7 +121,7 @@ def _walk_pyinstaller() -> typing.Iterator[ExtensionInfo]:
Inspired by:
https://github.com/webcomics/dosage/blob/master/dosagelib/loader.py
"""
- toc = set() # type: typing.Set[str]
+ toc: Set[str] = set()
for importer in pkgutil.iter_importers('qutebrowser'):
if hasattr(importer, 'toc'):
toc |= importer.toc
diff --git a/qutebrowser/html/warning-old-qt.html b/qutebrowser/html/warning-old-qt.html
deleted file mode 100644
index 157d50714..000000000
--- a/qutebrowser/html/warning-old-qt.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{% 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/old-qt</span> to show it again at a later time.</span>
-
-<p>You're using qutebrowser with Qt {{qt_version}}.</p>
-
-<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>
-
-<p>Qt 5.8 has had various bugs, and has been unsupported (but working to some
-degree) in qutebrowser for a while.</p>
-
-<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
-<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..82bc02aab
--- /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. While this is the recommended Qt version to use (due to QtWebEngine security updates), qutebrowser only provides partial support for session files.</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 (October 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 v2.0.0 (planned to be released at the end of the year or early 2021).</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..975f98c1b 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>
+QtWebEngine. From the
+<a href="https://github.com/qtwebkit/qtwebkit/releases">QtWebKit release
+notes</a>:</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>
+<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>
@@ -71,7 +73,7 @@ installed.</p>
developers about QtWebEngine being "non-free" have repeatedly been disputed,
and so far nobody came up with solid evidence about that being the case. Also,
note that their qutebrowser package is usually very outdated (even qutebrowser
-security fixes took months to arrive there). You might be better off chosing an
+security fixes took months to arrive there). You might be better off choosing an
<a href="https://qutebrowser.org/doc/install.html#tox"> alternative install
method</a>.</p>
diff --git a/qutebrowser/javascript/.eslintrc.yaml b/qutebrowser/javascript/.eslintrc.yaml
index 4fdd43854..939500aa3 100644
--- a/qutebrowser/javascript/.eslintrc.yaml
+++ b/qutebrowser/javascript/.eslintrc.yaml
@@ -12,53 +12,52 @@
env:
browser: true
-
-parserOptions:
- ecmaVersion: 6
+ es6: true
extends:
"eslint:all"
rules:
- strict: ["error", "global"]
- one-var: "off"
- padded-blocks: ["error", "never"]
- space-before-function-paren: ["error", "never"]
- no-underscore-dangle: "off"
- camelcase: "off"
- require-jsdoc: "off"
- func-style: ["error", "declaration"]
- init-declarations: "off"
- no-plusplus: "off"
- no-extra-parens: off
- id-length: ["error", {"exceptions": ["i", "k", "x", "y"]}]
- object-shorthand: "off"
- max-statements: ["error", {"max": 40}]
- quotes: ["error", "double", {"avoidEscape": true}]
- object-property-newline: ["error", {"allowMultiplePropertiesPerLine": true}]
- comma-dangle: ["error", "always-multiline"]
- no-magic-numbers: "off"
- no-undefined: "off"
- wrap-iife: ["error", "inside"]
- func-names: "off"
- sort-keys: "off"
- no-warning-comments: "off"
- max-len: ["error", {"ignoreUrls": true}]
- capitalized-comments: "off"
- prefer-destructuring: "off"
- line-comment-position: "off"
- no-inline-comments: "off"
- array-bracket-newline: "off"
- array-element-newline: "off"
- no-multi-spaces: ["error", {"ignoreEOLComments": true}]
- function-paren-newline: "off"
- multiline-comment-style: "off"
- no-bitwise: "off"
- no-ternary: "off"
- max-lines: "off"
- multiline-ternary: ["error", "always-multiline"]
- max-lines-per-function: "off"
- require-unicode-regexp: "off"
- max-params: "off"
- prefer-named-capture-group: "off"
- function-call-argument-newline: "off"
+ strict: ["error", "global"]
+ one-var: "off"
+ padded-blocks: ["error", "never"]
+ space-before-function-paren: ["error", "never"]
+ no-underscore-dangle: "off"
+ camelcase: "off"
+ require-jsdoc: "off"
+ func-style: ["error", "declaration"]
+ init-declarations: "off"
+ no-plusplus: "off"
+ no-extra-parens: "off"
+ id-length: ["error", {"exceptions": ["i", "k", "v", "x", "y"]}]
+ object-shorthand: "off"
+ max-statements: ["error", {"max": 40}]
+ quotes: ["error", "double", {"avoidEscape": true}]
+ object-property-newline: ["error", {"allowMultiplePropertiesPerLine": true}]
+ comma-dangle: ["error", "always-multiline"]
+ no-magic-numbers: "off"
+ no-undefined: "off"
+ wrap-iife: ["error", "inside"]
+ func-names: "off"
+ sort-keys: "off"
+ no-warning-comments: "off"
+ max-len: ["error", {"ignoreUrls": true, "code": 88}]
+ capitalized-comments: "off"
+ prefer-destructuring: "off"
+ line-comment-position: "off"
+ no-inline-comments: "off"
+ array-bracket-newline: "off"
+ array-element-newline: "off"
+ no-multi-spaces: ["error", {"ignoreEOLComments": true}]
+ function-paren-newline: "off"
+ multiline-comment-style: "off"
+ no-bitwise: "off"
+ no-ternary: "off"
+ max-lines: "off"
+ multiline-ternary: ["error", "always-multiline"]
+ max-lines-per-function: "off"
+ require-unicode-regexp: "off"
+ 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..0e8de5e6e 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}
*/
@@ -762,13 +774,6 @@ window._qutebrowser.caret = (function() {
CaretBrowsing.isWindows = null;
/**
- * Whether we're running on on old Qt 5.7.1.
- * There, we need to use -webkit-filter.
- * @type {boolean}
- */
- CaretBrowsing.needsFilterPrefix = null;
-
- /**
* The id returned by window.setInterval for our stopAnimation function, so
* we can cancel it when we call stopAnimation again.
* @type {number?}
@@ -851,7 +856,6 @@ window._qutebrowser.caret = (function() {
};
CaretBrowsing.injectCaretStyles = function() {
- const prefix = CaretBrowsing.needsFilterPrefix ? "-webkit-" : "";
const style = `
.CaretBrowsing_Caret {
position: absolute;
@@ -863,7 +867,7 @@ window._qutebrowser.caret = (function() {
background-color: var(--inherited-color, #000);
color: var(--inherited-color, #000);
mix-blend-mode: difference;
- ${prefix}filter: invert(85%);
+ filter: invert(85%);
}
@keyframes blink {
50% { visibility: hidden; }
@@ -887,7 +891,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 +1153,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 +1211,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 +1222,10 @@ window._qutebrowser.caret = (function() {
window.
getSelection().
modify(action, boundary, "paragraphboundary");
+
+ if (CaretBrowsing.selectionState === CaretBrowsing.SelectionState.LINE) {
+ CaretBrowsing.selectLine();
+ }
}
};
@@ -1294,19 +1335,18 @@ 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) => {
CaretBrowsing.isWindows = flags.includes("windows");
- CaretBrowsing.needsFilterPrefix = flags.includes("filter-prefix");
};
funcs.disableCaret = () => {
@@ -1399,17 +1439,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/object_fromentries_quirk.user.js b/qutebrowser/javascript/object_fromentries_quirk.user.js
new file mode 100644
index 000000000..6f6ad8b31
--- /dev/null
+++ b/qutebrowser/javascript/object_fromentries_quirk.user.js
@@ -0,0 +1,46 @@
+// Based on: https://gitlab.com/moongoal/js-polyfill-object.fromentries/-/tree/master
+
+/*
+ Copyright 2018 Alfredo Mungo <alfredo.mungo@protonmail.ch>
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ IN THE SOFTWARE.
+*/
+
+"use strict";
+
+if (!Object.fromEntries) {
+ Object.defineProperty(Object, "fromEntries", {
+ value(entries) {
+ if (!entries || !entries[Symbol.iterator]) {
+ throw new Error(
+ "Object.fromEntries() requires a single iterable argument");
+ }
+
+ const obj = {};
+
+ Object.keys(entries).forEach((key) => {
+ const [k, v] = entries[key];
+ obj[k] = v;
+ });
+
+ return obj;
+ },
+ });
+}
+
diff --git a/qutebrowser/javascript/pac_utils.js b/qutebrowser/javascript/pac_utils.js
index 0aba4c070..f93c85a87 100644
--- a/qutebrowser/javascript/pac_utils.js
+++ b/qutebrowser/javascript/pac_utils.js
@@ -145,7 +145,7 @@ function dateRange() {
if (isGMT) {
argc--;
}
- // function will work even without explict handling of this case
+ // function will work even without explicit handling of this case
if (argc == 1) {
var tmp = parseInt(arguments[0]);
if (isNaN(tmp)) {
@@ -241,7 +241,7 @@ function timeRange() {
}
break;
default:
- throw 'timeRange: bad number of arguments'
+ throw 'timeRange: bad number of arguments';
}
}
diff --git a/qutebrowser/javascript/print.js b/qutebrowser/javascript/print.js
deleted file mode 100644
index ee38f1aa1..000000000
--- a/qutebrowser/javascript/print.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * Copyright 2018-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/>.
- */
-
-/*
- * this is a hack based on the QupZilla solution, https://github.com/QupZilla/qupzilla/commit/d3f0d766fb052dc504de2426d42f235d96b5eb60
- *
- * We go to a qute://print which triggers the print, then we cancel the request.
- */
-
-"use strict";
-
-window.print = function() {
- window.location = "qute://print";
-};
diff --git a/qutebrowser/javascript/webelem.js b/qutebrowser/javascript/webelem.js
index 7d3084670..86b41aef4 100644
--- a/qutebrowser/javascript/webelem.js
+++ b/qutebrowser/javascript/webelem.js
@@ -67,25 +67,6 @@ window._qutebrowser.webelem = (function() {
};
}
- function get_caret_position(elem, frame) {
- // With older Chromium versions (and QtWebKit), InvalidStateError will
- // be thrown if elem doesn't have selectionStart.
- // With newer Chromium versions (>= Qt 5.10), we get null.
- try {
- return elem.selectionStart;
- } catch (err) {
- if ((err instanceof DOMException ||
- (frame && err instanceof frame.DOMException)) &&
- err.name === "InvalidStateError") {
- // nothing to do, caret_position is already null
- } else {
- // not the droid we're looking for
- throw err;
- }
- }
- return null;
- }
-
function serialize_elem(elem, frame = null) {
if (!elem) {
return null;
@@ -94,12 +75,16 @@ window._qutebrowser.webelem = (function() {
const id = elements.length;
elements[id] = elem;
- const caret_position = get_caret_position(elem, frame);
+ const caret_position = elem.selectionStart;
+
+ // isContentEditable occasionally returns undefined.
+ const is_content_editable = elem.isContentEditable || false;
const out = {
"id": id,
"rects": [], // Gets filled up later
"caret_position": caret_position,
+ "is_content_editable": is_content_editable,
};
// Deal with various fun things which can happen in form elements
@@ -161,6 +146,25 @@ window._qutebrowser.webelem = (function() {
return out;
}
+ function is_hidden_css(elem) {
+ // Check if the element is hidden via CSS
+ const win = elem.ownerDocument.defaultView;
+ const style = win.getComputedStyle(elem, null);
+
+ const invisible = style.getPropertyValue("visibility") !== "visible";
+ const none_display = style.getPropertyValue("display") === "none";
+ const zero_opacity = style.getPropertyValue("opacity") === "0";
+
+ const is_framework = (
+ // ACE editor
+ elem.classList.contains("ace_text-input") ||
+ // bootstrap CSS
+ elem.classList.contains("custom-control-input")
+ );
+
+ return (invisible || none_display || (zero_opacity && !is_framework));
+ }
+
function is_visible(elem, frame = null) {
// Adopted from vimperator:
// https://github.com/vimperator/vimperator-labs/blob/vimperator-3.14.0/common/content/hints.js#L259-L285
@@ -168,7 +172,10 @@ window._qutebrowser.webelem = (function() {
// the cVim implementation here?
// https://github.com/1995eaton/chromium-vim/blob/1.2.85/content_scripts/dom.js#L74-L134
- const win = elem.ownerDocument.defaultView;
+ if (is_hidden_css(elem)) {
+ return false;
+ }
+
const offset_rect = get_frame_offset(frame);
let rect = add_offset_rect(elem.getBoundingClientRect(), offset_rect);
@@ -181,25 +188,7 @@ window._qutebrowser.webelem = (function() {
}
rect = elem.getClientRects()[0];
- if (!rect) {
- return false;
- }
-
- const style = win.getComputedStyle(elem, null);
- if (style.getPropertyValue("visibility") !== "visible" ||
- style.getPropertyValue("display") === "none" ||
- style.getPropertyValue("opacity") === "0") {
- // FIXME:qtwebengine do we need this <area> handling?
- // visibility and display style are misleading for area tags and
- // they get "display: none" by default.
- // See https://github.com/vimperator/vimperator-labs/issues/236
- if (elem.nodeName.toLowerCase() !== "area" &&
- !elem.classList.contains("ace_text-input")) {
- return false;
- }
- }
-
- return true;
+ return Boolean(rect);
}
// Returns true if the iframe is accessible without
diff --git a/qutebrowser/javascript/whatsapp_web_quirk.user.js b/qutebrowser/javascript/whatsapp_web_quirk.user.js
index b8979d15e..801d300e1 100644
--- a/qutebrowser/javascript/whatsapp_web_quirk.user.js
+++ b/qutebrowser/javascript/whatsapp_web_quirk.user.js
@@ -9,7 +9,9 @@
if (document.querySelector("a[href='https://support.google.com/chrome/answer/95414']")) {
navigator.serviceWorker.getRegistration().then((registration) => {
- registration.unregister();
+ if (registration) {
+ registration.unregister();
+ }
document.location.reload();
});
}
diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py
index 536a7e6ee..23b77cba1 100644
--- a/qutebrowser/keyinput/basekeyparser.py
+++ b/qutebrowser/keyinput/basekeyparser.py
@@ -21,12 +21,11 @@
import string
import types
-import typing
+from typing import Mapping, MutableMapping, Optional, Sequence
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
@@ -38,9 +37,9 @@ class MatchResult:
"""The result of matching a keybinding."""
- match_type = attr.ib() # type: QKeySequence.SequenceMatch
- command = attr.ib() # type: typing.Optional[str]
- sequence = attr.ib() # type: keyutils.KeySequence
+ match_type: QKeySequence.SequenceMatch = attr.ib()
+ command: Optional[str] = attr.ib()
+ sequence: keyutils.KeySequence = attr.ib()
def __attrs_post_init__(self) -> None:
if self.match_type == QKeySequence.ExactMatch:
@@ -76,9 +75,8 @@ class BindingTrie:
__slots__ = 'children', 'command'
def __init__(self) -> None:
- self.children = {
- } # type: typing.MutableMapping[keyutils.KeyInfo, BindingTrie]
- self.command = None # type: typing.Optional[str]
+ self.children: MutableMapping[keyutils.KeyInfo, BindingTrie] = {}
+ self.command: Optional[str] = None
def __setitem__(self, sequence: keyutils.KeySequence,
command: str) -> None:
@@ -97,7 +95,24 @@ class BindingTrie:
return utils.get_repr(self, children=self.children,
command=self.command)
- def update(self, mapping: typing.Mapping) -> None:
+ def __str__(self) -> str:
+ return '\n'.join(self.string_lines(blank=True))
+
+ def string_lines(self, indent: int = 0, blank: bool = False) -> 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: Mapping) -> None:
"""Add data from the given mapping to the trie."""
for key in mapping:
self[key] = mapping[key]
@@ -141,23 +156,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 +178,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 +210,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 +255,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 +340,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 992d9f4ce..d77c8702d 100644
--- a/qutebrowser/keyinput/eventfilter.py
+++ b/qutebrowser/keyinput/eventfilter.py
@@ -19,6 +19,8 @@
"""Global Qt event filter which dispatches key events."""
+from typing import cast
+
from PyQt5.QtCore import pyqtSlot, QObject, QEvent
from PyQt5.QtGui import QKeyEvent, QWindow
from PyQt5.QtWidgets import QApplication
@@ -100,7 +102,7 @@ class EventFilter(QObject):
handler = self._handlers[typ]
try:
- return handler(event)
+ return handler(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..bc8c4a5fe 100644
--- a/qutebrowser/keyinput/keyutils.py
+++ b/qutebrowser/keyinput/keyutils.py
@@ -32,7 +32,7 @@ handle what we actually think we do.
"""
import itertools
-import typing
+from typing import cast, overload, Iterable, Iterator, List, Mapping, Optional, Union
import attr
from PyQt5.QtCore import Qt, QEvent
@@ -53,113 +53,99 @@ _MODIFIER_MAP = {
_NIL_KEY = Qt.Key(0)
-
-def _build_special_names() -> typing.Mapping[Qt.Key, str]:
- """Build _SPECIAL_NAMES dict from the special_names_str mapping below.
-
- The reason we don't do this directly is that certain Qt versions don't have
- all the keys, so we want to ignore AttributeErrors.
- """
- special_names_str = {
- # Some keys handled in a weird way by QKeySequence::toString.
- # See https://bugreports.qt.io/browse/QTBUG-40030
- # Most are unlikely to be ever needed, but you never know ;)
- # For dead/combining keys, we return the corresponding non-combining
- # key, as that's easier to add to the config.
-
- 'Super_L': 'Super L',
- 'Super_R': 'Super R',
- 'Hyper_L': 'Hyper L',
- 'Hyper_R': 'Hyper R',
- 'Direction_L': 'Direction L',
- 'Direction_R': 'Direction R',
-
- 'Shift': 'Shift',
- 'Control': 'Control',
- 'Meta': 'Meta',
- 'Alt': 'Alt',
-
- 'AltGr': 'AltGr',
- 'Multi_key': 'Multi key',
- 'SingleCandidate': 'Single Candidate',
- 'Mode_switch': 'Mode switch',
- 'Dead_Grave': '`',
- 'Dead_Acute': '´',
- 'Dead_Circumflex': '^',
- 'Dead_Tilde': '~',
- 'Dead_Macron': '¯',
- 'Dead_Breve': '˘',
- 'Dead_Abovedot': '˙',
- 'Dead_Diaeresis': '¨',
- 'Dead_Abovering': '˚',
- 'Dead_Doubleacute': '˝',
- 'Dead_Caron': 'ˇ',
- 'Dead_Cedilla': '¸',
- 'Dead_Ogonek': '˛',
- 'Dead_Iota': 'Iota',
- 'Dead_Voiced_Sound': 'Voiced Sound',
- 'Dead_Semivoiced_Sound': 'Semivoiced Sound',
- 'Dead_Belowdot': 'Belowdot',
- 'Dead_Hook': 'Hook',
- 'Dead_Horn': 'Horn',
-
- 'Dead_Stroke': '\u0335', # '̵'
- 'Dead_Abovecomma': '\u0313', # '̓'
- 'Dead_Abovereversedcomma': '\u0314', # '̔'
- 'Dead_Doublegrave': '\u030f', # '̏'
- 'Dead_Belowring': '\u0325', # '̥'
- 'Dead_Belowmacron': '\u0331', # '̱'
- 'Dead_Belowcircumflex': '\u032d', # '̭'
- 'Dead_Belowtilde': '\u0330', # '̰'
- 'Dead_Belowbreve': '\u032e', # '̮'
- 'Dead_Belowdiaeresis': '\u0324', # '̤'
- 'Dead_Invertedbreve': '\u0311', # '̑'
- 'Dead_Belowcomma': '\u0326', # '̦'
- 'Dead_Currency': '¤',
- 'Dead_a': 'a',
- 'Dead_A': 'A',
- 'Dead_e': 'e',
- 'Dead_E': 'E',
- 'Dead_i': 'i',
- 'Dead_I': 'I',
- 'Dead_o': 'o',
- 'Dead_O': 'O',
- 'Dead_u': 'u',
- 'Dead_U': 'U',
- 'Dead_Small_Schwa': 'ə',
- 'Dead_Capital_Schwa': 'Ə',
- 'Dead_Greek': 'Greek',
- 'Dead_Lowline': '\u0332', # '̲'
- 'Dead_Aboveverticalline': '\u030d', # '̍'
- 'Dead_Belowverticalline': '\u0329',
- 'Dead_Longsolidusoverlay': '\u0338', # '̸'
-
- 'Memo': 'Memo',
- 'ToDoList': 'To Do List',
- 'Calendar': 'Calendar',
- 'ContrastAdjust': 'Contrast Adjust',
- 'LaunchG': 'Launch (G)',
- 'LaunchH': 'Launch (H)',
-
- 'MediaLast': 'Media Last',
-
- 'unknown': 'Unknown',
-
- # For some keys, we just want a different name
- 'Escape': 'Escape',
- }
- special_names = {_NIL_KEY: 'nil'}
-
- for k, v in special_names_str.items():
- try:
- special_names[getattr(Qt, 'Key_' + k)] = v
- except AttributeError: # pragma: no cover
- pass
-
- return special_names
-
-
-_SPECIAL_NAMES = _build_special_names()
+_ModifierType = Union[Qt.KeyboardModifier, Qt.KeyboardModifiers]
+
+
+_SPECIAL_NAMES = {
+ # Some keys handled in a weird way by QKeySequence::toString.
+ # See https://bugreports.qt.io/browse/QTBUG-40030
+ # Most are unlikely to be ever needed, but you never know ;)
+ # For dead/combining keys, we return the corresponding non-combining
+ # key, as that's easier to add to the config.
+
+ Qt.Key_Super_L: 'Super L',
+ Qt.Key_Super_R: 'Super R',
+ Qt.Key_Hyper_L: 'Hyper L',
+ Qt.Key_Hyper_R: 'Hyper R',
+ Qt.Key_Direction_L: 'Direction L',
+ Qt.Key_Direction_R: 'Direction R',
+
+ Qt.Key_Shift: 'Shift',
+ Qt.Key_Control: 'Control',
+ Qt.Key_Meta: 'Meta',
+ Qt.Key_Alt: 'Alt',
+
+ Qt.Key_AltGr: 'AltGr',
+ Qt.Key_Multi_key: 'Multi key',
+ Qt.Key_SingleCandidate: 'Single Candidate',
+ Qt.Key_Mode_switch: 'Mode switch',
+ Qt.Key_Dead_Grave: '`',
+ Qt.Key_Dead_Acute: '´',
+ Qt.Key_Dead_Circumflex: '^',
+ Qt.Key_Dead_Tilde: '~',
+ Qt.Key_Dead_Macron: '¯',
+ Qt.Key_Dead_Breve: '˘',
+ Qt.Key_Dead_Abovedot: '˙',
+ Qt.Key_Dead_Diaeresis: '¨',
+ Qt.Key_Dead_Abovering: '˚',
+ Qt.Key_Dead_Doubleacute: '˝',
+ Qt.Key_Dead_Caron: 'ˇ',
+ Qt.Key_Dead_Cedilla: '¸',
+ Qt.Key_Dead_Ogonek: '˛',
+ Qt.Key_Dead_Iota: 'Iota',
+ Qt.Key_Dead_Voiced_Sound: 'Voiced Sound',
+ Qt.Key_Dead_Semivoiced_Sound: 'Semivoiced Sound',
+ Qt.Key_Dead_Belowdot: 'Belowdot',
+ Qt.Key_Dead_Hook: 'Hook',
+ Qt.Key_Dead_Horn: 'Horn',
+
+ Qt.Key_Dead_Stroke: '\u0335', # '̵'
+ Qt.Key_Dead_Abovecomma: '\u0313', # '̓'
+ Qt.Key_Dead_Abovereversedcomma: '\u0314', # '̔'
+ Qt.Key_Dead_Doublegrave: '\u030f', # '̏'
+ Qt.Key_Dead_Belowring: '\u0325', # '̥'
+ Qt.Key_Dead_Belowmacron: '\u0331', # '̱'
+ Qt.Key_Dead_Belowcircumflex: '\u032d', # '̭'
+ Qt.Key_Dead_Belowtilde: '\u0330', # '̰'
+ Qt.Key_Dead_Belowbreve: '\u032e', # '̮'
+ Qt.Key_Dead_Belowdiaeresis: '\u0324', # '̤'
+ Qt.Key_Dead_Invertedbreve: '\u0311', # '̑'
+ Qt.Key_Dead_Belowcomma: '\u0326', # '̦'
+ Qt.Key_Dead_Currency: '¤',
+ Qt.Key_Dead_a: 'a',
+ Qt.Key_Dead_A: 'A',
+ Qt.Key_Dead_e: 'e',
+ Qt.Key_Dead_E: 'E',
+ Qt.Key_Dead_i: 'i',
+ Qt.Key_Dead_I: 'I',
+ Qt.Key_Dead_o: 'o',
+ Qt.Key_Dead_O: 'O',
+ Qt.Key_Dead_u: 'u',
+ Qt.Key_Dead_U: 'U',
+ Qt.Key_Dead_Small_Schwa: 'ə',
+ Qt.Key_Dead_Capital_Schwa: 'Ə',
+ Qt.Key_Dead_Greek: 'Greek',
+ Qt.Key_Dead_Lowline: '\u0332', # '̲'
+ Qt.Key_Dead_Aboveverticalline: '\u030d', # '̍'
+ Qt.Key_Dead_Belowverticalline: '\u0329',
+ Qt.Key_Dead_Longsolidusoverlay: '\u0338', # '̸'
+
+ Qt.Key_Memo: 'Memo',
+ Qt.Key_ToDoList: 'To Do List',
+ Qt.Key_Calendar: 'Calendar',
+ Qt.Key_ContrastAdjust: 'Contrast Adjust',
+ Qt.Key_LaunchG: 'Launch (G)',
+ Qt.Key_LaunchH: 'Launch (H)',
+
+ Qt.Key_MediaLast: 'Media Last',
+
+ Qt.Key_unknown: 'Unknown',
+
+ # For some keys, we just want a different name
+ Qt.Key_Escape: 'Escape',
+
+ _NIL_KEY: 'nil',
+}
def _assert_plain_key(key: Qt.Key) -> None:
@@ -167,9 +153,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 +164,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)
@@ -243,8 +215,7 @@ def _remap_unicode(key: Qt.Key, text: str) -> Qt.Key:
return key
-def _check_valid_utf8(s: str,
- data: typing.Union[Qt.Key, Qt.KeyboardModifier]) -> None:
+def _check_valid_utf8(s: str, data: 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 +225,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 +247,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 = ''
@@ -299,7 +271,7 @@ class KeyParseError(Exception):
"""Raised by _parse_single_key/parse_keystring on parse errors."""
- def __init__(self, keystr: typing.Optional[str], error: str) -> None:
+ def __init__(self, keystr: Optional[str], error: str) -> None:
if keystr is None:
msg = "Could not parse keystring: {}".format(error)
else:
@@ -307,7 +279,7 @@ class KeyParseError(Exception):
super().__init__(msg)
-def _parse_keystring(keystr: str) -> typing.Iterator[str]:
+def _parse_keystring(keystr: str) -> Iterator[str]:
key = ''
special = False
for c in keystr:
@@ -364,7 +336,7 @@ def _parse_single_key(keystr: str) -> str:
return 'Shift+' + keystr if keystr.isupper() else keystr
-@attr.s(frozen=True, hash=False)
+@attr.s(frozen=True)
class KeyInfo:
"""A key with optional modifiers.
@@ -374,8 +346,8 @@ class KeyInfo:
modifiers: A Qt::KeyboardModifiers enum value.
"""
- key = attr.ib() # type: Qt.Key
- modifiers = attr.ib() # type: Qt.KeyboardModifier
+ key: Qt.Key = attr.ib()
+ modifiers: _ModifierType = attr.ib()
@classmethod
def from_event(cls, e: QKeyEvent) -> 'KeyInfo':
@@ -388,15 +360,7 @@ class KeyInfo:
modifiers = e.modifiers()
_assert_plain_key(key)
_assert_plain_modifier(modifiers)
- return cls(key, modifiers)
-
- def __hash__(self) -> int:
- """Convert KeyInfo to int before hashing.
-
- This is needed as a WORKAROUND because enum members aren't hashable
- with PyQt 5.7.
- """
- return hash(self.to_int())
+ return cls(key, cast(Qt.KeyboardModifier, modifiers))
def __str__(self) -> str:
"""Convert this KeyInfo to a meaningful name.
@@ -451,7 +415,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
@@ -484,7 +448,7 @@ class KeySequence:
_MAX_LEN = 4
def __init__(self, *keys: int) -> None:
- self._sequences = [] # type: typing.List[QKeySequence]
+ self._sequences: List[QKeySequence] = []
for sub in utils.chunk(keys, self._MAX_LEN):
args = [self._convert_key(key) for key in sub]
sequence = QKeySequence(*args)
@@ -504,11 +468,11 @@ class KeySequence:
parts.append(str(info))
return ''.join(parts)
- def __iter__(self) -> typing.Iterator[KeyInfo]:
+ def __iter__(self) -> Iterator[KeyInfo]:
"""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)
@@ -546,17 +510,15 @@ class KeySequence:
def __bool__(self) -> bool:
return bool(self._sequences)
- @typing.overload
+ @overload
def __getitem__(self, item: int) -> KeyInfo:
...
- @typing.overload
+ @overload
def __getitem__(self, item: slice) -> 'KeySequence':
...
- def __getitem__(
- self, item: typing.Union[int, slice]
- ) -> typing.Union[KeyInfo, 'KeySequence']:
+ def __getitem__(self, item: Union[int, slice]) -> Union[KeyInfo, 'KeySequence']:
if isinstance(item, slice):
keys = list(self._iter_keys())
return self.__class__(*keys[item])
@@ -564,8 +526,9 @@ class KeySequence:
infos = list(self)
return infos[item]
- def _iter_keys(self) -> typing.Iterator[int]:
- return itertools.chain.from_iterable(self._sequences)
+ def _iter_keys(self) -> Iterator[int]:
+ sequences = cast(Iterable[Iterable[int]], self._sequences)
+ return itertools.chain.from_iterable(sequences)
def _validate(self, keystr: str = None) -> None:
for info in self:
@@ -646,19 +609,7 @@ class KeySequence:
if (modifiers == Qt.ShiftModifier and
_is_printable(key) and
not ev.text().isupper()):
- modifiers = Qt.KeyboardModifiers()
-
- # On macOS, swap Ctrl and Meta back
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-51293
- if utils.is_mac:
- if modifiers & Qt.ControlModifier and modifiers & Qt.MetaModifier:
- pass
- elif modifiers & Qt.ControlModifier:
- modifiers &= ~Qt.ControlModifier
- modifiers |= Qt.MetaModifier
- elif modifiers & Qt.MetaModifier:
- modifiers &= ~Qt.MetaModifier
- modifiers |= Qt.ControlModifier
+ modifiers = Qt.KeyboardModifiers() # type: ignore[assignment]
keys = list(self._iter_keys())
keys.append(key | int(modifiers))
@@ -673,7 +624,7 @@ class KeySequence:
def with_mappings(
self,
- mappings: typing.Mapping['KeySequence', 'KeySequence']
+ mappings: Mapping['KeySequence', 'KeySequence']
) -> 'KeySequence':
"""Get a new KeySequence with the given mappings applied."""
keys = []
diff --git a/qutebrowser/keyinput/macros.py b/qutebrowser/keyinput/macros.py
index 6e48e5a3f..ee8883070 100644
--- a/qutebrowser/keyinput/macros.py
+++ b/qutebrowser/keyinput/macros.py
@@ -20,7 +20,7 @@
"""Keyboard macro system."""
-import typing
+from typing import cast, Dict, List, Optional, Tuple
from qutebrowser.commands import runners
from qutebrowser.api import cmdutils
@@ -28,9 +28,9 @@ from qutebrowser.keyinput import modeman
from qutebrowser.utils import message, objreg, usertypes
-_CommandType = typing.Tuple[str, int] # command, type
+_CommandType = Tuple[str, int] # command, type
-macro_recorder = typing.cast('MacroRecorder', None)
+macro_recorder = cast('MacroRecorder', None)
class MacroRecorder:
@@ -47,10 +47,10 @@ class MacroRecorder:
"""
def __init__(self) -> None:
- self._macros = {} # type: typing.Dict[str, typing.List[_CommandType]]
- self._recording_macro = None # type: typing.Optional[str]
- self._macro_count = {} # type: typing.Dict[int, int]
- self._last_register = None # type: typing.Optional[str]
+ self._macros: Dict[str, List[_CommandType]] = {}
+ self._recording_macro: Optional[str] = None
+ self._macro_count: Dict[int, int] = {}
+ self._last_register: Optional[str] = None
@cmdutils.register(instance='macro-recorder', name='record-macro')
@cmdutils.argument('win_id', value=cmdutils.Value.win_id)
diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py
index 7e29ac3f5..dcc6fa949 100644
--- a/qutebrowser/keyinput/modeman.py
+++ b/qutebrowser/keyinput/modeman.py
@@ -17,10 +17,10 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-"""Mode manager singleton which handles the current keyboard mode."""
+"""Mode manager (per window) 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)
@@ -55,8 +54,8 @@ class KeyEvent:
text: A string (QKeyEvent::text).
"""
- key = attr.ib() # type: Qt.Key
- text = attr.ib() # type: str
+ key: Qt.Key = attr.ib()
+ text: str = attr.ib()
@classmethod
def from_event(cls, event: QKeyEvent) -> 'KeyEvent':
@@ -69,18 +68,28 @@ 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."""
+ commandrunner = runners.CommandRunner(win_id)
+
modeman = ModeManager(win_id, parent)
objreg.register('mode-manager', modeman, scope='window', window=win_id)
- commandrunner = runners.CommandRunner(win_id)
-
hintmanager = hints.HintManager(win_id, parent=parent)
objreg.register('hintmanager', hintmanager, scope='window',
window=win_id, command_only=True)
- keyparsers = {
+ modeman.hintmanager = hintmanager
+
+ keyparsers: ParserDictType = {
usertypes.KeyMode.normal:
modeparsers.NormalKeyParser(
win_id=win_id,
@@ -95,73 +104,89 @@ 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
+ }
for mode, parser in keyparsers.items():
modeman.register(mode, parser)
@@ -169,9 +194,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,
@@ -196,6 +229,7 @@ class ModeManager(QObject):
Attributes:
mode: The mode we're currently in.
+ hintmanager: The HintManager associated with this window.
_win_id: The window ID of this ModeManager
_prev_mode: Mode before a prompt popped up
parsers: A dictionary of modes and their keyparsers.
@@ -212,18 +246,25 @@ 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)
self._win_id = win_id
- self.parsers = {} # type: ParserDictType
+ self.parsers: 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[KeyEvent] = set()
+ # Set after __init__
+ self.hintmanager = cast(hints.HintManager, None)
def __repr__(self) -> str:
return utils.get_repr(self, mode=self.mode)
@@ -246,9 +287,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 +342,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,
@@ -355,7 +400,8 @@ class ModeManager(QObject):
raise cmdutils.CommandError("Mode {} does not exist!".format(mode))
if m in [usertypes.KeyMode.hint, usertypes.KeyMode.command,
- usertypes.KeyMode.yesno, usertypes.KeyMode.prompt]:
+ usertypes.KeyMode.yesno, usertypes.KeyMode.prompt,
+ usertypes.KeyMode.register]:
raise cmdutils.CommandError(
"Mode {} can't be entered manually!".format(mode))
@@ -411,14 +457,14 @@ class ModeManager(QObject):
Return:
True if event should be filtered, False otherwise.
"""
- handlers = {
+ handlers: Mapping[QEvent.Type, Callable[[QKeyEvent], bool]] = {
QEvent.KeyPress: self._handle_keypress,
QEvent.KeyRelease: self._handle_keyrelease,
QEvent.ShortcutOverride:
functools.partial(self._handle_keypress, dry_run=True),
- } # type: typing.Mapping[QEvent.Type, typing.Callable[[QEvent], 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..48f3594a5 100644
--- a/qutebrowser/keyinput/modeparsers.py
+++ b/qutebrowser/keyinput/modeparsers.py
@@ -23,9 +23,9 @@ Module attributes:
STARTCHARS: Possible chars for starting a commandline input.
"""
-import typing
import traceback
import enum
+from typing import TYPE_CHECKING, Sequence
from PyQt5.QtCore import pyqtSlot, Qt, QObject
from PyQt5.QtGui import QKeySequence, QKeyEvent
@@ -35,12 +35,20 @@ from qutebrowser.commands import cmdexc
from qutebrowser.config import config
from qutebrowser.keyinput import basekeyparser, keyutils, macros
from qutebrowser.utils import usertypes, log, message, objreg, utils
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.commands import runners
STARTCHARS = ":/?"
-LastPress = enum.Enum('LastPress', ['none', 'filtertext', 'keystring'])
+
+
+class LastPress(enum.Enum):
+
+ """Whether the last keypress filtered a text or was part of a keystring."""
+
+ none = enum.auto()
+ filtertext = enum.auto()
+ keystring = enum.auto()
class CommandKeyParser(basekeyparser.BaseKeyParser):
@@ -51,10 +59,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 +86,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 +144,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 +154,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 +210,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:
@@ -261,7 +232,7 @@ class HintKeyParser(CommandKeyParser):
return match
- def update_bindings(self, strings: typing.Sequence[str],
+ def update_bindings(self, strings: Sequence[str],
preserve_filter: bool = False) -> None:
"""Update bindings when the hint strings changed.
@@ -271,23 +242,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 +257,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 +287,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 03762766d..6273b3382 100644
--- a/qutebrowser/mainwindow/mainwindow.py
+++ b/qutebrowser/mainwindow/mainwindow.py
@@ -23,15 +23,16 @@ import binascii
import base64
import itertools
import functools
-import typing
+from typing import List, MutableSequence, Optional, Tuple, cast
-from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QRect, QPoint, QTimer, Qt,
- QCoreApplication, QEventLoop)
+from PyQt5.QtCore import (pyqtBoundSignal, pyqtSlot, QRect, QPoint, QTimer, Qt,
+ QCoreApplication, QEventLoop, QByteArray)
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QSizePolicy
+from PyQt5.QtGui import QPalette
from qutebrowser.commands import runners
from qutebrowser.api import cmdutils
-from qutebrowser.config import config, configfiles, stylesheet
+from qutebrowser.config import config, configfiles, stylesheet, websettings
from qutebrowser.utils import (message, log, usertypes, qtutils, objreg, utils,
jinja, debug)
from qutebrowser.mainwindow import messageview, prompt
@@ -39,54 +40,43 @@ from qutebrowser.completion import completionwidget, completer
from qutebrowser.keyinput import modeman
from qutebrowser.browser import commands, downloadview, hints, downloads
from qutebrowser.misc import crashsignal, keyhintwidget, sessions
+from qutebrowser.qt import sip
win_id_gen = itertools.count(0)
-def get_window(via_ipc, force_window=False, force_tab=False,
- force_target=None, no_raise=False):
+def get_window(*, via_ipc: bool,
+ target: str,
+ no_raise: bool = False) -> int:
"""Helper function for app.py to get a window id.
Args:
via_ipc: Whether the request was made via IPC.
- force_window: Whether to force opening in a window.
- force_tab: Whether to force opening in a tab.
- force_target: Override the new_instance_open_target config
+ target: Where/how to open the window (via setting, command-line or
+ override).
no_raise: suppress target window raising
Return:
ID of a window that was used to open URL
"""
- if force_window and force_tab:
- raise ValueError("force_window and force_tab are mutually exclusive!")
-
if not via_ipc:
# Initial main window
return 0
- open_target = config.val.new_instance_open_target
-
- # Apply any target overrides, ordered by precedence
- if force_target is not None:
- open_target = force_target
- if force_window:
- open_target = 'window'
- if force_tab and open_target == 'window':
- # Command sent via IPC
- open_target = 'tab-silent'
-
window = None
should_raise = False
# Try to find the existing tab target if opening in a tab
- if open_target != 'window':
+ if target not in {'window', 'private-window'}:
window = get_target_window()
- should_raise = open_target not in ['tab-silent', 'tab-bg-silent']
+ should_raise = target not in {'tab-silent', 'tab-bg-silent'}
+
+ is_private = target == 'private-window'
# Otherwise, or if no window was found, create a new one
if window is None:
- window = MainWindow(private=None)
+ window = MainWindow(private=is_private)
window.show()
should_raise = True
@@ -102,9 +92,12 @@ 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()
+
+ if not sip.isdeleted(window):
+ # Could be deleted by the events run above
+ window.activateWindow()
if alert:
QApplication.instance().alert(window)
@@ -112,23 +105,20 @@ def raise_window(window, alert=True):
def get_target_window():
"""Get the target window for new tabs, or None if none exist."""
+ getters = {
+ 'last-focused': objreg.last_focused_window,
+ 'first-opened': objreg.first_opened_window,
+ 'last-opened': objreg.last_opened_window,
+ 'last-visible': objreg.last_visible_window,
+ }
+ getter = getters[config.val.new_instance_open_target_window]
try:
- win_mode = config.val.new_instance_open_target_window
- if win_mode == 'last-focused':
- return objreg.last_focused_window()
- elif win_mode == 'first-opened':
- return objreg.window_by_index(0)
- elif win_mode == 'last-opened':
- return objreg.window_by_index(-1)
- elif win_mode == 'last-visible':
- return objreg.last_visible_window()
- else:
- raise ValueError("Invalid win_mode {}".format(win_mode))
+ return getter()
except objreg.NoWindow:
return None
-_OverlayInfoType = typing.Tuple[QWidget, pyqtSignal, bool, str]
+_OverlayInfoType = Tuple[QWidget, pyqtBoundSignal, bool, str]
class MainWindow(QWidget):
@@ -184,9 +174,21 @@ 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):
+ def __init__(self, *,
+ private: bool,
+ geometry: Optional[QByteArray] = None,
+ parent: Optional[QWidget] = None) -> None:
"""Create a new main window.
Args:
@@ -201,7 +203,11 @@ class MainWindow(QWidget):
from qutebrowser.mainwindow.statusbar import bar
self.setAttribute(Qt.WA_DeleteOnClose)
- self._overlays = [] # type: typing.MutableSequence[_OverlayInfoType]
+ if config.val.window.transparent:
+ self.setAttribute(Qt.WA_TranslucentBackground)
+ self.palette().setColor(QPalette.Window, Qt.transparent)
+
+ self._overlays: MutableSequence[_OverlayInfoType] = []
self.win_id = next(win_id_gen)
self.registry = objreg.ObjectRegistry()
objreg.window_registry[self.win_id] = self
@@ -211,10 +217,6 @@ class MainWindow(QWidget):
objreg.register('tab-registry', tab_registry, scope='window',
window=self.win_id)
- message_bridge = message.MessageBridge(self)
- objreg.register('message-bridge', message_bridge, scope='window',
- window=self.win_id)
-
self.setWindowTitle('qutebrowser')
self._vbox = QVBoxLayout(self)
self._vbox.setContentsMargins(0, 0, 0, 0)
@@ -224,15 +226,10 @@ class MainWindow(QWidget):
self._downloadview = downloadview.DownloadView(
model=self._download_model)
- if config.val.content.private_browsing:
- # This setting always trumps what's passed in.
- private = True
- else:
- private = bool(private)
- self._private = private
- self.tabbed_browser = tabbedbrowser.TabbedBrowser(win_id=self.win_id,
- private=private,
- parent=self)
+ self.is_private = config.val.content.private_browsing or private
+
+ self.tabbed_browser: tabbedbrowser.TabbedBrowser = tabbedbrowser.TabbedBrowser(
+ win_id=self.win_id, private=self.is_private, parent=self)
objreg.register('tabbed-browser', self.tabbed_browser, scope='window',
window=self.win_id)
self._init_command_dispatcher()
@@ -240,7 +237,8 @@ class MainWindow(QWidget):
# We need to set an explicit parent for StatusBar because it does some
# show/hide magic immediately which would mean it'd show up as a
# window.
- self.status = bar.StatusBar(win_id=self.win_id, private=private,
+ self.status = bar.StatusBar(win_id=self.win_id,
+ private=self.is_private,
parent=self)
self._add_widgets()
@@ -307,12 +305,17 @@ class MainWindow(QWidget):
if not widget.isVisible():
return
- size_hint = widget.sizeHint()
if widget.sizePolicy().horizontalPolicy() == QSizePolicy.Expanding:
width = self.width() - 2 * padding
+ if widget.hasHeightForWidth():
+ height = widget.heightForWidth(width)
+ else:
+ height = widget.sizeHint().height()
left = padding
else:
+ size_hint = widget.sizeHint()
width = min(size_hint.width(), self.width() - 2 * padding)
+ height = size_hint.height()
left = (self.width() - width) // 2 if centered else 0
height_padding = 20
@@ -324,7 +327,7 @@ class MainWindow(QWidget):
else:
status_height = 0
bottom = self.height()
- top = self.height() - status_height - size_hint.height()
+ top = self.height() - status_height - height
top = qtutils.check_overflow(top, 'int', fatal=False)
topleft = QPoint(left, max(height_padding, top))
bottomright = QPoint(left + width, bottom)
@@ -336,7 +339,7 @@ class MainWindow(QWidget):
status_height = 0
top = 0
topleft = QPoint(left, top)
- bottom = status_height + size_hint.height()
+ bottom = status_height + height
bottom = qtutils.check_overflow(bottom, 'int', fatal=False)
bottomright = QPoint(left + width,
min(self.height() - height_padding, bottom))
@@ -384,7 +387,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))
@@ -409,7 +414,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: List[QWidget] = [self.tabbed_browser.widget]
downloads_position = config.val.downloads.position
if downloads_position == 'top':
@@ -446,7 +451,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
@@ -473,13 +478,8 @@ class MainWindow(QWidget):
"""Set some sensible default geometry."""
self.setGeometry(QRect(50, 50, 800, 600))
- def _get_object(self, name):
- """Get an object for this window in the object registry."""
- return objreg.get(name, scope='window', window=self.win_id)
-
def _connect_signals(self):
"""Connect all mainwindow signals."""
- message_bridge = self._get_object('message-bridge')
mode_manager = modeman.instance(self.win_id)
# misc
@@ -487,29 +487,22 @@ class MainWindow(QWidget):
mode_manager.entered.connect(hints.on_mode_entered)
# status bar
+ mode_manager.hintmanager.set_text.connect(self.status.set_text)
mode_manager.entered.connect(self.status.on_mode_entered)
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
+ mode_manager.left.connect(message.global_bridge.mode_left)
# 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
- self._commandrunner.run_safely)
- self.status.cmd.got_cmd[str, int].connect( # type: ignore
- self._commandrunner.run_safely)
- self.status.cmd.returnPressed.connect(
- self.tabbed_browser.on_cmd_return_pressed)
- self.status.cmd.got_search.connect(
- self._command_dispatcher.search)
+ mode_manager.keystring_updated.connect(
+ self.status.keystring.on_keystring_updated)
+ self.status.cmd.got_cmd[str].connect(self._commandrunner.run_safely)
+ self.status.cmd.got_cmd[str, int].connect(self._commandrunner.run_safely)
+ self.status.cmd.returnPressed.connect(self.tabbed_browser.on_cmd_return_pressed)
+ self.status.cmd.got_search.connect(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(
@@ -518,10 +511,6 @@ class MainWindow(QWidget):
message.global_bridge.clear_messages.connect(
self._messageview.clear_messages)
- message_bridge.s_set_text.connect(self.status.set_text)
- message_bridge.s_maybe_reset_text.connect(
- self.status.txt.maybe_reset_text)
-
# statusbar
self.tabbed_browser.current_tab_changed.connect(
self.status.on_tab_changed)
@@ -570,11 +559,11 @@ class MainWindow(QWidget):
def _set_decoration(self, hidden):
"""Set the visibility of the window decoration via Qt."""
- window_flags = Qt.Window # type: int
+ window_flags: int = Qt.Window
refresh_window = self.isVisible()
if hidden:
window_flags |= Qt.CustomizeWindowHint | Qt.NoDropShadowWindowHint
- self.setWindowFlags(window_flags)
+ self.setWindowFlags(cast(Qt.WindowFlags, window_flags))
if refresh_window:
self.show()
@@ -583,8 +572,9 @@ class MainWindow(QWidget):
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(
@@ -671,13 +661,28 @@ class MainWindow(QWidget):
e.accept()
- try:
- last_visible = objreg.get('last-visible-main-window')
- if self is last_visible:
- objreg.delete('last-visible-main-window')
- except KeyError:
- pass
+ for key in ['last-visible-main-window', 'last-focused-main-window']:
+ try:
+ win = objreg.get(key)
+ if self is win:
+ objreg.delete(key)
+ except KeyError:
+ pass
sessions.session_manager.save_last_window_session()
self._save_geometry()
+
+ # Wipe private data if we close the last private window, but there are
+ # still other windows
+ if (
+ self.is_private and
+ len(objreg.window_registry) > 1 and
+ len([window for window in objreg.window_registry.values()
+ if window.is_private]) == 1
+ ):
+ log.destroy.debug("Wiping private data before closing last "
+ "private window")
+ websettings.clear_private_data()
+
log.destroy.debug("Closing window {}".format(self.win_id))
+ self.tabbed_browser.shutdown()
diff --git a/qutebrowser/mainwindow/messageview.py b/qutebrowser/mainwindow/messageview.py
index 2a20447ab..9c4b63084 100644
--- a/qutebrowser/mainwindow/messageview.py
+++ b/qutebrowser/mainwindow/messageview.py
@@ -19,9 +19,9 @@
"""Showing messages above the statusbar."""
-import typing
+from typing import MutableSequence
-from PyQt5.QtCore import pyqtSlot, pyqtSignal, QTimer, Qt, QSize
+from PyQt5.QtCore import pyqtSlot, pyqtSignal, QTimer, Qt
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QSizePolicy
from qutebrowser.config import config, stylesheet
@@ -36,6 +36,7 @@ class Message(QLabel):
super().__init__(text, parent)
self.replace = replace
self.setAttribute(Qt.WA_StyledBackground, True)
+ self.setWordWrap(True)
qss = """
padding-top: 2px;
padding-bottom: 2px;
@@ -64,8 +65,6 @@ class Message(QLabel):
"""
else: # pragma: no cover
raise ValueError("Invalid level {!r}".format(level))
- # We don't bother with set_register_stylesheet here as it's short-lived
- # anyways.
stylesheet.set_register(self, qss, update=False)
@@ -77,7 +76,7 @@ class MessageView(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
- self._messages = [] # type: typing.MutableSequence[Message]
+ self._messages: MutableSequence[Message] = []
self._vbox = QVBoxLayout(self)
self._vbox.setContentsMargins(0, 0, 0, 0)
self._vbox.setSpacing(0)
@@ -89,12 +88,6 @@ class MessageView(QWidget):
self._last_text = None
- def sizeHint(self):
- """Get the proposed height for the view."""
- height = sum(label.sizeHint().height() for label in self._messages)
- # The width isn't really relevant as we're expanding anyways.
- return QSize(-1, height)
-
@config.change_filter('messages.timeout')
def _set_clear_timer_interval(self):
"""Configure self._clear_timer according to the config."""
diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py
index bfe6fb812..42b6c3d97 100644
--- a/qutebrowser/mainwindow/prompt.py
+++ b/qutebrowser/mainwindow/prompt.py
@@ -23,7 +23,7 @@ import os.path
import html
import collections
import functools
-import typing
+from typing import Deque, MutableSequence, Optional, cast
import attr
from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QTimer, QDir, QModelIndex,
@@ -40,7 +40,7 @@ from qutebrowser.api import cmdutils
from qutebrowser.utils import urlmatch
-prompt_queue = typing.cast('PromptQueue', None)
+prompt_queue = cast('PromptQueue', None)
@attr.s
@@ -102,9 +102,8 @@ class PromptQueue(QObject):
super().__init__(parent)
self._question = None
self._shutting_down = False
- self._loops = [] # type: typing.MutableSequence[qtutils.EventLoop]
- self._queue = collections.deque(
- ) # type: typing.Deque[usertypes.Question]
+ self._loops: MutableSequence[qtutils.EventLoop] = []
+ self._queue: Deque[usertypes.Question] = collections.deque()
message.global_bridge.mode_left.connect(self._on_mode_left)
def __repr__(self):
@@ -192,12 +191,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 = 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))
@@ -288,7 +288,7 @@ class PromptContainer(QWidget):
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(10, 10, 10, 10)
self._win_id = win_id
- self._prompt = None # type: typing.Optional[_BasePrompt]
+ self._prompt: Optional[_BasePrompt] = None
self.setObjectName('PromptContainer')
self.setAttribute(Qt.WA_StyledBackground, True)
@@ -329,7 +329,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
@@ -590,6 +590,7 @@ class LineEditPrompt(_BasePrompt):
self._vbox.addWidget(self._lineedit)
if question.default:
self._lineedit.setText(question.default)
+ self._lineedit.selectAll()
self.setFocusProxy(self._lineedit)
self._init_key_label()
@@ -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)
@@ -793,8 +794,7 @@ class DownloadFilenamePrompt(FilenamePrompt):
def download_open(self, cmdline, pdfjs):
if pdfjs:
- target = downloads.PDFJSDownloadTarget(
- ) # type: downloads._DownloadTarget
+ target: 'downloads._DownloadTarget' = downloads.PDFJSDownloadTarget()
else:
target = downloads.OpenFileDownloadTarget(cmdline)
@@ -960,5 +960,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..821ea030b 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
@@ -30,8 +31,7 @@ from qutebrowser.keyinput import modeman
from qutebrowser.utils import usertypes, log, objreg, utils
from qutebrowser.mainwindow.statusbar import (backforward, command, progress,
keystring, percentage, url,
- tabindex)
-from qutebrowser.mainwindow.statusbar import text as textwidget
+ tabindex, textbase)
@attr.s
@@ -48,7 +48,14 @@ class ColorFlags:
passthrough: If we're currently in passthrough-mode.
"""
- CaretMode = enum.Enum('CaretMode', ['off', 'on', 'selection'])
+ class CaretMode(enum.Enum):
+
+ """The current caret "sub-mode" we're in."""
+
+ off = enum.auto()
+ on = enum.auto()
+ selection = enum.auto()
+
prompt = attr.ib(False)
insert = attr.ib(False)
command = attr.ib(False)
@@ -179,7 +186,7 @@ class StatusBar(QWidget):
objreg.register('status-command', self.cmd, scope='window',
window=win_id)
- self.txt = textwidget.Text()
+ self.txt = textbase.TextBase()
self._stack.addWidget(self.txt)
self.cmd.show_cmd.connect(self._show_cmd_widget)
@@ -202,7 +209,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 +260,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
@@ -313,7 +334,7 @@ class StatusBar(QWidget):
else:
suffix = ''
text = "-- {} MODE --{}".format(mode.upper(), suffix)
- self.txt.set_text(self.txt.Text.normal, text)
+ self.txt.setText(text)
def _show_cmd_widget(self):
"""Show command widget instead of temporary text."""
@@ -327,14 +348,17 @@ class StatusBar(QWidget):
self.maybe_hide()
@pyqtSlot(str)
- def set_text(self, val):
+ def set_text(self, text):
"""Set a normal (persistent) text in the status bar."""
- self.txt.set_text(self.txt.Text.normal, val)
+ log.message.debug(text)
+ self.txt.setText(text)
@pyqtSlot(usertypes.KeyMode)
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,11 +373,13 @@ 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)
else:
- self.txt.set_text(self.txt.Text.normal, '')
+ self.txt.setText('')
if old_mode in [usertypes.KeyMode.insert,
usertypes.KeyMode.command,
usertypes.KeyMode.caret,
@@ -372,13 +398,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..da48d1fbd 100644
--- a/qutebrowser/mainwindow/statusbar/command.py
+++ b/qutebrowser/mainwindow/statusbar/command.py
@@ -71,9 +71,8 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit):
self.history.changed.connect(command_history.changed)
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Ignored)
- self.cursorPositionChanged.connect(
- self.update_completion) # type: ignore
- self.textChanged.connect(self.update_completion) # type: ignore
+ self.cursorPositionChanged.connect(self.update_completion)
+ self.textChanged.connect(self.update_completion)
self.textChanged.connect(self.updateGeometry)
self.textChanged.connect(self._incremental_search)
@@ -148,7 +147,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)
else:
self.set_cmd_text(text)
@@ -198,7 +197,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:])
@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/percentage.py b/qutebrowser/mainwindow/statusbar/percentage.py
index 90eaecf1a..cffd2d629 100644
--- a/qutebrowser/mainwindow/statusbar/percentage.py
+++ b/qutebrowser/mainwindow/statusbar/percentage.py
@@ -23,6 +23,7 @@ from PyQt5.QtCore import pyqtSlot, Qt
from qutebrowser.mainwindow.statusbar import textbase
from qutebrowser.misc import throttle
+from qutebrowser.utils import utils
class Percentage(textbase.TextBase):
@@ -47,13 +48,14 @@ class Percentage(textbase.TextBase):
return strings
@pyqtSlot(int, int)
- def set_perc(self, x, y): # pylint: disable=unused-argument
+ def set_perc(self, x, y):
"""Setter to be used as a Qt slot.
Args:
x: The x percentage (int), currently ignored.
y: The y percentage (int)
"""
+ utils.unused(x)
self._set_text(self._strings.get(y, '[???]'))
def on_tab_changed(self, tab):
diff --git a/qutebrowser/mainwindow/statusbar/text.py b/qutebrowser/mainwindow/statusbar/text.py
deleted file mode 100644
index 449836740..000000000
--- a/qutebrowser/mainwindow/statusbar/text.py
+++ /dev/null
@@ -1,82 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2014-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/>.
-
-"""Text displayed in the statusbar."""
-
-import enum
-
-from PyQt5.QtCore import pyqtSlot
-
-from qutebrowser.mainwindow.statusbar import textbase
-from qutebrowser.utils import log
-
-
-class Text(textbase.TextBase):
-
- """Text displayed in the statusbar.
-
- Attributes:
- _normaltext: The "permanent" text. Never automatically cleared.
- _temptext: The temporary text to display.
-
- The temptext is shown from StatusBar when a temporary text or error is
- available. If not, the permanent text is shown.
- """
-
- Text = enum.Enum('Text', ['normal', 'temp'])
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._normaltext = ''
- self._temptext = ''
-
- def set_text(self, which, text):
- """Set a text.
-
- Args:
- which: Which text to set, a self.Text instance.
- text: The text to set.
- """
- log.statusbar.debug("Setting {} text to '{}'.".format(
- which.name, text))
- if which is self.Text.normal:
- self._normaltext = text
- elif which is self.Text.temp:
- self._temptext = text
- else:
- raise ValueError("Invalid value {} for which!".format(which))
- self.update_text()
-
- @pyqtSlot(str)
- def maybe_reset_text(self, text):
- """Clear a normal text if it still matches an expected text."""
- if self._normaltext == text:
- log.statusbar.debug("Resetting: '{}'".format(text))
- self.set_text(self.Text.normal, '')
- else:
- log.statusbar.debug("Ignoring reset: '{}'".format(text))
-
- def update_text(self):
- """Update QLabel text when needed."""
- if self._temptext:
- self.setText(self._temptext)
- elif self._normaltext:
- self.setText(self._normaltext)
- else:
- self.setText('')
diff --git a/qutebrowser/mainwindow/statusbar/url.py b/qutebrowser/mainwindow/statusbar/url.py
index b48f0005f..db8905345 100644
--- a/qutebrowser/mainwindow/statusbar/url.py
+++ b/qutebrowser/mainwindow/statusbar/url.py
@@ -21,16 +21,27 @@
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
from qutebrowser.utils import usertypes, urlutils
-# Note this has entries for success/error/warn from widgets.webview:LoadStatus
-UrlType = enum.Enum('UrlType', ['success', 'success_https', 'error', 'warn',
- 'hover', 'normal'])
+class UrlType(enum.Enum):
+
+ """The type/color of the URL being shown.
+
+ Note this has entries for success/error/warn from widgets.webview:LoadStatus.
+ """
+
+ success = enum.auto()
+ success_https = enum.auto()
+ error = enum.auto()
+ warn = enum.auto()
+ hover = enum.auto()
+ normal = enum.auto()
class UrlText(textbase.TextBase):
diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py
index f5dc3277b..78eb864a6 100644
--- a/qutebrowser/mainwindow/tabbedbrowser.py
+++ b/qutebrowser/mainwindow/tabbedbrowser.py
@@ -22,12 +22,13 @@
import collections
import functools
import weakref
-import typing
+import datetime
+from typing import (
+ Any, Deque, List, Mapping, MutableMapping, MutableSequence, Optional, Tuple)
import attr
from PyQt5.QtWidgets import QSizePolicy, QWidget, QApplication
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QTimer, QUrl
-from PyQt5.QtGui import QIcon
from qutebrowser.config import config
from qutebrowser.keyinput import modeman
@@ -39,7 +40,7 @@ from qutebrowser.misc import quitter
@attr.s
-class UndoEntry:
+class _UndoEntry:
"""Information needed for :undo."""
@@ -47,6 +48,7 @@ class UndoEntry:
history = attr.ib()
index = attr.ib()
pinned = attr.ib()
+ created_at = attr.ib(attr.Factory(datetime.datetime.now))
class TabDeque:
@@ -64,12 +66,13 @@ class TabDeque:
"""
def __init__(self) -> None:
- self._stack = collections.deque(
- maxlen=config.val.tabs.focus_stack_size
- ) # type: typing.Deque[weakref.ReferenceType[QWidget]]
+ size = config.val.tabs.focus_stack_size
+ if size < 0:
+ size = None
+ self._stack: Deque[weakref.ReferenceType[QWidget]] = collections.deque(
+ maxlen=size)
# Items that have been removed from the primary stack.
- self._stack_deleted = [
- ] # type: typing.List[weakref.ReferenceType[QWidget]]
+ self._stack_deleted: List[weakref.ReferenceType[QWidget]] = []
self._ignore_next = False
self._keep_deleted_next = False
@@ -90,19 +93,19 @@ class TabDeque:
Throws IndexError on failure.
"""
- tab = None # type: typing.Optional[QWidget]
+ tab: Optional[QWidget] = None
while tab is None or tab.pending_removal or tab is cur_tab:
tab = self._stack.pop()()
self._stack_deleted.append(weakref.ref(cur_tab))
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.
"""
- tab = None # type: typing.Optional[QWidget]
+ tab: Optional[QWidget] = None
while tab is None or tab.pending_removal or tab is cur_tab:
tab = self._stack_deleted.pop()()
# On next tab-switch, current tab will be added to stack as normal.
@@ -156,8 +159,8 @@ class TabbedBrowser(QWidget):
_tab_insert_idx_left: Where to insert a new tab with
tabs.new_tab_position set to 'prev'.
_tab_insert_idx_right: Same as above, for 'next'.
- _undo_stack: List of lists of UndoEntry objects of closed tabs.
- shutting_down: Whether we're currently shutting down.
+ undo_stack: List of lists of _UndoEntry objects of closed tabs.
+ is_shutting_down: Whether we're currently shutting down.
_local_marks: Jump markers local to each page
_global_marks: Jump markers used across all pages
default_window_icon: The qutebrowser window icon
@@ -179,6 +182,7 @@ class TabbedBrowser(QWidget):
arg: The new size.
current_tab_changed: The current tab changed to the emitted tab.
new_tab: Emits the new WebView and its index when a new tab is opened.
+ shutting_down: This TabbedBrowser will be deleted soon.
"""
cur_progress = pyqtSignal(int)
@@ -189,11 +193,12 @@ 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)
new_tab = pyqtSignal(browsertab.AbstractTab, int)
+ shutting_down = pyqtSignal()
def __init__(self, *, win_id, private, parent=None):
if private:
@@ -203,35 +208,28 @@ class TabbedBrowser(QWidget):
self._win_id = win_id
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.is_shutting_down = False
+ self.widget.tabCloseRequested.connect(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.currentChanged.connect(self._on_current_changed)
self.cur_fullscreen_requested.connect(self.widget.tabBar().maybe_hide)
self.widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-65223
- if qtutils.version_check('5.10', compiled=False):
- self.cur_load_finished.connect(self._leave_modes_on_load)
- else:
- self.cur_load_started.connect(self._leave_modes_on_load)
+ # load_finished instead of load_started as WORKAROUND for
+ # https://bugreports.qt.io/browse/QTBUG-65223
+ self.cur_load_finished.connect(self._leave_modes_on_load)
# This init is never used, it is immediately thrown away in the next
# line.
- self._undo_stack = (
- collections.deque()
- ) # type: typing.MutableSequence[typing.MutableSequence[UndoEntry]]
+ self.undo_stack: MutableSequence[MutableSequence[_UndoEntry]] = (
+ collections.deque())
self._update_stack_size()
self._filter = signalfilter.SignalFilter(win_id, self)
self._now_focused = None
self.search_text = None
- self.search_options = {} # type: typing.Mapping[str, typing.Any]
- self._local_marks = {
- } # type: typing.MutableMapping[QUrl, typing.MutableMapping[str, int]]
- self._global_marks = {
- } # type: typing.MutableMapping[str, typing.Tuple[int, QUrl]]
+ self.search_options: Mapping[str, Any] = {}
+ self._local_marks: MutableMapping[QUrl, MutableMapping[str, int]] = {}
+ self._global_marks: MutableMapping[str, Tuple[int, QUrl]] = {}
self.default_window_icon = self.widget.window().windowIcon()
self.is_private = private
self.tab_deque = TabDeque()
@@ -243,7 +241,7 @@ class TabbedBrowser(QWidget):
if newsize < 0:
newsize = None
# We can't resize a collections.deque so just recreate it >:(
- self._undo_stack = collections.deque(self._undo_stack, maxlen=newsize)
+ self.undo_stack = collections.deque(self.undo_stack, maxlen=newsize)
def __repr__(self):
return utils.get_repr(self, count=self.widget.count())
@@ -286,7 +284,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)
@@ -345,6 +343,8 @@ class TabbedBrowser(QWidget):
functools.partial(self._on_title_changed, tab))
tab.icon_changed.connect(
functools.partial(self._on_icon_changed, tab))
+ tab.pinned_changed.connect(
+ functools.partial(self._on_pinned_changed, tab))
tab.load_progress.connect(
functools.partial(self._on_load_progress, tab))
tab.load_finished.connect(
@@ -379,12 +379,13 @@ class TabbedBrowser(QWidget):
def shutdown(self):
"""Try to shut down all tabs cleanly."""
- self.shutting_down = True
- # Reverse tabs so we don't have to recacluate tab titles over and over
+ self.is_shutting_down = True
+ # Reverse tabs so we don't have to recalculate tab titles over and over
# Removing first causes [2..-1] to be recomputed
# Removing the last causes nothing to be recomputed
- for tab in reversed(self.widgets()):
- self._remove_tab(tab)
+ for idx, tab in enumerate(reversed(self.widgets())):
+ self._remove_tab(tab, new_undo=idx == 0)
+ self.shutting_down.emit()
def tab_close_prompt_if_pinned(
self, tab, force, yes_action,
@@ -410,7 +411,11 @@ class TabbedBrowser(QWidget):
add_undo: Whether the tab close can be undone.
new_undo: Whether the undo entry should be a new item in the stack.
"""
- last_close = config.val.tabs.last_close
+ if config.val.tabs.tabs_are_windows:
+ last_close = 'close'
+ else:
+ last_close = config.val.tabs.last_close
+
count = self.widget.count()
if last_close == 'ignore' and count == 1:
@@ -466,28 +471,30 @@ class TabbedBrowser(QWidget):
except browsertab.WebTabError:
pass # special URL
else:
- entry = UndoEntry(tab.url(), history_data, idx,
- tab.data.pinned)
- if new_undo or not self._undo_stack:
- self._undo_stack.append([entry])
+ entry = _UndoEntry(url=tab.url(),
+ history=history_data,
+ index=idx,
+ pinned=tab.data.pinned)
+ if new_undo or not self.undo_stack:
+ self.undo_stack.append([entry])
else:
- self._undo_stack[-1].append(entry)
+ self.undo_stack[-1].append(entry)
tab.private_api.shutdown()
self.widget.removeTab(idx)
- if not crashed:
- # WORKAROUND for a segfault when we delete the crashed tab.
- # see https://bugreports.qt.io/browse/QTBUG-58698
- tab.layout().unwrap()
- tab.deleteLater()
- def undo(self):
+ tab.deleteLater()
+
+ def undo(self, depth=1):
"""Undo removing of a tab or tabs."""
# Remove unused tab which may be created after the last tab is closed
last_close = config.val.tabs.last_close
use_current_tab = False
- if last_close in ['blank', 'startpage', 'default-page']:
- only_one_tab_open = self.widget.count() == 1
+ last_close_replaces = last_close in [
+ 'blank', 'startpage', 'default-page'
+ ]
+ only_one_tab_open = self.widget.count() == 1
+ if only_one_tab_open and last_close_replaces:
no_history = len(self.widget.widget(0).history) == 1
urls = {
'blank': QUrl('about:blank'),
@@ -501,18 +508,18 @@ class TabbedBrowser(QWidget):
use_current_tab = (only_one_tab_open and no_history and
last_close_url_used)
- for entry in reversed(self._undo_stack.pop()):
+ entries = self.undo_stack[-depth]
+ del self.undo_stack[-depth]
+
+ for entry in reversed(entries):
if use_current_tab:
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)
+ newtab.set_pinned(entry.pinned)
@pyqtSlot('QUrl', bool)
def load_url(self, url, newtab):
@@ -533,7 +540,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(
@@ -619,9 +626,6 @@ class TabbedBrowser(QWidget):
self.widget.currentWidget().setFocus()
else:
self.widget.setCurrentWidget(tab)
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-68076
- # Still seems to be needed with Qt 5.11.1
- tab.setFocus()
mode = modeman.instance(self._win_id).mode
if mode in [usertypes.KeyMode.command, usertypes.KeyMode.prompt,
@@ -770,26 +774,21 @@ class TabbedBrowser(QWidget):
if not self.widget.page_title(idx):
self.widget.set_page_title(idx, url.toDisplayString())
- @pyqtSlot(browsertab.AbstractTab, QIcon)
- def _on_icon_changed(self, tab, icon):
+ @pyqtSlot(browsertab.AbstractTab)
+ def _on_icon_changed(self, tab):
"""Set the icon of a tab.
Slot for the iconChanged signal of any tab.
Args:
tab: The WebView where the title was changed.
- icon: The new icon
"""
- if not tab.data.should_show_icon():
- return
try:
- idx = self._tab_index(tab)
+ self._tab_index(tab)
except TabDeletedError:
# We can get signals for tabs we already deleted...
return
- self.widget.setTabIcon(idx, icon)
- if config.val.tabs.tabs_are_windows:
- self.widget.window().setWindowIcon(icon)
+ self.widget.update_tab_favicon(tab)
@pyqtSlot(usertypes.KeyMode)
def on_mode_entered(self, mode):
@@ -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))
@@ -817,12 +816,12 @@ class TabbedBrowser(QWidget):
def _on_current_changed(self, idx):
"""Add prev tab to stack and leave hinting mode when focus changed."""
mode_on_change = config.val.tabs.mode_on_change
- if idx == -1 or self.shutting_down:
+ if idx == -1 or self.is_shutting_down:
# closing the last tab (before quitting) or shutting down
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
@@ -868,7 +867,7 @@ class TabbedBrowser(QWidget):
start = config.cache['colors.tabs.indicator.start']
stop = config.cache['colors.tabs.indicator.stop']
system = config.cache['colors.tabs.indicator.system']
- color = utils.interpolate_color(start, stop, perc, system)
+ color = qtutils.interpolate_color(start, stop, perc, system)
self.widget.set_tab_indicator_color(idx, color)
self.widget.update_tab_title(idx)
if idx == self.widget.currentIndex():
@@ -885,7 +884,7 @@ class TabbedBrowser(QWidget):
start = config.cache['colors.tabs.indicator.start']
stop = config.cache['colors.tabs.indicator.stop']
system = config.cache['colors.tabs.indicator.system']
- color = utils.interpolate_color(start, stop, 100, system)
+ color = qtutils.interpolate_color(start, stop, 100, system)
else:
color = config.cache['colors.tabs.indicator.error']
self.widget.set_tab_indicator_color(idx, color)
@@ -904,6 +903,12 @@ class TabbedBrowser(QWidget):
self._update_window_title('scroll_pos')
self.widget.update_tab_title(idx, 'scroll_pos')
+ def _on_pinned_changed(self, tab):
+ """Update the tab's pinned status."""
+ idx = self.widget.indexOf(tab)
+ self.widget.update_tab_favicon(tab)
+ self.widget.update_tab_title(idx)
+
def _on_audio_changed(self, tab, _muted):
"""Update audio field in tab when mute or recentlyAudible changed."""
try:
@@ -936,18 +941,11 @@ class TabbedBrowser(QWidget):
tab.set_html(html)
log.webview.error(msg)
- if qtutils.version_check('5.9', compiled=False):
- url_string = tab.url(requested=True).toDisplayString()
- error_page = jinja.render(
- 'error.html', title="Error loading {}".format(url_string),
- url=url_string, error=msg)
- QTimer.singleShot(100, lambda: show_error_page(error_page))
- else:
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-58698
- message.error(msg)
- self._remove_tab(tab, crashed=True)
- if self.widget.count() == 0:
- self.tabopen(QUrl('about:blank'))
+ url_string = tab.url(requested=True).toDisplayString()
+ error_page = jinja.render(
+ 'error.html', title="Error loading {}".format(url_string),
+ url=url_string, error=msg)
+ QTimer.singleShot(100, lambda: show_error_page(error_page))
def resizeEvent(self, e):
"""Extend resizeEvent of QWidget to emit a resized signal afterwards.
diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py
index ca4014b28..5263ecff9 100644
--- a/qutebrowser/mainwindow/tabwidget.py
+++ b/qutebrowser/mainwindow/tabwidget.py
@@ -19,9 +19,9 @@
"""The tab widget used for TabbedBrowser from browser.py."""
-import typing
import functools
import contextlib
+from typing import Optional, cast
import attr
from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QSize, QRect, QPoint,
@@ -60,10 +60,10 @@ 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)
+ 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 +82,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()
@@ -97,19 +98,6 @@ class TabWidget(QTabWidget):
bar.set_tab_data(idx, 'indicator-color', color)
bar.update(bar.tabRect(idx))
- def set_tab_pinned(self, tab: QWidget,
- pinned: bool) -> None:
- """Set the tab status as pinned.
-
- Args:
- tab: The tab to pin
- pinned: Pinned tab state to set.
- """
- idx = self.indexOf(tab)
- tab.data.pinned = pinned
- self.update_tab_favicon(tab)
- self.update_tab_title(idx)
-
def tab_indicator_color(self, idx):
"""Get the tab indicator color for the given index."""
return self.tabBar().tab_indicator_color(idx)
@@ -137,6 +125,7 @@ class TabWidget(QTabWidget):
field: A field name which was updated. If given, the title
is only set if the given field is in the template.
"""
+ assert idx != -1
tab = self.widget(idx)
if tab.data.pinned:
fmt = config.cache['tabs.title.format_pinned']
@@ -150,6 +139,7 @@ class TabWidget(QTabWidget):
fields = self.get_tab_fields(idx)
fields['current_title'] = fields['current_title'].replace('&', '&&')
fields['index'] = idx + 1
+ fields['aligned_index'] = str(idx + 1).rjust(len(str(self.count())))
title = '' if fmt is None else fmt.format(**fields)
tabbar = self.tabBar()
@@ -163,7 +153,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)
@@ -222,7 +212,7 @@ class TabWidget(QTabWidget):
Every single call to setTabText calls the size hinting functions for
every single tab, which are slow. Since we know we are updating all
the tab's titles, we can delay this processing by making the tab
- non-visible. To avoid flickering, disable repaint updates whlie we
+ non-visible. To avoid flickering, disable repaint updates while we
work.
"""
bar = self.tabBar()
@@ -330,7 +320,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.
@@ -341,19 +331,15 @@ class TabWidget(QTabWidget):
"""Update favicon of the given tab."""
idx = self.indexOf(tab)
- if tab.data.should_show_icon():
- self.setTabIcon(idx, tab.icon())
- if config.val.tabs.tabs_are_windows:
- self.window().setWindowIcon(tab.icon())
- else:
- self.setTabIcon(idx, QIcon())
- if config.val.tabs.tabs_are_windows:
- self.window().setWindowIcon(self.window().windowIcon())
+ icon = tab.icon() if tab.data.should_show_icon() else QIcon()
+ self.setTabIcon(idx, icon)
+
+ if config.val.tabs.tabs_are_windows:
+ self.window().setWindowIcon(tab.icon())
- 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))
+ tab = cast(Optional[browsertab.AbstractTab], self.widget(idx))
if (icon.isNull() and
config.cache['tabs.favicons.show'] != 'never' and
config.cache['tabs.pinned.shrink'] and
@@ -383,8 +369,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 +384,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 +392,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 +406,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 +424,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 +499,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 +531,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)
@@ -652,7 +639,7 @@ class TabBar(QTabBar):
main_window = objreg.get('main-window', scope='window',
window=self._win_id)
perc = int(confwidth.rstrip('%'))
- width = main_window.width() * perc / 100
+ width = main_window.width() * perc // 100
else:
width = int(confwidth)
size = QSize(width, height)
diff --git a/qutebrowser/mainwindow/windowundo.py b/qutebrowser/mainwindow/windowundo.py
new file mode 100644
index 000000000..af7b2766a
--- /dev/null
+++ b/qutebrowser/mainwindow/windowundo.py
@@ -0,0 +1,91 @@
+# 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/>.
+
+"""Code for :undo --window."""
+
+import collections
+from typing import MutableSequence, cast
+
+import attr
+from PyQt5.QtCore import QObject
+from PyQt5.QtWidgets import QApplication
+
+from qutebrowser.config import config
+from qutebrowser.mainwindow import mainwindow
+
+
+instance = cast('WindowUndoManager', None)
+
+
+@attr.s
+class _WindowUndoEntry:
+
+ """Information needed for :undo -w."""
+
+ geometry = attr.ib()
+ tab_stack = attr.ib()
+
+
+class WindowUndoManager(QObject):
+
+ """Manager which saves/restores windows."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._undos: MutableSequence[_WindowUndoEntry] = collections.deque()
+ QApplication.instance().window_closing.connect(self._on_window_closing)
+ config.instance.changed.connect(self._on_config_changed)
+
+ @config.change_filter('tabs.undo_stack_size')
+ def _on_config_changed(self):
+ self._update_undo_stack_size()
+
+ def _on_window_closing(self, window):
+ if window.tabbed_browser.is_private:
+ return
+
+ self._undos.append(_WindowUndoEntry(
+ geometry=window.saveGeometry(),
+ tab_stack=window.tabbed_browser.undo_stack,
+ ))
+
+ def _update_undo_stack_size(self):
+ newsize = config.instance.get('tabs.undo_stack_size')
+ if newsize < 0:
+ newsize = None
+ self._undos = collections.deque(self._undos, maxlen=newsize)
+
+ def undo_last_window_close(self):
+ """Restore the last window to be closed.
+
+ It will have the same tab and undo stack as when it was closed.
+ """
+ entry = self._undos.pop()
+ window = mainwindow.MainWindow(
+ private=False,
+ geometry=entry.geometry,
+ )
+ window.show()
+ window.tabbed_browser.undo_stack = entry.tab_stack
+ window.tabbed_browser.undo()
+
+
+def init():
+ global instance
+ instance = WindowUndoManager(parent=QApplication.instance())
diff --git a/qutebrowser/misc/autoupdate.py b/qutebrowser/misc/autoupdate.py
index e51ecfb19..49d648f58 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)
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..e459d81c1 100644
--- a/qutebrowser/misc/backendproblem.py
+++ b/qutebrowser/misc/backendproblem.py
@@ -23,12 +23,10 @@ import os
import sys
import functools
import html
-import ctypes
-import ctypes.util
import enum
import shutil
-import typing
import argparse
+from typing import Any, List, Sequence, Tuple
import attr
from PyQt5.QtCore import Qt
@@ -57,15 +55,13 @@ class _Button:
"""A button passed to BackendProblemDialog."""
- text = attr.ib() # type: str
- setting = attr.ib() # type: str
- value = attr.ib() # type: str
- default = attr.ib(default=False) # type: bool
+ text: str = attr.ib()
+ setting: str = attr.ib()
+ value: Any = attr.ib()
+ default: bool = attr.ib(default=False)
-def _other_backend(
- backend: usertypes.Backend
-) -> typing.Tuple[usertypes.Backend, str]:
+def _other_backend(backend: usertypes.Backend) -> Tuple[usertypes.Backend, str]:
"""Get the other backend enum/setting for a given backend."""
other_backend = {
usertypes.Backend.QtWebKit: usertypes.Backend.QtWebEngine,
@@ -81,19 +77,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):
@@ -103,7 +101,7 @@ class _Dialog(QDialog):
def __init__(self, *, because: str,
text: str,
backend: usertypes.Backend,
- buttons: typing.Sequence[_Button] = None,
+ buttons: Sequence[_Button] = None,
parent: QWidget = None) -> None:
super().__init__(parent)
vbox = QVBoxLayout(self)
@@ -157,10 +155,10 @@ class _BackendImports:
"""Whether backend modules could be imported."""
- webkit_available = attr.ib(default=None) # type: bool
- webengine_available = attr.ib(default=None) # type: bool
- webkit_error = attr.ib(default=None) # type: str
- webengine_error = attr.ib(default=None) # type: str
+ webkit_available: bool = attr.ib(default=None)
+ webengine_available: bool = attr.ib(default=None)
+ webkit_error: str = attr.ib(default=None)
+ webengine_error: str = attr.ib(default=None)
class _BackendProblemChecker:
@@ -173,7 +171,7 @@ class _BackendProblemChecker:
self._save_manager = save_manager
self._no_err_windows = no_err_windows
- def _show_dialog(self, *args: typing.Any, **kwargs: typing.Any) -> None:
+ def _show_dialog(self, *args: Any, **kwargs: Any) -> None:
"""Show a dialog for a backend problem."""
if self._no_err_windows:
text = _error_text(*args, **kwargs)
@@ -201,83 +199,13 @@ 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)
+ utils.libgl_workaround()
- 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)
-
- def _handle_nouveau_graphics(self) -> None:
- """Force software rendering when using the Nouveau driver.
-
- WORKAROUND for
- https://bugreports.qt.io/browse/QTBUG-41242
- Should be fixed in Qt 5.10 via
- https://codereview.qt-project.org/#/c/208664/
- """
- self._assert_backend(usertypes.Backend.QtWebEngine)
-
- if os.environ.get('QUTE_SKIP_NOUVEAU_CHECK'):
- return
-
- if qtutils.version_check('5.10', compiled=False):
- return
-
- if version.opengl_vendor() != 'nouveau':
- return
-
- if (os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1' or
- # qt.force_software_rendering = 'software-opengl'
- 'QT_XCB_FORCE_SOFTWARE_OPENGL' in os.environ or
- # qt.force_software_rendering = 'chromium', also see:
- # https://build.opensuse.org/package/view_file/openSUSE:Factory/libqt5-qtwebengine/disable-gpu-when-using-nouveau-boo-1005323.diff?expand=1
- 'QT_WEBENGINE_DISABLE_NOUVEAU_WORKAROUND' in os.environ):
- return
-
- button = _Button("Force software rendering",
- 'qt.force_software_rendering',
- 'chromium')
- 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>"),
- 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) -> Tuple[str, 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,20 +224,53 @@ class _BackendProblemChecker:
"<p>This allows you to use the newer QtWebEngine backend "
"(based on Chromium). ")
- 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>")
+ return text, 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="you're using Wayland",
+ because=("of frequent crashes with Qt 5.14 on "
+ "Wayland with older graphics hardware"),
text=text,
buttons=buttons)
@@ -320,8 +281,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 +295,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 +315,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",
@@ -430,9 +401,7 @@ class _BackendProblemChecker:
# It seems these issues started with Qt 5.12.
# They should be fixed with Qt 5.12.5:
# https://codereview.qt-project.org/c/qt/qtwebengine-chromium/+/265408
- affected = (qtutils.version_check('5.12', compiled=False) and not
- qtutils.version_check('5.12.5', compiled=False))
- if not affected:
+ if qtutils.version_check('5.12.5', compiled=False):
return
log.init.info("Qt version changed, nuking QtWebEngine cache")
@@ -483,9 +452,8 @@ class _BackendProblemChecker:
self._check_backend_modules()
if objects.backend == usertypes.Backend.QtWebEngine:
self._handle_ssl_support()
- self._handle_wayland()
self._nvidia_shader_workaround()
- self._handle_nouveau_graphics()
+ self._handle_wayland_webgl()
self._handle_cache_nuking()
self._handle_serviceworker_nuking()
else:
diff --git a/qutebrowser/misc/checkpyver.py b/qutebrowser/misc/checkpyver.py
index d3829a0bf..6f6659a24 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
- import tkMessageBox as messagebox # type: ignore # noqa: N813
+ from Tkinter import Tk # type: ignore[import, no-redef]
+ import tkMessageBox as messagebox # type: ignore[import, no-redef] # 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
@@ -43,13 +43,13 @@ except ImportError: # pragma: no cover
# to stderr.
def check_python_version():
"""Check if correct python version is run."""
- if sys.hexversion < 0x03050200:
+ if sys.hexversion < 0x03060000:
# We don't use .format() and print_function here just in case someone
# still has < 2.6 installed.
version_str = '.'.join(map(str, sys.version_info[:3]))
- text = ("At least Python 3.5.2 is required to run qutebrowser, but " +
+ text = ("At least Python 3.6 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/cmdhistory.py b/qutebrowser/misc/cmdhistory.py
index 810bbd1f9..1403ee56d 100644
--- a/qutebrowser/misc/cmdhistory.py
+++ b/qutebrowser/misc/cmdhistory.py
@@ -19,7 +19,7 @@
"""Command history for the status bar."""
-import typing
+from typing import MutableSequence
from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject
@@ -60,7 +60,7 @@ class History(QObject):
super().__init__(parent)
self._tmphist = None
if history is None:
- self.history = [] # type: typing.MutableSequence[str]
+ self.history: MutableSequence[str] = []
else:
self.history = history
@@ -82,9 +82,9 @@ class History(QObject):
"""
log.misc.debug("Preset text: '{}'".format(text))
if text:
- items = [
+ items: MutableSequence[str] = [
e for e in self.history
- if e.startswith(text)] # type: typing.MutableSequence[str]
+ if e.startswith(text)]
else:
items = self.history
if not items:
diff --git a/qutebrowser/misc/consolewidget.py b/qutebrowser/misc/consolewidget.py
index 7e46da3c8..14e4a7dc3 100644
--- a/qutebrowser/misc/consolewidget.py
+++ b/qutebrowser/misc/consolewidget.py
@@ -21,13 +21,13 @@
import sys
import code
-import typing
+from typing import MutableSequence
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'):
@@ -173,7 +165,7 @@ class ConsoleWidget(QWidget):
'objreg': objreg,
}
self._more = False
- self._buffer = [] # type: typing.MutableSequence[str]
+ self._buffer: MutableSequence[str] = []
self._lineedit = ConsoleLineEdit(namespace, self)
self._lineedit.execute.connect(self.push)
self._output = ConsoleTextEdit()
@@ -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..41160198a 100644
--- a/qutebrowser/misc/crashdialog.py
+++ b/qutebrowser/misc/crashdialog.py
@@ -28,9 +28,8 @@ import fnmatch
import traceback
import datetime
import enum
-import typing
+from typing import List, Tuple
-import pkg_resources
from PyQt5.QtCore import pyqtSlot, Qt, QSize
from PyQt5.QtWidgets import (QDialog, QLabel, QTextEdit, QPushButton,
QVBoxLayout, QHBoxLayout, QCheckBox,
@@ -84,7 +83,7 @@ def parse_fatal_stacktrace(text):
def _get_environment_vars():
"""Gather environment variables for the crash info."""
masks = ('DESKTOP_SESSION', 'DE', 'QT_*', 'PYTHON*', 'LC_*', 'LANG',
- 'XDG_*', 'QUTE_*', 'PATH')
+ 'XDG_*', 'QUTE_*', 'PATH', 'XMODIFIERS', 'XIM_*')
info = []
for key, value in os.environ.items():
for m in masks:
@@ -119,15 +118,19 @@ class _CrashDialog(QDialog):
super().__init__(parent)
# We don't set WA_DeleteOnClose here as on an exception, we'll get
# closed anyways, and it only could have unintended side-effects.
- self._crash_info = [] # type: typing.List[typing.Tuple[str, str]]
+ self._crash_info: List[Tuple[str, str]] = []
self._btn_box = None
self._paste_text = None
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 +249,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 +299,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 +333,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 +350,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)
@@ -355,8 +360,8 @@ class _CrashDialog(QDialog):
Args:
newest: The newest version as a string.
"""
- new_version = pkg_resources.parse_version(newest)
- cur_version = pkg_resources.parse_version(qutebrowser.__version__)
+ new_version = utils.parse_version(newest)
+ cur_version = utils.parse_version(qutebrowser.__version__)
lines = ['The report has been sent successfully. Thanks!']
if new_version > cur_version:
lines.append("<b>Note:</b> The newest available version is v{}, "
@@ -532,7 +537,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 +634,7 @@ class ReportErrorDialog(QDialog):
hbox = QHBoxLayout()
hbox.addStretch()
btn = QPushButton("Close")
- btn.clicked.connect(self.close) # type: ignore
+ btn.clicked.connect(self.close)
hbox.addWidget(btn)
vbox.addLayout(hbox)
@@ -650,7 +655,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..d07d8e49c 100644
--- a/qutebrowser/misc/crashsignal.py
+++ b/qutebrowser/misc/crashsignal.py
@@ -27,13 +27,9 @@ import pdb # noqa: T002
import signal
import argparse
import functools
+import threading
import faulthandler
-import typing
-try:
- # WORKAROUND for segfaults when using pdb in pytest for some reason...
- import readline # pylint: disable=unused-import
-except ImportError:
- pass
+from typing import TYPE_CHECKING, Optional, MutableMapping, cast
import attr
from PyQt5.QtCore import (pyqtSlot, qInstallMessageHandler, QObject,
@@ -43,7 +39,8 @@ 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
-if typing.TYPE_CHECKING:
+from qutebrowser.qt import sip
+if TYPE_CHECKING:
from qutebrowser.misc import quitter
@@ -57,7 +54,7 @@ class ExceptionInfo:
objects = attr.ib()
-crash_handler = typing.cast('CrashHandler', None)
+crash_handler = cast('CrashHandler', None)
class CrashHandler(QObject):
@@ -160,14 +157,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 +191,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 +223,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 +252,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()
@@ -305,10 +332,9 @@ class SignalHandler(QObject):
self._quitter = quitter
self._notifier = None
self._timer = usertypes.Timer(self, 'python_hacks')
- self._orig_handlers = {
- } # type: typing.MutableMapping[int, signal._HANDLER]
+ self._orig_handlers: MutableMapping[int, 'signal._HANDLER'] = {}
self._activated = False
- self._orig_wakeup_fd = None # type: typing.Optional[int]
+ self._orig_wakeup_fd: Optional[int] = None
def activate(self):
"""Set up signal handlers.
@@ -331,9 +357,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(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/debugcachestats.py b/qutebrowser/misc/debugcachestats.py
index 417e4505a..227f9d668 100644
--- a/qutebrowser/misc/debugcachestats.py
+++ b/qutebrowser/misc/debugcachestats.py
@@ -23,17 +23,17 @@ Because many modules depend on this command, this needs to have as few
dependencies as possible to avoid cyclic dependencies.
"""
-import typing
+from typing import Any, Callable, List, Optional, Tuple, TypeVar
# The second element of each tuple should be a lru_cache wrapped function
-_CACHE_FUNCTIONS = [] # type: typing.List[typing.Tuple[str, typing.Any]]
+_CACHE_FUNCTIONS: List[Tuple[str, Any]] = []
-_T = typing.TypeVar('_T', bound=typing.Callable)
+_T = TypeVar('_T', bound=Callable)
-def register(name: typing.Optional[str] = None) -> typing.Callable[[_T], _T]:
+def register(name: Optional[str] = None) -> Callable[[_T], _T]:
"""Register a lru_cache wrapped function for debug_cache_stats."""
def wrapper(fn: _T) -> _T:
_CACHE_FUNCTIONS.append((fn.__name__ if name is None else name, fn))
diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py
index 99ddd3701..d742a6706 100644
--- a/qutebrowser/misc/earlyinit.py
+++ b/qutebrowser/misc/earlyinit.py
@@ -19,7 +19,7 @@
"""Things which need to be done really early (e.g. before importing Qt).
-At this point we can be sure we have all python 3.5 features available.
+At this point we can be sure we have all python 3.6 features available.
"""
try:
@@ -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!
@@ -170,29 +170,21 @@ def qt_version(qversion=None, qt_version_str=None):
def check_qt_version():
"""Check if the Qt version is recent enough."""
- from PyQt5.QtCore import (qVersion, QT_VERSION, PYQT_VERSION,
- PYQT_VERSION_STR)
- from pkg_resources import parse_version
- from qutebrowser.utils import log
- parsed_qversion = parse_version(qVersion())
-
- if (QT_VERSION < 0x050701 or PYQT_VERSION < 0x050700 or
- parsed_qversion < parse_version('5.7.1')):
- text = ("Fatal error: Qt >= 5.7.1 and PyQt >= 5.7 are required, "
+ from PyQt5.QtCore import QT_VERSION, PYQT_VERSION, PYQT_VERSION_STR
+ try:
+ from PyQt5.QtCore import QVersionNumber, QLibraryInfo
+ qt_ver = QLibraryInfo.version().normalized()
+ recent_qt_runtime = qt_ver >= QVersionNumber(5, 12) # type: ignore[operator]
+ except (ImportError, AttributeError):
+ # QVersionNumber was added in Qt 5.6, QLibraryInfo.version() in 5.8
+ recent_qt_runtime = False
+
+ if QT_VERSION < 0x050C00 or PYQT_VERSION < 0x050C00 or not recent_qt_runtime:
+ text = ("Fatal error: Qt >= 5.12.0 and PyQt >= 5.12.0 are required, "
"but Qt {} / PyQt {} is installed.".format(qt_version(),
PYQT_VERSION_STR))
_die(text)
- if qVersion().startswith('5.8.'):
- log.init.warning("Running qutebrowser with Qt 5.8 is untested and "
- "unsupported!")
-
- if (parsed_qversion >= parse_version('5.12') and
- (PYQT_VERSION < 0x050c00 or QT_VERSION < 0x050c00)):
- log.init.warning("Combining PyQt {} with Qt {} is unsupported! Ensure "
- "all versions are newer than 5.12 to avoid potential "
- "issues.".format(PYQT_VERSION_STR, qt_version()))
-
def check_ssl_support():
"""Check if SSL support is available."""
@@ -210,15 +202,19 @@ def _check_modules(modules):
try:
# https://bitbucket.org/fdik/pypeg/commits/dd15ca462b532019c0a3be1d39b8ee2f3fa32f4e
# pylint: disable=bad-continuation
- with log.ignore_py_warnings(
+ with log.py_warning_filter(
category=DeprecationWarning,
message=r'invalid escape sequence'
- ), log.ignore_py_warnings(
+ ), log.py_warning_filter(
category=ImportWarning,
message=r'Not importing directory .*: missing __init__'
- ), log.ignore_py_warnings(
+ ), log.py_warning_filter(
category=DeprecationWarning,
message=r'the imp module is deprecated',
+ ), log.py_warning_filter(
+ # WORKAROUND for https://github.com/pypa/setuptools/issues/2466
+ category=DeprecationWarning,
+ message=r'Creating a LegacyVersion has been deprecated',
):
# pylint: enable=bad-continuation
importlib.import_module(name)
@@ -251,17 +247,13 @@ 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(True) # type: ignore[attr-defined]
except AttributeError:
# Added in PyQt 5.13 somewhere, going to be the default in 5.14
pass
from qutebrowser.qt import sip
- try:
- # Added in sip 4.19.4
- sip.enableoverflowchecking(True) # type: ignore
- except AttributeError:
- pass
+ sip.enableoverflowchecking(True)
def init_log(args):
diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py
index 1dcc3a532..17fbb9956 100644
--- a/qutebrowser/misc/editor.py
+++ b/qutebrowser/misc/editor.py
@@ -28,6 +28,7 @@ from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QProcess,
from qutebrowser.config import config
from qutebrowser.utils import message, log
from qutebrowser.misc import guiprocess
+from qutebrowser.qt import sip
class ExternalEditor(QObject):
@@ -64,7 +65,9 @@ class ExternalEditor(QObject):
def _cleanup(self):
"""Clean up temporary files after the editor closed."""
assert self._remove_file is not None
- if self._watcher is not None and self._watcher.files():
+ if (self._watcher is not None and
+ not sip.isdeleted(self._watcher) and
+ self._watcher.files()):
failed = self._watcher.removePaths(self._watcher.files())
if failed:
log.procs.error("Failed to unwatch paths: {}".format(failed))
@@ -89,6 +92,10 @@ class ExternalEditor(QObject):
Callback for QProcess when the editor was closed.
"""
+ if sip.isdeleted(self): # pragma: no cover
+ log.procs.debug("Ignoring _on_proc_closed for deleted editor")
+ return
+
log.procs.debug("Editor closed")
if exitstatus != QProcess.NormalExit:
# No error/cleanup here, since we already handle this in
@@ -164,6 +171,9 @@ class ExternalEditor(QObject):
def edit_file(self, filename):
"""Edit the file with the given filename."""
+ if not os.path.exists(filename):
+ with open(filename, 'w', encoding='utf-8'):
+ pass
self._filename = filename
self._remove_file = False
self._start_editor()
@@ -187,7 +197,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..872a594f3 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)
+ self._proc.finished.connect(self._on_finished)
+ self._proc.finished.connect(self.finished)
+ self._proc.started.connect(self._on_started)
+ self._proc.started.connect(self.started)
if additional_env is not None:
procenv = QProcessEnvironment.systemEnvironment()
@@ -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/httpclient.py b/qutebrowser/misc/httpclient.py
index 822ba8805..778b53a94 100644
--- a/qutebrowser/misc/httpclient.py
+++ b/qutebrowser/misc/httpclient.py
@@ -19,10 +19,9 @@
"""An HTTP client based on QNetworkAccessManager."""
-import typing
import functools
-import urllib.request
import urllib.parse
+from typing import MutableMapping
from PyQt5.QtCore import pyqtSignal, QObject, QTimer
from PyQt5.QtNetwork import (QNetworkAccessManager, QNetworkRequest,
@@ -34,14 +33,8 @@ class HTTPRequest(QNetworkRequest):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- try:
- self.setAttribute(QNetworkRequest.RedirectPolicyAttribute,
- QNetworkRequest.NoLessSafeRedirectPolicy)
- except AttributeError:
- # RedirectPolicyAttribute was introduced in 5.9 to replace
- # FollowRedirectsAttribute.
- self.setAttribute(QNetworkRequest.FollowRedirectsAttribute,
- True)
+ self.setAttribute(QNetworkRequest.RedirectPolicyAttribute,
+ QNetworkRequest.NoLessSafeRedirectPolicy)
class HTTPClient(QObject):
@@ -67,7 +60,7 @@ class HTTPClient(QObject):
def __init__(self, parent=None):
super().__init__(parent)
self._nam = QNetworkAccessManager(self)
- self._timers = {} # type: typing.MutableMapping[QNetworkReply, QTimer]
+ self._timers: MutableMapping[QNetworkReply, QTimer] = {}
def post(self, url, data=None):
"""Create a new POST request.
diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py
index ad1d3eb56..e7f370557 100644
--- a/qutebrowser/misc/ipc.py
+++ b/qutebrowser/misc/ipc.py
@@ -31,12 +31,13 @@ from PyQt5.QtNetwork import QLocalSocket, QLocalServer, QAbstractSocket
import qutebrowser
from qutebrowser.utils import log, usertypes, error, standarddir, utils
+from qutebrowser.qt import sip
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
@@ -46,7 +47,19 @@ server = None
def _get_socketname_windows(basedir):
"""Get a socketname to use for Windows."""
- parts = ['qutebrowser', getpass.getuser()]
+ try:
+ username = getpass.getuser()
+ except ImportError:
+ # getpass.getuser() first tries a couple of environment variables. If
+ # none of those are set (i.e., USERNAME is missing), it tries to import
+ # the "pwd" module which is unavailable on Windows.
+ raise Error("Could not find username. This should only happen if "
+ "there is a bug in the application launching qutebrowser, "
+ "preventing the USERNAME environment variable from being "
+ "passed. If you know more about when this happens, please "
+ "report this to mail@qutebrowser.org.")
+
+ parts = ['qutebrowser', username]
if basedir is not None:
md5 = hashlib.md5(basedir.encode('utf-8')).hexdigest()
parts.append(md5)
@@ -178,14 +191,19 @@ 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
self._old_socket = None
+
if utils.is_windows: # pragma: no cover
- # If we use setSocketOptions on Unix with Qt < 5.4, we get a
- # NameError while listening...
+ # As a WORKAROUND for a Qt bug, we can't use UserAccessOption on Unix. If we
+ # do, we don't get an AddressInUseError anymore:
+ # https://bugreports.qt.io/browse/QTBUG-48635
+ #
+ # Thus, we only do so on Windows, and handle permissions manually in
+ # listen() on Linux.
log.ipc.debug("Calling setSocketOptions")
self._server.setSocketOptions(QLocalServer.UserAccessOption)
else: # pragma: no cover
@@ -209,15 +227,9 @@ class IPCServer(QObject):
if self._server.serverError() == QAbstractSocket.AddressInUseError:
raise AddressInUseError(self._server)
raise ListenError(self._server)
+
if not utils.is_windows: # pragma: no cover
- # If we use setSocketOptions on Unix with Qt < 5.4, we get a
- # NameError while listening.
- # (see b135569d5c6e68c735ea83f42e4baf51f7972281)
- #
- # Also, we don't get an AddressInUseError with Qt 5.5:
- # https://bugreports.qt.io/browse/QTBUG-48635
- #
- # This means we only use setSocketOption on Windows...
+ # WORKAROUND for QTBUG-48635, see the comment in __init__ for details.
try:
os.chmod(self._server.fullServerName(), 0o700)
except FileNotFoundError:
@@ -252,21 +264,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
+ self._timer.start()
+ 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()
@@ -294,7 +309,7 @@ class IPCServer(QObject):
self._socket.disconnectFromServer()
def _handle_data(self, data):
- """Handle data (as bytes) we got from on_ready_ready_read."""
+ """Handle data (as bytes) we got from on_ready_read."""
try:
decoded = data.decode('utf-8')
except UnicodeDecodeError:
@@ -355,6 +370,11 @@ class IPCServer(QObject):
socket = self._old_socket
else:
socket = self._socket
+
+ if sip.isdeleted(socket): # pragma: no cover
+ log.ipc.warning("Ignoring deleted IPC socket")
+ return
+
self._timer.stop()
while socket is not None and socket.canReadLine():
data = bytes(socket.readLine())
@@ -362,14 +382,14 @@ class IPCServer(QObject):
log.ipc.debug("Read from socket 0x{:x}: {!r}".format(
id(socket), data))
self._handle_data(data)
- self._timer.start()
+
+ if self._socket is not None:
+ self._timer.start()
@pyqtSlot()
def on_timeout(self):
"""Cancel the current connection if it was idle for too long."""
- if self._socket is None: # pragma: no cover
- log.ipc.debug("on_timeout got called with None socket!")
- return
+ assert self._socket is not None
log.ipc.error("IPC connection timed out "
"(socket 0x{:x}).".format(id(self._socket)))
self._socket.disconnectFromServer()
@@ -394,8 +414,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):
@@ -473,7 +501,6 @@ def display_error(exc, args):
"""Display a message box with an IPC error."""
error.handle_fatal_exc(
exc, "Error while connecting to running instance!",
- post_text="Maybe another instance is running but frozen?",
no_err_windows=args.no_err_windows)
@@ -488,8 +515,8 @@ def send_or_listen(args):
None if an instance was running and received our request.
"""
global server
- socketname = _get_socketname(args.basedir)
try:
+ socketname = _get_socketname(args.basedir)
try:
sent = send_to_running_instance(socketname, args.command,
args.target)
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/lineparser.py b/qutebrowser/misc/lineparser.py
index 749c5dff1..d3e5a2db0 100644
--- a/qutebrowser/misc/lineparser.py
+++ b/qutebrowser/misc/lineparser.py
@@ -22,7 +22,7 @@
import os
import os.path
import contextlib
-import typing
+from typing import Sequence
from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject
@@ -150,7 +150,7 @@ class LineParser(BaseLineParser):
"""
super().__init__(configdir, fname, binary=binary, parent=parent)
if not os.path.isfile(self._configfile):
- self.data = [] # type: typing.Sequence[str]
+ self.data: Sequence[str] = []
else:
log.init.debug("Reading {}".format(self._configfile))
self._read()
diff --git a/qutebrowser/misc/miscwidgets.py b/qutebrowser/misc/miscwidgets.py
index 39be3f15c..58bdf374d 100644
--- a/qutebrowser/misc/miscwidgets.py
+++ b/qutebrowser/misc/miscwidgets.py
@@ -19,16 +19,19 @@
"""Misc. widgets used at different places."""
-import typing
+from typing import Optional
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QSize, QTimer
from PyQt5.QtWidgets import (QLineEdit, QWidget, QHBoxLayout, QLabel,
- QStyleOption, QStyle, QLayout, QApplication)
-from PyQt5.QtGui import QValidator, QPainter
+ QStyleOption, QStyle, QLayout, QApplication,
+ QSplitter)
+from PyQt5.QtGui import QValidator, QPainter, QResizeEvent
-from qutebrowser.config import config
-from qutebrowser.utils import utils
+from qutebrowser.config import config, configfiles
+from qutebrowser.utils import utils, log, usertypes
from qutebrowser.misc import cmdhistory
+from qutebrowser.browser import inspector
+from qutebrowser.keyinput import keyutils, modeman
class MinimalLineEditMixin:
@@ -36,7 +39,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 +48,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 +60,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)
@@ -235,12 +239,16 @@ class WrapperLayout(QLayout):
def __init__(self, parent=None):
super().__init__(parent)
- self._widget = typing.cast(QWidget, None)
+ self._widget: Optional[QWidget] = None
+ self._container: Optional[QWidget] = None
def addItem(self, _widget):
raise utils.Unreachable
def sizeHint(self):
+ """Get the size of the underlying widget."""
+ if self._widget is None:
+ return QSize()
return self._widget.sizeHint()
def itemAt(self, _index):
@@ -250,58 +258,30 @@ class WrapperLayout(QLayout):
raise utils.Unreachable
def setGeometry(self, rect):
+ """Pass through setGeometry calls to the underlying widget."""
+ if self._widget is None:
+ return
self._widget.setGeometry(rect)
def wrap(self, container, widget):
"""Wrap the given widget in the given container."""
+ self._container = container
self._widget = widget
container.setFocusProxy(widget)
widget.setParent(container)
def unwrap(self):
- self._widget.setParent(None) # type: ignore
- self._widget.deleteLater()
-
-
-class PseudoLayout(QLayout):
-
- """A layout which isn't actually a real layout.
-
- This is used to replace QWebEngineView's internal layout, as a WORKAROUND
- for https://bugreports.qt.io/browse/QTBUG-68224 and other related issues.
-
- This is partly inspired by https://codereview.qt-project.org/#/c/230894/
- which does something similar as part of Qt.
- """
-
- def addItem(self, item):
- assert self.parent() is not None
- item.widget().setParent(self.parent())
-
- def removeItem(self, item):
- item.widget().setParent(None)
-
- def count(self):
- return 0
-
- def itemAt(self, _pos):
- return None
+ """Remove the widget from this layout.
- def widget(self):
- return self.parent().render_widget()
-
- def setGeometry(self, rect):
- """Resize the render widget when the view is resized."""
- widget = self.widget()
- if widget is not None:
- widget.setGeometry(rect)
-
- def sizeHint(self):
- """Make sure the view has the sizeHint of the render widget."""
- widget = self.widget()
- if widget is not None:
- return widget.sizeHint()
- return QSize()
+ Does nothing if it nothing was wrapped before.
+ """
+ if self._widget is None:
+ return
+ assert self._container is not None
+ self._widget.setParent(None) # type: ignore[call-overload]
+ self._widget.deleteLater()
+ self._widget = None
+ self._container.setFocusProxy(None) # type: ignore[arg-type]
class FullscreenNotification(QLabel):
@@ -341,3 +321,186 @@ class FullscreenNotification(QLabel):
"""Hide and delete the widget."""
self.hide()
self.deleteLater()
+
+
+class InspectorSplitter(QSplitter):
+
+ """Allows putting an inspector inside the tab.
+
+ Attributes:
+ _main_idx: index of the main webview widget
+ _position: position of the inspector (right/left/top/bottom)
+ _preferred_size: the preferred size of the inpector widget in pixels
+
+ Class attributes:
+ _PROTECTED_MAIN_SIZE: How much space should be reserved for the main
+ content (website).
+ _SMALL_SIZE_THRESHOLD: If the window size is under this threshold, we
+ consider this a temporary "emergency" situation.
+ """
+
+ _PROTECTED_MAIN_SIZE = 150
+ _SMALL_SIZE_THRESHOLD = 300
+
+ def __init__(self, win_id: int, main_webview: QWidget,
+ parent: QWidget = None) -> None:
+ super().__init__(parent)
+ self._win_id = win_id
+ self.addWidget(main_webview)
+ self.setFocusProxy(main_webview)
+ self.splitterMoved.connect(self._on_splitter_moved)
+ self._main_idx: Optional[int] = None
+ self._inspector_idx: Optional[int] = None
+ self._position: Optional[inspector.Position] = None
+ self._preferred_size: Optional[int] = None
+
+ def cycle_focus(self):
+ """Cycle keyboard focus between the main/inspector widget."""
+ if self.count() == 1:
+ raise inspector.Error("No inspector inside main window")
+
+ assert self._main_idx is not None
+ assert self._inspector_idx is not None
+
+ main_widget = self.widget(self._main_idx)
+ inspector_widget = self.widget(self._inspector_idx)
+
+ if not inspector_widget.isVisible():
+ raise inspector.Error("No inspector inside main window")
+
+ if main_widget.hasFocus():
+ inspector_widget.setFocus()
+ modeman.enter(self._win_id, usertypes.KeyMode.insert,
+ reason='Inspector focused', only_if_normal=True)
+ elif inspector_widget.hasFocus():
+ main_widget.setFocus()
+
+ def set_inspector(self, inspector_widget: inspector.AbstractWebInspector,
+ position: inspector.Position) -> None:
+ """Set the position of the inspector."""
+ assert position != inspector.Position.window
+
+ if position in [inspector.Position.right, inspector.Position.bottom]:
+ self._main_idx = 0
+ self._inspector_idx = 1
+ else:
+ self._inspector_idx = 0
+ self._main_idx = 1
+
+ self.setOrientation(Qt.Horizontal
+ if position in [inspector.Position.left,
+ inspector.Position.right]
+ else Qt.Vertical)
+ self.insertWidget(self._inspector_idx, inspector_widget)
+ self._position = position
+ self._load_preferred_size()
+ self._adjust_size()
+
+ def _save_preferred_size(self) -> None:
+ """Save the preferred size of the inspector widget."""
+ assert self._position is not None
+ size = str(self._preferred_size)
+ configfiles.state['inspector'][self._position.name] = size
+
+ def _load_preferred_size(self) -> None:
+ """Load the preferred size of the inspector widget."""
+ assert self._position is not None
+ full = (self.width() if self.orientation() == Qt.Horizontal
+ else self.height())
+
+ # If we first open the inspector with a window size of < 300px
+ # (self._SMALL_SIZE_THRESHOLD), we don't want to default to half of the
+ # window size as the small window is likely a temporary situation and
+ # the inspector isn't very usable in that state.
+ self._preferred_size = max(self._SMALL_SIZE_THRESHOLD, full // 2)
+
+ try:
+ size = int(configfiles.state['inspector'][self._position.name])
+ except KeyError:
+ # First start
+ pass
+ except ValueError as e:
+ log.misc.error("Could not read inspector size: {}".format(e))
+ else:
+ self._preferred_size = int(size)
+
+ def _adjust_size(self) -> None:
+ """Adjust the size of the inspector similarly to Chromium.
+
+ In general, we want to keep the absolute size of the inspector (rather
+ than the ratio) the same, as it's confusing when the layout of its
+ contents changes.
+
+ We're essentially handling three different cases:
+
+ 1) We have plenty of space -> Keep inspector at the preferred absolute
+ size.
+
+ 2) We're slowly running out of space. Make sure the page still has
+ 150px (self._PROTECTED_MAIN_SIZE) left, give the rest to the
+ inspector.
+
+ 3) The window is very small (< 300px, self._SMALL_SIZE_THRESHOLD).
+ Keep Qt's behavior of keeping the aspect ratio, as all hope is lost
+ at this point.
+ """
+ sizes = self.sizes()
+ total = sizes[0] + sizes[1]
+
+ assert self._main_idx is not None
+ assert self._inspector_idx is not None
+ assert self._preferred_size is not None
+
+ if total >= self._preferred_size + self._PROTECTED_MAIN_SIZE:
+ # Case 1 above
+ sizes[self._inspector_idx] = self._preferred_size
+ sizes[self._main_idx] = total - self._preferred_size
+ self.setSizes(sizes)
+ elif (sizes[self._main_idx] < self._PROTECTED_MAIN_SIZE and
+ total >= self._SMALL_SIZE_THRESHOLD):
+ # Case 2 above
+ handle_size = self.handleWidth()
+ sizes[self._main_idx] = (
+ self._PROTECTED_MAIN_SIZE - handle_size // 2)
+ sizes[self._inspector_idx] = (
+ total - self._PROTECTED_MAIN_SIZE + handle_size // 2)
+ self.setSizes(sizes)
+ else:
+ # Case 3 above
+ pass
+
+ @pyqtSlot()
+ def _on_splitter_moved(self) -> None:
+ assert self._inspector_idx is not None
+ sizes = self.sizes()
+ self._preferred_size = sizes[self._inspector_idx]
+ self._save_preferred_size()
+
+ def resizeEvent(self, e: QResizeEvent) -> None:
+ """Window resize event."""
+ super().resizeEvent(e)
+ if self.count() == 2:
+ self._adjust_size()
+
+
+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/objects.py b/qutebrowser/misc/objects.py
index 28a1830d5..c2e20e9ad 100644
--- a/qutebrowser/misc/objects.py
+++ b/qutebrowser/misc/objects.py
@@ -22,10 +22,10 @@
# NOTE: We need to be careful with imports here, as this is imported from
# earlyinit.
-import typing
import argparse
+from typing import TYPE_CHECKING, Any, Dict, Set, Union, cast
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
from qutebrowser.utils import usertypes
from qutebrowser.commands import command
@@ -38,11 +38,11 @@ class NoBackend:
def name(self) -> str:
raise AssertionError("No backend set!")
- def __eq__(self, other: typing.Any) -> bool:
+ def __eq__(self, other: Any) -> bool:
raise AssertionError("No backend set!")
-backend = NoBackend() # type: typing.Union[usertypes.Backend, NoBackend]
-commands = {} # type: typing.Dict[str, command.Command]
-debug_flags = set() # type: typing.Set[str]
-args = typing.cast(argparse.Namespace, None)
+backend: Union['usertypes.Backend', NoBackend] = NoBackend()
+commands: Dict[str, 'command.Command'] = {}
+debug_flags: Set[str] = set()
+args = cast(argparse.Namespace, None)
diff --git a/qutebrowser/misc/quitter.py b/qutebrowser/misc/quitter.py
index a42b9d067..86342d57b 100644
--- a/qutebrowser/misc/quitter.py
+++ b/qutebrowser/misc/quitter.py
@@ -25,11 +25,11 @@ import sys
import json
import atexit
import shutil
-import typing
import argparse
import tokenize
import functools
import subprocess
+from typing import Iterable, Mapping, MutableSequence, Sequence, cast
from PyQt5.QtCore import QObject, pyqtSignal, QTimer
from PyQt5.QtWidgets import QApplication
@@ -40,14 +40,13 @@ except ImportError:
import qutebrowser
from qutebrowser.api import cmdutils
-from qutebrowser.config import config
from qutebrowser.utils import log
from qutebrowser.misc import sessions, ipc, objects
from qutebrowser.mainwindow import prompt
from qutebrowser.completion.models import miscmodels
-instance = typing.cast('Quitter', None)
+instance = cast('Quitter', None)
class Quitter(QObject):
@@ -98,10 +97,10 @@ class Quitter(QObject):
compile(f.read(), fn, 'exec')
def _get_restart_args(
- self, pages: typing.Iterable[str] = (),
+ self, pages: Iterable[str] = (),
session: str = None,
- override_args: typing.Mapping[str, str] = None
- ) -> typing.Sequence[str]:
+ override_args: Mapping[str, str] = None
+ ) -> Sequence[str]:
"""Get args to relaunch qutebrowser.
Args:
@@ -121,7 +120,7 @@ class Quitter(QObject):
args = [sys.executable, '-m', 'qutebrowser']
# Add all open pages so they get reopened.
- page_args = [] # type: typing.MutableSequence[str]
+ page_args: MutableSequence[str] = []
for win in pages:
page_args.extend(win)
page_args.append('')
@@ -158,9 +157,9 @@ class Quitter(QObject):
return args
- def restart(self, pages: typing.Sequence[str] = (),
+ def restart(self, pages: Sequence[str] = (),
session: str = None,
- override_args: typing.Mapping[str, str] = None) -> bool:
+ override_args: Mapping[str, str] = None) -> bool:
"""Inner logic to restart qutebrowser.
The "better" way to restart is to pass a session (_restart usually) as
@@ -221,15 +220,8 @@ class Quitter(QObject):
self._is_shutting_down = True
log.destroy.debug("Shutting down with status {}, session {}...".format(
status, session))
- if sessions.session_manager is not None:
- if session is not None:
- sessions.session_manager.save(session,
- last_window=last_window,
- load_next_time=True)
- elif config.val.auto_save.session:
- sessions.session_manager.save(sessions.default,
- last_window=last_window,
- load_next_time=True)
+
+ sessions.shutdown(session, last_window=last_window)
if prompt.prompt_queue.shutdown():
# If shutdown was called while we were asking a question, we're in
diff --git a/qutebrowser/misc/savemanager.py b/qutebrowser/misc/savemanager.py
index daa484934..c7a20adbb 100644
--- a/qutebrowser/misc/savemanager.py
+++ b/qutebrowser/misc/savemanager.py
@@ -21,7 +21,7 @@
import os.path
import collections
-import typing
+from typing import MutableMapping
from PyQt5.QtCore import pyqtSlot, QObject, QTimer
@@ -112,8 +112,7 @@ class SaveManager(QObject):
def __init__(self, parent=None):
super().__init__(parent)
- self.saveables = collections.OrderedDict(
- ) # type: typing.MutableMapping[str, Saveable]
+ self.saveables: MutableMapping[str, Saveable] = collections.OrderedDict()
self._save_timer = usertypes.Timer(self, name='save-timer')
self._save_timer.timeout.connect(self.autosave)
self._set_autosave_interval()
diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py
index 1bcd42baf..b4aa72f32 100644
--- a/qutebrowser/misc/sessions.py
+++ b/qutebrowser/misc/sessions.py
@@ -23,9 +23,11 @@ import os
import os.path
import itertools
import urllib
-import typing
+import glob
+import shutil
+from typing import Any, Iterable, MutableMapping, MutableSequence, Optional, Union, cast
-from PyQt5.QtCore import QUrl, QObject, QPoint, QTimer, pyqtSlot
+from PyQt5.QtCore import Qt, QUrl, QObject, QPoint, QTimer, QDateTime
from PyQt5.QtWidgets import QApplication
import yaml
@@ -38,7 +40,7 @@ from qutebrowser.mainwindow import mainwindow
from qutebrowser.qt import sip
-_JsonType = typing.MutableMapping[str, typing.Any]
+_JsonType = MutableMapping[str, Any]
class Sentinel:
@@ -47,9 +49,9 @@ class Sentinel:
default = Sentinel()
-session_manager = typing.cast('SessionManager', None)
+session_manager = cast('SessionManager', None)
-ArgType = typing.Union[str, Sentinel]
+ArgType = Union[str, Sentinel]
def init(parent=None):
@@ -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:
@@ -68,8 +80,21 @@ def init(parent=None):
session_manager = SessionManager(base_path, parent)
-@pyqtSlot()
-def shutdown():
+def shutdown(session: Optional[ArgType], last_window: bool) -> None:
+ """Handle a shutdown by saving sessions and removing the autosave file."""
+ if session_manager is None:
+ return # type: ignore[unreachable]
+
+ try:
+ if session is not None:
+ session_manager.save(session, last_window=last_window,
+ load_next_time=True)
+ elif config.val.auto_save.session:
+ session_manager.save(default, last_window=last_window,
+ load_next_time=True)
+ except SessionError as e:
+ log.sessions.error("Failed to save session: {}".format(e))
+
session_manager.delete_autosave()
@@ -96,7 +121,7 @@ class TabHistoryItem:
"""
def __init__(self, url, title, *, original_url=None, active=False,
- user_data=None):
+ user_data=None, last_visited=None):
self.url = url
if original_url is None:
self.original_url = url
@@ -105,11 +130,13 @@ class TabHistoryItem:
self.title = title
self.active = active
self.user_data = user_data
+ self.last_visited = last_visited
def __repr__(self):
return utils.get_repr(self, constructor=True, url=self.url,
original_url=self.original_url, title=self.title,
- active=self.active, user_data=self.user_data)
+ active=self.active, user_data=self.user_data,
+ last_visited=self.last_visited)
class SessionManager(QObject):
@@ -126,7 +153,7 @@ class SessionManager(QObject):
def __init__(self, base_path, parent=None):
super().__init__(parent)
- self.current = None # type: typing.Optional[str]
+ self.current: Optional[str] = None
self._base_path = base_path
self._last_window_session = None
self.did_load = False
@@ -169,9 +196,9 @@ class SessionManager(QObject):
Return:
A dict with the saved data for this item.
"""
- data = {
+ data: _JsonType = {
'url': bytes(item.url().toEncoded()).decode('ascii'),
- } # type: _JsonType
+ }
if item.title():
data['title'] = item.title()
@@ -195,6 +222,8 @@ class SessionManager(QObject):
# QtWebEngine
user_data = None
+ data['last_visited'] = item.lastVisited().toString(Qt.ISODate)
+
if tab.history.current_idx() == idx:
pos = tab.scroller.pos_px()
data['zoom'] = tab.zoom.factor()
@@ -217,7 +246,7 @@ class SessionManager(QObject):
tab: The WebView to save.
active: Whether the tab is currently active.
"""
- data = {'history': []} # type: _JsonType
+ data: _JsonType = {'history': []}
if active:
data['active'] = True
for idx, item in enumerate(tab.history):
@@ -234,9 +263,9 @@ class SessionManager(QObject):
def _save_all(self, *, only_window=None, with_private=False):
"""Get a dict with data for all windows/tabs."""
- data = {'windows': []} # type: _JsonType
+ data: _JsonType = {'windows': []}
if only_window is not None:
- winlist = [only_window] # type: typing.Iterable[int]
+ winlist: Iterable[int] = [only_window]
else:
winlist = objreg.window_registry
@@ -253,7 +282,7 @@ class SessionManager(QObject):
if tabbed_browser.is_private and not with_private:
continue
- win_data = {} # type: _JsonType
+ win_data: _JsonType = {}
active_window = QApplication.instance().activeWindow()
if getattr(active_window, 'win_id', None) == win_id:
win_data['active'] = True
@@ -311,10 +340,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)
@@ -344,10 +374,10 @@ class SessionManager(QObject):
"""Temporarily save the session for the last closed window."""
self._last_window_session = self._save_all()
- def _load_tab(self, new_tab, data):
+ def _load_tab(self, new_tab, data): # noqa: C901
"""Load yaml data into a newly opened tab."""
entries = []
- lazy_load = [] # type: typing.MutableSequence[_JsonType]
+ lazy_load: MutableSequence[_JsonType] = []
# use len(data['history'])
# -> dropwhile empty if not session.lazy_session
lazy_index = len(data['history'])
@@ -398,14 +428,25 @@ class SessionManager(QObject):
active = histentry.get('active', False)
url = QUrl.fromEncoded(histentry['url'].encode('ascii'))
+
if 'original-url' in histentry:
orig_url = QUrl.fromEncoded(
histentry['original-url'].encode('ascii'))
else:
orig_url = url
+
+ if histentry.get("last_visited"):
+ last_visited: Optional[QDateTime] = QDateTime.fromString(
+ histentry.get("last_visited"),
+ Qt.ISODate,
+ )
+ else:
+ last_visited = None
+
entry = TabHistoryItem(url=url, original_url=orig_url,
title=histentry['title'], active=active,
- user_data=user_data)
+ user_data=user_data,
+ last_visited=last_visited)
entries.append(entry)
if active:
new_tab.title_changed.emit(histentry['title'])
@@ -429,8 +470,7 @@ class SessionManager(QObject):
if tab.get('active', False):
tab_to_focus = i
if new_tab.data.pinned:
- tabbed_browser.widget.set_tab_pinned(new_tab,
- new_tab.data.pinned)
+ new_tab.set_pinned(True)
if tab_to_focus is not None:
tabbed_browser.widget.setCurrentIndex(tab_to_focus)
if win.get('active', False):
@@ -574,7 +614,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..50ae0a251 100644
--- a/qutebrowser/misc/sql.py
+++ b/qutebrowser/misc/sql.py
@@ -35,7 +35,6 @@ class SqliteErrorCode:
in qutebrowser here.
"""
- UNKNOWN = '-1'
ERROR = '1' # generic error code
BUSY = '5' # database is locked
READONLY = '8' # attempt to write a readonly database
@@ -108,13 +107,6 @@ def raise_sqlite_error(msg, error):
SqliteErrorCode.NOTADB,
]
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-70506
- # We don't know what the actual error was, but let's assume it's not us to
- # blame... Usually this is something like an unreadable database file.
- qtbug_70506 = (error_code == SqliteErrorCode.UNKNOWN and
- driver_text == "Error opening database" and
- database_text == "out of memory")
-
# https://github.com/qutebrowser/qutebrowser/issues/4681
# If the query we built was too long
too_long_err = (
@@ -123,7 +115,7 @@ def raise_sqlite_error(msg, error):
database_text in ["too many SQL variables",
"LIKE or GLOB pattern too complex"]))
- if error_code in known_errors or qtbug_70506 or too_long_err:
+ if error_code in known_errors or too_long_err:
raise KnownError(msg, error)
raise BugError(msg, error)
@@ -190,7 +182,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()
@@ -325,12 +318,13 @@ class SqlTable(QObject):
q.run()
return q.value()
- def delete(self, field, value):
+ def delete(self, field, value, *, optional=False):
"""Remove all rows for which `field` equals `value`.
Args:
field: Field to use as the key.
value: Key value to delete.
+ optional: If set, non-existent values are ignored.
Return:
The number of rows deleted.
@@ -339,6 +333,8 @@ class SqlTable(QObject):
.format(table=self._name, field=field))
q.run(val=value)
if not q.rows_affected():
+ if optional:
+ return
raise KeyError('No row with {} = "{}"'.format(field, value))
self.changed.emit()
diff --git a/qutebrowser/misc/throttle.py b/qutebrowser/misc/throttle.py
index a8d24bd12..3540d8824 100644
--- a/qutebrowser/misc/throttle.py
+++ b/qutebrowser/misc/throttle.py
@@ -19,8 +19,8 @@
"""A throttle for throttling function calls."""
-import typing
import time
+from typing import Any, Callable, Mapping, Optional, Sequence
import attr
from PyQt5.QtCore import QObject
@@ -31,8 +31,8 @@ from qutebrowser.utils import usertypes
@attr.s
class _CallArgs:
- args = attr.ib() # type: typing.Sequence[typing.Any]
- kwargs = attr.ib() # type: typing.Mapping[str, typing.Any]
+ args: Sequence[Any] = attr.ib()
+ kwargs: Mapping[str, Any] = attr.ib()
class Throttle(QObject):
@@ -45,7 +45,7 @@ class Throttle(QObject):
"""
def __init__(self,
- func: typing.Callable,
+ func: Callable,
delay_ms: int,
parent: QObject = None) -> None:
"""Constructor.
@@ -59,8 +59,8 @@ class Throttle(QObject):
super().__init__(parent)
self._delay_ms = delay_ms
self._func = func
- self._pending_call = None # type: typing.Optional[_CallArgs]
- self._last_call_ms = None # type: typing.Optional[int]
+ self._pending_call: Optional[_CallArgs] = None
+ self._last_call_ms: Optional[int] = None
self._timer = usertypes.Timer(self, 'throttle-timer')
self._timer.setSingleShot(True)
@@ -71,7 +71,7 @@ class Throttle(QObject):
self._pending_call = None
self._last_call_ms = int(time.monotonic() * 1000)
- def __call__(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
cur_time_ms = int(time.monotonic() * 1000)
if self._pending_call is None:
if (self._last_call_ms is None or
diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py
index 328e77531..fa327b772 100644
--- a/qutebrowser/misc/utilcmds.py
+++ b/qutebrowser/misc/utilcmds.py
@@ -24,6 +24,7 @@
import functools
import os
import traceback
+from typing import Optional
from PyQt5.QtCore import QUrl
from PyQt5.QtWidgets import QApplication
@@ -34,22 +35,24 @@ 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
@cmdutils.register(maxsplit=1, no_cmd_split=True, no_replace_variables=True)
@cmdutils.argument('win_id', value=cmdutils.Value.win_id)
-def later(ms: int, command: str, win_id: int) -> None:
+def later(duration: str, command: str, win_id: int) -> None:
"""Execute a command after some time.
Args:
- ms: How many milliseconds to wait.
+ duration: Duration to wait in format XhYmZs or a number for milliseconds.
command: The command to run, with optional args.
"""
- if ms < 0:
- raise cmdutils.CommandError("I can't run something in the past!")
+ try:
+ ms = utils.parse_duration(duration)
+ except ValueError as e:
+ raise cmdutils.CommandError(e)
commandrunner = runners.CommandRunner(win_id)
timer = usertypes.Timer(name='later', parent=QApplication.instance())
try:
@@ -224,24 +227,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 +238,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 +274,19 @@ def version(win_id: int, paste: bool = False) -> None:
if paste:
pastebin_version()
+
+
+_keytester_widget: Optional[miscwidgets.KeyTesterWidget] = None
+
+
+@cmdutils.register(debug=True)
+def debug_keytester() -> None:
+ """Show a keytester widget."""
+ global _keytester_widget
+ if (_keytester_widget and
+ not sip.isdeleted(_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..a9cd8ed90 100644
--- a/qutebrowser/qt.py
+++ b/qutebrowser/qt.py
@@ -20,9 +20,4 @@
"""Wrappers around Qt/PyQt code."""
# pylint: disable=unused-import
-# PyQt 5.11 comes with a bundled sip,
-# for older PyQt versions it's a separate module.
-try:
- from PyQt5 import sip
-except ImportError:
- import sip # type: ignore
+from PyQt5 import sip
diff --git a/qutebrowser/qutebrowser.py b/qutebrowser/qutebrowser.py
index 129e8f745..7f36d4807 100644
--- a/qutebrowser/qutebrowser.py
+++ b/qutebrowser/qutebrowser.py
@@ -77,26 +77,23 @@ def get_argparser():
action='store_true')
parser.add_argument('--target', choices=['auto', 'tab', 'tab-bg',
'tab-silent', 'tab-bg-silent',
- 'window'],
+ 'window', 'private-window'],
help="How URLs should be opened if there is already a "
"qutebrowser instance running.")
parser.add_argument('--backend', choices=['webkit', 'webengine'],
help="Which backend to use.")
- parser.add_argument('--enable-webengine-inspector', action='store_true',
- help="Enable the web inspector for QtWebEngine. Note "
- "that this is a SECURITY RISK and you should not "
- "visit untrusted websites with the inspector turned "
- "on. See https://bugreports.qt.io/browse/QTBUG-50725 "
- "for more details. This is not needed anymore since "
- "Qt 5.11 where the inspector is always enabled and "
- "secure.")
parser.add_argument('--json-args', help=argparse.SUPPRESS)
parser.add_argument('--temp-basedir-restarted', help=argparse.SUPPRESS)
+ parser.add_argument('--desktop-file-name',
+ default="org.qutebrowser.qutebrowser",
+ help="Set the base name of the desktop entry for this "
+ "application. Used to set the app_id under Wayland. See "
+ "https://doc.qt.io/qt-5/qguiapplication.html#desktopFileName-prop")
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 +147,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 +163,18 @@ 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.
+ wait-renderer-process: Wait for debugger in renderer process.
+ avoid-chromium-init: Enable `--version` without initializing Chromium.
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',
+ 'log-scroll-pos', 'stack', 'chromium',
+ 'wait-renderer-process', 'avoid-chromium-init', 'werror']
if flag in valid_flags:
return flag
diff --git a/qutebrowser/utils/debug.py b/qutebrowser/utils/debug.py
index 395f41a1b..229a26ead 100644
--- a/qutebrowser/utils/debug.py
+++ b/qutebrowser/utils/debug.py
@@ -24,21 +24,23 @@ import inspect
import logging
import functools
import datetime
-import typing
import types
+from typing import (
+ Any, Callable, List, Mapping, MutableSequence, Optional, Sequence, Type, Union)
-from PyQt5.QtCore import Qt, QEvent, QMetaMethod, QObject, pyqtSignal
+from PyQt5.QtCore import Qt, QEvent, QMetaMethod, QObject, pyqtBoundSignal
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:
+def log_events(klass: Type) -> Type:
"""Class decorator to log Qt events."""
old_event = klass.event
@functools.wraps(old_event)
- def new_event(self: typing.Any, e: QEvent) -> bool:
+ def new_event(self: Any, e: QEvent) -> bool:
"""Wrapper for event() which logs events."""
log.misc.debug("Event in {}: {}".format(utils.qualname(klass),
qenum_key(QEvent, e.type())))
@@ -53,7 +55,7 @@ def log_signals(obj: QObject) -> QObject:
Can be used as class decorator.
"""
- def log_slot(obj: QObject, signal: pyqtSignal, *args: typing.Any) -> None:
+ def log_slot(obj: QObject, signal: pyqtBoundSignal, *args: Any) -> None:
"""Slot connected to a signal to log it."""
dbg = dbg_signal(signal, args)
try:
@@ -69,7 +71,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,27 +81,25 @@ 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,
- *args: typing.Any,
- **kwargs: typing.Any) -> None:
+ def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
"""Wrapper for __init__() which logs signals."""
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)
return obj
-def qenum_key(base: typing.Type,
- value: int,
+def qenum_key(base: Type,
+ value: Union[int, sip.simplewrapper],
add_base: bool = False,
- klass: typing.Type = None) -> str:
+ klass: Type = None) -> str:
"""Convert a Qt Enum value to its key as a string.
Args:
@@ -121,7 +121,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 +131,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])
@@ -139,10 +139,10 @@ def qenum_key(base: typing.Type,
return ret
-def qflags_key(base: typing.Type,
- value: int,
+def qflags_key(base: Type,
+ value: Union[int, sip.simplewrapper],
add_base: bool = False,
- klass: typing.Type = None) -> str:
+ klass: Type = None) -> str:
"""Convert a Qt QFlags value to its keys as string.
Note: Passing a combined value (such as Qt.AlignCenter) will get the names
@@ -174,7 +174,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,22 +182,22 @@ 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)
-def signal_name(sig: pyqtSignal) -> str:
+def signal_name(sig: pyqtBoundSignal) -> str:
"""Get a cleaned up name of a signal.
- Unfortunately, the way to get the name of a signal differs based on:
- - PyQt versions (5.11 added .signatures for unbound signals)
- - Bound vs. unbound signals
+ Unfortunately, the way to get the name of a signal differs based on
+ bound vs. unbound signals.
Here, we try to get the name from .signal or .signatures, or if all else
fails, extract it from the repr().
Args:
- sig: The pyqtSignal
+ sig: A bound signal.
Return:
The cleaned up signal name.
@@ -207,37 +207,20 @@ def signal_name(sig: pyqtSignal) -> str:
# Examples:
# sig.signal == '2signal1'
# sig.signal == '2signal2(QString,QString)'
- m = re.fullmatch(r'[0-9]+(?P<name>.*)\(.*\)',
- sig.signal) # type: ignore
- elif hasattr(sig, 'signatures'):
+ m = re.fullmatch(r'[0-9]+(?P<name>.*)\(.*\)', sig.signal)
+ else:
# 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
- else: # pragma: no cover
- # Unbound signal, PyQt < 5.11
- # Examples:
- # repr(sig) == "<unbound PYQT_SIGNAL SignalObject.signal1[]>"
- # repr(sig) == "<unbound PYQT_SIGNAL SignalObject.signal2[str, str]>"
- # repr(sig) == "<unbound PYQT_SIGNAL timeout()>"
- # repr(sig) == "<unbound PYQT_SIGNAL valueChanged(int)>"
- patterns = [
- r'<unbound PYQT_SIGNAL [^.]*\.(?P<name>[^[]*)\[.*>',
- r'<unbound PYQT_SIGNAL (?P<name>[^(]*)\(.*>',
- ]
- for pattern in patterns:
- m = re.fullmatch(pattern, repr(sig))
- if m is not None:
- break
+ sig.signatures[0]) # type: ignore[attr-defined]
assert m is not None, sig
return m.group('name')
-def format_args(args: typing.Sequence = None,
- kwargs: typing.Mapping = None) -> str:
+def format_args(args: Sequence = None, kwargs: Mapping = None) -> str:
"""Format a list of arguments/kwargs to a function-call like string."""
if args is not None:
arglist = [utils.compact_text(repr(arg), 200) for arg in args]
@@ -249,11 +232,11 @@ def format_args(args: typing.Sequence = None,
return ', '.join(arglist)
-def dbg_signal(sig: pyqtSignal, args: typing.Any) -> str:
+def dbg_signal(sig: pyqtBoundSignal, args: Any) -> str:
"""Get a string representation of a signal for debugging.
Args:
- sig: A pyqtSignal.
+ sig: A bound signal.
args: The arguments as list of strings.
Return:
@@ -262,9 +245,9 @@ def dbg_signal(sig: pyqtSignal, args: typing.Any) -> str:
return '{}({})'.format(signal_name(sig), format_args(args))
-def format_call(func: typing.Callable,
- args: typing.Sequence = None,
- kwargs: typing.Mapping = None,
+def format_call(func: Callable,
+ args: Sequence = None,
+ kwargs: Mapping = None,
full: bool = True) -> str:
"""Get a string representation of a function calls with the given args.
@@ -291,7 +274,7 @@ class log_time: # noqa: N801,N806 pylint: disable=invalid-name
Usable as context manager or as decorator.
"""
- def __init__(self, logger: typing.Union[logging.Logger, str],
+ def __init__(self, logger: Union[logging.Logger, str],
action: str = 'operation') -> None:
"""Constructor.
@@ -303,28 +286,25 @@ class log_time: # noqa: N801,N806 pylint: disable=invalid-name
self._logger = logging.getLogger(logger)
else:
self._logger = logger
- self._started = None # type: typing.Optional[datetime.datetime]
+ self._started: Optional[datetime.datetime] = None
self._action = action
def __enter__(self) -> None:
self._started = datetime.datetime.now()
- # The string annotation is a WORKAROUND for a Python 3.5.2 bug:
- # https://github.com/python/typing/issues/266
-
def __exit__(self,
- _exc_type: 'typing.Optional[typing.Type[BaseException]]',
- _exc_val: typing.Optional[BaseException],
- _exc_tb: typing.Optional[types.TracebackType]) -> None:
+ _exc_type: Optional[Type[BaseException]],
+ _exc_val: Optional[BaseException],
+ _exc_tb: Optional[types.TracebackType]) -> None:
assert self._started is not None
finished = datetime.datetime.now()
delta = (finished - self._started).total_seconds()
self._logger.debug("{} took {} seconds.".format(
self._action.capitalize(), delta))
- def __call__(self, func: typing.Callable) -> typing.Callable:
+ def __call__(self, func: Callable) -> Callable:
@functools.wraps(func)
- def wrapped(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
"""Call the original function."""
with self:
return func(*args, **kwargs)
@@ -332,14 +312,14 @@ class log_time: # noqa: N801,N806 pylint: disable=invalid-name
return wrapped
-def _get_widgets() -> typing.Sequence[str]:
+def _get_widgets() -> Sequence[str]:
"""Get a string list of all widgets."""
widgets = QApplication.instance().allWidgets()
widgets.sort(key=repr)
return [repr(w) for w in widgets]
-def _get_pyqt_objects(lines: typing.MutableSequence[str],
+def _get_pyqt_objects(lines: MutableSequence[str],
obj: QObject,
depth: int = 0) -> None:
"""Recursive method for get_all_objects to get Qt objects."""
@@ -360,7 +340,7 @@ def get_all_objects(start_obj: QObject = None) -> str:
if start_obj is None:
start_obj = QApplication.instance()
- pyqt_lines = [] # type: typing.List[str]
+ pyqt_lines: List[str] = []
_get_pyqt_objects(pyqt_lines, start_obj)
pyqt_lines = [' ' + e for e in pyqt_lines]
pyqt_lines.insert(0, 'Qt objects - {} objects:'.format(len(pyqt_lines)))
diff --git a/qutebrowser/utils/docutils.py b/qutebrowser/utils/docutils.py
index 0ef971dfc..f1ee11f84 100644
--- a/qutebrowser/utils/docutils.py
+++ b/qutebrowser/utils/docutils.py
@@ -25,7 +25,7 @@ import inspect
import os.path
import collections
import enum
-import typing
+from typing import Callable, MutableMapping, Optional, List, Union
import qutebrowser
from qutebrowser.utils import log, utils
@@ -77,21 +77,29 @@ class DocstringParser:
arg_descs: A dict of argument names to their descriptions
"""
- State = enum.Enum('State', ['short', 'desc', 'desc_hidden',
- 'arg_start', 'arg_inside', 'misc'])
+ class State(enum.Enum):
- def __init__(self, func: typing.Callable) -> None:
+ """The current state of the parser."""
+
+ short = enum.auto()
+ desc = enum.auto()
+ desc_hidden = enum.auto()
+ arg_start = enum.auto()
+ arg_inside = enum.auto()
+ misc = enum.auto()
+
+ def __init__(self, func: Callable) -> None:
"""Constructor.
Args:
func: The function to parse the docstring for.
"""
self._state = self.State.short
- self._cur_arg_name = None # type: typing.Optional[str]
- self._short_desc_parts = [] # type: typing.List[str]
- self._long_desc_parts = [] # type: typing.List[str]
- self.arg_descs = collections.OrderedDict(
- ) # type: typing.Dict[str, typing.Union[str, typing.List[str]]]
+ self._cur_arg_name: Optional[str] = None
+ self._short_desc_parts: List[str] = []
+ self._long_desc_parts: List[str] = []
+ self.arg_descs: MutableMapping[
+ str, Union[str, List[str]]] = collections.OrderedDict()
doc = inspect.getdoc(func)
handlers = {
self.State.short: self._parse_short,
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/javascript.py b/qutebrowser/utils/javascript.py
index 1b499a377..94068c640 100644
--- a/qutebrowser/utils/javascript.py
+++ b/qutebrowser/utils/javascript.py
@@ -19,10 +19,10 @@
"""Utilities related to javascript interaction."""
-import typing
+from typing import Sequence, Union
-_InnerJsArgType = typing.Union[None, str, bool, int, float]
-_JsArgType = typing.Union[_InnerJsArgType, typing.Sequence[_InnerJsArgType]]
+_InnerJsArgType = Union[None, str, bool, int, float]
+_JsArgType = Union[_InnerJsArgType, Sequence[_InnerJsArgType]]
def string_escape(text: str) -> str:
diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py
index a7bb1f686..c4ba703cc 100644
--- a/qutebrowser/utils/jinja.py
+++ b/qutebrowser/utils/jinja.py
@@ -21,10 +21,10 @@
import os
import os.path
-import typing
import functools
import contextlib
import html
+from typing import Any, Callable, FrozenSet, Iterator, List, Set, Tuple
import jinja2
import jinja2.nodes
@@ -68,7 +68,7 @@ class Loader(jinja2.BaseLoader):
self,
_env: jinja2.Environment,
template: str
- ) -> typing.Tuple[str, str, typing.Callable[[], bool]]:
+ ) -> Tuple[str, str, Callable[[], bool]]:
path = os.path.join(self._subdir, template)
try:
source = utils.read_file(path)
@@ -98,7 +98,7 @@ class Environment(jinja2.Environment):
self._autoescape = True
@contextlib.contextmanager
- def no_autoescape(self) -> typing.Iterator[None]:
+ def no_autoescape(self) -> Iterator[None]:
"""Context manager to temporarily turn off autoescaping."""
self._autoescape = False
yield
@@ -112,17 +112,16 @@ 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:
"""Get a data: url for the broken qutebrowser logo."""
data = utils.read_file(path, binary=True)
- filename = utils.resource_filename(path)
- mimetype = utils.guess_mimetype(filename)
+ mimetype = utils.guess_mimetype(path)
return urlutils.data_url(mimetype, data).toString()
- def getattr(self, obj: typing.Any, attribute: str) -> typing.Any:
+ def getattr(self, obj: Any, attribute: str) -> Any:
"""Override jinja's getattr() to be less clever.
This means it doesn't fall back to __getitem__, and it doesn't hide
@@ -131,7 +130,7 @@ class Environment(jinja2.Environment):
return getattr(obj, attribute)
-def render(template: str, **kwargs: typing.Any) -> str:
+def render(template: str, **kwargs: Any) -> str:
"""Render the given template and pass the given arguments to it."""
return environment.get_template(template).render(**kwargs)
@@ -142,10 +141,10 @@ js_environment = jinja2.Environment(loader=Loader('javascript'))
@debugcachestats.register()
@functools.lru_cache()
-def template_config_variables(template: str) -> typing.FrozenSet[str]:
+def template_config_variables(template: str) -> FrozenSet[str]:
"""Return the config variables used in the template."""
unvisted_nodes = [environment.parse(template)]
- result = set() # type: typing.Set[str]
+ result: Set[str] = set()
while unvisted_nodes:
node = unvisted_nodes.pop()
if not isinstance(node, jinja2.nodes.Getattr):
@@ -154,13 +153,13 @@ def template_config_variables(template: str) -> typing.FrozenSet[str]:
# List of attribute names in reverse order.
# For example it's ['ab', 'c', 'd'] for 'conf.d.c.ab'.
- attrlist = [] # type: typing.List[str]
+ attrlist: 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..9aedb419f 100644
--- a/qutebrowser/utils/log.py
+++ b/qutebrowser/utils/log.py
@@ -31,8 +31,9 @@ import traceback
import warnings
import json
import inspect
-import typing
import argparse
+from typing import (TYPE_CHECKING, Any, Iterator, Mapping, MutableSequence,
+ Optional, Set, Tuple, Union, cast)
from PyQt5 import QtCore
# Optional imports
@@ -41,6 +42,9 @@ try:
except ImportError:
colorama = None
+if TYPE_CHECKING:
+ from qutebrowser.config import config as configmodule
+
_log_inited = False
_args = None
@@ -81,10 +85,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,
@@ -95,8 +99,8 @@ LOG_LEVELS = {
def vdebug(self: logging.Logger,
msg: str,
- *args: typing.Any,
- **kwargs: typing.Any) -> None:
+ *args: Any,
+ **kwargs: Any) -> None:
"""Log with a VDEBUG level.
VDEBUG is used when a debug message is rather verbose, and probably of
@@ -109,7 +113,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.
@@ -156,8 +160,8 @@ LOGGER_NAMES = [
]
-ram_handler = None # type: typing.Optional[RAMHandler]
-console_handler = None # type: typing.Optional[logging.Handler]
+ram_handler: Optional['RAMHandler'] = None
+console_handler: Optional[logging.Handler] = None
console_filter = None
@@ -176,7 +180,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 +194,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 +211,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
@@ -239,19 +234,9 @@ def _init_py_warnings() -> None:
@contextlib.contextmanager
-def disable_qt_msghandler() -> typing.Iterator[None]:
- """Contextmanager which temporarily disables the Qt message handler."""
- old_handler = QtCore.qInstallMessageHandler(None)
- try:
- yield
- finally:
- QtCore.qInstallMessageHandler(old_handler)
-
-
-@contextlib.contextmanager
-def ignore_py_warnings(**kwargs: typing.Any) -> typing.Iterator[None]:
+def py_warning_filter(action: str = 'ignore', **kwargs: Any) -> Iterator[None]:
"""Contextmanager to temporarily disable certain Python warnings."""
- warnings.filterwarnings('ignore', **kwargs)
+ warnings.filterwarnings(action, **kwargs)
yield
if _log_inited:
_init_py_warnings()
@@ -263,7 +248,7 @@ def _init_handlers(
force_color: bool,
json_logging: bool,
ram_capacity: int
-) -> typing.Tuple[logging.StreamHandler, typing.Optional['RAMHandler']]:
+) -> Tuple[logging.StreamHandler, Optional['RAMHandler']]:
"""Init log handlers.
Args:
@@ -278,7 +263,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 +278,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
@@ -317,8 +302,8 @@ def _init_formatters(
color: bool,
force_color: bool,
json_logging: bool
-) -> typing.Tuple[typing.Union['JSONFormatter', 'ColoredFormatter'],
- 'ColoredFormatter', 'HTMLFormatter', bool]:
+) -> Tuple[Union['JSONFormatter', 'ColoredFormatter'],
+ 'ColoredFormatter', 'HTMLFormatter', bool]:
"""Init log formatters.
Args:
@@ -337,14 +322,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:
@@ -367,7 +355,7 @@ def change_console_formatter(level: int) -> None:
"""
assert console_handler is not None
- old_formatter = typing.cast(ColoredFormatter, console_handler.formatter)
+ old_formatter = cast(ColoredFormatter, console_handler.formatter)
console_fmt = get_console_format(level)
console_formatter = ColoredFormatter(console_fmt, DATEFMT, '{',
use_colors=old_formatter.use_colors)
@@ -396,7 +384,9 @@ def qt_message_handler(msg_type: QtCore.QtMsgType,
try:
qt_to_logging[QtCore.QtInfoMsg] = logging.INFO
except AttributeError:
- # While we don't support Qt < 5.5 anymore, logging still needs to work
+ # Added in Qt 5.5.
+ # While we don't support Qt < 5.5 anymore, logging still needs to work so that
+ # the Qt version warning in earlyinit.py does.
pass
# Change levels of some well-known messages to debug so they don't get
@@ -480,14 +470,19 @@ def qt_message_handler(msg_type: QtCore.QtMsgType,
else:
level = qt_to_logging[msg_type]
+ if context.line is None:
+ lineno = -1 # type: ignore[unreachable]
+ else:
+ lineno = context.line
+
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:
@@ -502,17 +497,18 @@ def qt_message_handler(msg_type: QtCore.QtMsgType,
assert _args is not None
if _args.debug:
- stack = ''.join(traceback.format_stack()) # type: typing.Optional[str]
+ stack: Optional[str] = ''.join(traceback.format_stack())
else:
stack = None
- record = qt.makeRecord(name, level, context.file, context.line, msg, (),
- None, func, sinfo=stack)
+ record = qt.makeRecord(name=name, level=level, fn=context.file, lno=lineno,
+ msg=msg, args=(), exc_info=None, func=func,
+ sinfo=stack)
qt.handle(record)
@contextlib.contextmanager
-def hide_qt_warning(pattern: str, logger: str = 'qt') -> typing.Iterator[None]:
+def hide_qt_warning(pattern: str, logger: str = 'qt') -> Iterator[None]:
"""Hide Qt warnings matching the given regex."""
log_filter = QtWarningFilter(pattern)
logger_obj = logging.getLogger(logger)
@@ -523,6 +519,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 +567,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: 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 +586,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: 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: 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
@@ -589,28 +654,35 @@ class RAMHandler(logging.Handler):
def __init__(self, capacity: int) -> None:
super().__init__()
- self.html_formatter = None # type: typing.Optional[HTMLFormatter]
+ self.html_formatter: Optional[HTMLFormatter] = None
if capacity != -1:
- self._data = collections.deque(
+ self._data: MutableSequence[logging.LogRecord] = collections.deque(
maxlen=capacity
- ) # type: typing.MutableSequence[logging.LogRecord]
+ )
else:
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 +693,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)
@@ -668,9 +741,7 @@ class HTMLFormatter(logging.Formatter):
_colordict: The colordict passed to the logger.
"""
- def __init__(self, fmt: str,
- datefmt: str,
- log_colors: typing.Mapping[str, str]) -> None:
+ def __init__(self, fmt: str, datefmt: str, log_colors: Mapping[str, str]) -> None:
"""Constructor.
Args:
@@ -679,8 +750,8 @@ class HTMLFormatter(logging.Formatter):
log_colors: The colors to use for logging levels.
"""
super().__init__(fmt, datefmt)
- self._log_colors = log_colors # type: typing.Mapping[str, str]
- self._colordict = {} # type: typing.Mapping[str, str]
+ self._log_colors: Mapping[str, str] = log_colors
+ self._colordict: Mapping[str, str] = {}
# We could solve this nicer by using CSS, but for this simple case this
# works.
for color in COLORS:
@@ -692,9 +763,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..1009f1e98 100644
--- a/qutebrowser/utils/message.py
+++ b/qutebrowser/utils/message.py
@@ -23,11 +23,11 @@
"""Message singleton so we don't have to define unneeded signals."""
import traceback
-import typing
+from typing import Any, Callable, Iterable, List, Tuple, Union
-from PyQt5.QtCore import pyqtSignal, QObject, QUrl
+from PyQt5.QtCore import pyqtSignal, pyqtBoundSignal, QObject
-from qutebrowser.utils import usertypes, log, utils
+from qutebrowser.utils import usertypes, log
def _log_stack(typ: str, stack: str) -> None:
@@ -86,9 +86,9 @@ def info(message: str, *, replace: bool = False) -> None:
def _build_question(title: str,
text: str = None, *,
mode: usertypes.PromptMode,
- default: typing.Union[None, bool, str] = None,
- abort_on: typing.Iterable[pyqtSignal] = (),
- url: QUrl = None,
+ default: Union[None, bool, str] = None,
+ abort_on: Iterable[pyqtBoundSignal] = (),
+ url: str = None,
option: bool = None) -> usertypes.Question:
"""Common function for ask/ask_async."""
question = usertypes.Question()
@@ -110,7 +110,7 @@ def _build_question(title: str,
return question
-def ask(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+def ask(*args: Any, **kwargs: Any) -> Any:
"""Ask a modular question in the statusbar (blocking).
Args:
@@ -134,8 +134,8 @@ def ask(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
def ask_async(title: str,
mode: usertypes.PromptMode,
- handler: typing.Callable[[typing.Any], None],
- **kwargs: typing.Any) -> None:
+ handler: Callable[[Any], None],
+ **kwargs: Any) -> None:
"""Ask an async question in the statusbar.
Args:
@@ -151,13 +151,13 @@ def ask_async(title: str,
global_bridge.ask(question, blocking=False)
-_ActionType = typing.Callable[[], typing.Any]
+_ActionType = Callable[[], Any]
def confirm_async(*, yes_action: _ActionType,
no_action: _ActionType = None,
cancel_action: _ActionType = None,
- **kwargs: typing.Any) -> usertypes.Question:
+ **kwargs: Any) -> usertypes.Question:
"""Ask a yes/no question to the user and execute the given actions.
Args:
@@ -219,8 +219,7 @@ class GlobalMessageBridge(QObject):
def __init__(self, parent: QObject = None) -> None:
super().__init__(parent)
self._connected = False
- self._cache = [
- ] # type: typing.List[typing.Tuple[usertypes.MessageLevel, str, bool]]
+ self._cache: List[Tuple[usertypes.MessageLevel, str, bool]] = []
def ask(self, question: usertypes.Question,
blocking: bool, *,
@@ -259,42 +258,4 @@ class GlobalMessageBridge(QObject):
self._cache = []
-class MessageBridge(QObject):
-
- """Bridge for messages to be shown in the statusbar.
-
- Signals:
- s_set_text: Set a persistent text in the statusbar.
- arg: The text to set.
- s_maybe_reset_text: Reset the text if it hasn't been changed yet.
- arg: The expected text.
- """
-
- s_set_text = pyqtSignal(str)
- s_maybe_reset_text = pyqtSignal(str)
-
- def __repr__(self) -> str:
- return utils.get_repr(self)
-
- def set_text(self, text: str, *, log_stack: bool = False) -> None:
- """Set the normal text of the statusbar.
-
- Args:
- text: The text to set.
- log_stack: ignored
- """
- text = str(text)
- log.message.debug(text)
- self.s_set_text.emit(text)
-
- def maybe_reset_text(self, text: str, *, log_stack: bool = False) -> None:
- """Reset the text in the statusbar if it matches an expected text.
-
- Args:
- text: The expected text.
- log_stack: ignored
- """
- self.s_maybe_reset_text.emit(str(text))
-
-
global_bridge = GlobalMessageBridge()
diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py
index fa6097586..c74cb9873 100644
--- a/qutebrowser/utils/objreg.py
+++ b/qutebrowser/utils/objreg.py
@@ -22,18 +22,19 @@
import collections
import functools
-import typing
+from typing import (TYPE_CHECKING, Any, Callable, MutableMapping, MutableSequence,
+ Optional, Sequence, Union)
from PyQt5.QtCore import QObject, QTimer
from PyQt5.QtWidgets import QApplication
-from PyQt5.QtWidgets import QWidget # pylint: disable=unused-import
+from PyQt5.QtWidgets import QWidget
-from qutebrowser.utils import log, usertypes
-if typing.TYPE_CHECKING:
+from qutebrowser.utils import log, usertypes, utils
+if TYPE_CHECKING:
from qutebrowser.mainwindow import mainwindow
-_WindowTab = typing.Union[str, int, None]
+_WindowTab = Union[str, int, None]
class RegistryUnavailableError(Exception):
@@ -51,7 +52,7 @@ class CommandOnlyError(Exception):
"""Raised when an object is requested which is used for commands only."""
-_IndexType = typing.Union[str, int]
+_IndexType = Union[str, int]
class ObjectRegistry(collections.UserDict):
@@ -67,11 +68,10 @@ class ObjectRegistry(collections.UserDict):
def __init__(self) -> None:
super().__init__()
- self._partial_objs = {
- } # type: typing.MutableMapping[_IndexType, typing.Callable[[], None]]
- self.command_only = [] # type: typing.MutableSequence[str]
+ self._partial_objs: MutableMapping[_IndexType, Callable[[], None]] = {}
+ self.command_only: MutableSequence[str] = []
- def __setitem__(self, name: _IndexType, obj: typing.Any) -> None:
+ def __setitem__(self, name: _IndexType, obj: Any) -> None:
"""Register an object in the object registry.
Sets a slot to remove QObjects when they are destroyed.
@@ -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)
@@ -101,7 +101,7 @@ class ObjectRegistry(collections.UserDict):
try:
partial_objs = self._partial_objs
except AttributeError:
- # This sometimes seems to happen on Travis during
+ # This sometimes seems to happen on CI during
# test_history.test_adding_item_during_async_read
# and I have no idea why...
return
@@ -129,7 +129,7 @@ class ObjectRegistry(collections.UserDict):
"""Remove a destroyed QObject."""
log.destroy.debug("removed: {}".format(name))
if not hasattr(self, 'data'):
- # This sometimes seems to happen on Travis during
+ # This sometimes seems to happen on CI during
# test_history.test_adding_item_during_async_read
# and I have no idea why...
return
@@ -139,7 +139,7 @@ class ObjectRegistry(collections.UserDict):
except KeyError:
pass
- def dump_objects(self) -> typing.Sequence[str]:
+ def dump_objects(self) -> Sequence[str]:
"""Dump all objects as a list of strings."""
lines = []
for name, obj in self.data.items():
@@ -166,13 +166,11 @@ def _get_tab_registry(win_id: _WindowTab,
if tab_id is None:
raise ValueError("Got tab_id None (win_id {})".format(win_id))
if tab_id == 'current' and win_id is None:
- window = QApplication.activeWindow() # type: typing.Optional[QWidget]
+ window: Optional[QWidget] = QApplication.activeWindow()
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':
@@ -194,7 +192,7 @@ def _get_window_registry(window: _WindowTab) -> ObjectRegistry:
raise TypeError("window is None with scope window!")
try:
if window == 'current':
- win = QApplication.activeWindow() # type: typing.Optional[QWidget]
+ win: Optional[QWidget] = QApplication.activeWindow()
elif window == 'last-focused':
win = last_focused_window()
else:
@@ -230,11 +228,11 @@ def _get_registry(scope: str,
def get(name: str,
- default: typing.Any = usertypes.UNSET,
+ default: Any = usertypes.UNSET,
scope: str = 'global',
window: _WindowTab = None,
tab: _WindowTab = None,
- from_command: bool = False) -> typing.Any:
+ from_command: bool = False) -> Any:
"""Helper function to get an object.
Args:
@@ -255,7 +253,7 @@ def get(name: str,
def register(name: str,
- obj: typing.Any,
+ obj: Any,
update: bool = False,
scope: str = None,
registry: ObjectRegistry = None,
@@ -298,7 +296,7 @@ def delete(name: str,
del reg[name]
-def dump_objects() -> typing.Sequence[str]:
+def dump_objects() -> Sequence[str]:
"""Get all registered objects in all registries as a string."""
blocks = []
lines = []
@@ -323,22 +321,50 @@ def dump_objects() -> typing.Sequence[str]:
def last_visible_window() -> 'mainwindow.MainWindow':
"""Get the last visible window, or the last focused window if none."""
try:
- return get('last-visible-main-window')
+ window = get('last-visible-main-window')
except KeyError:
return last_focused_window()
+ if window.tabbed_browser.is_shutting_down:
+ return last_focused_window()
+ return window
def last_focused_window() -> 'mainwindow.MainWindow':
"""Get the last focused window, or the last window if none."""
try:
- return get('last-focused-main-window')
+ window = get('last-focused-main-window')
except KeyError:
- return window_by_index(-1)
+ return last_opened_window()
+ if window.tabbed_browser.is_shutting_down:
+ return last_opened_window()
+ return window
-def window_by_index(idx: int) -> 'mainwindow.MainWindow':
+def _window_by_index(idx: int) -> 'mainwindow.MainWindow':
"""Get the Nth opened window object."""
if not window_registry:
raise NoWindow()
key = sorted(window_registry)[idx]
return window_registry[key]
+
+
+def last_opened_window() -> 'mainwindow.MainWindow':
+ """Get the last opened window object."""
+ if not window_registry:
+ raise NoWindow()
+ for idx in range(-1, -(len(window_registry)+1), -1):
+ window = _window_by_index(idx)
+ if not window.tabbed_browser.is_shutting_down:
+ return window
+ raise utils.Unreachable()
+
+
+def first_opened_window() -> 'mainwindow.MainWindow':
+ """Get the first opened window object."""
+ if not window_registry:
+ raise NoWindow()
+ for idx in range(0, len(window_registry)+1):
+ window = _window_by_index(idx)
+ if not window.tabbed_browser.is_shutting_down:
+ return window
+ raise utils.Unreachable()
diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py
index eb123e634..cd6ea2b32 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:
@@ -26,28 +24,30 @@ Module attributes:
value.
MINVALS: A dictionary of C/Qt types (as string) mapped to their minimum
value.
- MAX_WORLD_ID: The highest world ID allowed in this version of QtWebEngine.
+ MAX_WORLD_ID: The highest world ID allowed by QtWebEngine.
"""
import io
import operator
import contextlib
-import typing
+from typing import TYPE_CHECKING, BinaryIO, IO, Iterator, Optional, Union, Tuple, cast
-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
+if TYPE_CHECKING:
+ from PyQt5.QtWebKit import QWebHistory
+ from PyQt5.QtWebEngineWidgets import QWebEngineHistory
from qutebrowser.misc import objects
-from qutebrowser.utils import usertypes
+from qutebrowser.utils import usertypes, utils
MAXVALS = {
@@ -63,23 +63,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: Optional[QFileDevice.FileError] = None
+ 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,
@@ -95,33 +99,33 @@ def version_check(version: str,
if compiled and exact:
raise ValueError("Can't use compiled=True with exact=True!")
- parsed = pkg_resources.parse_version(version)
+ parsed = utils.parse_version(version)
op = operator.eq if exact else operator.ge
- result = op(pkg_resources.parse_version(qVersion()), parsed)
+ result = op(utils.parse_version(qVersion()), parsed)
if compiled and result:
# qVersion() ==/>= parsed, now check if QT_VERSION_STR ==/>= parsed.
- result = op(pkg_resources.parse_version(QT_VERSION_STR), parsed)
+ result = op(utils.parse_version(QT_VERSION_STR), parsed)
if compiled and result:
- # FInally, check PYQT_VERSION_STR as well.
- result = op(pkg_resources.parse_version(PYQT_VERSION_STR), parsed)
+ # Finally, check PYQT_VERSION_STR as well.
+ result = op(utils.parse_version(PYQT_VERSION_STR), parsed)
return result
-# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-69904
-MAX_WORLD_ID = 256 if version_check('5.11.2') else 11
+MAX_WORLD_ID = 256
def is_new_qtwebkit() -> bool:
"""Check if the given version is a new QtWebKit."""
assert qWebKitVersion is not None
- return (pkg_resources.parse_version(qWebKitVersion()) >
- pkg_resources.parse_version('538.1'))
+ return (utils.parse_version(qWebKitVersion()) >
+ utils.parse_version('538.1'))
def is_single_process() -> bool:
"""Check whether QtWebEngine is running in single-process mode."""
if objects.backend == usertypes.Backend.QtWebKit:
return False
+ assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend
args = QApplication.instance().arguments()
return '--single-process' in args
@@ -152,7 +156,15 @@ def check_overflow(arg: int, ctype: str, fatal: bool = True) -> int:
return arg
-def ensure_valid(obj: QObject) -> None:
+class Validatable(utils.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 +184,16 @@ def check_qdatastream(stream: QDataStream) -> None:
raise OSError(status_to_str[stream.status()])
-def serialize(obj: QObject) -> QByteArray:
+_QtSerializableType = Union[
+ QObject,
+ QByteArray,
+ QUrl,
+ 'QWebEngineHistory',
+ 'QWebHistory'
+]
+
+
+def serialize(obj: _QtSerializableType) -> QByteArray:
"""Serialize an object into a QByteArray."""
data = QByteArray()
stream = QDataStream(data, QIODevice.WriteOnly)
@@ -180,23 +201,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 +228,7 @@ def savefile_open(
filename: str,
binary: bool = False,
encoding: str = 'utf-8'
-) -> typing.Iterator[typing.Union['PyQIODevice', io.TextIOWrapper]]:
+) -> Iterator[IO]:
"""Context manager to easily use a QSaveFile."""
f = QSaveFile(filename)
cancelled = False
@@ -214,12 +237,12 @@ def savefile_open(
if not open_ok:
raise QtOSError(f)
+ dev = cast(BinaryIO, PyQIODevice(f))
+
if binary:
- new_f = PyQIODevice(
- f) # type: typing.Union[PyQIODevice, io.TextIOWrapper]
+ new_f: IO = dev
else:
- new_f = io.TextIOWrapper(PyQIODevice(f), # type: ignore
- encoding=encoding)
+ new_f = io.TextIOWrapper(dev, encoding=encoding)
yield new_f
@@ -335,28 +358,34 @@ class PyQIODevice(io.BufferedIOBase):
def readable(self) -> bool:
return self.dev.isReadable()
- def readline(self, size: int = -1) -> QByteArray:
+ def readline(self, size: Optional[int] = -1) -> bytes:
self._check_open()
self._check_readable()
- if size < 0:
+ if size is None or 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: Union[QByteArray, bytes, None] = None
if self.dev.canReadLine():
buf = self.dev.readLine(qt_size)
+ elif size is None or 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 +398,36 @@ class PyQIODevice(io.BufferedIOBase):
def writable(self) -> bool:
return self.dev.isWritable()
- def write(self, data: str) -> int: # type: ignore
+ def write( # type: ignore[override]
+ self,
+ data: 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: Optional[int] = None) -> bytes:
self._check_open()
self._check_readable()
+ buf: Union[QByteArray, bytes, None] = 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 +435,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 +459,88 @@ class EventLoop(QEventLoop):
def exec_(
self,
- flags: QEventLoop.ProcessEventsFlag = QEventLoop.AllEvents
+ flags: QEventLoop.ProcessEventsFlags =
+ 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
+
+
+def _get_color_percentage(x1: int, y1: int, z1: int, a1: int,
+ x2: int, y2: int, z2: int, a2: int,
+ percent: int) -> Tuple[int, int, int, int]:
+ """Get a color which is percent% interpolated between start and end.
+
+ Args:
+ x1, y1, z1, a1 : Start color components (R, G, B, A / H, S, V, A / H, S, L, A)
+ x2, y2, z2, a2 : End color components (R, G, B, A / H, S, V, A / H, S, L, A)
+ percent: Percentage to interpolate, 0-100.
+ 0: Start color will be returned.
+ 100: End color will be returned.
+
+ Return:
+ A (x, y, z, alpha) tuple with the interpolated color components.
+ """
+ if not 0 <= percent <= 100:
+ raise ValueError("percent needs to be between 0 and 100!")
+ x = round(x1 + (x2 - x1) * percent / 100)
+ y = round(y1 + (y2 - y1) * percent / 100)
+ z = round(z1 + (z2 - z1) * percent / 100)
+ a = round(a1 + (a2 - a1) * percent / 100)
+ return (x, y, z, a)
+
+
+def interpolate_color(
+ start: QColor,
+ end: QColor,
+ percent: int,
+ colorspace: Optional[QColor.Spec] = QColor.Rgb
+) -> QColor:
+ """Get an interpolated color value.
+
+ Args:
+ start: The start color.
+ end: The end color.
+ percent: Which value to get (0 - 100)
+ colorspace: The desired interpolation color system,
+ QColor::{Rgb,Hsv,Hsl} (from QColor::Spec enum)
+ If None, start is used except when percent is 100.
+
+ Return:
+ The interpolated QColor, with the same spec as the given start color.
+ """
+ ensure_valid(start)
+ ensure_valid(end)
+
+ if colorspace is None:
+ if percent == 100:
+ return QColor(*end.getRgb())
+ else:
+ return QColor(*start.getRgb())
+
+ out = QColor()
+ if colorspace == QColor.Rgb:
+ r1, g1, b1, a1 = start.getRgb()
+ r2, g2, b2, a2 = end.getRgb()
+ components = _get_color_percentage(r1, g1, b1, a1, r2, g2, b2, a2, percent)
+ out.setRgb(*components)
+ elif colorspace == QColor.Hsv:
+ h1, s1, v1, a1 = start.getHsv()
+ h2, s2, v2, a2 = end.getHsv()
+ components = _get_color_percentage(h1, s1, v1, a1, h2, s2, v2, a2, percent)
+ out.setHsv(*components)
+ elif colorspace == QColor.Hsl:
+ h1, s1, l1, a1 = start.getHsl()
+ h2, s2, l2, a2 = end.getHsl()
+ components = _get_color_percentage(h1, s1, l1, a1, h2, s2, l2, a2, percent)
+ out.setHsl(*components)
+ else:
+ raise ValueError("Invalid colorspace!")
+ out = out.convertTo(start.spec())
+ ensure_valid(out)
+ return out
diff --git a/qutebrowser/utils/standarddir.py b/qutebrowser/utils/standarddir.py
index ce5a20634..ff4afc83c 100644
--- a/qutebrowser/utils/standarddir.py
+++ b/qutebrowser/utils/standarddir.py
@@ -22,16 +22,15 @@
import os
import os.path
import sys
-import shutil
import contextlib
import enum
import argparse
-import typing
+from typing import Iterator, Optional
from PyQt5.QtCore import QStandardPaths
from PyQt5.QtWidgets import QApplication
-from qutebrowser.utils import log, debug, message, utils
+from qutebrowser.utils import log, debug, utils
# The cached locations
_locations = {}
@@ -41,14 +40,14 @@ class _Location(enum.Enum):
"""A key for _locations."""
- config = 1
- auto_config = 2
- data = 3
- system_data = 4
- cache = 5
- download = 6
- runtime = 7
- config_py = 8
+ config = enum.auto()
+ auto_config = enum.auto()
+ data = enum.auto()
+ system_data = enum.auto()
+ cache = enum.auto()
+ download = enum.auto()
+ runtime = enum.auto()
+ config_py = enum.auto()
APPNAME = 'qutebrowser'
@@ -60,7 +59,7 @@ class EmptyValueError(Exception):
@contextlib.contextmanager
-def _unset_organization() -> typing.Iterator[None]:
+def _unset_organization() -> Iterator[None]:
"""Temporarily unset QApplication.organizationName().
This is primarily needed in config.py.
@@ -68,7 +67,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:
@@ -76,7 +75,7 @@ def _unset_organization() -> typing.Iterator[None]:
qapp.setOrganizationName(orgname)
-def _init_config(args: typing.Optional[argparse.Namespace]) -> None:
+def _init_config(args: Optional[argparse.Namespace]) -> None:
"""Initialize the location for configs."""
typ = QStandardPaths.ConfigLocation
path = _from_args(typ, args)
@@ -127,13 +126,13 @@ def config_py() -> str:
return _locations[_Location.config_py]
-def _init_data(args: typing.Optional[argparse.Namespace]) -> None:
+def _init_data(args: Optional[argparse.Namespace]) -> None:
"""Initialize the location for data."""
- typ = QStandardPaths.DataLocation
+ typ = QStandardPaths.AppDataLocation
path = _from_args(typ, args)
if path is None:
if utils.is_windows:
- app_data_path = _writable_location(QStandardPaths.AppDataLocation)
+ app_data_path = _writable_location(typ) # same location as config
path = os.path.join(app_data_path, 'data')
elif sys.platform.startswith('haiku'):
# HaikuOS returns an empty value for AppDataLocation
@@ -167,14 +166,14 @@ def data(system: bool = False) -> str:
return _locations[_Location.data]
-def _init_cache(args: typing.Optional[argparse.Namespace]) -> None:
+def _init_cache(args: Optional[argparse.Namespace]) -> None:
"""Initialize the location for the cache."""
typ = QStandardPaths.CacheLocation
path = _from_args(typ, args)
if path is None:
if utils.is_windows:
# Local, not Roaming!
- data_path = _writable_location(QStandardPaths.DataLocation)
+ data_path = _writable_location(QStandardPaths.AppLocalDataLocation)
path = os.path.join(data_path, 'cache')
else:
path = _writable_location(typ)
@@ -187,7 +186,7 @@ def cache() -> str:
return _locations[_Location.cache]
-def _init_download(args: typing.Optional[argparse.Namespace]) -> None:
+def _init_download(args: Optional[argparse.Namespace]) -> None:
"""Initialize the location for downloads.
Note this is only the default directory as found by Qt.
@@ -204,7 +203,7 @@ def download() -> str:
return _locations[_Location.download]
-def _init_runtime(args: typing.Optional[argparse.Namespace]) -> None:
+def _init_runtime(args: Optional[argparse.Namespace]) -> None:
"""Initialize location for runtime data."""
if utils.is_mac or utils.is_windows:
# RuntimeLocation is a weird path on macOS and Windows.
@@ -251,11 +250,10 @@ def _writable_location(typ: QStandardPaths.StandardLocation) -> str:
# Types we are sure we handle correctly below.
assert typ in [
- QStandardPaths.ConfigLocation, QStandardPaths.DataLocation,
+ QStandardPaths.ConfigLocation, QStandardPaths.AppLocalDataLocation,
QStandardPaths.CacheLocation, QStandardPaths.DownloadLocation,
QStandardPaths.RuntimeLocation, QStandardPaths.TempLocation,
- # FIXME old Qt
- getattr(QStandardPaths, 'AppDataLocation', object())], typ_str
+ QStandardPaths.AppDataLocation], typ_str
with _unset_organization():
path = QStandardPaths.writableLocation(typ)
@@ -279,8 +277,8 @@ def _writable_location(typ: QStandardPaths.StandardLocation) -> str:
def _from_args(
typ: QStandardPaths.StandardLocation,
- args: typing.Optional[argparse.Namespace]
-) -> typing.Optional[str]:
+ args: Optional[argparse.Namespace]
+) -> Optional[str]:
"""Get the standard directory from an argparse namespace.
Return:
@@ -288,7 +286,8 @@ def _from_args(
"""
basedir_suffix = {
QStandardPaths.ConfigLocation: 'config',
- QStandardPaths.DataLocation: 'data',
+ QStandardPaths.AppDataLocation: 'data',
+ QStandardPaths.AppLocalDataLocation: 'data',
QStandardPaths.CacheLocation: 'cache',
QStandardPaths.DownloadLocation: 'download',
QStandardPaths.RuntimeLocation: 'runtime',
@@ -314,6 +313,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)
@@ -329,7 +331,7 @@ def _init_dirs(args: argparse.Namespace = None) -> None:
_init_runtime(args)
-def init(args: typing.Optional[argparse.Namespace]) -> None:
+def init(args: Optional[argparse.Namespace]) -> None:
"""Initialize all standard dirs."""
if args is not None:
# args can be None during tests
@@ -337,44 +339,6 @@ def init(args: typing.Optional[argparse.Namespace]) -> None:
_init_dirs(args)
_init_cachedir_tag()
- if args is not None and getattr(args, 'basedir', None) is None:
- if utils.is_mac: # pragma: no cover
- _move_macos()
- elif utils.is_windows: # pragma: no cover
- _move_windows()
-
-
-def _move_macos() -> None:
- """Move most config files to new location on macOS."""
- old_config = config(auto=True) # ~/Library/Preferences/qutebrowser
- new_config = config() # ~/.qutebrowser
- for f in os.listdir(old_config):
- if f not in ['qsettings', 'autoconfig.yml']:
- _move_data(os.path.join(old_config, f),
- os.path.join(new_config, f))
-
-
-def _move_windows() -> None:
- """Move the whole qutebrowser directory from Local to Roaming AppData."""
- # %APPDATA%\Local\qutebrowser
- old_appdata_dir = _writable_location(QStandardPaths.DataLocation)
- # %APPDATA%\Roaming\qutebrowser
- new_appdata_dir = _writable_location(QStandardPaths.AppDataLocation)
-
- # data subfolder
- old_data = os.path.join(old_appdata_dir, 'data')
- new_data = os.path.join(new_appdata_dir, 'data')
- ok = _move_data(old_data, new_data)
- if not ok: # pragma: no cover
- return
-
- # config files
- new_config_dir = os.path.join(new_appdata_dir, 'config')
- _create(new_config_dir)
- for f in os.listdir(old_appdata_dir):
- if f != 'cache':
- _move_data(os.path.join(old_appdata_dir, f),
- os.path.join(new_config_dir, f))
def _init_cachedir_tag() -> None:
@@ -394,33 +358,3 @@ def _init_cachedir_tag() -> None:
"cachedir/\n")
except OSError:
log.init.exception("Failed to create CACHEDIR.TAG")
-
-
-def _move_data(old: str, new: str) -> bool:
- """Migrate data from an old to a new directory.
-
- If the old directory does not exist, the migration is skipped.
- If the new directory already exists, an error is shown.
-
- Return: True if moving succeeded, False otherwise.
- """
- if not os.path.exists(old):
- return False
-
- log.init.debug("Migrating data from {} to {}".format(old, new))
-
- if os.path.exists(new):
- if not os.path.isdir(new) or os.listdir(new):
- message.error("Failed to move data from {} as {} is non-empty!"
- .format(old, new))
- return False
- os.rmdir(new)
-
- try:
- shutil.move(old, new)
- except OSError as e:
- message.error("Failed to move data from {} to {}: {}".format(
- old, new, e))
- return False
-
- return True
diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py
index 15478c05d..527d3403d 100644
--- a/qutebrowser/utils/urlmatch.py
+++ b/qutebrowser/utils/urlmatch.py
@@ -23,12 +23,16 @@ See:
https://developer.chrome.com/apps/match_patterns
https://cs.chromium.org/chromium/src/extensions/common/url_pattern.cc
https://cs.chromium.org/chromium/src/extensions/common/url_pattern.h
+
+Based on the following commit in Chromium:
+https://chromium.googlesource.com/chromium/src/+/6f4a6681eae01c2036336c18b06303e16a304a7c
+(October 10 2020, newest commit as per October 28th 2020)
"""
import ipaddress
import fnmatch
-import typing
import urllib.parse
+from typing import Any, Optional, Tuple
from PyQt5.QtCore import QUrl
@@ -69,11 +73,11 @@ class UrlPattern:
# Make sure all attributes are initialized if we exit early.
self._pattern = pattern
self._match_all = False
- self._match_subdomains = False # type: bool
- self._scheme = None # type: typing.Optional[str]
- self.host = None # type: typing.Optional[str]
- self._path = None # type: typing.Optional[str]
- self._port = None # type: typing.Optional[int]
+ self._match_subdomains: bool = False
+ self._scheme: Optional[str] = None
+ self.host: Optional[str] = None
+ self._path: Optional[str] = None
+ self._port: Optional[int] = None
# > The special pattern <all_urls> matches any URL that starts with a
# > permitted scheme.
@@ -100,7 +104,7 @@ class UrlPattern:
self._init_path(parsed)
self._init_port(parsed)
- def _to_tuple(self) -> typing.Tuple:
+ def _to_tuple(self) -> Tuple:
"""Get a pattern with information used for __eq__/__hash__."""
return (self._match_all, self._match_subdomains, self._scheme,
self.host, self._path, self._port)
@@ -108,7 +112,7 @@ class UrlPattern:
def __hash__(self) -> int:
return hash(self._to_tuple())
- def __eq__(self, other: typing.Any) -> bool:
+ def __eq__(self, other: Any) -> bool:
if not isinstance(other, UrlPattern):
return NotImplemented
return self._to_tuple() == other._to_tuple()
@@ -121,7 +125,7 @@ class UrlPattern:
def _fixup_pattern(self, pattern: str) -> str:
"""Make sure the given pattern is parseable by urllib.parse."""
- if pattern.startswith('*:'): # Any scheme, but *:// is unparseable
+ if pattern.startswith('*:'): # Any scheme, but *:// is unparsable
pattern = 'any:' + pattern[2:]
schemes = tuple(s + ':' for s in self._SCHEMES_WITHOUT_HOST)
@@ -174,6 +178,8 @@ class UrlPattern:
Deviation from Chromium:
- http://:1234/ is not a valid URL because it has no host.
+ - We don't allow patterns for dot/space hosts which QUrl considers
+ invalid.
"""
if parsed.hostname is None or not parsed.hostname.strip():
if self._scheme not in self._SCHEMES_WITHOUT_HOST:
@@ -190,24 +196,27 @@ class UrlPattern:
self.host = url.host()
return
- # FIXME what about multiple dots?
- host_parts = parsed.hostname.rstrip('.').split('.')
- if host_parts[0] == '*':
- host_parts = host_parts[1:]
+ if parsed.hostname == '*':
+ self._match_subdomains = True
+ hostname = None
+ elif parsed.hostname.startswith('*.'):
+ if len(parsed.hostname) == 2:
+ # We don't allow just '*.' as a host.
+ raise ParseError("Pattern without host")
self._match_subdomains = True
+ hostname = parsed.hostname[2:]
+ elif set(parsed.hostname) in {frozenset('.'), frozenset('. ')}:
+ raise ParseError("Invalid host")
+ else:
+ hostname = parsed.hostname
- if not host_parts:
+ if hostname is None:
self.host = None
- return
-
- self.host = '.'.join(host_parts)
-
- if self.host.endswith('.*'):
- # Special case to have a nicer error
- raise ParseError("TLD wildcards are not implemented yet")
- if '*' in self.host:
+ elif '*' in hostname:
# Only * or *.foo is allowed as host.
raise ParseError("Invalid host wildcard")
+ else:
+ self.host = hostname.rstrip('.')
def _init_port(self, parsed: urllib.parse.ParseResult) -> None:
"""Parse the port from the given URL.
@@ -276,6 +285,12 @@ class UrlPattern:
return self._port is None or self._port == port
def _matches_path(self, path: str) -> bool:
+ """Match the URL's path.
+
+ Deviations from Chromium:
+ - Chromium only matches <all_urls> with "javascript:" (pathless); but
+ we also match *://*/* and friends.
+ """
if self._path is None:
return True
diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py
index efd2caf75..af5c997da 100644
--- a/qutebrowser/utils/urlutils.py
+++ b/qutebrowser/utils/urlutils.py
@@ -25,9 +25,10 @@ import os.path
import ipaddress
import posixpath
import urllib.parse
-import typing
+import mimetypes
+from typing import Optional, Tuple, Union
-from PyQt5.QtCore import QUrl, QUrlQuery
+from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QHostInfo, QHostAddress, QNetworkProxy
from qutebrowser.api import cmdutils
@@ -55,7 +56,12 @@ WEBENGINE_SCHEMES = [
]
-class InvalidUrlError(Exception):
+class Error(Exception):
+
+ """Base class for errors in this module."""
+
+
+class InvalidUrlError(Error):
"""Error raised if a function got an invalid URL."""
@@ -67,8 +73,7 @@ class InvalidUrlError(Exception):
super().__init__(self.msg)
-def _parse_search_term(s: str) -> typing.Tuple[typing.Optional[str],
- typing.Optional[str]]:
+def _parse_search_term(s: str) -> Tuple[Optional[str], Optional[str]]:
"""Get a search engine name and search term from a string.
Args:
@@ -84,8 +89,8 @@ def _parse_search_term(s: str) -> typing.Tuple[typing.Optional[str],
if len(split) == 2:
if split[0] in config.val.url.searchengines:
- engine = split[0] # type: typing.Optional[str]
- term = split[1] # type: typing.Optional[str]
+ engine: Optional[str] = split[0]
+ term: Optional[str] = split[1]
else:
engine = None
term = s
@@ -122,12 +127,12 @@ def _get_search_url(txt: str) -> QUrl:
unquoted=term,
quoted=quoted_term,
semiquoted=semiquoted_term)
- url = qurl_from_user_input(evaluated)
+ url = QUrl.fromUserInput(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 = QUrl.fromUserInput(config.val.url.searchengines[engine])
+ 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
@@ -141,7 +146,7 @@ def _is_url_naive(urlstr: str) -> bool:
Return:
True if the URL really is a URL, False otherwise.
"""
- url = qurl_from_user_input(urlstr)
+ url = QUrl.fromUserInput(urlstr)
assert url.isValid()
host = url.host()
@@ -166,7 +171,7 @@ def _is_url_dns(urlstr: str) -> bool:
Return:
True if the URL really is a URL, False otherwise.
"""
- url = qurl_from_user_input(urlstr)
+ url = QUrl.fromUserInput(urlstr)
assert url.isValid()
if (utils.raises(ValueError, ipaddress.ip_address, urlstr) and
@@ -215,10 +220,10 @@ def fuzzy_url(urlstr: str,
try:
url = _get_search_url(urlstr)
except ValueError: # invalid search engine
- url = qurl_from_user_input(urlstr)
+ url = QUrl.fromUserInput(urlstr)
else: # probably an address
log.url.debug("URL is a fuzzy address")
- url = qurl_from_user_input(urlstr)
+ url = QUrl.fromUserInput(urlstr)
log.url.debug("Converting fuzzy term {!r} to URL -> {}".format(
urlstr, url.toDisplayString()))
ensure_valid(url)
@@ -268,7 +273,7 @@ def is_url(urlstr: str) -> bool:
urlstr = urlstr.strip()
qurl = QUrl(urlstr)
- qurl_userinput = qurl_from_user_input(urlstr)
+ qurl_userinput = QUrl.fromUserInput(urlstr)
if autosearch == 'never':
# no autosearch, so everything is a URL unless it has an explicit
@@ -288,6 +293,11 @@ def is_url(urlstr: str) -> bool:
# URLs with explicit schemes are always URLs
log.url.debug("Contains explicit scheme")
url = True
+ elif (autosearch == 'schemeless' and
+ (not _has_explicit_scheme(qurl) or ' ' in urlstr)):
+ # When autosearch=schemeless, URLs must contain schemes to be valid
+ log.url.debug("No explicit scheme in given URL, treating as non-URL")
+ url = False
elif qurl_userinput.host() in ['localhost', '127.0.0.1', '::1']:
log.url.debug("Is localhost.")
url = True
@@ -297,7 +307,7 @@ def is_url(urlstr: str) -> bool:
url = True
elif autosearch == 'dns':
log.url.debug("Checking via DNS check")
- # We want to use qurl_from_user_input here, as the user might enter
+ # We want to use QUrl.fromUserInput here, as the user might enter
# "foo.de" and that should be treated as URL here.
url = ' ' not in qurl_userinput.userName() and _is_url_dns(urlstr)
elif autosearch == 'naive':
@@ -309,42 +319,6 @@ def is_url(urlstr: str) -> bool:
return url
-def qurl_from_user_input(urlstr: str) -> QUrl:
- """Get a QUrl based on a user input. Additionally handles IPv6 addresses.
-
- QUrl.fromUserInput handles something like '::1' as a file URL instead of an
- IPv6, so we first try to handle it as a valid IPv6, and if that fails we
- use QUrl.fromUserInput.
-
- WORKAROUND - https://bugreports.qt.io/browse/QTBUG-41089
- FIXME - Maybe https://codereview.qt-project.org/#/c/93851/ has a better way
- to solve this?
- https://github.com/qutebrowser/qutebrowser/issues/109
-
- Args:
- urlstr: The URL as string.
-
- Return:
- The converted QUrl.
- """
- # First we try very liberally to separate something like an IPv6 from the
- # rest (e.g. path info or parameters)
- match = re.fullmatch(r'\[?([0-9a-fA-F:.]+)\]?(.*)', urlstr.strip())
- if match:
- ipstr, rest = match.groups()
- else:
- ipstr = urlstr.strip()
- rest = ''
- # Then we try to parse it as an IPv6, and if we fail use
- # QUrl.fromUserInput.
- try:
- ipaddress.IPv6Address(ipstr)
- except ipaddress.AddressValueError:
- return QUrl.fromUserInput(urlstr)
- else:
- return QUrl('http://[{}]{}'.format(ipstr, rest))
-
-
def ensure_valid(url: QUrl) -> None:
if not url.isValid():
raise InvalidUrlError(url)
@@ -375,7 +349,7 @@ def raise_cmdexc_if_invalid(url: QUrl) -> None:
def get_path_if_valid(pathstr: str,
cwd: str = None,
relative: bool = False,
- check_exists: bool = False) -> typing.Optional[str]:
+ check_exists: bool = False) -> Optional[str]:
"""Check if path is a valid path.
Args:
@@ -393,7 +367,7 @@ def get_path_if_valid(pathstr: str,
expanded = os.path.expanduser(pathstr)
if os.path.isabs(expanded):
- path = expanded # type: typing.Optional[str]
+ path: Optional[str] = expanded
elif relative and cwd:
path = os.path.join(cwd, expanded)
elif relative:
@@ -420,27 +394,37 @@ def get_path_if_valid(pathstr: str,
return path
-def filename_from_url(url: QUrl) -> typing.Optional[str]:
+def filename_from_url(url: QUrl, fallback: str = None) -> Optional[str]:
"""Get a suitable filename from a URL.
Args:
url: The URL to parse, as a QUrl.
+ fallback: Value to use if no name can be determined.
Return:
The suggested filename as a string, or None.
"""
if not url.isValid():
- return None
+ return fallback
+
+ if url.scheme().lower() == 'data':
+ mimetype, _encoding = mimetypes.guess_type(url.toString())
+ if not mimetype:
+ return fallback
+
+ ext = utils.mimetype_extension(mimetype) or ''
+ return 'download' + ext
+
pathname = posixpath.basename(url.path())
if pathname:
return pathname
elif url.host():
return url.host() + '.html'
else:
- return None
+ return fallback
-HostTupleType = typing.Tuple[str, str, int]
+HostTupleType = Tuple[str, str, int]
def host_tuple(url: QUrl) -> HostTupleType:
@@ -494,12 +478,19 @@ def same_domain(url1: QUrl, url2: QUrl) -> bool:
For example example.com and www.example.com are considered the same. but
example.co.uk and test.co.uk are not.
+ If the URL's schemes or ports are different, they are always treated as not equal.
+
Return:
True if the domains are the same, False otherwise.
"""
ensure_valid(url1)
ensure_valid(url2)
+ if url1.scheme() != url2.scheme():
+ return False
+ if url1.port() != url2.port():
+ return False
+
suffix1 = url1.topLevelDomain()
suffix2 = url2.topLevelDomain()
if not suffix1:
@@ -519,17 +510,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:
@@ -551,31 +542,17 @@ def safe_display_string(qurl: QUrl) -> str:
"""
ensure_valid(qurl)
- host = qurl.host(QUrl.FullyEncoded) # type: ignore
- if '..' in host: # pragma: no cover
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-60364
- return '(unparseable URL!) {}'.format(qurl.toDisplayString())
+ host = qurl.host(QUrl.FullyEncoded)
+ assert '..' not in host, qurl # https://bugreports.qt.io/browse/QTBUG-60364
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())
return qurl.toDisplayString()
-def query_string(qurl: QUrl) -> str:
- """Get a query string for the given URL.
-
- This is a WORKAROUND for:
- https://www.riverbankcomputing.com/pipermail/pyqt/2017-November/039702.html
- """
- try:
- return qurl.query()
- except AttributeError: # pragma: no cover
- return QUrlQuery(qurl).query()
-
-
class InvalidProxyTypeError(Exception):
"""Error raised when proxy_from_url gets an unknown proxy type."""
@@ -584,7 +561,7 @@ class InvalidProxyTypeError(Exception):
super().__init__("Invalid proxy type {}!".format(typ))
-def proxy_from_url(url: QUrl) -> QNetworkProxy:
+def proxy_from_url(url: QUrl) -> Union[QNetworkProxy, pac.PACFetcher]:
"""Create a QNetworkProxy from QUrl and a proxy type.
Args:
@@ -619,3 +596,26 @@ def proxy_from_url(url: QUrl) -> QNetworkProxy:
if url.password():
proxy.setPassword(url.password())
return proxy
+
+
+def parse_javascript_url(url: QUrl) -> str:
+ """Get JavaScript source from the given URL.
+
+ See https://wiki.whatwg.org/wiki/URL_schemes#javascript:_URLs
+ and https://github.com/whatwg/url/issues/385
+ """
+ ensure_valid(url)
+ if url.scheme() != 'javascript':
+ raise Error("Expected a javascript:... URL")
+ if url.authority():
+ raise Error("URL contains unexpected components: {}"
+ .format(url.authority()))
+
+ urlstr = url.toString(QUrl.FullyEncoded) # type: ignore[arg-type]
+ urlstr = urllib.parse.unquote(urlstr)
+
+ code = urlstr[len('javascript:'):]
+ if not code:
+ raise Error("Resulted in empty JavaScript code")
+
+ return code
diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py
index 3df2593ad..893dae877 100644
--- a/qutebrowser/utils/usertypes.py
+++ b/qutebrowser/utils/usertypes.py
@@ -21,7 +21,7 @@
import operator
import enum
-import typing
+from typing import Any, Optional, Sequence, TypeVar, Union
import attr
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QTimer
@@ -30,7 +30,7 @@ from PyQt5.QtCore import QUrl
from qutebrowser.utils import log, qtutils, utils
-_T = typing.TypeVar('_T')
+_T = TypeVar('_T', bound=utils.SupportsLessThan)
class Unset:
@@ -46,7 +46,7 @@ class Unset:
UNSET = Unset()
-class NeighborList(typing.Sequence[_T]):
+class NeighborList(Sequence[_T]):
"""A list of items which saves its current position.
@@ -60,10 +60,15 @@ class NeighborList(typing.Sequence[_T]):
_mode: The current mode.
"""
- Modes = enum.Enum('Modes', ['edge', 'exception'])
+ class Modes(enum.Enum):
- def __init__(self, items: typing.Sequence[_T] = None,
- default: typing.Union[_T, Unset] = UNSET,
+ """Behavior for the 'mode' argument."""
+
+ edge = enum.auto()
+ exception = enum.auto()
+
+ def __init__(self, items: Sequence[_T] = None,
+ default: Union[_T, Unset] = UNSET,
mode: Modes = Modes.exception) -> None:
"""Constructor.
@@ -77,21 +82,21 @@ class NeighborList(typing.Sequence[_T]):
if not isinstance(mode, self.Modes):
raise TypeError("Mode {} is not a Modes member!".format(mode))
if items is None:
- self._items = [] # type: typing.Sequence[_T]
+ self._items: Sequence[_T] = []
else:
self._items = list(items)
self._default = default
if not isinstance(default, Unset):
idx = self._items.index(default)
- self._idx = idx # type: typing.Optional[int]
+ self._idx: Optional[int] = idx
else:
self._idx = None
self._mode = mode
- self.fuzzyval = None # type: typing.Optional[int]
+ self.fuzzyval: Optional[int] = None
- 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 +125,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
@@ -157,7 +163,7 @@ class NeighborList(typing.Sequence[_T]):
return new
@property
- def items(self) -> typing.Sequence[_T]:
+ def items(self) -> Sequence[_T]:
"""Getter for items, which should not be set."""
return self._items
@@ -223,38 +229,48 @@ class NeighborList(typing.Sequence[_T]):
return self.curitem()
-# The mode of a Question.
-PromptMode = enum.Enum('PromptMode', ['yesno', 'text', 'user_pwd', 'alert',
- 'download'])
+class PromptMode(enum.Enum):
+
+ """The mode of a Question."""
+
+ yesno = enum.auto()
+ text = enum.auto()
+ user_pwd = enum.auto()
+ alert = enum.auto()
+ download = enum.auto()
class ClickTarget(enum.Enum):
"""How to open a clicked link."""
- normal = 0 #: Open the link in the current tab
- tab = 1 #: Open the link in a new foreground tab
- tab_bg = 2 #: Open the link in a new background tab
- window = 3 #: Open the link in a new window
- hover = 4 #: Only hover over the link
+ normal = enum.auto() #: Open the link in the current tab
+ tab = enum.auto() #: Open the link in a new foreground tab
+ tab_bg = enum.auto() #: Open the link in a new background tab
+ window = enum.auto() #: Open the link in a new window
+ hover = enum.auto() #: Only hover over the link
class KeyMode(enum.Enum):
"""Key input modes."""
- normal = 1 #: Normal mode (no mode was entered)
- hint = 2 #: Hint mode (showing labels for links)
- command = 3 #: Command mode (after pressing the colon key)
- yesno = 4 #: Yes/No prompts
- prompt = 5 #: Text prompts
- insert = 6 #: Insert mode (passing through most keys)
- passthrough = 7 #: Passthrough mode (passing through all keys)
- caret = 8 #: Caret mode (moving cursor with keys)
- set_mark = 9
- jump_mark = 10
- record_macro = 11
- run_macro = 12
+ normal = enum.auto() #: Normal mode (no mode was entered)
+ hint = enum.auto() #: Hint mode (showing labels for links)
+ command = enum.auto() #: Command mode (after pressing the colon key)
+ yesno = enum.auto() #: Yes/No prompts
+ prompt = enum.auto() #: Text prompts
+ insert = enum.auto() #: Insert mode (passing through most keys)
+ passthrough = enum.auto() #: Passthrough mode (passing through all keys)
+ caret = enum.auto() #: Caret mode (moving cursor with keys)
+ set_mark = enum.auto()
+ jump_mark = enum.auto()
+ record_macro = enum.auto()
+ run_macro = enum.auto()
+ # '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 = enum.auto()
class Exit(enum.IntEnum):
@@ -268,44 +284,76 @@ class Exit(enum.IntEnum):
err_init = 4
-# Load status of a tab
-LoadStatus = enum.Enum('LoadStatus', ['none', 'success', 'success_https',
- 'error', 'warn', 'loading'])
+class LoadStatus(enum.Enum):
+
+ """Load status of a tab."""
+ none = enum.auto()
+ success = enum.auto()
+ success_https = enum.auto()
+ error = enum.auto()
+ warn = enum.auto()
+ loading = enum.auto()
-# Backend of a tab
-Backend = enum.Enum('Backend', ['QtWebKit', 'QtWebEngine'])
+
+class Backend(enum.Enum):
+
+ """The backend being used (usertypes.backend)."""
+
+ QtWebKit = enum.auto()
+ QtWebEngine = enum.auto()
class JsWorld(enum.Enum):
"""World/context to run JavaScript code in."""
- main = 1 #: Same world as the web page's JavaScript.
- application = 2 #: Application world, used by qutebrowser internally.
- user = 3 #: User world, currently not used.
- jseval = 4 #: World used for the jseval-command.
+ main = enum.auto() #: Same world as the web page's JavaScript.
+ application = enum.auto() #: Application world, used by qutebrowser internally.
+ user = enum.auto() #: User world, currently not used.
+ jseval = enum.auto() #: World used for the jseval-command.
+
+
+class JsLogLevel(enum.Enum):
+ """Log level of a JS message.
-# Log level of a JS message. This needs to match up with the keys allowed for
-# the content.javascript.log setting.
-JsLogLevel = enum.Enum('JsLogLevel', ['unknown', 'info', 'warning', 'error'])
+ This needs to match up with the keys allowed for the
+ content.javascript.log setting.
+ """
+
+ unknown = enum.auto()
+ info = enum.auto()
+ warning = enum.auto()
+ error = enum.auto()
+
+
+class MessageLevel(enum.Enum):
+
+ """The level of a message being shown."""
+ error = enum.auto()
+ warning = enum.auto()
+ info = enum.auto()
-MessageLevel = enum.Enum('MessageLevel', ['error', 'warning', 'info'])
+class IgnoreCase(enum.Enum):
-IgnoreCase = enum.Enum('IgnoreCase', ['smart', 'never', 'always'])
+ """Possible values for the 'search.ignore_case' setting."""
+
+ smart = enum.auto()
+ never = enum.auto()
+ always = enum.auto()
class CommandValue(enum.Enum):
"""Special values which are injected when running a command handler."""
- count = 1
- win_id = 2
- cur_tab = 3
- count_tab = 4
+ count = enum.auto()
+ win_id = enum.auto()
+ cur_tab = enum.auto()
+ count_tab = enum.auto()
class Question(QObject):
@@ -355,13 +403,13 @@ class Question(QObject):
def __init__(self, parent: QObject = None) -> None:
super().__init__(parent)
- self.mode = None # type: typing.Optional[PromptMode]
- self.default = None # type: typing.Union[bool, str, None]
- self.title = None # type: typing.Optional[str]
- self.text = None # type: typing.Optional[str]
- self.url = None # type: typing.Optional[str]
- self.option = None # type: typing.Optional[bool]
- self.answer = None # type: typing.Union[str, bool, None]
+ self.mode: Optional[PromptMode] = None
+ self.default: Union[bool, str, None] = None
+ self.title: Optional[str] = None
+ self.text: Optional[str] = None
+ self.url: Optional[str] = None
+ self.option: Optional[bool] = None
+ self.answer: Union[str, bool, None] = None
self.is_aborted = False
self.interrupted = False
@@ -435,7 +483,7 @@ class AbstractCertificateErrorWrapper:
"""A wrapper over an SSL/certificate error."""
- def __init__(self, error: typing.Any) -> None:
+ def __init__(self, error: Any) -> None:
self._error = error
def __str__(self) -> str:
@@ -453,18 +501,32 @@ class NavigationRequest:
"""A request to navigate to the given URL."""
- Type = enum.Enum('Type', [
- 'link_clicked',
- 'typed', # QtWebEngine only
- 'form_submitted',
- 'form_resubmitted', # QtWebKit only
- 'back_forward',
- 'reloaded',
- 'redirect', # QtWebEngine >= 5.14 only
- 'other'
- ])
-
- url = attr.ib() # type: QUrl
- navigation_type = attr.ib() # type: Type
- is_main_frame = attr.ib() # type: bool
- accepted = attr.ib(default=True) # type: bool
+ class Type(enum.Enum):
+
+ """The type of a request.
+
+ Based on QWebEngineUrlRequestInfo::NavigationType and QWebPage::NavigationType.
+ """
+
+ #: Navigation initiated by clicking a link.
+ link_clicked = 1
+ #: Navigation explicitly initiated by typing a URL (QtWebEngine only).
+ typed = 2
+ #: Navigation submits a form.
+ form_submitted = 3
+ #: An HTML form was submitted a second time (QtWebKit only).
+ form_resubmitted = 4
+ #: Navigation initiated by a history action.
+ back_forward = 5
+ #: Navigation initiated by refreshing the page.
+ reloaded = 6
+ #: Navigation triggered automatically by page content or remote server
+ #: (QtWebEngine >= 5.14 only)
+ redirect = 7
+ #: None of the above.
+ other = 8
+
+ url: QUrl = attr.ib()
+ navigation_type: Type = attr.ib()
+ is_main_frame: bool = attr.ib()
+ accepted: bool = attr.ib(default=True)
diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py
index 58e89c2a1..e49309ef2 100644
--- a/qutebrowser/utils/utils.py
+++ b/qutebrowser/utils/utils.py
@@ -31,15 +31,31 @@ import traceback
import functools
import contextlib
import posixpath
-import socket
import shlex
import glob
import mimetypes
-import typing
+import ctypes
+import ctypes.util
+from typing import (Any, Callable, IO, Iterator, Optional, Sequence, Tuple, Type, Union,
+ TYPE_CHECKING, cast)
+try:
+ # Protocol was added in Python 3.8
+ from typing import Protocol
+except ImportError: # pragma: no cover
+ if not TYPE_CHECKING:
+ class Protocol:
+
+ """Empty stub at runtime."""
-from PyQt5.QtCore import QUrl
-from PyQt5.QtGui import QColor, QClipboard, QDesktopServices
+
+from PyQt5.QtCore import QUrl, QVersionNumber
+from PyQt5.QtGui import QClipboard, QDesktopServices
from PyQt5.QtWidgets import QApplication
+# We cannot use the stdlib version on 3.7-3.8 because we need the files() API.
+if sys.version_info >= (3, 9):
+ import importlib.resources as importlib_resources
+else: # pragma: no cover
+ import importlib_resources
import pkg_resources
import yaml
try:
@@ -47,12 +63,12 @@ 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
import qutebrowser
-from qutebrowser.utils import qtutils, log
+from qutebrowser.utils import log
fake_clipboard = None
@@ -65,6 +81,24 @@ is_windows = sys.platform.startswith('win')
is_posix = os.name == 'posix'
+class SupportsLessThan(Protocol):
+
+ """Protocol for a "comparable" object."""
+
+ def __lt__(self, other: Any) -> bool:
+ ...
+
+
+if TYPE_CHECKING:
+ class VersionNumber(SupportsLessThan, QVersionNumber):
+
+ """WORKAROUND for incorrect PyQt stubs."""
+else:
+ class VersionNumber:
+
+ """We can't inherit from Protocol and QVersionNumber at runtime."""
+
+
class Unreachable(Exception):
"""Raised when there was unreachable code."""
@@ -157,7 +191,7 @@ def preload_resources() -> None:
# FIXME:typing Return value should be bytes/str
-def read_file(filename: str, binary: bool = False) -> typing.Any:
+def read_file(filename: str, binary: bool = False) -> Any:
"""Get the contents of a file contained with qutebrowser.
Args:
@@ -179,19 +213,19 @@ def read_file(filename: str, binary: bool = False) -> typing.Any:
# https://github.com/pyinstaller/pyinstaller/wiki/FAQ#misc
fn = os.path.join(os.path.dirname(sys.executable), filename)
if binary:
- with open(fn, 'rb') as f: # type: typing.IO
+ f: IO
+ with open(fn, 'rb') as f:
return f.read()
else:
with open(fn, 'r', encoding='utf-8') as f:
return f.read()
else:
- data = pkg_resources.resource_string(
- qutebrowser.__name__, filename)
+ p = importlib_resources.files(qutebrowser) / filename
if binary:
- return data
+ return p.read_bytes()
- return data.decode('UTF-8')
+ return p.read_text()
def resource_filename(filename: str) -> str:
@@ -208,81 +242,10 @@ def resource_filename(filename: str) -> str:
return pkg_resources.resource_filename(qutebrowser.__name__, filename)
-def _get_color_percentage(a_c1: int, a_c2: int, a_c3:
- int, b_c1: int, b_c2: int, b_c3: int,
- percent: int) -> typing.Tuple[int, int, int]:
- """Get a color which is percent% interpolated between start and end.
-
- Args:
- a_c1, a_c2, a_c3: Start color components (R, G, B / H, S, V / H, S, L)
- b_c1, b_c2, b_c3: End color components (R, G, B / H, S, V / H, S, L)
- percent: Percentage to interpolate, 0-100.
- 0: Start color will be returned.
- 100: End color will be returned.
-
- Return:
- A (c1, c2, c3) tuple with the interpolated color components.
- """
- if not 0 <= percent <= 100:
- raise ValueError("percent needs to be between 0 and 100!")
- out_c1 = round(a_c1 + (b_c1 - a_c1) * percent / 100)
- out_c2 = round(a_c2 + (b_c2 - a_c2) * percent / 100)
- out_c3 = round(a_c3 + (b_c3 - a_c3) * percent / 100)
- return (out_c1, out_c2, out_c3)
-
-
-def interpolate_color(
- start: QColor,
- end: QColor,
- percent: int,
- colorspace: typing.Optional[QColor.Spec] = QColor.Rgb
-) -> QColor:
- """Get an interpolated color value.
-
- Args:
- start: The start color.
- end: The end color.
- percent: Which value to get (0 - 100)
- colorspace: The desired interpolation color system,
- QColor::{Rgb,Hsv,Hsl} (from QColor::Spec enum)
- If None, start is used except when percent is 100.
-
- Return:
- The interpolated QColor, with the same spec as the given start color.
- """
- qtutils.ensure_valid(start)
- qtutils.ensure_valid(end)
-
- if colorspace is None:
- if percent == 100:
- return QColor(*end.getRgb())
- else:
- return QColor(*start.getRgb())
-
- out = QColor()
- if colorspace == QColor.Rgb:
- a_c1, a_c2, a_c3, _alpha = start.getRgb()
- b_c1, b_c2, b_c3, _alpha = end.getRgb()
- components = _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3,
- percent)
- out.setRgb(*components)
- elif colorspace == QColor.Hsv:
- a_c1, a_c2, a_c3, _alpha = start.getHsv()
- b_c1, b_c2, b_c3, _alpha = end.getHsv()
- components = _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3,
- percent)
- out.setHsv(*components)
- elif colorspace == QColor.Hsl:
- a_c1, a_c2, a_c3, _alpha = start.getHsl()
- b_c1, b_c2, b_c3, _alpha = end.getHsl()
- components = _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3,
- percent)
- out.setHsl(*components)
- else:
- raise ValueError("Invalid colorspace!")
- out = out.convertTo(start.spec())
- qtutils.ensure_valid(out)
- return out
+def parse_version(version: str) -> VersionNumber:
+ """Parse a version string."""
+ v_q, _suffix = QVersionNumber.fromString(version)
+ return cast(VersionNumber, v_q.normalized())
def format_seconds(total_seconds: int) -> str:
@@ -301,9 +264,7 @@ def format_seconds(total_seconds: int) -> str:
return prefix + ':'.join(chunks)
-def format_size(size: typing.Optional[float],
- base: int = 1024,
- suffix: str = '') -> str:
+def format_size(size: Optional[float], base: int = 1024, suffix: str = '') -> str:
"""Format a byte size so it's human readable.
Inspired by http://stackoverflow.com/q/1094841
@@ -322,13 +283,13 @@ class FakeIOStream(io.TextIOBase):
"""A fake file-like stream which calls a function for write-calls."""
- def __init__(self, write_func: typing.Callable[[str], int]) -> None:
+ def __init__(self, write_func: Callable[[str], int]) -> None:
super().__init__()
- self.write = write_func # type: ignore
+ self.write = write_func # type: ignore[assignment]
@contextlib.contextmanager
-def fake_io(write_func: typing.Callable[[str], int]) -> typing.Iterator[None]:
+def fake_io(write_func: Callable[[str], int]) -> Iterator[None]:
"""Run code with stdout and stderr replaced by FakeIOStreams.
Args:
@@ -338,21 +299,21 @@ 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
@contextlib.contextmanager
-def disabled_excepthook() -> typing.Iterator[None]:
+def disabled_excepthook() -> Iterator[None]:
"""Run code with the exception hook temporarily disabled."""
old_excepthook = sys.excepthook
sys.excepthook = sys.__excepthook__
@@ -385,7 +346,7 @@ class prevent_exceptions: # noqa: N801,N806 pylint: disable=invalid-name
_predicate: The condition which needs to be True to prevent exceptions
"""
- def __init__(self, retval: typing.Any, predicate: bool = True) -> None:
+ def __init__(self, retval: Any, predicate: bool = True) -> None:
"""Save decorator arguments.
Gets called on parse-time with the decorator arguments.
@@ -396,7 +357,7 @@ class prevent_exceptions: # noqa: N801,N806 pylint: disable=invalid-name
self._retval = retval
self._predicate = predicate
- def __call__(self, func: typing.Callable) -> typing.Callable:
+ def __call__(self, func: Callable) -> Callable:
"""Called when a function should be decorated.
Args:
@@ -411,7 +372,7 @@ class prevent_exceptions: # noqa: N801,N806 pylint: disable=invalid-name
retval = self._retval
@functools.wraps(func)
- def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
"""Call the original function."""
try:
return func(*args, **kwargs)
@@ -422,7 +383,7 @@ class prevent_exceptions: # noqa: N801,N806 pylint: disable=invalid-name
return wrapper
-def is_enum(obj: typing.Any) -> bool:
+def is_enum(obj: Any) -> bool:
"""Check if a given object is an enum."""
try:
return issubclass(obj, enum.Enum)
@@ -430,9 +391,7 @@ def is_enum(obj: typing.Any) -> bool:
return False
-def get_repr(obj: typing.Any,
- constructor: bool = False,
- **attrs: typing.Any) -> str:
+def get_repr(obj: Any, constructor: bool = False, **attrs: Any) -> str:
"""Get a suitable __repr__ string for an object.
Args:
@@ -455,7 +414,7 @@ def get_repr(obj: typing.Any,
return '<{}>'.format(cls)
-def qualname(obj: typing.Any) -> str:
+def qualname(obj: Any) -> str:
"""Get the fully qualified name of an object.
Based on twisted.python.reflect.fullyQualifiedName.
@@ -483,14 +442,10 @@ def qualname(obj: typing.Any) -> str:
return repr(obj)
-# The string annotation is a WORKAROUND for a Python 3.5.2 bug:
-# https://github.com/python/typing/issues/266
+_ExceptionType = Union[Type[BaseException], Tuple[Type[BaseException]]]
+
-def raises(exc: ('typing.Union[' # pylint: disable=bad-docstring-quotes
- ' typing.Type[BaseException], '
- ' typing.Tuple[typing.Type[BaseException]]]'),
- func: typing.Callable,
- *args: typing.Any) -> bool:
+def raises(exc: _ExceptionType, func: Callable, *args: Any) -> bool:
"""Check if a function raises a given exception.
Args:
@@ -518,7 +473,8 @@ def force_encoding(text: str, encoding: str) -> str:
def sanitize_filename(name: str,
- replacement: typing.Optional[str] = '_') -> str:
+ replacement: Optional[str] = '_',
+ shorten: bool = False) -> str:
"""Replace invalid filename characters.
Note: This should be used for the basename, as it also removes the path
@@ -527,6 +483,7 @@ def sanitize_filename(name: str,
Args:
name: The filename.
replacement: The replacement character (or None).
+ shorten: Shorten the filename if it's too long for the filesystem.
"""
if replacement is None:
replacement = ''
@@ -548,6 +505,40 @@ def sanitize_filename(name: str,
for bad_char in bad_chars:
name = name.replace(bad_char, replacement)
+
+ if not shorten:
+ return name
+
+ # Truncate the filename if it's too long.
+ # Most filesystems have a maximum filename length of 255 bytes:
+ # https://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits
+ # We also want to keep some space for QtWebEngine's ".download" suffix, as
+ # well as deduplication counters.
+ max_bytes = 255 - len("(123).download")
+ root, ext = os.path.splitext(name)
+ root = root[:max_bytes - len(ext)]
+ excess = len(os.fsencode(root + ext)) - max_bytes
+
+ while excess > 0 and root:
+ # Max 4 bytes per character is assumed.
+ # Integer division floors to -∞, not to 0.
+ root = root[:(-excess // 4)]
+ excess = len(os.fsencode(root + ext)) - max_bytes
+
+ if not root:
+ # Trimming the root is not enough. We must trim the extension.
+ # We leave one character in the root, so that the filename
+ # doesn't start with a dot, which makes the file hidden.
+ root = name[0]
+ excess = len(os.fsencode(root + ext)) - max_bytes
+ while excess > 0 and ext:
+ ext = ext[:(-excess // 4)]
+ excess = len(os.fsencode(root + ext)) - max_bytes
+
+ assert ext, name
+
+ name = root + ext
+
return name
@@ -603,15 +594,6 @@ def supports_selection() -> bool:
return QApplication.clipboard().supportsSelection()
-def random_port() -> int:
- """Get a random free port."""
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.bind(('localhost', 0))
- port = sock.getsockname()[1]
- sock.close()
- return port
-
-
def open_file(filename: str, cmdline: str = None) -> None:
"""Open the given file.
@@ -670,7 +652,7 @@ def open_file(filename: str, cmdline: str = None) -> None:
proc.start_detached(cmd, args)
-def unused(_arg: typing.Any) -> None:
+def unused(_arg: Any) -> None:
"""Function which does nothing to avoid pylint complaining."""
@@ -692,16 +674,22 @@ def expand_windows_drive(path: str) -> str:
return path
-def yaml_load(f: typing.Union[str, typing.IO[str]]) -> typing.Any:
+def yaml_load(f: Union[str, IO[str]]) -> Any:
"""Wrapper over yaml.load using the C loader if possible."""
start = datetime.datetime.now()
# WORKAROUND for https://github.com/yaml/pyyaml/pull/181
- with log.ignore_py_warnings(
+ with log.py_warning_filter(
category=DeprecationWarning,
message=r"Using or importing the ABCs from 'collections' instead "
r"of from 'collections\.abc' is deprecated.*"):
- data = yaml.load(f, Loader=YamlLoader)
+ try:
+ data = yaml.load(f, Loader=YamlLoader)
+ except ValueError as e:
+ if str(e).startswith('could not convert string to float'):
+ # WORKAROUND for https://github.com/yaml/pyyaml/issues/168
+ raise yaml.YAMLError(e)
+ raise # pragma: no cover
end = datetime.datetime.now()
@@ -722,8 +710,7 @@ def yaml_load(f: typing.Union[str, typing.IO[str]]) -> typing.Any:
return data
-def yaml_dump(data: typing.Any,
- f: typing.IO[str] = None) -> typing.Optional[str]:
+def yaml_dump(data: Any, f: IO[str] = None) -> Optional[str]:
"""Wrapper over yaml.dump using the C dumper if possible.
Also returns a str instead of bytes.
@@ -736,7 +723,7 @@ def yaml_dump(data: typing.Any,
return yaml_data.decode('utf-8')
-def chunk(elems: typing.Sequence, n: int) -> typing.Iterator[typing.Sequence]:
+def chunk(elems: Sequence, n: int) -> Iterator[Sequence]:
"""Yield successive n-sized chunks from elems.
If elems % n != 0, the last chunk will be smaller.
@@ -776,3 +763,78 @@ 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)
+
+
+def parse_duration(duration: str) -> int:
+ """Parse duration in format XhYmZs into milliseconds duration."""
+ if duration.isdigit():
+ # For backward compatibility return milliseconds
+ return int(duration)
+
+ match = re.fullmatch(
+ r'(?P<hours>[0-9]+(\.[0-9])?h)?\s*'
+ r'(?P<minutes>[0-9]+(\.[0-9])?m)?\s*'
+ r'(?P<seconds>[0-9]+(\.[0-9])?s)?',
+ duration
+ )
+ if not match or not match.group(0):
+ raise ValueError(
+ f"Invalid duration: {duration} - "
+ "expected XhYmZs or a number of milliseconds"
+ )
+ seconds_string = match.group('seconds') if match.group('seconds') else '0'
+ seconds = float(seconds_string.rstrip('s'))
+ minutes_string = match.group('minutes') if match.group('minutes') else '0'
+ minutes = float(minutes_string.rstrip('m'))
+ hours_string = match.group('hours') if match.group('hours') else '0'
+ hours = float(hours_string.rstrip('h'))
+ milliseconds = int((seconds + minutes * 60 + hours * 3600) * 1000)
+ return milliseconds
+
+
+def mimetype_extension(mimetype: str) -> Optional[str]:
+ """Get a suitable extension for a given mimetype.
+
+ This mostly delegates to Python's mimetypes.guess_extension(), but backports some
+ changes (via a simple override dict) which are missing from earlier Python versions.
+ Most likely, this can be dropped once the minimum Python version is raised to 3.7.
+ """
+ overrides = {
+ # Added around 3.8
+ "application/manifest+json": ".webmanifest",
+ "application/x-hdf5": ".h5",
+
+ # Added in Python 3.7
+ "application/wasm": ".wasm",
+
+ # Wrong values for Python 3.6
+ # https://bugs.python.org/issue1043134
+ # https://github.com/python/cpython/pull/14375
+ "application/octet-stream": ".bin", # not .a
+ "application/postscript": ".ps", # not .ai
+ "application/vnd.ms-excel": ".xls", # not .xlb
+ "application/vnd.ms-powerpoint": ".ppt", # not .pot
+ "application/xml": ".xsl", # not .rdf
+ "audio/mpeg": ".mp3", # not .mp2
+ "image/jpeg": ".jpg", # not .jpe
+ "image/tiff": ".tiff", # not .tif
+ "text/html": ".html", # not .htm
+ "text/plain": ".txt", # not .bat
+ "video/mpeg": ".mpeg", # not .m1v
+ }
+ if mimetype in overrides:
+ return overrides[mimetype]
+ return mimetypes.guess_extension(mimetype, strict=False)
diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py
index d10c57411..175d0d715 100644
--- a/qutebrowser/utils/version.py
+++ b/qutebrowser/utils/version.py
@@ -30,10 +30,10 @@ import collections
import enum
import datetime
import getpass
-import typing
+import functools
+from typing import Mapping, Optional, Sequence, Tuple, cast
import attr
-import pkg_resources
from PyQt5.QtCore import PYQT_VERSION_STR, QLibraryInfo
from PyQt5.QtNetwork import QSslSocket
from PyQt5.QtGui import (QOpenGLContext, QOpenGLVersionProfile,
@@ -43,7 +43,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 +54,26 @@ 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]
+
+
+_LOGO = r'''
+ ______ ,,
+ ,.-"` | ,-` |
+ .^ || |
+ / ,-*^| || |
+; / | || ;-*```^*.
+; ; | |;,-*` \
+| | | ,-*` ,-"""\ \
+| \ ,-"` ,-^`| \ |
+ \ `^^ ,-;| | ; |
+ *; ,-*` || | / ;;
+ `^^`` | || | ,^ /
+ | || `^^` ,^
+ | _,"| _,-"
+ -*` ****"""``
+
+'''
@attr.s
@@ -62,20 +81,36 @@ class DistributionInfo:
"""Information about the running distribution."""
- id = attr.ib() # type: typing.Optional[str]
- parsed = attr.ib() # type: Distribution
- version = attr.ib() # type: typing.Optional[typing.Tuple[str, ...]]
- pretty = attr.ib() # type: str
+ id: Optional[str] = attr.ib()
+ parsed: 'Distribution' = attr.ib()
+ version: Optional[utils.VersionNumber] = attr.ib()
+ pretty: str = attr.ib()
pastebin_url = None
-Distribution = enum.Enum(
- 'Distribution', ['unknown', 'ubuntu', 'debian', 'void', 'arch',
- 'gentoo', 'fedora', 'opensuse', 'linuxmint', 'manjaro',
- 'kde_flatpak'])
-def distribution() -> typing.Optional[DistributionInfo]:
+class Distribution(enum.Enum):
+
+ """A known Linux distribution.
+
+ Usually lines up with ID=... in /etc/os-release.
+ """
+
+ unknown = enum.auto()
+ ubuntu = enum.auto()
+ debian = enum.auto()
+ void = enum.auto()
+ arch = enum.auto()
+ gentoo = enum.auto() # includes funtoo
+ fedora = enum.auto()
+ opensuse = enum.auto()
+ linuxmint = enum.auto()
+ manjaro = enum.auto()
+ kde_flatpak = enum.auto() # org.kde.Platform
+
+
+def distribution() -> Optional[DistributionInfo]:
"""Get some information about the running Linux distribution.
Returns:
@@ -103,9 +138,8 @@ def distribution() -> typing.Optional[DistributionInfo]:
assert pretty is not None
if 'VERSION_ID' in info:
- dist_version = pkg_resources.parse_version(
- info['VERSION_ID']
- ) # type: typing.Optional[typing.Tuple[str, ...]]
+ version_id = info['VERSION_ID']
+ dist_version: Optional[utils.VersionNumber] = utils.parse_version(version_id)
else:
dist_version = None
@@ -134,7 +168,7 @@ def is_sandboxed() -> bool:
return current_distro.parsed == Distribution.kde_flatpak
-def _git_str() -> typing.Optional[str]:
+def _git_str() -> Optional[str]:
"""Try to find out git version.
Return:
@@ -160,7 +194,15 @@ def _git_str() -> typing.Optional[str]:
return None
-def _git_str_subprocess(gitpath: str) -> typing.Optional[str]:
+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) -> Optional[str]:
"""Try to get the git commit ID and timestamp by calling git.
Args:
@@ -173,20 +215,16 @@ 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
-def _release_info() -> typing.Sequence[typing.Tuple[str, str]]:
+def _release_info() -> Sequence[Tuple[str, str]]:
"""Try to gather distribution release information.
Return:
@@ -210,46 +248,142 @@ def _release_info() -> typing.Sequence[typing.Tuple[str, str]]:
return data
-def _module_versions() -> typing.Sequence[str]:
- """Get versions of optional modules.
+class ModuleInfo:
- Return:
- A list of lines with version info.
+ """Class to query version information of qutebrowser dependencies.
+
+ Attributes:
+ name: Name of the module as it is imported.
+ _version_attributes:
+ Sequence of attribute names belonging to the module which may hold
+ version information.
+ min_version: Minimum version of this module which qutebrowser can use.
+ _installed: Is the module installed? Determined at runtime.
+ _version: Version of the module. Determined at runtime.
+ _initialized:
+ Set to `True` if the `self._installed` and `self._version`
+ attributes have been set.
"""
- lines = []
- modules = collections.OrderedDict([
+
+ def __init__(
+ self,
+ name: str,
+ version_attributes: Sequence[str],
+ min_version: Optional[str] = None
+ ):
+ self.name = name
+ self._version_attributes = version_attributes
+ self.min_version = min_version
+ self._installed = False
+ self._version: Optional[str] = None
+ self._initialized = False
+
+ def _reset_cache(self) -> None:
+ """Reset the version cache.
+
+ It is necessary to call this method in unit tests that mock a module's
+ version number.
+ """
+ self._installed = False
+ self._version = None
+ self._initialized = False
+
+ def _initialize_info(self) -> None:
+ """Import module and set `self.installed` and `self.version`."""
+ try:
+ module = importlib.import_module(self.name)
+ except (ImportError, ValueError):
+ self._installed = False
+ return
+ else:
+ self._installed = True
+
+ for attribute_name in self._version_attributes:
+ if hasattr(module, attribute_name):
+ version = getattr(module, attribute_name)
+ assert isinstance(version, (str, float))
+ self._version = str(version)
+ break
+
+ self._initialized = True
+
+ def get_version(self) -> Optional[str]:
+ """Finds the module version if it exists."""
+ if not self._initialized:
+ self._initialize_info()
+ return self._version
+
+ def is_installed(self) -> bool:
+ """Checks whether the module is installed."""
+ if not self._initialized:
+ self._initialize_info()
+ return self._installed
+
+ def is_outdated(self) -> Optional[bool]:
+ """Checks whether the module is outdated.
+
+ Return:
+ A boolean when the version and minimum version are both defined.
+ Otherwise `None`.
+ """
+ version = self.get_version()
+ if (
+ not self.is_installed()
+ or version is None
+ or self.min_version is None
+ ):
+ return None
+ return version < self.min_version
+
+ def is_usable(self) -> bool:
+ """Whether the module is both installed and not outdated."""
+ return self.is_installed() and not self.is_outdated()
+
+ def __str__(self) -> str:
+ if not self.is_installed():
+ return f'{self.name}: no'
+
+ version = self.get_version()
+ if version is None:
+ return f'{self.name}: yes'
+
+ text = f'{self.name}: {version}'
+ if self.is_outdated():
+ text += f" (< {self.min_version}, outdated)"
+ return text
+
+
+MODULE_INFO: Mapping[str, ModuleInfo] = collections.OrderedDict([
+ # FIXME: Mypy doesn't understand this. See https://github.com/python/mypy/issues/9706
+ (name, ModuleInfo(name, *args)) # type: ignore[arg-type, misc]
+ for (name, *args) in
+ [
('sip', ['SIP_VERSION_STR']),
('colorama', ['VERSION', '__version__']),
('pypeg2', ['__version__']),
('jinja2', ['__version__']),
('pygments', ['__version__']),
('yaml', ['__version__']),
- ('cssutils', ['__version__']),
+ ('adblock', ['__version__'], "0.3.2"),
('attr', ['__version__']),
+ ('importlib_resources', []),
('PyQt5.QtWebEngineWidgets', []),
('PyQt5.QtWebEngine', ['PYQT_WEBENGINE_VERSION_STR']),
('PyQt5.QtWebKitWidgets', []),
- ]) # type: typing.Mapping[str, typing.Sequence[str]]
- for modname, attributes in modules.items():
- try:
- module = importlib.import_module(modname)
- except ImportError:
- text = '{}: no'.format(modname)
- else:
- for name in attributes:
- try:
- text = '{}: {}'.format(modname, getattr(module, name))
- except AttributeError:
- pass
- else:
- break
- else:
- text = '{}: yes'.format(modname)
- lines.append(text)
- return lines
+ ]
+])
+
+def _module_versions() -> Sequence[str]:
+ """Get versions of optional modules.
-def _path_info() -> typing.Mapping[str, str]:
+ Return:
+ A list of lines with version info.
+ """
+ return [str(mod_info) for mod_info in MODULE_INFO.values()]
+
+
+def _path_info() -> Mapping[str, str]:
"""Get info about important path names.
Return:
@@ -268,7 +402,7 @@ def _path_info() -> typing.Mapping[str, str]:
return info
-def _os_info() -> typing.Sequence[str]:
+def _os_info() -> Sequence[str]:
"""Get operating system info.
Return:
@@ -312,16 +446,14 @@ def _pdfjs_version() -> str:
else:
pdfjs_file = pdfjs_file.decode('utf-8')
version_re = re.compile(
- r"^ *(PDFJS\.version|var pdfjsVersion) = '([^']+)';$",
+ r"^ *(PDFJS\.version|(var|const) pdfjsVersion) = '(?P<version>[^']+)';$",
re.MULTILINE)
match = version_re.search(pdfjs_file)
- if not match:
- pdfjs_version = 'unknown'
- else:
- pdfjs_version = match.group(2)
+ pdfjs_version = 'unknown' if not match else match.group('version')
if file_path is None:
file_path = 'bundled'
+
return '{} ({})'.format(pdfjs_version, file_path)
@@ -333,48 +465,52 @@ def _chromium_version() -> str:
Quick reference:
- Qt 5.7: Chromium 49
- 49.0.2623.111 (2016-03-31)
- 5.7.1: Security fixes up to 54.0.2840.87 (2016-11-01)
-
- Qt 5.8: Chromium 53
- 53.0.2785.148 (2016-08-31)
- 5.8.0: Security fixes up to 55.0.2883.75 (2016-12-01)
-
- 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)
-
- Qt 5.10: Chromium 61
- 61.0.3163.140 (2017-09-05)
- 5.10.1: Security fixes up to 64.0.3282.140 (2018-02-01)
-
- Qt 5.11: Chromium 65
- 65.0.3325.151 (.1: .230) (2018-03-06)
- 5.11.3: Security fixes up to 70.0.3538.102 (2018-11-09)
-
Qt 5.12: Chromium 69
- (LTS) 69.0.3497.113 (2018-09-27)
+ (LTS) 69.0.3497.128 (~2018-09-11)
+ 5.12.0: Security fixes up to 70.0.3538.102 (~2018-10-24)
+ 5.12.1: Security fixes up to 71.0.3578.94 (2018-12-12)
+ 5.12.2: Security fixes up to 72.0.3626.121 (2019-03-01)
+ 5.12.3: Security fixes up to 73.0.3683.75 (2019-03-12)
+ 5.12.4: Security fixes up to 74.0.3729.157 (2019-05-14)
+ 5.12.5: Security fixes up to 76.0.3809.87 (2019-07-30)
+ 5.12.6: Security fixes up to 77.0.3865.120 (~2019-09-10)
+ 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)
+ 5.12.9: Security fixes up to 83.0.4103.97 (2020-06-03)
+ 5.12.10: Security fixes up to 86.0.4240.75 (2020-10-06)
Qt 5.13: Chromium 73
73.0.3683.105 (~2019-02-28)
+ 5.13.0: Security fixes up to 74.0.3729.157 (2019-05-14)
+ 5.13.1: Security fixes up to 76.0.3809.87 (2019-07-30)
5.13.2: Security fixes up to 77.0.3865.120 (2019-10-10)
Qt 5.14: Chromium 77
77.0.3865.129 (~2019-10-10)
+ 5.14.0: Security fixes up to 77.0.3865.129 (~2019-09-10)
+ 5.14.1: Security fixes up to 79.0.3945.117 (2020-01-07)
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)
+ 5.15.1: Security fixes up to 85.0.4183.83 (2020-08-25)
- Also see https://www.chromium.org/developers/calendar
- and https://chromereleases.googleblog.com/
+ 5.15.2: Updated to 83.0.4103.122 (~2020-06-24)
+ Security fixes up to 86.0.4240.183 (2020-11-02)
+
+ Also see:
+
+ - https://chromiumdash.appspot.com/schedule
+ - https://www.chromium.org/developers/calendar
+ - https://chromereleases.googleblog.com/
"""
if webenginesettings is None:
- return 'unavailable' # type: ignore
+ return 'unavailable' # type: ignore[unreachable]
if webenginesettings.parsed_user_agent is None:
+ if 'avoid-chromium-init' in objects.debug_flags:
+ return 'avoided'
webenginesettings.init_user_agent()
assert webenginesettings.parsed_user_agent is not None
@@ -385,10 +521,11 @@ def _backend() -> str:
"""Get the backend line with relevant information."""
if objects.backend == usertypes.Backend.QtWebKit:
return 'new QtWebKit (WebKit {})'.format(qWebKitVersion())
- else:
+ elif objects.backend == usertypes.Backend.QtWebEngine:
webengine = usertypes.Backend.QtWebEngine
assert objects.backend == webengine, objects.backend
return 'QtWebEngine (Chromium {})'.format(_chromium_version())
+ raise utils.Unreachable(objects.backend)
def _uptime() -> datetime.timedelta:
@@ -410,9 +547,11 @@ 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__)]
+ lines = _LOGO.lstrip('\n').splitlines()
+
+ lines.append("qutebrowser v{}".format(qutebrowser.__version__))
gitver = _git_str()
if gitver is not None:
lines.append("Git commit: {}".format(gitver))
@@ -441,8 +580,8 @@ def version() -> str:
if qapp:
style = qapp.style()
lines.append('Style: {}'.format(style.metaObject().className()))
- platform_name = qapp.platformName()
- lines.append('Platform plugin: {}'.format(platform_name))
+ lines.append('Platform plugin: {}'.format(qapp.platformName()))
+ lines.append('OpenGL: {}'.format(opengl_info()))
importpath = os.path.dirname(os.path.abspath(qutebrowser.__file__))
@@ -486,7 +625,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: bool = attr.ib(False)
+
+ # The name of the vendor. Examples:
+ # - nouveau
+ # - "Intel Open Source Technology Center", "Intel", "Intel Inc."
+ vendor: Optional[str] = attr.ib(None)
+
+ # The OpenGL version as a string. See tests for examples.
+ version_str: Optional[str] = attr.ib(None)
+
+ # The parsed version as a (major, minor) tuple of ints
+ version: Optional[Tuple[int, ...]] = attr.ib(None)
+
+ # The vendor specific information following the version number
+ vendor_specific: Optional[str] = attr.ib(None)
+
+ 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() -> Optional[OpenGLInfo]: # pragma: no cover
"""Get the OpenGL vendor used.
This returns a string such as 'nouveau' or
@@ -495,13 +692,16 @@ 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())
+ old_context = cast(Optional[QOpenGLContext], QOpenGLContext.currentContext())
old_surface = None if old_context is None else old_context.surface()
surface = QOffscreenSurface()
@@ -521,7 +721,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)
@@ -536,7 +736,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:
@@ -579,5 +782,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 59b6bb414..48c1991a3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,10 +1,11 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-attrs==19.3.0
-colorama==0.4.3
-cssutils==1.0.2
+adblock==0.4.0
+attrs==20.3.0
+colorama==0.4.4
+importlib-resources==4.1.1 ; python_version<"3.9"
Jinja2==2.11.2
MarkupSafe==1.1.1
-Pygments==2.6.1
+Pygments==2.7.3
pyPEG2==2.15.2
PyYAML==5.3.1
diff --git a/scripts/asciidoc2html.py b/scripts/asciidoc2html.py
index d43531ca3..109e61625 100755
--- a/scripts/asciidoc2html.py
+++ b/scripts/asciidoc2html.py
@@ -20,18 +20,21 @@
"""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))
+REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
+DOC_DIR = REPO_ROOT / 'qutebrowser' / 'html' / 'doc'
+
+sys.path.insert(0, str(REPO_ROOT))
from scripts import utils
@@ -42,31 +45,34 @@ class AsciiDoc:
FILES = ['faq', 'changelog', 'contributing', 'quickstart', 'userscripts']
- def __init__(self, asciidoc, website):
- self._cmd = None
+ def __init__(self,
+ asciidoc: Optional[str],
+ asciidoc_python: Optional[str],
+ website: Optional[str]) -> None:
+ self._cmd: Optional[List[str]] = None
self._asciidoc = asciidoc
+ self._asciidoc_python = asciidoc_python
self._website = website
- self._homedir = None
- self._themedir = None
- self._tempdir = None
+ self._homedir: Optional[pathlib.Path] = None
+ self._themedir: Optional[pathlib.Path] = None
+ self._tempdir: Optional[pathlib.Path] = None
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)
+ shutil.rmtree(str(self._homedir))
- def build(self):
+ def build(self) -> None:
"""Build either the website or the docs."""
if self._website:
self._build_website()
@@ -74,14 +80,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 = [((REPO_ROOT / 'doc' / '{}.asciidoc'.format(f)),
+ DOC_DIR / (f + ".html")) for f in self.FILES]
+ for src in (REPO_ROOT / 'doc' / 'help').glob('*.asciidoc'):
+ dst = DOC_DIR / (src.stem + ".html")
files.append((src, dst))
# patch image links to use local copy
@@ -94,53 +98,44 @@ 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)
- with open(modified_src, 'w', encoding='utf-8') as modified_f, \
- open(src, 'r', encoding='utf-8') as f:
+ assert self._tempdir is not None # for mypy
+ modified_src = self._tempdir / src.name
+ with modified_src.open('w', encoding='utf-8') as moded_f, \
+ src.open('r', encoding='utf-8') as f:
for line in f:
for orig, repl in replacements:
line = line.replace(orig, repl)
- modified_f.write(line)
+ moded_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)
- shutil.copy(src, dst)
+ src = REPO_ROOT / 'doc' / 'img' / filename
+ dst = dst_path / filename
+ shutil.copy(str(src), str(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)
- shutil.copy('www/header.asciidoc', modified_src)
+ src = root / filename
+ assert self._website is not None # for mypy
+ dst = pathlib.Path(self._website)
+ dst = dst / src.parent.relative_to(REPO_ROOT) / (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(str(REPO_ROOT / 'www' / 'header.asciidoc'), modified_src)
outfp = io.StringIO()
- with open(modified_src, 'r', encoding='utf-8') as header_file:
- header = header_file.read()
- header += "\n\n"
+ header = modified_src.read_text(encoding='utf-8')
+ header += "\n\n"
- with open(src, 'r', encoding='utf-8') as infp:
+ with src.open('r', encoding='utf-8') as infp:
outfp.write("\n\n")
hidden = False
found_title = False
@@ -180,70 +175,69 @@ class AsciiDoc:
current_lines = outfp.getvalue()
outfp.close()
- with open(modified_src, 'w+', encoding='utf-8') as final_version:
- final_version.write(title + "\n\n" + header + current_lines)
+ modified_str = title + "\n\n" + header + current_lines
+ modified_src.write_text(modified_str, encoding='utf-8')
asciidoc_args = ['--theme=qute', '-a toc', '-a toc-placement=manual',
'-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 = REPO_ROOT / 'www' / 'qute.css'
+ 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(REPO_ROOT).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_src = REPO_ROOT / src
+ full_dest = outdir / dest
try:
shutil.rmtree(full_dest)
except FileNotFoundError:
pass
- shutil.copytree(src, full_dest)
+ shutil.copytree(full_src, full_dest)
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
-
- try:
- subprocess.run(['asciidoc'], stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL, check=True)
- except OSError:
- pass
- else:
- return ['asciidoc']
+ python = (sys.executable if self._asciidoc_python is None
+ else self._asciidoc_python)
+ return [python, self._asciidoc]
- try:
- subprocess.run(['asciidoc.py'], stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL, check=True)
- except OSError:
- pass
- else:
- return ['asciidoc.py']
+ for executable in ['asciidoc', 'asciidoc.py']:
+ try:
+ subprocess.run([executable, '--version'],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ check=True)
+ except OSError:
+ pass
+ else:
+ return [executable]
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,15 +245,21 @@ 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))
+
+ # So the virtualenv's Pygments is found
+ bin_path = pathlib.Path(sys.executable).parent
+
try:
env = os.environ.copy()
- env['HOME'] = self._homedir
+ env['HOME'] = str(self._homedir)
+ env['PATH'] = str(bin_path) + os.pathsep + env['PATH']
subprocess.run(cmdline, check=True, env=env)
except (subprocess.CalledProcessError, OSError) as e:
self._failed = True
@@ -269,24 +269,23 @@ class AsciiDoc:
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)
- parser.add_argument('--asciidoc', help="Full path to python and "
- "asciidoc.py. If not given, it's searched in PATH.",
- nargs=2, required=False,
- metavar=('PYTHON', 'ASCIIDOC'))
+ "directory.")
+ parser.add_argument('--asciidoc', help="Full path to asciidoc.py. "
+ "If not given, it's searched in PATH.",
+ nargs='?')
+ parser.add_argument('--asciidoc-python', help="Python to use for asciidoc."
+ "If not given, the current Python interpreter is used.",
+ nargs='?')
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:
@@ -303,12 +302,13 @@ 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
args = parse_args()
- run(asciidoc=args.asciidoc, website=args.website)
+ run(asciidoc=args.asciidoc, asciidoc_python=args.asciidoc_python,
+ website=args.website)
if __name__ == '__main__':
diff --git a/scripts/dev/build_pyqt_wheel.py b/scripts/dev/build_pyqt_wheel.py
index b4a3477c6..aa3fe9322 100644
--- a/scripts/dev/build_pyqt_wheel.py
+++ b/scripts/dev/build_pyqt_wheel.py
@@ -31,6 +31,21 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), 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')
@@ -40,6 +55,18 @@ def main():
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)
@@ -58,9 +85,10 @@ def main():
input_files = wheels_dir.glob('*.whl')
for wheel in input_files:
utils.print_subtitle(wheel.stem.split('-')[0])
- bin_path = pathlib.Path(sys.executable).parent
- subprocess.run([str(bin_path / 'pyqt-bundle'),
- '--qt-dir', args.qt_location, str(wheel)],
+ subprocess.run([str(pyqt_bundle),
+ '--qt-dir', args.qt_location,
+ '--ignore-missing',
+ str(wheel)],
check=True)
wheel.unlink()
diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py
index 68befff65..479283a92 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):
@@ -78,10 +78,11 @@ def call_tox(toxenv, *args, python=sys.executable):
def run_asciidoc2html(args):
"""Common buildsteps used for all OS'."""
utils.print_title("Running asciidoc2html.py")
+ a2h_args = []
if args.asciidoc is not None:
- a2h_args = ['--asciidoc'] + args.asciidoc
- else:
- a2h_args = []
+ a2h_args += ['--asciidoc', args.asciidoc]
+ if args.asciidoc_python is not None:
+ a2h_args += ['--asciidoc-python', args.asciidoc_python]
call_script('asciidoc2html.py', *a2h_args)
@@ -115,7 +116,13 @@ def smoke_test(executable):
(r'\[.*:ERROR:mach_port_broker.mm\(48\)\] bootstrap_look_up '
r'org\.chromium\.Chromium\.rohitfork\.1: Permission denied \(1100\)'),
(r'\[.*:ERROR:mach_port_broker.mm\(43\)\] bootstrap_look_up: '
- r'Unknown service name \(1102\)')
+ r'Unknown service name \(1102\)'),
+
+ # Windows N:
+ # https://github.com/microsoft/playwright/issues/2901
+ (r'\[.*:ERROR:dxva_video_decode_accelerator_win.cc\(\d+\)\] '
+ r'DXVAVDA fatal error: could not LoadLibrary: .*: The specified '
+ r'module could not be found. \(0x7E\)'),
]
proc = subprocess.run([executable, '--no-err-windows', '--nowindow',
@@ -201,7 +208,7 @@ def build_mac():
utils.print_title("Updating 3rdparty content")
update_3rdparty.run(ace=False, pdfjs=True, fancy_dmg=False)
utils.print_title("Building .app via pyinstaller")
- call_tox('pyinstaller', '-r')
+ call_tox('pyinstaller-64', '-r')
utils.print_title("Patching .app")
patch_mac_app()
utils.print_title("Building .dmg")
@@ -251,7 +258,7 @@ def _get_windows_python_path(x64):
return fallback
-def build_windows():
+def build_windows(*, skip_packaging):
"""Build windows executables/setups."""
utils.print_title("Updating 3rdparty content")
update_3rdparty.run(nsis=True, ace=False, pdfjs=True, fancy_dmg=False)
@@ -275,12 +282,12 @@ def build_windows():
utils.print_title("Running pyinstaller 32bit")
_maybe_remove(out_32)
- call_tox('pyinstaller', '-r', python=python_x86)
+ call_tox('pyinstaller-32', '-r', python=python_x86)
shutil.move(out_pyinstaller, out_32)
utils.print_title("Running pyinstaller 64bit")
_maybe_remove(out_64)
- call_tox('pyinstaller', '-r', python=python_x64)
+ call_tox('pyinstaller-64', '-r', python=python_x64)
shutil.move(out_pyinstaller, out_64)
utils.print_title("Running 32bit smoke test")
@@ -288,6 +295,14 @@ def build_windows():
utils.print_title("Running 64bit smoke test")
smoke_test(os.path.join(out_64, 'qutebrowser.exe'))
+ if not skip_packaging:
+ artifacts += _package_windows(out_32, out_64)
+
+ return artifacts
+
+
+def _package_windows(out_32, out_64):
+ """Build installers/zips for Windows."""
utils.print_title("Building installers")
subprocess.run(['makensis.exe',
'/DVERSION={}'.format(qutebrowser.__version__),
@@ -300,7 +315,7 @@ def build_windows():
name_32 = 'qutebrowser-{}-win32.exe'.format(qutebrowser.__version__)
name_64 = 'qutebrowser-{}-amd64.exe'.format(qutebrowser.__version__)
- artifacts += [
+ artifacts = [
(os.path.join('dist', name_32),
'application/vnd.microsoft.portable-executable',
'Windows 32bit installer'),
@@ -310,16 +325,17 @@ def build_windows():
]
utils.print_title("Zipping 32bit standalone...")
- name = 'qutebrowser-{}-windows-standalone-win32'.format(
- qutebrowser.__version__)
+ template = 'qutebrowser-{}-windows-standalone-{}'
+ name = os.path.join('dist',
+ template.format(qutebrowser.__version__, 'win32'))
shutil.make_archive(name, 'zip', 'dist', os.path.basename(out_32))
artifacts.append(('{}.zip'.format(name),
'application/zip',
'Windows 32bit standalone'))
utils.print_title("Zipping 64bit standalone...")
- name = 'qutebrowser-{}-windows-standalone-amd64'.format(
- qutebrowser.__version__)
+ name = os.path.join('dist',
+ template.format(qutebrowser.__version__, 'amd64'))
shutil.make_archive(name, 'zip', 'dist', os.path.basename(out_64))
artifacts.append(('{}.zip'.format(name),
'application/zip',
@@ -456,12 +472,16 @@ def main():
parser = argparse.ArgumentParser()
parser.add_argument('--no-asciidoc', action='store_true',
help="Don't generate docs")
- parser.add_argument('--asciidoc', help="Full path to python and "
- "asciidoc.py. If not given, it's searched in PATH.",
- nargs=2, required=False,
- metavar=('PYTHON', 'ASCIIDOC'))
+ parser.add_argument('--asciidoc', help="Full path to asciidoc.py. "
+ "If not given, it's searched in PATH.",
+ nargs='?')
+ parser.add_argument('--asciidoc-python', help="Python to use for asciidoc."
+ "If not given, the current Python interpreter is used.",
+ nargs='?')
parser.add_argument('--upload', action='store_true', required=False,
- help="Toggle to upload the release to GitHub")
+ help="Toggle to upload the release to GitHub.")
+ parser.add_argument('--skip-packaging', action='store_true', required=False,
+ help="Skip Windows installer/zip generation.")
args = parser.parse_args()
utils.change_cwd()
@@ -473,13 +493,17 @@ 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:
run_asciidoc2html(args)
if os.name == 'nt':
- artifacts = build_windows()
+ artifacts = build_windows(skip_packaging=args.skip_packaging)
elif sys.platform == 'darwin':
artifacts = build_mac()
else:
diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py
index 9e8f9cda4..dae90d636 100644
--- a/scripts/dev/check_coverage.py
+++ b/scripts/dev/check_coverage.py
@@ -45,174 +45,194 @@ class Message:
filename = attr.ib()
text = attr.ib()
+ def show(self):
+ """Print this message."""
+ if scriptutils.ON_CI:
+ scriptutils.gha_error(self.text)
+ else:
+ print(self.text)
+
+
+class MsgType(enum.Enum):
-MsgType = enum.Enum('MsgType', 'insufficient_coverage, perfect_file')
+ """The type of a message to be output."""
+
+ insufficient_coverage = enum.auto()
+ perfect_file = enum.auto()
# A list of (test_file, tested_file) tuples. test_file can be None.
PERFECT_FILES = [
(None,
- 'commands/cmdexc.py'),
+ 'qutebrowser/commands/cmdexc.py'),
('tests/unit/commands/test_argparser.py',
- 'commands/argparser.py'),
+ 'qutebrowser/commands/argparser.py'),
('tests/unit/api/test_cmdutils.py',
- 'api/cmdutils.py'),
+ 'qutebrowser/api/cmdutils.py'),
+ (None,
+ 'qutebrowser/api/apitypes.py'),
(None,
- 'api/apitypes.py'),
+ 'qutebrowser/api/config.py'),
(None,
- 'api/config.py'),
+ 'qutebrowser/api/message.py'),
(None,
- 'api/message.py'),
+ 'qutebrowser/api/qtutils.py'),
(None,
- 'api/qtutils.py'),
+ 'qutebrowser/qt.py'),
('tests/unit/browser/webkit/test_cache.py',
- 'browser/webkit/cache.py'),
+ 'qutebrowser/browser/webkit/cache.py'),
('tests/unit/browser/webkit/test_cookies.py',
- 'browser/webkit/cookies.py'),
+ 'qutebrowser/browser/webkit/cookies.py'),
('tests/unit/browser/test_history.py',
- 'browser/history.py'),
+ 'qutebrowser/browser/history.py'),
('tests/unit/browser/test_pdfjs.py',
- 'browser/pdfjs.py'),
+ 'qutebrowser/browser/pdfjs.py'),
('tests/unit/browser/webkit/http/test_http.py',
- 'browser/webkit/http.py'),
+ 'qutebrowser/browser/webkit/http.py'),
('tests/unit/browser/webkit/http/test_content_disposition.py',
- 'browser/webkit/rfc6266.py'),
+ 'qutebrowser/browser/webkit/rfc6266.py'),
# ('tests/unit/browser/webkit/test_webkitelem.py',
- # 'browser/webkit/webkitelem.py'),
+ # 'qutebrowser/browser/webkit/webkitelem.py'),
# ('tests/unit/browser/webkit/test_webkitelem.py',
- # 'browser/webelem.py'),
+ # 'qutebrowser/browser/webelem.py'),
('tests/unit/browser/webkit/network/test_filescheme.py',
- 'browser/webkit/network/filescheme.py'),
+ 'qutebrowser/browser/webkit/network/filescheme.py'),
('tests/unit/browser/webkit/network/test_networkreply.py',
- 'browser/webkit/network/networkreply.py'),
+ 'qutebrowser/browser/webkit/network/networkreply.py'),
('tests/unit/browser/test_signalfilter.py',
- 'browser/signalfilter.py'),
+ 'qutebrowser/browser/signalfilter.py'),
(None,
- 'browser/webengine/certificateerror.py'),
+ 'qutebrowser/browser/webengine/certificateerror.py'),
# ('tests/unit/browser/test_tab.py',
- # 'browser/tab.py'),
+ # 'qutebrowser/browser/tab.py'),
('tests/unit/keyinput/test_basekeyparser.py',
- 'keyinput/basekeyparser.py'),
+ 'qutebrowser/keyinput/basekeyparser.py'),
('tests/unit/keyinput/test_keyutils.py',
- 'keyinput/keyutils.py'),
+ 'qutebrowser/keyinput/keyutils.py'),
('tests/unit/components/test_readlinecommands.py',
- 'components/readlinecommands.py'),
+ 'qutebrowser/components/readlinecommands.py'),
('tests/unit/misc/test_autoupdate.py',
- 'misc/autoupdate.py'),
+ 'qutebrowser/misc/autoupdate.py'),
('tests/unit/misc/test_split.py',
- 'misc/split.py'),
+ 'qutebrowser/misc/split.py'),
('tests/unit/misc/test_msgbox.py',
- 'misc/msgbox.py'),
+ 'qutebrowser/misc/msgbox.py'),
('tests/unit/misc/test_checkpyver.py',
- 'misc/checkpyver.py'),
+ 'qutebrowser/misc/checkpyver.py'),
('tests/unit/misc/test_guiprocess.py',
- 'misc/guiprocess.py'),
+ 'qutebrowser/misc/guiprocess.py'),
('tests/unit/misc/test_editor.py',
- 'misc/editor.py'),
+ 'qutebrowser/misc/editor.py'),
('tests/unit/misc/test_cmdhistory.py',
- 'misc/cmdhistory.py'),
+ 'qutebrowser/misc/cmdhistory.py'),
('tests/unit/misc/test_ipc.py',
- 'misc/ipc.py'),
+ 'qutebrowser/misc/ipc.py'),
('tests/unit/misc/test_keyhints.py',
- 'misc/keyhintwidget.py'),
+ 'qutebrowser/misc/keyhintwidget.py'),
('tests/unit/misc/test_pastebin.py',
- 'misc/pastebin.py'),
+ 'qutebrowser/misc/pastebin.py'),
('tests/unit/misc/test_objects.py',
- 'misc/objects.py'),
+ 'qutebrowser/misc/objects.py'),
('tests/unit/misc/test_throttle.py',
- 'misc/throttle.py'),
+ 'qutebrowser/misc/throttle.py'),
(None,
- 'mainwindow/statusbar/keystring.py'),
+ 'qutebrowser/mainwindow/statusbar/keystring.py'),
('tests/unit/mainwindow/statusbar/test_percentage.py',
- 'mainwindow/statusbar/percentage.py'),
+ 'qutebrowser/mainwindow/statusbar/percentage.py'),
('tests/unit/mainwindow/statusbar/test_progress.py',
- 'mainwindow/statusbar/progress.py'),
+ 'qutebrowser/mainwindow/statusbar/progress.py'),
('tests/unit/mainwindow/statusbar/test_tabindex.py',
- 'mainwindow/statusbar/tabindex.py'),
+ 'qutebrowser/mainwindow/statusbar/tabindex.py'),
('tests/unit/mainwindow/statusbar/test_textbase.py',
- 'mainwindow/statusbar/textbase.py'),
+ 'qutebrowser/mainwindow/statusbar/textbase.py'),
('tests/unit/mainwindow/statusbar/test_url.py',
- 'mainwindow/statusbar/url.py'),
+ 'qutebrowser/mainwindow/statusbar/url.py'),
('tests/unit/mainwindow/statusbar/test_backforward.py',
- 'mainwindow/statusbar/backforward.py'),
+ 'qutebrowser/mainwindow/statusbar/backforward.py'),
('tests/unit/mainwindow/test_messageview.py',
- 'mainwindow/messageview.py'),
+ 'qutebrowser/mainwindow/messageview.py'),
('tests/unit/config/test_config.py',
- 'config/config.py'),
+ 'qutebrowser/config/config.py'),
('tests/unit/config/test_stylesheet.py',
- 'config/stylesheet.py'),
+ 'qutebrowser/config/stylesheet.py'),
('tests/unit/config/test_configdata.py',
- 'config/configdata.py'),
+ 'qutebrowser/config/configdata.py'),
('tests/unit/config/test_configexc.py',
- 'config/configexc.py'),
+ 'qutebrowser/config/configexc.py'),
('tests/unit/config/test_configfiles.py',
- 'config/configfiles.py'),
+ 'qutebrowser/config/configfiles.py'),
('tests/unit/config/test_configtypes.py',
- 'config/configtypes.py'),
+ 'qutebrowser/config/configtypes.py'),
('tests/unit/config/test_configinit.py',
- 'config/configinit.py'),
+ 'qutebrowser/config/configinit.py'),
+ ('tests/unit/config/test_qtargs.py',
+ 'qutebrowser/config/qtargs.py'),
('tests/unit/config/test_configcommands.py',
- 'config/configcommands.py'),
+ 'qutebrowser/config/configcommands.py'),
('tests/unit/config/test_configutils.py',
- 'config/configutils.py'),
+ 'qutebrowser/config/configutils.py'),
('tests/unit/config/test_configcache.py',
- 'config/configcache.py'),
+ 'qutebrowser/config/configcache.py'),
('tests/unit/utils/test_qtutils.py',
- 'utils/qtutils.py'),
+ 'qutebrowser/utils/qtutils.py'),
('tests/unit/utils/test_standarddir.py',
- 'utils/standarddir.py'),
+ 'qutebrowser/utils/standarddir.py'),
('tests/unit/utils/test_urlutils.py',
- 'utils/urlutils.py'),
+ 'qutebrowser/utils/urlutils.py'),
('tests/unit/utils/usertypes',
- 'utils/usertypes.py'),
+ 'qutebrowser/utils/usertypes.py'),
('tests/unit/utils/test_utils.py',
- 'utils/utils.py'),
+ 'qutebrowser/utils/utils.py'),
('tests/unit/utils/test_version.py',
- 'utils/version.py'),
+ 'qutebrowser/utils/version.py'),
('tests/unit/utils/test_debug.py',
- 'utils/debug.py'),
+ 'qutebrowser/utils/debug.py'),
('tests/unit/utils/test_jinja.py',
- 'utils/jinja.py'),
+ 'qutebrowser/utils/jinja.py'),
('tests/unit/utils/test_error.py',
- 'utils/error.py'),
+ 'qutebrowser/utils/error.py'),
('tests/unit/utils/test_javascript.py',
- 'utils/javascript.py'),
+ 'qutebrowser/utils/javascript.py'),
('tests/unit/utils/test_urlmatch.py',
- 'utils/urlmatch.py'),
+ 'qutebrowser/utils/urlmatch.py'),
(None,
- 'completion/models/util.py'),
+ 'qutebrowser/completion/models/util.py'),
('tests/unit/completion/test_models.py',
- 'completion/models/urlmodel.py'),
+ 'qutebrowser/completion/models/urlmodel.py'),
('tests/unit/completion/test_models.py',
- 'completion/models/configmodel.py'),
+ 'qutebrowser/completion/models/configmodel.py'),
('tests/unit/completion/test_histcategory.py',
- 'completion/models/histcategory.py'),
+ 'qutebrowser/completion/models/histcategory.py'),
('tests/unit/completion/test_listcategory.py',
- 'completion/models/listcategory.py'),
+ 'qutebrowser/completion/models/listcategory.py'),
('tests/unit/browser/webengine/test_spell.py',
- 'browser/webengine/spell.py'),
-
+ 'qutebrowser/browser/webengine/spell.py'),
+ ('tests/unit/browser/webengine/test_webengine_cookies.py',
+ 'qutebrowser/browser/webengine/cookies.py'),
+ ('tests/unit/browser/webengine/test_darkmode.py',
+ 'qutebrowser/browser/webengine/darkmode.py'),
]
# 100% coverage because of end2end tests, but no perfect unit tests yet.
WHITELISTED_FILES = [
- 'browser/webkit/webkitinspector.py',
- 'misc/debugcachestats.py',
- 'keyinput/macros.py',
- 'browser/webkit/webkitelem.py',
- 'api/interceptor.py',
+ 'qutebrowser/browser/webkit/webkitinspector.py',
+ 'qutebrowser/misc/debugcachestats.py',
+ 'qutebrowser/keyinput/macros.py',
+ 'qutebrowser/browser/webkit/webkitelem.py',
+ 'qutebrowser/api/interceptor.py',
+ 'qutebrowser/extensions/interceptors.py',
]
@@ -233,8 +253,6 @@ def _get_filename(filename):
common_path = os.path.commonprefix([basedir, filename])
if common_path:
filename = filename[len(common_path):].lstrip('/')
- if filename.startswith('qutebrowser/'):
- filename = filename.split('/', maxsplit=1)[1]
return filename
@@ -285,8 +303,10 @@ def check(fileobj, perfect_files):
filename, line_cov, branch_cov)
messages.append(Message(MsgType.insufficient_coverage, filename,
text))
- elif (filename not in perfect_src_files and not is_bad and
- filename not in WHITELISTED_FILES):
+ elif (filename not in perfect_src_files and
+ not is_bad and
+ filename not in WHITELISTED_FILES and
+ not filename.startswith('tests/')):
text = ("{} has 100% coverage but is not in "
"perfect_files!".format(filename))
messages.append(Message(MsgType.perfect_file, filename, text))
@@ -308,18 +328,19 @@ def main_check():
print()
scriptutils.print_title("Coverage check failed")
for msg in messages:
- print(msg.text)
+ msg.show()
print()
- filters = ','.join('qutebrowser/' + msg.filename for msg in messages)
+ filters = ','.join(msg.filename for msg in messages)
subprocess.run([sys.executable, '-m', 'coverage', 'report',
'--show-missing', '--include', filters], check=True)
print()
- print("To debug this, run 'tox -e py36-pyqt59-cov' "
- "(or py35-pyqt59-cov) locally and check htmlcov/index.html")
+ print("To debug this, run 'tox -e py36-pyqt515-cov' "
+ "(replace Python/Qt versions based on your system) locally and check "
+ "htmlcov/index.html")
print("or check https://codecov.io/github/qutebrowser/qutebrowser")
print()
- if 'CI' in os.environ:
+ if scriptutils.ON_CI:
print("Keeping coverage.xml on CI.")
else:
os.remove('coverage.xml')
diff --git a/scripts/dev/check_doc_changes.py b/scripts/dev/check_doc_changes.py
index f673ad4ea..edc613f47 100755
--- a/scripts/dev/check_doc_changes.py
+++ b/scripts/dev/check_doc_changes.py
@@ -23,11 +23,17 @@
import sys
import subprocess
import os
+import os.path
-code = subprocess.run(['git', '--no-pager', 'diff',
- '--exit-code', '--stat'], check=False).returncode
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir,
+ os.pardir))
-if os.environ.get('TRAVIS_PULL_REQUEST', 'false') != 'false':
+from scripts import utils
+
+code = subprocess.run(['git', '--no-pager', 'diff', '--exit-code', '--stat',
+ '--', 'doc'], check=False).returncode
+
+if os.environ.get('GITHUB_REF', 'refs/heads/master') != 'refs/heads/master':
if code != 0:
print("Docs changed but ignoring change as we're building a PR")
sys.exit(0)
@@ -40,9 +46,9 @@ if code != 0:
print()
print('(Or you have uncommitted changes, in which case you can ignore '
'this.)')
- if 'TRAVIS' in os.environ:
+ if utils.ON_CI:
+ utils.gha_error('The autogenerated docs changed')
print()
- print("travis_fold:start:gitdiff")
- subprocess.run(['git', '--no-pager', 'diff'], check=True)
- print("travis_fold:end:gitdiff")
+ with utils.gha_group('Diff'):
+ subprocess.run(['git', '--no-pager', 'diff'], check=True)
sys.exit(code)
diff --git a/scripts/dev/ci/travis_backtrace.sh b/scripts/dev/ci/backtrace.sh
index 227dde8a8..f9b32f6d6 100644
--- a/scripts/dev/ci/travis_backtrace.sh
+++ b/scripts/dev/ci/backtrace.sh
@@ -4,13 +4,15 @@
# to determine exe using file(1) and dump stack trace with gdb.
#
-case $TESTENV in
+testenv=$1
+
+case $testenv in
py3*-pyqt*)
- exe=$(readlink -f ".tox/$TESTENV/bin/python")
+ exe=$(readlink -f ".tox/$testenv/bin/python")
full=
;;
*)
- echo "Skipping coredump analysis in testenv $TESTENV!"
+ echo "Skipping coredump analysis in testenv $testenv!"
exit 0
;;
esac
diff --git a/scripts/dev/ci/docker/Dockerfile.j2 b/scripts/dev/ci/docker/Dockerfile.j2
new file mode 100644
index 000000000..d3fc82793
--- /dev/null
+++ b/scripts/dev/ci/docker/Dockerfile.j2
@@ -0,0 +1,29 @@
+FROM archlinux:latest
+
+{% if unstable %}
+RUN sed -i '/^# after the header/a[kde-unstable]\nInclude = /etc/pacman.d/mirrorlist\n\n[testing]\nInclude = /etc/pacman.d/mirrorlist' /etc/pacman.conf
+{% endif %}
+RUN pacman -Suyy --noconfirm \
+ git \
+ python-tox \
+ python-distlib \
+ qt5-base \
+ qt5-declarative \
+ {% if webengine %}qt5-webengine python-pyqtwebengine{% else %}qt5-webkit{% endif %} \
+ python-pyqt5 \
+ xorg-xinit \
+ xorg-server-xvfb \
+ ttf-bitstream-vera \
+ gcc \
+ libyaml \
+ xorg-xdpyinfo
+
+RUN useradd user -u 1001 && \
+ mkdir /home/user && \
+ chown user:users /home/user
+USER user
+WORKDIR /home/user
+
+CMD git clone /outside qutebrowser.git && \
+ cd qutebrowser.git && \
+ tox -e py
diff --git a/scripts/dev/ci/docker/README.md b/scripts/dev/ci/docker/README.md
new file mode 100644
index 000000000..eb2b8db91
--- /dev/null
+++ b/scripts/dev/ci/docker/README.md
@@ -0,0 +1,9 @@
+This directory contains a Dockerfile template for containers used to test
+qutebrowser on CI.
+
+The `generate.py` script uses that template to generate various image
+configuration.
+
+The images are rebuilt via Github Actions in this directory, and qutebrowser
+then downloads them during the CI run. Note that means that it'll take a while
+until builds will use the newer image if you make a change to this directory.
diff --git a/scripts/dev/ci/docker/generate.py b/scripts/dev/ci/docker/generate.py
new file mode 100644
index 000000000..7d09fdb20
--- /dev/null
+++ b/scripts/dev/ci/docker/generate.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+# vim: ft=sh fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2019-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/>.
+
+"""Generate Dockerfiles for qutebrowser's CI."""
+
+import sys
+
+import jinja2
+
+
+def main():
+ with open('Dockerfile.j2') as f:
+ template = jinja2.Template(f.read())
+
+ image = sys.argv[1]
+ config = {
+ 'archlinux-webkit': {'webengine': False, 'unstable': False},
+ 'archlinux-webengine': {'webengine': True, 'unstable': False},
+ 'archlinux-webengine-unstable': {'webengine': True, 'unstable': True},
+ }[image]
+
+ with open('Dockerfile', 'w') as f:
+ f.write(template.render(**config))
+ f.write('\n')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/dev/ci/problemmatchers.py b/scripts/dev/ci/problemmatchers.py
new file mode 100644
index 000000000..cc423f922
--- /dev/null
+++ b/scripts/dev/ci/problemmatchers.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+# vim: ft=sh 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/>.
+
+"""Register problem matchers for GitHub Actions.
+
+Relevant docs:
+https://github.com/actions/toolkit/blob/master/docs/problem-matchers.md
+https://github.com/actions/toolkit/blob/master/docs/commands.md#problem-matchers
+"""
+
+import sys
+import pathlib
+import json
+
+
+MATCHERS = {
+ # scripts/dev/ci/run.sh:41:39: error: Double quote array expansions to
+ # avoid re-splitting elements. [SC2068]
+ "shellcheck": [
+ {
+ "pattern": [
+ {
+ "regexp": r"^(.+):(\d+):(\d+):\s(note|warning|error):\s(.*)\s\[(SC\d+)\]$",
+ "file": 1,
+ "line": 2,
+ "column": 3,
+ "severity": 4,
+ "message": 5,
+ "code": 6,
+ },
+ ],
+ },
+ ],
+
+ "yamllint": [
+ {
+ "pattern": [
+ {
+ "regexp": r"^\033\[4m([^\033]+)\033\[0m$",
+ "file": 1,
+ },
+ {
+ "regexp": r"^ \033\[2m(\d+):(\d+)\033\[0m \033\[3[13]m([^\033]+)\033\[0m +([^\033]*)\033\[2m\(([^)]+)\)\033\[0m$",
+ "line": 1,
+ "column": 2,
+ "severity": 3,
+ "message": 4,
+ "code": 5,
+ "loop": True,
+ },
+ ],
+ },
+ ],
+
+ # filename.py:313: unused function 'i_am_never_used' (60% confidence)
+ "vulture": [
+ {
+ "severity": "warning",
+ "pattern": [
+ {
+ "regexp": r"^([^:]+):(\d+): ([^(]+ \(\d+% confidence\))$",
+ "file": 1,
+ "line": 2,
+ "message": 3,
+ }
+ ]
+ },
+ ],
+
+ # filename.py:1:1: D100 Missing docstring in public module
+ "flake8": [
+ {
+ # "undefined name" is FXXX (i.e. not an error), but e.g. multiple
+ # spaces before an operator is EXXX (i.e. an error) - that makes little
+ # sense, so let's just treat everything as a warning instead.
+ "severity": "warning",
+ "pattern": [
+ {
+ "regexp": r"^(\033\[0m)?([^:]+):(\d+):(\d+): ([A-Z]\d{3}) (.*)$",
+ "file": 2,
+ "line": 3,
+ "column": 4,
+ "code": 5,
+ "message": 6,
+ },
+ ],
+ },
+ ],
+
+ # filename.py:80: error: Name 'foo' is not defined [name-defined]
+ "mypy": [
+ {
+ "pattern": [
+ {
+ "regexp": r"^(\033\[0m)?([^:]+):(\d+): ([^:]+): (.*) \[(.*)\]$",
+ "file": 2,
+ "line": 3,
+ "severity": 4,
+ "message": 5,
+ "code": 6,
+ },
+ ],
+ },
+ ],
+
+ # For some reason, ANSI color escape codes end up as part of the message
+ # GitHub gets with colored pylint output - so we have those escape codes
+ # (e.g. "\033[35m...\033[0m") as part of the regex patterns...
+ "pylint": [
+ {
+ # filename.py:80:10: E0602: Undefined variable 'foo' (undefined-variable)
+ "severity": "error",
+ "pattern": [
+ {
+ "regexp": r"^([^:]+):(\d+):(\d+): (E\d+): \033\[[\d;]+m([^\033]+).*$",
+ "file": 1,
+ "line": 2,
+ "column": 3,
+ "code": 4,
+ "message": 5,
+ },
+ ],
+ },
+ {
+ # filename.py:78:14: W0613: Unused argument 'unused' (unused-argument)
+ "severity": "warning",
+ "pattern": [
+ {
+ "regexp": r"^([^:]+):(\d+):(\d+): ([A-DF-Z]\d+): \033\[[\d;]+m([^\033]+).*$",
+ "file": 1,
+ "line": 2,
+ "column": 3,
+ "code": 4,
+ "message": 5,
+ },
+ ],
+ },
+ ],
+
+ "tests": [
+ {
+ # pytest test summary output
+ "severity": "error",
+ "pattern": [
+ {
+ "regexp": r'^=+ short test summary info =+$',
+ },
+ {
+ "regexp": r"^((ERROR|FAILED) .*)",
+ "message": 1,
+ "loop": True,
+ }
+ ],
+ },
+ {
+ # pytest error lines
+ # E end2end.fixtures.testprocess.WaitForTimeout: Timed out
+ # after 15000ms waiting for [...]
+ "severity": "error",
+ "pattern": [
+ {
+ "regexp": r'^\033\[1m\033\[31mE ([a-zA-Z0-9.]+: [^\033]*)\033\[0m$',
+ "message": 1,
+ },
+ ],
+ },
+ ],
+
+ "misc": [
+ {
+ "severity": "error",
+ "pattern": [
+ {
+ "regexp": r'^([^:]+):(\d+): \033\[34m(Found .*)\033\[0m',
+ "file": 1,
+ "line": 2,
+ "message": 3,
+ }
+ ]
+ }
+ ]
+}
+
+
+def add_matcher(output_dir, owner, data):
+ data['owner'] = owner
+ out_data = {'problemMatcher': [data]}
+ output_file = output_dir / '{}.json'.format(owner)
+ with output_file.open('w', encoding='utf-8') as f:
+ json.dump(out_data, f)
+
+ print("::add-matcher::{}".format(output_file))
+
+
+def main(testenv, tempdir):
+ testenv = sys.argv[1]
+ if testenv.startswith('py3'):
+ testenv = 'tests'
+
+ if testenv not in MATCHERS:
+ return
+
+ output_dir = pathlib.Path(tempdir)
+
+ for idx, data in enumerate(MATCHERS[testenv]):
+ owner = '{}-{}'.format(testenv, idx)
+ add_matcher(output_dir=output_dir, owner=owner, data=data)
+
+
+if __name__ == '__main__':
+ sys.exit(main(*sys.argv[1:]))
diff --git a/scripts/dev/ci/travis_install.sh b/scripts/dev/ci/travis_install.sh
deleted file mode 100644
index 2975a52d7..000000000
--- a/scripts/dev/ci/travis_install.sh
+++ /dev/null
@@ -1,75 +0,0 @@
-#!/bin/bash
-# vim: ft=sh fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2016-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
-
-# This file is part of qutebrowser.
-#
-# qutebrowser is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# qutebrowser is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-
-# Stolen from https://github.com/travis-ci/travis-build/blob/master/lib/travis/build/templates/header.sh
-# and adjusted to use ((...))
-travis_retry() {
- local ANSI_RED='\033[31;1m'
- local ANSI_RESET='\033[0m'
- local result=0
- local count=1
- while (( count < 3 )); do
- if (( result != 0 )); then
- echo -e "\\n${ANSI_RED}The command \"$*\" failed. Retrying, $count of 3.${ANSI_RESET}\\n" >&2
- fi
- "$@"
- result=$?
- (( result == 0 )) && break
- count=$(( count + 1 ))
- sleep 1
- done
-
- if (( count > 3 )); then
- echo -e "\\n${ANSI_RED}The command \"$*\" failed 3 times.${ANSI_RESET}\\n" >&2
- fi
-
- return $result
-}
-
-pip_install() {
- travis_retry python3 -m pip install "$@"
-}
-
-npm_install() {
- # Make sure npm is up-to-date first
- travis_retry npm install -g npm
- travis_retry npm install -g "$@"
-}
-
-set -e
-
-if [[ -n $DOCKER ]]; then
- exit 0
-fi
-
-case $TESTENV in
- eslint)
- npm_install eslint
- ;;
- shellcheck)
- ;;
- *)
- pip_install -U pip
- pip_install -U -r misc/requirements/requirements-tox.txt
- if [[ $TESTENV == *-cov ]]; then
- pip_install -U -r misc/requirements/requirements-codecov.txt
- fi
- ;;
-esac
diff --git a/scripts/dev/ci/travis_run.sh b/scripts/dev/ci/travis_run.sh
deleted file mode 100644
index 96af14553..000000000
--- a/scripts/dev/ci/travis_run.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/bash
-
-if [[ -n $DOCKER ]]; then
- docker run \
- --privileged \
- -v "$PWD:/outside" \
- -e "QUTE_BDD_WEBENGINE=$QUTE_BDD_WEBENGINE" \
- -e "DOCKER=$DOCKER" \
- -e "CI=$CI" \
- -e "TRAVIS=$TRAVIS" \
- "qutebrowser/travis:$DOCKER"
-elif [[ $TESTENV == eslint ]]; then
- # Can't run this via tox as we can't easily install tox in the javascript
- # travis env
- cd qutebrowser/javascript || exit 1
- eslint --color --report-unused-disable-directives .
-elif [[ $TESTENV == shellcheck ]]; then
- SCRIPTS=$( mktemp )
- find scripts/dev/ -name '*.sh' >"$SCRIPTS"
- find misc/userscripts/ -type f -exec grep -lE '[/ ][bd]ash$|[/ ]sh$|[/ ]ksh$' {} + >>"$SCRIPTS"
- mapfile -t scripts <"$SCRIPTS"
- rm -f "$SCRIPTS"
- docker run \
- -v "$PWD:/outside" \
- -w /outside \
- koalaman/shellcheck:stable "${scripts[@]}"
-else
- args=()
- # We only run unit tests on macOS because it's quite slow.
- [[ $TRAVIS_OS_NAME == osx ]] && args+=('--qute-bdd-webengine' '--no-xvfb' 'tests/unit')
-
- # WORKAROUND for unknown crash inside swrast_dri.so
- # See https://github.com/qutebrowser/qutebrowser/pull/4218#issuecomment-421931770
- [[ $TESTENV == py36-pyqt59 ]] && export QT_QUICK_BACKEND=software
-
- tox -e "$TESTENV" -- "${args[@]}"
-fi
diff --git a/scripts/dev/misc_checks.py b/scripts/dev/misc_checks.py
index 24c3a1ddc..4b5699086 100644
--- a/scripts/dev/misc_checks.py
+++ b/scripts/dev/misc_checks.py
@@ -28,36 +28,96 @@ import argparse
import subprocess
import tokenize
import traceback
-import collections
import pathlib
+from typing import List, Iterator, Optional
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir,
- os.pardir))
+REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(REPO_ROOT))
from scripts import utils
+from scripts.dev import recompile_requirements
+BINARY_EXTS = {'.png', '.icns', '.ico', '.bmp', '.gz', '.bin', '.pdf',
+ '.sqlite', '.woff2', '.whl'}
-def _get_files(only_py=False):
- """Iterate over all python files and yield filenames."""
- for (dirpath, _dirnames, filenames) in os.walk('.'):
- parts = dirpath.split(os.sep)
- if len(parts) >= 2:
- rootdir = parts[1]
- if rootdir.startswith('.') or rootdir == 'htmlcov':
- # ignore hidden dirs and htmlcov
- continue
- if only_py:
- endings = {'.py'}
- else:
- endings = {'.py', '.asciidoc', '.js', '.feature'}
- files = (e for e in filenames if os.path.splitext(e)[1] in endings)
- for name in files:
- yield os.path.join(dirpath, name)
+def _get_files(
+ *,
+ verbose: bool,
+ ignored: List[pathlib.Path] = None
+) -> Iterator[pathlib.Path]:
+ """Iterate over all files and yield filenames."""
+ filenames = subprocess.run(
+ ['git', 'ls-files', '--cached', '--others', '--exclude-standard', '-z'],
+ stdout=subprocess.PIPE,
+ universal_newlines=True,
+ check=True,
+ )
+ all_ignored = ignored or []
+ all_ignored.append(
+ pathlib.Path('tests', 'unit', 'scripts', 'importer_sample', 'chrome'))
+
+ for filename in filenames.stdout.split('\0'):
+ path = pathlib.Path(filename)
+ is_ignored = any(path == p or p in path.parents for p in all_ignored)
+ if not filename or path.suffix in BINARY_EXTS or is_ignored:
+ continue
+
+ try:
+ with tokenize.open(str(path)):
+ pass
+ except SyntaxError as e:
+ # Could not find encoding
+ utils.print_col("{} - maybe {} should be added to BINARY_EXTS?".format(
+ str(e).capitalize(), path.suffix), 'yellow')
+ continue
+
+ if verbose:
+ print(path)
+
+ yield path
+
+
+def check_changelog_urls(_args: argparse.Namespace = None) -> bool:
+ """Ensure we have changelog URLs for all requirements."""
+ ok = True
+ all_requirements = set()
+
+ for name in recompile_requirements.get_all_names():
+ outfile = recompile_requirements.get_outfile(name)
+ missing = set()
+ with open(outfile, 'r', encoding='utf-8') as f:
+ for line in f:
+ line = line.strip()
+ if line.startswith('#') or not line:
+ continue
+ req, _version = recompile_requirements.parse_versioned_line(line)
+ if req.startswith('./'):
+ continue
+ all_requirements.add(req)
+ if req not in recompile_requirements.CHANGELOG_URLS:
+ missing.add(req)
+
+ if missing:
+ ok = False
+ req_str = ', '.join(sorted(missing))
+ utils.print_col(
+ f"Missing changelog URLs in {name} requirements: {req_str}", 'red')
+
+ extra = set(recompile_requirements.CHANGELOG_URLS) - all_requirements
+ if extra:
+ ok = False
+ req_str = ', '.join(sorted(extra))
+ utils.print_col(f"Extra changelog URLs: {req_str}", 'red')
+
+ if not ok:
+ print("Hint: Changelog URLs are in scripts/dev/recompile_requirements.py")
+
+ return ok
-def check_git():
- """Check for uncommitted git files.."""
+def check_git(_args: argparse.Namespace = None) -> bool:
+ """Check for uncommitted git files."""
if not os.path.isdir(".git"):
print("No .git dir, ignoring")
print()
@@ -79,50 +139,83 @@ def check_git():
return status
-def check_spelling():
+def _check_spelling_file(path, fobj, patterns):
+ ok = True
+ for num, line in enumerate(fobj, start=1):
+ for pattern, explanation in patterns:
+ if pattern.search(line):
+ ok = False
+ print(f'{path}:{num}: ', end='')
+ utils.print_col(f'Found "{pattern.pattern}" - {explanation}', 'blue')
+ return ok
+
+
+def check_spelling(args: argparse.Namespace) -> Optional[bool]:
"""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',
- '[Aa]uxillary', '[Aa]ccidentaly', '[Aa]mbigious', '[Ll]oosly',
- '[Ii]nitialis', '[Cc]onvienence', '[Ss]imiliar', '[Uu]ncommited',
- '[Rr]eproducable', '[Aa]n [Uu]ser', '[Cc]onvienience',
- '[Ww]ether', '[Pp]rogramatically', '[Ss]plitted', '[Ee]xitted',
- '[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]'}
+ words = {'behaviour', 'quitted', 'likelyhood', 'sucessfully',
+ 'occur[^rs .!]', 'seperator', 'explicitely', 'auxillary',
+ 'accidentaly', 'ambigious', 'loosly', 'initialis', 'convienence',
+ 'similiar', 'uncommited', 'reproducable', 'an user',
+ 'convienience', 'wether', 'programatically', 'splitted',
+ 'exitted', 'mininum', 'resett?ed', 'recieved', 'regularily',
+ 'underlaying', 'inexistant', 'elipsis', 'commiting', 'existant',
+ 'resetted', 'similarily', 'informations', 'an url', 'treshold',
+ 'artefact', 'an unix', 'an utf', 'an unicode', 'unparseable',
+ 'dependancies', 'convertable', 'chosing', 'authentification'}
# Words which look better when splitted, but might need some fine tuning.
- words |= {'[Ww]ebelements', '[Mm]ouseevent', '[Kk]eysequence',
- '[Nn]ormalmode', '[Ee]ventloops', '[Ss]izehint',
- '[Ss]tatemachine', '[Mm]etaobject', '[Ll]ogrecord',
- '[Ff]iletype'}
+ words |= {'webelements', 'mouseevent', 'keysequence', 'normalmode',
+ 'eventloops', 'sizehint', 'statemachine', 'metaobject',
+ 'logrecord'}
+
+ patterns = [
+ (
+ re.compile(r'[{}{}]{}'.format(w[0], w[0].upper(), w[1:])),
+ "Common misspelling or non-US spelling"
+ ) for w in words
+ ]
+ patterns += [
+ (
+ re.compile(r'(?i)# noqa(?!: )'),
+ "Don't use a blanket 'noqa', use something like 'noqa: X123' instead.",
+ ),
+ (
+ re.compile(r'# type: ignore[^\[]'),
+ ("Don't use a blanket 'type: ignore', use something like "
+ "'type: ignore[error-code]' instead."),
+ ),
+ (
+ re.compile(r'# type: (?!ignore(\[|$))'),
+ "Don't use type comments, use type annotations instead.",
+ ),
+ (
+ re.compile(r': typing\.'),
+ "Don't use typing.SomeType, do 'from typing import SomeType' instead.",
+ ),
+ (
+ re.compile(r"""monkeypatch\.setattr\(['"]"""),
+ "Don't use monkeypatch.setattr('obj.attr', value), use "
+ "setattr(obj, 'attr', value) instead.",
+ ),
+ ]
# Files which should be ignored, e.g. because they come from another
# package
+ hint_data = pathlib.Path('tests', 'end2end', 'data', 'hints')
ignored = [
- os.path.join('.', 'scripts', 'dev', 'misc_checks.py'),
- os.path.join('.', 'qutebrowser', '3rdparty', 'pdfjs'),
- os.path.join('.', 'tests', 'end2end', 'data', 'hints', 'ace',
- 'ace.js'),
+ pathlib.Path('scripts', 'dev', 'misc_checks.py'),
+ pathlib.Path('qutebrowser', '3rdparty', 'pdfjs'),
+ hint_data / 'ace' / 'ace.js',
+ hint_data / 'bootstrap' / 'bootstrap.css',
]
- seen = collections.defaultdict(list)
try:
ok = True
- for fn in _get_files():
- with tokenize.open(fn) as f:
- if any(fn.startswith(i) for i in ignored):
- continue
- for line in f:
- for w in words:
- if (re.search(w, line) and
- fn not in seen[w] and
- '# pragma: no spellcheck' not in line):
- print('Found "{}" in {}!'.format(w, fn))
- seen[w].append(fn)
- ok = False
+ for path in _get_files(verbose=args.verbose, ignored=ignored):
+ with tokenize.open(str(path)) as f:
+ if not _check_spelling_file(path, f, patterns):
+ ok = False
print()
return ok
except Exception:
@@ -130,15 +223,18 @@ def check_spelling():
return None
-def check_vcs_conflict():
+def check_vcs_conflict(args: argparse.Namespace) -> Optional[bool]:
"""Check VCS conflict markers."""
try:
ok = True
- for fn in _get_files(only_py=True):
- with tokenize.open(fn) as f:
+ for path in _get_files(verbose=args.verbose):
+ if path.suffix in {'.rst', '.asciidoc'}:
+ # False positives
+ continue
+ with tokenize.open(str(path)) as f:
for line in f:
if any(line.startswith(c * 7) for c in '<>=|'):
- print("Found conflict marker in {}".format(fn))
+ print("Found conflict marker in {}".format(path))
ok = False
print()
return ok
@@ -147,7 +243,7 @@ def check_vcs_conflict():
return None
-def check_userscripts_descriptions():
+def check_userscripts_descriptions(_args: argparse.Namespace = None) -> bool:
"""Make sure all userscripts are described properly."""
folder = pathlib.Path('misc/userscripts')
readme = folder / 'README.md'
@@ -163,7 +259,7 @@ def check_userscripts_descriptions():
described.add(match.group(1))
present = {path.name for path in folder.iterdir()}
- present.remove('README.md')
+ present -= {'README.md', '.mypy_cache', '__pycache__'}
missing = present - described
additional = described - present
@@ -179,20 +275,54 @@ def check_userscripts_descriptions():
return ok
-def main():
+def check_userscript_shebangs(_args: argparse.Namespace) -> bool:
+ """Check that we're using /usr/bin/env in shebangs."""
+ ok = True
+ folder = pathlib.Path('misc/userscripts')
+
+ for sub in folder.iterdir():
+ if sub.is_dir() or sub.name == 'README.md':
+ continue
+
+ with sub.open('r', encoding='utf-8') as f:
+ shebang = f.readline()
+ assert shebang.startswith('#!'), shebang
+ binary = shebang.split()[0][2:]
+
+ if binary not in ['/bin/sh', '/usr/bin/env']:
+ bin_name = pathlib.Path(binary).name
+ print(f"In {sub}, use #!/usr/bin/env {bin_name} instead of #!{binary}")
+ ok = False
+
+ return ok
+
+
+def main() -> int:
+ checkers = {
+ 'git': check_git,
+ 'vcs': check_vcs_conflict,
+ 'spelling': check_spelling,
+ 'userscript-descriptions': check_userscripts_descriptions,
+ 'userscript-shebangs': check_userscript_shebangs,
+ 'changelog-urls': check_changelog_urls,
+ }
+
parser = argparse.ArgumentParser()
+ parser.add_argument('--verbose', action='store_true', help='Show checked filenames')
parser.add_argument('checker',
- choices=('git', 'vcs', 'spelling', 'userscripts'),
+ choices=list(checkers) + ['all'],
help="Which checker to run.")
args = parser.parse_args()
- if args.checker == 'git':
- ok = check_git()
- elif args.checker == 'vcs':
- ok = check_vcs_conflict()
- elif args.checker == 'spelling':
- ok = check_spelling()
- elif args.checker == 'userscripts':
- ok = check_userscripts_descriptions()
+
+ if args.checker == 'all':
+ retvals = []
+ for name, checker in checkers.items():
+ utils.print_title(name)
+ retvals.append(checker(args))
+ return 0 if all(retvals) else 1
+
+ checker = checkers[args.checker]
+ ok = checker(args)
return 0 if ok else 1
diff --git a/scripts/dev/pylint_checkers/qute_pylint/config.py b/scripts/dev/pylint_checkers/qute_pylint/config.py
index 097b8ada3..7eedeb215 100644
--- a/scripts/dev/pylint_checkers/qute_pylint/config.py
+++ b/scripts/dev/pylint_checkers/qute_pylint/config.py
@@ -49,13 +49,24 @@ class ConfigChecker(checkers.BaseChecker):
@utils.check_messages('bad-config-option')
def visit_attribute(self, node):
"""Visit a getattr node."""
- # At the end of a config.val.foo.bar chain
- if not isinstance(node.parent, astroid.Attribute):
- # FIXME:conf do some proper check for this...
- node_str = node.as_string()
- prefix = 'config.val.'
- if node_str.startswith(prefix):
- self._check_config(node, node_str[len(prefix):])
+ # We're only interested in the end of a config.val.foo.bar chain
+ if isinstance(node.parent, astroid.Attribute):
+ return
+
+ if isinstance(node.parent, astroid.Call):
+ # Skip dynamic getattr()
+ func = node.parent.func
+ if isinstance(func, astroid.Name) and func.name == 'getattr':
+ return
+ # Handle .items() / .values()
+ if node.attrname in ['items', 'values']:
+ node = node.expr
+
+ # FIXME:conf do some proper check for this...
+ node_str = node.as_string()
+ prefix = 'config.val.'
+ if node_str.startswith(prefix):
+ self._check_config(node, node_str[len(prefix):])
def _check_config(self, node, name):
"""Check that we're accessing proper config options."""
diff --git a/scripts/dev/recompile_requirements.py b/scripts/dev/recompile_requirements.py
index ecd7dd153..e43a3111d 100644
--- a/scripts/dev/recompile_requirements.py
+++ b/scripts/dev/recompile_requirements.py
@@ -26,6 +26,7 @@ import os.path
import glob
import subprocess
import tempfile
+import argparse
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir,
os.pardir))
@@ -36,6 +37,147 @@ REPO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'..', '..') # /scripts/dev -> /scripts -> /
REQ_DIR = os.path.join(REPO_DIR, 'misc', 'requirements')
+CHANGELOG_URLS = {
+ 'pyparsing': 'https://github.com/pyparsing/pyparsing/blob/master/CHANGES',
+ 'pylint': 'http://pylint.pycqa.org/en/latest/whatsnew/changelog.html',
+ 'isort': 'https://pycqa.github.io/isort/CHANGELOG/',
+ 'lazy-object-proxy': 'https://github.com/ionelmc/python-lazy-object-proxy/blob/master/CHANGELOG.rst',
+ 'mccabe': 'https://github.com/PyCQA/mccabe#changes',
+ 'pytest-cov': 'https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst',
+ 'pytest-xdist': 'https://github.com/pytest-dev/pytest-xdist/blob/master/CHANGELOG.rst',
+ 'pytest-forked': 'https://github.com/pytest-dev/pytest-forked/blob/master/CHANGELOG',
+ 'pytest-xvfb': 'https://github.com/The-Compiler/pytest-xvfb/blob/master/CHANGELOG.rst',
+ 'EasyProcess': 'https://github.com/ponty/EasyProcess/commits/master',
+ 'PyVirtualDisplay': 'https://github.com/ponty/PyVirtualDisplay/commits/master',
+ 'execnet': 'https://execnet.readthedocs.io/en/latest/changelog.html',
+ 'apipkg': 'https://github.com/pytest-dev/apipkg/blob/master/CHANGELOG',
+ 'pytest-rerunfailures': 'https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst',
+ 'pytest-repeat': 'https://github.com/pytest-dev/pytest-repeat/blob/master/CHANGES.rst',
+ 'requests': 'https://github.com/psf/requests/blob/master/HISTORY.md',
+ 'requests-file': 'https://github.com/dashea/requests-file/blob/master/CHANGES.rst',
+ 'Werkzeug': 'https://github.com/pallets/werkzeug/blob/master/CHANGES.rst',
+ 'click': 'https://click.palletsprojects.com/en/7.x/changelog/',
+ 'itsdangerous': 'https://itsdangerous.palletsprojects.com/en/1.1.x/changes/',
+ 'parse-type': 'https://github.com/jenisys/parse_type/blob/master/CHANGES.txt',
+ 'sortedcontainers': 'https://github.com/grantjenks/python-sortedcontainers/blob/master/HISTORY.rst',
+ 'soupsieve': 'https://facelessuser.github.io/soupsieve/about/changelog/',
+ 'Flask': 'https://flask.palletsprojects.com/en/1.1.x/changelog/',
+ 'Mako': 'https://docs.makotemplates.org/en/latest/changelog.html',
+ 'glob2': 'https://github.com/miracle2k/python-glob2/blob/master/CHANGES',
+ 'hypothesis': 'https://hypothesis.readthedocs.io/en/latest/changes.html',
+ 'mypy': 'https://mypy-lang.blogspot.com/',
+ 'pytest': 'https://docs.pytest.org/en/latest/changelog.html',
+ 'iniconfig': 'https://github.com/RonnyPfannschmidt/iniconfig/blob/master/CHANGELOG',
+ 'tox': 'https://tox.readthedocs.io/en/latest/changelog.html',
+ 'PyYAML': 'https://github.com/yaml/pyyaml/blob/master/CHANGES',
+ 'pytest-bdd': 'https://github.com/pytest-dev/pytest-bdd/blob/master/CHANGES.rst',
+ 'snowballstemmer': 'https://github.com/snowballstem/snowball/blob/master/NEWS',
+ 'virtualenv': 'https://virtualenv.pypa.io/en/latest/changelog.html',
+ 'packaging': 'https://packaging.pypa.io/en/latest/changelog.html',
+ 'build': 'https://github.com/pypa/build/commits/master',
+ 'attrs': 'http://www.attrs.org/en/stable/changelog.html',
+ 'Jinja2': 'https://github.com/pallets/jinja/blob/master/CHANGES.rst',
+ 'MarkupSafe': 'https://markupsafe.palletsprojects.com/en/1.1.x/changes/',
+ 'flake8': 'https://gitlab.com/pycqa/flake8/tree/master/docs/source/release-notes',
+ 'flake8-docstrings': 'https://pypi.org/project/flake8-docstrings/',
+ 'flake8-debugger': 'https://github.com/JBKahn/flake8-debugger/',
+ 'flake8-builtins': 'https://github.com/gforcada/flake8-builtins/blob/master/CHANGES.rst',
+ 'flake8-bugbear': 'https://github.com/PyCQA/flake8-bugbear#change-log',
+ 'flake8-tidy-imports': 'https://github.com/adamchainz/flake8-tidy-imports/blob/master/HISTORY.rst',
+ 'flake8-tuple': 'https://github.com/ar4s/flake8_tuple/blob/master/HISTORY.rst',
+ 'flake8-comprehensions': 'https://github.com/adamchainz/flake8-comprehensions/blob/master/HISTORY.rst',
+ 'flake8-copyright': 'https://github.com/savoirfairelinux/flake8-copyright/blob/master/CHANGELOG.rst',
+ 'flake8-deprecated': 'https://github.com/gforcada/flake8-deprecated/blob/master/CHANGES.rst',
+ 'flake8-future-import': 'https://github.com/xZise/flake8-future-import#changes',
+ 'flake8-mock': 'https://github.com/aleGpereira/flake8-mock#changes',
+ 'flake8-polyfill': 'https://gitlab.com/pycqa/flake8-polyfill/-/blob/master/CHANGELOG.rst',
+ 'flake8-string-format': 'https://github.com/xZise/flake8-string-format#changes',
+ 'pep8-naming': 'https://github.com/PyCQA/pep8-naming/blob/master/CHANGELOG.rst',
+ 'pycodestyle': 'https://github.com/PyCQA/pycodestyle/blob/master/CHANGES.txt',
+ 'pyflakes': 'https://github.com/PyCQA/pyflakes/blob/master/NEWS.rst',
+ 'cffi': 'https://cffi.readthedocs.io/en/latest/whatsnew.html',
+ 'astroid': 'https://github.com/PyCQA/astroid/blob/2.4/ChangeLog',
+ 'pytest-instafail': 'https://github.com/pytest-dev/pytest-instafail/blob/master/CHANGES.rst',
+ 'coverage': 'https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst',
+ 'colorama': 'https://github.com/tartley/colorama/blob/master/CHANGELOG.rst',
+ 'hunter': 'https://github.com/ionelmc/python-hunter/blob/master/CHANGELOG.rst',
+ 'uritemplate': 'https://github.com/python-hyper/uritemplate/blob/master/HISTORY.rst',
+ 'more-itertools': 'https://github.com/erikrose/more-itertools/blob/master/docs/versions.rst',
+ 'pydocstyle': 'http://www.pydocstyle.org/en/latest/release_notes.html',
+ 'Sphinx': 'https://www.sphinx-doc.org/en/master/changes.html',
+ 'Babel': 'https://github.com/python-babel/babel/blob/master/CHANGES',
+ 'alabaster': 'https://alabaster.readthedocs.io/en/latest/changelog.html',
+ 'imagesize': 'https://github.com/shibukawa/imagesize_py/commits/master',
+ 'pytz': 'https://mm.icann.org/pipermail/tz-announce/',
+ 'sphinxcontrib-applehelp': 'https://www.sphinx-doc.org/en/master/changes.html',
+ 'sphinxcontrib-devhelp': 'https://www.sphinx-doc.org/en/master/changes.html',
+ 'sphinxcontrib-htmlhelp': 'https://www.sphinx-doc.org/en/master/changes.html',
+ 'sphinxcontrib-jsmath': 'https://www.sphinx-doc.org/en/master/changes.html',
+ 'sphinxcontrib-qthelp': 'https://www.sphinx-doc.org/en/master/changes.html',
+ 'sphinxcontrib-serializinghtml': 'https://www.sphinx-doc.org/en/master/changes.html',
+ 'jaraco.functools': 'https://github.com/jaraco/jaraco.functools/blob/master/CHANGES.rst',
+ 'parse': 'https://github.com/r1chardj0n3s/parse#potential-gotchas',
+ 'py': 'https://py.readthedocs.io/en/latest/changelog.html#changelog',
+ 'Pympler': 'https://github.com/pympler/pympler/blob/master/CHANGELOG.md',
+ 'pytest-mock': 'https://github.com/pytest-dev/pytest-mock/blob/master/CHANGELOG.rst',
+ 'pytest-qt': 'https://github.com/pytest-dev/pytest-qt/blob/master/CHANGELOG.rst',
+ 'pyinstaller': 'https://pyinstaller.readthedocs.io/en/stable/CHANGES.html',
+ 'pyinstaller-hooks-contrib': 'https://github.com/pyinstaller/pyinstaller-hooks-contrib/blob/master/CHANGELOG.rst',
+ 'pytest-benchmark': 'https://pytest-benchmark.readthedocs.io/en/stable/changelog.html',
+ 'typed-ast': 'https://github.com/python/typed_ast/commits/master',
+ 'docutils': 'https://docutils.sourceforge.io/RELEASE-NOTES.html',
+ 'bump2version': 'https://github.com/c4urself/bump2version/blob/master/CHANGELOG.md',
+ 'six': 'https://github.com/benjaminp/six/blob/master/CHANGES',
+ 'altgraph': 'https://github.com/ronaldoussoren/altgraph/blob/master/doc/changelog.rst',
+ 'urllib3': 'https://github.com/urllib3/urllib3/blob/master/CHANGES.rst',
+ 'lxml': 'https://lxml.de/index.html#old-versions',
+ 'jwcrypto': 'https://github.com/latchset/jwcrypto/commits/master',
+ 'wrapt': 'https://github.com/GrahamDumpleton/wrapt/blob/develop/docs/changes.rst',
+ 'pep517': 'https://github.com/pypa/pep517/blob/master/doc/changelog.rst',
+ 'cryptography': 'https://cryptography.io/en/latest/changelog.html',
+ 'toml': 'https://github.com/uiri/toml/releases',
+ 'PyQt5': 'https://www.riverbankcomputing.com/news',
+ 'PyQtWebEngine': 'https://www.riverbankcomputing.com/news',
+ 'PyQt-builder': 'https://www.riverbankcomputing.com/news',
+ 'PyQt5-sip': 'https://www.riverbankcomputing.com/news',
+ 'PyQt5_stubs': 'https://github.com/stlehmann/PyQt5-stubs/blob/master/CHANGELOG.md',
+ 'sip': 'https://www.riverbankcomputing.com/news',
+ 'Pygments': 'https://pygments.org/docs/changelog/',
+ 'vulture': 'https://github.com/jendrikseipp/vulture/blob/master/CHANGELOG.md',
+ 'distlib': 'https://bitbucket.org/pypa/distlib/src/master/CHANGES.rst',
+ 'py-cpuinfo': 'https://github.com/workhorsy/py-cpuinfo/blob/master/ChangeLog',
+ 'cheroot': 'https://cheroot.cherrypy.org/en/latest/history.html',
+ 'certifi': 'https://ccadb-public.secure.force.com/mozilla/IncludedCACertificateReport',
+ 'chardet': 'https://github.com/chardet/chardet/releases',
+ 'idna': 'https://github.com/kjd/idna/blob/master/HISTORY.rst',
+ 'tldextract': 'https://github.com/john-kurkowski/tldextract/blob/master/CHANGELOG.md',
+ 'typing-extensions': 'https://github.com/python/typing/commits/master/typing_extensions',
+ 'diff-cover': 'https://github.com/Bachmann1234/diff_cover/blob/master/CHANGELOG',
+ 'pytest-clarity': 'https://github.com/darrenburns/pytest-clarity/commits/master',
+ 'pytest-icdiff': 'https://github.com/hjwp/pytest-icdiff/blob/master/HISTORY.rst',
+ 'icdiff': 'https://github.com/jeffkaufman/icdiff/blob/master/ChangeLog',
+ 'termcolor': 'https://pypi.org/project/termcolor/',
+ 'pprintpp': 'https://github.com/wolever/pprintpp/blob/master/CHANGELOG.txt',
+ 'beautifulsoup4': 'https://bazaar.launchpad.net/~leonardr/beautifulsoup/bs4/view/head:/CHANGELOG',
+ 'check-manifest': 'https://github.com/mgedmin/check-manifest/blob/master/CHANGES.rst',
+ 'yamllint': 'https://github.com/adrienverge/yamllint/blob/master/CHANGELOG.rst',
+ 'pathspec': 'https://github.com/cpburnz/python-path-specification/blob/master/CHANGES.rst',
+ 'filelock': 'https://github.com/benediktschmitt/py-filelock/commits/master',
+ 'github3.py': 'https://github3py.readthedocs.io/en/master/release-notes/index.html',
+ 'manhole': 'https://github3py.readthedocs.io/en/master/release-notes/index.html',
+ 'pycparser': 'https://github.com/eliben/pycparser/blob/master/CHANGES',
+ 'python-dateutil': 'https://dateutil.readthedocs.io/en/stable/changelog.html',
+ 'appdirs': 'https://github.com/ActiveState/appdirs/blob/master/CHANGES.rst',
+ 'pluggy': 'https://github.com/pytest-dev/pluggy/blob/master/CHANGELOG.rst',
+ 'inflect': 'https://github.com/jazzband/inflect/blob/master/CHANGES.rst',
+ 'jinja2-pluralize': 'https://github.com/audreyfeldroy/jinja2_pluralize/blob/master/HISTORY.rst',
+ 'mypy-extensions': 'https://github.com/python/mypy_extensions/commits/master',
+ 'pyroma': 'https://github.com/regebro/pyroma/blob/master/HISTORY.txt',
+ 'adblock': 'https://github.com/ArniDagur/python-adblock/blob/master/CHANGELOG.md',
+ 'pyPEG2': None,
+ 'importlib-resources': 'https://importlib-resources.readthedocs.io/en/latest/history.html',
+}
+
def convert_line(line, comments):
"""Convert the given requirement line to place into the output."""
@@ -81,12 +223,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 +251,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,69 +263,295 @@ def get_all_names():
yield basename[len('requirements-'):-len('.txt-raw')]
-def init_venv(host_python, venv_dir, requirements):
- """Initialize a new virtualenv and install the given packages."""
- subprocess.run([host_python, '-m', 'venv', venv_dir], check=True)
+def run_pip(venv_dir, *args, quiet=False, **kwargs):
+ """Run pip inside the virtualenv."""
+ args = list(args)
+ if quiet:
+ args.insert(1, '-q')
+
+ arg_str = ' '.join(str(arg) for arg in args)
+ utils.print_col('venv$ pip {}'.format(arg_str), 'blue')
venv_python = os.path.join(venv_dir, 'bin', 'python')
- subprocess.run([venv_python, '-m', 'pip',
- 'install', '-U', 'pip'], check=True)
+ return subprocess.run([venv_python, '-m', 'pip'] + args, check=True, **kwargs)
- subprocess.run([venv_python, '-m', 'pip',
- 'install', '-r', requirements], check=True)
- subprocess.run([venv_python, '-m', 'pip', 'check'], check=True)
- return venv_python
+def init_venv(host_python, venv_dir, requirements, pre=False):
+ """Initialize a new virtualenv and install the given packages."""
+ with utils.gha_group('Creating virtualenv'):
+ utils.print_col('$ python3 -m venv {}'.format(venv_dir), 'blue')
+ subprocess.run([host_python, '-m', 'venv', venv_dir], check=True)
-def main():
- """Re-compile the given (or all) requirement files."""
- names = sys.argv[1:] if len(sys.argv) > 1 else sorted(get_all_names())
+ run_pip(venv_dir, 'install', '-U', 'pip', quiet=not utils.ON_CI)
+ run_pip(venv_dir, 'install', '-U', 'setuptools', 'wheel', quiet=not utils.ON_CI)
- for name in names:
- utils.print_title(name)
- filename = os.path.join(REQ_DIR,
- 'requirements-{}.txt-raw'.format(name))
- if name == 'qutebrowser':
- outfile = os.path.join(REPO_DIR, 'requirements.txt')
- else:
- outfile = os.path.join(REQ_DIR, 'requirements-{}.txt'.format(name))
-
- if name in [
- # Need sip v4 which doesn't work on Python 3.8
- 'pyqt-5.7', 'pyqt-5.9', 'pyqt-5.10', 'pyqt-5.11', 'pyqt-5.12',
- # Installs typed_ast on < 3.8 only
- 'pylint',
- ]:
- host_python = 'python3.7'
- else:
- host_python = sys.executable
+ install_command = ['install', '-r', requirements]
+ if pre:
+ install_command.append('--pre')
- utils.print_subtitle("Building")
+ with utils.gha_group('Installing requirements'):
+ run_pip(venv_dir, *install_command)
+ run_pip(venv_dir, 'check')
+
+
+def parse_args():
+ """Parse commandline arguments via argparse."""
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--force-test', help="Force running environment tests",
+ action='store_true')
+ parser.add_argument('names', nargs='*')
+ return parser.parse_args()
- with tempfile.TemporaryDirectory() as tmpdir:
- venv_python = init_venv(host_python, tmpdir, filename)
- 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)
+def git_diff(*args):
+ """Run a git diff command."""
+ command = (['git', '--no-pager', 'diff'] + list(args) + [
+ '--', 'requirements.txt', 'misc/requirements/requirements-*.txt'])
+ proc = subprocess.run(command,
+ stdout=subprocess.PIPE,
+ encoding='utf-8',
+ check=True)
+ return proc.stdout.splitlines()
- with open(outfile, 'w', encoding='utf-8') as f:
- f.write("# This file is automatically generated by "
- "scripts/dev/recompile_requirements.py\n\n")
- for line in reqs.splitlines():
- if line.startswith('qutebrowser=='):
- continue
- f.write(convert_line(line, comments) + '\n')
- for line in comments['add']:
- f.write(line + '\n')
+class Change:
- # Test resulting file
- utils.print_subtitle("Testing")
- with tempfile.TemporaryDirectory() as tmpdir:
- init_venv(host_python, tmpdir, outfile)
+ """A single requirements change from a git diff output."""
+
+ def __init__(self, name):
+ self.name = name
+ self.old = None
+ self.new = None
+ if CHANGELOG_URLS.get(name):
+ self.url = CHANGELOG_URLS[name]
+ self.link = '[{}]({})'.format(self.name, self.url)
+ else:
+ self.url = '(no changelog)'
+ self.link = self.name
+
+ def __str__(self):
+ if self.old is None:
+ return '- {} new: {} {}'.format(self.name, self.new, self.url)
+ elif self.new is None:
+ return '- {} removed: {} {}'.format(self.name, self.old,
+ self.url)
+ else:
+ return '- {} {} -> {} {}'.format(self.name, self.old, self.new,
+ self.url)
+
+ def table_str(self):
+ """Generate a markdown table."""
+ if self.old is None:
+ return '| {} | -- | {} |'.format(self.link, self.new)
+ elif self.new is None:
+ return '| {} | {} | -- |'.format(self.link, self.old)
+ else:
+ return '| {} | {} | {} |'.format(self.link, self.old, self.new)
+
+
+def _get_changed_files():
+ """Get a list of changed files via git."""
+ changed_files = set()
+ filenames = git_diff('--name-only')
+ for filename in filenames:
+ filename = filename.strip()
+ filename = filename.replace('misc/requirements/requirements-', '')
+ filename = filename.replace('.txt', '')
+ changed_files.add(filename)
+
+ return sorted(changed_files)
+
+
+def parse_versioned_line(line):
+ """Parse a requirements.txt line into name/version."""
+ if '==' in line:
+ line = line.rsplit('#', maxsplit=1)[0] # Strip comments
+ name, version = line.split('==')
+ if ';' in version: # pip environment markers
+ version = version.split(';')[0].strip()
+ elif line.startswith('-e'):
+ rest, name = line.split('#egg=')
+ version = rest.split('@')[1][:7]
+ else:
+ name = line
+ version = '?'
+
+ if name.startswith('#'): # duplicate requirements
+ name = name[1:].strip()
+
+ return name, version
+
+
+def _get_changes():
+ """Get a list of changed versions from git."""
+ changes_dict = {}
+ diff = git_diff()
+ for line in diff:
+ if not line.startswith('-') and not line.startswith('+'):
+ continue
+ if line.startswith('+++ ') or line.startswith('--- '):
+ continue
+
+ name, version = parse_versioned_line(line[1:])
+
+ if name not in changes_dict:
+ changes_dict[name] = Change(name)
+
+ if line.startswith('-'):
+ changes_dict[name].old = version
+ elif line.startswith('+'):
+ changes_dict[name].new = version
+
+ return [change for _name, change in sorted(changes_dict.items())]
+
+
+def print_changed_files():
+ """Output all changed files from this run."""
+ changed_files = _get_changed_files()
+ files_text = '\n'.join('- ' + line for line in changed_files)
+
+ changes = _get_changes()
+ diff_text = '\n'.join(str(change) for change in changes)
+
+ utils.print_title('Changed')
+ utils.print_subtitle('Files')
+ print(files_text)
+ print()
+ utils.print_subtitle('Diff')
+ print(diff_text)
+
+ if utils.ON_CI:
+ print()
+ print('::set-output name=changed::' +
+ files_text.replace('\n', '%0A'))
+ table_header = [
+ '| Requirement | old | new |',
+ '|-------------|-----|-----|',
+ ]
+ diff_table = '%0A'.join(table_header +
+ [change.table_str() for change in changes])
+ print('::set-output name=diff::' + diff_table)
+
+
+def get_host_python(name):
+ """Get the Python to use for a given requirement name.
+
+ pylint installs typed_ast on < 3.8 only
+ """
+ if name == 'pylint':
+ return 'python3.7'
+ else:
+ return sys.executable
+
+
+def get_outfile(name):
+ """Get the path to the output requirements.txt file."""
+ if name == 'qutebrowser':
+ return os.path.join(REPO_DIR, 'requirements.txt')
+ return os.path.join(REQ_DIR, 'requirements-{}.txt'.format(name))
+
+
+def build_requirements(name):
+ """Build a requirements file."""
+ utils.print_subtitle("Building")
+ filename = os.path.join(REQ_DIR, 'requirements-{}.txt-raw'.format(name))
+ host_python = get_host_python(name)
+
+ with open(filename, 'r', encoding='utf-8') as f:
+ comments = read_comments(f)
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ init_venv(host_python=host_python,
+ venv_dir=tmpdir,
+ requirements=filename,
+ pre=comments['pre'])
+ with utils.gha_group('Freezing requirements'):
+ proc = run_pip(tmpdir, 'freeze', stdout=subprocess.PIPE)
+ reqs = proc.stdout.decode('utf-8')
+ if utils.ON_CI:
+ print(reqs.strip())
+
+ outfile = get_outfile(name)
+
+ with open(outfile, 'w', encoding='utf-8') as f:
+ f.write("# This file is automatically generated by "
+ "scripts/dev/recompile_requirements.py\n\n")
+ for line in reqs.splitlines():
+ if line.startswith('qutebrowser=='):
+ continue
+ f.write(convert_line(line, comments) + '\n')
+
+ for line in comments['add']:
+ f.write(line + '\n')
+
+ return outfile
+
+
+def test_tox():
+ """Test requirements via tox."""
+ host_python = get_host_python('tox')
+ req_path = os.path.join(REQ_DIR, 'requirements-tox.txt')
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ venv_dir = os.path.join(tmpdir, 'venv')
+ tox_workdir = os.path.join(tmpdir, 'tox-workdir')
+ venv_python = os.path.join(venv_dir, 'bin', 'python')
+ init_venv(host_python, venv_dir, req_path)
+ list_proc = subprocess.run([venv_python, '-m', 'tox', '--listenvs'],
+ check=True,
+ stdout=subprocess.PIPE,
+ universal_newlines=True)
+ environments = list_proc.stdout.strip().split('\n')
+ for env in environments:
+ with utils.gha_group('tox for {}'.format(env)):
+ utils.print_subtitle(env)
+ utils.print_col('venv$ tox -e {} --notest'.format(env), 'blue')
+ subprocess.run([venv_python, '-m', 'tox',
+ '--workdir', tox_workdir,
+ '-e', env,
+ '--notest'],
+ check=True)
+
+
+def test_requirements(name, outfile, *, force=False):
+ """Test a resulting requirements file."""
+ print()
+ utils.print_subtitle("Testing")
+
+ if name not in _get_changed_files() and not force:
+ print(f"Skipping test as there were no changes for {name}.")
+ return
+
+ host_python = get_host_python(name)
+ with tempfile.TemporaryDirectory() as tmpdir:
+ init_venv(host_python, tmpdir, outfile)
+
+
+def main():
+ """Re-compile the given (or all) requirement files."""
+ args = parse_args()
+ if args.names:
+ names = args.names
+ else:
+ names = sorted(get_all_names())
+
+ utils.print_col('Rebuilding requirements: ' + ', '.join(names), 'green')
+ for name in names:
+ utils.print_title(name)
+ outfile = build_requirements(name)
+ test_requirements(name, outfile, force=args.force_test)
+
+ utils.print_title('Testing via tox')
+ if args.names and not args.force_test:
+ # If we selected a subset, let's not go through the trouble of testing
+ # via tox.
+ print("Skipping: Selected a subset only")
+ elif not _get_changed_files() and not args.force_test:
+ print("Skipping: No changes")
+ else:
+ test_tox()
+
+ print_changed_files()
if __name__ == '__main__':
diff --git a/scripts/dev/run_shellcheck.sh b/scripts/dev/run_shellcheck.sh
new file mode 100644
index 000000000..885e68375
--- /dev/null
+++ b/scripts/dev/run_shellcheck.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+# vim: ft=sh 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/>.
+
+set -e
+
+script_list=$(mktemp)
+find scripts/dev/ -name '*.sh' > "$script_list"
+find misc/userscripts/ -type f -exec grep -lE '[/ ][bd]ash$|[/ ]sh$|[/ ]ksh$' {} + >> "$script_list"
+mapfile -t scripts < "$script_list"
+rm -f "$script_list"
+
+if [[ $1 == --docker ]]; then
+ shift 1
+ docker run \
+ -v "$PWD:/outside" \
+ -w /outside \
+ -t \
+ koalaman/shellcheck:stable "$@" "${scripts[@]}"
+else
+ shellcheck --version
+ shellcheck "$@" "${scripts[@]}"
+fi
diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py
index f069d50de..05fcc9134 100755
--- a/scripts/dev/run_vulture.py
+++ b/scripts/dev/run_vulture.py
@@ -32,7 +32,7 @@ import vulture
import qutebrowser.app # pylint: disable=unused-import
from qutebrowser.extensions import loader
from qutebrowser.misc import objects
-from qutebrowser.utils import utils
+from qutebrowser.utils import utils, version
from qutebrowser.browser.webkit import rfc6266
# To run the decorators from there
# pylint: disable=unused-import
@@ -42,7 +42,7 @@ from qutebrowser.browser import qutescheme
from qutebrowser.config import configtypes
-def whitelist_generator(): # noqa
+def whitelist_generator(): # noqa: C901
"""Generator which yields lines to add to a vulture whitelist."""
loader.load_components(skip_hooks=True)
@@ -124,6 +124,9 @@ def whitelist_generator(): # noqa
'_get_default_metavar_for_positional', '_metavar_formatter']:
yield 'scripts.dev.src2asciidoc.UsageFormatter.' + attr
+ for dist in version.Distribution:
+ yield 'qutebrowser.utils.version.Distribution.{}'.format(dist.name)
+
# attrs
yield 'qutebrowser.browser.webkit.network.networkmanager.ProxyId.hostname'
yield 'qutebrowser.command.command.ArgInfo._validate_exclusive'
@@ -132,7 +135,10 @@ def whitelist_generator(): # noqa
yield 'scripts.importer.import_moz_places.places.row_factory'
# component hooks
- yield 'qutebrowser.components.adblock.on_config_changed'
+ yield 'qutebrowser.components.hostblock.on_lists_changed'
+ yield 'qutebrowser.components.braveadblock.on_lists_changed'
+ yield 'qutebrowser.components.hostblock.on_method_changed'
+ yield 'qutebrowser.components.braveadblock.on_method_changed'
# used in type comments
yield 'pending_download_type'
diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py
index b82ede3e1..60cb3d611 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, '')
@@ -74,7 +79,7 @@ class UsageFormatter(argparse.HelpFormatter):
def _metavar_formatter(self, action, default_metavar):
"""Override _metavar_formatter to add asciidoc markup to metavars.
- Most code here is copied from Python 3.4's argparse.py.
+ Most code here is copied from Python 3.10's argparse.py.
"""
if action.metavar is not None:
result = "'{}'".format(action.metavar)
@@ -110,6 +115,19 @@ class UsageFormatter(argparse.HelpFormatter):
action.option_strings = old_option_strings[action]
return ret
+ def _format_args(self, action, default_metavar):
+ """Backport simplified star nargs usage.
+
+ https://github.com/python/cpython/pull/17106
+ """
+ if sys.version_info >= (3, 9) or action.nargs != argparse.ZERO_OR_MORE:
+ return super()._format_args(action, default_metavar)
+
+ get_metavar = self._metavar_formatter(action, default_metavar)
+ metavar = get_metavar(1)
+ assert len(metavar) == 1
+ return f'[{metavar[0]} ...]'
+
def _open_file(name, mode='w'):
"""Open a file with a preset newline/encoding mode."""
@@ -162,7 +180,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)
@@ -424,7 +442,7 @@ def _generate_setting_option(f, opt):
f.write("=== {}".format(opt.name) + "\n")
f.write(opt.description + "\n")
if opt.restart:
- f.write("This setting requires a restart.\n")
+ f.write("\nThis setting requires a restart.\n")
if opt.supports_pattern:
f.write("\nThis setting supports URL patterns.\n")
if opt.no_autoconfig:
diff --git a/scripts/dev/ua_fetch.py b/scripts/dev/ua_fetch.py
new file mode 100644
index 000000000..a4ef889a0
--- /dev/null
+++ b/scripts/dev/ua_fetch.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+"""Fetch and print the most common user agents.
+
+This script fetches the most common user agents according to
+https://github.com/Kikobeats/top-user-agents, and prints the most recent
+Chrome user agent for Windows, macOS and Linux.
+"""
+
+import math
+import sys
+import textwrap
+
+import requests
+import qutebrowser.config.websettings
+
+
+def version(ua):
+ """Comparable version of a user agent."""
+ return tuple(int(v) for v in ua.upstream_browser_version.split('.')[:2])
+
+
+def wrap(ini, sub, string):
+ return textwrap.wrap(string, width=80, initial_indent=ini, subsequent_indent=sub)
+
+
+response = requests.get('https://raw.githubusercontent.com/Kikobeats/top-user-agents/master/index.json')
+
+if response.status_code != 200:
+ print('Unable to fetch the user agent index', file=sys.stderr)
+ sys.exit(1)
+
+ua_checks = {
+ 'Win10': lambda ua: ua.os_info.startswith('Windows NT'),
+ 'macOS': lambda ua: ua.os_info.startswith('Macintosh'),
+ 'Linux': lambda ua: ua.os_info.startswith('X11'),
+}
+
+ua_strings = {}
+ua_versions = {}
+ua_names = {}
+
+for ua_string in reversed(response.json()):
+ # reversed to prefer more common versions
+
+ # Filter out browsers that are not Chrome-based
+ parts = ua_string.split()
+ if not any(part.startswith("Chrome/") for part in parts):
+ continue
+ if any(part.startswith("OPR/") or part.startswith("Edg/") for part in parts):
+ continue
+
+ user_agent = qutebrowser.config.websettings.UserAgent.parse(ua_string)
+
+ # check which os_string conditions are met and select the most recent version
+ for key, check in ua_checks.items():
+ if check(user_agent):
+ v = version(user_agent)
+ if v >= ua_versions.get(key, (-math.inf,)):
+ ua_versions[key] = v
+ ua_strings[key] = ua_string
+ ua_names[key] = f'Chrome {v[0]} {key}'
+
+for key, ua_string in ua_strings.items():
+ quoted_ua_string = f'"{ua_string}"'
+ for line in wrap(" - - ", " ", quoted_ua_string):
+ print(line)
+ for line in wrap(" - ", " ", ua_names[key]):
+ print(line)
diff --git a/scripts/dev/update_version.py b/scripts/dev/update_version.py
index f6cc2c27b..1f744d392 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__
@@ -74,19 +78,12 @@ if __name__ == "__main__":
print("* Create new release via GitHub (required to upload release "
"artifacts)")
print("* Linux: git fetch && git checkout v{v} && "
- "./.venv/bin/python3 scripts/dev/build_release.py --upload"
+ "tox -e build-release -- --upload"
.format(v=version))
print("* Windows: git fetch; git checkout v{v}; "
- "py -3 scripts\\dev\\build_release.py --asciidoc "
- "C:\\Python27\\python "
- "$env:userprofile\\bin\\asciidoc-8.6.10\\asciidoc.py --upload"
+ "py -3.7 -m tox -e build-release -- --asciidoc "
+ "$env:userprofile\\bin\\asciidoc-9.0.4\\asciidoc.py --upload"
.format(v=version))
print("* macOS: git fetch && git checkout v{v} && "
- "python3 scripts/dev/build_release.py --upload"
- .format(v=version))
-
- print("* On server:")
- print(" - bash download_release.sh {v}"
+ "tox -e build-release -- --upload"
.format(v=version))
- print(" - git pull github master && sudo python3 "
- "scripts/asciidoc2html.py --website /srv/http/qutebrowser")
diff --git a/scripts/dictcli.py b/scripts/dictcli.py
index ebe4e285c..4e38727dd 100755
--- a/scripts/dictcli.py
+++ b/scripts/dictcli.py
@@ -37,8 +37,7 @@ import attr
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
+from qutebrowser.utils import standarddir
API_URL = 'https://chromium.googlesource.com/chromium/deps/hunspell_dictionaries.git/+/master/'
@@ -216,17 +215,8 @@ def install_lang(lang):
def install(languages):
"""Install languages."""
for lang in languages:
- try:
- print('Installing {}: {}'.format(lang.code, lang.name))
- install_lang(lang)
- except PermissionError as 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)
+ print('Installing {}: {}'.format(lang.code, lang.name))
+ install_lang(lang)
def update(languages):
@@ -250,24 +240,7 @@ def remove_old(languages):
os.remove(os.path.join(spell.dictionary_dir(), old_file))
-def check_root():
- """Ask for confirmation if running as root when unnecessary."""
- if not utils.is_posix:
- return
-
- if spell.can_use_data_path() and os.geteuid() == 0:
- print("You're running Qt >= 5.10 which means qutebrowser will "
- "load dictionaries from a path in your home-directory. "
- "Unless you run qutebrowser as root (bad idea!), you "
- "most likely want to run this script as your user. ")
- answer = input("Do you want to continue anyways? [y/N] ")
- if answer not in ['y', 'Y']:
- sys.exit(0)
-
-
def main():
- check_root()
-
if configdata.DATA is None:
configdata.init()
standarddir.init(None)
diff --git a/scripts/hostblock_blame.py b/scripts/hostblock_blame.py
index 2949b9e78..7b5d787a3 100644
--- a/scripts/hostblock_blame.py
+++ b/scripts/hostblock_blame.py
@@ -27,7 +27,7 @@ import os.path
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
-from qutebrowser.components import adblock
+from qutebrowser.components import hostblock
from qutebrowser.config import configdata
@@ -39,11 +39,11 @@ def main():
configdata.init()
- for url in configdata.DATA['content.host_blocking.lists'].default:
+ for url in configdata.DATA['content.blocking.hosts.lists'].default:
print("checking {}...".format(url))
raw_file = urllib.request.urlopen(url)
byte_io = io.BytesIO(raw_file.read())
- f = adblock.get_fileobj(byte_io)
+ f = hostblock.get_fileobj(byte_io)
for line in f:
line = line.decode('utf-8')
if sys.argv[1] in line:
diff --git a/scripts/importer.py b/scripts/importer.py
index bae483b09..111082072 100755
--- a/scripts/importer.py
+++ b/scripts/importer.py
@@ -22,27 +22,19 @@
"""Tool to import data from other browsers.
-Currently importing bookmarks from Netscape Bookmark files and Mozilla
-profiles is supported.
+Currently importing bookmarks from Netscape HTML Bookmark files, Chrome
+profiles, and Mozilla profiles is supported.
"""
import argparse
+import textwrap
import sqlite3
import os
import urllib.parse
import json
import string
-browser_default_input_format = {
- 'chromium': 'chrome',
- 'chrome': 'chrome',
- 'ie': 'netscape',
- 'firefox': 'mozilla',
- 'seamonkey': 'mozilla',
- 'palemoon': 'mozilla',
-}
-
def main():
args = get_args()
@@ -68,15 +60,9 @@ def main():
bookmark_types = ['bookmark', 'keyword']
if not output_format:
output_format = 'quickmark'
- if not input_format:
- if args.browser:
- input_format = browser_default_input_format[args.browser]
- else:
- #default to netscape
- input_format = 'netscape'
import_function = {
- 'netscape': import_netscape_bookmarks,
+ 'html': import_html_bookmarks,
'mozilla': import_moz_places,
'chrome': import_chrome,
}
@@ -87,20 +73,25 @@ def main():
def get_args():
"""Get the argparse parser."""
parser = argparse.ArgumentParser(
- epilog="To import bookmarks from Chromium, Firefox or IE, "
- "export them to HTML in your browsers bookmark manager. ")
- parser.add_argument(
- 'browser',
- help="Which browser? {%(choices)s}",
- choices=browser_default_input_format.keys(),
- nargs='?',
- metavar='browser')
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=textwrap.dedent('''
+ To import bookmarks, you'll need the path to your profile or an
+ exported HTML file from your browser's bookmark manager. Redirect
+ the output from this script to the appropriate file in your
+ qutebrowser config directory (listed in the output of :version),
+ usually done with the '>' operator; for example,
+ ./importer.py -i mozilla your_profile_path > ~/.config/qutebrowser/quickmarks
+
+ Common browsers with native input format support:
+ chrome: Chrome, Chromium, Edge
+ mozilla: Firefox, SeaMonkey, Pale Moon
+ '''))
parser.add_argument(
'-i',
'--input-format',
- help='Which input format? (overrides browser default; "netscape" if '
- 'neither given)',
- choices=set(browser_default_input_format.values()),
+ help="Which input format? Defaults to html",
+ choices=['html', 'mozilla', 'chrome'],
+ default='html',
required=False)
parser.add_argument(
'-b',
@@ -186,7 +177,7 @@ def opensearch_convert(url):
return search_escape(url.format(**subst)).replace('%s', '{}')
-def import_netscape_bookmarks(bookmarks_file, bookmark_types, output_format):
+def import_html_bookmarks(bookmarks_file, bookmark_types, output_format):
"""Import bookmarks from a NETSCAPE-Bookmark-file v1.
Generated by Chromium, Firefox, IE and possibly more browsers. Not all
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 04cf0e8c0..57e6fe3d1 100644
--- a/scripts/mkvenv.py
+++ b/scripts/mkvenv.py
@@ -24,12 +24,13 @@
import argparse
import pathlib
import sys
+import re
import os
import os.path
-import typing
import shutil
-import venv
+import venv as pyvenv
import subprocess
+from typing import List, Optional, Tuple, Dict, Union
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from scripts import utils, link_pyqt
@@ -38,9 +39,27 @@ from scripts import utils, link_pyqt
REPO_ROOT = pathlib.Path(__file__).parent.parent
-def parse_args() -> argparse.Namespace:
+class Error(Exception):
+
+ """Exception for errors in this script."""
+
+ def __init__(self, msg, code=1):
+ super().__init__(msg)
+ self.code = code
+
+
+def print_command(*cmd: Union[str, pathlib.Path], venv: bool) -> None:
+ """Print a command being run."""
+ prefix = 'venv$ ' if venv else '$ '
+ utils.print_col(prefix + ' '.join([str(e) for e in cmd]), 'blue')
+
+
+def parse_args(argv: List[str] = None) -> argparse.Namespace:
"""Parse commandline arguments."""
parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--update',
+ action='store_true',
+ help="Run 'git pull' before creating the environment.")
parser.add_argument('--keep',
action='store_true',
help="Reuse an existing virtualenv.")
@@ -71,13 +90,16 @@ def parse_args() -> argparse.Namespace:
parser.add_argument('--skip-docs',
action='store_true',
help="Skip doc generation.")
+ parser.add_argument('--skip-smoke-test',
+ action='store_true',
+ help="Skip Qt smoke test.")
parser.add_argument('--tox-error',
action='store_true',
help=argparse.SUPPRESS)
- return parser.parse_args()
+ return parser.parse_args(argv)
-def pyqt_versions() -> typing.List[str]:
+def pyqt_versions() -> List[str]:
"""Get a list of all available PyQt versions.
The list is based on the filenames of misc/requirements/ files.
@@ -93,43 +115,43 @@ def pyqt_versions() -> typing.List[str]:
return versions + ['auto']
-def run_venv(venv_dir: pathlib.Path, executable, *args: str) -> None:
+def run_venv(
+ venv_dir: pathlib.Path,
+ executable,
+ *args: str,
+ capture_output=False,
+ capture_error=False,
+ env=None,
+) -> subprocess.CompletedProcess:
"""Run the given command inside the virtualenv."""
subdir = 'Scripts' if os.name == 'nt' else 'bin'
+ if env is None:
+ proc_env = None
+ else:
+ proc_env = os.environ.copy()
+ proc_env.update(env)
+
try:
- subprocess.run([str(venv_dir / subdir / executable)] +
- [str(arg) for arg in args], check=True)
+ return subprocess.run(
+ [str(venv_dir / subdir / executable)] + [str(arg) for arg in args],
+ check=True,
+ universal_newlines=capture_output or capture_error,
+ stdout=subprocess.PIPE if capture_output else None,
+ stderr=subprocess.PIPE if capture_error else None,
+ env=proc_env,
+ )
except subprocess.CalledProcessError as e:
- utils.print_error("Subprocess failed, exiting")
- sys.exit(e.returncode)
+ raise Error("Subprocess failed, exiting") from e
def pip_install(venv_dir: pathlib.Path, *args: str) -> None:
"""Run a pip install command inside the virtualenv."""
arg_str = ' '.join(str(arg) for arg in args)
- utils.print_col('venv$ pip install {}'.format(arg_str), 'blue')
+ print_command('pip install', arg_str, venv=True)
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_error('tox -e {} is deprecated. '
- 'Please use "python3 scripts/mkvenv.py{}" instead.'
- .format(env, args))
- print()
-
-
def delete_old_venv(venv_dir: pathlib.Path) -> None:
"""Remove an existing virtualenv directory."""
if not venv_dir.exists():
@@ -143,27 +165,25 @@ def delete_old_venv(venv_dir: pathlib.Path) -> None:
]
if not any(m.exists() for m in markers):
- utils.print_error('{} does not look like a virtualenv, '
- 'cowardly refusing to remove it.'.format(venv_dir))
- sys.exit(1)
+ raise Error('{} does not look like a virtualenv, cowardly refusing to '
+ 'remove it.'.format(venv_dir))
- utils.print_col('$ rm -r {}'.format(venv_dir), 'blue')
+ print_command('rm -r', venv_dir, venv=False)
shutil.rmtree(str(venv_dir))
def create_venv(venv_dir: pathlib.Path, use_virtualenv: bool = False) -> None:
"""Create a new virtualenv."""
if use_virtualenv:
- utils.print_col('$ python3 -m virtualenv {}'.format(venv_dir), 'blue')
+ print_command('python3 -m virtualenv', venv_dir, venv=False)
try:
subprocess.run([sys.executable, '-m', 'virtualenv', venv_dir],
check=True)
except subprocess.CalledProcessError as e:
- utils.print_error("virtualenv failed, exiting")
- sys.exit(e.returncode)
+ raise Error("virtualenv failed, exiting", e.returncode)
else:
- utils.print_col('$ python3 -m venv {}'.format(venv_dir), 'blue')
- venv.create(str(venv_dir), with_pip=True)
+ print_command('python3 -m venv', venv_dir, venv=False)
+ pyvenv.create(str(venv_dir), with_pip=True)
def upgrade_seed_pkgs(venv_dir: pathlib.Path) -> None:
@@ -220,6 +240,129 @@ def install_pyqt_wheels(venv_dir: pathlib.Path,
pip_install(venv_dir, *wheels)
+def apply_xcb_util_workaround(
+ venv_dir: pathlib.Path,
+ pyqt_type: str,
+ pyqt_version: str,
+) -> None:
+ """If needed (Debian Stable), symlink libxcb-util.so.0 -> .1.
+
+ WORKAROUND for https://bugreports.qt.io/browse/QTBUG-88688
+ """
+ utils.print_title("Running xcb-util workaround")
+
+ if not sys.platform.startswith('linux'):
+ print("Workaround not needed: Not on Linux.")
+ return
+ if pyqt_type != 'binary':
+ print("Workaround not needed: Not installing from PyQt binaries.")
+ return
+ if pyqt_version not in ['auto', '5.15']:
+ print("Workaround not needed: Not installing Qt 5.15.")
+ return
+
+ libs = _find_libs()
+ abi_type = 'libc6,x86-64' # the only one PyQt wheels are available for
+
+ if ('libxcb-util.so.1', abi_type) in libs:
+ print("Workaround not needed: libxcb-util.so.1 found.")
+ return
+
+ try:
+ libxcb_util_libs = libs['libxcb-util.so.0', abi_type]
+ except KeyError:
+ utils.print_error('Workaround failed: libxcb-util.so.0 not found.')
+ return
+
+ if len(libxcb_util_libs) > 1:
+ utils.print_error(
+ f'Workaround failed: Multiple matching libxcb-util found: '
+ f'{libxcb_util_libs}')
+ return
+
+ libxcb_util_path = pathlib.Path(libxcb_util_libs[0])
+
+ code = [
+ 'from PyQt5.QtCore import QLibraryInfo',
+ 'print(QLibraryInfo.location(QLibraryInfo.LibrariesPath))',
+ ]
+ proc = run_venv(venv_dir, 'python', '-c', '; '.join(code), capture_output=True)
+ venv_lib_path = pathlib.Path(proc.stdout.strip())
+
+ link_path = venv_lib_path / libxcb_util_path.with_suffix('.1').name
+
+ # This gives us a nicer path to print, and also conveniently makes sure we
+ # didn't accidentally end up with a path outside the venv.
+ rel_link_path = venv_dir / link_path.relative_to(venv_dir.resolve())
+ print_command('ln -s', libxcb_util_path, rel_link_path, venv=False)
+
+ link_path.symlink_to(libxcb_util_path)
+
+
+def _find_libs() -> Dict[Tuple[str, str], List[str]]:
+ """Find all system-wide .so libraries."""
+ all_libs: Dict[Tuple[str, str], List[str]] = {}
+
+ if pathlib.Path("/sbin/ldconfig").exists():
+ # /sbin might not be in PATH on e.g. Debian
+ ldconfig_bin = "/sbin/ldconfig"
+ else:
+ ldconfig_bin = "ldconfig"
+ ldconfig_proc = subprocess.run(
+ [ldconfig_bin, '-p'],
+ check=True,
+ stdout=subprocess.PIPE,
+ encoding=sys.getfilesystemencoding(),
+ )
+
+ pattern = re.compile(r'(?P<name>\S+) \((?P<abi_type>[^)]+)\) => (?P<path>.*)')
+ for line in ldconfig_proc.stdout.splitlines():
+ match = pattern.fullmatch(line.strip())
+ if match is None:
+ if 'libs found in cache' not in line:
+ utils.print_col(f'Failed to match ldconfig output: {line}', 'yellow')
+ continue
+
+ key = match.group('name'), match.group('abi_type')
+ path = match.group('path')
+
+ libs = all_libs.setdefault(key, [])
+ libs.append(path)
+
+ return all_libs
+
+
+def run_qt_smoke_test(venv_dir: pathlib.Path) -> None:
+ """Make sure the Qt installation works."""
+ utils.print_title("Running Qt smoke test")
+ code = [
+ 'import sys',
+ 'from PyQt5.QtWidgets import QApplication',
+ 'from PyQt5.QtCore import qVersion, QT_VERSION_STR, PYQT_VERSION_STR',
+ 'print(f"Python: {sys.version}")',
+ 'print(f"qVersion: {qVersion()}")',
+ 'print(f"QT_VERSION_STR: {QT_VERSION_STR}")',
+ 'print(f"PYQT_VERSION_STR: {PYQT_VERSION_STR}")',
+ 'QApplication([])',
+ 'print("Qt seems to work properly!")',
+ 'print()',
+ ]
+ try:
+ run_venv(
+ venv_dir,
+ 'python', '-c', '; '.join(code),
+ env={'QT_DEBUG_PLUGINS': '1'},
+ capture_error=True
+ )
+ except Error as e:
+ proc_e = e.__cause__
+ assert isinstance(proc_e, subprocess.CalledProcessError), proc_e
+ print(proc_e.stderr)
+ raise Error(
+ f"Smoke test failed with status {proc_e.returncode}. "
+ "You might find additional information in the debug output above.")
+
+
def install_requirements(venv_dir: pathlib.Path) -> None:
"""Install qutebrowser's requirement.txt."""
utils.print_title("Installing other qutebrowser dependencies")
@@ -242,7 +385,7 @@ def install_qutebrowser(venv_dir: pathlib.Path) -> None:
def regenerate_docs(venv_dir: pathlib.Path,
- asciidoc: typing.Optional[typing.Tuple[str, str]]):
+ asciidoc: Optional[Tuple[str, str]]):
"""Regenerate docs using asciidoc."""
utils.print_title("Generating documentation")
if asciidoc is not None:
@@ -251,38 +394,21 @@ def regenerate_docs(venv_dir: pathlib.Path,
a2h_args = []
script_path = pathlib.Path(__file__).parent / 'asciidoc2html.py'
- utils.print_col('venv$ python3 scripts/asciidoc2html.py {}'
- .format(' '.join(a2h_args)), 'blue')
+ print_command('python3 scripts/asciidoc2html.py', *a2h_args, venv=True)
run_venv(venv_dir, 'python', str(script_path), *a2h_args)
-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)
- sys.exit(1)
- elif (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_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:
- utils.print_title("Creating virtual environment")
- delete_old_venv(venv_dir)
- create_venv(venv_dir, use_virtualenv=args.virtualenv)
+def update_repo():
+ """Update the git repository via git pull."""
+ print_command('git pull', venv=False)
+ try:
+ subprocess.run(['git', 'pull'], check=True)
+ except subprocess.CalledProcessError as e:
+ raise Error("git pull failed, exiting") from e
- upgrade_seed_pkgs(venv_dir)
+def install_pyqt(venv_dir, args):
+ """Install PyQt in the virtualenv."""
if args.pyqt_type == 'binary':
install_pyqt_binary(venv_dir, args.pyqt_version)
elif args.pyqt_type == 'source':
@@ -290,12 +416,44 @@ def main() -> None:
elif args.pyqt_type == 'link':
install_pyqt_link(venv_dir)
elif args.pyqt_type == 'wheels':
+ wheels_dir = pathlib.Path(args.pyqt_wheels_dir)
install_pyqt_wheels(venv_dir, wheels_dir)
elif args.pyqt_type == 'skip':
pass
else:
raise AssertionError
+
+def run(args) -> None:
+ """Install qutebrowser in a virtualenv.."""
+ venv_dir = pathlib.Path(args.venv_dir)
+ utils.change_cwd()
+
+ if (args.pyqt_version != 'auto' and
+ args.pyqt_type not in ['binary', 'source']):
+ raise Error('The --pyqt-version option is only available when installing PyQt '
+ 'from binary or source')
+
+ if args.pyqt_wheels_dir != 'wheels' and args.pyqt_type != 'wheels':
+ raise Error('The --pyqt-wheels-dir option is only available when installing '
+ 'PyQt from wheels')
+
+ if args.update:
+ utils.print_title("Updating repository")
+ update_repo()
+
+ if not args.keep:
+ utils.print_title("Creating virtual environment")
+ delete_old_venv(venv_dir)
+ create_venv(venv_dir, use_virtualenv=args.virtualenv)
+
+ upgrade_seed_pkgs(venv_dir)
+ install_pyqt(venv_dir, args)
+
+ apply_xcb_util_workaround(venv_dir, args.pyqt_type, args.pyqt_version)
+ if args.pyqt_type != 'skip' and not args.skip_smoke_test:
+ run_qt_smoke_test(venv_dir)
+
install_requirements(venv_dir)
install_qutebrowser(venv_dir)
if args.dev:
@@ -305,5 +463,14 @@ def main() -> None:
regenerate_docs(venv_dir, args.asciidoc)
+def main():
+ args = parse_args()
+ try:
+ run(args)
+ except Error as e:
+ utils.print_error(str(e))
+ sys.exit(e.code)
+
+
if __name__ == '__main__':
main()
diff --git a/scripts/open_url_in_instance.sh b/scripts/open_url_in_instance.sh
index ec2a5a26d..0d6edef51 100755
--- a/scripts/open_url_in_instance.sh
+++ b/scripts/open_url_in_instance.sh
@@ -12,4 +12,4 @@ printf '{"args": ["%s"], "target_arg": null, "version": "%s", "protocol_version"
"${_url}" \
"${_qb_version}" \
"${_proto_version}" \
- "${PWD}" | socat - UNIX-CONNECT:"${_ipc_socket}" 2>/dev/null || "$_qute_bin" "$@" &
+ "${PWD}" | socat -lf /dev/null - UNIX-CONNECT:"${_ipc_socket}" || "$_qute_bin" "$@" &
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 bdf3f96fc..f46e6a4de 100644
--- a/scripts/utils.py
+++ b/scripts/utils.py
@@ -22,6 +22,7 @@
import os
import os.path
import sys
+import contextlib
# Import side-effects are an evil thing, but here it's okay so scripts using
@@ -54,6 +55,9 @@ fg_colors = {
bg_colors = {name: col + 10 for name, col in fg_colors.items()}
+ON_CI = 'CI' in os.environ
+
+
def _esc(code):
"""Get an ANSI color code based on a color number."""
return '\033[{}m'.format(code)
@@ -64,9 +68,9 @@ def print_col(text, color, file=sys.stdout):
if use_color:
fg = _esc(fg_colors[color.lower()])
reset = _esc(fg_colors['reset'])
- print(''.join([fg, text, reset]), file=file)
+ print(''.join([fg, text, reset]), file=file, flush=True)
else:
- print(text, file=file)
+ print(text, file=file, flush=True)
def print_error(text):
@@ -90,3 +94,26 @@ def change_cwd():
cwd = os.getcwd()
if os.path.split(cwd)[1] == 'scripts':
os.chdir(os.path.join(cwd, os.pardir))
+
+
+@contextlib.contextmanager
+def gha_group(name):
+ """Print a GitHub Actions group.
+
+ Gets ignored if not on CI.
+ """
+ if ON_CI:
+ print('::group::' + name)
+ yield
+ print('::endgroup::')
+ else:
+ yield
+
+
+def gha_error(message):
+ """Print a GitHub Actions error.
+
+ Should only be called on CI.
+ """
+ assert ON_CI
+ print('::error::' + message)
diff --git a/setup.py b/setup.py
index 6b2ea0acd..137b514ca 100755
--- a/setup.py
+++ b/setup.py
@@ -71,8 +71,9 @@ try:
entry_points={'gui_scripts':
['qutebrowser = qutebrowser.qutebrowser:main']},
zip_safe=True,
- install_requires=['pypeg2', 'jinja2', 'pygments', 'PyYAML', 'attrs'],
- python_requires='>=3.5',
+ install_requires=['pypeg2', 'jinja2', 'pygments', 'PyYAML', 'attrs',
+ 'importlib_resources>=1.1.0; python_version < "3.9"'],
+ python_requires='>=3.6',
name='qutebrowser',
version=_get_constant('version'),
description=_get_constant('description'),
@@ -83,7 +84,7 @@ try:
author_email=_get_constant('email'),
license=_get_constant('license'),
classifiers=[
- 'Development Status :: 4 - Beta',
+ 'Development Status :: 5 - Production/Stable',
'Environment :: X11 Applications :: Qt',
'Intended Audience :: End Users/Desktop',
'License :: OSI Approved :: GNU General Public License v3 or later '
@@ -94,9 +95,10 @@ try:
'Operating System :: MacOS',
'Operating System :: POSIX :: BSD',
'Programming Language :: Python :: 3',
- 'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
+ 'Programming Language :: Python :: 3.8',
+ 'Programming Language :: Python :: 3.9',
'Topic :: Internet',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Internet :: WWW/HTTP :: Browsers',
diff --git a/tests/conftest.py b/tests/conftest.py
index 0990e2702..fd317d6c4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -25,18 +25,16 @@ import os
import sys
import warnings
import pathlib
-import ctypes
-import ctypes.util
import pytest
import hypothesis
-from PyQt5.QtCore import qVersion, PYQT_VERSION
+from PyQt5.QtCore import PYQT_VERSION
pytest.register_assert_rewrite('helpers')
from helpers import logfail
from helpers.logfail import fail_on_logging
-from helpers.messagemock import message_mock, message_bridge
+from helpers.messagemock import message_mock
from helpers.fixtures import * # noqa: F403
from helpers import utils as testutils
from qutebrowser.utils import qtutils, standarddir, usertypes, utils, version
@@ -50,10 +48,12 @@ _qute_scheme_handler = None
# Set hypothesis settings
-hypothesis.settings.register_profile('default',
- hypothesis.settings(deadline=600))
-hypothesis.settings.register_profile('ci',
- hypothesis.settings(deadline=None))
+hypothesis.settings.register_profile(
+ 'default', hypothesis.settings(deadline=600))
+hypothesis.settings.register_profile(
+ 'ci', hypothesis.settings(
+ deadline=None,
+ suppress_health_check=[hypothesis.HealthCheck.too_slow]))
hypothesis.settings.load_profile('ci' if testutils.ON_CI else 'default')
@@ -101,16 +101,9 @@ def _apply_platform_markers(config, item):
sys.getfilesystemencoding() == 'ascii',
"Skipped because of ASCII locale"),
- ('qtbug60673',
- pytest.mark.xfail,
- qtutils.version_check('5.8') and
- not qtutils.version_check('5.10') and
- config.webengine,
- "Broken on webengine due to "
- "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")
]
@@ -175,11 +168,6 @@ def pytest_collection_modifyitems(config, items):
_apply_platform_markers(config, item)
if list(item.iter_markers('xfail_norun')):
item.add_marker(pytest.mark.xfail(run=False))
- if list(item.iter_markers('js_prompt')):
- if config.webengine:
- item.add_marker(pytest.mark.skipif(
- PYQT_VERSION <= 0x050700,
- reason='JS prompts are not supported with PyQt 5.7'))
if deselected:
deselected_items.append(item)
@@ -199,12 +187,10 @@ def pytest_ignore_collect(path):
@pytest.fixture(scope='session')
def qapp_args():
- """Make QtWebEngine unit tests run on Qt 5.7.1.
-
- See https://github.com/qutebrowser/qutebrowser/issues/3163
- """
- if qVersion() == '5.7.1':
- return [sys.argv[0], '--disable-seccomp-filter-sandbox']
+ """Make QtWebEngine unit tests run on older Qt versions + newer kernels."""
+ seccomp_args = testutils.seccomp_args(qt_flag=False)
+ if seccomp_args:
+ return [sys.argv[0]] + seccomp_args
return []
@@ -226,8 +212,8 @@ def pytest_addoption(parser):
def pytest_configure(config):
webengine_arg = config.getoption('--qute-bdd-webengine')
- webengine_env = os.environ.get('QUTE_BDD_WEBENGINE', '')
- config.webengine = bool(webengine_arg or webengine_env)
+ webengine_env = os.environ.get('QUTE_BDD_WEBENGINE', 'false')
+ config.webengine = webengine_arg or webengine_env == 'true'
# Fail early if QtWebEngine is not available
if config.webengine:
import PyQt5.QtWebEngineWidgets
@@ -236,11 +222,6 @@ def pytest_configure(config):
@pytest.fixture(scope='session', autouse=True)
def check_display(request):
- if (not request.config.getoption('--no-xvfb') and
- 'QUTE_BUILDBOT' in os.environ and
- request.config.xvfb is not None):
- raise Exception("Xvfb is running on buildbot!")
-
if utils.is_linux and not os.environ.get('DISPLAY', ''):
raise Exception("No display and no Xvfb available!")
@@ -255,12 +236,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)
@@ -290,16 +269,20 @@ def apply_fake_os(monkeypatch, request):
else:
raise ValueError("Invalid fake_os {}".format(name))
- monkeypatch.setattr('qutebrowser.utils.utils.is_mac', mac)
- monkeypatch.setattr('qutebrowser.utils.utils.is_linux', linux)
- monkeypatch.setattr('qutebrowser.utils.utils.is_windows', windows)
- monkeypatch.setattr('qutebrowser.utils.utils.is_posix', posix)
+ monkeypatch.setattr(utils, 'is_mac', mac)
+ monkeypatch.setattr(utils, 'is_linux', linux)
+ monkeypatch.setattr(utils, 'is_windows', windows)
+ monkeypatch.setattr(utils, 'is_posix', posix)
@pytest.fixture(scope='session', autouse=True)
def check_yaml_c_exts():
- """Make sure PyYAML C extensions are available on Travis."""
- if 'TRAVIS' in os.environ:
+ """Make sure PyYAML C extensions are available on CI.
+
+ Not available yet with a nightly Python, see:
+ https://github.com/yaml/pyyaml/issues/416
+ """
+ if testutils.ON_CI and sys.version_info[:2] != (3, 10):
from yaml import CLoader
@@ -312,3 +295,15 @@ def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, "rep_" + rep.when, rep)
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_terminal_summary(terminalreporter):
+ """Group benchmark results on CI."""
+ if testutils.ON_CI:
+ terminalreporter.write_line(
+ testutils.gha_group_begin('Benchmark results'))
+ yield
+ terminalreporter.write_line(testutils.gha_group_end())
+ else:
+ yield
diff --git a/tests/end2end/conftest.py b/tests/end2end/conftest.py
index 273d8170c..17b457521 100644
--- a/tests/end2end/conftest.py
+++ b/tests/end2end/conftest.py
@@ -28,13 +28,14 @@ import sys
import shutil
import pstats
import operator
+import pathlib
import pytest
-from PyQt5.QtCore import PYQT_VERSION
+from PyQt5.QtCore import PYQT_VERSION, QCoreApplication
pytest.register_assert_rewrite('end2end.fixtures')
-from end2end.fixtures.webserver import server, server_per_test, ssl_server
+from end2end.fixtures.webserver import server, server_per_test, server2, ssl_server
from end2end.fixtures.quteprocess import (quteproc_process, quteproc,
quteproc_new)
from end2end.fixtures.testprocess import pytest_runtest_makereport
@@ -137,6 +138,13 @@ 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 = qtutils.version_check('5.15', compiled=False)
+
+ lib_path = pathlib.Path(QCoreApplication.libraryPaths()[0])
+ qpdf_image_plugin = lib_path / 'imageformats' / 'libqpdf.so'
+
markers = [
('qtwebengine_todo', 'QtWebEngine TODO', pytest.mark.xfail,
config.webengine),
@@ -152,6 +160,13 @@ 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),
+ ('qtwebkit_pdf_imageformat_skip',
+ 'Skipped with QtWebKit if PDF image plugin is available',
+ pytest.mark.skipif,
+ not config.webengine and qpdf_image_plugin.exists()),
]
for item in items:
diff --git a/tests/end2end/data/adblock/simple b/tests/end2end/data/adblock/simple
deleted file mode 100644
index 5778335db..000000000
--- a/tests/end2end/data/adblock/simple
+++ /dev/null
@@ -1 +0,0 @@
-example.org
diff --git a/tests/end2end/data/blocking/external_logo.html b/tests/end2end/data/blocking/external_logo.html
new file mode 100644
index 000000000..7fa7e9ebb
--- /dev/null
+++ b/tests/end2end/data/blocking/external_logo.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>External logo</title>
+ </head>
+ <body>
+ <p>
+ <b>NOTE:</> This should never be used in a test where
+ qutebrowser.org isn't blocked, as no network requests should be
+ made while running the testsuite.
+ </p>
+ <img src="https://qutebrowser.org/icons/qutebrowser.svg">
+ </body>
+</html>
diff --git a/tests/end2end/data/blocking/qutebrowser-adblock b/tests/end2end/data/blocking/qutebrowser-adblock
new file mode 100644
index 000000000..4b279b32c
--- /dev/null
+++ b/tests/end2end/data/blocking/qutebrowser-adblock
@@ -0,0 +1 @@
+||qutebrowser.org^
diff --git a/tests/end2end/data/blocking/qutebrowser-hosts b/tests/end2end/data/blocking/qutebrowser-hosts
new file mode 100644
index 000000000..d104c0104
--- /dev/null
+++ b/tests/end2end/data/blocking/qutebrowser-hosts
@@ -0,0 +1 @@
+qutebrowser.org
diff --git a/tests/end2end/data/brave-adblock/LICENSE b/tests/end2end/data/brave-adblock/LICENSE
new file mode 100644
index 000000000..6f03f190d
--- /dev/null
+++ b/tests/end2end/data/brave-adblock/LICENSE
@@ -0,0 +1,318 @@
+Mozilla Public License, version 2.0
+
+Copyright (c) 2019, Andrius Aucinas
+
+1. Definitions
+
+ 1.1. “Contributor” means each individual or legal entity that
+ creates, contributes to the creation of, or owns Covered Software.
+
+ 1.2. “Contributor Version” means the combination of the
+ Contributions of others (if any) used by a Contributor and that
+ particular Contributor’s Contribution.
+
+ 1.3. “Contribution” means Covered Software of a particular
+ Contributor.
+
+ 1.4. “Covered Software” means Source Code Form to which the initial
+ Contributor has attached the notice in Exhibit A, the Executable
+ Form of such Source Code Form, and Modifications of such Source Code
+ Form, in each case including portions thereof.
+
+ 1.5. “Incompatible With Secondary Licenses” means
+
+ that the initial Contributor has attached the notice described in
+ Exhibit B to the Covered Software; or
+
+ that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the terms
+ of a Secondary License.
+
+ 1.6. “Executable Form” means any form of the work other than Source
+ Code Form.
+
+ 1.7. “Larger Work” means a work that combines Covered Software with
+ other material, in a separate file or files, that is not Covered
+ Software.
+
+ 1.8. “License” means this document.
+
+ 1.9. “Licensable” means having the right to grant, to the maximum
+ extent possible, whether at the time of the initial grant or
+ subsequently, any and all of the rights conveyed by this License.
+
+ 1.10. “Modifications” means any of the following:
+
+ any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered Software;
+ or
+
+ any new file in Source Code Form that contains any Covered Software.
+
+ 1.11. “Patent Claims” of a Contributor means any patent claim(s),
+ including without limitation, method, process, and apparatus claims,
+ in any patent Licensable by such Contributor that would be
+ infringed, but for the grant of the License, by the making, using,
+ selling, offering for sale, having made, import, or transfer of
+ either its Contributions or its Contributor Version.
+
+ 1.12. “Secondary License” means either the GNU General Public
+ License, Version 2.0, the GNU Lesser General Public License, Version
+ 2.1, the GNU Affero General Public License, Version 3.0, or any
+ later versions of those licenses.
+
+ 1.13. “Source Code Form” means the form of the work preferred for
+ making modifications.
+
+ 1.14. “You” (or “Your”) means an individual or a legal entity
+ exercising rights under this License. For legal entities, “You”
+ includes any entity that controls, is controlled by, or is under
+ common control with You. For purposes of this definition, “control”
+ means (a) the power, direct or indirect, to cause the direction or
+ management of such entity, whether by contract or otherwise, or (b)
+ ownership of more than fifty percent (50%) of the outstanding shares
+ or beneficial ownership of such entity.
+
+
+2. License Grants and Conditions
+
+ 2.1. Grants Each Contributor hereby grants You a world-wide,
+ royalty-free, non-exclusive license:
+
+ under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+ under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+ 2.2. Effective Date The licenses granted in Section 2.1 with respect
+ to any Contribution become effective for each Contribution on the
+ date the Contributor first distributes such Contribution.
+
+ 2.3. Limitations on Grant Scope The licenses granted in this Section
+ 2 are the only rights granted under this License. No additional
+ rights or licenses will be implied from the distribution or
+ licensing of Covered Software under this License. Notwithstanding
+ Section 2.1(b) above, no patent license is granted by a Contributor:
+
+ for any code that a Contributor has removed from Covered Software;
+ or
+
+ for infringements caused by: (i) Your and any other third party’s
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+ under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+ This License does not grant any rights in the trademarks, service
+ marks, or logos of any Contributor (except as may be necessary to
+ comply with the notice requirements in Section 3.4).
+
+ 2.4. Subsequent Licenses No Contributor makes additional grants as a
+ result of Your choice to distribute the Covered Software under a
+ subsequent version of this License (see Section 10.2) or under the
+ terms of a Secondary License (if permitted under the terms of
+ Section 3.3).
+
+ 2.5. Representation Each Contributor represents that the Contributor
+ believes its Contributions are its original creation(s) or it has
+ sufficient rights to grant the rights to its Contributions conveyed
+ by this License.
+
+ 2.6. Fair Use This License is not intended to limit any rights You
+ have under applicable copyright doctrines of fair use, fair dealing,
+ or other equivalents.
+
+ 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of
+ the licenses granted in Section 2.1.
+
+
+3. Responsibilities
+
+ 3.1. Distribution of Source Form All distribution of Covered
+ Software in Source Code Form, including any Modifications that You
+ create or to which You contribute, must be under the terms of this
+ License. You must inform recipients that the Source Code Form of the
+ Covered Software is governed by the terms of this License, and how
+ they can obtain a copy of this License. You may not attempt to alter
+ or restrict the recipients’ rights in the Source Code Form.
+
+ 3.2. Distribution of Executable Form If You distribute Covered
+ Software in Executable Form then:
+
+ such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+ You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients’ rights in the Source Code Form under this License.
+
+ 3.3. Distribution of a Larger Work You may create and distribute a
+ Larger Work under terms of Your choice, provided that You also
+ comply with the requirements of this License for the Covered
+ Software. If the Larger Work is a combination of Covered Software
+ with a work governed by one or more Secondary Licenses, and the
+ Covered Software is not Incompatible With Secondary Licenses, this
+ License permits You to additionally distribute such Covered Software
+ under the terms of such Secondary License(s), so that the recipient
+ of the Larger Work may, at their option, further distribute the
+ Covered Software under the terms of either this License or such
+ Secondary License(s).
+
+ 3.4. Notices You may not remove or alter the substance of any
+ license notices (including copyright notices, patent notices,
+ disclaimers of warranty, or limitations of liability) contained
+ within the Source Code Form of the Covered Software, except that You
+ may alter any license notices to the extent required to remedy known
+ factual inaccuracies.
+
+ 3.5. Application of Additional Terms You may choose to offer, and to
+ charge a fee for, warranty, support, indemnity or liability
+ obligations to one or more recipients of Covered Software. However,
+ You may do so only on Your own behalf, and not on behalf of any
+ Contributor. You must make it absolutely clear that any such
+ warranty, support, indemnity, or liability obligation is offered by
+ You alone, and You hereby agree to indemnify every Contributor for
+ any liability incurred by such Contributor as a result of warranty,
+ support, indemnity or liability terms You offer. You may include
+ additional disclaimers of warranty and limitations of liability
+ specific to any jurisdiction.
+
+
+4. Inability to Comply Due to Statute or Regulation
+
+ If it is impossible for You to comply with any of the terms of this
+ License with respect to some or all of the Covered Software due to
+ statute, judicial order, or regulation then You must: (a) comply
+ with the terms of this License to the maximum extent possible; and
+ (b) describe the limitations and the code they affect. Such
+ description must be placed in a text file included with all
+ distributions of the Covered Software under this License. Except to
+ the extent prohibited by statute or regulation, such description
+ must be sufficiently detailed for a recipient of ordinary skill to
+ be able to understand it.
+
+
+5. Termination
+
+ 5.1. The rights granted under this License will terminate
+ automatically if You fail to comply with any of its terms. However,
+ if You become compliant, then the rights granted under this License
+ from a particular Contributor are reinstated (a) provisionally,
+ unless and until such Contributor explicitly and finally terminates
+ Your grants, and (b) on an ongoing basis, if such Contributor fails
+ to notify You of the non-compliance by some reasonable means prior
+ to 60 days after You have come back into compliance. Moreover, Your
+ grants from a particular Contributor are reinstated on an ongoing
+ basis if such Contributor notifies You of the non-compliance by some
+ reasonable means, this is the first time You have received notice of
+ non-compliance with this License from such Contributor, and You
+ become compliant prior to 30 days after Your receipt of the notice.
+
+ 5.2. If You initiate litigation against any entity by asserting a
+ patent infringement claim (excluding declaratory judgment actions,
+ counter-claims, and cross-claims) alleging that a Contributor
+ Version directly or indirectly infringes any patent, then the rights
+ granted to You by any and all Contributors for the Covered Software
+ under Section 2.1 of this License shall terminate.
+
+ 5.3. In the event of termination under Sections 5.1 or 5.2 above,
+ all end user license agreements (excluding distributors and
+ resellers) which have been validly granted by You or Your
+ distributors under this License prior to termination shall survive
+ termination.
+
+6. Disclaimer of Warranty Covered Software is provided under this
+License on an “as is” basis, without warranty of any kind, either
+expressed, implied, or statutory, including, without limitation,
+warranties that the Covered Software is free of defects, merchantable,
+fit for a particular purpose or non-infringing. The entire risk as to
+the quality and performance of the Covered Software is with You.
+Should any Covered Software prove defective in any respect, You (not
+any Contributor) assume the cost of any necessary servicing, repair,
+or correction. This disclaimer of warranty constitutes an essential
+part of this License. No use of any Covered Software is authorized
+under this License except under this disclaimer.
+
+7. Limitation of Liability Under no circumstances and under no legal
+theory, whether tort (including negligence), contract, or otherwise,
+shall any Contributor, or anyone who distributes Covered Software as
+permitted above, be liable to You for any direct, indirect, special,
+incidental, or consequential damages of any character including,
+without limitation, damages for lost profits, loss of goodwill, work
+stoppage, computer failure or malfunction, or any and all other
+commercial damages or losses, even if such party shall have been
+informed of the possibility of such damages. This limitation of
+liability shall not apply to liability for death or personal injury
+resulting from such party’s negligence to the extent applicable law
+prohibits such limitation. Some jurisdictions do not allow the
+exclusion or limitation of incidental or consequential damages, so
+this exclusion and limitation may not apply to You.
+
+8. Litigation Any litigation relating to this License may be brought
+only in the courts of a jurisdiction where the defendant maintains its
+principal place of business and such litigation shall be governed by
+laws of that jurisdiction, without reference to its conflict-of-law
+provisions. Nothing in this Section shall prevent a party’s ability to
+bring cross-claims or counter-claims.
+
+9. Miscellaneous This License represents the complete agreement
+concerning the subject matter hereof. If any provision of this License
+is held to be unenforceable, such provision shall be reformed only to
+the extent necessary to make it enforceable. Any law or regulation
+which provides that the language of a contract shall be construed
+against the drafter shall not be used to construe this License against
+a Contributor.
+
+10. Versions of the License
+
+ 10.1. New Versions Mozilla Foundation is the license steward. Except
+ as provided in Section 10.3, no one other than the license steward
+ has the right to modify or publish new versions of this License.
+ Each version will be given a distinguishing version number.
+
+ 10.2. Effect of New Versions You may distribute the Covered Software
+ under the terms of the version of the License under which You
+ originally received the Covered Software, or under the terms of any
+ subsequent version published by the license steward.
+
+ 10.3. Modified Versions If you create software not governed by this
+ License, and you want to create a new license for such software, you
+ may create and use a modified version of this License if you rename
+ the license and remove any references to the name of the license
+ steward (except to note that such modified license differs from this
+ License).
+
+ 10.4. Distributing Source Code Form that is Incompatible With
+ Secondary Licenses If You choose to distribute Source Code Form that
+ is Incompatible With Secondary Licenses under the terms of this
+ version of the License, the notice described in Exhibit B of this
+ License must be attached.
+
+Exhibit A - Source Code Form License Notice
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ If it is not possible or desirable to put the notice in a particular
+ file, then You may include the notice in a location (such as a
+ LICENSE file in a relevant directory) where a recipient would be
+ likely to look for such a notice.
+
+ You may add additional accurate notices of copyright ownership.
+
+Exhibit B - “Incompatible With Secondary Licenses” Notice
+
+ This Source Code Form is “Incompatible With Secondary Licenses”, as
+ defined by the Mozilla Public License, v. 2.0.
diff --git a/tests/end2end/data/brave-adblock/README.md b/tests/end2end/data/brave-adblock/README.md
new file mode 100644
index 000000000..0550e91a4
--- /dev/null
+++ b/tests/end2end/data/brave-adblock/README.md
@@ -0,0 +1,12 @@
+The `ublock-matches.tsv` file is [downloaded from][1] `adblock-rust`'s Github and preprocessed and compressed using `generate.py` to produce
+`ublock-matches.tsv.gz`.
+
+## License
+
+The aforementioned file was released under terms of the Mozilla Public
+License, version 2.0 (MPLv2) by Andrius Aucinas. A copy of the license may be
+found in the [`LICENSE`][2] file of this directory, or on [Mozilla's website][3].
+
+[1]: https://github.com/brave/adblock-rust/blob/master/data/ublock-matches.tsv
+[2]: LICENSE
+[3]: https://www.mozilla.org/en-US/MPL/2.0/
diff --git a/tests/end2end/data/brave-adblock/generate.py b/tests/end2end/data/brave-adblock/generate.py
new file mode 100644
index 000000000..ae47c586b
--- /dev/null
+++ b/tests/end2end/data/brave-adblock/generate.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2020 Árni Dagur <arni@dagur.eu>
+#
+# 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 io
+import gzip
+import csv
+import pathlib
+import itertools
+import urllib.request
+import tempfile
+from typing import Optional
+
+URL = "https://raw.githubusercontent.com/brave/adblock-rust/master/data/ublock-matches.tsv"
+CACHE_PATH = pathlib.Path(tempfile.gettempdir(), "ublock-matches-cache.tsv")
+ROWS_TO_USE = 30_000
+
+
+def type_rename(type_str: str) -> Optional[str]:
+ """Use the same resource type names as QtWebEngine."""
+ if type_str == "other":
+ return "unknown"
+ if type_str == "xmlhttprequest":
+ return "xhr"
+ if type_str == "font":
+ return "font_resource"
+ if type_str in ["image", "stylesheet", "media", "script", "sub_frame"]:
+ return type_str
+ return None
+
+
+def main():
+ # Download file or use cached version
+ if CACHE_PATH.is_file():
+ print(f"Using cached file {CACHE_PATH}")
+ data = io.StringIO(CACHE_PATH.read_text(encoding="utf-8"))
+ else:
+ request = urllib.request.Request(URL)
+ print(f"Downloading {URL} ...")
+ response = urllib.request.urlopen(request)
+ assert response.status == 200
+ data_str = response.read().decode("utf-8")
+ print(f"Saving to cache file {CACHE_PATH} ...")
+ CACHE_PATH.write_text(data_str, encoding="utf-8")
+ data = io.StringIO(data_str)
+
+ # We only want the first three columns and the first ROWS_TO_USE rows
+ print("Reading rows into memory...")
+ reader = csv.DictReader(data, delimiter="\t")
+ rows = list(itertools.islice(reader, ROWS_TO_USE))
+
+ print("Writing filtered file to memory...")
+ uncompressed_f = io.StringIO()
+ writer = csv.DictWriter(
+ uncompressed_f, ["url", "source_url", "type"], delimiter="\t"
+ )
+ writer.writeheader()
+ for row in rows:
+ type_renamed = type_rename(row["type"])
+ if type_renamed is None:
+ # Ignore request types we don't recognize
+ continue
+ writer.writerow(
+ {
+ "url": row["url"],
+ "source_url": row["sourceUrl"],
+ "type": type_renamed,
+ }
+ )
+ uncompressed_f.seek(0)
+
+ print("Compressing filtered file and saving to disk...")
+ # Compress the data before storing on the filesystem
+ with gzip.open("ublock-matches.tsv.gz", "wb", compresslevel=9) as gzip_f:
+ gzip_f.write(uncompressed_f.read().encode("utf-8"))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/end2end/data/brave-adblock/ublock-matches.tsv.gz b/tests/end2end/data/brave-adblock/ublock-matches.tsv.gz
new file mode 100644
index 000000000..bced0da75
--- /dev/null
+++ b/tests/end2end/data/brave-adblock/ublock-matches.tsv.gz
Binary files differ
diff --git a/tests/end2end/data/data_link.html b/tests/end2end/data/data_link.html
index 227a9f2f9..29ecadb68 100644
--- a/tests/end2end/data/data_link.html
+++ b/tests/end2end/data/data_link.html
@@ -5,6 +5,7 @@
<title>data: link</title>
</head>
<body>
- <a href="data:;base64,cXV0ZWJyb3dzZXI=" id="link">download</a>
+ <a href="data:;base64,cXV0ZWJyb3dzZXI=" id="link">plaintext</a>
+ <a href="data:application/pdf;base64,cXV0ZWJyb3dzZXI=" id="pdf">PDF download</a>
</body>
</html>
diff --git a/tests/end2end/data/easylist.txt.gz b/tests/end2end/data/easylist.txt.gz
new file mode 100644
index 000000000..b854af6f5
--- /dev/null
+++ b/tests/end2end/data/easylist.txt.gz
Binary files differ
diff --git a/tests/end2end/data/easyprivacy.txt.gz b/tests/end2end/data/easyprivacy.txt.gz
new file mode 100644
index 000000000..6ee5e2319
--- /dev/null
+++ b/tests/end2end/data/easyprivacy.txt.gz
Binary files differ
diff --git a/tests/end2end/data/editor.html b/tests/end2end/data/editor.html
index 9f5f9c067..eda6d51f0 100644
--- a/tests/end2end/data/editor.html
+++ b/tests/end2end/data/editor.html
@@ -11,7 +11,6 @@
</script>
</head>
<body>
- <textarea id="qute-textarea"></textarea>
- <input type="button" id="qute-button" onclick="log_text()" value="Log text">
+ <textarea id="qute-textarea" oninput="log_text()"></textarea>
</body>
</html>
diff --git a/tests/end2end/data/hints/bootstrap/bootstrap.css b/tests/end2end/data/hints/bootstrap/bootstrap.css
new file mode 100644
index 000000000..e461d3fb9
--- /dev/null
+++ b/tests/end2end/data/hints/bootstrap/bootstrap.css
@@ -0,0 +1,10278 @@
+/*!
+ * Bootstrap v4.5.0 (https://getbootstrap.com/)
+ * Copyright 2011-2020 The Bootstrap Authors
+ * Copyright 2011-2020 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ */
+:root {
+ --blue: #007bff;
+ --indigo: #6610f2;
+ --purple: #6f42c1;
+ --pink: #e83e8c;
+ --red: #dc3545;
+ --orange: #fd7e14;
+ --yellow: #ffc107;
+ --green: #28a745;
+ --teal: #20c997;
+ --cyan: #17a2b8;
+ --white: #fff;
+ --gray: #6c757d;
+ --gray-dark: #343a40;
+ --primary: #007bff;
+ --secondary: #6c757d;
+ --success: #28a745;
+ --info: #17a2b8;
+ --warning: #ffc107;
+ --danger: #dc3545;
+ --light: #f8f9fa;
+ --dark: #343a40;
+ --breakpoint-xs: 0;
+ --breakpoint-sm: 576px;
+ --breakpoint-md: 768px;
+ --breakpoint-lg: 992px;
+ --breakpoint-xl: 1200px;
+ --font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ --font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ font-family: sans-serif;
+ line-height: 1.15;
+ -webkit-text-size-adjust: 100%;
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+}
+
+article, aside, figcaption, figure, footer, header, hgroup, main, nav, section {
+ display: block;
+}
+
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-size: 1rem;
+ font-weight: 400;
+ line-height: 1.5;
+ color: #212529;
+ text-align: left;
+ background-color: #fff;
+}
+
+[tabindex="-1"]:focus:not(:focus-visible) {
+ outline: 0 !important;
+}
+
+hr {
+ box-sizing: content-box;
+ height: 0;
+ overflow: visible;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+}
+
+p {
+ margin-top: 0;
+ margin-bottom: 1rem;
+}
+
+abbr[title],
+abbr[data-original-title] {
+ text-decoration: underline;
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+ cursor: help;
+ border-bottom: 0;
+ -webkit-text-decoration-skip-ink: none;
+ text-decoration-skip-ink: none;
+}
+
+address {
+ margin-bottom: 1rem;
+ font-style: normal;
+ line-height: inherit;
+}
+
+ol,
+ul,
+dl {
+ margin-top: 0;
+ margin-bottom: 1rem;
+}
+
+ol ol,
+ul ul,
+ol ul,
+ul ol {
+ margin-bottom: 0;
+}
+
+dt {
+ font-weight: 700;
+}
+
+dd {
+ margin-bottom: .5rem;
+ margin-left: 0;
+}
+
+blockquote {
+ margin: 0 0 1rem;
+}
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+small {
+ font-size: 80%;
+}
+
+sub,
+sup {
+ position: relative;
+ font-size: 75%;
+ line-height: 0;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -.25em;
+}
+
+sup {
+ top: -.5em;
+}
+
+a {
+ color: #007bff;
+ text-decoration: none;
+ background-color: transparent;
+}
+
+a:hover {
+ color: #0056b3;
+ text-decoration: underline;
+}
+
+a:not([href]) {
+ color: inherit;
+ text-decoration: none;
+}
+
+a:not([href]):hover {
+ color: inherit;
+ text-decoration: none;
+}
+
+pre,
+code,
+kbd,
+samp {
+ font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ font-size: 1em;
+}
+
+pre {
+ margin-top: 0;
+ margin-bottom: 1rem;
+ overflow: auto;
+ -ms-overflow-style: scrollbar;
+}
+
+figure {
+ margin: 0 0 1rem;
+}
+
+img {
+ vertical-align: middle;
+ border-style: none;
+}
+
+svg {
+ overflow: hidden;
+ vertical-align: middle;
+}
+
+table {
+ border-collapse: collapse;
+}
+
+caption {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+ color: #6c757d;
+ text-align: left;
+ caption-side: bottom;
+}
+
+th {
+ text-align: inherit;
+}
+
+label {
+ display: inline-block;
+ margin-bottom: 0.5rem;
+}
+
+button {
+ border-radius: 0;
+}
+
+button:focus {
+ outline: 1px dotted;
+ outline: 5px auto -webkit-focus-ring-color;
+}
+
+input,
+button,
+select,
+optgroup,
+textarea {
+ margin: 0;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+}
+
+button,
+input {
+ overflow: visible;
+}
+
+button,
+select {
+ text-transform: none;
+}
+
+[role="button"] {
+ cursor: pointer;
+}
+
+select {
+ word-wrap: normal;
+}
+
+button,
+[type="button"],
+[type="reset"],
+[type="submit"] {
+ -webkit-appearance: button;
+}
+
+button:not(:disabled),
+[type="button"]:not(:disabled),
+[type="reset"]:not(:disabled),
+[type="submit"]:not(:disabled) {
+ cursor: pointer;
+}
+
+button::-moz-focus-inner,
+[type="button"]::-moz-focus-inner,
+[type="reset"]::-moz-focus-inner,
+[type="submit"]::-moz-focus-inner {
+ padding: 0;
+ border-style: none;
+}
+
+input[type="radio"],
+input[type="checkbox"] {
+ box-sizing: border-box;
+ padding: 0;
+}
+
+textarea {
+ overflow: auto;
+ resize: vertical;
+}
+
+fieldset {
+ min-width: 0;
+ padding: 0;
+ margin: 0;
+ border: 0;
+}
+
+legend {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ padding: 0;
+ margin-bottom: .5rem;
+ font-size: 1.5rem;
+ line-height: inherit;
+ color: inherit;
+ white-space: normal;
+}
+
+progress {
+ vertical-align: baseline;
+}
+
+[type="number"]::-webkit-inner-spin-button,
+[type="number"]::-webkit-outer-spin-button {
+ height: auto;
+}
+
+[type="search"] {
+ outline-offset: -2px;
+ -webkit-appearance: none;
+}
+
+[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+::-webkit-file-upload-button {
+ font: inherit;
+ -webkit-appearance: button;
+}
+
+output {
+ display: inline-block;
+}
+
+summary {
+ display: list-item;
+ cursor: pointer;
+}
+
+template {
+ display: none;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+h1, h2, h3, h4, h5, h6,
+.h1, .h2, .h3, .h4, .h5, .h6 {
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+ line-height: 1.2;
+}
+
+h1, .h1 {
+ font-size: 2.5rem;
+}
+
+h2, .h2 {
+ font-size: 2rem;
+}
+
+h3, .h3 {
+ font-size: 1.75rem;
+}
+
+h4, .h4 {
+ font-size: 1.5rem;
+}
+
+h5, .h5 {
+ font-size: 1.25rem;
+}
+
+h6, .h6 {
+ font-size: 1rem;
+}
+
+.lead {
+ font-size: 1.25rem;
+ font-weight: 300;
+}
+
+.display-1 {
+ font-size: 6rem;
+ font-weight: 300;
+ line-height: 1.2;
+}
+
+.display-2 {
+ font-size: 5.5rem;
+ font-weight: 300;
+ line-height: 1.2;
+}
+
+.display-3 {
+ font-size: 4.5rem;
+ font-weight: 300;
+ line-height: 1.2;
+}
+
+.display-4 {
+ font-size: 3.5rem;
+ font-weight: 300;
+ line-height: 1.2;
+}
+
+hr {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ border: 0;
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
+}
+
+small,
+.small {
+ font-size: 80%;
+ font-weight: 400;
+}
+
+mark,
+.mark {
+ padding: 0.2em;
+ background-color: #fcf8e3;
+}
+
+.list-unstyled {
+ padding-left: 0;
+ list-style: none;
+}
+
+.list-inline {
+ padding-left: 0;
+ list-style: none;
+}
+
+.list-inline-item {
+ display: inline-block;
+}
+
+.list-inline-item:not(:last-child) {
+ margin-right: 0.5rem;
+}
+
+.initialism {
+ font-size: 90%;
+ text-transform: uppercase;
+}
+
+.blockquote {
+ margin-bottom: 1rem;
+ font-size: 1.25rem;
+}
+
+.blockquote-footer {
+ display: block;
+ font-size: 80%;
+ color: #6c757d;
+}
+
+.blockquote-footer::before {
+ content: "\2014\00A0";
+}
+
+.img-fluid {
+ max-width: 100%;
+ height: auto;
+}
+
+.img-thumbnail {
+ padding: 0.25rem;
+ background-color: #fff;
+ border: 1px solid #dee2e6;
+ border-radius: 0.25rem;
+ max-width: 100%;
+ height: auto;
+}
+
+.figure {
+ display: inline-block;
+}
+
+.figure-img {
+ margin-bottom: 0.5rem;
+ line-height: 1;
+}
+
+.figure-caption {
+ font-size: 90%;
+ color: #6c757d;
+}
+
+code {
+ font-size: 87.5%;
+ color: #e83e8c;
+ word-wrap: break-word;
+}
+
+a > code {
+ color: inherit;
+}
+
+kbd {
+ padding: 0.2rem 0.4rem;
+ font-size: 87.5%;
+ color: #fff;
+ background-color: #212529;
+ border-radius: 0.2rem;
+}
+
+kbd kbd {
+ padding: 0;
+ font-size: 100%;
+ font-weight: 700;
+}
+
+pre {
+ display: block;
+ font-size: 87.5%;
+ color: #212529;
+}
+
+pre code {
+ font-size: inherit;
+ color: inherit;
+ word-break: normal;
+}
+
+.pre-scrollable {
+ max-height: 340px;
+ overflow-y: scroll;
+}
+
+.container {
+ width: 100%;
+ padding-right: 15px;
+ padding-left: 15px;
+ margin-right: auto;
+ margin-left: auto;
+}
+
+@media (min-width: 576px) {
+ .container {
+ max-width: 540px;
+ }
+}
+
+@media (min-width: 768px) {
+ .container {
+ max-width: 720px;
+ }
+}
+
+@media (min-width: 992px) {
+ .container {
+ max-width: 960px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .container {
+ max-width: 1140px;
+ }
+}
+
+.container-fluid, .container-sm, .container-md, .container-lg, .container-xl {
+ width: 100%;
+ padding-right: 15px;
+ padding-left: 15px;
+ margin-right: auto;
+ margin-left: auto;
+}
+
+@media (min-width: 576px) {
+ .container, .container-sm {
+ max-width: 540px;
+ }
+}
+
+@media (min-width: 768px) {
+ .container, .container-sm, .container-md {
+ max-width: 720px;
+ }
+}
+
+@media (min-width: 992px) {
+ .container, .container-sm, .container-md, .container-lg {
+ max-width: 960px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .container, .container-sm, .container-md, .container-lg, .container-xl {
+ max-width: 1140px;
+ }
+}
+
+.row {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ margin-right: -15px;
+ margin-left: -15px;
+}
+
+.no-gutters {
+ margin-right: 0;
+ margin-left: 0;
+}
+
+.no-gutters > .col,
+.no-gutters > [class*="col-"] {
+ padding-right: 0;
+ padding-left: 0;
+}
+
+.col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12, .col,
+.col-auto, .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm,
+.col-sm-auto, .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12, .col-md,
+.col-md-auto, .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg,
+.col-lg-auto, .col-xl-1, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl,
+.col-xl-auto {
+ position: relative;
+ width: 100%;
+ padding-right: 15px;
+ padding-left: 15px;
+}
+
+.col {
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ min-width: 0;
+ max-width: 100%;
+}
+
+.row-cols-1 > * {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+}
+
+.row-cols-2 > * {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+}
+
+.row-cols-3 > * {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+}
+
+.row-cols-4 > * {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+}
+
+.row-cols-5 > * {
+ -ms-flex: 0 0 20%;
+ flex: 0 0 20%;
+ max-width: 20%;
+}
+
+.row-cols-6 > * {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+}
+
+.col-auto {
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ width: auto;
+ max-width: 100%;
+}
+
+.col-1 {
+ -ms-flex: 0 0 8.333333%;
+ flex: 0 0 8.333333%;
+ max-width: 8.333333%;
+}
+
+.col-2 {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+}
+
+.col-3 {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+}
+
+.col-4 {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+}
+
+.col-5 {
+ -ms-flex: 0 0 41.666667%;
+ flex: 0 0 41.666667%;
+ max-width: 41.666667%;
+}
+
+.col-6 {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+}
+
+.col-7 {
+ -ms-flex: 0 0 58.333333%;
+ flex: 0 0 58.333333%;
+ max-width: 58.333333%;
+}
+
+.col-8 {
+ -ms-flex: 0 0 66.666667%;
+ flex: 0 0 66.666667%;
+ max-width: 66.666667%;
+}
+
+.col-9 {
+ -ms-flex: 0 0 75%;
+ flex: 0 0 75%;
+ max-width: 75%;
+}
+
+.col-10 {
+ -ms-flex: 0 0 83.333333%;
+ flex: 0 0 83.333333%;
+ max-width: 83.333333%;
+}
+
+.col-11 {
+ -ms-flex: 0 0 91.666667%;
+ flex: 0 0 91.666667%;
+ max-width: 91.666667%;
+}
+
+.col-12 {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+}
+
+.order-first {
+ -ms-flex-order: -1;
+ order: -1;
+}
+
+.order-last {
+ -ms-flex-order: 13;
+ order: 13;
+}
+
+.order-0 {
+ -ms-flex-order: 0;
+ order: 0;
+}
+
+.order-1 {
+ -ms-flex-order: 1;
+ order: 1;
+}
+
+.order-2 {
+ -ms-flex-order: 2;
+ order: 2;
+}
+
+.order-3 {
+ -ms-flex-order: 3;
+ order: 3;
+}
+
+.order-4 {
+ -ms-flex-order: 4;
+ order: 4;
+}
+
+.order-5 {
+ -ms-flex-order: 5;
+ order: 5;
+}
+
+.order-6 {
+ -ms-flex-order: 6;
+ order: 6;
+}
+
+.order-7 {
+ -ms-flex-order: 7;
+ order: 7;
+}
+
+.order-8 {
+ -ms-flex-order: 8;
+ order: 8;
+}
+
+.order-9 {
+ -ms-flex-order: 9;
+ order: 9;
+}
+
+.order-10 {
+ -ms-flex-order: 10;
+ order: 10;
+}
+
+.order-11 {
+ -ms-flex-order: 11;
+ order: 11;
+}
+
+.order-12 {
+ -ms-flex-order: 12;
+ order: 12;
+}
+
+.offset-1 {
+ margin-left: 8.333333%;
+}
+
+.offset-2 {
+ margin-left: 16.666667%;
+}
+
+.offset-3 {
+ margin-left: 25%;
+}
+
+.offset-4 {
+ margin-left: 33.333333%;
+}
+
+.offset-5 {
+ margin-left: 41.666667%;
+}
+
+.offset-6 {
+ margin-left: 50%;
+}
+
+.offset-7 {
+ margin-left: 58.333333%;
+}
+
+.offset-8 {
+ margin-left: 66.666667%;
+}
+
+.offset-9 {
+ margin-left: 75%;
+}
+
+.offset-10 {
+ margin-left: 83.333333%;
+}
+
+.offset-11 {
+ margin-left: 91.666667%;
+}
+
+@media (min-width: 576px) {
+ .col-sm {
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ min-width: 0;
+ max-width: 100%;
+ }
+ .row-cols-sm-1 > * {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .row-cols-sm-2 > * {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .row-cols-sm-3 > * {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .row-cols-sm-4 > * {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .row-cols-sm-5 > * {
+ -ms-flex: 0 0 20%;
+ flex: 0 0 20%;
+ max-width: 20%;
+ }
+ .row-cols-sm-6 > * {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-sm-auto {
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ width: auto;
+ max-width: 100%;
+ }
+ .col-sm-1 {
+ -ms-flex: 0 0 8.333333%;
+ flex: 0 0 8.333333%;
+ max-width: 8.333333%;
+ }
+ .col-sm-2 {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-sm-3 {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .col-sm-4 {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .col-sm-5 {
+ -ms-flex: 0 0 41.666667%;
+ flex: 0 0 41.666667%;
+ max-width: 41.666667%;
+ }
+ .col-sm-6 {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .col-sm-7 {
+ -ms-flex: 0 0 58.333333%;
+ flex: 0 0 58.333333%;
+ max-width: 58.333333%;
+ }
+ .col-sm-8 {
+ -ms-flex: 0 0 66.666667%;
+ flex: 0 0 66.666667%;
+ max-width: 66.666667%;
+ }
+ .col-sm-9 {
+ -ms-flex: 0 0 75%;
+ flex: 0 0 75%;
+ max-width: 75%;
+ }
+ .col-sm-10 {
+ -ms-flex: 0 0 83.333333%;
+ flex: 0 0 83.333333%;
+ max-width: 83.333333%;
+ }
+ .col-sm-11 {
+ -ms-flex: 0 0 91.666667%;
+ flex: 0 0 91.666667%;
+ max-width: 91.666667%;
+ }
+ .col-sm-12 {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .order-sm-first {
+ -ms-flex-order: -1;
+ order: -1;
+ }
+ .order-sm-last {
+ -ms-flex-order: 13;
+ order: 13;
+ }
+ .order-sm-0 {
+ -ms-flex-order: 0;
+ order: 0;
+ }
+ .order-sm-1 {
+ -ms-flex-order: 1;
+ order: 1;
+ }
+ .order-sm-2 {
+ -ms-flex-order: 2;
+ order: 2;
+ }
+ .order-sm-3 {
+ -ms-flex-order: 3;
+ order: 3;
+ }
+ .order-sm-4 {
+ -ms-flex-order: 4;
+ order: 4;
+ }
+ .order-sm-5 {
+ -ms-flex-order: 5;
+ order: 5;
+ }
+ .order-sm-6 {
+ -ms-flex-order: 6;
+ order: 6;
+ }
+ .order-sm-7 {
+ -ms-flex-order: 7;
+ order: 7;
+ }
+ .order-sm-8 {
+ -ms-flex-order: 8;
+ order: 8;
+ }
+ .order-sm-9 {
+ -ms-flex-order: 9;
+ order: 9;
+ }
+ .order-sm-10 {
+ -ms-flex-order: 10;
+ order: 10;
+ }
+ .order-sm-11 {
+ -ms-flex-order: 11;
+ order: 11;
+ }
+ .order-sm-12 {
+ -ms-flex-order: 12;
+ order: 12;
+ }
+ .offset-sm-0 {
+ margin-left: 0;
+ }
+ .offset-sm-1 {
+ margin-left: 8.333333%;
+ }
+ .offset-sm-2 {
+ margin-left: 16.666667%;
+ }
+ .offset-sm-3 {
+ margin-left: 25%;
+ }
+ .offset-sm-4 {
+ margin-left: 33.333333%;
+ }
+ .offset-sm-5 {
+ margin-left: 41.666667%;
+ }
+ .offset-sm-6 {
+ margin-left: 50%;
+ }
+ .offset-sm-7 {
+ margin-left: 58.333333%;
+ }
+ .offset-sm-8 {
+ margin-left: 66.666667%;
+ }
+ .offset-sm-9 {
+ margin-left: 75%;
+ }
+ .offset-sm-10 {
+ margin-left: 83.333333%;
+ }
+ .offset-sm-11 {
+ margin-left: 91.666667%;
+ }
+}
+
+@media (min-width: 768px) {
+ .col-md {
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ min-width: 0;
+ max-width: 100%;
+ }
+ .row-cols-md-1 > * {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .row-cols-md-2 > * {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .row-cols-md-3 > * {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .row-cols-md-4 > * {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .row-cols-md-5 > * {
+ -ms-flex: 0 0 20%;
+ flex: 0 0 20%;
+ max-width: 20%;
+ }
+ .row-cols-md-6 > * {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-md-auto {
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ width: auto;
+ max-width: 100%;
+ }
+ .col-md-1 {
+ -ms-flex: 0 0 8.333333%;
+ flex: 0 0 8.333333%;
+ max-width: 8.333333%;
+ }
+ .col-md-2 {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-md-3 {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .col-md-4 {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .col-md-5 {
+ -ms-flex: 0 0 41.666667%;
+ flex: 0 0 41.666667%;
+ max-width: 41.666667%;
+ }
+ .col-md-6 {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .col-md-7 {
+ -ms-flex: 0 0 58.333333%;
+ flex: 0 0 58.333333%;
+ max-width: 58.333333%;
+ }
+ .col-md-8 {
+ -ms-flex: 0 0 66.666667%;
+ flex: 0 0 66.666667%;
+ max-width: 66.666667%;
+ }
+ .col-md-9 {
+ -ms-flex: 0 0 75%;
+ flex: 0 0 75%;
+ max-width: 75%;
+ }
+ .col-md-10 {
+ -ms-flex: 0 0 83.333333%;
+ flex: 0 0 83.333333%;
+ max-width: 83.333333%;
+ }
+ .col-md-11 {
+ -ms-flex: 0 0 91.666667%;
+ flex: 0 0 91.666667%;
+ max-width: 91.666667%;
+ }
+ .col-md-12 {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .order-md-first {
+ -ms-flex-order: -1;
+ order: -1;
+ }
+ .order-md-last {
+ -ms-flex-order: 13;
+ order: 13;
+ }
+ .order-md-0 {
+ -ms-flex-order: 0;
+ order: 0;
+ }
+ .order-md-1 {
+ -ms-flex-order: 1;
+ order: 1;
+ }
+ .order-md-2 {
+ -ms-flex-order: 2;
+ order: 2;
+ }
+ .order-md-3 {
+ -ms-flex-order: 3;
+ order: 3;
+ }
+ .order-md-4 {
+ -ms-flex-order: 4;
+ order: 4;
+ }
+ .order-md-5 {
+ -ms-flex-order: 5;
+ order: 5;
+ }
+ .order-md-6 {
+ -ms-flex-order: 6;
+ order: 6;
+ }
+ .order-md-7 {
+ -ms-flex-order: 7;
+ order: 7;
+ }
+ .order-md-8 {
+ -ms-flex-order: 8;
+ order: 8;
+ }
+ .order-md-9 {
+ -ms-flex-order: 9;
+ order: 9;
+ }
+ .order-md-10 {
+ -ms-flex-order: 10;
+ order: 10;
+ }
+ .order-md-11 {
+ -ms-flex-order: 11;
+ order: 11;
+ }
+ .order-md-12 {
+ -ms-flex-order: 12;
+ order: 12;
+ }
+ .offset-md-0 {
+ margin-left: 0;
+ }
+ .offset-md-1 {
+ margin-left: 8.333333%;
+ }
+ .offset-md-2 {
+ margin-left: 16.666667%;
+ }
+ .offset-md-3 {
+ margin-left: 25%;
+ }
+ .offset-md-4 {
+ margin-left: 33.333333%;
+ }
+ .offset-md-5 {
+ margin-left: 41.666667%;
+ }
+ .offset-md-6 {
+ margin-left: 50%;
+ }
+ .offset-md-7 {
+ margin-left: 58.333333%;
+ }
+ .offset-md-8 {
+ margin-left: 66.666667%;
+ }
+ .offset-md-9 {
+ margin-left: 75%;
+ }
+ .offset-md-10 {
+ margin-left: 83.333333%;
+ }
+ .offset-md-11 {
+ margin-left: 91.666667%;
+ }
+}
+
+@media (min-width: 992px) {
+ .col-lg {
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ min-width: 0;
+ max-width: 100%;
+ }
+ .row-cols-lg-1 > * {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .row-cols-lg-2 > * {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .row-cols-lg-3 > * {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .row-cols-lg-4 > * {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .row-cols-lg-5 > * {
+ -ms-flex: 0 0 20%;
+ flex: 0 0 20%;
+ max-width: 20%;
+ }
+ .row-cols-lg-6 > * {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-lg-auto {
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ width: auto;
+ max-width: 100%;
+ }
+ .col-lg-1 {
+ -ms-flex: 0 0 8.333333%;
+ flex: 0 0 8.333333%;
+ max-width: 8.333333%;
+ }
+ .col-lg-2 {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-lg-3 {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .col-lg-4 {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .col-lg-5 {
+ -ms-flex: 0 0 41.666667%;
+ flex: 0 0 41.666667%;
+ max-width: 41.666667%;
+ }
+ .col-lg-6 {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .col-lg-7 {
+ -ms-flex: 0 0 58.333333%;
+ flex: 0 0 58.333333%;
+ max-width: 58.333333%;
+ }
+ .col-lg-8 {
+ -ms-flex: 0 0 66.666667%;
+ flex: 0 0 66.666667%;
+ max-width: 66.666667%;
+ }
+ .col-lg-9 {
+ -ms-flex: 0 0 75%;
+ flex: 0 0 75%;
+ max-width: 75%;
+ }
+ .col-lg-10 {
+ -ms-flex: 0 0 83.333333%;
+ flex: 0 0 83.333333%;
+ max-width: 83.333333%;
+ }
+ .col-lg-11 {
+ -ms-flex: 0 0 91.666667%;
+ flex: 0 0 91.666667%;
+ max-width: 91.666667%;
+ }
+ .col-lg-12 {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .order-lg-first {
+ -ms-flex-order: -1;
+ order: -1;
+ }
+ .order-lg-last {
+ -ms-flex-order: 13;
+ order: 13;
+ }
+ .order-lg-0 {
+ -ms-flex-order: 0;
+ order: 0;
+ }
+ .order-lg-1 {
+ -ms-flex-order: 1;
+ order: 1;
+ }
+ .order-lg-2 {
+ -ms-flex-order: 2;
+ order: 2;
+ }
+ .order-lg-3 {
+ -ms-flex-order: 3;
+ order: 3;
+ }
+ .order-lg-4 {
+ -ms-flex-order: 4;
+ order: 4;
+ }
+ .order-lg-5 {
+ -ms-flex-order: 5;
+ order: 5;
+ }
+ .order-lg-6 {
+ -ms-flex-order: 6;
+ order: 6;
+ }
+ .order-lg-7 {
+ -ms-flex-order: 7;
+ order: 7;
+ }
+ .order-lg-8 {
+ -ms-flex-order: 8;
+ order: 8;
+ }
+ .order-lg-9 {
+ -ms-flex-order: 9;
+ order: 9;
+ }
+ .order-lg-10 {
+ -ms-flex-order: 10;
+ order: 10;
+ }
+ .order-lg-11 {
+ -ms-flex-order: 11;
+ order: 11;
+ }
+ .order-lg-12 {
+ -ms-flex-order: 12;
+ order: 12;
+ }
+ .offset-lg-0 {
+ margin-left: 0;
+ }
+ .offset-lg-1 {
+ margin-left: 8.333333%;
+ }
+ .offset-lg-2 {
+ margin-left: 16.666667%;
+ }
+ .offset-lg-3 {
+ margin-left: 25%;
+ }
+ .offset-lg-4 {
+ margin-left: 33.333333%;
+ }
+ .offset-lg-5 {
+ margin-left: 41.666667%;
+ }
+ .offset-lg-6 {
+ margin-left: 50%;
+ }
+ .offset-lg-7 {
+ margin-left: 58.333333%;
+ }
+ .offset-lg-8 {
+ margin-left: 66.666667%;
+ }
+ .offset-lg-9 {
+ margin-left: 75%;
+ }
+ .offset-lg-10 {
+ margin-left: 83.333333%;
+ }
+ .offset-lg-11 {
+ margin-left: 91.666667%;
+ }
+}
+
+@media (min-width: 1200px) {
+ .col-xl {
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ min-width: 0;
+ max-width: 100%;
+ }
+ .row-cols-xl-1 > * {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .row-cols-xl-2 > * {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .row-cols-xl-3 > * {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .row-cols-xl-4 > * {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .row-cols-xl-5 > * {
+ -ms-flex: 0 0 20%;
+ flex: 0 0 20%;
+ max-width: 20%;
+ }
+ .row-cols-xl-6 > * {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-xl-auto {
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ width: auto;
+ max-width: 100%;
+ }
+ .col-xl-1 {
+ -ms-flex: 0 0 8.333333%;
+ flex: 0 0 8.333333%;
+ max-width: 8.333333%;
+ }
+ .col-xl-2 {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-xl-3 {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .col-xl-4 {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .col-xl-5 {
+ -ms-flex: 0 0 41.666667%;
+ flex: 0 0 41.666667%;
+ max-width: 41.666667%;
+ }
+ .col-xl-6 {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .col-xl-7 {
+ -ms-flex: 0 0 58.333333%;
+ flex: 0 0 58.333333%;
+ max-width: 58.333333%;
+ }
+ .col-xl-8 {
+ -ms-flex: 0 0 66.666667%;
+ flex: 0 0 66.666667%;
+ max-width: 66.666667%;
+ }
+ .col-xl-9 {
+ -ms-flex: 0 0 75%;
+ flex: 0 0 75%;
+ max-width: 75%;
+ }
+ .col-xl-10 {
+ -ms-flex: 0 0 83.333333%;
+ flex: 0 0 83.333333%;
+ max-width: 83.333333%;
+ }
+ .col-xl-11 {
+ -ms-flex: 0 0 91.666667%;
+ flex: 0 0 91.666667%;
+ max-width: 91.666667%;
+ }
+ .col-xl-12 {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .order-xl-first {
+ -ms-flex-order: -1;
+ order: -1;
+ }
+ .order-xl-last {
+ -ms-flex-order: 13;
+ order: 13;
+ }
+ .order-xl-0 {
+ -ms-flex-order: 0;
+ order: 0;
+ }
+ .order-xl-1 {
+ -ms-flex-order: 1;
+ order: 1;
+ }
+ .order-xl-2 {
+ -ms-flex-order: 2;
+ order: 2;
+ }
+ .order-xl-3 {
+ -ms-flex-order: 3;
+ order: 3;
+ }
+ .order-xl-4 {
+ -ms-flex-order: 4;
+ order: 4;
+ }
+ .order-xl-5 {
+ -ms-flex-order: 5;
+ order: 5;
+ }
+ .order-xl-6 {
+ -ms-flex-order: 6;
+ order: 6;
+ }
+ .order-xl-7 {
+ -ms-flex-order: 7;
+ order: 7;
+ }
+ .order-xl-8 {
+ -ms-flex-order: 8;
+ order: 8;
+ }
+ .order-xl-9 {
+ -ms-flex-order: 9;
+ order: 9;
+ }
+ .order-xl-10 {
+ -ms-flex-order: 10;
+ order: 10;
+ }
+ .order-xl-11 {
+ -ms-flex-order: 11;
+ order: 11;
+ }
+ .order-xl-12 {
+ -ms-flex-order: 12;
+ order: 12;
+ }
+ .offset-xl-0 {
+ margin-left: 0;
+ }
+ .offset-xl-1 {
+ margin-left: 8.333333%;
+ }
+ .offset-xl-2 {
+ margin-left: 16.666667%;
+ }
+ .offset-xl-3 {
+ margin-left: 25%;
+ }
+ .offset-xl-4 {
+ margin-left: 33.333333%;
+ }
+ .offset-xl-5 {
+ margin-left: 41.666667%;
+ }
+ .offset-xl-6 {
+ margin-left: 50%;
+ }
+ .offset-xl-7 {
+ margin-left: 58.333333%;
+ }
+ .offset-xl-8 {
+ margin-left: 66.666667%;
+ }
+ .offset-xl-9 {
+ margin-left: 75%;
+ }
+ .offset-xl-10 {
+ margin-left: 83.333333%;
+ }
+ .offset-xl-11 {
+ margin-left: 91.666667%;
+ }
+}
+
+.table {
+ width: 100%;
+ margin-bottom: 1rem;
+ color: #212529;
+}
+
+.table th,
+.table td {
+ padding: 0.75rem;
+ vertical-align: top;
+ border-top: 1px solid #dee2e6;
+}
+
+.table thead th {
+ vertical-align: bottom;
+ border-bottom: 2px solid #dee2e6;
+}
+
+.table tbody + tbody {
+ border-top: 2px solid #dee2e6;
+}
+
+.table-sm th,
+.table-sm td {
+ padding: 0.3rem;
+}
+
+.table-bordered {
+ border: 1px solid #dee2e6;
+}
+
+.table-bordered th,
+.table-bordered td {
+ border: 1px solid #dee2e6;
+}
+
+.table-bordered thead th,
+.table-bordered thead td {
+ border-bottom-width: 2px;
+}
+
+.table-borderless th,
+.table-borderless td,
+.table-borderless thead th,
+.table-borderless tbody + tbody {
+ border: 0;
+}
+
+.table-striped tbody tr:nth-of-type(odd) {
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+.table-hover tbody tr:hover {
+ color: #212529;
+ background-color: rgba(0, 0, 0, 0.075);
+}
+
+.table-primary,
+.table-primary > th,
+.table-primary > td {
+ background-color: #b8daff;
+}
+
+.table-primary th,
+.table-primary td,
+.table-primary thead th,
+.table-primary tbody + tbody {
+ border-color: #7abaff;
+}
+
+.table-hover .table-primary:hover {
+ background-color: #9fcdff;
+}
+
+.table-hover .table-primary:hover > td,
+.table-hover .table-primary:hover > th {
+ background-color: #9fcdff;
+}
+
+.table-secondary,
+.table-secondary > th,
+.table-secondary > td {
+ background-color: #d6d8db;
+}
+
+.table-secondary th,
+.table-secondary td,
+.table-secondary thead th,
+.table-secondary tbody + tbody {
+ border-color: #b3b7bb;
+}
+
+.table-hover .table-secondary:hover {
+ background-color: #c8cbcf;
+}
+
+.table-hover .table-secondary:hover > td,
+.table-hover .table-secondary:hover > th {
+ background-color: #c8cbcf;
+}
+
+.table-success,
+.table-success > th,
+.table-success > td {
+ background-color: #c3e6cb;
+}
+
+.table-success th,
+.table-success td,
+.table-success thead th,
+.table-success tbody + tbody {
+ border-color: #8fd19e;
+}
+
+.table-hover .table-success:hover {
+ background-color: #b1dfbb;
+}
+
+.table-hover .table-success:hover > td,
+.table-hover .table-success:hover > th {
+ background-color: #b1dfbb;
+}
+
+.table-info,
+.table-info > th,
+.table-info > td {
+ background-color: #bee5eb;
+}
+
+.table-info th,
+.table-info td,
+.table-info thead th,
+.table-info tbody + tbody {
+ border-color: #86cfda;
+}
+
+.table-hover .table-info:hover {
+ background-color: #abdde5;
+}
+
+.table-hover .table-info:hover > td,
+.table-hover .table-info:hover > th {
+ background-color: #abdde5;
+}
+
+.table-warning,
+.table-warning > th,
+.table-warning > td {
+ background-color: #ffeeba;
+}
+
+.table-warning th,
+.table-warning td,
+.table-warning thead th,
+.table-warning tbody + tbody {
+ border-color: #ffdf7e;
+}
+
+.table-hover .table-warning:hover {
+ background-color: #ffe8a1;
+}
+
+.table-hover .table-warning:hover > td,
+.table-hover .table-warning:hover > th {
+ background-color: #ffe8a1;
+}
+
+.table-danger,
+.table-danger > th,
+.table-danger > td {
+ background-color: #f5c6cb;
+}
+
+.table-danger th,
+.table-danger td,
+.table-danger thead th,
+.table-danger tbody + tbody {
+ border-color: #ed969e;
+}
+
+.table-hover .table-danger:hover {
+ background-color: #f1b0b7;
+}
+
+.table-hover .table-danger:hover > td,
+.table-hover .table-danger:hover > th {
+ background-color: #f1b0b7;
+}
+
+.table-light,
+.table-light > th,
+.table-light > td {
+ background-color: #fdfdfe;
+}
+
+.table-light th,
+.table-light td,
+.table-light thead th,
+.table-light tbody + tbody {
+ border-color: #fbfcfc;
+}
+
+.table-hover .table-light:hover {
+ background-color: #ececf6;
+}
+
+.table-hover .table-light:hover > td,
+.table-hover .table-light:hover > th {
+ background-color: #ececf6;
+}
+
+.table-dark,
+.table-dark > th,
+.table-dark > td {
+ background-color: #c6c8ca;
+}
+
+.table-dark th,
+.table-dark td,
+.table-dark thead th,
+.table-dark tbody + tbody {
+ border-color: #95999c;
+}
+
+.table-hover .table-dark:hover {
+ background-color: #b9bbbe;
+}
+
+.table-hover .table-dark:hover > td,
+.table-hover .table-dark:hover > th {
+ background-color: #b9bbbe;
+}
+
+.table-active,
+.table-active > th,
+.table-active > td {
+ background-color: rgba(0, 0, 0, 0.075);
+}
+
+.table-hover .table-active:hover {
+ background-color: rgba(0, 0, 0, 0.075);
+}
+
+.table-hover .table-active:hover > td,
+.table-hover .table-active:hover > th {
+ background-color: rgba(0, 0, 0, 0.075);
+}
+
+.table .thead-dark th {
+ color: #fff;
+ background-color: #343a40;
+ border-color: #454d55;
+}
+
+.table .thead-light th {
+ color: #495057;
+ background-color: #e9ecef;
+ border-color: #dee2e6;
+}
+
+.table-dark {
+ color: #fff;
+ background-color: #343a40;
+}
+
+.table-dark th,
+.table-dark td,
+.table-dark thead th {
+ border-color: #454d55;
+}
+
+.table-dark.table-bordered {
+ border: 0;
+}
+
+.table-dark.table-striped tbody tr:nth-of-type(odd) {
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+.table-dark.table-hover tbody tr:hover {
+ color: #fff;
+ background-color: rgba(255, 255, 255, 0.075);
+}
+
+@media (max-width: 575.98px) {
+ .table-responsive-sm {
+ display: block;
+ width: 100%;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+ .table-responsive-sm > .table-bordered {
+ border: 0;
+ }
+}
+
+@media (max-width: 767.98px) {
+ .table-responsive-md {
+ display: block;
+ width: 100%;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+ .table-responsive-md > .table-bordered {
+ border: 0;
+ }
+}
+
+@media (max-width: 991.98px) {
+ .table-responsive-lg {
+ display: block;
+ width: 100%;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+ .table-responsive-lg > .table-bordered {
+ border: 0;
+ }
+}
+
+@media (max-width: 1199.98px) {
+ .table-responsive-xl {
+ display: block;
+ width: 100%;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+ .table-responsive-xl > .table-bordered {
+ border: 0;
+ }
+}
+
+.table-responsive {
+ display: block;
+ width: 100%;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.table-responsive > .table-bordered {
+ border: 0;
+}
+
+.form-control {
+ display: block;
+ width: 100%;
+ height: calc(1.5em + 0.75rem + 2px);
+ padding: 0.375rem 0.75rem;
+ font-size: 1rem;
+ font-weight: 400;
+ line-height: 1.5;
+ color: #495057;
+ background-color: #fff;
+ background-clip: padding-box;
+ border: 1px solid #ced4da;
+ border-radius: 0.25rem;
+ transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .form-control {
+ transition: none;
+ }
+}
+
+.form-control::-ms-expand {
+ background-color: transparent;
+ border: 0;
+}
+
+.form-control:-moz-focusring {
+ color: transparent;
+ text-shadow: 0 0 0 #495057;
+}
+
+.form-control:focus {
+ color: #495057;
+ background-color: #fff;
+ border-color: #80bdff;
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.form-control::-webkit-input-placeholder {
+ color: #6c757d;
+ opacity: 1;
+}
+
+.form-control::-moz-placeholder {
+ color: #6c757d;
+ opacity: 1;
+}
+
+.form-control:-ms-input-placeholder {
+ color: #6c757d;
+ opacity: 1;
+}
+
+.form-control::-ms-input-placeholder {
+ color: #6c757d;
+ opacity: 1;
+}
+
+.form-control::placeholder {
+ color: #6c757d;
+ opacity: 1;
+}
+
+.form-control:disabled, .form-control[readonly] {
+ background-color: #e9ecef;
+ opacity: 1;
+}
+
+input[type="date"].form-control,
+input[type="time"].form-control,
+input[type="datetime-local"].form-control,
+input[type="month"].form-control {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+}
+
+select.form-control:focus::-ms-value {
+ color: #495057;
+ background-color: #fff;
+}
+
+.form-control-file,
+.form-control-range {
+ display: block;
+ width: 100%;
+}
+
+.col-form-label {
+ padding-top: calc(0.375rem + 1px);
+ padding-bottom: calc(0.375rem + 1px);
+ margin-bottom: 0;
+ font-size: inherit;
+ line-height: 1.5;
+}
+
+.col-form-label-lg {
+ padding-top: calc(0.5rem + 1px);
+ padding-bottom: calc(0.5rem + 1px);
+ font-size: 1.25rem;
+ line-height: 1.5;
+}
+
+.col-form-label-sm {
+ padding-top: calc(0.25rem + 1px);
+ padding-bottom: calc(0.25rem + 1px);
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+
+.form-control-plaintext {
+ display: block;
+ width: 100%;
+ padding: 0.375rem 0;
+ margin-bottom: 0;
+ font-size: 1rem;
+ line-height: 1.5;
+ color: #212529;
+ background-color: transparent;
+ border: solid transparent;
+ border-width: 1px 0;
+}
+
+.form-control-plaintext.form-control-sm, .form-control-plaintext.form-control-lg {
+ padding-right: 0;
+ padding-left: 0;
+}
+
+.form-control-sm {
+ height: calc(1.5em + 0.5rem + 2px);
+ padding: 0.25rem 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ border-radius: 0.2rem;
+}
+
+.form-control-lg {
+ height: calc(1.5em + 1rem + 2px);
+ padding: 0.5rem 1rem;
+ font-size: 1.25rem;
+ line-height: 1.5;
+ border-radius: 0.3rem;
+}
+
+select.form-control[size], select.form-control[multiple] {
+ height: auto;
+}
+
+textarea.form-control {
+ height: auto;
+}
+
+.form-group {
+ margin-bottom: 1rem;
+}
+
+.form-text {
+ display: block;
+ margin-top: 0.25rem;
+}
+
+.form-row {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ margin-right: -5px;
+ margin-left: -5px;
+}
+
+.form-row > .col,
+.form-row > [class*="col-"] {
+ padding-right: 5px;
+ padding-left: 5px;
+}
+
+.form-check {
+ position: relative;
+ display: block;
+ padding-left: 1.25rem;
+}
+
+.form-check-input {
+ position: absolute;
+ margin-top: 0.3rem;
+ margin-left: -1.25rem;
+}
+
+.form-check-input[disabled] ~ .form-check-label,
+.form-check-input:disabled ~ .form-check-label {
+ color: #6c757d;
+}
+
+.form-check-label {
+ margin-bottom: 0;
+}
+
+.form-check-inline {
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ -ms-flex-align: center;
+ align-items: center;
+ padding-left: 0;
+ margin-right: 0.75rem;
+}
+
+.form-check-inline .form-check-input {
+ position: static;
+ margin-top: 0;
+ margin-right: 0.3125rem;
+ margin-left: 0;
+}
+
+.valid-feedback {
+ display: none;
+ width: 100%;
+ margin-top: 0.25rem;
+ font-size: 80%;
+ color: #28a745;
+}
+
+.valid-tooltip {
+ position: absolute;
+ top: 100%;
+ z-index: 5;
+ display: none;
+ max-width: 100%;
+ padding: 0.25rem 0.5rem;
+ margin-top: .1rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ color: #fff;
+ background-color: rgba(40, 167, 69, 0.9);
+ border-radius: 0.25rem;
+}
+
+.was-validated :valid ~ .valid-feedback,
+.was-validated :valid ~ .valid-tooltip,
+.is-valid ~ .valid-feedback,
+.is-valid ~ .valid-tooltip {
+ display: block;
+}
+
+.was-validated .form-control:valid, .form-control.is-valid {
+ border-color: #28a745;
+ padding-right: calc(1.5em + 0.75rem);
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");
+ background-repeat: no-repeat;
+ background-position: right calc(0.375em + 0.1875rem) center;
+ background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
+}
+
+.was-validated .form-control:valid:focus, .form-control.is-valid:focus {
+ border-color: #28a745;
+ box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);
+}
+
+.was-validated textarea.form-control:valid, textarea.form-control.is-valid {
+ padding-right: calc(1.5em + 0.75rem);
+ background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem);
+}
+
+.was-validated .custom-select:valid, .custom-select.is-valid {
+ border-color: #28a745;
+ padding-right: calc(0.75em + 2.3125rem);
+ background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px, url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
+}
+
+.was-validated .custom-select:valid:focus, .custom-select.is-valid:focus {
+ border-color: #28a745;
+ box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);
+}
+
+.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label {
+ color: #28a745;
+}
+
+.was-validated .form-check-input:valid ~ .valid-feedback,
+.was-validated .form-check-input:valid ~ .valid-tooltip, .form-check-input.is-valid ~ .valid-feedback,
+.form-check-input.is-valid ~ .valid-tooltip {
+ display: block;
+}
+
+.was-validated .custom-control-input:valid ~ .custom-control-label, .custom-control-input.is-valid ~ .custom-control-label {
+ color: #28a745;
+}
+
+.was-validated .custom-control-input:valid ~ .custom-control-label::before, .custom-control-input.is-valid ~ .custom-control-label::before {
+ border-color: #28a745;
+}
+
+.was-validated .custom-control-input:valid:checked ~ .custom-control-label::before, .custom-control-input.is-valid:checked ~ .custom-control-label::before {
+ border-color: #34ce57;
+ background-color: #34ce57;
+}
+
+.was-validated .custom-control-input:valid:focus ~ .custom-control-label::before, .custom-control-input.is-valid:focus ~ .custom-control-label::before {
+ box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);
+}
+
+.was-validated .custom-control-input:valid:focus:not(:checked) ~ .custom-control-label::before, .custom-control-input.is-valid:focus:not(:checked) ~ .custom-control-label::before {
+ border-color: #28a745;
+}
+
+.was-validated .custom-file-input:valid ~ .custom-file-label, .custom-file-input.is-valid ~ .custom-file-label {
+ border-color: #28a745;
+}
+
+.was-validated .custom-file-input:valid:focus ~ .custom-file-label, .custom-file-input.is-valid:focus ~ .custom-file-label {
+ border-color: #28a745;
+ box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);
+}
+
+.invalid-feedback {
+ display: none;
+ width: 100%;
+ margin-top: 0.25rem;
+ font-size: 80%;
+ color: #dc3545;
+}
+
+.invalid-tooltip {
+ position: absolute;
+ top: 100%;
+ z-index: 5;
+ display: none;
+ max-width: 100%;
+ padding: 0.25rem 0.5rem;
+ margin-top: .1rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ color: #fff;
+ background-color: rgba(220, 53, 69, 0.9);
+ border-radius: 0.25rem;
+}
+
+.was-validated :invalid ~ .invalid-feedback,
+.was-validated :invalid ~ .invalid-tooltip,
+.is-invalid ~ .invalid-feedback,
+.is-invalid ~ .invalid-tooltip {
+ display: block;
+}
+
+.was-validated .form-control:invalid, .form-control.is-invalid {
+ border-color: #dc3545;
+ padding-right: calc(1.5em + 0.75rem);
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");
+ background-repeat: no-repeat;
+ background-position: right calc(0.375em + 0.1875rem) center;
+ background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
+}
+
+.was-validated .form-control:invalid:focus, .form-control.is-invalid:focus {
+ border-color: #dc3545;
+ box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
+}
+
+.was-validated textarea.form-control:invalid, textarea.form-control.is-invalid {
+ padding-right: calc(1.5em + 0.75rem);
+ background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem);
+}
+
+.was-validated .custom-select:invalid, .custom-select.is-invalid {
+ border-color: #dc3545;
+ padding-right: calc(0.75em + 2.3125rem);
+ background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px, url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
+}
+
+.was-validated .custom-select:invalid:focus, .custom-select.is-invalid:focus {
+ border-color: #dc3545;
+ box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
+}
+
+.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label {
+ color: #dc3545;
+}
+
+.was-validated .form-check-input:invalid ~ .invalid-feedback,
+.was-validated .form-check-input:invalid ~ .invalid-tooltip, .form-check-input.is-invalid ~ .invalid-feedback,
+.form-check-input.is-invalid ~ .invalid-tooltip {
+ display: block;
+}
+
+.was-validated .custom-control-input:invalid ~ .custom-control-label, .custom-control-input.is-invalid ~ .custom-control-label {
+ color: #dc3545;
+}
+
+.was-validated .custom-control-input:invalid ~ .custom-control-label::before, .custom-control-input.is-invalid ~ .custom-control-label::before {
+ border-color: #dc3545;
+}
+
+.was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before, .custom-control-input.is-invalid:checked ~ .custom-control-label::before {
+ border-color: #e4606d;
+ background-color: #e4606d;
+}
+
+.was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before, .custom-control-input.is-invalid:focus ~ .custom-control-label::before {
+ box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
+}
+
+.was-validated .custom-control-input:invalid:focus:not(:checked) ~ .custom-control-label::before, .custom-control-input.is-invalid:focus:not(:checked) ~ .custom-control-label::before {
+ border-color: #dc3545;
+}
+
+.was-validated .custom-file-input:invalid ~ .custom-file-label, .custom-file-input.is-invalid ~ .custom-file-label {
+ border-color: #dc3545;
+}
+
+.was-validated .custom-file-input:invalid:focus ~ .custom-file-label, .custom-file-input.is-invalid:focus ~ .custom-file-label {
+ border-color: #dc3545;
+ box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
+}
+
+.form-inline {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-flow: row wrap;
+ flex-flow: row wrap;
+ -ms-flex-align: center;
+ align-items: center;
+}
+
+.form-inline .form-check {
+ width: 100%;
+}
+
+@media (min-width: 576px) {
+ .form-inline label {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-align: center;
+ align-items: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ margin-bottom: 0;
+ }
+ .form-inline .form-group {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ -ms-flex-flow: row wrap;
+ flex-flow: row wrap;
+ -ms-flex-align: center;
+ align-items: center;
+ margin-bottom: 0;
+ }
+ .form-inline .form-control {
+ display: inline-block;
+ width: auto;
+ vertical-align: middle;
+ }
+ .form-inline .form-control-plaintext {
+ display: inline-block;
+ }
+ .form-inline .input-group,
+ .form-inline .custom-select {
+ width: auto;
+ }
+ .form-inline .form-check {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-align: center;
+ align-items: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ width: auto;
+ padding-left: 0;
+ }
+ .form-inline .form-check-input {
+ position: relative;
+ -ms-flex-negative: 0;
+ flex-shrink: 0;
+ margin-top: 0;
+ margin-right: 0.25rem;
+ margin-left: 0;
+ }
+ .form-inline .custom-control {
+ -ms-flex-align: center;
+ align-items: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ }
+ .form-inline .custom-control-label {
+ margin-bottom: 0;
+ }
+}
+
+.btn {
+ display: inline-block;
+ font-weight: 400;
+ color: #212529;
+ text-align: center;
+ vertical-align: middle;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ background-color: transparent;
+ border: 1px solid transparent;
+ padding: 0.375rem 0.75rem;
+ font-size: 1rem;
+ line-height: 1.5;
+ border-radius: 0.25rem;
+ transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .btn {
+ transition: none;
+ }
+}
+
+.btn:hover {
+ color: #212529;
+ text-decoration: none;
+}
+
+.btn:focus, .btn.focus {
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.btn.disabled, .btn:disabled {
+ opacity: 0.65;
+}
+
+.btn:not(:disabled):not(.disabled) {
+ cursor: pointer;
+}
+
+a.btn.disabled,
+fieldset:disabled a.btn {
+ pointer-events: none;
+}
+
+.btn-primary {
+ color: #fff;
+ background-color: #007bff;
+ border-color: #007bff;
+}
+
+.btn-primary:hover {
+ color: #fff;
+ background-color: #0069d9;
+ border-color: #0062cc;
+}
+
+.btn-primary:focus, .btn-primary.focus {
+ color: #fff;
+ background-color: #0069d9;
+ border-color: #0062cc;
+ box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5);
+}
+
+.btn-primary.disabled, .btn-primary:disabled {
+ color: #fff;
+ background-color: #007bff;
+ border-color: #007bff;
+}
+
+.btn-primary:not(:disabled):not(.disabled):active, .btn-primary:not(:disabled):not(.disabled).active,
+.show > .btn-primary.dropdown-toggle {
+ color: #fff;
+ background-color: #0062cc;
+ border-color: #005cbf;
+}
+
+.btn-primary:not(:disabled):not(.disabled):active:focus, .btn-primary:not(:disabled):not(.disabled).active:focus,
+.show > .btn-primary.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5);
+}
+
+.btn-secondary {
+ color: #fff;
+ background-color: #6c757d;
+ border-color: #6c757d;
+}
+
+.btn-secondary:hover {
+ color: #fff;
+ background-color: #5a6268;
+ border-color: #545b62;
+}
+
+.btn-secondary:focus, .btn-secondary.focus {
+ color: #fff;
+ background-color: #5a6268;
+ border-color: #545b62;
+ box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5);
+}
+
+.btn-secondary.disabled, .btn-secondary:disabled {
+ color: #fff;
+ background-color: #6c757d;
+ border-color: #6c757d;
+}
+
+.btn-secondary:not(:disabled):not(.disabled):active, .btn-secondary:not(:disabled):not(.disabled).active,
+.show > .btn-secondary.dropdown-toggle {
+ color: #fff;
+ background-color: #545b62;
+ border-color: #4e555b;
+}
+
+.btn-secondary:not(:disabled):not(.disabled):active:focus, .btn-secondary:not(:disabled):not(.disabled).active:focus,
+.show > .btn-secondary.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5);
+}
+
+.btn-success {
+ color: #fff;
+ background-color: #28a745;
+ border-color: #28a745;
+}
+
+.btn-success:hover {
+ color: #fff;
+ background-color: #218838;
+ border-color: #1e7e34;
+}
+
+.btn-success:focus, .btn-success.focus {
+ color: #fff;
+ background-color: #218838;
+ border-color: #1e7e34;
+ box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5);
+}
+
+.btn-success.disabled, .btn-success:disabled {
+ color: #fff;
+ background-color: #28a745;
+ border-color: #28a745;
+}
+
+.btn-success:not(:disabled):not(.disabled):active, .btn-success:not(:disabled):not(.disabled).active,
+.show > .btn-success.dropdown-toggle {
+ color: #fff;
+ background-color: #1e7e34;
+ border-color: #1c7430;
+}
+
+.btn-success:not(:disabled):not(.disabled):active:focus, .btn-success:not(:disabled):not(.disabled).active:focus,
+.show > .btn-success.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5);
+}
+
+.btn-info {
+ color: #fff;
+ background-color: #17a2b8;
+ border-color: #17a2b8;
+}
+
+.btn-info:hover {
+ color: #fff;
+ background-color: #138496;
+ border-color: #117a8b;
+}
+
+.btn-info:focus, .btn-info.focus {
+ color: #fff;
+ background-color: #138496;
+ border-color: #117a8b;
+ box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5);
+}
+
+.btn-info.disabled, .btn-info:disabled {
+ color: #fff;
+ background-color: #17a2b8;
+ border-color: #17a2b8;
+}
+
+.btn-info:not(:disabled):not(.disabled):active, .btn-info:not(:disabled):not(.disabled).active,
+.show > .btn-info.dropdown-toggle {
+ color: #fff;
+ background-color: #117a8b;
+ border-color: #10707f;
+}
+
+.btn-info:not(:disabled):not(.disabled):active:focus, .btn-info:not(:disabled):not(.disabled).active:focus,
+.show > .btn-info.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5);
+}
+
+.btn-warning {
+ color: #212529;
+ background-color: #ffc107;
+ border-color: #ffc107;
+}
+
+.btn-warning:hover {
+ color: #212529;
+ background-color: #e0a800;
+ border-color: #d39e00;
+}
+
+.btn-warning:focus, .btn-warning.focus {
+ color: #212529;
+ background-color: #e0a800;
+ border-color: #d39e00;
+ box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5);
+}
+
+.btn-warning.disabled, .btn-warning:disabled {
+ color: #212529;
+ background-color: #ffc107;
+ border-color: #ffc107;
+}
+
+.btn-warning:not(:disabled):not(.disabled):active, .btn-warning:not(:disabled):not(.disabled).active,
+.show > .btn-warning.dropdown-toggle {
+ color: #212529;
+ background-color: #d39e00;
+ border-color: #c69500;
+}
+
+.btn-warning:not(:disabled):not(.disabled):active:focus, .btn-warning:not(:disabled):not(.disabled).active:focus,
+.show > .btn-warning.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5);
+}
+
+.btn-danger {
+ color: #fff;
+ background-color: #dc3545;
+ border-color: #dc3545;
+}
+
+.btn-danger:hover {
+ color: #fff;
+ background-color: #c82333;
+ border-color: #bd2130;
+}
+
+.btn-danger:focus, .btn-danger.focus {
+ color: #fff;
+ background-color: #c82333;
+ border-color: #bd2130;
+ box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5);
+}
+
+.btn-danger.disabled, .btn-danger:disabled {
+ color: #fff;
+ background-color: #dc3545;
+ border-color: #dc3545;
+}
+
+.btn-danger:not(:disabled):not(.disabled):active, .btn-danger:not(:disabled):not(.disabled).active,
+.show > .btn-danger.dropdown-toggle {
+ color: #fff;
+ background-color: #bd2130;
+ border-color: #b21f2d;
+}
+
+.btn-danger:not(:disabled):not(.disabled):active:focus, .btn-danger:not(:disabled):not(.disabled).active:focus,
+.show > .btn-danger.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5);
+}
+
+.btn-light {
+ color: #212529;
+ background-color: #f8f9fa;
+ border-color: #f8f9fa;
+}
+
+.btn-light:hover {
+ color: #212529;
+ background-color: #e2e6ea;
+ border-color: #dae0e5;
+}
+
+.btn-light:focus, .btn-light.focus {
+ color: #212529;
+ background-color: #e2e6ea;
+ border-color: #dae0e5;
+ box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5);
+}
+
+.btn-light.disabled, .btn-light:disabled {
+ color: #212529;
+ background-color: #f8f9fa;
+ border-color: #f8f9fa;
+}
+
+.btn-light:not(:disabled):not(.disabled):active, .btn-light:not(:disabled):not(.disabled).active,
+.show > .btn-light.dropdown-toggle {
+ color: #212529;
+ background-color: #dae0e5;
+ border-color: #d3d9df;
+}
+
+.btn-light:not(:disabled):not(.disabled):active:focus, .btn-light:not(:disabled):not(.disabled).active:focus,
+.show > .btn-light.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5);
+}
+
+.btn-dark {
+ color: #fff;
+ background-color: #343a40;
+ border-color: #343a40;
+}
+
+.btn-dark:hover {
+ color: #fff;
+ background-color: #23272b;
+ border-color: #1d2124;
+}
+
+.btn-dark:focus, .btn-dark.focus {
+ color: #fff;
+ background-color: #23272b;
+ border-color: #1d2124;
+ box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5);
+}
+
+.btn-dark.disabled, .btn-dark:disabled {
+ color: #fff;
+ background-color: #343a40;
+ border-color: #343a40;
+}
+
+.btn-dark:not(:disabled):not(.disabled):active, .btn-dark:not(:disabled):not(.disabled).active,
+.show > .btn-dark.dropdown-toggle {
+ color: #fff;
+ background-color: #1d2124;
+ border-color: #171a1d;
+}
+
+.btn-dark:not(:disabled):not(.disabled):active:focus, .btn-dark:not(:disabled):not(.disabled).active:focus,
+.show > .btn-dark.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5);
+}
+
+.btn-outline-primary {
+ color: #007bff;
+ border-color: #007bff;
+}
+
+.btn-outline-primary:hover {
+ color: #fff;
+ background-color: #007bff;
+ border-color: #007bff;
+}
+
+.btn-outline-primary:focus, .btn-outline-primary.focus {
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);
+}
+
+.btn-outline-primary.disabled, .btn-outline-primary:disabled {
+ color: #007bff;
+ background-color: transparent;
+}
+
+.btn-outline-primary:not(:disabled):not(.disabled):active, .btn-outline-primary:not(:disabled):not(.disabled).active,
+.show > .btn-outline-primary.dropdown-toggle {
+ color: #fff;
+ background-color: #007bff;
+ border-color: #007bff;
+}
+
+.btn-outline-primary:not(:disabled):not(.disabled):active:focus, .btn-outline-primary:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-primary.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);
+}
+
+.btn-outline-secondary {
+ color: #6c757d;
+ border-color: #6c757d;
+}
+
+.btn-outline-secondary:hover {
+ color: #fff;
+ background-color: #6c757d;
+ border-color: #6c757d;
+}
+
+.btn-outline-secondary:focus, .btn-outline-secondary.focus {
+ box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);
+}
+
+.btn-outline-secondary.disabled, .btn-outline-secondary:disabled {
+ color: #6c757d;
+ background-color: transparent;
+}
+
+.btn-outline-secondary:not(:disabled):not(.disabled):active, .btn-outline-secondary:not(:disabled):not(.disabled).active,
+.show > .btn-outline-secondary.dropdown-toggle {
+ color: #fff;
+ background-color: #6c757d;
+ border-color: #6c757d;
+}
+
+.btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .btn-outline-secondary:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-secondary.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);
+}
+
+.btn-outline-success {
+ color: #28a745;
+ border-color: #28a745;
+}
+
+.btn-outline-success:hover {
+ color: #fff;
+ background-color: #28a745;
+ border-color: #28a745;
+}
+
+.btn-outline-success:focus, .btn-outline-success.focus {
+ box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);
+}
+
+.btn-outline-success.disabled, .btn-outline-success:disabled {
+ color: #28a745;
+ background-color: transparent;
+}
+
+.btn-outline-success:not(:disabled):not(.disabled):active, .btn-outline-success:not(:disabled):not(.disabled).active,
+.show > .btn-outline-success.dropdown-toggle {
+ color: #fff;
+ background-color: #28a745;
+ border-color: #28a745;
+}
+
+.btn-outline-success:not(:disabled):not(.disabled):active:focus, .btn-outline-success:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-success.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);
+}
+
+.btn-outline-info {
+ color: #17a2b8;
+ border-color: #17a2b8;
+}
+
+.btn-outline-info:hover {
+ color: #fff;
+ background-color: #17a2b8;
+ border-color: #17a2b8;
+}
+
+.btn-outline-info:focus, .btn-outline-info.focus {
+ box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);
+}
+
+.btn-outline-info.disabled, .btn-outline-info:disabled {
+ color: #17a2b8;
+ background-color: transparent;
+}
+
+.btn-outline-info:not(:disabled):not(.disabled):active, .btn-outline-info:not(:disabled):not(.disabled).active,
+.show > .btn-outline-info.dropdown-toggle {
+ color: #fff;
+ background-color: #17a2b8;
+ border-color: #17a2b8;
+}
+
+.btn-outline-info:not(:disabled):not(.disabled):active:focus, .btn-outline-info:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-info.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);
+}
+
+.btn-outline-warning {
+ color: #ffc107;
+ border-color: #ffc107;
+}
+
+.btn-outline-warning:hover {
+ color: #212529;
+ background-color: #ffc107;
+ border-color: #ffc107;
+}
+
+.btn-outline-warning:focus, .btn-outline-warning.focus {
+ box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);
+}
+
+.btn-outline-warning.disabled, .btn-outline-warning:disabled {
+ color: #ffc107;
+ background-color: transparent;
+}
+
+.btn-outline-warning:not(:disabled):not(.disabled):active, .btn-outline-warning:not(:disabled):not(.disabled).active,
+.show > .btn-outline-warning.dropdown-toggle {
+ color: #212529;
+ background-color: #ffc107;
+ border-color: #ffc107;
+}
+
+.btn-outline-warning:not(:disabled):not(.disabled):active:focus, .btn-outline-warning:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-warning.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);
+}
+
+.btn-outline-danger {
+ color: #dc3545;
+ border-color: #dc3545;
+}
+
+.btn-outline-danger:hover {
+ color: #fff;
+ background-color: #dc3545;
+ border-color: #dc3545;
+}
+
+.btn-outline-danger:focus, .btn-outline-danger.focus {
+ box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);
+}
+
+.btn-outline-danger.disabled, .btn-outline-danger:disabled {
+ color: #dc3545;
+ background-color: transparent;
+}
+
+.btn-outline-danger:not(:disabled):not(.disabled):active, .btn-outline-danger:not(:disabled):not(.disabled).active,
+.show > .btn-outline-danger.dropdown-toggle {
+ color: #fff;
+ background-color: #dc3545;
+ border-color: #dc3545;
+}
+
+.btn-outline-danger:not(:disabled):not(.disabled):active:focus, .btn-outline-danger:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-danger.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);
+}
+
+.btn-outline-light {
+ color: #f8f9fa;
+ border-color: #f8f9fa;
+}
+
+.btn-outline-light:hover {
+ color: #212529;
+ background-color: #f8f9fa;
+ border-color: #f8f9fa;
+}
+
+.btn-outline-light:focus, .btn-outline-light.focus {
+ box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);
+}
+
+.btn-outline-light.disabled, .btn-outline-light:disabled {
+ color: #f8f9fa;
+ background-color: transparent;
+}
+
+.btn-outline-light:not(:disabled):not(.disabled):active, .btn-outline-light:not(:disabled):not(.disabled).active,
+.show > .btn-outline-light.dropdown-toggle {
+ color: #212529;
+ background-color: #f8f9fa;
+ border-color: #f8f9fa;
+}
+
+.btn-outline-light:not(:disabled):not(.disabled):active:focus, .btn-outline-light:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-light.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);
+}
+
+.btn-outline-dark {
+ color: #343a40;
+ border-color: #343a40;
+}
+
+.btn-outline-dark:hover {
+ color: #fff;
+ background-color: #343a40;
+ border-color: #343a40;
+}
+
+.btn-outline-dark:focus, .btn-outline-dark.focus {
+ box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);
+}
+
+.btn-outline-dark.disabled, .btn-outline-dark:disabled {
+ color: #343a40;
+ background-color: transparent;
+}
+
+.btn-outline-dark:not(:disabled):not(.disabled):active, .btn-outline-dark:not(:disabled):not(.disabled).active,
+.show > .btn-outline-dark.dropdown-toggle {
+ color: #fff;
+ background-color: #343a40;
+ border-color: #343a40;
+}
+
+.btn-outline-dark:not(:disabled):not(.disabled):active:focus, .btn-outline-dark:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-dark.dropdown-toggle:focus {
+ box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);
+}
+
+.btn-link {
+ font-weight: 400;
+ color: #007bff;
+ text-decoration: none;
+}
+
+.btn-link:hover {
+ color: #0056b3;
+ text-decoration: underline;
+}
+
+.btn-link:focus, .btn-link.focus {
+ text-decoration: underline;
+}
+
+.btn-link:disabled, .btn-link.disabled {
+ color: #6c757d;
+ pointer-events: none;
+}
+
+.btn-lg, .btn-group-lg > .btn {
+ padding: 0.5rem 1rem;
+ font-size: 1.25rem;
+ line-height: 1.5;
+ border-radius: 0.3rem;
+}
+
+.btn-sm, .btn-group-sm > .btn {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ border-radius: 0.2rem;
+}
+
+.btn-block {
+ display: block;
+ width: 100%;
+}
+
+.btn-block + .btn-block {
+ margin-top: 0.5rem;
+}
+
+input[type="submit"].btn-block,
+input[type="reset"].btn-block,
+input[type="button"].btn-block {
+ width: 100%;
+}
+
+.fade {
+ transition: opacity 0.15s linear;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .fade {
+ transition: none;
+ }
+}
+
+.fade:not(.show) {
+ opacity: 0;
+}
+
+.collapse:not(.show) {
+ display: none;
+}
+
+.collapsing {
+ position: relative;
+ height: 0;
+ overflow: hidden;
+ transition: height 0.35s ease;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .collapsing {
+ transition: none;
+ }
+}
+
+.dropup,
+.dropright,
+.dropdown,
+.dropleft {
+ position: relative;
+}
+
+.dropdown-toggle {
+ white-space: nowrap;
+}
+
+.dropdown-toggle::after {
+ display: inline-block;
+ margin-left: 0.255em;
+ vertical-align: 0.255em;
+ content: "";
+ border-top: 0.3em solid;
+ border-right: 0.3em solid transparent;
+ border-bottom: 0;
+ border-left: 0.3em solid transparent;
+}
+
+.dropdown-toggle:empty::after {
+ margin-left: 0;
+}
+
+.dropdown-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 1000;
+ display: none;
+ float: left;
+ min-width: 10rem;
+ padding: 0.5rem 0;
+ margin: 0.125rem 0 0;
+ font-size: 1rem;
+ color: #212529;
+ text-align: left;
+ list-style: none;
+ background-color: #fff;
+ background-clip: padding-box;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ border-radius: 0.25rem;
+}
+
+.dropdown-menu-left {
+ right: auto;
+ left: 0;
+}
+
+.dropdown-menu-right {
+ right: 0;
+ left: auto;
+}
+
+@media (min-width: 576px) {
+ .dropdown-menu-sm-left {
+ right: auto;
+ left: 0;
+ }
+ .dropdown-menu-sm-right {
+ right: 0;
+ left: auto;
+ }
+}
+
+@media (min-width: 768px) {
+ .dropdown-menu-md-left {
+ right: auto;
+ left: 0;
+ }
+ .dropdown-menu-md-right {
+ right: 0;
+ left: auto;
+ }
+}
+
+@media (min-width: 992px) {
+ .dropdown-menu-lg-left {
+ right: auto;
+ left: 0;
+ }
+ .dropdown-menu-lg-right {
+ right: 0;
+ left: auto;
+ }
+}
+
+@media (min-width: 1200px) {
+ .dropdown-menu-xl-left {
+ right: auto;
+ left: 0;
+ }
+ .dropdown-menu-xl-right {
+ right: 0;
+ left: auto;
+ }
+}
+
+.dropup .dropdown-menu {
+ top: auto;
+ bottom: 100%;
+ margin-top: 0;
+ margin-bottom: 0.125rem;
+}
+
+.dropup .dropdown-toggle::after {
+ display: inline-block;
+ margin-left: 0.255em;
+ vertical-align: 0.255em;
+ content: "";
+ border-top: 0;
+ border-right: 0.3em solid transparent;
+ border-bottom: 0.3em solid;
+ border-left: 0.3em solid transparent;
+}
+
+.dropup .dropdown-toggle:empty::after {
+ margin-left: 0;
+}
+
+.dropright .dropdown-menu {
+ top: 0;
+ right: auto;
+ left: 100%;
+ margin-top: 0;
+ margin-left: 0.125rem;
+}
+
+.dropright .dropdown-toggle::after {
+ display: inline-block;
+ margin-left: 0.255em;
+ vertical-align: 0.255em;
+ content: "";
+ border-top: 0.3em solid transparent;
+ border-right: 0;
+ border-bottom: 0.3em solid transparent;
+ border-left: 0.3em solid;
+}
+
+.dropright .dropdown-toggle:empty::after {
+ margin-left: 0;
+}
+
+.dropright .dropdown-toggle::after {
+ vertical-align: 0;
+}
+
+.dropleft .dropdown-menu {
+ top: 0;
+ right: 100%;
+ left: auto;
+ margin-top: 0;
+ margin-right: 0.125rem;
+}
+
+.dropleft .dropdown-toggle::after {
+ display: inline-block;
+ margin-left: 0.255em;
+ vertical-align: 0.255em;
+ content: "";
+}
+
+.dropleft .dropdown-toggle::after {
+ display: none;
+}
+
+.dropleft .dropdown-toggle::before {
+ display: inline-block;
+ margin-right: 0.255em;
+ vertical-align: 0.255em;
+ content: "";
+ border-top: 0.3em solid transparent;
+ border-right: 0.3em solid;
+ border-bottom: 0.3em solid transparent;
+}
+
+.dropleft .dropdown-toggle:empty::after {
+ margin-left: 0;
+}
+
+.dropleft .dropdown-toggle::before {
+ vertical-align: 0;
+}
+
+.dropdown-menu[x-placement^="top"], .dropdown-menu[x-placement^="right"], .dropdown-menu[x-placement^="bottom"], .dropdown-menu[x-placement^="left"] {
+ right: auto;
+ bottom: auto;
+}
+
+.dropdown-divider {
+ height: 0;
+ margin: 0.5rem 0;
+ overflow: hidden;
+ border-top: 1px solid #e9ecef;
+}
+
+.dropdown-item {
+ display: block;
+ width: 100%;
+ padding: 0.25rem 1.5rem;
+ clear: both;
+ font-weight: 400;
+ color: #212529;
+ text-align: inherit;
+ white-space: nowrap;
+ background-color: transparent;
+ border: 0;
+}
+
+.dropdown-item:hover, .dropdown-item:focus {
+ color: #16181b;
+ text-decoration: none;
+ background-color: #f8f9fa;
+}
+
+.dropdown-item.active, .dropdown-item:active {
+ color: #fff;
+ text-decoration: none;
+ background-color: #007bff;
+}
+
+.dropdown-item.disabled, .dropdown-item:disabled {
+ color: #6c757d;
+ pointer-events: none;
+ background-color: transparent;
+}
+
+.dropdown-menu.show {
+ display: block;
+}
+
+.dropdown-header {
+ display: block;
+ padding: 0.5rem 1.5rem;
+ margin-bottom: 0;
+ font-size: 0.875rem;
+ color: #6c757d;
+ white-space: nowrap;
+}
+
+.dropdown-item-text {
+ display: block;
+ padding: 0.25rem 1.5rem;
+ color: #212529;
+}
+
+.btn-group,
+.btn-group-vertical {
+ position: relative;
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ vertical-align: middle;
+}
+
+.btn-group > .btn,
+.btn-group-vertical > .btn {
+ position: relative;
+ -ms-flex: 1 1 auto;
+ flex: 1 1 auto;
+}
+
+.btn-group > .btn:hover,
+.btn-group-vertical > .btn:hover {
+ z-index: 1;
+}
+
+.btn-group > .btn:focus, .btn-group > .btn:active, .btn-group > .btn.active,
+.btn-group-vertical > .btn:focus,
+.btn-group-vertical > .btn:active,
+.btn-group-vertical > .btn.active {
+ z-index: 1;
+}
+
+.btn-toolbar {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ -ms-flex-pack: start;
+ justify-content: flex-start;
+}
+
+.btn-toolbar .input-group {
+ width: auto;
+}
+
+.btn-group > .btn:not(:first-child),
+.btn-group > .btn-group:not(:first-child) {
+ margin-left: -1px;
+}
+
+.btn-group > .btn:not(:last-child):not(.dropdown-toggle),
+.btn-group > .btn-group:not(:last-child) > .btn {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.btn-group > .btn:not(:first-child),
+.btn-group > .btn-group:not(:first-child) > .btn {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.dropdown-toggle-split {
+ padding-right: 0.5625rem;
+ padding-left: 0.5625rem;
+}
+
+.dropdown-toggle-split::after,
+.dropup .dropdown-toggle-split::after,
+.dropright .dropdown-toggle-split::after {
+ margin-left: 0;
+}
+
+.dropleft .dropdown-toggle-split::before {
+ margin-right: 0;
+}
+
+.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split {
+ padding-right: 0.375rem;
+ padding-left: 0.375rem;
+}
+
+.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split {
+ padding-right: 0.75rem;
+ padding-left: 0.75rem;
+}
+
+.btn-group-vertical {
+ -ms-flex-direction: column;
+ flex-direction: column;
+ -ms-flex-align: start;
+ align-items: flex-start;
+ -ms-flex-pack: center;
+ justify-content: center;
+}
+
+.btn-group-vertical > .btn,
+.btn-group-vertical > .btn-group {
+ width: 100%;
+}
+
+.btn-group-vertical > .btn:not(:first-child),
+.btn-group-vertical > .btn-group:not(:first-child) {
+ margin-top: -1px;
+}
+
+.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle),
+.btn-group-vertical > .btn-group:not(:last-child) > .btn {
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.btn-group-vertical > .btn:not(:first-child),
+.btn-group-vertical > .btn-group:not(:first-child) > .btn {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+
+.btn-group-toggle > .btn,
+.btn-group-toggle > .btn-group > .btn {
+ margin-bottom: 0;
+}
+
+.btn-group-toggle > .btn input[type="radio"],
+.btn-group-toggle > .btn input[type="checkbox"],
+.btn-group-toggle > .btn-group > .btn input[type="radio"],
+.btn-group-toggle > .btn-group > .btn input[type="checkbox"] {
+ position: absolute;
+ clip: rect(0, 0, 0, 0);
+ pointer-events: none;
+}
+
+.input-group {
+ position: relative;
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ -ms-flex-align: stretch;
+ align-items: stretch;
+ width: 100%;
+}
+
+.input-group > .form-control,
+.input-group > .form-control-plaintext,
+.input-group > .custom-select,
+.input-group > .custom-file {
+ position: relative;
+ -ms-flex: 1 1 auto;
+ flex: 1 1 auto;
+ width: 1%;
+ min-width: 0;
+ margin-bottom: 0;
+}
+
+.input-group > .form-control + .form-control,
+.input-group > .form-control + .custom-select,
+.input-group > .form-control + .custom-file,
+.input-group > .form-control-plaintext + .form-control,
+.input-group > .form-control-plaintext + .custom-select,
+.input-group > .form-control-plaintext + .custom-file,
+.input-group > .custom-select + .form-control,
+.input-group > .custom-select + .custom-select,
+.input-group > .custom-select + .custom-file,
+.input-group > .custom-file + .form-control,
+.input-group > .custom-file + .custom-select,
+.input-group > .custom-file + .custom-file {
+ margin-left: -1px;
+}
+
+.input-group > .form-control:focus,
+.input-group > .custom-select:focus,
+.input-group > .custom-file .custom-file-input:focus ~ .custom-file-label {
+ z-index: 3;
+}
+
+.input-group > .custom-file .custom-file-input:focus {
+ z-index: 4;
+}
+
+.input-group > .form-control:not(:last-child),
+.input-group > .custom-select:not(:last-child) {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.input-group > .form-control:not(:first-child),
+.input-group > .custom-select:not(:first-child) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.input-group > .custom-file {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-align: center;
+ align-items: center;
+}
+
+.input-group > .custom-file:not(:last-child) .custom-file-label,
+.input-group > .custom-file:not(:last-child) .custom-file-label::after {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.input-group > .custom-file:not(:first-child) .custom-file-label {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.input-group-prepend,
+.input-group-append {
+ display: -ms-flexbox;
+ display: flex;
+}
+
+.input-group-prepend .btn,
+.input-group-append .btn {
+ position: relative;
+ z-index: 2;
+}
+
+.input-group-prepend .btn:focus,
+.input-group-append .btn:focus {
+ z-index: 3;
+}
+
+.input-group-prepend .btn + .btn,
+.input-group-prepend .btn + .input-group-text,
+.input-group-prepend .input-group-text + .input-group-text,
+.input-group-prepend .input-group-text + .btn,
+.input-group-append .btn + .btn,
+.input-group-append .btn + .input-group-text,
+.input-group-append .input-group-text + .input-group-text,
+.input-group-append .input-group-text + .btn {
+ margin-left: -1px;
+}
+
+.input-group-prepend {
+ margin-right: -1px;
+}
+
+.input-group-append {
+ margin-left: -1px;
+}
+
+.input-group-text {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-align: center;
+ align-items: center;
+ padding: 0.375rem 0.75rem;
+ margin-bottom: 0;
+ font-size: 1rem;
+ font-weight: 400;
+ line-height: 1.5;
+ color: #495057;
+ text-align: center;
+ white-space: nowrap;
+ background-color: #e9ecef;
+ border: 1px solid #ced4da;
+ border-radius: 0.25rem;
+}
+
+.input-group-text input[type="radio"],
+.input-group-text input[type="checkbox"] {
+ margin-top: 0;
+}
+
+.input-group-lg > .form-control:not(textarea),
+.input-group-lg > .custom-select {
+ height: calc(1.5em + 1rem + 2px);
+}
+
+.input-group-lg > .form-control,
+.input-group-lg > .custom-select,
+.input-group-lg > .input-group-prepend > .input-group-text,
+.input-group-lg > .input-group-append > .input-group-text,
+.input-group-lg > .input-group-prepend > .btn,
+.input-group-lg > .input-group-append > .btn {
+ padding: 0.5rem 1rem;
+ font-size: 1.25rem;
+ line-height: 1.5;
+ border-radius: 0.3rem;
+}
+
+.input-group-sm > .form-control:not(textarea),
+.input-group-sm > .custom-select {
+ height: calc(1.5em + 0.5rem + 2px);
+}
+
+.input-group-sm > .form-control,
+.input-group-sm > .custom-select,
+.input-group-sm > .input-group-prepend > .input-group-text,
+.input-group-sm > .input-group-append > .input-group-text,
+.input-group-sm > .input-group-prepend > .btn,
+.input-group-sm > .input-group-append > .btn {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ border-radius: 0.2rem;
+}
+
+.input-group-lg > .custom-select,
+.input-group-sm > .custom-select {
+ padding-right: 1.75rem;
+}
+
+.input-group > .input-group-prepend > .btn,
+.input-group > .input-group-prepend > .input-group-text,
+.input-group > .input-group-append:not(:last-child) > .btn,
+.input-group > .input-group-append:not(:last-child) > .input-group-text,
+.input-group > .input-group-append:last-child > .btn:not(:last-child):not(.dropdown-toggle),
+.input-group > .input-group-append:last-child > .input-group-text:not(:last-child) {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.input-group > .input-group-append > .btn,
+.input-group > .input-group-append > .input-group-text,
+.input-group > .input-group-prepend:not(:first-child) > .btn,
+.input-group > .input-group-prepend:not(:first-child) > .input-group-text,
+.input-group > .input-group-prepend:first-child > .btn:not(:first-child),
+.input-group > .input-group-prepend:first-child > .input-group-text:not(:first-child) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.custom-control {
+ position: relative;
+ display: block;
+ min-height: 1.5rem;
+ padding-left: 1.5rem;
+}
+
+.custom-control-inline {
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ margin-right: 1rem;
+}
+
+.custom-control-input {
+ position: absolute;
+ left: 0;
+ z-index: -1;
+ width: 1rem;
+ height: 1.25rem;
+ opacity: 0;
+}
+
+.custom-control-input:checked ~ .custom-control-label::before {
+ color: #fff;
+ border-color: #007bff;
+ background-color: #007bff;
+}
+
+.custom-control-input:focus ~ .custom-control-label::before {
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.custom-control-input:focus:not(:checked) ~ .custom-control-label::before {
+ border-color: #80bdff;
+}
+
+.custom-control-input:not(:disabled):active ~ .custom-control-label::before {
+ color: #fff;
+ background-color: #b3d7ff;
+ border-color: #b3d7ff;
+}
+
+.custom-control-input[disabled] ~ .custom-control-label, .custom-control-input:disabled ~ .custom-control-label {
+ color: #6c757d;
+}
+
+.custom-control-input[disabled] ~ .custom-control-label::before, .custom-control-input:disabled ~ .custom-control-label::before {
+ background-color: #e9ecef;
+}
+
+.custom-control-label {
+ position: relative;
+ margin-bottom: 0;
+ vertical-align: top;
+}
+
+.custom-control-label::before {
+ position: absolute;
+ top: 0.25rem;
+ left: -1.5rem;
+ display: block;
+ width: 1rem;
+ height: 1rem;
+ pointer-events: none;
+ content: "";
+ background-color: #fff;
+ border: #adb5bd solid 1px;
+}
+
+.custom-control-label::after {
+ position: absolute;
+ top: 0.25rem;
+ left: -1.5rem;
+ display: block;
+ width: 1rem;
+ height: 1rem;
+ content: "";
+ background: no-repeat 50% / 50% 50%;
+}
+
+.custom-checkbox .custom-control-label::before {
+ border-radius: 0.25rem;
+}
+
+.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e");
+}
+
+.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before {
+ border-color: #007bff;
+ background-color: #007bff;
+}
+
+.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e");
+}
+
+.custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before {
+ background-color: rgba(0, 123, 255, 0.5);
+}
+
+.custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before {
+ background-color: rgba(0, 123, 255, 0.5);
+}
+
+.custom-radio .custom-control-label::before {
+ border-radius: 50%;
+}
+
+.custom-radio .custom-control-input:checked ~ .custom-control-label::after {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e");
+}
+
+.custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before {
+ background-color: rgba(0, 123, 255, 0.5);
+}
+
+.custom-switch {
+ padding-left: 2.25rem;
+}
+
+.custom-switch .custom-control-label::before {
+ left: -2.25rem;
+ width: 1.75rem;
+ pointer-events: all;
+ border-radius: 0.5rem;
+}
+
+.custom-switch .custom-control-label::after {
+ top: calc(0.25rem + 2px);
+ left: calc(-2.25rem + 2px);
+ width: calc(1rem - 4px);
+ height: calc(1rem - 4px);
+ background-color: #adb5bd;
+ border-radius: 0.5rem;
+ transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-transform 0.15s ease-in-out;
+ transition: transform 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+ transition: transform 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-transform 0.15s ease-in-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .custom-switch .custom-control-label::after {
+ transition: none;
+ }
+}
+
+.custom-switch .custom-control-input:checked ~ .custom-control-label::after {
+ background-color: #fff;
+ -webkit-transform: translateX(0.75rem);
+ transform: translateX(0.75rem);
+}
+
+.custom-switch .custom-control-input:disabled:checked ~ .custom-control-label::before {
+ background-color: rgba(0, 123, 255, 0.5);
+}
+
+.custom-select {
+ display: inline-block;
+ width: 100%;
+ height: calc(1.5em + 0.75rem + 2px);
+ padding: 0.375rem 1.75rem 0.375rem 0.75rem;
+ font-size: 1rem;
+ font-weight: 400;
+ line-height: 1.5;
+ color: #495057;
+ vertical-align: middle;
+ background: #fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px;
+ border: 1px solid #ced4da;
+ border-radius: 0.25rem;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+}
+
+.custom-select:focus {
+ border-color: #80bdff;
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.custom-select:focus::-ms-value {
+ color: #495057;
+ background-color: #fff;
+}
+
+.custom-select[multiple], .custom-select[size]:not([size="1"]) {
+ height: auto;
+ padding-right: 0.75rem;
+ background-image: none;
+}
+
+.custom-select:disabled {
+ color: #6c757d;
+ background-color: #e9ecef;
+}
+
+.custom-select::-ms-expand {
+ display: none;
+}
+
+.custom-select:-moz-focusring {
+ color: transparent;
+ text-shadow: 0 0 0 #495057;
+}
+
+.custom-select-sm {
+ height: calc(1.5em + 0.5rem + 2px);
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+ padding-left: 0.5rem;
+ font-size: 0.875rem;
+}
+
+.custom-select-lg {
+ height: calc(1.5em + 1rem + 2px);
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ padding-left: 1rem;
+ font-size: 1.25rem;
+}
+
+.custom-file {
+ position: relative;
+ display: inline-block;
+ width: 100%;
+ height: calc(1.5em + 0.75rem + 2px);
+ margin-bottom: 0;
+}
+
+.custom-file-input {
+ position: relative;
+ z-index: 2;
+ width: 100%;
+ height: calc(1.5em + 0.75rem + 2px);
+ margin: 0;
+ opacity: 0;
+}
+
+.custom-file-input:focus ~ .custom-file-label {
+ border-color: #80bdff;
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.custom-file-input[disabled] ~ .custom-file-label,
+.custom-file-input:disabled ~ .custom-file-label {
+ background-color: #e9ecef;
+}
+
+.custom-file-input:lang(en) ~ .custom-file-label::after {
+ content: "Browse";
+}
+
+.custom-file-input ~ .custom-file-label[data-browse]::after {
+ content: attr(data-browse);
+}
+
+.custom-file-label {
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 0;
+ z-index: 1;
+ height: calc(1.5em + 0.75rem + 2px);
+ padding: 0.375rem 0.75rem;
+ font-weight: 400;
+ line-height: 1.5;
+ color: #495057;
+ background-color: #fff;
+ border: 1px solid #ced4da;
+ border-radius: 0.25rem;
+}
+
+.custom-file-label::after {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 3;
+ display: block;
+ height: calc(1.5em + 0.75rem);
+ padding: 0.375rem 0.75rem;
+ line-height: 1.5;
+ color: #495057;
+ content: "Browse";
+ background-color: #e9ecef;
+ border-left: inherit;
+ border-radius: 0 0.25rem 0.25rem 0;
+}
+
+.custom-range {
+ width: 100%;
+ height: 1.4rem;
+ padding: 0;
+ background-color: transparent;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+}
+
+.custom-range:focus {
+ outline: none;
+}
+
+.custom-range:focus::-webkit-slider-thumb {
+ box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.custom-range:focus::-moz-range-thumb {
+ box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.custom-range:focus::-ms-thumb {
+ box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.custom-range::-moz-focus-outer {
+ border: 0;
+}
+
+.custom-range::-webkit-slider-thumb {
+ width: 1rem;
+ height: 1rem;
+ margin-top: -0.25rem;
+ background-color: #007bff;
+ border: 0;
+ border-radius: 1rem;
+ -webkit-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+ transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .custom-range::-webkit-slider-thumb {
+ -webkit-transition: none;
+ transition: none;
+ }
+}
+
+.custom-range::-webkit-slider-thumb:active {
+ background-color: #b3d7ff;
+}
+
+.custom-range::-webkit-slider-runnable-track {
+ width: 100%;
+ height: 0.5rem;
+ color: transparent;
+ cursor: pointer;
+ background-color: #dee2e6;
+ border-color: transparent;
+ border-radius: 1rem;
+}
+
+.custom-range::-moz-range-thumb {
+ width: 1rem;
+ height: 1rem;
+ background-color: #007bff;
+ border: 0;
+ border-radius: 1rem;
+ -moz-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+ transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+ -moz-appearance: none;
+ appearance: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .custom-range::-moz-range-thumb {
+ -moz-transition: none;
+ transition: none;
+ }
+}
+
+.custom-range::-moz-range-thumb:active {
+ background-color: #b3d7ff;
+}
+
+.custom-range::-moz-range-track {
+ width: 100%;
+ height: 0.5rem;
+ color: transparent;
+ cursor: pointer;
+ background-color: #dee2e6;
+ border-color: transparent;
+ border-radius: 1rem;
+}
+
+.custom-range::-ms-thumb {
+ width: 1rem;
+ height: 1rem;
+ margin-top: 0;
+ margin-right: 0.2rem;
+ margin-left: 0.2rem;
+ background-color: #007bff;
+ border: 0;
+ border-radius: 1rem;
+ -ms-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+ transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+ appearance: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .custom-range::-ms-thumb {
+ -ms-transition: none;
+ transition: none;
+ }
+}
+
+.custom-range::-ms-thumb:active {
+ background-color: #b3d7ff;
+}
+
+.custom-range::-ms-track {
+ width: 100%;
+ height: 0.5rem;
+ color: transparent;
+ cursor: pointer;
+ background-color: transparent;
+ border-color: transparent;
+ border-width: 0.5rem;
+}
+
+.custom-range::-ms-fill-lower {
+ background-color: #dee2e6;
+ border-radius: 1rem;
+}
+
+.custom-range::-ms-fill-upper {
+ margin-right: 15px;
+ background-color: #dee2e6;
+ border-radius: 1rem;
+}
+
+.custom-range:disabled::-webkit-slider-thumb {
+ background-color: #adb5bd;
+}
+
+.custom-range:disabled::-webkit-slider-runnable-track {
+ cursor: default;
+}
+
+.custom-range:disabled::-moz-range-thumb {
+ background-color: #adb5bd;
+}
+
+.custom-range:disabled::-moz-range-track {
+ cursor: default;
+}
+
+.custom-range:disabled::-ms-thumb {
+ background-color: #adb5bd;
+}
+
+.custom-control-label::before,
+.custom-file-label,
+.custom-select {
+ transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .custom-control-label::before,
+ .custom-file-label,
+ .custom-select {
+ transition: none;
+ }
+}
+
+.nav {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ padding-left: 0;
+ margin-bottom: 0;
+ list-style: none;
+}
+
+.nav-link {
+ display: block;
+ padding: 0.5rem 1rem;
+}
+
+.nav-link:hover, .nav-link:focus {
+ text-decoration: none;
+}
+
+.nav-link.disabled {
+ color: #6c757d;
+ pointer-events: none;
+ cursor: default;
+}
+
+.nav-tabs {
+ border-bottom: 1px solid #dee2e6;
+}
+
+.nav-tabs .nav-item {
+ margin-bottom: -1px;
+}
+
+.nav-tabs .nav-link {
+ border: 1px solid transparent;
+ border-top-left-radius: 0.25rem;
+ border-top-right-radius: 0.25rem;
+}
+
+.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus {
+ border-color: #e9ecef #e9ecef #dee2e6;
+}
+
+.nav-tabs .nav-link.disabled {
+ color: #6c757d;
+ background-color: transparent;
+ border-color: transparent;
+}
+
+.nav-tabs .nav-link.active,
+.nav-tabs .nav-item.show .nav-link {
+ color: #495057;
+ background-color: #fff;
+ border-color: #dee2e6 #dee2e6 #fff;
+}
+
+.nav-tabs .dropdown-menu {
+ margin-top: -1px;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+
+.nav-pills .nav-link {
+ border-radius: 0.25rem;
+}
+
+.nav-pills .nav-link.active,
+.nav-pills .show > .nav-link {
+ color: #fff;
+ background-color: #007bff;
+}
+
+.nav-fill .nav-item {
+ -ms-flex: 1 1 auto;
+ flex: 1 1 auto;
+ text-align: center;
+}
+
+.nav-justified .nav-item {
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ text-align: center;
+}
+
+.tab-content > .tab-pane {
+ display: none;
+}
+
+.tab-content > .active {
+ display: block;
+}
+
+.navbar {
+ position: relative;
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ -ms-flex-align: center;
+ align-items: center;
+ -ms-flex-pack: justify;
+ justify-content: space-between;
+ padding: 0.5rem 1rem;
+}
+
+.navbar .container,
+.navbar .container-fluid, .navbar .container-sm, .navbar .container-md, .navbar .container-lg, .navbar .container-xl {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ -ms-flex-align: center;
+ align-items: center;
+ -ms-flex-pack: justify;
+ justify-content: space-between;
+}
+
+.navbar-brand {
+ display: inline-block;
+ padding-top: 0.3125rem;
+ padding-bottom: 0.3125rem;
+ margin-right: 1rem;
+ font-size: 1.25rem;
+ line-height: inherit;
+ white-space: nowrap;
+}
+
+.navbar-brand:hover, .navbar-brand:focus {
+ text-decoration: none;
+}
+
+.navbar-nav {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ padding-left: 0;
+ margin-bottom: 0;
+ list-style: none;
+}
+
+.navbar-nav .nav-link {
+ padding-right: 0;
+ padding-left: 0;
+}
+
+.navbar-nav .dropdown-menu {
+ position: static;
+ float: none;
+}
+
+.navbar-text {
+ display: inline-block;
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+}
+
+.navbar-collapse {
+ -ms-flex-preferred-size: 100%;
+ flex-basis: 100%;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ -ms-flex-align: center;
+ align-items: center;
+}
+
+.navbar-toggler {
+ padding: 0.25rem 0.75rem;
+ font-size: 1.25rem;
+ line-height: 1;
+ background-color: transparent;
+ border: 1px solid transparent;
+ border-radius: 0.25rem;
+}
+
+.navbar-toggler:hover, .navbar-toggler:focus {
+ text-decoration: none;
+}
+
+.navbar-toggler-icon {
+ display: inline-block;
+ width: 1.5em;
+ height: 1.5em;
+ vertical-align: middle;
+ content: "";
+ background: no-repeat center center;
+ background-size: 100% 100%;
+}
+
+@media (max-width: 575.98px) {
+ .navbar-expand-sm > .container,
+ .navbar-expand-sm > .container-fluid, .navbar-expand-sm > .container-sm, .navbar-expand-sm > .container-md, .navbar-expand-sm > .container-lg, .navbar-expand-sm > .container-xl {
+ padding-right: 0;
+ padding-left: 0;
+ }
+}
+
+@media (min-width: 576px) {
+ .navbar-expand-sm {
+ -ms-flex-flow: row nowrap;
+ flex-flow: row nowrap;
+ -ms-flex-pack: start;
+ justify-content: flex-start;
+ }
+ .navbar-expand-sm .navbar-nav {
+ -ms-flex-direction: row;
+ flex-direction: row;
+ }
+ .navbar-expand-sm .navbar-nav .dropdown-menu {
+ position: absolute;
+ }
+ .navbar-expand-sm .navbar-nav .nav-link {
+ padding-right: 0.5rem;
+ padding-left: 0.5rem;
+ }
+ .navbar-expand-sm > .container,
+ .navbar-expand-sm > .container-fluid, .navbar-expand-sm > .container-sm, .navbar-expand-sm > .container-md, .navbar-expand-sm > .container-lg, .navbar-expand-sm > .container-xl {
+ -ms-flex-wrap: nowrap;
+ flex-wrap: nowrap;
+ }
+ .navbar-expand-sm .navbar-collapse {
+ display: -ms-flexbox !important;
+ display: flex !important;
+ -ms-flex-preferred-size: auto;
+ flex-basis: auto;
+ }
+ .navbar-expand-sm .navbar-toggler {
+ display: none;
+ }
+}
+
+@media (max-width: 767.98px) {
+ .navbar-expand-md > .container,
+ .navbar-expand-md > .container-fluid, .navbar-expand-md > .container-sm, .navbar-expand-md > .container-md, .navbar-expand-md > .container-lg, .navbar-expand-md > .container-xl {
+ padding-right: 0;
+ padding-left: 0;
+ }
+}
+
+@media (min-width: 768px) {
+ .navbar-expand-md {
+ -ms-flex-flow: row nowrap;
+ flex-flow: row nowrap;
+ -ms-flex-pack: start;
+ justify-content: flex-start;
+ }
+ .navbar-expand-md .navbar-nav {
+ -ms-flex-direction: row;
+ flex-direction: row;
+ }
+ .navbar-expand-md .navbar-nav .dropdown-menu {
+ position: absolute;
+ }
+ .navbar-expand-md .navbar-nav .nav-link {
+ padding-right: 0.5rem;
+ padding-left: 0.5rem;
+ }
+ .navbar-expand-md > .container,
+ .navbar-expand-md > .container-fluid, .navbar-expand-md > .container-sm, .navbar-expand-md > .container-md, .navbar-expand-md > .container-lg, .navbar-expand-md > .container-xl {
+ -ms-flex-wrap: nowrap;
+ flex-wrap: nowrap;
+ }
+ .navbar-expand-md .navbar-collapse {
+ display: -ms-flexbox !important;
+ display: flex !important;
+ -ms-flex-preferred-size: auto;
+ flex-basis: auto;
+ }
+ .navbar-expand-md .navbar-toggler {
+ display: none;
+ }
+}
+
+@media (max-width: 991.98px) {
+ .navbar-expand-lg > .container,
+ .navbar-expand-lg > .container-fluid, .navbar-expand-lg > .container-sm, .navbar-expand-lg > .container-md, .navbar-expand-lg > .container-lg, .navbar-expand-lg > .container-xl {
+ padding-right: 0;
+ padding-left: 0;
+ }
+}
+
+@media (min-width: 992px) {
+ .navbar-expand-lg {
+ -ms-flex-flow: row nowrap;
+ flex-flow: row nowrap;
+ -ms-flex-pack: start;
+ justify-content: flex-start;
+ }
+ .navbar-expand-lg .navbar-nav {
+ -ms-flex-direction: row;
+ flex-direction: row;
+ }
+ .navbar-expand-lg .navbar-nav .dropdown-menu {
+ position: absolute;
+ }
+ .navbar-expand-lg .navbar-nav .nav-link {
+ padding-right: 0.5rem;
+ padding-left: 0.5rem;
+ }
+ .navbar-expand-lg > .container,
+ .navbar-expand-lg > .container-fluid, .navbar-expand-lg > .container-sm, .navbar-expand-lg > .container-md, .navbar-expand-lg > .container-lg, .navbar-expand-lg > .container-xl {
+ -ms-flex-wrap: nowrap;
+ flex-wrap: nowrap;
+ }
+ .navbar-expand-lg .navbar-collapse {
+ display: -ms-flexbox !important;
+ display: flex !important;
+ -ms-flex-preferred-size: auto;
+ flex-basis: auto;
+ }
+ .navbar-expand-lg .navbar-toggler {
+ display: none;
+ }
+}
+
+@media (max-width: 1199.98px) {
+ .navbar-expand-xl > .container,
+ .navbar-expand-xl > .container-fluid, .navbar-expand-xl > .container-sm, .navbar-expand-xl > .container-md, .navbar-expand-xl > .container-lg, .navbar-expand-xl > .container-xl {
+ padding-right: 0;
+ padding-left: 0;
+ }
+}
+
+@media (min-width: 1200px) {
+ .navbar-expand-xl {
+ -ms-flex-flow: row nowrap;
+ flex-flow: row nowrap;
+ -ms-flex-pack: start;
+ justify-content: flex-start;
+ }
+ .navbar-expand-xl .navbar-nav {
+ -ms-flex-direction: row;
+ flex-direction: row;
+ }
+ .navbar-expand-xl .navbar-nav .dropdown-menu {
+ position: absolute;
+ }
+ .navbar-expand-xl .navbar-nav .nav-link {
+ padding-right: 0.5rem;
+ padding-left: 0.5rem;
+ }
+ .navbar-expand-xl > .container,
+ .navbar-expand-xl > .container-fluid, .navbar-expand-xl > .container-sm, .navbar-expand-xl > .container-md, .navbar-expand-xl > .container-lg, .navbar-expand-xl > .container-xl {
+ -ms-flex-wrap: nowrap;
+ flex-wrap: nowrap;
+ }
+ .navbar-expand-xl .navbar-collapse {
+ display: -ms-flexbox !important;
+ display: flex !important;
+ -ms-flex-preferred-size: auto;
+ flex-basis: auto;
+ }
+ .navbar-expand-xl .navbar-toggler {
+ display: none;
+ }
+}
+
+.navbar-expand {
+ -ms-flex-flow: row nowrap;
+ flex-flow: row nowrap;
+ -ms-flex-pack: start;
+ justify-content: flex-start;
+}
+
+.navbar-expand > .container,
+.navbar-expand > .container-fluid, .navbar-expand > .container-sm, .navbar-expand > .container-md, .navbar-expand > .container-lg, .navbar-expand > .container-xl {
+ padding-right: 0;
+ padding-left: 0;
+}
+
+.navbar-expand .navbar-nav {
+ -ms-flex-direction: row;
+ flex-direction: row;
+}
+
+.navbar-expand .navbar-nav .dropdown-menu {
+ position: absolute;
+}
+
+.navbar-expand .navbar-nav .nav-link {
+ padding-right: 0.5rem;
+ padding-left: 0.5rem;
+}
+
+.navbar-expand > .container,
+.navbar-expand > .container-fluid, .navbar-expand > .container-sm, .navbar-expand > .container-md, .navbar-expand > .container-lg, .navbar-expand > .container-xl {
+ -ms-flex-wrap: nowrap;
+ flex-wrap: nowrap;
+}
+
+.navbar-expand .navbar-collapse {
+ display: -ms-flexbox !important;
+ display: flex !important;
+ -ms-flex-preferred-size: auto;
+ flex-basis: auto;
+}
+
+.navbar-expand .navbar-toggler {
+ display: none;
+}
+
+.navbar-light .navbar-brand {
+ color: rgba(0, 0, 0, 0.9);
+}
+
+.navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:focus {
+ color: rgba(0, 0, 0, 0.9);
+}
+
+.navbar-light .navbar-nav .nav-link {
+ color: rgba(0, 0, 0, 0.5);
+}
+
+.navbar-light .navbar-nav .nav-link:hover, .navbar-light .navbar-nav .nav-link:focus {
+ color: rgba(0, 0, 0, 0.7);
+}
+
+.navbar-light .navbar-nav .nav-link.disabled {
+ color: rgba(0, 0, 0, 0.3);
+}
+
+.navbar-light .navbar-nav .show > .nav-link,
+.navbar-light .navbar-nav .active > .nav-link,
+.navbar-light .navbar-nav .nav-link.show,
+.navbar-light .navbar-nav .nav-link.active {
+ color: rgba(0, 0, 0, 0.9);
+}
+
+.navbar-light .navbar-toggler {
+ color: rgba(0, 0, 0, 0.5);
+ border-color: rgba(0, 0, 0, 0.1);
+}
+
+.navbar-light .navbar-toggler-icon {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
+}
+
+.navbar-light .navbar-text {
+ color: rgba(0, 0, 0, 0.5);
+}
+
+.navbar-light .navbar-text a {
+ color: rgba(0, 0, 0, 0.9);
+}
+
+.navbar-light .navbar-text a:hover, .navbar-light .navbar-text a:focus {
+ color: rgba(0, 0, 0, 0.9);
+}
+
+.navbar-dark .navbar-brand {
+ color: #fff;
+}
+
+.navbar-dark .navbar-brand:hover, .navbar-dark .navbar-brand:focus {
+ color: #fff;
+}
+
+.navbar-dark .navbar-nav .nav-link {
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.navbar-dark .navbar-nav .nav-link:hover, .navbar-dark .navbar-nav .nav-link:focus {
+ color: rgba(255, 255, 255, 0.75);
+}
+
+.navbar-dark .navbar-nav .nav-link.disabled {
+ color: rgba(255, 255, 255, 0.25);
+}
+
+.navbar-dark .navbar-nav .show > .nav-link,
+.navbar-dark .navbar-nav .active > .nav-link,
+.navbar-dark .navbar-nav .nav-link.show,
+.navbar-dark .navbar-nav .nav-link.active {
+ color: #fff;
+}
+
+.navbar-dark .navbar-toggler {
+ color: rgba(255, 255, 255, 0.5);
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+.navbar-dark .navbar-toggler-icon {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
+}
+
+.navbar-dark .navbar-text {
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.navbar-dark .navbar-text a {
+ color: #fff;
+}
+
+.navbar-dark .navbar-text a:hover, .navbar-dark .navbar-text a:focus {
+ color: #fff;
+}
+
+.card {
+ position: relative;
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ min-width: 0;
+ word-wrap: break-word;
+ background-color: #fff;
+ background-clip: border-box;
+ border: 1px solid rgba(0, 0, 0, 0.125);
+ border-radius: 0.25rem;
+}
+
+.card > hr {
+ margin-right: 0;
+ margin-left: 0;
+}
+
+.card > .list-group {
+ border-top: inherit;
+ border-bottom: inherit;
+}
+
+.card > .list-group:first-child {
+ border-top-width: 0;
+ border-top-left-radius: calc(0.25rem - 1px);
+ border-top-right-radius: calc(0.25rem - 1px);
+}
+
+.card > .list-group:last-child {
+ border-bottom-width: 0;
+ border-bottom-right-radius: calc(0.25rem - 1px);
+ border-bottom-left-radius: calc(0.25rem - 1px);
+}
+
+.card-body {
+ -ms-flex: 1 1 auto;
+ flex: 1 1 auto;
+ min-height: 1px;
+ padding: 1.25rem;
+}
+
+.card-title {
+ margin-bottom: 0.75rem;
+}
+
+.card-subtitle {
+ margin-top: -0.375rem;
+ margin-bottom: 0;
+}
+
+.card-text:last-child {
+ margin-bottom: 0;
+}
+
+.card-link:hover {
+ text-decoration: none;
+}
+
+.card-link + .card-link {
+ margin-left: 1.25rem;
+}
+
+.card-header {
+ padding: 0.75rem 1.25rem;
+ margin-bottom: 0;
+ background-color: rgba(0, 0, 0, 0.03);
+ border-bottom: 1px solid rgba(0, 0, 0, 0.125);
+}
+
+.card-header:first-child {
+ border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0;
+}
+
+.card-header + .list-group .list-group-item:first-child {
+ border-top: 0;
+}
+
+.card-footer {
+ padding: 0.75rem 1.25rem;
+ background-color: rgba(0, 0, 0, 0.03);
+ border-top: 1px solid rgba(0, 0, 0, 0.125);
+}
+
+.card-footer:last-child {
+ border-radius: 0 0 calc(0.25rem - 1px) calc(0.25rem - 1px);
+}
+
+.card-header-tabs {
+ margin-right: -0.625rem;
+ margin-bottom: -0.75rem;
+ margin-left: -0.625rem;
+ border-bottom: 0;
+}
+
+.card-header-pills {
+ margin-right: -0.625rem;
+ margin-left: -0.625rem;
+}
+
+.card-img-overlay {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ padding: 1.25rem;
+}
+
+.card-img,
+.card-img-top,
+.card-img-bottom {
+ -ms-flex-negative: 0;
+ flex-shrink: 0;
+ width: 100%;
+}
+
+.card-img,
+.card-img-top {
+ border-top-left-radius: calc(0.25rem - 1px);
+ border-top-right-radius: calc(0.25rem - 1px);
+}
+
+.card-img,
+.card-img-bottom {
+ border-bottom-right-radius: calc(0.25rem - 1px);
+ border-bottom-left-radius: calc(0.25rem - 1px);
+}
+
+.card-deck .card {
+ margin-bottom: 15px;
+}
+
+@media (min-width: 576px) {
+ .card-deck {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-flow: row wrap;
+ flex-flow: row wrap;
+ margin-right: -15px;
+ margin-left: -15px;
+ }
+ .card-deck .card {
+ -ms-flex: 1 0 0%;
+ flex: 1 0 0%;
+ margin-right: 15px;
+ margin-bottom: 0;
+ margin-left: 15px;
+ }
+}
+
+.card-group > .card {
+ margin-bottom: 15px;
+}
+
+@media (min-width: 576px) {
+ .card-group {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-flow: row wrap;
+ flex-flow: row wrap;
+ }
+ .card-group > .card {
+ -ms-flex: 1 0 0%;
+ flex: 1 0 0%;
+ margin-bottom: 0;
+ }
+ .card-group > .card + .card {
+ margin-left: 0;
+ border-left: 0;
+ }
+ .card-group > .card:not(:last-child) {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+ .card-group > .card:not(:last-child) .card-img-top,
+ .card-group > .card:not(:last-child) .card-header {
+ border-top-right-radius: 0;
+ }
+ .card-group > .card:not(:last-child) .card-img-bottom,
+ .card-group > .card:not(:last-child) .card-footer {
+ border-bottom-right-radius: 0;
+ }
+ .card-group > .card:not(:first-child) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+ .card-group > .card:not(:first-child) .card-img-top,
+ .card-group > .card:not(:first-child) .card-header {
+ border-top-left-radius: 0;
+ }
+ .card-group > .card:not(:first-child) .card-img-bottom,
+ .card-group > .card:not(:first-child) .card-footer {
+ border-bottom-left-radius: 0;
+ }
+}
+
+.card-columns .card {
+ margin-bottom: 0.75rem;
+}
+
+@media (min-width: 576px) {
+ .card-columns {
+ -webkit-column-count: 3;
+ -moz-column-count: 3;
+ column-count: 3;
+ -webkit-column-gap: 1.25rem;
+ -moz-column-gap: 1.25rem;
+ column-gap: 1.25rem;
+ orphans: 1;
+ widows: 1;
+ }
+ .card-columns .card {
+ display: inline-block;
+ width: 100%;
+ }
+}
+
+.accordion > .card {
+ overflow: hidden;
+}
+
+.accordion > .card:not(:last-of-type) {
+ border-bottom: 0;
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.accordion > .card:not(:first-of-type) {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+
+.accordion > .card > .card-header {
+ border-radius: 0;
+ margin-bottom: -1px;
+}
+
+.breadcrumb {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ list-style: none;
+ background-color: #e9ecef;
+ border-radius: 0.25rem;
+}
+
+.breadcrumb-item {
+ display: -ms-flexbox;
+ display: flex;
+}
+
+.breadcrumb-item + .breadcrumb-item {
+ padding-left: 0.5rem;
+}
+
+.breadcrumb-item + .breadcrumb-item::before {
+ display: inline-block;
+ padding-right: 0.5rem;
+ color: #6c757d;
+ content: "/";
+}
+
+.breadcrumb-item + .breadcrumb-item:hover::before {
+ text-decoration: underline;
+}
+
+.breadcrumb-item + .breadcrumb-item:hover::before {
+ text-decoration: none;
+}
+
+.breadcrumb-item.active {
+ color: #6c757d;
+}
+
+.pagination {
+ display: -ms-flexbox;
+ display: flex;
+ padding-left: 0;
+ list-style: none;
+ border-radius: 0.25rem;
+}
+
+.page-link {
+ position: relative;
+ display: block;
+ padding: 0.5rem 0.75rem;
+ margin-left: -1px;
+ line-height: 1.25;
+ color: #007bff;
+ background-color: #fff;
+ border: 1px solid #dee2e6;
+}
+
+.page-link:hover {
+ z-index: 2;
+ color: #0056b3;
+ text-decoration: none;
+ background-color: #e9ecef;
+ border-color: #dee2e6;
+}
+
+.page-link:focus {
+ z-index: 3;
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.page-item:first-child .page-link {
+ margin-left: 0;
+ border-top-left-radius: 0.25rem;
+ border-bottom-left-radius: 0.25rem;
+}
+
+.page-item:last-child .page-link {
+ border-top-right-radius: 0.25rem;
+ border-bottom-right-radius: 0.25rem;
+}
+
+.page-item.active .page-link {
+ z-index: 3;
+ color: #fff;
+ background-color: #007bff;
+ border-color: #007bff;
+}
+
+.page-item.disabled .page-link {
+ color: #6c757d;
+ pointer-events: none;
+ cursor: auto;
+ background-color: #fff;
+ border-color: #dee2e6;
+}
+
+.pagination-lg .page-link {
+ padding: 0.75rem 1.5rem;
+ font-size: 1.25rem;
+ line-height: 1.5;
+}
+
+.pagination-lg .page-item:first-child .page-link {
+ border-top-left-radius: 0.3rem;
+ border-bottom-left-radius: 0.3rem;
+}
+
+.pagination-lg .page-item:last-child .page-link {
+ border-top-right-radius: 0.3rem;
+ border-bottom-right-radius: 0.3rem;
+}
+
+.pagination-sm .page-link {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+
+.pagination-sm .page-item:first-child .page-link {
+ border-top-left-radius: 0.2rem;
+ border-bottom-left-radius: 0.2rem;
+}
+
+.pagination-sm .page-item:last-child .page-link {
+ border-top-right-radius: 0.2rem;
+ border-bottom-right-radius: 0.2rem;
+}
+
+.badge {
+ display: inline-block;
+ padding: 0.25em 0.4em;
+ font-size: 75%;
+ font-weight: 700;
+ line-height: 1;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: baseline;
+ border-radius: 0.25rem;
+ transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .badge {
+ transition: none;
+ }
+}
+
+a.badge:hover, a.badge:focus {
+ text-decoration: none;
+}
+
+.badge:empty {
+ display: none;
+}
+
+.btn .badge {
+ position: relative;
+ top: -1px;
+}
+
+.badge-pill {
+ padding-right: 0.6em;
+ padding-left: 0.6em;
+ border-radius: 10rem;
+}
+
+.badge-primary {
+ color: #fff;
+ background-color: #007bff;
+}
+
+a.badge-primary:hover, a.badge-primary:focus {
+ color: #fff;
+ background-color: #0062cc;
+}
+
+a.badge-primary:focus, a.badge-primary.focus {
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);
+}
+
+.badge-secondary {
+ color: #fff;
+ background-color: #6c757d;
+}
+
+a.badge-secondary:hover, a.badge-secondary:focus {
+ color: #fff;
+ background-color: #545b62;
+}
+
+a.badge-secondary:focus, a.badge-secondary.focus {
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);
+}
+
+.badge-success {
+ color: #fff;
+ background-color: #28a745;
+}
+
+a.badge-success:hover, a.badge-success:focus {
+ color: #fff;
+ background-color: #1e7e34;
+}
+
+a.badge-success:focus, a.badge-success.focus {
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);
+}
+
+.badge-info {
+ color: #fff;
+ background-color: #17a2b8;
+}
+
+a.badge-info:hover, a.badge-info:focus {
+ color: #fff;
+ background-color: #117a8b;
+}
+
+a.badge-info:focus, a.badge-info.focus {
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);
+}
+
+.badge-warning {
+ color: #212529;
+ background-color: #ffc107;
+}
+
+a.badge-warning:hover, a.badge-warning:focus {
+ color: #212529;
+ background-color: #d39e00;
+}
+
+a.badge-warning:focus, a.badge-warning.focus {
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);
+}
+
+.badge-danger {
+ color: #fff;
+ background-color: #dc3545;
+}
+
+a.badge-danger:hover, a.badge-danger:focus {
+ color: #fff;
+ background-color: #bd2130;
+}
+
+a.badge-danger:focus, a.badge-danger.focus {
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);
+}
+
+.badge-light {
+ color: #212529;
+ background-color: #f8f9fa;
+}
+
+a.badge-light:hover, a.badge-light:focus {
+ color: #212529;
+ background-color: #dae0e5;
+}
+
+a.badge-light:focus, a.badge-light.focus {
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);
+}
+
+.badge-dark {
+ color: #fff;
+ background-color: #343a40;
+}
+
+a.badge-dark:hover, a.badge-dark:focus {
+ color: #fff;
+ background-color: #1d2124;
+}
+
+a.badge-dark:focus, a.badge-dark.focus {
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);
+}
+
+.jumbotron {
+ padding: 2rem 1rem;
+ margin-bottom: 2rem;
+ background-color: #e9ecef;
+ border-radius: 0.3rem;
+}
+
+@media (min-width: 576px) {
+ .jumbotron {
+ padding: 4rem 2rem;
+ }
+}
+
+.jumbotron-fluid {
+ padding-right: 0;
+ padding-left: 0;
+ border-radius: 0;
+}
+
+.alert {
+ position: relative;
+ padding: 0.75rem 1.25rem;
+ margin-bottom: 1rem;
+ border: 1px solid transparent;
+ border-radius: 0.25rem;
+}
+
+.alert-heading {
+ color: inherit;
+}
+
+.alert-link {
+ font-weight: 700;
+}
+
+.alert-dismissible {
+ padding-right: 4rem;
+}
+
+.alert-dismissible .close {
+ position: absolute;
+ top: 0;
+ right: 0;
+ padding: 0.75rem 1.25rem;
+ color: inherit;
+}
+
+.alert-primary {
+ color: #004085;
+ background-color: #cce5ff;
+ border-color: #b8daff;
+}
+
+.alert-primary hr {
+ border-top-color: #9fcdff;
+}
+
+.alert-primary .alert-link {
+ color: #002752;
+}
+
+.alert-secondary {
+ color: #383d41;
+ background-color: #e2e3e5;
+ border-color: #d6d8db;
+}
+
+.alert-secondary hr {
+ border-top-color: #c8cbcf;
+}
+
+.alert-secondary .alert-link {
+ color: #202326;
+}
+
+.alert-success {
+ color: #155724;
+ background-color: #d4edda;
+ border-color: #c3e6cb;
+}
+
+.alert-success hr {
+ border-top-color: #b1dfbb;
+}
+
+.alert-success .alert-link {
+ color: #0b2e13;
+}
+
+.alert-info {
+ color: #0c5460;
+ background-color: #d1ecf1;
+ border-color: #bee5eb;
+}
+
+.alert-info hr {
+ border-top-color: #abdde5;
+}
+
+.alert-info .alert-link {
+ color: #062c33;
+}
+
+.alert-warning {
+ color: #856404;
+ background-color: #fff3cd;
+ border-color: #ffeeba;
+}
+
+.alert-warning hr {
+ border-top-color: #ffe8a1;
+}
+
+.alert-warning .alert-link {
+ color: #533f03;
+}
+
+.alert-danger {
+ color: #721c24;
+ background-color: #f8d7da;
+ border-color: #f5c6cb;
+}
+
+.alert-danger hr {
+ border-top-color: #f1b0b7;
+}
+
+.alert-danger .alert-link {
+ color: #491217;
+}
+
+.alert-light {
+ color: #818182;
+ background-color: #fefefe;
+ border-color: #fdfdfe;
+}
+
+.alert-light hr {
+ border-top-color: #ececf6;
+}
+
+.alert-light .alert-link {
+ color: #686868;
+}
+
+.alert-dark {
+ color: #1b1e21;
+ background-color: #d6d8d9;
+ border-color: #c6c8ca;
+}
+
+.alert-dark hr {
+ border-top-color: #b9bbbe;
+}
+
+.alert-dark .alert-link {
+ color: #040505;
+}
+
+@-webkit-keyframes progress-bar-stripes {
+ from {
+ background-position: 1rem 0;
+ }
+ to {
+ background-position: 0 0;
+ }
+}
+
+@keyframes progress-bar-stripes {
+ from {
+ background-position: 1rem 0;
+ }
+ to {
+ background-position: 0 0;
+ }
+}
+
+.progress {
+ display: -ms-flexbox;
+ display: flex;
+ height: 1rem;
+ overflow: hidden;
+ line-height: 0;
+ font-size: 0.75rem;
+ background-color: #e9ecef;
+ border-radius: 0.25rem;
+}
+
+.progress-bar {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ -ms-flex-pack: center;
+ justify-content: center;
+ overflow: hidden;
+ color: #fff;
+ text-align: center;
+ white-space: nowrap;
+ background-color: #007bff;
+ transition: width 0.6s ease;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .progress-bar {
+ transition: none;
+ }
+}
+
+.progress-bar-striped {
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+ background-size: 1rem 1rem;
+}
+
+.progress-bar-animated {
+ -webkit-animation: progress-bar-stripes 1s linear infinite;
+ animation: progress-bar-stripes 1s linear infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .progress-bar-animated {
+ -webkit-animation: none;
+ animation: none;
+ }
+}
+
+.media {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-align: start;
+ align-items: flex-start;
+}
+
+.media-body {
+ -ms-flex: 1;
+ flex: 1;
+}
+
+.list-group {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ padding-left: 0;
+ margin-bottom: 0;
+ border-radius: 0.25rem;
+}
+
+.list-group-item-action {
+ width: 100%;
+ color: #495057;
+ text-align: inherit;
+}
+
+.list-group-item-action:hover, .list-group-item-action:focus {
+ z-index: 1;
+ color: #495057;
+ text-decoration: none;
+ background-color: #f8f9fa;
+}
+
+.list-group-item-action:active {
+ color: #212529;
+ background-color: #e9ecef;
+}
+
+.list-group-item {
+ position: relative;
+ display: block;
+ padding: 0.75rem 1.25rem;
+ background-color: #fff;
+ border: 1px solid rgba(0, 0, 0, 0.125);
+}
+
+.list-group-item:first-child {
+ border-top-left-radius: inherit;
+ border-top-right-radius: inherit;
+}
+
+.list-group-item:last-child {
+ border-bottom-right-radius: inherit;
+ border-bottom-left-radius: inherit;
+}
+
+.list-group-item.disabled, .list-group-item:disabled {
+ color: #6c757d;
+ pointer-events: none;
+ background-color: #fff;
+}
+
+.list-group-item.active {
+ z-index: 2;
+ color: #fff;
+ background-color: #007bff;
+ border-color: #007bff;
+}
+
+.list-group-item + .list-group-item {
+ border-top-width: 0;
+}
+
+.list-group-item + .list-group-item.active {
+ margin-top: -1px;
+ border-top-width: 1px;
+}
+
+.list-group-horizontal {
+ -ms-flex-direction: row;
+ flex-direction: row;
+}
+
+.list-group-horizontal > .list-group-item:first-child {
+ border-bottom-left-radius: 0.25rem;
+ border-top-right-radius: 0;
+}
+
+.list-group-horizontal > .list-group-item:last-child {
+ border-top-right-radius: 0.25rem;
+ border-bottom-left-radius: 0;
+}
+
+.list-group-horizontal > .list-group-item.active {
+ margin-top: 0;
+}
+
+.list-group-horizontal > .list-group-item + .list-group-item {
+ border-top-width: 1px;
+ border-left-width: 0;
+}
+
+.list-group-horizontal > .list-group-item + .list-group-item.active {
+ margin-left: -1px;
+ border-left-width: 1px;
+}
+
+@media (min-width: 576px) {
+ .list-group-horizontal-sm {
+ -ms-flex-direction: row;
+ flex-direction: row;
+ }
+ .list-group-horizontal-sm > .list-group-item:first-child {
+ border-bottom-left-radius: 0.25rem;
+ border-top-right-radius: 0;
+ }
+ .list-group-horizontal-sm > .list-group-item:last-child {
+ border-top-right-radius: 0.25rem;
+ border-bottom-left-radius: 0;
+ }
+ .list-group-horizontal-sm > .list-group-item.active {
+ margin-top: 0;
+ }
+ .list-group-horizontal-sm > .list-group-item + .list-group-item {
+ border-top-width: 1px;
+ border-left-width: 0;
+ }
+ .list-group-horizontal-sm > .list-group-item + .list-group-item.active {
+ margin-left: -1px;
+ border-left-width: 1px;
+ }
+}
+
+@media (min-width: 768px) {
+ .list-group-horizontal-md {
+ -ms-flex-direction: row;
+ flex-direction: row;
+ }
+ .list-group-horizontal-md > .list-group-item:first-child {
+ border-bottom-left-radius: 0.25rem;
+ border-top-right-radius: 0;
+ }
+ .list-group-horizontal-md > .list-group-item:last-child {
+ border-top-right-radius: 0.25rem;
+ border-bottom-left-radius: 0;
+ }
+ .list-group-horizontal-md > .list-group-item.active {
+ margin-top: 0;
+ }
+ .list-group-horizontal-md > .list-group-item + .list-group-item {
+ border-top-width: 1px;
+ border-left-width: 0;
+ }
+ .list-group-horizontal-md > .list-group-item + .list-group-item.active {
+ margin-left: -1px;
+ border-left-width: 1px;
+ }
+}
+
+@media (min-width: 992px) {
+ .list-group-horizontal-lg {
+ -ms-flex-direction: row;
+ flex-direction: row;
+ }
+ .list-group-horizontal-lg > .list-group-item:first-child {
+ border-bottom-left-radius: 0.25rem;
+ border-top-right-radius: 0;
+ }
+ .list-group-horizontal-lg > .list-group-item:last-child {
+ border-top-right-radius: 0.25rem;
+ border-bottom-left-radius: 0;
+ }
+ .list-group-horizontal-lg > .list-group-item.active {
+ margin-top: 0;
+ }
+ .list-group-horizontal-lg > .list-group-item + .list-group-item {
+ border-top-width: 1px;
+ border-left-width: 0;
+ }
+ .list-group-horizontal-lg > .list-group-item + .list-group-item.active {
+ margin-left: -1px;
+ border-left-width: 1px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .list-group-horizontal-xl {
+ -ms-flex-direction: row;
+ flex-direction: row;
+ }
+ .list-group-horizontal-xl > .list-group-item:first-child {
+ border-bottom-left-radius: 0.25rem;
+ border-top-right-radius: 0;
+ }
+ .list-group-horizontal-xl > .list-group-item:last-child {
+ border-top-right-radius: 0.25rem;
+ border-bottom-left-radius: 0;
+ }
+ .list-group-horizontal-xl > .list-group-item.active {
+ margin-top: 0;
+ }
+ .list-group-horizontal-xl > .list-group-item + .list-group-item {
+ border-top-width: 1px;
+ border-left-width: 0;
+ }
+ .list-group-horizontal-xl > .list-group-item + .list-group-item.active {
+ margin-left: -1px;
+ border-left-width: 1px;
+ }
+}
+
+.list-group-flush {
+ border-radius: 0;
+}
+
+.list-group-flush > .list-group-item {
+ border-width: 0 0 1px;
+}
+
+.list-group-flush > .list-group-item:last-child {
+ border-bottom-width: 0;
+}
+
+.list-group-item-primary {
+ color: #004085;
+ background-color: #b8daff;
+}
+
+.list-group-item-primary.list-group-item-action:hover, .list-group-item-primary.list-group-item-action:focus {
+ color: #004085;
+ background-color: #9fcdff;
+}
+
+.list-group-item-primary.list-group-item-action.active {
+ color: #fff;
+ background-color: #004085;
+ border-color: #004085;
+}
+
+.list-group-item-secondary {
+ color: #383d41;
+ background-color: #d6d8db;
+}
+
+.list-group-item-secondary.list-group-item-action:hover, .list-group-item-secondary.list-group-item-action:focus {
+ color: #383d41;
+ background-color: #c8cbcf;
+}
+
+.list-group-item-secondary.list-group-item-action.active {
+ color: #fff;
+ background-color: #383d41;
+ border-color: #383d41;
+}
+
+.list-group-item-success {
+ color: #155724;
+ background-color: #c3e6cb;
+}
+
+.list-group-item-success.list-group-item-action:hover, .list-group-item-success.list-group-item-action:focus {
+ color: #155724;
+ background-color: #b1dfbb;
+}
+
+.list-group-item-success.list-group-item-action.active {
+ color: #fff;
+ background-color: #155724;
+ border-color: #155724;
+}
+
+.list-group-item-info {
+ color: #0c5460;
+ background-color: #bee5eb;
+}
+
+.list-group-item-info.list-group-item-action:hover, .list-group-item-info.list-group-item-action:focus {
+ color: #0c5460;
+ background-color: #abdde5;
+}
+
+.list-group-item-info.list-group-item-action.active {
+ color: #fff;
+ background-color: #0c5460;
+ border-color: #0c5460;
+}
+
+.list-group-item-warning {
+ color: #856404;
+ background-color: #ffeeba;
+}
+
+.list-group-item-warning.list-group-item-action:hover, .list-group-item-warning.list-group-item-action:focus {
+ color: #856404;
+ background-color: #ffe8a1;
+}
+
+.list-group-item-warning.list-group-item-action.active {
+ color: #fff;
+ background-color: #856404;
+ border-color: #856404;
+}
+
+.list-group-item-danger {
+ color: #721c24;
+ background-color: #f5c6cb;
+}
+
+.list-group-item-danger.list-group-item-action:hover, .list-group-item-danger.list-group-item-action:focus {
+ color: #721c24;
+ background-color: #f1b0b7;
+}
+
+.list-group-item-danger.list-group-item-action.active {
+ color: #fff;
+ background-color: #721c24;
+ border-color: #721c24;
+}
+
+.list-group-item-light {
+ color: #818182;
+ background-color: #fdfdfe;
+}
+
+.list-group-item-light.list-group-item-action:hover, .list-group-item-light.list-group-item-action:focus {
+ color: #818182;
+ background-color: #ececf6;
+}
+
+.list-group-item-light.list-group-item-action.active {
+ color: #fff;
+ background-color: #818182;
+ border-color: #818182;
+}
+
+.list-group-item-dark {
+ color: #1b1e21;
+ background-color: #c6c8ca;
+}
+
+.list-group-item-dark.list-group-item-action:hover, .list-group-item-dark.list-group-item-action:focus {
+ color: #1b1e21;
+ background-color: #b9bbbe;
+}
+
+.list-group-item-dark.list-group-item-action.active {
+ color: #fff;
+ background-color: #1b1e21;
+ border-color: #1b1e21;
+}
+
+.close {
+ float: right;
+ font-size: 1.5rem;
+ font-weight: 700;
+ line-height: 1;
+ color: #000;
+ text-shadow: 0 1px 0 #fff;
+ opacity: .5;
+}
+
+.close:hover {
+ color: #000;
+ text-decoration: none;
+}
+
+.close:not(:disabled):not(.disabled):hover, .close:not(:disabled):not(.disabled):focus {
+ opacity: .75;
+}
+
+button.close {
+ padding: 0;
+ background-color: transparent;
+ border: 0;
+}
+
+a.close.disabled {
+ pointer-events: none;
+}
+
+.toast {
+ max-width: 350px;
+ overflow: hidden;
+ font-size: 0.875rem;
+ background-color: rgba(255, 255, 255, 0.85);
+ background-clip: padding-box;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
+ -webkit-backdrop-filter: blur(10px);
+ backdrop-filter: blur(10px);
+ opacity: 0;
+ border-radius: 0.25rem;
+}
+
+.toast:not(:last-child) {
+ margin-bottom: 0.75rem;
+}
+
+.toast.showing {
+ opacity: 1;
+}
+
+.toast.show {
+ display: block;
+ opacity: 1;
+}
+
+.toast.hide {
+ display: none;
+}
+
+.toast-header {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-align: center;
+ align-items: center;
+ padding: 0.25rem 0.75rem;
+ color: #6c757d;
+ background-color: rgba(255, 255, 255, 0.85);
+ background-clip: padding-box;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+.toast-body {
+ padding: 0.75rem;
+}
+
+.modal-open {
+ overflow: hidden;
+}
+
+.modal-open .modal {
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 1050;
+ display: none;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ outline: 0;
+}
+
+.modal-dialog {
+ position: relative;
+ width: auto;
+ margin: 0.5rem;
+ pointer-events: none;
+}
+
+.modal.fade .modal-dialog {
+ transition: -webkit-transform 0.3s ease-out;
+ transition: transform 0.3s ease-out;
+ transition: transform 0.3s ease-out, -webkit-transform 0.3s ease-out;
+ -webkit-transform: translate(0, -50px);
+ transform: translate(0, -50px);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .modal.fade .modal-dialog {
+ transition: none;
+ }
+}
+
+.modal.show .modal-dialog {
+ -webkit-transform: none;
+ transform: none;
+}
+
+.modal.modal-static .modal-dialog {
+ -webkit-transform: scale(1.02);
+ transform: scale(1.02);
+}
+
+.modal-dialog-scrollable {
+ display: -ms-flexbox;
+ display: flex;
+ max-height: calc(100% - 1rem);
+}
+
+.modal-dialog-scrollable .modal-content {
+ max-height: calc(100vh - 1rem);
+ overflow: hidden;
+}
+
+.modal-dialog-scrollable .modal-header,
+.modal-dialog-scrollable .modal-footer {
+ -ms-flex-negative: 0;
+ flex-shrink: 0;
+}
+
+.modal-dialog-scrollable .modal-body {
+ overflow-y: auto;
+}
+
+.modal-dialog-centered {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-align: center;
+ align-items: center;
+ min-height: calc(100% - 1rem);
+}
+
+.modal-dialog-centered::before {
+ display: block;
+ height: calc(100vh - 1rem);
+ height: -webkit-min-content;
+ height: -moz-min-content;
+ height: min-content;
+ content: "";
+}
+
+.modal-dialog-centered.modal-dialog-scrollable {
+ -ms-flex-direction: column;
+ flex-direction: column;
+ -ms-flex-pack: center;
+ justify-content: center;
+ height: 100%;
+}
+
+.modal-dialog-centered.modal-dialog-scrollable .modal-content {
+ max-height: none;
+}
+
+.modal-dialog-centered.modal-dialog-scrollable::before {
+ content: none;
+}
+
+.modal-content {
+ position: relative;
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ width: 100%;
+ pointer-events: auto;
+ background-color: #fff;
+ background-clip: padding-box;
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ border-radius: 0.3rem;
+ outline: 0;
+}
+
+.modal-backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 1040;
+ width: 100vw;
+ height: 100vh;
+ background-color: #000;
+}
+
+.modal-backdrop.fade {
+ opacity: 0;
+}
+
+.modal-backdrop.show {
+ opacity: 0.5;
+}
+
+.modal-header {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-align: start;
+ align-items: flex-start;
+ -ms-flex-pack: justify;
+ justify-content: space-between;
+ padding: 1rem 1rem;
+ border-bottom: 1px solid #dee2e6;
+ border-top-left-radius: calc(0.3rem - 1px);
+ border-top-right-radius: calc(0.3rem - 1px);
+}
+
+.modal-header .close {
+ padding: 1rem 1rem;
+ margin: -1rem -1rem -1rem auto;
+}
+
+.modal-title {
+ margin-bottom: 0;
+ line-height: 1.5;
+}
+
+.modal-body {
+ position: relative;
+ -ms-flex: 1 1 auto;
+ flex: 1 1 auto;
+ padding: 1rem;
+}
+
+.modal-footer {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ -ms-flex-align: center;
+ align-items: center;
+ -ms-flex-pack: end;
+ justify-content: flex-end;
+ padding: 0.75rem;
+ border-top: 1px solid #dee2e6;
+ border-bottom-right-radius: calc(0.3rem - 1px);
+ border-bottom-left-radius: calc(0.3rem - 1px);
+}
+
+.modal-footer > * {
+ margin: 0.25rem;
+}
+
+.modal-scrollbar-measure {
+ position: absolute;
+ top: -9999px;
+ width: 50px;
+ height: 50px;
+ overflow: scroll;
+}
+
+@media (min-width: 576px) {
+ .modal-dialog {
+ max-width: 500px;
+ margin: 1.75rem auto;
+ }
+ .modal-dialog-scrollable {
+ max-height: calc(100% - 3.5rem);
+ }
+ .modal-dialog-scrollable .modal-content {
+ max-height: calc(100vh - 3.5rem);
+ }
+ .modal-dialog-centered {
+ min-height: calc(100% - 3.5rem);
+ }
+ .modal-dialog-centered::before {
+ height: calc(100vh - 3.5rem);
+ height: -webkit-min-content;
+ height: -moz-min-content;
+ height: min-content;
+ }
+ .modal-sm {
+ max-width: 300px;
+ }
+}
+
+@media (min-width: 992px) {
+ .modal-lg,
+ .modal-xl {
+ max-width: 800px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .modal-xl {
+ max-width: 1140px;
+ }
+}
+
+.tooltip {
+ position: absolute;
+ z-index: 1070;
+ display: block;
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-style: normal;
+ font-weight: 400;
+ line-height: 1.5;
+ text-align: left;
+ text-align: start;
+ text-decoration: none;
+ text-shadow: none;
+ text-transform: none;
+ letter-spacing: normal;
+ word-break: normal;
+ word-spacing: normal;
+ white-space: normal;
+ line-break: auto;
+ font-size: 0.875rem;
+ word-wrap: break-word;
+ opacity: 0;
+}
+
+.tooltip.show {
+ opacity: 0.9;
+}
+
+.tooltip .arrow {
+ position: absolute;
+ display: block;
+ width: 0.8rem;
+ height: 0.4rem;
+}
+
+.tooltip .arrow::before {
+ position: absolute;
+ content: "";
+ border-color: transparent;
+ border-style: solid;
+}
+
+.bs-tooltip-top, .bs-tooltip-auto[x-placement^="top"] {
+ padding: 0.4rem 0;
+}
+
+.bs-tooltip-top .arrow, .bs-tooltip-auto[x-placement^="top"] .arrow {
+ bottom: 0;
+}
+
+.bs-tooltip-top .arrow::before, .bs-tooltip-auto[x-placement^="top"] .arrow::before {
+ top: 0;
+ border-width: 0.4rem 0.4rem 0;
+ border-top-color: #000;
+}
+
+.bs-tooltip-right, .bs-tooltip-auto[x-placement^="right"] {
+ padding: 0 0.4rem;
+}
+
+.bs-tooltip-right .arrow, .bs-tooltip-auto[x-placement^="right"] .arrow {
+ left: 0;
+ width: 0.4rem;
+ height: 0.8rem;
+}
+
+.bs-tooltip-right .arrow::before, .bs-tooltip-auto[x-placement^="right"] .arrow::before {
+ right: 0;
+ border-width: 0.4rem 0.4rem 0.4rem 0;
+ border-right-color: #000;
+}
+
+.bs-tooltip-bottom, .bs-tooltip-auto[x-placement^="bottom"] {
+ padding: 0.4rem 0;
+}
+
+.bs-tooltip-bottom .arrow, .bs-tooltip-auto[x-placement^="bottom"] .arrow {
+ top: 0;
+}
+
+.bs-tooltip-bottom .arrow::before, .bs-tooltip-auto[x-placement^="bottom"] .arrow::before {
+ bottom: 0;
+ border-width: 0 0.4rem 0.4rem;
+ border-bottom-color: #000;
+}
+
+.bs-tooltip-left, .bs-tooltip-auto[x-placement^="left"] {
+ padding: 0 0.4rem;
+}
+
+.bs-tooltip-left .arrow, .bs-tooltip-auto[x-placement^="left"] .arrow {
+ right: 0;
+ width: 0.4rem;
+ height: 0.8rem;
+}
+
+.bs-tooltip-left .arrow::before, .bs-tooltip-auto[x-placement^="left"] .arrow::before {
+ left: 0;
+ border-width: 0.4rem 0 0.4rem 0.4rem;
+ border-left-color: #000;
+}
+
+.tooltip-inner {
+ max-width: 200px;
+ padding: 0.25rem 0.5rem;
+ color: #fff;
+ text-align: center;
+ background-color: #000;
+ border-radius: 0.25rem;
+}
+
+.popover {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1060;
+ display: block;
+ max-width: 276px;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-style: normal;
+ font-weight: 400;
+ line-height: 1.5;
+ text-align: left;
+ text-align: start;
+ text-decoration: none;
+ text-shadow: none;
+ text-transform: none;
+ letter-spacing: normal;
+ word-break: normal;
+ word-spacing: normal;
+ white-space: normal;
+ line-break: auto;
+ font-size: 0.875rem;
+ word-wrap: break-word;
+ background-color: #fff;
+ background-clip: padding-box;
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ border-radius: 0.3rem;
+}
+
+.popover .arrow {
+ position: absolute;
+ display: block;
+ width: 1rem;
+ height: 0.5rem;
+ margin: 0 0.3rem;
+}
+
+.popover .arrow::before, .popover .arrow::after {
+ position: absolute;
+ display: block;
+ content: "";
+ border-color: transparent;
+ border-style: solid;
+}
+
+.bs-popover-top, .bs-popover-auto[x-placement^="top"] {
+ margin-bottom: 0.5rem;
+}
+
+.bs-popover-top > .arrow, .bs-popover-auto[x-placement^="top"] > .arrow {
+ bottom: calc(-0.5rem - 1px);
+}
+
+.bs-popover-top > .arrow::before, .bs-popover-auto[x-placement^="top"] > .arrow::before {
+ bottom: 0;
+ border-width: 0.5rem 0.5rem 0;
+ border-top-color: rgba(0, 0, 0, 0.25);
+}
+
+.bs-popover-top > .arrow::after, .bs-popover-auto[x-placement^="top"] > .arrow::after {
+ bottom: 1px;
+ border-width: 0.5rem 0.5rem 0;
+ border-top-color: #fff;
+}
+
+.bs-popover-right, .bs-popover-auto[x-placement^="right"] {
+ margin-left: 0.5rem;
+}
+
+.bs-popover-right > .arrow, .bs-popover-auto[x-placement^="right"] > .arrow {
+ left: calc(-0.5rem - 1px);
+ width: 0.5rem;
+ height: 1rem;
+ margin: 0.3rem 0;
+}
+
+.bs-popover-right > .arrow::before, .bs-popover-auto[x-placement^="right"] > .arrow::before {
+ left: 0;
+ border-width: 0.5rem 0.5rem 0.5rem 0;
+ border-right-color: rgba(0, 0, 0, 0.25);
+}
+
+.bs-popover-right > .arrow::after, .bs-popover-auto[x-placement^="right"] > .arrow::after {
+ left: 1px;
+ border-width: 0.5rem 0.5rem 0.5rem 0;
+ border-right-color: #fff;
+}
+
+.bs-popover-bottom, .bs-popover-auto[x-placement^="bottom"] {
+ margin-top: 0.5rem;
+}
+
+.bs-popover-bottom > .arrow, .bs-popover-auto[x-placement^="bottom"] > .arrow {
+ top: calc(-0.5rem - 1px);
+}
+
+.bs-popover-bottom > .arrow::before, .bs-popover-auto[x-placement^="bottom"] > .arrow::before {
+ top: 0;
+ border-width: 0 0.5rem 0.5rem 0.5rem;
+ border-bottom-color: rgba(0, 0, 0, 0.25);
+}
+
+.bs-popover-bottom > .arrow::after, .bs-popover-auto[x-placement^="bottom"] > .arrow::after {
+ top: 1px;
+ border-width: 0 0.5rem 0.5rem 0.5rem;
+ border-bottom-color: #fff;
+}
+
+.bs-popover-bottom .popover-header::before, .bs-popover-auto[x-placement^="bottom"] .popover-header::before {
+ position: absolute;
+ top: 0;
+ left: 50%;
+ display: block;
+ width: 1rem;
+ margin-left: -0.5rem;
+ content: "";
+ border-bottom: 1px solid #f7f7f7;
+}
+
+.bs-popover-left, .bs-popover-auto[x-placement^="left"] {
+ margin-right: 0.5rem;
+}
+
+.bs-popover-left > .arrow, .bs-popover-auto[x-placement^="left"] > .arrow {
+ right: calc(-0.5rem - 1px);
+ width: 0.5rem;
+ height: 1rem;
+ margin: 0.3rem 0;
+}
+
+.bs-popover-left > .arrow::before, .bs-popover-auto[x-placement^="left"] > .arrow::before {
+ right: 0;
+ border-width: 0.5rem 0 0.5rem 0.5rem;
+ border-left-color: rgba(0, 0, 0, 0.25);
+}
+
+.bs-popover-left > .arrow::after, .bs-popover-auto[x-placement^="left"] > .arrow::after {
+ right: 1px;
+ border-width: 0.5rem 0 0.5rem 0.5rem;
+ border-left-color: #fff;
+}
+
+.popover-header {
+ padding: 0.5rem 0.75rem;
+ margin-bottom: 0;
+ font-size: 1rem;
+ background-color: #f7f7f7;
+ border-bottom: 1px solid #ebebeb;
+ border-top-left-radius: calc(0.3rem - 1px);
+ border-top-right-radius: calc(0.3rem - 1px);
+}
+
+.popover-header:empty {
+ display: none;
+}
+
+.popover-body {
+ padding: 0.5rem 0.75rem;
+ color: #212529;
+}
+
+.carousel {
+ position: relative;
+}
+
+.carousel.pointer-event {
+ -ms-touch-action: pan-y;
+ touch-action: pan-y;
+}
+
+.carousel-inner {
+ position: relative;
+ width: 100%;
+ overflow: hidden;
+}
+
+.carousel-inner::after {
+ display: block;
+ clear: both;
+ content: "";
+}
+
+.carousel-item {
+ position: relative;
+ display: none;
+ float: left;
+ width: 100%;
+ margin-right: -100%;
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
+ transition: -webkit-transform 0.6s ease-in-out;
+ transition: transform 0.6s ease-in-out;
+ transition: transform 0.6s ease-in-out, -webkit-transform 0.6s ease-in-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .carousel-item {
+ transition: none;
+ }
+}
+
+.carousel-item.active,
+.carousel-item-next,
+.carousel-item-prev {
+ display: block;
+}
+
+.carousel-item-next:not(.carousel-item-left),
+.active.carousel-item-right {
+ -webkit-transform: translateX(100%);
+ transform: translateX(100%);
+}
+
+.carousel-item-prev:not(.carousel-item-right),
+.active.carousel-item-left {
+ -webkit-transform: translateX(-100%);
+ transform: translateX(-100%);
+}
+
+.carousel-fade .carousel-item {
+ opacity: 0;
+ transition-property: opacity;
+ -webkit-transform: none;
+ transform: none;
+}
+
+.carousel-fade .carousel-item.active,
+.carousel-fade .carousel-item-next.carousel-item-left,
+.carousel-fade .carousel-item-prev.carousel-item-right {
+ z-index: 1;
+ opacity: 1;
+}
+
+.carousel-fade .active.carousel-item-left,
+.carousel-fade .active.carousel-item-right {
+ z-index: 0;
+ opacity: 0;
+ transition: opacity 0s 0.6s;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .carousel-fade .active.carousel-item-left,
+ .carousel-fade .active.carousel-item-right {
+ transition: none;
+ }
+}
+
+.carousel-control-prev,
+.carousel-control-next {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ z-index: 1;
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-align: center;
+ align-items: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ width: 15%;
+ color: #fff;
+ text-align: center;
+ opacity: 0.5;
+ transition: opacity 0.15s ease;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .carousel-control-prev,
+ .carousel-control-next {
+ transition: none;
+ }
+}
+
+.carousel-control-prev:hover, .carousel-control-prev:focus,
+.carousel-control-next:hover,
+.carousel-control-next:focus {
+ color: #fff;
+ text-decoration: none;
+ outline: 0;
+ opacity: 0.9;
+}
+
+.carousel-control-prev {
+ left: 0;
+}
+
+.carousel-control-next {
+ right: 0;
+}
+
+.carousel-control-prev-icon,
+.carousel-control-next-icon {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ background: no-repeat 50% / 100% 100%;
+}
+
+.carousel-control-prev-icon {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e");
+}
+
+.carousel-control-next-icon {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e");
+}
+
+.carousel-indicators {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 15;
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-pack: center;
+ justify-content: center;
+ padding-left: 0;
+ margin-right: 15%;
+ margin-left: 15%;
+ list-style: none;
+}
+
+.carousel-indicators li {
+ box-sizing: content-box;
+ -ms-flex: 0 1 auto;
+ flex: 0 1 auto;
+ width: 30px;
+ height: 3px;
+ margin-right: 3px;
+ margin-left: 3px;
+ text-indent: -999px;
+ cursor: pointer;
+ background-color: #fff;
+ background-clip: padding-box;
+ border-top: 10px solid transparent;
+ border-bottom: 10px solid transparent;
+ opacity: .5;
+ transition: opacity 0.6s ease;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .carousel-indicators li {
+ transition: none;
+ }
+}
+
+.carousel-indicators .active {
+ opacity: 1;
+}
+
+.carousel-caption {
+ position: absolute;
+ right: 15%;
+ bottom: 20px;
+ left: 15%;
+ z-index: 10;
+ padding-top: 20px;
+ padding-bottom: 20px;
+ color: #fff;
+ text-align: center;
+}
+
+@-webkit-keyframes spinner-border {
+ to {
+ -webkit-transform: rotate(360deg);
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes spinner-border {
+ to {
+ -webkit-transform: rotate(360deg);
+ transform: rotate(360deg);
+ }
+}
+
+.spinner-border {
+ display: inline-block;
+ width: 2rem;
+ height: 2rem;
+ vertical-align: text-bottom;
+ border: 0.25em solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ -webkit-animation: spinner-border .75s linear infinite;
+ animation: spinner-border .75s linear infinite;
+}
+
+.spinner-border-sm {
+ width: 1rem;
+ height: 1rem;
+ border-width: 0.2em;
+}
+
+@-webkit-keyframes spinner-grow {
+ 0% {
+ -webkit-transform: scale(0);
+ transform: scale(0);
+ }
+ 50% {
+ opacity: 1;
+ -webkit-transform: none;
+ transform: none;
+ }
+}
+
+@keyframes spinner-grow {
+ 0% {
+ -webkit-transform: scale(0);
+ transform: scale(0);
+ }
+ 50% {
+ opacity: 1;
+ -webkit-transform: none;
+ transform: none;
+ }
+}
+
+.spinner-grow {
+ display: inline-block;
+ width: 2rem;
+ height: 2rem;
+ vertical-align: text-bottom;
+ background-color: currentColor;
+ border-radius: 50%;
+ opacity: 0;
+ -webkit-animation: spinner-grow .75s linear infinite;
+ animation: spinner-grow .75s linear infinite;
+}
+
+.spinner-grow-sm {
+ width: 1rem;
+ height: 1rem;
+}
+
+.align-baseline {
+ vertical-align: baseline !important;
+}
+
+.align-top {
+ vertical-align: top !important;
+}
+
+.align-middle {
+ vertical-align: middle !important;
+}
+
+.align-bottom {
+ vertical-align: bottom !important;
+}
+
+.align-text-bottom {
+ vertical-align: text-bottom !important;
+}
+
+.align-text-top {
+ vertical-align: text-top !important;
+}
+
+.bg-primary {
+ background-color: #007bff !important;
+}
+
+a.bg-primary:hover, a.bg-primary:focus,
+button.bg-primary:hover,
+button.bg-primary:focus {
+ background-color: #0062cc !important;
+}
+
+.bg-secondary {
+ background-color: #6c757d !important;
+}
+
+a.bg-secondary:hover, a.bg-secondary:focus,
+button.bg-secondary:hover,
+button.bg-secondary:focus {
+ background-color: #545b62 !important;
+}
+
+.bg-success {
+ background-color: #28a745 !important;
+}
+
+a.bg-success:hover, a.bg-success:focus,
+button.bg-success:hover,
+button.bg-success:focus {
+ background-color: #1e7e34 !important;
+}
+
+.bg-info {
+ background-color: #17a2b8 !important;
+}
+
+a.bg-info:hover, a.bg-info:focus,
+button.bg-info:hover,
+button.bg-info:focus {
+ background-color: #117a8b !important;
+}
+
+.bg-warning {
+ background-color: #ffc107 !important;
+}
+
+a.bg-warning:hover, a.bg-warning:focus,
+button.bg-warning:hover,
+button.bg-warning:focus {
+ background-color: #d39e00 !important;
+}
+
+.bg-danger {
+ background-color: #dc3545 !important;
+}
+
+a.bg-danger:hover, a.bg-danger:focus,
+button.bg-danger:hover,
+button.bg-danger:focus {
+ background-color: #bd2130 !important;
+}
+
+.bg-light {
+ background-color: #f8f9fa !important;
+}
+
+a.bg-light:hover, a.bg-light:focus,
+button.bg-light:hover,
+button.bg-light:focus {
+ background-color: #dae0e5 !important;
+}
+
+.bg-dark {
+ background-color: #343a40 !important;
+}
+
+a.bg-dark:hover, a.bg-dark:focus,
+button.bg-dark:hover,
+button.bg-dark:focus {
+ background-color: #1d2124 !important;
+}
+
+.bg-white {
+ background-color: #fff !important;
+}
+
+.bg-transparent {
+ background-color: transparent !important;
+}
+
+.border {
+ border: 1px solid #dee2e6 !important;
+}
+
+.border-top {
+ border-top: 1px solid #dee2e6 !important;
+}
+
+.border-right {
+ border-right: 1px solid #dee2e6 !important;
+}
+
+.border-bottom {
+ border-bottom: 1px solid #dee2e6 !important;
+}
+
+.border-left {
+ border-left: 1px solid #dee2e6 !important;
+}
+
+.border-0 {
+ border: 0 !important;
+}
+
+.border-top-0 {
+ border-top: 0 !important;
+}
+
+.border-right-0 {
+ border-right: 0 !important;
+}
+
+.border-bottom-0 {
+ border-bottom: 0 !important;
+}
+
+.border-left-0 {
+ border-left: 0 !important;
+}
+
+.border-primary {
+ border-color: #007bff !important;
+}
+
+.border-secondary {
+ border-color: #6c757d !important;
+}
+
+.border-success {
+ border-color: #28a745 !important;
+}
+
+.border-info {
+ border-color: #17a2b8 !important;
+}
+
+.border-warning {
+ border-color: #ffc107 !important;
+}
+
+.border-danger {
+ border-color: #dc3545 !important;
+}
+
+.border-light {
+ border-color: #f8f9fa !important;
+}
+
+.border-dark {
+ border-color: #343a40 !important;
+}
+
+.border-white {
+ border-color: #fff !important;
+}
+
+.rounded-sm {
+ border-radius: 0.2rem !important;
+}
+
+.rounded {
+ border-radius: 0.25rem !important;
+}
+
+.rounded-top {
+ border-top-left-radius: 0.25rem !important;
+ border-top-right-radius: 0.25rem !important;
+}
+
+.rounded-right {
+ border-top-right-radius: 0.25rem !important;
+ border-bottom-right-radius: 0.25rem !important;
+}
+
+.rounded-bottom {
+ border-bottom-right-radius: 0.25rem !important;
+ border-bottom-left-radius: 0.25rem !important;
+}
+
+.rounded-left {
+ border-top-left-radius: 0.25rem !important;
+ border-bottom-left-radius: 0.25rem !important;
+}
+
+.rounded-lg {
+ border-radius: 0.3rem !important;
+}
+
+.rounded-circle {
+ border-radius: 50% !important;
+}
+
+.rounded-pill {
+ border-radius: 50rem !important;
+}
+
+.rounded-0 {
+ border-radius: 0 !important;
+}
+
+.clearfix::after {
+ display: block;
+ clear: both;
+ content: "";
+}
+
+.d-none {
+ display: none !important;
+}
+
+.d-inline {
+ display: inline !important;
+}
+
+.d-inline-block {
+ display: inline-block !important;
+}
+
+.d-block {
+ display: block !important;
+}
+
+.d-table {
+ display: table !important;
+}
+
+.d-table-row {
+ display: table-row !important;
+}
+
+.d-table-cell {
+ display: table-cell !important;
+}
+
+.d-flex {
+ display: -ms-flexbox !important;
+ display: flex !important;
+}
+
+.d-inline-flex {
+ display: -ms-inline-flexbox !important;
+ display: inline-flex !important;
+}
+
+@media (min-width: 576px) {
+ .d-sm-none {
+ display: none !important;
+ }
+ .d-sm-inline {
+ display: inline !important;
+ }
+ .d-sm-inline-block {
+ display: inline-block !important;
+ }
+ .d-sm-block {
+ display: block !important;
+ }
+ .d-sm-table {
+ display: table !important;
+ }
+ .d-sm-table-row {
+ display: table-row !important;
+ }
+ .d-sm-table-cell {
+ display: table-cell !important;
+ }
+ .d-sm-flex {
+ display: -ms-flexbox !important;
+ display: flex !important;
+ }
+ .d-sm-inline-flex {
+ display: -ms-inline-flexbox !important;
+ display: inline-flex !important;
+ }
+}
+
+@media (min-width: 768px) {
+ .d-md-none {
+ display: none !important;
+ }
+ .d-md-inline {
+ display: inline !important;
+ }
+ .d-md-inline-block {
+ display: inline-block !important;
+ }
+ .d-md-block {
+ display: block !important;
+ }
+ .d-md-table {
+ display: table !important;
+ }
+ .d-md-table-row {
+ display: table-row !important;
+ }
+ .d-md-table-cell {
+ display: table-cell !important;
+ }
+ .d-md-flex {
+ display: -ms-flexbox !important;
+ display: flex !important;
+ }
+ .d-md-inline-flex {
+ display: -ms-inline-flexbox !important;
+ display: inline-flex !important;
+ }
+}
+
+@media (min-width: 992px) {
+ .d-lg-none {
+ display: none !important;
+ }
+ .d-lg-inline {
+ display: inline !important;
+ }
+ .d-lg-inline-block {
+ display: inline-block !important;
+ }
+ .d-lg-block {
+ display: block !important;
+ }
+ .d-lg-table {
+ display: table !important;
+ }
+ .d-lg-table-row {
+ display: table-row !important;
+ }
+ .d-lg-table-cell {
+ display: table-cell !important;
+ }
+ .d-lg-flex {
+ display: -ms-flexbox !important;
+ display: flex !important;
+ }
+ .d-lg-inline-flex {
+ display: -ms-inline-flexbox !important;
+ display: inline-flex !important;
+ }
+}
+
+@media (min-width: 1200px) {
+ .d-xl-none {
+ display: none !important;
+ }
+ .d-xl-inline {
+ display: inline !important;
+ }
+ .d-xl-inline-block {
+ display: inline-block !important;
+ }
+ .d-xl-block {
+ display: block !important;
+ }
+ .d-xl-table {
+ display: table !important;
+ }
+ .d-xl-table-row {
+ display: table-row !important;
+ }
+ .d-xl-table-cell {
+ display: table-cell !important;
+ }
+ .d-xl-flex {
+ display: -ms-flexbox !important;
+ display: flex !important;
+ }
+ .d-xl-inline-flex {
+ display: -ms-inline-flexbox !important;
+ display: inline-flex !important;
+ }
+}
+
+@media print {
+ .d-print-none {
+ display: none !important;
+ }
+ .d-print-inline {
+ display: inline !important;
+ }
+ .d-print-inline-block {
+ display: inline-block !important;
+ }
+ .d-print-block {
+ display: block !important;
+ }
+ .d-print-table {
+ display: table !important;
+ }
+ .d-print-table-row {
+ display: table-row !important;
+ }
+ .d-print-table-cell {
+ display: table-cell !important;
+ }
+ .d-print-flex {
+ display: -ms-flexbox !important;
+ display: flex !important;
+ }
+ .d-print-inline-flex {
+ display: -ms-inline-flexbox !important;
+ display: inline-flex !important;
+ }
+}
+
+.embed-responsive {
+ position: relative;
+ display: block;
+ width: 100%;
+ padding: 0;
+ overflow: hidden;
+}
+
+.embed-responsive::before {
+ display: block;
+ content: "";
+}
+
+.embed-responsive .embed-responsive-item,
+.embed-responsive iframe,
+.embed-responsive embed,
+.embed-responsive object,
+.embed-responsive video {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border: 0;
+}
+
+.embed-responsive-21by9::before {
+ padding-top: 42.857143%;
+}
+
+.embed-responsive-16by9::before {
+ padding-top: 56.25%;
+}
+
+.embed-responsive-4by3::before {
+ padding-top: 75%;
+}
+
+.embed-responsive-1by1::before {
+ padding-top: 100%;
+}
+
+.flex-row {
+ -ms-flex-direction: row !important;
+ flex-direction: row !important;
+}
+
+.flex-column {
+ -ms-flex-direction: column !important;
+ flex-direction: column !important;
+}
+
+.flex-row-reverse {
+ -ms-flex-direction: row-reverse !important;
+ flex-direction: row-reverse !important;
+}
+
+.flex-column-reverse {
+ -ms-flex-direction: column-reverse !important;
+ flex-direction: column-reverse !important;
+}
+
+.flex-wrap {
+ -ms-flex-wrap: wrap !important;
+ flex-wrap: wrap !important;
+}
+
+.flex-nowrap {
+ -ms-flex-wrap: nowrap !important;
+ flex-wrap: nowrap !important;
+}
+
+.flex-wrap-reverse {
+ -ms-flex-wrap: wrap-reverse !important;
+ flex-wrap: wrap-reverse !important;
+}
+
+.flex-fill {
+ -ms-flex: 1 1 auto !important;
+ flex: 1 1 auto !important;
+}
+
+.flex-grow-0 {
+ -ms-flex-positive: 0 !important;
+ flex-grow: 0 !important;
+}
+
+.flex-grow-1 {
+ -ms-flex-positive: 1 !important;
+ flex-grow: 1 !important;
+}
+
+.flex-shrink-0 {
+ -ms-flex-negative: 0 !important;
+ flex-shrink: 0 !important;
+}
+
+.flex-shrink-1 {
+ -ms-flex-negative: 1 !important;
+ flex-shrink: 1 !important;
+}
+
+.justify-content-start {
+ -ms-flex-pack: start !important;
+ justify-content: flex-start !important;
+}
+
+.justify-content-end {
+ -ms-flex-pack: end !important;
+ justify-content: flex-end !important;
+}
+
+.justify-content-center {
+ -ms-flex-pack: center !important;
+ justify-content: center !important;
+}
+
+.justify-content-between {
+ -ms-flex-pack: justify !important;
+ justify-content: space-between !important;
+}
+
+.justify-content-around {
+ -ms-flex-pack: distribute !important;
+ justify-content: space-around !important;
+}
+
+.align-items-start {
+ -ms-flex-align: start !important;
+ align-items: flex-start !important;
+}
+
+.align-items-end {
+ -ms-flex-align: end !important;
+ align-items: flex-end !important;
+}
+
+.align-items-center {
+ -ms-flex-align: center !important;
+ align-items: center !important;
+}
+
+.align-items-baseline {
+ -ms-flex-align: baseline !important;
+ align-items: baseline !important;
+}
+
+.align-items-stretch {
+ -ms-flex-align: stretch !important;
+ align-items: stretch !important;
+}
+
+.align-content-start {
+ -ms-flex-line-pack: start !important;
+ align-content: flex-start !important;
+}
+
+.align-content-end {
+ -ms-flex-line-pack: end !important;
+ align-content: flex-end !important;
+}
+
+.align-content-center {
+ -ms-flex-line-pack: center !important;
+ align-content: center !important;
+}
+
+.align-content-between {
+ -ms-flex-line-pack: justify !important;
+ align-content: space-between !important;
+}
+
+.align-content-around {
+ -ms-flex-line-pack: distribute !important;
+ align-content: space-around !important;
+}
+
+.align-content-stretch {
+ -ms-flex-line-pack: stretch !important;
+ align-content: stretch !important;
+}
+
+.align-self-auto {
+ -ms-flex-item-align: auto !important;
+ align-self: auto !important;
+}
+
+.align-self-start {
+ -ms-flex-item-align: start !important;
+ align-self: flex-start !important;
+}
+
+.align-self-end {
+ -ms-flex-item-align: end !important;
+ align-self: flex-end !important;
+}
+
+.align-self-center {
+ -ms-flex-item-align: center !important;
+ align-self: center !important;
+}
+
+.align-self-baseline {
+ -ms-flex-item-align: baseline !important;
+ align-self: baseline !important;
+}
+
+.align-self-stretch {
+ -ms-flex-item-align: stretch !important;
+ align-self: stretch !important;
+}
+
+@media (min-width: 576px) {
+ .flex-sm-row {
+ -ms-flex-direction: row !important;
+ flex-direction: row !important;
+ }
+ .flex-sm-column {
+ -ms-flex-direction: column !important;
+ flex-direction: column !important;
+ }
+ .flex-sm-row-reverse {
+ -ms-flex-direction: row-reverse !important;
+ flex-direction: row-reverse !important;
+ }
+ .flex-sm-column-reverse {
+ -ms-flex-direction: column-reverse !important;
+ flex-direction: column-reverse !important;
+ }
+ .flex-sm-wrap {
+ -ms-flex-wrap: wrap !important;
+ flex-wrap: wrap !important;
+ }
+ .flex-sm-nowrap {
+ -ms-flex-wrap: nowrap !important;
+ flex-wrap: nowrap !important;
+ }
+ .flex-sm-wrap-reverse {
+ -ms-flex-wrap: wrap-reverse !important;
+ flex-wrap: wrap-reverse !important;
+ }
+ .flex-sm-fill {
+ -ms-flex: 1 1 auto !important;
+ flex: 1 1 auto !important;
+ }
+ .flex-sm-grow-0 {
+ -ms-flex-positive: 0 !important;
+ flex-grow: 0 !important;
+ }
+ .flex-sm-grow-1 {
+ -ms-flex-positive: 1 !important;
+ flex-grow: 1 !important;
+ }
+ .flex-sm-shrink-0 {
+ -ms-flex-negative: 0 !important;
+ flex-shrink: 0 !important;
+ }
+ .flex-sm-shrink-1 {
+ -ms-flex-negative: 1 !important;
+ flex-shrink: 1 !important;
+ }
+ .justify-content-sm-start {
+ -ms-flex-pack: start !important;
+ justify-content: flex-start !important;
+ }
+ .justify-content-sm-end {
+ -ms-flex-pack: end !important;
+ justify-content: flex-end !important;
+ }
+ .justify-content-sm-center {
+ -ms-flex-pack: center !important;
+ justify-content: center !important;
+ }
+ .justify-content-sm-between {
+ -ms-flex-pack: justify !important;
+ justify-content: space-between !important;
+ }
+ .justify-content-sm-around {
+ -ms-flex-pack: distribute !important;
+ justify-content: space-around !important;
+ }
+ .align-items-sm-start {
+ -ms-flex-align: start !important;
+ align-items: flex-start !important;
+ }
+ .align-items-sm-end {
+ -ms-flex-align: end !important;
+ align-items: flex-end !important;
+ }
+ .align-items-sm-center {
+ -ms-flex-align: center !important;
+ align-items: center !important;
+ }
+ .align-items-sm-baseline {
+ -ms-flex-align: baseline !important;
+ align-items: baseline !important;
+ }
+ .align-items-sm-stretch {
+ -ms-flex-align: stretch !important;
+ align-items: stretch !important;
+ }
+ .align-content-sm-start {
+ -ms-flex-line-pack: start !important;
+ align-content: flex-start !important;
+ }
+ .align-content-sm-end {
+ -ms-flex-line-pack: end !important;
+ align-content: flex-end !important;
+ }
+ .align-content-sm-center {
+ -ms-flex-line-pack: center !important;
+ align-content: center !important;
+ }
+ .align-content-sm-between {
+ -ms-flex-line-pack: justify !important;
+ align-content: space-between !important;
+ }
+ .align-content-sm-around {
+ -ms-flex-line-pack: distribute !important;
+ align-content: space-around !important;
+ }
+ .align-content-sm-stretch {
+ -ms-flex-line-pack: stretch !important;
+ align-content: stretch !important;
+ }
+ .align-self-sm-auto {
+ -ms-flex-item-align: auto !important;
+ align-self: auto !important;
+ }
+ .align-self-sm-start {
+ -ms-flex-item-align: start !important;
+ align-self: flex-start !important;
+ }
+ .align-self-sm-end {
+ -ms-flex-item-align: end !important;
+ align-self: flex-end !important;
+ }
+ .align-self-sm-center {
+ -ms-flex-item-align: center !important;
+ align-self: center !important;
+ }
+ .align-self-sm-baseline {
+ -ms-flex-item-align: baseline !important;
+ align-self: baseline !important;
+ }
+ .align-self-sm-stretch {
+ -ms-flex-item-align: stretch !important;
+ align-self: stretch !important;
+ }
+}
+
+@media (min-width: 768px) {
+ .flex-md-row {
+ -ms-flex-direction: row !important;
+ flex-direction: row !important;
+ }
+ .flex-md-column {
+ -ms-flex-direction: column !important;
+ flex-direction: column !important;
+ }
+ .flex-md-row-reverse {
+ -ms-flex-direction: row-reverse !important;
+ flex-direction: row-reverse !important;
+ }
+ .flex-md-column-reverse {
+ -ms-flex-direction: column-reverse !important;
+ flex-direction: column-reverse !important;
+ }
+ .flex-md-wrap {
+ -ms-flex-wrap: wrap !important;
+ flex-wrap: wrap !important;
+ }
+ .flex-md-nowrap {
+ -ms-flex-wrap: nowrap !important;
+ flex-wrap: nowrap !important;
+ }
+ .flex-md-wrap-reverse {
+ -ms-flex-wrap: wrap-reverse !important;
+ flex-wrap: wrap-reverse !important;
+ }
+ .flex-md-fill {
+ -ms-flex: 1 1 auto !important;
+ flex: 1 1 auto !important;
+ }
+ .flex-md-grow-0 {
+ -ms-flex-positive: 0 !important;
+ flex-grow: 0 !important;
+ }
+ .flex-md-grow-1 {
+ -ms-flex-positive: 1 !important;
+ flex-grow: 1 !important;
+ }
+ .flex-md-shrink-0 {
+ -ms-flex-negative: 0 !important;
+ flex-shrink: 0 !important;
+ }
+ .flex-md-shrink-1 {
+ -ms-flex-negative: 1 !important;
+ flex-shrink: 1 !important;
+ }
+ .justify-content-md-start {
+ -ms-flex-pack: start !important;
+ justify-content: flex-start !important;
+ }
+ .justify-content-md-end {
+ -ms-flex-pack: end !important;
+ justify-content: flex-end !important;
+ }
+ .justify-content-md-center {
+ -ms-flex-pack: center !important;
+ justify-content: center !important;
+ }
+ .justify-content-md-between {
+ -ms-flex-pack: justify !important;
+ justify-content: space-between !important;
+ }
+ .justify-content-md-around {
+ -ms-flex-pack: distribute !important;
+ justify-content: space-around !important;
+ }
+ .align-items-md-start {
+ -ms-flex-align: start !important;
+ align-items: flex-start !important;
+ }
+ .align-items-md-end {
+ -ms-flex-align: end !important;
+ align-items: flex-end !important;
+ }
+ .align-items-md-center {
+ -ms-flex-align: center !important;
+ align-items: center !important;
+ }
+ .align-items-md-baseline {
+ -ms-flex-align: baseline !important;
+ align-items: baseline !important;
+ }
+ .align-items-md-stretch {
+ -ms-flex-align: stretch !important;
+ align-items: stretch !important;
+ }
+ .align-content-md-start {
+ -ms-flex-line-pack: start !important;
+ align-content: flex-start !important;
+ }
+ .align-content-md-end {
+ -ms-flex-line-pack: end !important;
+ align-content: flex-end !important;
+ }
+ .align-content-md-center {
+ -ms-flex-line-pack: center !important;
+ align-content: center !important;
+ }
+ .align-content-md-between {
+ -ms-flex-line-pack: justify !important;
+ align-content: space-between !important;
+ }
+ .align-content-md-around {
+ -ms-flex-line-pack: distribute !important;
+ align-content: space-around !important;
+ }
+ .align-content-md-stretch {
+ -ms-flex-line-pack: stretch !important;
+ align-content: stretch !important;
+ }
+ .align-self-md-auto {
+ -ms-flex-item-align: auto !important;
+ align-self: auto !important;
+ }
+ .align-self-md-start {
+ -ms-flex-item-align: start !important;
+ align-self: flex-start !important;
+ }
+ .align-self-md-end {
+ -ms-flex-item-align: end !important;
+ align-self: flex-end !important;
+ }
+ .align-self-md-center {
+ -ms-flex-item-align: center !important;
+ align-self: center !important;
+ }
+ .align-self-md-baseline {
+ -ms-flex-item-align: baseline !important;
+ align-self: baseline !important;
+ }
+ .align-self-md-stretch {
+ -ms-flex-item-align: stretch !important;
+ align-self: stretch !important;
+ }
+}
+
+@media (min-width: 992px) {
+ .flex-lg-row {
+ -ms-flex-direction: row !important;
+ flex-direction: row !important;
+ }
+ .flex-lg-column {
+ -ms-flex-direction: column !important;
+ flex-direction: column !important;
+ }
+ .flex-lg-row-reverse {
+ -ms-flex-direction: row-reverse !important;
+ flex-direction: row-reverse !important;
+ }
+ .flex-lg-column-reverse {
+ -ms-flex-direction: column-reverse !important;
+ flex-direction: column-reverse !important;
+ }
+ .flex-lg-wrap {
+ -ms-flex-wrap: wrap !important;
+ flex-wrap: wrap !important;
+ }
+ .flex-lg-nowrap {
+ -ms-flex-wrap: nowrap !important;
+ flex-wrap: nowrap !important;
+ }
+ .flex-lg-wrap-reverse {
+ -ms-flex-wrap: wrap-reverse !important;
+ flex-wrap: wrap-reverse !important;
+ }
+ .flex-lg-fill {
+ -ms-flex: 1 1 auto !important;
+ flex: 1 1 auto !important;
+ }
+ .flex-lg-grow-0 {
+ -ms-flex-positive: 0 !important;
+ flex-grow: 0 !important;
+ }
+ .flex-lg-grow-1 {
+ -ms-flex-positive: 1 !important;
+ flex-grow: 1 !important;
+ }
+ .flex-lg-shrink-0 {
+ -ms-flex-negative: 0 !important;
+ flex-shrink: 0 !important;
+ }
+ .flex-lg-shrink-1 {
+ -ms-flex-negative: 1 !important;
+ flex-shrink: 1 !important;
+ }
+ .justify-content-lg-start {
+ -ms-flex-pack: start !important;
+ justify-content: flex-start !important;
+ }
+ .justify-content-lg-end {
+ -ms-flex-pack: end !important;
+ justify-content: flex-end !important;
+ }
+ .justify-content-lg-center {
+ -ms-flex-pack: center !important;
+ justify-content: center !important;
+ }
+ .justify-content-lg-between {
+ -ms-flex-pack: justify !important;
+ justify-content: space-between !important;
+ }
+ .justify-content-lg-around {
+ -ms-flex-pack: distribute !important;
+ justify-content: space-around !important;
+ }
+ .align-items-lg-start {
+ -ms-flex-align: start !important;
+ align-items: flex-start !important;
+ }
+ .align-items-lg-end {
+ -ms-flex-align: end !important;
+ align-items: flex-end !important;
+ }
+ .align-items-lg-center {
+ -ms-flex-align: center !important;
+ align-items: center !important;
+ }
+ .align-items-lg-baseline {
+ -ms-flex-align: baseline !important;
+ align-items: baseline !important;
+ }
+ .align-items-lg-stretch {
+ -ms-flex-align: stretch !important;
+ align-items: stretch !important;
+ }
+ .align-content-lg-start {
+ -ms-flex-line-pack: start !important;
+ align-content: flex-start !important;
+ }
+ .align-content-lg-end {
+ -ms-flex-line-pack: end !important;
+ align-content: flex-end !important;
+ }
+ .align-content-lg-center {
+ -ms-flex-line-pack: center !important;
+ align-content: center !important;
+ }
+ .align-content-lg-between {
+ -ms-flex-line-pack: justify !important;
+ align-content: space-between !important;
+ }
+ .align-content-lg-around {
+ -ms-flex-line-pack: distribute !important;
+ align-content: space-around !important;
+ }
+ .align-content-lg-stretch {
+ -ms-flex-line-pack: stretch !important;
+ align-content: stretch !important;
+ }
+ .align-self-lg-auto {
+ -ms-flex-item-align: auto !important;
+ align-self: auto !important;
+ }
+ .align-self-lg-start {
+ -ms-flex-item-align: start !important;
+ align-self: flex-start !important;
+ }
+ .align-self-lg-end {
+ -ms-flex-item-align: end !important;
+ align-self: flex-end !important;
+ }
+ .align-self-lg-center {
+ -ms-flex-item-align: center !important;
+ align-self: center !important;
+ }
+ .align-self-lg-baseline {
+ -ms-flex-item-align: baseline !important;
+ align-self: baseline !important;
+ }
+ .align-self-lg-stretch {
+ -ms-flex-item-align: stretch !important;
+ align-self: stretch !important;
+ }
+}
+
+@media (min-width: 1200px) {
+ .flex-xl-row {
+ -ms-flex-direction: row !important;
+ flex-direction: row !important;
+ }
+ .flex-xl-column {
+ -ms-flex-direction: column !important;
+ flex-direction: column !important;
+ }
+ .flex-xl-row-reverse {
+ -ms-flex-direction: row-reverse !important;
+ flex-direction: row-reverse !important;
+ }
+ .flex-xl-column-reverse {
+ -ms-flex-direction: column-reverse !important;
+ flex-direction: column-reverse !important;
+ }
+ .flex-xl-wrap {
+ -ms-flex-wrap: wrap !important;
+ flex-wrap: wrap !important;
+ }
+ .flex-xl-nowrap {
+ -ms-flex-wrap: nowrap !important;
+ flex-wrap: nowrap !important;
+ }
+ .flex-xl-wrap-reverse {
+ -ms-flex-wrap: wrap-reverse !important;
+ flex-wrap: wrap-reverse !important;
+ }
+ .flex-xl-fill {
+ -ms-flex: 1 1 auto !important;
+ flex: 1 1 auto !important;
+ }
+ .flex-xl-grow-0 {
+ -ms-flex-positive: 0 !important;
+ flex-grow: 0 !important;
+ }
+ .flex-xl-grow-1 {
+ -ms-flex-positive: 1 !important;
+ flex-grow: 1 !important;
+ }
+ .flex-xl-shrink-0 {
+ -ms-flex-negative: 0 !important;
+ flex-shrink: 0 !important;
+ }
+ .flex-xl-shrink-1 {
+ -ms-flex-negative: 1 !important;
+ flex-shrink: 1 !important;
+ }
+ .justify-content-xl-start {
+ -ms-flex-pack: start !important;
+ justify-content: flex-start !important;
+ }
+ .justify-content-xl-end {
+ -ms-flex-pack: end !important;
+ justify-content: flex-end !important;
+ }
+ .justify-content-xl-center {
+ -ms-flex-pack: center !important;
+ justify-content: center !important;
+ }
+ .justify-content-xl-between {
+ -ms-flex-pack: justify !important;
+ justify-content: space-between !important;
+ }
+ .justify-content-xl-around {
+ -ms-flex-pack: distribute !important;
+ justify-content: space-around !important;
+ }
+ .align-items-xl-start {
+ -ms-flex-align: start !important;
+ align-items: flex-start !important;
+ }
+ .align-items-xl-end {
+ -ms-flex-align: end !important;
+ align-items: flex-end !important;
+ }
+ .align-items-xl-center {
+ -ms-flex-align: center !important;
+ align-items: center !important;
+ }
+ .align-items-xl-baseline {
+ -ms-flex-align: baseline !important;
+ align-items: baseline !important;
+ }
+ .align-items-xl-stretch {
+ -ms-flex-align: stretch !important;
+ align-items: stretch !important;
+ }
+ .align-content-xl-start {
+ -ms-flex-line-pack: start !important;
+ align-content: flex-start !important;
+ }
+ .align-content-xl-end {
+ -ms-flex-line-pack: end !important;
+ align-content: flex-end !important;
+ }
+ .align-content-xl-center {
+ -ms-flex-line-pack: center !important;
+ align-content: center !important;
+ }
+ .align-content-xl-between {
+ -ms-flex-line-pack: justify !important;
+ align-content: space-between !important;
+ }
+ .align-content-xl-around {
+ -ms-flex-line-pack: distribute !important;
+ align-content: space-around !important;
+ }
+ .align-content-xl-stretch {
+ -ms-flex-line-pack: stretch !important;
+ align-content: stretch !important;
+ }
+ .align-self-xl-auto {
+ -ms-flex-item-align: auto !important;
+ align-self: auto !important;
+ }
+ .align-self-xl-start {
+ -ms-flex-item-align: start !important;
+ align-self: flex-start !important;
+ }
+ .align-self-xl-end {
+ -ms-flex-item-align: end !important;
+ align-self: flex-end !important;
+ }
+ .align-self-xl-center {
+ -ms-flex-item-align: center !important;
+ align-self: center !important;
+ }
+ .align-self-xl-baseline {
+ -ms-flex-item-align: baseline !important;
+ align-self: baseline !important;
+ }
+ .align-self-xl-stretch {
+ -ms-flex-item-align: stretch !important;
+ align-self: stretch !important;
+ }
+}
+
+.float-left {
+ float: left !important;
+}
+
+.float-right {
+ float: right !important;
+}
+
+.float-none {
+ float: none !important;
+}
+
+@media (min-width: 576px) {
+ .float-sm-left {
+ float: left !important;
+ }
+ .float-sm-right {
+ float: right !important;
+ }
+ .float-sm-none {
+ float: none !important;
+ }
+}
+
+@media (min-width: 768px) {
+ .float-md-left {
+ float: left !important;
+ }
+ .float-md-right {
+ float: right !important;
+ }
+ .float-md-none {
+ float: none !important;
+ }
+}
+
+@media (min-width: 992px) {
+ .float-lg-left {
+ float: left !important;
+ }
+ .float-lg-right {
+ float: right !important;
+ }
+ .float-lg-none {
+ float: none !important;
+ }
+}
+
+@media (min-width: 1200px) {
+ .float-xl-left {
+ float: left !important;
+ }
+ .float-xl-right {
+ float: right !important;
+ }
+ .float-xl-none {
+ float: none !important;
+ }
+}
+
+.user-select-all {
+ -webkit-user-select: all !important;
+ -moz-user-select: all !important;
+ -ms-user-select: all !important;
+ user-select: all !important;
+}
+
+.user-select-auto {
+ -webkit-user-select: auto !important;
+ -moz-user-select: auto !important;
+ -ms-user-select: auto !important;
+ user-select: auto !important;
+}
+
+.user-select-none {
+ -webkit-user-select: none !important;
+ -moz-user-select: none !important;
+ -ms-user-select: none !important;
+ user-select: none !important;
+}
+
+.overflow-auto {
+ overflow: auto !important;
+}
+
+.overflow-hidden {
+ overflow: hidden !important;
+}
+
+.position-static {
+ position: static !important;
+}
+
+.position-relative {
+ position: relative !important;
+}
+
+.position-absolute {
+ position: absolute !important;
+}
+
+.position-fixed {
+ position: fixed !important;
+}
+
+.position-sticky {
+ position: -webkit-sticky !important;
+ position: sticky !important;
+}
+
+.fixed-top {
+ position: fixed;
+ top: 0;
+ right: 0;
+ left: 0;
+ z-index: 1030;
+}
+
+.fixed-bottom {
+ position: fixed;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 1030;
+}
+
+@supports ((position: -webkit-sticky) or (position: sticky)) {
+ .sticky-top {
+ position: -webkit-sticky;
+ position: sticky;
+ top: 0;
+ z-index: 1020;
+ }
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.sr-only-focusable:active, .sr-only-focusable:focus {
+ position: static;
+ width: auto;
+ height: auto;
+ overflow: visible;
+ clip: auto;
+ white-space: normal;
+}
+
+.shadow-sm {
+ box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;
+}
+
+.shadow {
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
+}
+
+.shadow-lg {
+ box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important;
+}
+
+.shadow-none {
+ box-shadow: none !important;
+}
+
+.w-25 {
+ width: 25% !important;
+}
+
+.w-50 {
+ width: 50% !important;
+}
+
+.w-75 {
+ width: 75% !important;
+}
+
+.w-100 {
+ width: 100% !important;
+}
+
+.w-auto {
+ width: auto !important;
+}
+
+.h-25 {
+ height: 25% !important;
+}
+
+.h-50 {
+ height: 50% !important;
+}
+
+.h-75 {
+ height: 75% !important;
+}
+
+.h-100 {
+ height: 100% !important;
+}
+
+.h-auto {
+ height: auto !important;
+}
+
+.mw-100 {
+ max-width: 100% !important;
+}
+
+.mh-100 {
+ max-height: 100% !important;
+}
+
+.min-vw-100 {
+ min-width: 100vw !important;
+}
+
+.min-vh-100 {
+ min-height: 100vh !important;
+}
+
+.vw-100 {
+ width: 100vw !important;
+}
+
+.vh-100 {
+ height: 100vh !important;
+}
+
+.m-0 {
+ margin: 0 !important;
+}
+
+.mt-0,
+.my-0 {
+ margin-top: 0 !important;
+}
+
+.mr-0,
+.mx-0 {
+ margin-right: 0 !important;
+}
+
+.mb-0,
+.my-0 {
+ margin-bottom: 0 !important;
+}
+
+.ml-0,
+.mx-0 {
+ margin-left: 0 !important;
+}
+
+.m-1 {
+ margin: 0.25rem !important;
+}
+
+.mt-1,
+.my-1 {
+ margin-top: 0.25rem !important;
+}
+
+.mr-1,
+.mx-1 {
+ margin-right: 0.25rem !important;
+}
+
+.mb-1,
+.my-1 {
+ margin-bottom: 0.25rem !important;
+}
+
+.ml-1,
+.mx-1 {
+ margin-left: 0.25rem !important;
+}
+
+.m-2 {
+ margin: 0.5rem !important;
+}
+
+.mt-2,
+.my-2 {
+ margin-top: 0.5rem !important;
+}
+
+.mr-2,
+.mx-2 {
+ margin-right: 0.5rem !important;
+}
+
+.mb-2,
+.my-2 {
+ margin-bottom: 0.5rem !important;
+}
+
+.ml-2,
+.mx-2 {
+ margin-left: 0.5rem !important;
+}
+
+.m-3 {
+ margin: 1rem !important;
+}
+
+.mt-3,
+.my-3 {
+ margin-top: 1rem !important;
+}
+
+.mr-3,
+.mx-3 {
+ margin-right: 1rem !important;
+}
+
+.mb-3,
+.my-3 {
+ margin-bottom: 1rem !important;
+}
+
+.ml-3,
+.mx-3 {
+ margin-left: 1rem !important;
+}
+
+.m-4 {
+ margin: 1.5rem !important;
+}
+
+.mt-4,
+.my-4 {
+ margin-top: 1.5rem !important;
+}
+
+.mr-4,
+.mx-4 {
+ margin-right: 1.5rem !important;
+}
+
+.mb-4,
+.my-4 {
+ margin-bottom: 1.5rem !important;
+}
+
+.ml-4,
+.mx-4 {
+ margin-left: 1.5rem !important;
+}
+
+.m-5 {
+ margin: 3rem !important;
+}
+
+.mt-5,
+.my-5 {
+ margin-top: 3rem !important;
+}
+
+.mr-5,
+.mx-5 {
+ margin-right: 3rem !important;
+}
+
+.mb-5,
+.my-5 {
+ margin-bottom: 3rem !important;
+}
+
+.ml-5,
+.mx-5 {
+ margin-left: 3rem !important;
+}
+
+.p-0 {
+ padding: 0 !important;
+}
+
+.pt-0,
+.py-0 {
+ padding-top: 0 !important;
+}
+
+.pr-0,
+.px-0 {
+ padding-right: 0 !important;
+}
+
+.pb-0,
+.py-0 {
+ padding-bottom: 0 !important;
+}
+
+.pl-0,
+.px-0 {
+ padding-left: 0 !important;
+}
+
+.p-1 {
+ padding: 0.25rem !important;
+}
+
+.pt-1,
+.py-1 {
+ padding-top: 0.25rem !important;
+}
+
+.pr-1,
+.px-1 {
+ padding-right: 0.25rem !important;
+}
+
+.pb-1,
+.py-1 {
+ padding-bottom: 0.25rem !important;
+}
+
+.pl-1,
+.px-1 {
+ padding-left: 0.25rem !important;
+}
+
+.p-2 {
+ padding: 0.5rem !important;
+}
+
+.pt-2,
+.py-2 {
+ padding-top: 0.5rem !important;
+}
+
+.pr-2,
+.px-2 {
+ padding-right: 0.5rem !important;
+}
+
+.pb-2,
+.py-2 {
+ padding-bottom: 0.5rem !important;
+}
+
+.pl-2,
+.px-2 {
+ padding-left: 0.5rem !important;
+}
+
+.p-3 {
+ padding: 1rem !important;
+}
+
+.pt-3,
+.py-3 {
+ padding-top: 1rem !important;
+}
+
+.pr-3,
+.px-3 {
+ padding-right: 1rem !important;
+}
+
+.pb-3,
+.py-3 {
+ padding-bottom: 1rem !important;
+}
+
+.pl-3,
+.px-3 {
+ padding-left: 1rem !important;
+}
+
+.p-4 {
+ padding: 1.5rem !important;
+}
+
+.pt-4,
+.py-4 {
+ padding-top: 1.5rem !important;
+}
+
+.pr-4,
+.px-4 {
+ padding-right: 1.5rem !important;
+}
+
+.pb-4,
+.py-4 {
+ padding-bottom: 1.5rem !important;
+}
+
+.pl-4,
+.px-4 {
+ padding-left: 1.5rem !important;
+}
+
+.p-5 {
+ padding: 3rem !important;
+}
+
+.pt-5,
+.py-5 {
+ padding-top: 3rem !important;
+}
+
+.pr-5,
+.px-5 {
+ padding-right: 3rem !important;
+}
+
+.pb-5,
+.py-5 {
+ padding-bottom: 3rem !important;
+}
+
+.pl-5,
+.px-5 {
+ padding-left: 3rem !important;
+}
+
+.m-n1 {
+ margin: -0.25rem !important;
+}
+
+.mt-n1,
+.my-n1 {
+ margin-top: -0.25rem !important;
+}
+
+.mr-n1,
+.mx-n1 {
+ margin-right: -0.25rem !important;
+}
+
+.mb-n1,
+.my-n1 {
+ margin-bottom: -0.25rem !important;
+}
+
+.ml-n1,
+.mx-n1 {
+ margin-left: -0.25rem !important;
+}
+
+.m-n2 {
+ margin: -0.5rem !important;
+}
+
+.mt-n2,
+.my-n2 {
+ margin-top: -0.5rem !important;
+}
+
+.mr-n2,
+.mx-n2 {
+ margin-right: -0.5rem !important;
+}
+
+.mb-n2,
+.my-n2 {
+ margin-bottom: -0.5rem !important;
+}
+
+.ml-n2,
+.mx-n2 {
+ margin-left: -0.5rem !important;
+}
+
+.m-n3 {
+ margin: -1rem !important;
+}
+
+.mt-n3,
+.my-n3 {
+ margin-top: -1rem !important;
+}
+
+.mr-n3,
+.mx-n3 {
+ margin-right: -1rem !important;
+}
+
+.mb-n3,
+.my-n3 {
+ margin-bottom: -1rem !important;
+}
+
+.ml-n3,
+.mx-n3 {
+ margin-left: -1rem !important;
+}
+
+.m-n4 {
+ margin: -1.5rem !important;
+}
+
+.mt-n4,
+.my-n4 {
+ margin-top: -1.5rem !important;
+}
+
+.mr-n4,
+.mx-n4 {
+ margin-right: -1.5rem !important;
+}
+
+.mb-n4,
+.my-n4 {
+ margin-bottom: -1.5rem !important;
+}
+
+.ml-n4,
+.mx-n4 {
+ margin-left: -1.5rem !important;
+}
+
+.m-n5 {
+ margin: -3rem !important;
+}
+
+.mt-n5,
+.my-n5 {
+ margin-top: -3rem !important;
+}
+
+.mr-n5,
+.mx-n5 {
+ margin-right: -3rem !important;
+}
+
+.mb-n5,
+.my-n5 {
+ margin-bottom: -3rem !important;
+}
+
+.ml-n5,
+.mx-n5 {
+ margin-left: -3rem !important;
+}
+
+.m-auto {
+ margin: auto !important;
+}
+
+.mt-auto,
+.my-auto {
+ margin-top: auto !important;
+}
+
+.mr-auto,
+.mx-auto {
+ margin-right: auto !important;
+}
+
+.mb-auto,
+.my-auto {
+ margin-bottom: auto !important;
+}
+
+.ml-auto,
+.mx-auto {
+ margin-left: auto !important;
+}
+
+@media (min-width: 576px) {
+ .m-sm-0 {
+ margin: 0 !important;
+ }
+ .mt-sm-0,
+ .my-sm-0 {
+ margin-top: 0 !important;
+ }
+ .mr-sm-0,
+ .mx-sm-0 {
+ margin-right: 0 !important;
+ }
+ .mb-sm-0,
+ .my-sm-0 {
+ margin-bottom: 0 !important;
+ }
+ .ml-sm-0,
+ .mx-sm-0 {
+ margin-left: 0 !important;
+ }
+ .m-sm-1 {
+ margin: 0.25rem !important;
+ }
+ .mt-sm-1,
+ .my-sm-1 {
+ margin-top: 0.25rem !important;
+ }
+ .mr-sm-1,
+ .mx-sm-1 {
+ margin-right: 0.25rem !important;
+ }
+ .mb-sm-1,
+ .my-sm-1 {
+ margin-bottom: 0.25rem !important;
+ }
+ .ml-sm-1,
+ .mx-sm-1 {
+ margin-left: 0.25rem !important;
+ }
+ .m-sm-2 {
+ margin: 0.5rem !important;
+ }
+ .mt-sm-2,
+ .my-sm-2 {
+ margin-top: 0.5rem !important;
+ }
+ .mr-sm-2,
+ .mx-sm-2 {
+ margin-right: 0.5rem !important;
+ }
+ .mb-sm-2,
+ .my-sm-2 {
+ margin-bottom: 0.5rem !important;
+ }
+ .ml-sm-2,
+ .mx-sm-2 {
+ margin-left: 0.5rem !important;
+ }
+ .m-sm-3 {
+ margin: 1rem !important;
+ }
+ .mt-sm-3,
+ .my-sm-3 {
+ margin-top: 1rem !important;
+ }
+ .mr-sm-3,
+ .mx-sm-3 {
+ margin-right: 1rem !important;
+ }
+ .mb-sm-3,
+ .my-sm-3 {
+ margin-bottom: 1rem !important;
+ }
+ .ml-sm-3,
+ .mx-sm-3 {
+ margin-left: 1rem !important;
+ }
+ .m-sm-4 {
+ margin: 1.5rem !important;
+ }
+ .mt-sm-4,
+ .my-sm-4 {
+ margin-top: 1.5rem !important;
+ }
+ .mr-sm-4,
+ .mx-sm-4 {
+ margin-right: 1.5rem !important;
+ }
+ .mb-sm-4,
+ .my-sm-4 {
+ margin-bottom: 1.5rem !important;
+ }
+ .ml-sm-4,
+ .mx-sm-4 {
+ margin-left: 1.5rem !important;
+ }
+ .m-sm-5 {
+ margin: 3rem !important;
+ }
+ .mt-sm-5,
+ .my-sm-5 {
+ margin-top: 3rem !important;
+ }
+ .mr-sm-5,
+ .mx-sm-5 {
+ margin-right: 3rem !important;
+ }
+ .mb-sm-5,
+ .my-sm-5 {
+ margin-bottom: 3rem !important;
+ }
+ .ml-sm-5,
+ .mx-sm-5 {
+ margin-left: 3rem !important;
+ }
+ .p-sm-0 {
+ padding: 0 !important;
+ }
+ .pt-sm-0,
+ .py-sm-0 {
+ padding-top: 0 !important;
+ }
+ .pr-sm-0,
+ .px-sm-0 {
+ padding-right: 0 !important;
+ }
+ .pb-sm-0,
+ .py-sm-0 {
+ padding-bottom: 0 !important;
+ }
+ .pl-sm-0,
+ .px-sm-0 {
+ padding-left: 0 !important;
+ }
+ .p-sm-1 {
+ padding: 0.25rem !important;
+ }
+ .pt-sm-1,
+ .py-sm-1 {
+ padding-top: 0.25rem !important;
+ }
+ .pr-sm-1,
+ .px-sm-1 {
+ padding-right: 0.25rem !important;
+ }
+ .pb-sm-1,
+ .py-sm-1 {
+ padding-bottom: 0.25rem !important;
+ }
+ .pl-sm-1,
+ .px-sm-1 {
+ padding-left: 0.25rem !important;
+ }
+ .p-sm-2 {
+ padding: 0.5rem !important;
+ }
+ .pt-sm-2,
+ .py-sm-2 {
+ padding-top: 0.5rem !important;
+ }
+ .pr-sm-2,
+ .px-sm-2 {
+ padding-right: 0.5rem !important;
+ }
+ .pb-sm-2,
+ .py-sm-2 {
+ padding-bottom: 0.5rem !important;
+ }
+ .pl-sm-2,
+ .px-sm-2 {
+ padding-left: 0.5rem !important;
+ }
+ .p-sm-3 {
+ padding: 1rem !important;
+ }
+ .pt-sm-3,
+ .py-sm-3 {
+ padding-top: 1rem !important;
+ }
+ .pr-sm-3,
+ .px-sm-3 {
+ padding-right: 1rem !important;
+ }
+ .pb-sm-3,
+ .py-sm-3 {
+ padding-bottom: 1rem !important;
+ }
+ .pl-sm-3,
+ .px-sm-3 {
+ padding-left: 1rem !important;
+ }
+ .p-sm-4 {
+ padding: 1.5rem !important;
+ }
+ .pt-sm-4,
+ .py-sm-4 {
+ padding-top: 1.5rem !important;
+ }
+ .pr-sm-4,
+ .px-sm-4 {
+ padding-right: 1.5rem !important;
+ }
+ .pb-sm-4,
+ .py-sm-4 {
+ padding-bottom: 1.5rem !important;
+ }
+ .pl-sm-4,
+ .px-sm-4 {
+ padding-left: 1.5rem !important;
+ }
+ .p-sm-5 {
+ padding: 3rem !important;
+ }
+ .pt-sm-5,
+ .py-sm-5 {
+ padding-top: 3rem !important;
+ }
+ .pr-sm-5,
+ .px-sm-5 {
+ padding-right: 3rem !important;
+ }
+ .pb-sm-5,
+ .py-sm-5 {
+ padding-bottom: 3rem !important;
+ }
+ .pl-sm-5,
+ .px-sm-5 {
+ padding-left: 3rem !important;
+ }
+ .m-sm-n1 {
+ margin: -0.25rem !important;
+ }
+ .mt-sm-n1,
+ .my-sm-n1 {
+ margin-top: -0.25rem !important;
+ }
+ .mr-sm-n1,
+ .mx-sm-n1 {
+ margin-right: -0.25rem !important;
+ }
+ .mb-sm-n1,
+ .my-sm-n1 {
+ margin-bottom: -0.25rem !important;
+ }
+ .ml-sm-n1,
+ .mx-sm-n1 {
+ margin-left: -0.25rem !important;
+ }
+ .m-sm-n2 {
+ margin: -0.5rem !important;
+ }
+ .mt-sm-n2,
+ .my-sm-n2 {
+ margin-top: -0.5rem !important;
+ }
+ .mr-sm-n2,
+ .mx-sm-n2 {
+ margin-right: -0.5rem !important;
+ }
+ .mb-sm-n2,
+ .my-sm-n2 {
+ margin-bottom: -0.5rem !important;
+ }
+ .ml-sm-n2,
+ .mx-sm-n2 {
+ margin-left: -0.5rem !important;
+ }
+ .m-sm-n3 {
+ margin: -1rem !important;
+ }
+ .mt-sm-n3,
+ .my-sm-n3 {
+ margin-top: -1rem !important;
+ }
+ .mr-sm-n3,
+ .mx-sm-n3 {
+ margin-right: -1rem !important;
+ }
+ .mb-sm-n3,
+ .my-sm-n3 {
+ margin-bottom: -1rem !important;
+ }
+ .ml-sm-n3,
+ .mx-sm-n3 {
+ margin-left: -1rem !important;
+ }
+ .m-sm-n4 {
+ margin: -1.5rem !important;
+ }
+ .mt-sm-n4,
+ .my-sm-n4 {
+ margin-top: -1.5rem !important;
+ }
+ .mr-sm-n4,
+ .mx-sm-n4 {
+ margin-right: -1.5rem !important;
+ }
+ .mb-sm-n4,
+ .my-sm-n4 {
+ margin-bottom: -1.5rem !important;
+ }
+ .ml-sm-n4,
+ .mx-sm-n4 {
+ margin-left: -1.5rem !important;
+ }
+ .m-sm-n5 {
+ margin: -3rem !important;
+ }
+ .mt-sm-n5,
+ .my-sm-n5 {
+ margin-top: -3rem !important;
+ }
+ .mr-sm-n5,
+ .mx-sm-n5 {
+ margin-right: -3rem !important;
+ }
+ .mb-sm-n5,
+ .my-sm-n5 {
+ margin-bottom: -3rem !important;
+ }
+ .ml-sm-n5,
+ .mx-sm-n5 {
+ margin-left: -3rem !important;
+ }
+ .m-sm-auto {
+ margin: auto !important;
+ }
+ .mt-sm-auto,
+ .my-sm-auto {
+ margin-top: auto !important;
+ }
+ .mr-sm-auto,
+ .mx-sm-auto {
+ margin-right: auto !important;
+ }
+ .mb-sm-auto,
+ .my-sm-auto {
+ margin-bottom: auto !important;
+ }
+ .ml-sm-auto,
+ .mx-sm-auto {
+ margin-left: auto !important;
+ }
+}
+
+@media (min-width: 768px) {
+ .m-md-0 {
+ margin: 0 !important;
+ }
+ .mt-md-0,
+ .my-md-0 {
+ margin-top: 0 !important;
+ }
+ .mr-md-0,
+ .mx-md-0 {
+ margin-right: 0 !important;
+ }
+ .mb-md-0,
+ .my-md-0 {
+ margin-bottom: 0 !important;
+ }
+ .ml-md-0,
+ .mx-md-0 {
+ margin-left: 0 !important;
+ }
+ .m-md-1 {
+ margin: 0.25rem !important;
+ }
+ .mt-md-1,
+ .my-md-1 {
+ margin-top: 0.25rem !important;
+ }
+ .mr-md-1,
+ .mx-md-1 {
+ margin-right: 0.25rem !important;
+ }
+ .mb-md-1,
+ .my-md-1 {
+ margin-bottom: 0.25rem !important;
+ }
+ .ml-md-1,
+ .mx-md-1 {
+ margin-left: 0.25rem !important;
+ }
+ .m-md-2 {
+ margin: 0.5rem !important;
+ }
+ .mt-md-2,
+ .my-md-2 {
+ margin-top: 0.5rem !important;
+ }
+ .mr-md-2,
+ .mx-md-2 {
+ margin-right: 0.5rem !important;
+ }
+ .mb-md-2,
+ .my-md-2 {
+ margin-bottom: 0.5rem !important;
+ }
+ .ml-md-2,
+ .mx-md-2 {
+ margin-left: 0.5rem !important;
+ }
+ .m-md-3 {
+ margin: 1rem !important;
+ }
+ .mt-md-3,
+ .my-md-3 {
+ margin-top: 1rem !important;
+ }
+ .mr-md-3,
+ .mx-md-3 {
+ margin-right: 1rem !important;
+ }
+ .mb-md-3,
+ .my-md-3 {
+ margin-bottom: 1rem !important;
+ }
+ .ml-md-3,
+ .mx-md-3 {
+ margin-left: 1rem !important;
+ }
+ .m-md-4 {
+ margin: 1.5rem !important;
+ }
+ .mt-md-4,
+ .my-md-4 {
+ margin-top: 1.5rem !important;
+ }
+ .mr-md-4,
+ .mx-md-4 {
+ margin-right: 1.5rem !important;
+ }
+ .mb-md-4,
+ .my-md-4 {
+ margin-bottom: 1.5rem !important;
+ }
+ .ml-md-4,
+ .mx-md-4 {
+ margin-left: 1.5rem !important;
+ }
+ .m-md-5 {
+ margin: 3rem !important;
+ }
+ .mt-md-5,
+ .my-md-5 {
+ margin-top: 3rem !important;
+ }
+ .mr-md-5,
+ .mx-md-5 {
+ margin-right: 3rem !important;
+ }
+ .mb-md-5,
+ .my-md-5 {
+ margin-bottom: 3rem !important;
+ }
+ .ml-md-5,
+ .mx-md-5 {
+ margin-left: 3rem !important;
+ }
+ .p-md-0 {
+ padding: 0 !important;
+ }
+ .pt-md-0,
+ .py-md-0 {
+ padding-top: 0 !important;
+ }
+ .pr-md-0,
+ .px-md-0 {
+ padding-right: 0 !important;
+ }
+ .pb-md-0,
+ .py-md-0 {
+ padding-bottom: 0 !important;
+ }
+ .pl-md-0,
+ .px-md-0 {
+ padding-left: 0 !important;
+ }
+ .p-md-1 {
+ padding: 0.25rem !important;
+ }
+ .pt-md-1,
+ .py-md-1 {
+ padding-top: 0.25rem !important;
+ }
+ .pr-md-1,
+ .px-md-1 {
+ padding-right: 0.25rem !important;
+ }
+ .pb-md-1,
+ .py-md-1 {
+ padding-bottom: 0.25rem !important;
+ }
+ .pl-md-1,
+ .px-md-1 {
+ padding-left: 0.25rem !important;
+ }
+ .p-md-2 {
+ padding: 0.5rem !important;
+ }
+ .pt-md-2,
+ .py-md-2 {
+ padding-top: 0.5rem !important;
+ }
+ .pr-md-2,
+ .px-md-2 {
+ padding-right: 0.5rem !important;
+ }
+ .pb-md-2,
+ .py-md-2 {
+ padding-bottom: 0.5rem !important;
+ }
+ .pl-md-2,
+ .px-md-2 {
+ padding-left: 0.5rem !important;
+ }
+ .p-md-3 {
+ padding: 1rem !important;
+ }
+ .pt-md-3,
+ .py-md-3 {
+ padding-top: 1rem !important;
+ }
+ .pr-md-3,
+ .px-md-3 {
+ padding-right: 1rem !important;
+ }
+ .pb-md-3,
+ .py-md-3 {
+ padding-bottom: 1rem !important;
+ }
+ .pl-md-3,
+ .px-md-3 {
+ padding-left: 1rem !important;
+ }
+ .p-md-4 {
+ padding: 1.5rem !important;
+ }
+ .pt-md-4,
+ .py-md-4 {
+ padding-top: 1.5rem !important;
+ }
+ .pr-md-4,
+ .px-md-4 {
+ padding-right: 1.5rem !important;
+ }
+ .pb-md-4,
+ .py-md-4 {
+ padding-bottom: 1.5rem !important;
+ }
+ .pl-md-4,
+ .px-md-4 {
+ padding-left: 1.5rem !important;
+ }
+ .p-md-5 {
+ padding: 3rem !important;
+ }
+ .pt-md-5,
+ .py-md-5 {
+ padding-top: 3rem !important;
+ }
+ .pr-md-5,
+ .px-md-5 {
+ padding-right: 3rem !important;
+ }
+ .pb-md-5,
+ .py-md-5 {
+ padding-bottom: 3rem !important;
+ }
+ .pl-md-5,
+ .px-md-5 {
+ padding-left: 3rem !important;
+ }
+ .m-md-n1 {
+ margin: -0.25rem !important;
+ }
+ .mt-md-n1,
+ .my-md-n1 {
+ margin-top: -0.25rem !important;
+ }
+ .mr-md-n1,
+ .mx-md-n1 {
+ margin-right: -0.25rem !important;
+ }
+ .mb-md-n1,
+ .my-md-n1 {
+ margin-bottom: -0.25rem !important;
+ }
+ .ml-md-n1,
+ .mx-md-n1 {
+ margin-left: -0.25rem !important;
+ }
+ .m-md-n2 {
+ margin: -0.5rem !important;
+ }
+ .mt-md-n2,
+ .my-md-n2 {
+ margin-top: -0.5rem !important;
+ }
+ .mr-md-n2,
+ .mx-md-n2 {
+ margin-right: -0.5rem !important;
+ }
+ .mb-md-n2,
+ .my-md-n2 {
+ margin-bottom: -0.5rem !important;
+ }
+ .ml-md-n2,
+ .mx-md-n2 {
+ margin-left: -0.5rem !important;
+ }
+ .m-md-n3 {
+ margin: -1rem !important;
+ }
+ .mt-md-n3,
+ .my-md-n3 {
+ margin-top: -1rem !important;
+ }
+ .mr-md-n3,
+ .mx-md-n3 {
+ margin-right: -1rem !important;
+ }
+ .mb-md-n3,
+ .my-md-n3 {
+ margin-bottom: -1rem !important;
+ }
+ .ml-md-n3,
+ .mx-md-n3 {
+ margin-left: -1rem !important;
+ }
+ .m-md-n4 {
+ margin: -1.5rem !important;
+ }
+ .mt-md-n4,
+ .my-md-n4 {
+ margin-top: -1.5rem !important;
+ }
+ .mr-md-n4,
+ .mx-md-n4 {
+ margin-right: -1.5rem !important;
+ }
+ .mb-md-n4,
+ .my-md-n4 {
+ margin-bottom: -1.5rem !important;
+ }
+ .ml-md-n4,
+ .mx-md-n4 {
+ margin-left: -1.5rem !important;
+ }
+ .m-md-n5 {
+ margin: -3rem !important;
+ }
+ .mt-md-n5,
+ .my-md-n5 {
+ margin-top: -3rem !important;
+ }
+ .mr-md-n5,
+ .mx-md-n5 {
+ margin-right: -3rem !important;
+ }
+ .mb-md-n5,
+ .my-md-n5 {
+ margin-bottom: -3rem !important;
+ }
+ .ml-md-n5,
+ .mx-md-n5 {
+ margin-left: -3rem !important;
+ }
+ .m-md-auto {
+ margin: auto !important;
+ }
+ .mt-md-auto,
+ .my-md-auto {
+ margin-top: auto !important;
+ }
+ .mr-md-auto,
+ .mx-md-auto {
+ margin-right: auto !important;
+ }
+ .mb-md-auto,
+ .my-md-auto {
+ margin-bottom: auto !important;
+ }
+ .ml-md-auto,
+ .mx-md-auto {
+ margin-left: auto !important;
+ }
+}
+
+@media (min-width: 992px) {
+ .m-lg-0 {
+ margin: 0 !important;
+ }
+ .mt-lg-0,
+ .my-lg-0 {
+ margin-top: 0 !important;
+ }
+ .mr-lg-0,
+ .mx-lg-0 {
+ margin-right: 0 !important;
+ }
+ .mb-lg-0,
+ .my-lg-0 {
+ margin-bottom: 0 !important;
+ }
+ .ml-lg-0,
+ .mx-lg-0 {
+ margin-left: 0 !important;
+ }
+ .m-lg-1 {
+ margin: 0.25rem !important;
+ }
+ .mt-lg-1,
+ .my-lg-1 {
+ margin-top: 0.25rem !important;
+ }
+ .mr-lg-1,
+ .mx-lg-1 {
+ margin-right: 0.25rem !important;
+ }
+ .mb-lg-1,
+ .my-lg-1 {
+ margin-bottom: 0.25rem !important;
+ }
+ .ml-lg-1,
+ .mx-lg-1 {
+ margin-left: 0.25rem !important;
+ }
+ .m-lg-2 {
+ margin: 0.5rem !important;
+ }
+ .mt-lg-2,
+ .my-lg-2 {
+ margin-top: 0.5rem !important;
+ }
+ .mr-lg-2,
+ .mx-lg-2 {
+ margin-right: 0.5rem !important;
+ }
+ .mb-lg-2,
+ .my-lg-2 {
+ margin-bottom: 0.5rem !important;
+ }
+ .ml-lg-2,
+ .mx-lg-2 {
+ margin-left: 0.5rem !important;
+ }
+ .m-lg-3 {
+ margin: 1rem !important;
+ }
+ .mt-lg-3,
+ .my-lg-3 {
+ margin-top: 1rem !important;
+ }
+ .mr-lg-3,
+ .mx-lg-3 {
+ margin-right: 1rem !important;
+ }
+ .mb-lg-3,
+ .my-lg-3 {
+ margin-bottom: 1rem !important;
+ }
+ .ml-lg-3,
+ .mx-lg-3 {
+ margin-left: 1rem !important;
+ }
+ .m-lg-4 {
+ margin: 1.5rem !important;
+ }
+ .mt-lg-4,
+ .my-lg-4 {
+ margin-top: 1.5rem !important;
+ }
+ .mr-lg-4,
+ .mx-lg-4 {
+ margin-right: 1.5rem !important;
+ }
+ .mb-lg-4,
+ .my-lg-4 {
+ margin-bottom: 1.5rem !important;
+ }
+ .ml-lg-4,
+ .mx-lg-4 {
+ margin-left: 1.5rem !important;
+ }
+ .m-lg-5 {
+ margin: 3rem !important;
+ }
+ .mt-lg-5,
+ .my-lg-5 {
+ margin-top: 3rem !important;
+ }
+ .mr-lg-5,
+ .mx-lg-5 {
+ margin-right: 3rem !important;
+ }
+ .mb-lg-5,
+ .my-lg-5 {
+ margin-bottom: 3rem !important;
+ }
+ .ml-lg-5,
+ .mx-lg-5 {
+ margin-left: 3rem !important;
+ }
+ .p-lg-0 {
+ padding: 0 !important;
+ }
+ .pt-lg-0,
+ .py-lg-0 {
+ padding-top: 0 !important;
+ }
+ .pr-lg-0,
+ .px-lg-0 {
+ padding-right: 0 !important;
+ }
+ .pb-lg-0,
+ .py-lg-0 {
+ padding-bottom: 0 !important;
+ }
+ .pl-lg-0,
+ .px-lg-0 {
+ padding-left: 0 !important;
+ }
+ .p-lg-1 {
+ padding: 0.25rem !important;
+ }
+ .pt-lg-1,
+ .py-lg-1 {
+ padding-top: 0.25rem !important;
+ }
+ .pr-lg-1,
+ .px-lg-1 {
+ padding-right: 0.25rem !important;
+ }
+ .pb-lg-1,
+ .py-lg-1 {
+ padding-bottom: 0.25rem !important;
+ }
+ .pl-lg-1,
+ .px-lg-1 {
+ padding-left: 0.25rem !important;
+ }
+ .p-lg-2 {
+ padding: 0.5rem !important;
+ }
+ .pt-lg-2,
+ .py-lg-2 {
+ padding-top: 0.5rem !important;
+ }
+ .pr-lg-2,
+ .px-lg-2 {
+ padding-right: 0.5rem !important;
+ }
+ .pb-lg-2,
+ .py-lg-2 {
+ padding-bottom: 0.5rem !important;
+ }
+ .pl-lg-2,
+ .px-lg-2 {
+ padding-left: 0.5rem !important;
+ }
+ .p-lg-3 {
+ padding: 1rem !important;
+ }
+ .pt-lg-3,
+ .py-lg-3 {
+ padding-top: 1rem !important;
+ }
+ .pr-lg-3,
+ .px-lg-3 {
+ padding-right: 1rem !important;
+ }
+ .pb-lg-3,
+ .py-lg-3 {
+ padding-bottom: 1rem !important;
+ }
+ .pl-lg-3,
+ .px-lg-3 {
+ padding-left: 1rem !important;
+ }
+ .p-lg-4 {
+ padding: 1.5rem !important;
+ }
+ .pt-lg-4,
+ .py-lg-4 {
+ padding-top: 1.5rem !important;
+ }
+ .pr-lg-4,
+ .px-lg-4 {
+ padding-right: 1.5rem !important;
+ }
+ .pb-lg-4,
+ .py-lg-4 {
+ padding-bottom: 1.5rem !important;
+ }
+ .pl-lg-4,
+ .px-lg-4 {
+ padding-left: 1.5rem !important;
+ }
+ .p-lg-5 {
+ padding: 3rem !important;
+ }
+ .pt-lg-5,
+ .py-lg-5 {
+ padding-top: 3rem !important;
+ }
+ .pr-lg-5,
+ .px-lg-5 {
+ padding-right: 3rem !important;
+ }
+ .pb-lg-5,
+ .py-lg-5 {
+ padding-bottom: 3rem !important;
+ }
+ .pl-lg-5,
+ .px-lg-5 {
+ padding-left: 3rem !important;
+ }
+ .m-lg-n1 {
+ margin: -0.25rem !important;
+ }
+ .mt-lg-n1,
+ .my-lg-n1 {
+ margin-top: -0.25rem !important;
+ }
+ .mr-lg-n1,
+ .mx-lg-n1 {
+ margin-right: -0.25rem !important;
+ }
+ .mb-lg-n1,
+ .my-lg-n1 {
+ margin-bottom: -0.25rem !important;
+ }
+ .ml-lg-n1,
+ .mx-lg-n1 {
+ margin-left: -0.25rem !important;
+ }
+ .m-lg-n2 {
+ margin: -0.5rem !important;
+ }
+ .mt-lg-n2,
+ .my-lg-n2 {
+ margin-top: -0.5rem !important;
+ }
+ .mr-lg-n2,
+ .mx-lg-n2 {
+ margin-right: -0.5rem !important;
+ }
+ .mb-lg-n2,
+ .my-lg-n2 {
+ margin-bottom: -0.5rem !important;
+ }
+ .ml-lg-n2,
+ .mx-lg-n2 {
+ margin-left: -0.5rem !important;
+ }
+ .m-lg-n3 {
+ margin: -1rem !important;
+ }
+ .mt-lg-n3,
+ .my-lg-n3 {
+ margin-top: -1rem !important;
+ }
+ .mr-lg-n3,
+ .mx-lg-n3 {
+ margin-right: -1rem !important;
+ }
+ .mb-lg-n3,
+ .my-lg-n3 {
+ margin-bottom: -1rem !important;
+ }
+ .ml-lg-n3,
+ .mx-lg-n3 {
+ margin-left: -1rem !important;
+ }
+ .m-lg-n4 {
+ margin: -1.5rem !important;
+ }
+ .mt-lg-n4,
+ .my-lg-n4 {
+ margin-top: -1.5rem !important;
+ }
+ .mr-lg-n4,
+ .mx-lg-n4 {
+ margin-right: -1.5rem !important;
+ }
+ .mb-lg-n4,
+ .my-lg-n4 {
+ margin-bottom: -1.5rem !important;
+ }
+ .ml-lg-n4,
+ .mx-lg-n4 {
+ margin-left: -1.5rem !important;
+ }
+ .m-lg-n5 {
+ margin: -3rem !important;
+ }
+ .mt-lg-n5,
+ .my-lg-n5 {
+ margin-top: -3rem !important;
+ }
+ .mr-lg-n5,
+ .mx-lg-n5 {
+ margin-right: -3rem !important;
+ }
+ .mb-lg-n5,
+ .my-lg-n5 {
+ margin-bottom: -3rem !important;
+ }
+ .ml-lg-n5,
+ .mx-lg-n5 {
+ margin-left: -3rem !important;
+ }
+ .m-lg-auto {
+ margin: auto !important;
+ }
+ .mt-lg-auto,
+ .my-lg-auto {
+ margin-top: auto !important;
+ }
+ .mr-lg-auto,
+ .mx-lg-auto {
+ margin-right: auto !important;
+ }
+ .mb-lg-auto,
+ .my-lg-auto {
+ margin-bottom: auto !important;
+ }
+ .ml-lg-auto,
+ .mx-lg-auto {
+ margin-left: auto !important;
+ }
+}
+
+@media (min-width: 1200px) {
+ .m-xl-0 {
+ margin: 0 !important;
+ }
+ .mt-xl-0,
+ .my-xl-0 {
+ margin-top: 0 !important;
+ }
+ .mr-xl-0,
+ .mx-xl-0 {
+ margin-right: 0 !important;
+ }
+ .mb-xl-0,
+ .my-xl-0 {
+ margin-bottom: 0 !important;
+ }
+ .ml-xl-0,
+ .mx-xl-0 {
+ margin-left: 0 !important;
+ }
+ .m-xl-1 {
+ margin: 0.25rem !important;
+ }
+ .mt-xl-1,
+ .my-xl-1 {
+ margin-top: 0.25rem !important;
+ }
+ .mr-xl-1,
+ .mx-xl-1 {
+ margin-right: 0.25rem !important;
+ }
+ .mb-xl-1,
+ .my-xl-1 {
+ margin-bottom: 0.25rem !important;
+ }
+ .ml-xl-1,
+ .mx-xl-1 {
+ margin-left: 0.25rem !important;
+ }
+ .m-xl-2 {
+ margin: 0.5rem !important;
+ }
+ .mt-xl-2,
+ .my-xl-2 {
+ margin-top: 0.5rem !important;
+ }
+ .mr-xl-2,
+ .mx-xl-2 {
+ margin-right: 0.5rem !important;
+ }
+ .mb-xl-2,
+ .my-xl-2 {
+ margin-bottom: 0.5rem !important;
+ }
+ .ml-xl-2,
+ .mx-xl-2 {
+ margin-left: 0.5rem !important;
+ }
+ .m-xl-3 {
+ margin: 1rem !important;
+ }
+ .mt-xl-3,
+ .my-xl-3 {
+ margin-top: 1rem !important;
+ }
+ .mr-xl-3,
+ .mx-xl-3 {
+ margin-right: 1rem !important;
+ }
+ .mb-xl-3,
+ .my-xl-3 {
+ margin-bottom: 1rem !important;
+ }
+ .ml-xl-3,
+ .mx-xl-3 {
+ margin-left: 1rem !important;
+ }
+ .m-xl-4 {
+ margin: 1.5rem !important;
+ }
+ .mt-xl-4,
+ .my-xl-4 {
+ margin-top: 1.5rem !important;
+ }
+ .mr-xl-4,
+ .mx-xl-4 {
+ margin-right: 1.5rem !important;
+ }
+ .mb-xl-4,
+ .my-xl-4 {
+ margin-bottom: 1.5rem !important;
+ }
+ .ml-xl-4,
+ .mx-xl-4 {
+ margin-left: 1.5rem !important;
+ }
+ .m-xl-5 {
+ margin: 3rem !important;
+ }
+ .mt-xl-5,
+ .my-xl-5 {
+ margin-top: 3rem !important;
+ }
+ .mr-xl-5,
+ .mx-xl-5 {
+ margin-right: 3rem !important;
+ }
+ .mb-xl-5,
+ .my-xl-5 {
+ margin-bottom: 3rem !important;
+ }
+ .ml-xl-5,
+ .mx-xl-5 {
+ margin-left: 3rem !important;
+ }
+ .p-xl-0 {
+ padding: 0 !important;
+ }
+ .pt-xl-0,
+ .py-xl-0 {
+ padding-top: 0 !important;
+ }
+ .pr-xl-0,
+ .px-xl-0 {
+ padding-right: 0 !important;
+ }
+ .pb-xl-0,
+ .py-xl-0 {
+ padding-bottom: 0 !important;
+ }
+ .pl-xl-0,
+ .px-xl-0 {
+ padding-left: 0 !important;
+ }
+ .p-xl-1 {
+ padding: 0.25rem !important;
+ }
+ .pt-xl-1,
+ .py-xl-1 {
+ padding-top: 0.25rem !important;
+ }
+ .pr-xl-1,
+ .px-xl-1 {
+ padding-right: 0.25rem !important;
+ }
+ .pb-xl-1,
+ .py-xl-1 {
+ padding-bottom: 0.25rem !important;
+ }
+ .pl-xl-1,
+ .px-xl-1 {
+ padding-left: 0.25rem !important;
+ }
+ .p-xl-2 {
+ padding: 0.5rem !important;
+ }
+ .pt-xl-2,
+ .py-xl-2 {
+ padding-top: 0.5rem !important;
+ }
+ .pr-xl-2,
+ .px-xl-2 {
+ padding-right: 0.5rem !important;
+ }
+ .pb-xl-2,
+ .py-xl-2 {
+ padding-bottom: 0.5rem !important;
+ }
+ .pl-xl-2,
+ .px-xl-2 {
+ padding-left: 0.5rem !important;
+ }
+ .p-xl-3 {
+ padding: 1rem !important;
+ }
+ .pt-xl-3,
+ .py-xl-3 {
+ padding-top: 1rem !important;
+ }
+ .pr-xl-3,
+ .px-xl-3 {
+ padding-right: 1rem !important;
+ }
+ .pb-xl-3,
+ .py-xl-3 {
+ padding-bottom: 1rem !important;
+ }
+ .pl-xl-3,
+ .px-xl-3 {
+ padding-left: 1rem !important;
+ }
+ .p-xl-4 {
+ padding: 1.5rem !important;
+ }
+ .pt-xl-4,
+ .py-xl-4 {
+ padding-top: 1.5rem !important;
+ }
+ .pr-xl-4,
+ .px-xl-4 {
+ padding-right: 1.5rem !important;
+ }
+ .pb-xl-4,
+ .py-xl-4 {
+ padding-bottom: 1.5rem !important;
+ }
+ .pl-xl-4,
+ .px-xl-4 {
+ padding-left: 1.5rem !important;
+ }
+ .p-xl-5 {
+ padding: 3rem !important;
+ }
+ .pt-xl-5,
+ .py-xl-5 {
+ padding-top: 3rem !important;
+ }
+ .pr-xl-5,
+ .px-xl-5 {
+ padding-right: 3rem !important;
+ }
+ .pb-xl-5,
+ .py-xl-5 {
+ padding-bottom: 3rem !important;
+ }
+ .pl-xl-5,
+ .px-xl-5 {
+ padding-left: 3rem !important;
+ }
+ .m-xl-n1 {
+ margin: -0.25rem !important;
+ }
+ .mt-xl-n1,
+ .my-xl-n1 {
+ margin-top: -0.25rem !important;
+ }
+ .mr-xl-n1,
+ .mx-xl-n1 {
+ margin-right: -0.25rem !important;
+ }
+ .mb-xl-n1,
+ .my-xl-n1 {
+ margin-bottom: -0.25rem !important;
+ }
+ .ml-xl-n1,
+ .mx-xl-n1 {
+ margin-left: -0.25rem !important;
+ }
+ .m-xl-n2 {
+ margin: -0.5rem !important;
+ }
+ .mt-xl-n2,
+ .my-xl-n2 {
+ margin-top: -0.5rem !important;
+ }
+ .mr-xl-n2,
+ .mx-xl-n2 {
+ margin-right: -0.5rem !important;
+ }
+ .mb-xl-n2,
+ .my-xl-n2 {
+ margin-bottom: -0.5rem !important;
+ }
+ .ml-xl-n2,
+ .mx-xl-n2 {
+ margin-left: -0.5rem !important;
+ }
+ .m-xl-n3 {
+ margin: -1rem !important;
+ }
+ .mt-xl-n3,
+ .my-xl-n3 {
+ margin-top: -1rem !important;
+ }
+ .mr-xl-n3,
+ .mx-xl-n3 {
+ margin-right: -1rem !important;
+ }
+ .mb-xl-n3,
+ .my-xl-n3 {
+ margin-bottom: -1rem !important;
+ }
+ .ml-xl-n3,
+ .mx-xl-n3 {
+ margin-left: -1rem !important;
+ }
+ .m-xl-n4 {
+ margin: -1.5rem !important;
+ }
+ .mt-xl-n4,
+ .my-xl-n4 {
+ margin-top: -1.5rem !important;
+ }
+ .mr-xl-n4,
+ .mx-xl-n4 {
+ margin-right: -1.5rem !important;
+ }
+ .mb-xl-n4,
+ .my-xl-n4 {
+ margin-bottom: -1.5rem !important;
+ }
+ .ml-xl-n4,
+ .mx-xl-n4 {
+ margin-left: -1.5rem !important;
+ }
+ .m-xl-n5 {
+ margin: -3rem !important;
+ }
+ .mt-xl-n5,
+ .my-xl-n5 {
+ margin-top: -3rem !important;
+ }
+ .mr-xl-n5,
+ .mx-xl-n5 {
+ margin-right: -3rem !important;
+ }
+ .mb-xl-n5,
+ .my-xl-n5 {
+ margin-bottom: -3rem !important;
+ }
+ .ml-xl-n5,
+ .mx-xl-n5 {
+ margin-left: -3rem !important;
+ }
+ .m-xl-auto {
+ margin: auto !important;
+ }
+ .mt-xl-auto,
+ .my-xl-auto {
+ margin-top: auto !important;
+ }
+ .mr-xl-auto,
+ .mx-xl-auto {
+ margin-right: auto !important;
+ }
+ .mb-xl-auto,
+ .my-xl-auto {
+ margin-bottom: auto !important;
+ }
+ .ml-xl-auto,
+ .mx-xl-auto {
+ margin-left: auto !important;
+ }
+}
+
+.stretched-link::after {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 1;
+ pointer-events: auto;
+ content: "";
+ background-color: rgba(0, 0, 0, 0);
+}
+
+.text-monospace {
+ font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
+}
+
+.text-justify {
+ text-align: justify !important;
+}
+
+.text-wrap {
+ white-space: normal !important;
+}
+
+.text-nowrap {
+ white-space: nowrap !important;
+}
+
+.text-truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.text-left {
+ text-align: left !important;
+}
+
+.text-right {
+ text-align: right !important;
+}
+
+.text-center {
+ text-align: center !important;
+}
+
+@media (min-width: 576px) {
+ .text-sm-left {
+ text-align: left !important;
+ }
+ .text-sm-right {
+ text-align: right !important;
+ }
+ .text-sm-center {
+ text-align: center !important;
+ }
+}
+
+@media (min-width: 768px) {
+ .text-md-left {
+ text-align: left !important;
+ }
+ .text-md-right {
+ text-align: right !important;
+ }
+ .text-md-center {
+ text-align: center !important;
+ }
+}
+
+@media (min-width: 992px) {
+ .text-lg-left {
+ text-align: left !important;
+ }
+ .text-lg-right {
+ text-align: right !important;
+ }
+ .text-lg-center {
+ text-align: center !important;
+ }
+}
+
+@media (min-width: 1200px) {
+ .text-xl-left {
+ text-align: left !important;
+ }
+ .text-xl-right {
+ text-align: right !important;
+ }
+ .text-xl-center {
+ text-align: center !important;
+ }
+}
+
+.text-lowercase {
+ text-transform: lowercase !important;
+}
+
+.text-uppercase {
+ text-transform: uppercase !important;
+}
+
+.text-capitalize {
+ text-transform: capitalize !important;
+}
+
+.font-weight-light {
+ font-weight: 300 !important;
+}
+
+.font-weight-lighter {
+ font-weight: lighter !important;
+}
+
+.font-weight-normal {
+ font-weight: 400 !important;
+}
+
+.font-weight-bold {
+ font-weight: 700 !important;
+}
+
+.font-weight-bolder {
+ font-weight: bolder !important;
+}
+
+.font-italic {
+ font-style: italic !important;
+}
+
+.text-white {
+ color: #fff !important;
+}
+
+.text-primary {
+ color: #007bff !important;
+}
+
+a.text-primary:hover, a.text-primary:focus {
+ color: #0056b3 !important;
+}
+
+.text-secondary {
+ color: #6c757d !important;
+}
+
+a.text-secondary:hover, a.text-secondary:focus {
+ color: #494f54 !important;
+}
+
+.text-success {
+ color: #28a745 !important;
+}
+
+a.text-success:hover, a.text-success:focus {
+ color: #19692c !important;
+}
+
+.text-info {
+ color: #17a2b8 !important;
+}
+
+a.text-info:hover, a.text-info:focus {
+ color: #0f6674 !important;
+}
+
+.text-warning {
+ color: #ffc107 !important;
+}
+
+a.text-warning:hover, a.text-warning:focus {
+ color: #ba8b00 !important;
+}
+
+.text-danger {
+ color: #dc3545 !important;
+}
+
+a.text-danger:hover, a.text-danger:focus {
+ color: #a71d2a !important;
+}
+
+.text-light {
+ color: #f8f9fa !important;
+}
+
+a.text-light:hover, a.text-light:focus {
+ color: #cbd3da !important;
+}
+
+.text-dark {
+ color: #343a40 !important;
+}
+
+a.text-dark:hover, a.text-dark:focus {
+ color: #121416 !important;
+}
+
+.text-body {
+ color: #212529 !important;
+}
+
+.text-muted {
+ color: #6c757d !important;
+}
+
+.text-black-50 {
+ color: rgba(0, 0, 0, 0.5) !important;
+}
+
+.text-white-50 {
+ color: rgba(255, 255, 255, 0.5) !important;
+}
+
+.text-hide {
+ font: 0/0 a;
+ color: transparent;
+ text-shadow: none;
+ background-color: transparent;
+ border: 0;
+}
+
+.text-decoration-none {
+ text-decoration: none !important;
+}
+
+.text-break {
+ word-wrap: break-word !important;
+}
+
+.text-reset {
+ color: inherit !important;
+}
+
+.visible {
+ visibility: visible !important;
+}
+
+.invisible {
+ visibility: hidden !important;
+}
+
+@media print {
+ *,
+ *::before,
+ *::after {
+ text-shadow: none !important;
+ box-shadow: none !important;
+ }
+ a:not(.btn) {
+ text-decoration: underline;
+ }
+ abbr[title]::after {
+ content: " (" attr(title) ")";
+ }
+ pre {
+ white-space: pre-wrap !important;
+ }
+ pre,
+ blockquote {
+ border: 1px solid #adb5bd;
+ page-break-inside: avoid;
+ }
+ thead {
+ display: table-header-group;
+ }
+ tr,
+ img {
+ page-break-inside: avoid;
+ }
+ p,
+ h2,
+ h3 {
+ orphans: 3;
+ widows: 3;
+ }
+ h2,
+ h3 {
+ page-break-after: avoid;
+ }
+ @page {
+ size: a3;
+ }
+ body {
+ min-width: 992px !important;
+ }
+ .container {
+ min-width: 992px !important;
+ }
+ .navbar {
+ display: none;
+ }
+ .badge {
+ border: 1px solid #000;
+ }
+ .table {
+ border-collapse: collapse !important;
+ }
+ .table td,
+ .table th {
+ background-color: #fff !important;
+ }
+ .table-bordered th,
+ .table-bordered td {
+ border: 1px solid #dee2e6 !important;
+ }
+ .table-dark {
+ color: inherit;
+ }
+ .table-dark th,
+ .table-dark td,
+ .table-dark thead th,
+ .table-dark tbody + tbody {
+ border-color: #dee2e6;
+ }
+ .table .thead-dark th {
+ color: inherit;
+ border-color: #dee2e6;
+ }
+}
+/*# sourceMappingURL=bootstrap.css.map */ \ No newline at end of file
diff --git a/tests/end2end/data/hints/bootstrap/checkbox.html b/tests/end2end/data/hints/bootstrap/checkbox.html
new file mode 100644
index 000000000..969e89a64
--- /dev/null
+++ b/tests/end2end/data/hints/bootstrap/checkbox.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+ <title>Bootstrap checkbox</title>
+ <link rel="stylesheet" href="bootstrap.css">
+</head>
+
+<body>
+ <div class="container">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox" class="custom-control-input" id="customCheck1">
+ <label class="custom-control-label" for="customCheck1">Check this custom checkbox</label>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/tests/end2end/data/hints/input.html b/tests/end2end/data/hints/input.html
index 1e027ab1c..a81361281 100644
--- a/tests/end2end/data/hints/input.html
+++ b/tests/end2end/data/hints/input.html
@@ -20,5 +20,7 @@
<form><input type="text" style="padding-left: 20px;"></input></form>
With existing text (logs to JS)::
<form><input id="qute-input-existing" value="existing"></input></form>
+ Contenteditable attributes
+ <p contenteditable="true">laythe</p>
</body>
</html>
diff --git a/tests/end2end/data/invalid_resource.html b/tests/end2end/data/invalid_resource.html
new file mode 100644
index 000000000..021165693
--- /dev/null
+++ b/tests/end2end/data/invalid_resource.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Invalid resource</title>
+ </head>
+ <body>
+ <img src="what://::" alt="I'm broken">
+ <img src="https://.i/" alt="Me too">
+ </body>
+</html>
diff --git a/tests/end2end/data/misc/xhr_headers.html b/tests/end2end/data/misc/xhr_headers.html
new file mode 100644
index 000000000..eda129e68
--- /dev/null
+++ b/tests/end2end/data/misc/xhr_headers.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>XHR headers test</title>
+ <script>
+ function xhr_headers() {
+ const xhr = new XMLHttpRequest();
+ xhr.open("GET", "/headers");
+ xhr.setRequestHeader("X-Qute-Test", "from XHR");
+
+ const elem = document.getElementById("output");
+ xhr.addEventListener("load", function(event) {
+ if (xhr.status == 200) {
+ elem.textContent = xhr.responseText;
+ console.log("Got headers via XHR")
+ } else {
+ elem.textContent = xhr.statusText;
+ console.warn(xhr.statusText, xhr.responseText);
+ }
+ });
+ xhr.send();
+ }
+ </script>
+ </head>
+ <body onload="xhr_headers()">
+ <pre id="output">unknown</pre>
+ </body>
+</html>
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 6ac5f281d..65f934d9f 100644
--- a/tests/end2end/features/conftest.py
+++ b/tests/end2end/features/conftest.py
@@ -73,7 +73,8 @@ def pytest_runtest_makereport(item, call):
# non-BDD ones.
return
- if sys.stdout.isatty() and item.config.getoption('--color') != 'no':
+ if ((sys.stdout.isatty() or testutils.ON_CI) and
+ item.config.getoption('--color') != 'no'):
colors = {
'failed': log.COLOR_ESCAPES['red'],
'passed': log.COLOR_ESCAPES['green'],
@@ -89,6 +90,9 @@ def pytest_runtest_makereport(item, call):
}
output = []
+ if testutils.ON_CI:
+ output.append(testutils.gha_group_begin('Scenario'))
+
output.append("{kw_color}Feature:{reset} {name}".format(
kw_color=colors['keyword'],
name=report.scenario['feature']['name'],
@@ -114,6 +118,9 @@ def pytest_runtest_makereport(item, call):
reset=colors['reset'])
)
+ if testutils.ON_CI:
+ output.append(testutils.gha_group_end())
+
report.longrepr.addsection("BDD scenario", '\n'.join(output))
@@ -152,6 +159,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."""
@@ -175,6 +193,11 @@ def pdfjs_available(data_tmpdir):
pytest.skip("No pdfjs installation found.")
+@bdd.given('I clear the log')
+def clear_log_lines(quteproc):
+ quteproc.clear_data()
+
+
## When
@@ -355,6 +378,7 @@ def hint(quteproc, args):
@bdd.when(bdd.parsers.parse('I hint with args "{args}" and follow {letter}'))
def hint_and_follow(quteproc, args, letter):
args = args.replace('(testdata)', testutils.abs_datapath())
+ args = args.replace('(python-executable)', sys.executable)
quteproc.send_cmd(':hint {}'.format(args))
quteproc.wait_for(message='hints: *')
quteproc.send_cmd(':follow-hint {}'.format(letter))
@@ -538,6 +562,9 @@ def check_header(quteproc, header, value):
print(data)
if value == '<unset>':
assert header not in data['headers']
+ elif value.startswith("'") and value.endswith("'"): # literal match
+ actual = data['headers'][header]
+ assert actual == value[1:-1]
else:
actual = data['headers'][header]
assert testutils.pattern_match(pattern=value, value=actual)
diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature
index e14a1886a..57d6dfb7f 100644
--- a/tests/end2end/features/downloads.feature
+++ b/tests/end2end/features/downloads.feature
@@ -87,8 +87,18 @@ Feature: Downloading things from a website.
When I set downloads.location.suggestion to filename
And I set downloads.location.prompt to true
And I open data/data_link.html
- And I hint with args "links download" and follow a
- And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='binary blob' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
+ And I hint with args "links download" and follow s
+ And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='download.pdf' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
+ And I run :leave-mode
+ Then no crash should happen
+
+ @qtwebkit_skip
+ Scenario: Downloading a data: link via QtWebEngine (issue 1214)
+ When I set downloads.location.suggestion to filename
+ And I set downloads.location.prompt to true
+ And I open data/data_link.html
+ And I hint with args "links" and follow s
+ And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='download.pdf' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I run :leave-mode
Then no crash should happen
@@ -101,18 +111,6 @@ Feature: Downloading things from a website.
And I run :leave-mode
Then no crash should happen
- # https://github.com/qutebrowser/qutebrowser/issues/4240
- @qt<5.11.2
- Scenario: Downloading with SSL errors (issue 1413)
- When SSL is supported
- And I clear SSL errors
- And I set content.ssl_strict to ask
- And I set downloads.location.prompt to false
- And I download an SSL page
- And I wait for "Entering mode KeyMode.* (reason: question asked)" in the log
- And I run :prompt-accept
- Then the error "Download error: SSL handshake failed" should be shown
-
Scenario: Closing window with downloads.remove_finished timeout (issue 1242)
When I set downloads.remove_finished to 500
And I open data/downloads/download.bin in a new window without waiting
@@ -121,8 +119,6 @@ Feature: Downloading things from a website.
And I wait 0.5s
Then no crash should happen
- # This sometimes hangs on exit for some reason on Travis...
- @windows
Scenario: Quitting with finished downloads and confirm_quit=downloads (issue 846)
Given I have a fresh instance
When I set downloads.location.prompt to false
@@ -150,16 +146,16 @@ Feature: Downloading things from a website.
And I wait until the download is finished
Then the downloaded file download with spaces.bin should exist
- @qtwebkit_skip @qt<5.9 @qt>=5.13
- Scenario: Downloading a file with evil content-disposition header (Qt 5.8 or older and 5.13 and newer)
+ @qtwebkit_skip @qt>=5.13
+ Scenario: Downloading a file with evil content-disposition header (Qt 5.13 and newer)
# Content-Disposition: download; filename=..%2Ffoo
When I open response-headers?Content-Disposition=download;%20filename%3D..%252Ffoo without waiting
And I wait until the download is finished
Then the downloaded file ../foo should not exist
And the downloaded file foo should exist
- @qtwebkit_skip @qt<5.13 @qt>=5.9
- Scenario: Downloading a file with evil content-disposition header (Qt 5.9 to 5.12)
+ @qtwebkit_skip @qt<5.13
+ Scenario: Downloading a file with evil content-disposition header (Qt 5.12)
# Content-Disposition: download; filename=..%2Ffoo
When I open response-headers?Content-Disposition=download;%20filename%3D..%252Ffoo without waiting
And I wait until the download is finished
@@ -213,24 +209,6 @@ Feature: Downloading things from a website.
does-not-exist
does-not-exist
- @qtwebkit_skip @qt<5.10
- Scenario: Retrying a failed download with QtWebEngine (Qt < 5.10)
- When I open data/downloads/issue2298.html
- And I run :click-element id download
- And I wait for "Download error: *" in the log
- And I run :download-retry
- Then the error "Retrying downloads is unsupported *" should be shown
-
- @qtwebkit_skip @qt==5.10.1
- Scenario: Retrying a failed download with QtWebEngine (Qt 5.10)
- When I open data/downloads/issue2298.html
- And I run :click-element id download
- And I wait for "Download error: *" in the log
- And I run :download-retry
- # For some reason it doesn't actually try again here, but let's hope it
- # works e.g. on a connection loss, which we can't test automatically.
- Then "Retrying downloads is unsupported *" should not be logged
-
@flaky
Scenario: Retrying with count
When I run :download http://localhost:(port)/data/downloads/download.bin
diff --git a/tests/end2end/features/editor.feature b/tests/end2end/features/editor.feature
index 59f7fdf4e..db80c89ba 100644
--- a/tests/end2end/features/editor.feature
+++ b/tests/end2end/features/editor.feature
@@ -101,7 +101,6 @@ Feature: Opening external editors
And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log
And I run :open-editor
And I wait for "Read back: foobar" in the log
- And I run :click-element id qute-button
Then the javascript message "text: foobar" should be logged
Scenario: Spawning an editor in normal mode
@@ -113,7 +112,6 @@ Feature: Opening external editors
And I wait for "Leaving mode KeyMode.insert (reason: leave current)" in the log
And I run :open-editor
And I wait for "Read back: foobar" in the log
- And I run :click-element id qute-button
Then the javascript message "text: foobar" should be logged
# Could not get signals working on Windows
@@ -143,7 +141,6 @@ Feature: Opening external editors
And I wait until the editor has started
And I save without exiting the editor
And I wait for "Read back: foobar" in the log
- And I run :click-element id qute-button
Then the javascript message "text: foobar" should be logged
Scenario: Spawning an editor in caret mode
@@ -157,7 +154,6 @@ Feature: Opening external editors
And I wait for "Entering mode KeyMode.caret (reason: command)" in the log
And I run :open-editor
And I wait for "Read back: foobar" in the log
- And I run :click-element id qute-button
Then the javascript message "text: foobar" should be logged
Scenario: Spawning an editor with existing text
@@ -169,7 +165,6 @@ Feature: Opening external editors
And I wait for "Inserting text into element *" in the log
And I run :open-editor
And I wait for "Read back: bar" in the log
- And I run :click-element id qute-button
Then the javascript message "text: bar" should be logged
## :edit-command
diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature
index 190e95f79..271450d90 100644
--- a/tests/end2end/features/hints.feature
+++ b/tests/end2end/features/hints.feature
@@ -61,17 +61,17 @@ Feature: Using hints
Scenario: Using :hint spawn with flags and -- (issue 797)
When I open data/hints/html/simple.html
- And I hint with args "-- all spawn -v python -c ''" and follow a
+ And I hint with args "-- all spawn -v (python-executable) -c ''" and follow a
Then the message "Command exited successfully." should be shown
Scenario: Using :hint spawn with flags (issue 797)
When I open data/hints/html/simple.html
- And I hint with args "all spawn -v python -c ''" and follow a
+ And I hint with args "all spawn -v (python-executable) -c ''" and follow a
Then the message "Command exited successfully." should be shown
Scenario: Using :hint spawn with flags and --rapid (issue 797)
When I open data/hints/html/simple.html
- And I hint with args "--rapid all spawn -v python -c ''" and follow a
+ And I hint with args "--rapid all spawn -v (python-executable) -c ''" and follow a
Then the message "Command exited successfully." should be shown
@posix
@@ -179,18 +179,6 @@ Feature: Using hints
And I hint with args "all run message-info {hint-url}" and follow a
Then the message "http://localhost:(port)/data/hello.txt" should be shown
- @qt<5.11
- Scenario: Clicking an invalid link
- When I open data/invalid_link.html
- And I hint with args "all" and follow a
- Then the error "Invalid link clicked - *" should be shown
-
- @qt<5.11
- Scenario: Clicking an invalid link opening in a new tab
- When I open data/invalid_link.html
- And I hint with args "all tab" and follow a
- Then the error "Invalid link clicked - *" should be shown
-
Scenario: Hinting inputs without type
When I open data/hints/input.html
And I hint with args "inputs" and follow a
@@ -247,6 +235,12 @@ Feature: Using hints
# The actual check is already done above
Then no crash should happen
+ Scenario: Hinting Twitter bootstrap checkbox
+ When I open data/hints/bootstrap/checkbox.html
+ And I hint with args "all" and follow a
+ # The actual check is already done above
+ Then "No elements found." should not be logged
+
Scenario: Hinting invisible elements
When I open data/hints/invisible.html
And I run :hint
@@ -323,8 +317,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
@@ -592,6 +586,8 @@ Feature: Using hints
And I press the key "<Enter>"
Then data/hello.txt should be loaded
+ ## Other
+
Scenario: Using --first with normal links
When I open data/hints/html/simple.html
And I hint with args "all --first"
@@ -606,9 +602,25 @@ Feature: Using hints
And I run :leave-mode
Then the javascript message "true" should be logged
- # Delete hint target
+ Scenario: Hinting contenteditable inputs
+ When I open data/hints/input.html
+ And I hint with args "inputs" and follow f
+ And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log
+ And I run :leave-mode
+ # The actual check is already done above
+ Then no crash should happen
+
Scenario: Deleting a simple target
When I open data/hints/html/simple.html
And I hint with args "all delete" and follow a
And I run :hint
Then the error "No elements found." should be shown
+
+ Scenario: Statusbar text when entering hint mode from other mode
+ When I open data/hints/html/simple.html
+ And I run :enter-mode insert
+ And I hint with args "all"
+ And I run :debug-pyeval objreg.get('main-window', window='current', scope='window').status.txt.text()
+ # Changing tabs will leave hint mode
+ And I wait until qute://pyeval/ is loaded
+ Then the page should contain the plaintext "'Follow hint...'"
diff --git a/tests/end2end/features/history.feature b/tests/end2end/features/history.feature
index 22eef8bc7..00e22297d 100644
--- a/tests/end2end/features/history.feature
+++ b/tests/end2end/features/history.feature
@@ -51,8 +51,6 @@ Feature: Page history
Then the history should contain:
http://localhost:(port)/404 Error loading page: http://localhost:(port)/404
- # Hangs a lot on AppVeyor
- @posix
Scenario: History with invalid URL
When I run :tab-only
And I open data/javascript/window_open.html
@@ -74,8 +72,6 @@ Feature: Page history
Then the history should contain:
http://localhost:(port)/data/title.html Test title
- # Hangs a lot on AppVeyor
- @posix
Scenario: Clearing history
When I run :tab-only
And I open data/title.html
@@ -105,8 +101,7 @@ Feature: Page history
Then the page should contain the plaintext "3.txt"
Then the page should contain the plaintext "4.txt"
- # Hangs a lot on AppVeyor
- @posix @flaky
+ @flaky
Scenario: Listing history with qute:history redirect
When I open data/numbers/3.txt
And I open data/numbers/4.txt
@@ -116,6 +111,7 @@ Feature: Page history
Then the page should contain the plaintext "3.txt"
Then the page should contain the plaintext "4.txt"
+ @flaky
Scenario: XSS in :history
When I open data/issue4011.html
And I open qute://history
diff --git a/tests/end2end/features/invoke.feature b/tests/end2end/features/invoke.feature
index 9be38659e..ee45dcb29 100644
--- a/tests/end2end/features/invoke.feature
+++ b/tests/end2end/features/invoke.feature
@@ -36,6 +36,21 @@ Feature: Invoking a new process
- history:
- url: http://localhost:*/data/search.html
+ Scenario: Using new_instance_open_target = private-window
+ When I set new_instance_open_target to private-window
+ And I open data/title.html
+ And I open data/search.html as a URL
+ Then the session should look like:
+ windows:
+ - tabs:
+ - history:
+ - url: about:blank
+ - url: http://localhost:*/data/title.html
+ - private: True
+ tabs:
+ - history:
+ - url: http://localhost:*/data/search.html
+
Scenario: Using new_instance_open_target_window = last-opened
When I set new_instance_open_target to tab
And I set new_instance_open_target_window to last-opened
diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature
index a4c3d1338..6238e8b7b 100644
--- a/tests/end2end/features/javascript.feature
+++ b/tests/end2end/features/javascript.feature
@@ -124,33 +124,11 @@ Feature: Javascript stuff
# https://github.com/qutebrowser/qutebrowser/issues/1190
# https://github.com/qutebrowser/qutebrowser/issues/2495
- # Currently broken on Windows and on Qt 5.12
- # https://github.com/qutebrowser/qutebrowser/issues/4230
- @posix @qt<5.12
- Scenario: Checking visible/invisible window size
- When I run :tab-only
- And I open data/javascript/windowsize.html in a new background tab
- And I wait for "[*/data/javascript/windowsize.html:*] loaded" in the log
- And I run :tab-next
- Then the window sizes should be the same
-
- @flaky @qt<5.12
- Scenario: Checking visible/invisible window size with vertical tabbar
- When I run :tab-only
- And I set tabs.position to left
- And I open data/javascript/windowsize.html in a new background tab
- And I wait for "[*/data/javascript/windowsize.html:*] loaded" in the log
- And I run :tab-next
- Then the window sizes should be the same
-
@flaky
Scenario: Have a GreaseMonkey script run at page start
When I have a GreaseMonkey file saved for document-start with noframes unset
And I run :greasemonkey-reload
And I open data/hints/iframe.html
- # This second reload is required in webengine < 5.8 for scripts
- # registered to run at document-start, some sort of timing issue.
- And I run :reload
Then the javascript message "Script is running on /data/hints/iframe.html" should be logged
Scenario: Have a GreaseMonkey script running on frames
diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature
index 5456b6739..0e344947d 100644
--- a/tests/end2end/features/keyinput.feature
+++ b/tests/end2end/features/keyinput.feature
@@ -18,31 +18,6 @@ Feature: Keyboard input
# input.forward_unbound_keys
- @qt<5.11.1
- Scenario: Forwarding all keys
- When I open data/keyinput/log.html
- And I set input.forward_unbound_keys to all
- And I press the key ","
- And I press the key "<F1>"
- # ,
- Then the javascript message "key press: 188" should be logged
- And the javascript message "key release: 188" should be logged
- # <F1>
- And the javascript message "key press: 112" should be logged
- And the javascript message "key release: 112" should be logged
-
- @qt<5.11.1
- Scenario: Forwarding special keys
- When I open data/keyinput/log.html
- And I set input.forward_unbound_keys to auto
- And I press the keys ",<F1>"
- # <F1>
- Then the javascript message "key press: 112" should be logged
- And the javascript message "key release: 112" should be logged
- # ,
- And the javascript message "key press: 188" should not be logged
- And the javascript message "key release: 188" should not be logged
-
Scenario: Forwarding no keys
When I open data/keyinput/log.html
And I set input.forward_unbound_keys to none
@@ -66,10 +41,10 @@ Feature: Keyboard input
@no_xvfb @posix @qtwebengine_skip
Scenario: :fake-key sending key to the website with other window focused
When I open data/keyinput/log.html
- And I run :inspector
+ And I run :devtools
And I wait for "Focus object changed: <PyQt5.QtWebKitWidgets.QWebView object at *>" in the log
And I run :fake-key x
- And I run :inspector
+ And I run :devtools
And I wait for "Focus object changed: <qutebrowser.browser.webkit.webview.WebView *>" in the log
Then the error "No focused webview!" should be shown
@@ -81,11 +56,17 @@ Feature: Keyboard input
Scenario: :fake-key sending keychain to the website
When I open data/keyinput/log.html
- And I run :fake-key xy
+ And I run :fake-key x<greater>y<less>" "
Then the javascript message "key press: 88" should be logged
And the javascript message "key release: 88" should be logged
+ And the javascript message "key press: 190" should be logged
+ And the javascript message "key release: 190" should be logged
And the javascript message "key press: 89" should be logged
And the javascript message "key release: 89" should be logged
+ And the javascript message "key press: 188" should be logged
+ And the javascript message "key release: 188" should be logged
+ And the javascript message "key press: 32" should be logged
+ And the javascript message "key release: 32" should be logged
Scenario: :fake-key sending keypress to qutebrowser
When I run :fake-key -g x
@@ -170,10 +151,10 @@ Feature: Keyboard input
And I clean up open tabs
When I open data/hello.txt
And I run :enter-mode insert
+ And I wait for "Entering mode KeyMode.insert (reason: command)" in the log
And I open data/hello2.txt in a new background tab
And I run :tab-focus 2
- Then "Entering mode KeyMode.insert (reason: command)" should be logged
- And "Leaving mode KeyMode.insert (reason: tab changed)" should be logged
+ Then "Leaving mode KeyMode.insert (reason: tab changed)" should be logged
And "Mode before tab change: insert (mode_on_change = normal)" should be logged
And "Mode after tab change: normal (mode_on_change = normal)" should be logged
@@ -182,10 +163,10 @@ Feature: Keyboard input
And I clean up open tabs
When I open data/hello.txt
And I run :enter-mode insert
+ And I wait for "Entering mode KeyMode.insert (reason: command)" in the log
And I open data/hello2.txt in a new background tab
And I run :tab-focus 2
- Then "Entering mode KeyMode.insert (reason: command)" should be logged
- And "Leaving mode KeyMode.insert (reason: tab changed)" should not be logged
+ Then "Leaving mode KeyMode.insert (reason: tab changed)" should not be logged
And "Mode before tab change: insert (mode_on_change = persist)" should be logged
And "Mode after tab change: insert (mode_on_change = persist)" should be logged
@@ -194,14 +175,14 @@ Feature: Keyboard input
And I clean up open tabs
When I open data/hello.txt
And I run :enter-mode insert
+ And I wait for "Entering mode KeyMode.insert (reason: command)" in the log
And I open data/hello2.txt in a new background tab
And I run :tab-focus 2
+ And I wait for "Mode before tab change: insert (mode_on_change = restore)" in the log
+ And I wait for "Mode after tab change: normal (mode_on_change = restore)" in the log
And I run :enter-mode passthrough
+ And I wait for "Entering mode KeyMode.passthrough (reason: command)" in the log
And I run :tab-focus 1
- Then "Entering mode KeyMode.insert (reason: command)" should be logged
- And "Mode before tab change: insert (mode_on_change = restore)" should be logged
- And "Mode after tab change: normal (mode_on_change = restore)" should be logged
- And "Entering mode KeyMode.passthrough (reason: command)" should be logged
- And "Mode before tab change: passthrough (mode_on_change = restore)" should be logged
+ Then "Mode before tab change: passthrough (mode_on_change = restore)" should be logged
And "Entering mode KeyMode.insert (reason: restore)" should be logged
And "Mode after tab change: insert (mode_on_change = restore)" should be logged
diff --git a/tests/end2end/features/marks.feature b/tests/end2end/features/marks.feature
index ca05b7fad..921e0e76c 100644
--- a/tests/end2end/features/marks.feature
+++ b/tests/end2end/features/marks.feature
@@ -103,8 +103,6 @@ Feature: Setting positional marks
And I wait until the scroll position changed to 20/15
Then the page should be scrolled to 20 15
- # FIXME:qtwebengine
- @qtwebengine_skip: Does not find Grail on Travis for some reason?
Scenario: Jumping back after search-next
When I run :search Grail
And I run :search-next
@@ -114,11 +112,11 @@ Feature: Setting positional marks
Then the page should be scrolled to 0 0
Scenario: Hovering a hint does not set the ' mark
- When I run :scroll-px 30 20
- And I wait until the scroll position changed to 30/20
- And I run :scroll-to-perc 0
+ When I run :scroll-px 10 20
+ And I wait until the scroll position changed to 10/20
+ And I run :scroll-to-perc 0
And I wait until the scroll position changed
And I hint with args "links hover" and follow s
And I run :jump-mark "'"
- And I wait until the scroll position changed to 30/20
- Then the page should be scrolled to 30 20
+ And I wait until the scroll position changed to 10/20
+ Then the page should be scrolled to 10 20
diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature
index 7a26c9dda..584101e6d 100644
--- a/tests/end2end/features/misc.feature
+++ b/tests/end2end/features/misc.feature
@@ -89,6 +89,10 @@ Feature: Various utility commands.
When I run :jseval Array(5002).join("x")
Then the message "x* [...trimmed...]" should be shown
+ Scenario: :jseval --url
+ When I run :jseval --url javascript:console.log("hello world?")
+ Then the javascript message "hello world?" should be logged
+
@qtwebengine_skip
Scenario: :jseval with --world on QtWebKit
When I run :jseval --world=1 console.log("Hello from JS!");
@@ -167,25 +171,20 @@ Feature: Various utility commands.
# :inspect
- @qtwebkit_skip @qt<5.11
- Scenario: Inspector without --enable-webengine-inspector
- When I run :inspector
- Then the error "QtWebEngine inspector is not enabled. See 'qutebrowser --help' for details." should be shown
-
@no_xvfb @posix @qtwebengine_skip
Scenario: Inspector smoke test
- When I run :inspector
+ When I run :devtools
And I wait for "Focus object changed: <PyQt5.QtWebKitWidgets.QWebView object at *>" in the log
- And I run :inspector
+ And I run :devtools
And I wait for "Focus object changed: *" in the log
Then no crash should happen
# Different code path as an inspector got created now
@no_xvfb @posix @qtwebengine_skip
Scenario: Inspector smoke test 2
- When I run :inspector
+ When I run :devtools
And I wait for "Focus object changed: <PyQt5.QtWebKitWidgets.QWebView object at *>" in the log
- And I run :inspector
+ And I run :devtools
And I wait for "Focus object changed: *" in the log
Then no crash should happen
@@ -310,7 +309,6 @@ Feature: Various utility commands.
And I press the key "<Ctrl-C>"
Then no crash should happen
- @js_prompt
Scenario: Focusing download widget via Tab (original issue)
When I open data/prompt/jsprompt.html
And I run :click-element id button
@@ -327,6 +325,11 @@ Feature: Various utility commands.
And I open headers
Then the header X-Qute-Test should be set to testvalue
+ Scenario: Setting accept header
+ When I set content.headers.custom to {"Accept": "testvalue"}
+ And I open headers
+ Then the header Accept should be set to testvalue
+
Scenario: DNT header
When I set content.headers.do_not_track to true
And I open headers
@@ -349,18 +352,32 @@ 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
+
+ @qtwebkit_skip
+ Scenario: Custom headers via XHR
+ When I set content.headers.custom to {"Accept": "config-value", "X-Qute-Test": "config-value"}
+ And I open data/misc/xhr_headers.html
+ And I wait for the javascript message "Got headers via XHR"
+ Then the header Accept should be set to '*/*'
+ And the header X-Qute-Test should be set to config-value
## https://github.com/qutebrowser/qutebrowser/issues/1523
@@ -496,25 +513,14 @@ Feature: Various utility commands.
## Renderer crashes
# Skipped on Windows as "... has stopped working" hangs.
- @qtwebkit_skip @no_invalid_lines @posix @qt<5.9
+ @qtwebkit_skip @no_invalid_lines @posix
Scenario: Renderer crash
When I run :open -t chrome://crash
- Then the error "Renderer process crashed" should be shown
-
- @qtwebkit_skip @no_invalid_lines @qt<5.9
- Scenario: Renderer kill
- When I run :open -t chrome://kill
- Then the error "Renderer process was killed" should be shown
-
- # Skipped on Windows as "... has stopped working" hangs.
- @qtwebkit_skip @no_invalid_lines @posix @qt>=5.9
- Scenario: Renderer crash (5.9)
- When I run :open -t chrome://crash
Then "Renderer process crashed" should be logged
And "* 'Error loading chrome://crash/'" should be logged
- @qtwebkit_skip @no_invalid_lines @qt>=5.9 @flaky
- Scenario: Renderer kill (5.9)
+ @qtwebkit_skip @no_invalid_lines @flaky
+ Scenario: Renderer kill
When I run :open -t chrome://kill
Then "Renderer process was killed" should be logged
And "* 'Error loading chrome://kill/'" should be logged
@@ -530,7 +536,9 @@ Feature: Various utility commands.
And I open data/numbers/3.txt
Then no crash should happen
- Scenario: Simple adblock update
- When I set up "simple" as block lists
- And I run :adblock-update
- Then the message "adblock: Read 1 hosts from 1 sources." should be shown
+ ## Other
+
+ Scenario: Resource with invalid URL
+ When I open data/invalid_resource.html in a new tab
+ Then "Ignoring invalid * URL: Invalid hostname (contains invalid characters); *" should be logged
+ And no crash should happen
diff --git a/tests/end2end/features/navigate.feature b/tests/end2end/features/navigate.feature
index 2596f3ef1..ec295c4d1 100644
--- a/tests/end2end/features/navigate.feature
+++ b/tests/end2end/features/navigate.feature
@@ -4,25 +4,10 @@ Feature: Using :navigate
Scenario: :navigate with invalid argument
When I run :navigate foo
- Then the error "where: Invalid value foo - expected one of: prev, next, up, increment, decrement" should be shown
+ Then the error "where: Invalid value foo - expected one of: prev, next, up, increment, decrement, strip" should be shown
# up
- Scenario: Navigating up
- When I open data/navigate/sub
- And I run :navigate up
- Then data/navigate should be loaded
-
- Scenario: Navigating up with a query
- When I open data/navigate/sub?foo=bar
- And I run :navigate up
- Then data/navigate should be loaded
-
- Scenario: Navigating up by count
- When I open data/navigate/sub/index.html
- And I run :navigate up with count 2
- Then data/navigate should be loaded
-
Scenario: Navigating up in qute://help/
When the documentation is up to date
And I open qute://help/commands.html
@@ -90,48 +75,6 @@ Feature: Using :navigate
# increment/decrement
- Scenario: Incrementing number in URL
- When I open data/numbers/1.txt
- And I run :navigate increment
- Then data/numbers/2.txt should be loaded
-
- Scenario: Decrementing number in URL
- When I open data/numbers/4.txt
- And I run :navigate decrement
- Then data/numbers/3.txt should be loaded
-
- Scenario: Decrementing with no number in URL
- When I open data/navigate
- And I run :navigate decrement
- Then the error "No number found in URL!" should be shown
-
- Scenario: Incrementing with no number in URL
- When I open data/navigate
- And I run :navigate increment
- Then the error "No number found in URL!" should be shown
-
- Scenario: Incrementing number in URL by count
- When I open data/numbers/3.txt
- And I run :navigate increment with count 3
- Then data/numbers/6.txt should be loaded
-
- Scenario: Decrementing number in URL by count
- When I open data/numbers/8.txt
- And I run :navigate decrement with count 5
- Then data/numbers/3.txt should be loaded
-
- Scenario: Setting url.incdec_segments
- When I set url.incdec_segments to [anchor]
- And I open data/numbers/1.txt
- And I run :navigate increment
- Then the error "No number found in URL!" should be shown
-
- Scenario: Incrementing query
- When I set url.incdec_segments to ["query"]
- And I open data/numbers/1.txt?value=2
- And I run :navigate increment
- Then data/numbers/1.txt?value=3 should be loaded
-
@qtwebengine_todo: Doesn't find any elements
Scenario: Navigating multiline links
When I open data/navigate/multilinelinks.html
diff --git a/tests/end2end/features/private.feature b/tests/end2end/features/private.feature
index 35097f545..11b2dc0ab 100644
--- a/tests/end2end/features/private.feature
+++ b/tests/end2end/features/private.feature
@@ -42,6 +42,25 @@ Feature: Using private browsing
## https://github.com/qutebrowser/qutebrowser/issues/1219
+ Scenario: Make sure private data is cleared when closing last private window
+ When I open about:blank in a private window
+ And I open cookies/set?cookie-to-delete=1 without waiting in a new tab
+ And I wait until cookies is loaded
+ And I run :close
+ And I open about:blank in a private window
+ And I open cookies
+ Then the cookie cookie-to-delete should not be set
+
+ Scenario: Make sure private data is not cleared when closing a private window but another remains
+ When I open about:blank in a private window
+ And I open about:blank in a private window
+ And I open cookies/set?cookie-to-preserve=1 without waiting in a new tab
+ And I wait until cookies is loaded
+ And I run :close
+ And I open about:blank in a private window
+ And I open cookies
+ Then the cookie cookie-to-preserve should be set to 1
+
Scenario: Sharing cookies with private browsing
When I open cookies/set?qute-test=42 without waiting in a private window
And I wait until cookies is loaded
@@ -153,7 +172,7 @@ Feature: Using private browsing
- url: http://localhost:*/data/numbers/1.txt
- url: http://localhost:*/data/numbers/2.txt
-
+ @skip # Too 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
@@ -162,7 +181,12 @@ Feature: Using private browsing
And I open data/numbers/5.txt in a new tab
And I run :session-save --only-active-window window_session_name
And I run :window-only
+ And I wait for "removed: tab" in the log
+ And I wait for "removed: tab" in the log
And I run :tab-only
+ And I wait for "removed: tab" in the log
+ And I wait for "removed: tab" in the log
+ And I wait for "removed: tab" in the log
And I run :session-load -c window_session_name
And I wait until data/numbers/5.txt is loaded
Then the session should look like:
@@ -175,3 +199,50 @@ Feature: Using private browsing
- history:
- active: true
url: http://localhost:*/data/numbers/5.txt
+
+ # https://github.com/qutebrowser/qutebrowser/issues/5810
+
+ Scenario: Using qute:// scheme after reiniting private profile
+ When I open about:blank in a private window
+ And I run :close
+ And I open qute://version in a private window
+ Then the page should contain the plaintext "Version info"
+
+ Scenario: Downloading after reiniting private profile
+ When I open about:blank in a private window
+ And I run :close
+ And I open data/downloads/downloads.html in a private window
+ And I run :click-element id download
+ And I wait for "*PromptMode.download*" in the log
+ And I run :leave-mode
+ Then "Removed download *: download.bin *" should be logged
+
+ Scenario: Adblocking after reiniting private profile
+ When I open about:blank in a private window
+ And I run :close
+ And I set content.blocking.hosts.lists to ["http://localhost:(port)/data/blocking/qutebrowser-hosts"]
+ And I set content.blocking.method to hosts
+ And I run :adblock-update
+ And I wait for the message "hostblock: Read 1 hosts from 1 sources."
+ And I open data/blocking/external_logo.html in a private window
+ Then "Request to qutebrowser.org blocked by host blocker." should be logged
+
+ @pyqt!=5.15.0 # cookie filtering is broken on QtWebEngine 5.15.0
+ Scenario: Cookie filtering after reiniting private profile
+ When I open about:blank in a private window
+ And I run :close
+ And I set content.cookies.accept to never
+ And I open data/title.html in a private window
+ And I open cookies/set?unsuccessful-cookie=1 without waiting in a new tab
+ And I wait until cookies is loaded
+ And I open cookies
+ Then the cookie unsuccessful-cookie should not be set
+
+ Scenario: Disabling JS after reiniting private profile
+ When I open about:blank in a new window
+ And I run :window-only
+ And I set content.javascript.enabled to false
+ And I open about:blank in a private window
+ And I run :close
+ And I open data/javascript/enabled.html in a private window
+ Then the page should contain the plaintext "JavaScript is disabled"
diff --git a/tests/end2end/features/prompts.feature b/tests/end2end/features/prompts.feature
index 96532dc8c..8562b50c4 100644
--- a/tests/end2end/features/prompts.feature
+++ b/tests/end2end/features/prompts.feature
@@ -1,7 +1,7 @@
# vim: ft=cucumber fileencoding=utf-8 sts=4 sw=4 et:
Feature: Prompts
- Various prompts (javascript, SSL errors, authentification, etc.)
+ Various prompts (javascript, SSL errors, authentication, etc.)
# Javascript
@@ -39,7 +39,6 @@ Feature: Prompts
And I run :leave-mode
Then the javascript message "confirm reply: false" should be logged
- @js_prompt
Scenario: Javascript prompt
When I open data/prompt/jsprompt.html
And I run :click-element id button
@@ -48,7 +47,6 @@ Feature: Prompts
And I run :prompt-accept
Then the javascript message "Prompt reply: prompt test" should be logged
- @js_prompt
Scenario: Javascript prompt with default
When I open data/prompt/jsprompt.html
And I run :click-element id button-default
@@ -56,7 +54,6 @@ Feature: Prompts
And I run :prompt-accept
Then the javascript message "Prompt reply: default" should be logged
- @js_prompt
Scenario: Rejected javascript prompt
When I open data/prompt/jsprompt.html
And I run :click-element id button
@@ -137,7 +134,6 @@ Feature: Prompts
# Shift-Insert with prompt (issue 1299)
- @js_prompt
Scenario: Pasting via shift-insert in prompt mode
When selection is supported
And I put "insert test" into the primary selection
@@ -148,7 +144,6 @@ Feature: Prompts
And I run :prompt-accept
Then the javascript message "Prompt reply: insert test" should be logged
- @js_prompt
Scenario: Pasting via shift-insert without it being supported
When selection is not supported
And I put "insert test" into the primary selection
@@ -160,7 +155,6 @@ Feature: Prompts
And I run :prompt-accept
Then the javascript message "Prompt reply: clipboard test" should be logged
- @js_prompt
Scenario: Using content.javascript.prompt
When I set content.javascript.prompt to false
And I open data/prompt/jsprompt.html
@@ -325,7 +319,7 @@ Feature: Prompts
# Page authentication
- Scenario: Successful webpage authentification
+ Scenario: Successful webpage authentication
When I open basic-auth/user1/password1 without waiting
And I wait for a prompt
And I press the keys "user1"
@@ -378,7 +372,7 @@ Feature: Prompts
}
@qtwebengine_skip
- Scenario: Cancellling webpage authentification with QtWebKit
+ Scenario: Cancellling webpage authentication with QtWebKit
When I open basic-auth/user6/password6 without waiting
And I wait for a prompt
And I run :leave-mode
@@ -396,7 +390,6 @@ Feature: Prompts
Then the javascript message "Alert done" should be logged
And the error "No value is permitted with alert prompts!" should be shown
- @js_prompt
Scenario: Javascript prompt with value
When I set content.javascript.prompt to true
And I open data/prompt/jsprompt.html
diff --git a/tests/end2end/features/qutescheme.feature b/tests/end2end/features/qutescheme.feature
index 35c110dc5..4c283710a 100644
--- a/tests/end2end/features/qutescheme.feature
+++ b/tests/end2end/features/qutescheme.feature
@@ -89,6 +89,7 @@ Feature: Special qute:// pages
And I open qute://help/img/ without waiting
Then "*Error while * qute://*" should be logged
And "* url='qute://help/img'* LoadStatus.error" should be logged
+ And "Load error: ERR_FILE_NOT_FOUND" should be logged
# :history
@@ -177,13 +178,13 @@ Feature: Special qute:// pages
And I open data/misc/test.pdf without waiting
Then the javascript message "PDF * [*] (PDF.js: *)" should be logged
+ @qtwebkit_pdf_imageformat_skip
Scenario: pdfjs is not used when disabled
When I set content.pdfjs to false
And I set downloads.location.prompt to false
And I open data/misc/test.pdf without waiting
Then "Download test.pdf finished" should be logged
- @qtwebengine_skip: Might work with Qt 5.12
Scenario: Downloading a pdf via pdf.js button (issue 1214)
Given pdfjs is available
When I set content.pdfjs to true
@@ -191,7 +192,7 @@ Feature: Special qute:// pages
And I open data/misc/test.pdf without waiting
And I wait for "[qute://pdfjs/*] PDF * (PDF.js: *)" in the log
And I run :jseval document.getElementById("download").click()
- And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=* mode=<PromptMode.download: 5> text=* title='Save file to:'>, *" in the log
+ And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=* mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I run :leave-mode
Then no crash should happen
@@ -258,20 +259,36 @@ 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/scroll.feature b/tests/end2end/features/scroll.feature
index 27d724435..3aa3f0df4 100644
--- a/tests/end2end/features/scroll.feature
+++ b/tests/end2end/features/scroll.feature
@@ -106,6 +106,7 @@ Feature: Scrolling
When I run :scroll bottom
Then the page should be scrolled vertically
+ @flaky
Scenario: Scrolling to bottom and to top
When I run :scroll bottom
And I wait until the scroll position changed
@@ -192,6 +193,7 @@ Feature: Scrolling
When I run :scroll-to-perc --horizontal 100
Then the page should be scrolled horizontally
+ @flaky
Scenario: Scrolling to right and to left with :scroll-to-perc
When I run :scroll-to-perc --horizontal 100
And I wait until the scroll position changed
@@ -218,6 +220,7 @@ Feature: Scrolling
When I run :scroll-to-perc --horizontal
Then the page should be scrolled horizontally
+ @flaky
Scenario: :scroll-to-perc with count
When I run :scroll-to-perc with count 50
Then the page should be scrolled vertically
@@ -237,6 +240,7 @@ Feature: Scrolling
Then the page should be scrolled vertically
# https://github.com/qutebrowser/qutebrowser/issues/1821
+ @flaky
Scenario: :scroll-to-perc without doctype
When I open data/scroll/no_doctype.html
And I run :scroll-to-perc 100
diff --git a/tests/end2end/features/sessions.feature b/tests/end2end/features/sessions.feature
index 494feb0ba..0f0c015e0 100644
--- a/tests/end2end/features/sessions.feature
+++ b/tests/end2end/features/sessions.feature
@@ -292,6 +292,8 @@ Feature: Saving and loading sessions
And I run :window-only
And I run :tab-only
And I run :session-load window_session_name
+ And I wait until data/numbers/3.txt is loaded
+ And I wait until data/numbers/4.txt is loaded
And I wait until data/numbers/5.txt is loaded
Then the session should look like:
windows:
@@ -381,6 +383,7 @@ Feature: Saving and loading sessions
# Test load/save of pinned tabs
+ @qtwebengine_flaky
Scenario: Saving/Loading a session with pinned tabs
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 623bf4959..11b344439 100644
--- a/tests/end2end/features/spawn.feature
+++ b/tests/end2end/features/spawn.feature
@@ -14,12 +14,10 @@ Feature: :spawn
When I run :spawn -u this_does_not_exist
Then the error "Userscript 'this_does_not_exist' not found in userscript directories *" should be shown
- Scenario: Starting a userscript with absoloute path which doesn't exist
+ Scenario: Starting a userscript with absolute path which doesn't exist
When I run :spawn -u /this_does_not_exist
Then the error "Userscript '/this_does_not_exist' not found" should be shown
- # https://github.com/qutebrowser/qutebrowser/issues/1614
- @posix
Scenario: Running :spawn with invalid quoting
When I run :spawn ""'""
Then the error "Error while splitting command: No closing quotation" should be shown
diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature
index 49b9fc51b..b17cbe1e6 100644
--- a/tests/end2end/features/tabs.feature
+++ b/tests/end2end/features/tabs.feature
@@ -6,6 +6,7 @@ Feature: Tab management
Background:
Given I clean up open tabs
And I set tabs.tabs_are_windows to false
+ And I clear the log
# :tab-close
@@ -737,15 +738,8 @@ Feature: Tab management
# https://github.com/qutebrowser/qutebrowser/issues/2289
- @qtwebkit_skip @qt<5.9
- Scenario: Cloning a tab with a view-source URL
- When I open /
- And I open view-source:http://localhost:(port)
- And I run :tab-clone
- Then the error "Can't serialize special URL!" should be shown
-
- @qtwebkit_skip @qt>=5.9
- Scenario: Cloning a tab with a special URL (Qt 5.9)
+ @qtwebkit_skip
+ Scenario: Cloning a tab with a special URL
When I open chrome://gpu
And I run :tab-clone
Then no crash should happen
@@ -755,7 +749,7 @@ Feature: Tab management
Scenario: Undo without any closed tabs
Given I have a fresh instance
When I run :undo
- Then the error "Nothing to undo!" should be shown
+ Then the error "Nothing to undo (use :undo --window to reopen a closed window)" should be shown
Scenario: Undo closing a tab
When I open data/numbers/1.txt
@@ -833,8 +827,8 @@ Feature: Tab management
And I set url.default_page to about:blank
And I run :undo
And I run :undo
- Then the error "Nothing to undo!" should be shown
- And the error "Nothing to undo!" should be shown
+ Then the error "Nothing to undo (use :undo --window to reopen a closed window)" should be shown
+ And the error "Nothing to undo (use :undo --window to reopen a closed window)" should be shown
Scenario: Undo a tab closed by index
When I open data/numbers/1.txt
@@ -896,6 +890,129 @@ Feature: Tab management
- data/numbers/2.txt
- data/numbers/3.txt
+ # :undo --window
+
+ Scenario: Undo the closing of a window
+ Given I clear the log
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new window
+ And I run :close
+ And I wait for "removed: tabbed-browser" in the log
+ And I run :undo -w
+ And I wait for "Focus object changed: *" in the log
+ Then the session should look like:
+ windows:
+ - tabs:
+ - active: true
+ history:
+ - url: about:blank
+ - url: http://localhost:*/data/numbers/1.txt
+ - active: true
+ tabs:
+ - active: true
+ history:
+ - url: http://localhost:*/data/numbers/2.txt
+
+ Scenario: Undo the closing of a window with multiple tabs
+ Given I clear the log
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new window
+ And I open data/numbers/3.txt in a new tab
+ And I run :close
+ And I wait for "removed: tabbed-browser" in the log
+ And I run :undo -w
+ And I wait for "Focus object changed: *" in the log
+ Then the session should look like:
+ windows:
+ - tabs:
+ - active: true
+ history:
+ - url: about:blank
+ - url: http://localhost:*/data/numbers/1.txt
+ - active: true
+ tabs:
+ - history:
+ - url: http://localhost:*/data/numbers/2.txt
+ - active: true
+ history:
+ - url: http://localhost:*/data/numbers/3.txt
+
+ Scenario: Undo the closing of a window with multiple tabs with undo stack
+ Given I clear the log
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new window
+ And I open data/numbers/3.txt in a new tab
+ And I run :tab-close
+ And I run :close
+ And I wait for "removed: tabbed-browser" in the log
+ And I run :undo -w
+ And I run :undo
+ And I wait for "Focus object changed: *" in the log
+ Then the session should look like:
+ windows:
+ - tabs:
+ - active: true
+ history:
+ - url: about:blank
+ - url: http://localhost:*/data/numbers/1.txt
+ - active: true
+ tabs:
+ - history:
+ - url: http://localhost:*/data/numbers/2.txt
+ - active: true
+ history:
+ - url: http://localhost:*/data/numbers/3.txt
+
+ Scenario: Undo the closing of a window with tabs are windows
+ Given I clear the log
+ When I set tabs.last_close to close
+ And I set tabs.tabs_are_windows to true
+ And I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new tab
+ And I run :tab-close
+ And I wait for "removed: tabbed-browser" in the log
+ And I run :undo -w
+ And I wait for "Focus object changed: *" in the log
+ Then the session should look like:
+ windows:
+ - tabs:
+ - active: true
+ history:
+ - url: about:blank
+ - url: http://localhost:*/data/numbers/1.txt
+ - tabs:
+ - active: true
+ history:
+ - url: http://localhost:*/data/numbers/2.txt
+
+ # :undo with count
+
+ Scenario: Undo the second to last closed tab
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new tab
+ And I open data/numbers/3.txt in a new tab
+ And I run :tab-close
+ And I run :tab-close
+ And I run :undo with count 2
+ Then the following tabs should be open:
+ - data/numbers/1.txt
+ - data/numbers/3.txt (active)
+
+ Scenario: Undo with a too-high count
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new tab
+ And I run :tab-close
+ And I run :undo with count 100
+ Then the error "Nothing to undo" should be shown
+
+ Scenario: Undo with --window and count
+ When I run :undo --window with count 2
+ Then the error ":undo --window does not support a count/depth" should be shown
+
+ Scenario: Undo with --window and depth
+ When I run :undo --window 1
+ Then the error ":undo --window does not support a count/depth" should be shown
+
# tabs.last_close
# FIXME:qtwebengine
@@ -1299,6 +1416,21 @@ Feature: Tab management
- history:
- url: http://localhost:*/data/hello.txt
+ Scenario: Closing tab with tabs_are_windows
+ When I set tabs.tabs_are_windows to true
+ And I set tabs.last_close to ignore
+ And I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new tab
+ And I run :tab-close
+ And I wait for "removed: tabbed-browser" in the log
+ Then the session should look like:
+ windows:
+ - tabs:
+ - active: true
+ history:
+ - url: about:blank
+ - url: http://localhost:*/data/numbers/1.txt
+
# :tab-pin
Scenario: :tab-pin command
@@ -1490,3 +1622,12 @@ Feature: Tab management
And I open data/hello.txt in a new tab
And I run :fake-key -g hello-world<enter>
Then the message "hello-world" should be shown
+
+ Scenario: Undo after changing tabs_are_windows
+ When I open data/hello.txt
+ And I open data/hello.txt in a new tab
+ And I set tabs.tabs_are_windows to true
+ And I run :tab-close
+ And I run :undo
+ And I run :message-info "Still alive!"
+ Then the message "Still alive!" should be shown
diff --git a/tests/end2end/features/test_downloads_bdd.py b/tests/end2end/features/test_downloads_bdd.py
index 951ebcfea..1f27d2794 100644
--- a/tests/end2end/features/test_downloads_bdd.py
+++ b/tests/end2end/features/test_downloads_bdd.py
@@ -96,12 +96,6 @@ def wait_for_download_prompt(tmpdir, quteproc, path):
"(reason: question asked)")
-@bdd.when("I download an SSL page")
-def download_ssl_page(quteproc, ssl_server):
- quteproc.send_cmd(':download https://localhost:{}/'
- .format(ssl_server.port))
-
-
@bdd.then(bdd.parsers.parse("The downloaded file {filename} should not exist"))
def download_should_not_exist(filename, tmpdir):
path = tmpdir / 'downloads' / filename
diff --git a/tests/end2end/features/test_misc_bdd.py b/tests/end2end/features/test_misc_bdd.py
index 8dcf7edd1..7aeae2739 100644
--- a/tests/end2end/features/test_misc_bdd.py
+++ b/tests/end2end/features/test_misc_bdd.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/>.
-import json
-
import pytest_bdd as bdd
bdd.scenarios('misc.feature')
@@ -28,10 +26,3 @@ def pdf_exists(quteproc, tmpdir, filename):
path = tmpdir / filename
data = path.read_binary()
assert data.startswith(b'%PDF')
-
-
-@bdd.when(bdd.parsers.parse('I set up "{lists}" as block lists'))
-def set_up_blocking(quteproc, lists, server):
- url = 'http://localhost:{}/data/adblock/'.format(server.port)
- urls = [url + item.strip() for item in lists.split(',')]
- quteproc.set_setting('content.host_blocking.lists', json.dumps(urls))
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..61cfcd8f3 100644
--- a/tests/end2end/features/test_prompts_bdd.py
+++ b/tests/end2end/features/test_prompts_bdd.py
@@ -17,11 +17,11 @@
# 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')
-from qutebrowser.utils import qtutils
-
@bdd.when("I load an SSL page")
def load_ssl_page(quteproc, ssl_server):
@@ -49,40 +49,32 @@ def no_prompt_shown(quteproc):
@bdd.then("a SSL error page should be shown")
def ssl_error_page(request, quteproc):
- if request.config.webengine and qtutils.version_check('5.9'):
+ if request.config.webengine:
quteproc.wait_for(message="Certificate error: *")
msg = quteproc.wait_for(message="Load error: *")
msg.expected = True
- expected_messages = [
- 'Load error: ERR_INSECURE_RESPONSE', # Qt <= 5.10
- 'Load error: ERR_CERT_AUTHORITY_INVALID', # Qt 5.11
- ]
- assert msg.message in expected_messages
+ assert msg.message == 'Load error: ERR_CERT_AUTHORITY_INVALID'
else:
- if not request.config.webengine:
- line = quteproc.wait_for(message='Error while loading *: SSL '
- 'handshake failed')
- line.expected = True
- quteproc.wait_for(message="Changing title for idx * to 'Error "
- "loading page: *'")
+ line = quteproc.wait_for(message='Error while loading *: SSL handshake failed')
+ line.expected = True
+ quteproc.wait_for(message="Changing title for idx * to 'Error loading page: *'")
content = quteproc.get_content().strip()
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/test_qutescheme_bdd.py b/tests/end2end/features/test_qutescheme_bdd.py
index 587aadc41..1be913ac9 100644
--- a/tests/end2end/features/test_qutescheme_bdd.py
+++ b/tests/end2end/features/test_qutescheme_bdd.py
@@ -47,7 +47,7 @@ def request_blocked(request, quteproc, kind):
webkit_error_unsupported = (
"Error while loading qute://settings/set?*: Unsupported request type")
- if request.config.webengine and qtutils.version_check('5.12'):
+ if request.config.webengine:
# On Qt 5.12, we mark qute:// as a local scheme, causing most requests
# being blocked by Chromium internally (logging to the JS console).
expected_messages = {
@@ -60,13 +60,6 @@ def request_blocked(request, quteproc, kind):
# On Qt 5.15, Chromium blocks the redirect as ERR_UNSAFE_REDIRECT
# instead.
expected_messages['redirect'] = [unsafe_redirect_msg]
- elif request.config.webengine:
- expected_messages = {
- 'img': [blocking_csrf_msg],
- 'link': [blocking_set_msg, blocked_request_msg],
- 'redirect': [blocking_set_msg, blocked_request_msg],
- 'form': [blocking_set_msg, blocked_request_msg],
- }
else: # QtWebKit
expected_messages = {
'img': [blocking_csrf_msg],
diff --git a/tests/end2end/features/test_search_bdd.py b/tests/end2end/features/test_search_bdd.py
index ba3254830..3a9fb036c 100644
--- a/tests/end2end/features/test_search_bdd.py
+++ b/tests/end2end/features/test_search_bdd.py
@@ -19,11 +19,14 @@
import json
+import pytest
import pytest_bdd as bdd
-# pylint: disable=unused-import
-from end2end.features.test_yankpaste_bdd import init_fake_clipboard
-# pylint: enable=unused-import
+
+@pytest.fixture(autouse=True)
+def init_fake_clipboard(quteproc):
+ """Make sure the fake clipboard will be used."""
+ quteproc.send_cmd(':debug-set-fake-clipboard')
@bdd.then(bdd.parsers.parse('"{text}" should be found'))
@@ -34,6 +37,7 @@ def check_found_text(request, quteproc, text):
# https://codereview.qt-project.org/#/c/192920/
# https://codereview.qt-project.org/#/c/192921/
# https://bugreports.qt.io/browse/QTBUG-53134
+ # FIXME: Doesn't actually work, investigate why.
return
quteproc.send_cmd(':yank selection')
quteproc.wait_for(message='Setting fake clipboard: {}'.format(
diff --git a/tests/end2end/features/urlmarks.feature b/tests/end2end/features/urlmarks.feature
index ec38116c3..e55b5839d 100644
--- a/tests/end2end/features/urlmarks.feature
+++ b/tests/end2end/features/urlmarks.feature
@@ -231,7 +231,7 @@ Feature: quickmarks and bookmarks
And the page should contain the plaintext "twentyone"
Scenario: Listing bookmarks
- When I open data/title.html in a new tab
+ When I open data/title.html#unique-url in a new tab
And I run :bookmark-add
And I open qute://bookmarks
Then the page should contain the plaintext "Test title"
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 ecabffd13..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]]'
diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py
index 5f8263334..7d27e1166 100644
--- a/tests/end2end/fixtures/quteprocess.py
+++ b/tests/end2end/fixtures/quteprocess.py
@@ -33,10 +33,10 @@ import json
import yaml
import pytest
-from PyQt5.QtCore import pyqtSignal, QUrl, qVersion
+from PyQt5.QtCore import pyqtSignal, QUrl
from qutebrowser.misc import ipc
-from qutebrowser.utils import log, utils, javascript, qtutils
+from qutebrowser.utils import log, utils, javascript
from helpers import utils as testutils
from end2end.fixtures import testprocess
@@ -114,6 +114,12 @@ def is_ignored_lowlevel_message(message):
'*/QtWebEngineProcess: /lib/x86_64-linux-gnu/libdbus-1.so.3: no '
'version information available (required by '
'*/libQt5WebEngineCore.so.5)',
+
+ # hunter and Python 3.9
+ # https://github.com/ionelmc/python-hunter/issues/87
+ '<frozen importlib._bootstrap>:*: RuntimeWarning: builtins.type size changed, '
+ 'may indicate binary incompatibility. Expected 872 from C header, got 880 from '
+ 'PyObject',
]
return any(testutils.pattern_match(pattern=pattern, value=message)
for pattern in ignored_messages)
@@ -285,6 +291,44 @@ def is_ignored_chromium_message(line):
# [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',
+
+ # Qt 5.15.1 debug build (Chromium 83)
+ # '[314297:7:0929/214605.491958:ERROR:context_provider_command_buffer.cc(145)]
+ # GpuChannelHost failed to create command buffer.'
+ 'GpuChannelHost failed to create command buffer.',
+ # [338691:4:0929/220114.488847:WARNING:ipc_message_attachment_set.cc(49)]
+ # MessageAttachmentSet destroyed with unconsumed attachments: 0/1
+ 'MessageAttachmentSet destroyed with unconsumed attachments: *',
+
+ # GitHub Actions with Qt 5.15.1
+ ('SharedImageManager::ProduceGLTexture: Trying to produce a '
+ 'representation from a non-existent mailbox. *'),
+ ('[.DisplayCompositor]GL ERROR :GL_INVALID_OPERATION : '
+ 'DoCreateAndTexStorage2DSharedImageINTERNAL: invalid mailbox name'),
+ ('[.DisplayCompositor]GL ERROR :GL_INVALID_OPERATION : '
+ 'DoBeginSharedImageAccessCHROMIUM: bound texture is not a shared image'),
+ ('[.DisplayCompositor]RENDER WARNING: texture bound to texture unit 0 is '
+ 'not renderable. It might be non-power-of-2 or have incompatible texture '
+ 'filtering (maybe)?'),
+ ('[.DisplayCompositor]GL ERROR :GL_INVALID_OPERATION : '
+ 'DoEndSharedImageAccessCHROMIUM: bound texture is not a shared image'),
]
return any(testutils.pattern_match(pattern=pattern, value=message)
for pattern in ignored_messages)
@@ -377,8 +421,6 @@ class QuteProc(testprocess.Process):
_webengine: Whether to use QtWebEngine
basedir: The base directory for this instance.
request: The request object for the current test.
- _focus_ready: Whether the main window got focused.
- _load_ready: Whether the about:blank page got loaded.
_instance_id: A unique ID for this QuteProc instance
_run_counter: A counter to get a unique ID for each run.
@@ -395,75 +437,23 @@ class QuteProc(testprocess.Process):
super().__init__(request, parent)
self._ipc_socket = None
self.basedir = None
- self._focus_ready = False
- self._load_ready = False
self._instance_id = next(instance_counter)
self._run_counter = itertools.count()
- def _is_ready(self, what):
- """Called by _parse_line if loading/focusing is done.
-
- When both are done, emits the 'ready' signal.
- """
- if what == 'load':
- self._load_ready = True
- elif what == 'focus':
- self._focus_ready = True
- else:
- raise ValueError("Invalid value {!r} for 'what'.".format(what))
-
- is_qt_5_12 = qtutils.version_check('5.12', compiled=False)
- if ((self._load_ready and self._focus_ready) or
- (self._load_ready and is_qt_5_12)):
- self._load_ready = False
- self._focus_ready = False
- self.ready.emit()
-
def _process_line(self, log_line):
"""Check if the line matches any initial lines we're interested in."""
- start_okay_message_load = (
+ start_okay_message = (
"load status for <qutebrowser.browser.* tab_id=0 "
"url='about:blank'>: LoadStatus.success")
- start_okay_messages_focus = [
- ## QtWebKit
- "Focus object changed: "
- "<qutebrowser.browser.* tab_id=0 url='about:blank'>",
- # when calling QApplication::sync
- "Focus object changed: "
- "<qutebrowser.browser.webkit.webview.WebView tab_id=0 url=''>",
-
- ## QtWebEngine
- "Focus object changed: "
- "<PyQt5.QtWidgets.QOpenGLWidget object at *>",
- # with Qt >= 5.8
- "Focus object changed: "
- "<PyQt5.QtGui.QWindow object at *>",
- # when calling QApplication::sync
- "Focus object changed: "
- "<PyQt5.QtWidgets.QWidget object at *>",
- # Qt >= 5.11
- "Focus object changed: "
- "<qutebrowser.browser.webengine.webview.WebEngineView object "
- "at *>",
- # Qt >= 5.11 with workarounds
- "Focus object changed: "
- "<PyQt5.QtQuickWidgets.QQuickWidget object at *>",
- ]
if (log_line.category == 'ipc' and
log_line.message.startswith("Listening as ")):
self._ipc_socket = log_line.message.split(' ', maxsplit=2)[2]
elif (log_line.category == 'webview' and
- testutils.pattern_match(pattern=start_okay_message_load,
+ testutils.pattern_match(pattern=start_okay_message,
value=log_line.message)):
- if not self._load_ready:
- log_line.waited_for = True
- self._is_ready('load')
- elif (log_line.category == 'misc' and any(
- testutils.pattern_match(pattern=pattern,
- value=log_line.message)
- for pattern in start_okay_messages_focus)):
- self._is_ready('focus')
+ log_line.waited_for = True
+ self.ready.emit()
elif (log_line.category == 'init' and
log_line.module == 'standarddir' and
log_line.function == 'init' and
@@ -527,9 +517,10 @@ class QuteProc(testprocess.Process):
'--json-logging', '--loglevel', 'vdebug',
'--backend', backend, '--debug-flag', 'no-sql-history',
'--debug-flag', 'werror']
- if qVersion() == '5.7.1':
- # https://github.com/qutebrowser/qutebrowser/issues/3163
- args += ['--qt-flag', 'disable-seccomp-filter-sandbox']
+
+ if self.request.config.webengine:
+ args += testutils.seccomp_args(qt_flag=True)
+
args.append('about:blank')
return args
@@ -665,14 +656,17 @@ class QuteProc(testprocess.Process):
if bad_msgs:
text = 'Logged unexpected errors:\n\n' + '\n'.join(
str(e) for e in bad_msgs)
- # We'd like to use pytrace=False here but don't as a WORKAROUND
- # for https://github.com/pytest-dev/pytest/issues/1316
- pytest.fail(text)
+ pytest.fail(text, pytrace=False)
else:
self._maybe_skip()
finally:
super().after_test()
+ def _wait_for_ipc(self):
+ """Wait for an IPC message to arrive."""
+ self.wait_for(category='ipc', module='ipc', function='on_ready_read',
+ message='Read from socket *')
+
def send_ipc(self, commands, target_arg=''):
"""Send a raw command to the running IPC socket."""
delay = self.request.config.getoption('--qute-delay')
@@ -680,21 +674,24 @@ class QuteProc(testprocess.Process):
assert self._ipc_socket is not None
ipc.send_to_running_instance(self._ipc_socket, commands, target_arg)
- self.wait_for(category='ipc', module='ipc', function='on_ready_read',
- message='Read from socket *')
- def start(self, *args, wait_focus=True,
- **kwargs): # pylint: disable=arguments-differ
- if not wait_focus:
- self._focus_ready = True
+ try:
+ self._wait_for_ipc()
+ except testprocess.WaitForTimeout:
+ # Sometimes IPC messages seem to get lost on Windows CI?
+ # Retry a second time as this shouldn't make tests fail.
+ ipc.send_to_running_instance(self._ipc_socket, commands,
+ target_arg)
+ self._wait_for_ipc()
+ def start(self, *args, **kwargs): # pylint: disable=arguments-differ
try:
super().start(*args, **kwargs)
except testprocess.ProcessExited:
is_dl_inconsistency = str(self.captured_log[-1]).endswith(
"_dl_allocate_tls_init: Assertion "
"`listp->slotinfo[cnt].gen <= GL(dl_tls_generation)' failed!")
- if 'TRAVIS' in os.environ and is_dl_inconsistency:
+ if testutils.ON_CI and is_dl_inconsistency:
# WORKAROUND for https://sourceware.org/bugzilla/show_bug.cgi?id=19329
self.captured_log = []
self._log("NOTE: Restarted after libc DL inconsistency!")
@@ -715,6 +712,7 @@ class QuteProc(testprocess.Process):
Return:
The parsed log line with "command called: ..." or None.
"""
+ __tracebackhide__ = lambda e: e.errisinstance(testprocess.WaitForTimeout)
summary = command
if count is not None:
summary += ' (count {})'.format(count)
@@ -811,7 +809,7 @@ class QuteProc(testprocess.Process):
testprocess.WaitForTimeout))
if timeout is None:
- if 'CI' in os.environ:
+ if testutils.ON_CI:
timeout = 15000
else:
timeout = 5000
@@ -821,24 +819,16 @@ class QuteProc(testprocess.Process):
raise ValueError("Invalid URL {}: {}".format(url,
qurl.errorString()))
- if (qurl == QUrl('about:blank') and
- not qtutils.version_check('5.10', compiled=False)):
- # For some reason, we don't get a LoadStatus.success for
- # about:blank sometimes.
- # However, if we do this for Qt 5.10, we get general testsuite
- # instability as site loads get reported with about:blank...
- pattern = "Changing title for idx * to 'about:blank'"
- else:
- # We really need the same representation that the webview uses in
- # its __repr__
- url = utils.elide(qurl.toDisplayString(QUrl.EncodeUnicode), 100)
- assert url
-
- pattern = re.compile(
- r"(load status for <qutebrowser\.browser\..* "
- r"tab_id=\d+ url='{url}/?'>: LoadStatus\.{load_status}|fetch: "
- r"PyQt5\.QtCore\.QUrl\('{url}'\) -> .*)".format(
- load_status=re.escape(load_status), url=re.escape(url)))
+ # We really need the same representation that the webview uses in
+ # its __repr__
+ url = utils.elide(qurl.toDisplayString(QUrl.EncodeUnicode), 100)
+ assert url
+
+ pattern = re.compile(
+ r"(load status for <qutebrowser\.browser\..* "
+ r"tab_id=\d+ url='{url}/?'>: LoadStatus\.{load_status}|fetch: "
+ r"PyQt5\.QtCore\.QUrl\('{url}'\) -> .*)".format(
+ load_status=re.escape(load_status), url=re.escape(url)))
try:
self.wait_for(message=pattern, timeout=timeout, after=after)
diff --git a/tests/end2end/fixtures/test_quteprocess.py b/tests/end2end/fixtures/test_quteprocess.py
index 30c85d5ba..5693f11e9 100644
--- a/tests/end2end/fixtures/test_quteprocess.py
+++ b/tests/end2end/fixtures/test_quteprocess.py
@@ -351,7 +351,7 @@ def test_set(quteproc, value):
@pytest.mark.parametrize('message, ignored', [
- # Unparseable
+ # Unparsable
('Hello World', False),
# Without process/thread ID
('[0606/135039:ERROR:cert_verify_proc_nss.cc(925)] CERT_PKIXVerifyCert '
diff --git a/tests/end2end/fixtures/testprocess.py b/tests/end2end/fixtures/testprocess.py
index 3c19b86ef..7a70e4de9 100644
--- a/tests/end2end/fixtures/testprocess.py
+++ b/tests/end2end/fixtures/testprocess.py
@@ -20,8 +20,8 @@
"""Base class for a subprocess run for tests."""
import re
-import os
import time
+import warnings
import attr
import pytest
@@ -74,10 +74,17 @@ 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:]
+
+ if utils.ON_CI:
+ data = [utils.gha_group_begin('Log')] + data + [utils.gha_group_end()]
+
return '\n'.join(data)
@@ -93,7 +100,7 @@ def pytest_runtest_makereport(item, call):
return
quteproc_log = getattr(item, '_quteproc_log', None)
- server_log = getattr(item, '_server_log', None)
+ server_logs = getattr(item, '_server_logs', [])
if not hasattr(report.longrepr, 'addsection'):
# In some conditions (on macOS and Windows it seems), report.longrepr
@@ -106,11 +113,11 @@ def pytest_runtest_makereport(item, call):
verbose = item.config.getoption('--verbose')
if quteproc_log is not None:
- report.longrepr.addsection("qutebrowser output",
- _render_log(quteproc_log, verbose=verbose))
- if server_log is not None:
- report.longrepr.addsection("server output",
- _render_log(server_log, verbose=verbose))
+ report.longrepr.addsection(
+ "qutebrowser output", _render_log(quteproc_log, verbose=verbose))
+ for name, content in server_logs:
+ report.longrepr.addsection(
+ f"{name} output", _render_log(content, verbose=verbose))
class Process(QObject):
@@ -226,7 +233,7 @@ class Process(QObject):
self._started = True
verbose = self.request.config.getoption('--verbose')
- timeout = 60 if 'CI' in os.environ else 20
+ timeout = 60 if utils.ON_CI else 20
for _ in range(timeout):
with self._wait_signal(self.ready, timeout=1000,
raising=False) as blocker:
@@ -309,8 +316,11 @@ class Process(QObject):
else:
self.proc.terminate()
- ok = self.proc.waitForFinished()
+ ok = self.proc.waitForFinished(5000)
if not ok:
+ cmdline = ' '.join([self.proc.program()] + self.proc.arguments())
+ warnings.warn(f"Test process {cmdline} with PID {self.proc.processId()} "
+ "failed to terminate!")
self.proc.kill()
self.proc.waitForFinished()
@@ -465,7 +475,7 @@ class Process(QObject):
if timeout is None:
if do_skip:
timeout = 2000
- elif 'CI' in os.environ:
+ elif utils.ON_CI:
timeout = 15000
else:
timeout = 5000
diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py
index 839355664..d40739724 100644
--- a/tests/end2end/fixtures/webserver.py
+++ b/tests/end2end/fixtures/webserver.py
@@ -23,6 +23,7 @@ import re
import sys
import json
import os.path
+import socket
from http import HTTPStatus
import attr
@@ -31,8 +32,6 @@ from PyQt5.QtCore import pyqtSignal, QUrl
from end2end.fixtures import testprocess
-from qutebrowser.utils import utils
-
class Request(testprocess.Line):
@@ -140,9 +139,17 @@ class WebserverProcess(testprocess.Process):
def __init__(self, request, script, parent=None):
super().__init__(request, parent)
self._script = script
- self.port = utils.random_port()
+ self.port = self._random_port()
self.new_data.connect(self.new_request)
+ def _random_port(self) -> int:
+ """Get a random free port."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(('localhost', 0))
+ port = sock.getsockname()[1]
+ sock.close()
+ return port
+
def get_requests(self):
"""Get the requests to the server during this test."""
requests = self._get_data()
@@ -185,21 +192,41 @@ def server(qapp, request):
@pytest.fixture(autouse=True)
def server_per_test(server, request):
"""Fixture to clean server request list after each test."""
- request.node._server_log = server.captured_log
+ if not hasattr(request.node, '_server_logs'):
+ request.node._server_logs = []
+ request.node._server_logs.append(('server', server.captured_log))
+
server.before_test()
yield
server.after_test()
@pytest.fixture
+def server2(qapp, request):
+ """Fixture for a second server object for cross-origin tests."""
+ server = WebserverProcess(request, 'webserver_sub')
+
+ if not hasattr(request.node, '_server_logs'):
+ request.node._server_logs = []
+ request.node._server_logs.append(('secondary server', server.captured_log))
+
+ server.start()
+ yield server
+ server.terminate()
+
+
+@pytest.fixture
def ssl_server(request, qapp):
"""Fixture for a webserver with a self-signed SSL certificate.
- This needs to be explicitly used in a test, and overwrites the server log
- used in that test.
+ This needs to be explicitly used in a test.
"""
server = WebserverProcess(request, 'webserver_sub_ssl')
- request.node._server_log = server.captured_log
+
+ if not hasattr(request.node, '_server_logs'):
+ request.node._server_logs = []
+ request.node._server_logs.append(('SSL server', server.captured_log))
+
server.start()
yield server
server.after_test()
diff --git a/tests/end2end/fixtures/webserver_sub.py b/tests/end2end/fixtures/webserver_sub.py
index 9902ab125..e38d64bb8 100644
--- a/tests/end2end/fixtures/webserver_sub.py
+++ b/tests/end2end/fixtures/webserver_sub.py
@@ -29,9 +29,8 @@ parameters or headers with the same name properly.
import sys
import json
import time
-import signal
-import os
import threading
+import pathlib
from http import HTTPStatus
import cheroot.wsgi
@@ -41,6 +40,9 @@ app = flask.Flask(__name__)
_redirect_later_event = None
+END2END_DIR = pathlib.Path(__file__).resolve().parents[1]
+
+
@app.route('/')
def root():
"""Show simple text."""
@@ -55,15 +57,8 @@ def send_data(path):
If a directory is requested, its index.html is sent.
"""
- if hasattr(sys, 'frozen'):
- basedir = os.path.realpath(os.path.dirname(sys.executable))
- data_dir = os.path.join(basedir, 'end2end', 'data')
- else:
- basedir = os.path.join(os.path.realpath(os.path.dirname(__file__)),
- '..')
- data_dir = os.path.join(basedir, 'data')
- print(basedir)
- if os.path.isdir(os.path.join(data_dir, path)):
+ data_dir = END2END_DIR / 'data'
+ if (data_dir / path).is_dir():
path += '/index.html'
return flask.send_from_directory(data_dir, path)
@@ -249,6 +244,12 @@ def view_headers():
return flask.jsonify(headers=dict(flask.request.headers))
+@app.route('/headers-link/<int:port>')
+def headers_link(port):
+ """Get a (possibly cross-origin) link to /headers."""
+ return flask.render_template('headers-link.html', port=port)
+
+
@app.route('/response-headers')
def response_headers():
"""Return a set of response headers from the query string."""
@@ -274,11 +275,9 @@ def view_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')
+ icon_dir = END2END_DIR.parents[1] / 'icons'
+ return flask.send_from_directory(
+ icon_dir, 'qutebrowser.ico', mimetype='image/vnd.microsoft.icon')
@app.after_request
@@ -322,18 +321,12 @@ class WSGIServer(cheroot.wsgi.Server):
def main():
- if hasattr(sys, 'frozen'):
- basedir = os.path.realpath(os.path.dirname(sys.executable))
- app.template_folder = os.path.join(basedir, 'end2end', 'templates')
+ app.template_folder = END2END_DIR / 'templates'
+ assert app.template_folder.is_dir(), app.template_folder
+
port = int(sys.argv[1])
server = WSGIServer(('127.0.0.1', port), app)
-
- signal.signal(signal.SIGTERM, lambda *args: server.stop())
-
- try:
- server.start()
- except KeyboardInterrupt:
- server.stop()
+ server.start()
if __name__ == '__main__':
diff --git a/tests/end2end/templates/headers-link.html b/tests/end2end/templates/headers-link.html
new file mode 100644
index 000000000..fece530b1
--- /dev/null
+++ b/tests/end2end/templates/headers-link.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Link to header page</title>
+ </head>
+ <body>
+ <a href="http://localhost:{{ port }}/headers" id="link">headers</a>
+ </body>
+</html>
diff --git a/tests/end2end/test_adblock_e2e.py b/tests/end2end/test_adblock_e2e.py
new file mode 100644
index 000000000..f8ecc596f
--- /dev/null
+++ b/tests/end2end/test_adblock_e2e.py
@@ -0,0 +1,61 @@
+# 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/>.
+
+"""End to end tests for adblocking."""
+
+import pytest
+
+try:
+ import adblock
+except ImportError:
+ adblock = None
+
+needs_adblock_lib = pytest.mark.skipif(
+ adblock is None, reason="Needs 'adblock' library")
+
+
+@pytest.mark.parametrize('method', [
+ 'auto',
+ 'hosts',
+ pytest.param('adblock', marks=needs_adblock_lib),
+ pytest.param('both', marks=needs_adblock_lib),
+])
+def test_adblock(method, quteproc, server):
+ for kind in ['hosts', 'adblock']:
+ quteproc.set_setting(
+ f'content.blocking.{kind}.lists',
+ f"['http://localhost:{server.port}/data/blocking/qutebrowser-{kind}']"
+ )
+
+ quteproc.set_setting('content.blocking.method', method)
+ quteproc.send_cmd(':adblock-update')
+
+ quteproc.wait_for(message="hostblock: Read 1 hosts from 1 sources.")
+ if adblock is not None:
+ quteproc.wait_for(
+ message="braveadblock: Filters successfully read from 1 sources.")
+
+ quteproc.open_path('data/blocking/external_logo.html')
+
+ if method in ['hosts', 'both'] or (method == 'auto' and adblock is None):
+ message = "Request to qutebrowser.org blocked by host blocker."
+ else:
+ message = ("Request to https://qutebrowser.org/icons/qutebrowser.svg blocked "
+ "by ad blocker.")
+ quteproc.wait_for(message=message)
diff --git a/tests/end2end/test_insert_mode.py b/tests/end2end/test_insert_mode.py
index 609e1f68b..eb27b27a6 100644
--- a/tests/end2end/test_insert_mode.py
+++ b/tests/end2end/test_insert_mode.py
@@ -27,7 +27,8 @@ import pytest
('textarea.html', 'qute-textarea', 'keypress', 'superqutebrowser'),
('input.html', 'qute-input', 'clipboard', 'amazingqutebrowser'),
('input.html', 'qute-input', 'keypress', 'awesomequtebrowser'),
- ('autofocus.html', 'qute-input-autofocus', 'keypress', 'cutebrowser'),
+ pytest.param('autofocus.html', 'qute-input-autofocus', 'keypress',
+ 'cutebrowser', marks=pytest.mark.flaky),
])
@pytest.mark.parametrize('zoom', [100, 125, 250])
def test_insert_mode(file_name, elem_id, source, input_text, zoom,
@@ -57,6 +58,7 @@ def test_insert_mode(file_name, elem_id, source, input_text, zoom,
(True, False, True), # enabled and foreground tab
(True, True, False), # background tab
])
+@pytest.mark.flaky
def test_auto_load(quteproc, auto_load, background, insert_mode):
quteproc.set_setting('input.insert_mode.auto_load', str(auto_load))
url_path = 'data/insert_mode_settings/html/autofocus.html'
diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py
index d61458ef3..1d2f79a1a 100644
--- a/tests/end2end/test_invocations.py
+++ b/tests/end2end/test_invocations.py
@@ -20,13 +20,13 @@
"""Test starting qutebrowser with special arguments/environments."""
import subprocess
-import socket
import sys
import logging
import re
+import json
import pytest
-from PyQt5.QtCore import QProcess, qVersion
+from PyQt5.QtCore import QProcess
from helpers import utils
@@ -43,9 +43,10 @@ def _base_args(config):
args += ['--backend', 'webengine']
else:
args += ['--backend', 'webkit']
- if qVersion() == '5.7.1':
- # https://github.com/qutebrowser/qutebrowser/issues/3163
- args += ['--qt-flag', 'disable-seccomp-filter-sandbox']
+
+ if config.webengine:
+ args += utils.seccomp_args(qt_flag=True)
+
args.append('about:blank')
return args
@@ -69,7 +70,6 @@ def temp_basedir_env(tmpdir, short_tmpdir):
'[general]',
'quickstart-done = 1',
'backend-warning-shown = 1',
- 'old-qt-warning-shown = 1',
'webkit-warning-shown = 1',
]
@@ -143,6 +143,10 @@ def test_open_with_ascii_locale(request, server, tmpdir, quteproc_new, url):
quteproc_new.wait_for(message="load status for <* tab_id=* "
"url='*/f%C3%B6%C3%B6.html'>: LoadStatus.error")
+ if request.config.webengine:
+ line = quteproc_new.wait_for(message='Load error: ERR_FILE_NOT_FOUND')
+ line.expected = True
+
@pytest.mark.linux
@ascii_locale
@@ -156,7 +160,7 @@ def test_open_command_line_with_ascii_locale(request, server, tmpdir,
# all be called. No exception thrown means test success.
args = (['--temp-basedir'] + _base_args(request.config) +
['/home/user/föö.html'])
- quteproc_new.start(args, env={'LC_ALL': 'C'}, wait_focus=False)
+ quteproc_new.start(args, env={'LC_ALL': 'C'})
if not request.config.webengine:
line = quteproc_new.wait_for(message="Error while loading *: Error "
@@ -166,6 +170,10 @@ def test_open_command_line_with_ascii_locale(request, server, tmpdir,
quteproc_new.wait_for(message="load status for <* tab_id=* "
"url='*/f*.html'>: LoadStatus.error")
+ if request.config.webengine:
+ line = quteproc_new.wait_for(message="Load error: ERR_FILE_NOT_FOUND")
+ line.expected = True
+
@pytest.mark.linux
def test_misconfigured_user_dirs(request, server, temp_basedir_env,
@@ -248,7 +256,8 @@ def test_version(request):
assert ok
assert proc.exitStatus() == QProcess.NormalExit
- assert re.search(r'^qutebrowser\s+v\d+(\.\d+)', stdout) is not None
+ match = re.search(r'^qutebrowser\s+v\d+(\.\d+)', stdout, re.MULTILINE)
+ assert match is not None
def test_qt_arg(request, quteproc_new, tmpdir):
@@ -265,23 +274,6 @@ def test_qt_arg(request, quteproc_new, tmpdir):
quteproc_new.wait_for_quit()
-@utils.skip_qt511
-def test_webengine_inspector(request, quteproc_new):
- if not request.config.webengine:
- pytest.skip()
- args = (['--temp-basedir', '--enable-webengine-inspector'] +
- _base_args(request.config))
- quteproc_new.start(args)
- line = quteproc_new.wait_for(
- message='Remote debugging server started successfully. Try pointing a '
- 'Chromium-based browser to http://127.0.0.1:*')
- port = int(line.message.split(':')[-1])
-
- s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- s.connect(('127.0.0.1', port))
- s.close()
-
-
@pytest.mark.linux
def test_webengine_download_suffix(request, quteproc_new, tmpdir):
"""Make sure QtWebEngine does not add a suffix to downloads."""
@@ -323,16 +315,17 @@ def test_command_on_start(request, quteproc_new):
quteproc_new.wait_for_quit()
-def test_launching_with_python2():
+@pytest.mark.parametrize('python', ['python2', 'python3.5'])
+def test_launching_with_old_python(python):
try:
proc = subprocess.run(
- ['python2', '-m', 'qutebrowser', '--no-err-windows'],
+ [python, '-m', 'qutebrowser', '--no-err-windows'],
stderr=subprocess.PIPE,
check=False)
except FileNotFoundError:
- pytest.skip("python2 not found")
+ pytest.skip(f"{python} not found")
assert proc.returncode == 1
- error = "At least Python 3.5.2 is required to run qutebrowser"
+ error = "At least Python 3.6 is required to run qutebrowser"
assert proc.stderr.decode('ascii').startswith(error)
@@ -381,6 +374,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'
@@ -392,3 +388,50 @@ def test_qute_settings_persistence(short_tmpdir, request, quteproc_new):
quteproc_new.send_cmd(':quit')
quteproc_new.wait_for_quit()
+
+
+@pytest.mark.parametrize('value, expected', [
+ ('always', 'http://localhost:(port2)/headers-link/(port)'),
+ ('never', None),
+ ('same-domain', 'http://localhost:(port2)/'), # None with QtWebKit
+])
+def test_referrer(quteproc_new, server, server2, request, value, expected):
+ """Check referrer settings."""
+ args = _base_args(request.config) + [
+ '--temp-basedir',
+ '-s', 'content.headers.referer', value,
+ ]
+ quteproc_new.start(args)
+
+ quteproc_new.open_path(f'headers-link/{server.port}', port=server2.port)
+ quteproc_new.send_cmd(':click-element id link')
+ quteproc_new.wait_for_load_finished('headers')
+
+ content = quteproc_new.get_content()
+ data = json.loads(content)
+ print(data)
+ headers = data['headers']
+
+ if not request.config.webengine and value == 'same-domain':
+ # With QtWebKit and same-domain, we don't send a referer at all.
+ expected = None
+
+ if expected is not None:
+ for key, val in [('(port)', server.port), ('(port2)', server2.port)]:
+ expected = expected.replace(key, str(val))
+
+ assert headers.get('Referer') == expected
+
+
+@pytest.mark.qtwebkit_skip
+@utils.qt514
+def test_preferred_colorscheme(request, quteproc_new):
+ """Make sure the the preferred colorscheme is set."""
+ args = _base_args(request.config) + [
+ '--temp-basedir',
+ '-s', 'colors.webpage.prefers_color_scheme_dark', 'true',
+ ]
+ quteproc_new.start(args)
+
+ quteproc_new.send_cmd(':jseval matchMedia("(prefers-color-scheme: dark)").matches')
+ quteproc_new.wait_for(message='True')
diff --git a/tests/end2end/test_mhtml_e2e.py b/tests/end2end/test_mhtml_e2e.py
index 5da9602ae..faf4127d7 100644
--- a/tests/end2end/test_mhtml_e2e.py
+++ b/tests/end2end/test_mhtml_e2e.py
@@ -66,9 +66,7 @@ class DownloadDir:
def read_file(self):
files = self._tmpdir.listdir()
assert len(files) == 1
-
- with open(str(files[0]), 'r', encoding='utf-8') as f:
- return f.readlines()
+ return files[0].read_text(encoding='utf-8').splitlines()
def sanity_check_mhtml(self):
assert 'Content-Type: multipart/related' in '\n'.join(self.read_file())
diff --git a/tests/end2end/test_mkvenv.py b/tests/end2end/test_mkvenv.py
new file mode 100644
index 000000000..430be0279
--- /dev/null
+++ b/tests/end2end/test_mkvenv.py
@@ -0,0 +1,28 @@
+#!/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/>.
+
+
+from scripts import mkvenv
+
+
+def test_smoke(tmp_path):
+ """Simple smoke test of mkvenv.py."""
+ args = mkvenv.parse_args(['--venv-dir', str(tmp_path / 'venv'), '--skip-docs'])
+ mkvenv.run(args)
diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py
index 3bb2ad3e5..b814a6ea7 100644
--- a/tests/helpers/fixtures.py
+++ b/tests/helpers/fixtures.py
@@ -50,6 +50,7 @@ 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
@@ -93,6 +97,10 @@ class WinRegistryHelper:
def windowTitle(self):
return 'window title - qutebrowser'
+ @property
+ def tabbed_browser(self):
+ return self.registry['tabbed-browser']
+
def __init__(self):
self._ids = []
@@ -158,7 +166,7 @@ def fake_web_tab(stubs, tab_registry, mode_manager, qapp):
@pytest.fixture
-def greasemonkey_manager(monkeypatch, data_tmpdir):
+def greasemonkey_manager(monkeypatch, data_tmpdir, config_tmpdir):
gm_manager = greasemonkey.GreasemonkeyManager()
monkeypatch.setattr(greasemonkey, 'gm_manager', gm_manager)
@@ -204,19 +212,28 @@ 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, monkeypatch):
webkittab = pytest.importorskip('qutebrowser.browser.webkit.webkittab')
+ monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit)
+
tab = webkittab.WebKitTab(win_id=0, mode_manager=mode_manager,
private=False)
+ tab.backend = usertypes.Backend.QtWebKit
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):
+ monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine)
+
tabwidget = tabbed_browser_stubs[0].widget
tabwidget.current_index = 0
tabwidget.index_of = 0
@@ -226,19 +243,32 @@ def webengine_tab(web_tab_setup, qtbot, redirect_webengine_data,
tab = webenginetab.WebEngineTab(win_id=0, mode_manager=mode_manager,
private=False)
+ tab.backend = usertypes.Backend.QtWebEngine
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).
+ 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 +343,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.
@@ -414,13 +444,15 @@ def webengineview(qtbot, monkeypatch, web_tab_setup):
monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine)
view = QtWebEngineWidgets.QWebEngineView()
qtbot.add_widget(view)
- return view
+ yield view
+ view.setPage(None) # Avoid warning if using QWebEngineProfile
@pytest.fixture
-def webpage(qnam):
+def webpage(qnam, monkeypatch):
"""Get a new QWebPage object."""
QtWebKitWidgets = pytest.importorskip('PyQt5.QtWebKitWidgets')
+ monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit)
class WebPageStub(QtWebKitWidgets.QWebPage):
@@ -442,10 +474,9 @@ def webpage(qnam):
@pytest.fixture
-def webview(qtbot, webpage, monkeypatch):
+def webview(qtbot, webpage):
"""Get a new QWebView object."""
QtWebKitWidgets = pytest.importorskip('PyQt5.QtWebKitWidgets')
- monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit)
view = QtWebKitWidgets.QWebView()
qtbot.add_widget(view)
@@ -652,3 +683,39 @@ def web_history(fake_save_manager, tmpdir, init_sql, config_stub, stubs,
web_history = history.WebHistory(stubs.FakeHistoryProgress())
monkeypatch.setattr(history, 'web_history', web_history)
return web_history
+
+
+@pytest.fixture
+def blue_widget(qtbot):
+ widget = QWidget()
+ widget.setStyleSheet('background-color: blue;')
+ qtbot.add_widget(widget)
+ return widget
+
+
+@pytest.fixture
+def red_widget(qtbot):
+ widget = QWidget()
+ widget.setStyleSheet('background-color: red;')
+ qtbot.add_widget(widget)
+ return widget
+
+
+@pytest.fixture
+def state_config(data_tmpdir, monkeypatch):
+ state = configfiles.StateConfig()
+ monkeypatch.setattr(configfiles, 'state', state)
+ return state
+
+
+@pytest.fixture
+def unwritable_tmp_path(tmp_path):
+ tmp_path.chmod(0)
+ if os.access(str(tmp_path), os.W_OK):
+ # Docker container or similar
+ pytest.skip("Directory was still writable")
+
+ yield tmp_path
+
+ # Make sure pytest can clean up the tmp_path
+ tmp_path.chmod(0o755)
diff --git a/tests/helpers/messagemock.py b/tests/helpers/messagemock.py
index 4c1107029..03320a98f 100644
--- a/tests/helpers/messagemock.py
+++ b/tests/helpers/messagemock.py
@@ -24,7 +24,7 @@ import logging
import attr
import pytest
-from qutebrowser.utils import usertypes, message, objreg
+from qutebrowser.utils import usertypes, message
@attr.s
@@ -90,12 +90,3 @@ def message_mock():
mmock.patch()
yield mmock
mmock.unpatch()
-
-
-@pytest.fixture
-def message_bridge(win_registry):
- """Fixture to get a MessageBridge."""
- bridge = message.MessageBridge()
- objreg.register('message-bridge', bridge, scope='window', window=0)
- yield bridge
- objreg.delete('message-bridge', scope='window', window=0)
diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py
index caa7aac3f..47fb78f85 100644
--- a/tests/helpers/stubs.py
+++ b/tests/helpers/stubs.py
@@ -257,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
@@ -286,6 +286,9 @@ class FakeWebTab(browsertab.AbstractTab):
def icon(self):
return QIcon()
+ def renderer_process_pid(self):
+ return None
+
class FakeSignal:
@@ -481,9 +484,10 @@ class TabbedBrowserStub(QObject):
def __init__(self, parent=None):
super().__init__(parent)
self.widget = TabWidgetStub()
- self.shutting_down = False
+ self.is_shutting_down = False
self.loaded_url = None
self.cur_url = None
+ self.undo_stack = None
def on_tab_close_requested(self, idx):
del self.widget.tabs[idx]
@@ -615,6 +619,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:
@@ -652,3 +660,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):
+ self.cookie_filter = None
+
+ def setCookieFilter(self, func):
+ self.cookie_filter = func
diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py
index 25c3fb9a1..5f7297719 100644
--- a/tests/helpers/utils.py
+++ b/tests/helpers/utils.py
@@ -25,23 +25,24 @@ import gzip
import pprint
import os.path
import contextlib
+import pathlib
+import importlib.util
+import importlib.machinery
import pytest
+from PyQt5.QtCore import qVersion
+try:
+ from PyQt5.QtWebEngine import PYQT_WEBENGINE_VERSION_STR
+except ImportError:
+ PYQT_WEBENGINE_VERSION_STR = None
+
from qutebrowser.utils import qtutils, log
ON_CI = 'CI' in os.environ
-qt58 = pytest.mark.skipif(
- qtutils.version_check('5.9'), reason="Needs Qt 5.8 or earlier")
-qt59 = pytest.mark.skipif(
- not qtutils.version_check('5.9'), reason="Needs Qt 5.9 or newer")
-qt510 = pytest.mark.skipif(
- not qtutils.version_check('5.10'), reason="Needs Qt 5.10 or newer")
qt514 = pytest.mark.skipif(
not qtutils.version_check('5.14'), reason="Needs Qt 5.14 or newer")
-skip_qt511 = pytest.mark.skipif(
- qtutils.version_check('5.11'), reason="Needs Qt 5.10 or earlier")
class PartialCompareOutcome:
@@ -122,6 +123,24 @@ def _partial_compare_eq(val1, val2, *, indent):
return PartialCompareOutcome("{!r} != {!r}".format(val1, val2))
+def gha_group_begin(name):
+ """Get a string to begin a GitHub Actions group.
+
+ Should only be called on CI.
+ """
+ assert ON_CI
+ return '::group::' + name
+
+
+def gha_group_end():
+ """Get a string to end a GitHub Actions group.
+
+ Should only be called on CI.
+ """
+ assert ON_CI
+ return '::endgroup::'
+
+
def partial_compare(val1, val2, *, indent=0):
"""Do a partial comparison between the given values.
@@ -131,6 +150,9 @@ def partial_compare(val1, val2, *, indent=0):
This happens recursively.
"""
+ if ON_CI and indent == 0:
+ print(gha_group_begin('Comparison'))
+
print_i("Comparing", indent)
print_i(pprint.pformat(val1), indent + 1)
print_i("|---- to ----", indent)
@@ -162,6 +184,10 @@ def partial_compare(val1, val2, *, indent=0):
print_i("|======= Comparing via ==", indent)
outcome = _partial_compare_eq(val1, val2, indent=indent)
print_i("---> {}".format(outcome), indent)
+
+ if ON_CI and indent == 0:
+ print(gha_group_end())
+
return outcome
@@ -187,15 +213,90 @@ 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(
+ with log.py_warning_filter(
category=DeprecationWarning,
message="Using or importing the ABCs from 'collections' instead "
"of from 'collections.abc' is deprecated", module='bs4.element'):
yield
+def _decompress_gzip_datafile(filename):
+ path = os.path.join(abs_datapath(), filename)
+ yield from io.TextIOWrapper(gzip.open(path), encoding="utf-8")
+
+
def blocked_hosts():
- path = os.path.join(abs_datapath(), 'blocked-hosts.gz')
- yield from io.TextIOWrapper(gzip.open(path), encoding='utf-8')
+ return _decompress_gzip_datafile("blocked-hosts.gz")
+
+
+def adblock_dataset_tsv():
+ return _decompress_gzip_datafile("brave-adblock/ublock-matches.tsv.gz")
+
+
+def easylist_txt():
+ return _decompress_gzip_datafile("easylist.txt.gz")
+
+
+def easyprivacy_txt():
+ return _decompress_gzip_datafile("easyprivacy.txt.gz")
+
+
+def seccomp_args(qt_flag):
+ """Get necessary flags to disable the seccomp BPF sandbox.
+
+ This is needed for some QtWebEngine setups, with older Qt versions but
+ newer kernels.
+
+ Args:
+ qt_flag: Add a '--qt-flag' argument.
+ """
+ affected_versions = set()
+ for base, patch_range in [
+ # 5.12.0 to 5.12.7 (inclusive)
+ ('5.12', range(0, 8)),
+ # 5.13.0 to 5.13.2 (inclusive)
+ ('5.13', range(0, 3)),
+ # 5.14.0
+ ('5.14', [0]),
+ ]:
+ for patch in patch_range:
+ affected_versions.add('{}.{}'.format(base, patch))
+
+ version = (PYQT_WEBENGINE_VERSION_STR
+ if PYQT_WEBENGINE_VERSION_STR is not None
+ else qVersion())
+ if version in affected_versions:
+ disable_arg = 'disable-seccomp-filter-sandbox'
+ return ['--qt-flag', disable_arg] if qt_flag else ['--' + disable_arg]
+
+ return []
+
+
+def import_userscript(name):
+ """Import a userscript via importlib.
+
+ This is needed because userscripts don't have a .py extension and violate
+ Python's module naming convention.
+ """
+ repo_root = pathlib.Path(__file__).resolve().parents[2]
+ script_path = repo_root / 'misc' / 'userscripts' / name
+ module_name = name.replace('-', '_')
+ loader = importlib.machinery.SourceFileLoader(
+ module_name, str(script_path))
+ spec = importlib.util.spec_from_loader(module_name, loader)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
diff --git a/tests/manual/hints/hide_unmatched_rapid_hints.html b/tests/manual/hints/hide_unmatched_rapid_hints.html
index 1630a790e..98affa254 100644
--- a/tests/manual/hints/hide_unmatched_rapid_hints.html
+++ b/tests/manual/hints/hide_unmatched_rapid_hints.html
@@ -7,7 +7,7 @@
<body>
<p>When <code>hints.hide_unmatched_rapid_hints</code> is set to true (default), rapid hints behave like normal hints, i.e. unmatched hints will be hidden as you type. Setting the option to false will disable hiding in rapid mode, which is sometimes useful (see <a href="https://github.com/qutebrowser/qutebrowser/issues/1799">#1799</a>).</p>
<p>Note that when hinting in number mode, the <code>hints.hide_unmatched_rapid_hints</code> option affects typing the hint string (number), but not the filter (letters).</p>
- <p>Here is couple of invalid links to test the behaviour:</p>
+ <p>Here is couple of invalid links to test the behavior:</p>
<p><a href="#foo">one</a></p>
<p><a href="#foo">two</a></p>
<p><a href="#foo">three</a></p>
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..811ba2659 100644
--- a/tests/unit/api/test_cmdutils.py
+++ b/tests/unit/api/test_cmdutils.py
@@ -24,8 +24,8 @@
import sys
import logging
import types
-import typing
import enum
+from typing import Union
import pytest
@@ -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)
@@ -269,24 +290,28 @@ class TestRegister:
else:
assert pos_args == [('arg', 'arg')]
- Enum = enum.Enum('Test', ['x', 'y'])
+ class Enum(enum.Enum):
+
+ # pylint: disable=invalid-name
+ x = enum.auto()
+ y = enum.auto()
@pytest.mark.parametrize('typ, inp, choices, expected', [
(int, '42', None, 42),
(int, 'x', None, cmdexc.ArgumentTypeError),
(str, 'foo', None, 'foo'),
- (typing.Union[str, int], 'foo', None, 'foo'),
- (typing.Union[str, int], '42', None, 42),
+ (Union[str, int], 'foo', None, 'foo'),
+ (Union[str, int], '42', None, 42),
# Choices
(str, 'foo', ['foo'], 'foo'),
(str, 'bar', ['foo'], cmdexc.ArgumentTypeError),
# Choices with Union: only checked when it's a str
- (typing.Union[str, int], 'foo', ['foo'], 'foo'),
- (typing.Union[str, int], 'bar', ['foo'], cmdexc.ArgumentTypeError),
- (typing.Union[str, int], '42', ['foo'], 42),
+ (Union[str, int], 'foo', ['foo'], 'foo'),
+ (Union[str, int], 'bar', ['foo'], cmdexc.ArgumentTypeError),
+ (Union[str, int], '42', ['foo'], 42),
(Enum, 'x', None, Enum.x),
(Enum, 'z', None, cmdexc.ArgumentTypeError),
diff --git a/tests/unit/browser/test_caret.py b/tests/unit/browser/test_caret.py
index 9b817c4ac..bfa718315 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,33 @@ 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
+
+
+def test_selection_callback_wrong_mode(qtbot, caplog,
+ webengine_tab, mode_manager):
+ """Test what calling the selection callback outside of caret mode.
+
+ It should be ignored, as something could have left caret mode while the
+ async callback was happening, so we don't want to mess with the status bar.
+ """
+ assert mode_manager.mode == usertypes.KeyMode.normal
+ with qtbot.assertNotEmitted(webengine_tab.caret.selection_toggled):
+ webengine_tab.caret._toggle_sel_translate('normal')
+
+ msg = 'Ignoring caret selection callback in KeyMode.normal'
+ assert caplog.messages == [msg]
+
+
class TestDocument:
def test_selecting_entire_document(self, caret, selection):
@@ -287,20 +324,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
@pytest.mark.no_xvfb
def test_yanking_a_searched_line(self, caret, selection, mode_manager, web_tab, qtbot):
mode_manager.leave(usertypes.KeyMode.caret)
@@ -313,7 +336,6 @@ class TestSearch:
caret.move_to_end_of_line()
selection.check('five six')
- @pytest.mark.qtbug60673
@pytest.mark.no_xvfb
def test_yanking_a_searched_line_with_multiple_matches(self, caret, selection, mode_manager, web_tab, qtbot):
mode_manager.leave(usertypes.KeyMode.caret)
@@ -340,15 +362,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 +418,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/webkit/test_downloads.py b/tests/unit/browser/test_downloads.py
index b3629b39d..4cd7b3727 100644
--- a/tests/unit/browser/webkit/test_downloads.py
+++ b/tests/unit/browser/test_downloads.py
@@ -22,10 +22,14 @@ import pytest
from qutebrowser.browser import downloads, qtnetworkdownloads
-def test_download_model(qapp, qtmodeltester, config_stub, cookiejar_and_cache,
- fake_args):
+@pytest.fixture
+def manager(config_stub, cookiejar_and_cache):
+ """A QtNetwork download manager."""
+ return qtnetworkdownloads.DownloadManager()
+
+
+def test_download_model(qapp, qtmodeltester, manager):
"""Simple check for download model internals."""
- manager = qtnetworkdownloads.DownloadManager()
model = downloads.DownloadModel(manager)
qtmodeltester.check(model)
@@ -107,7 +111,7 @@ def test_sanitized_filenames(raw, expected,
config_stub, download_tmpdir, monkeypatch):
manager = downloads.AbstractDownloadManager()
target = downloads.FileDownloadTarget(str(download_tmpdir))
- item = downloads.AbstractDownloadItem()
+ item = downloads.AbstractDownloadItem(manager=manager)
# Don't try to start a timer outside of a QThread
manager._update_timer.isActive = lambda: True
@@ -116,6 +120,58 @@ def test_sanitized_filenames(raw, expected,
item._ensure_can_set_filename = lambda *args: True
item._after_set_filename = lambda *args: True
+ # Don't try to get current window
+ monkeypatch.setattr(item, '_get_conflicting_download', list)
+
manager._init_item(item, True, raw)
item.set_target(target)
assert item._filename.endswith(expected)
+
+
+class TestConflictingDownloads:
+
+ @pytest.fixture
+ def item1(self, manager):
+ return downloads.AbstractDownloadItem(manager=manager)
+
+ @pytest.fixture
+ def item2(self, manager):
+ return downloads.AbstractDownloadItem(manager=manager)
+
+ def test_no_downloads(self, item1):
+ item1._filename = 'download.txt'
+ assert item1._get_conflicting_download() is None
+
+ @pytest.mark.parametrize('filename1, filename2, done, conflict', [
+ # Different name
+ ('download.txt', 'download2.txt', False, False),
+ # Finished
+ ('download.txt', 'download.txt', True, False),
+ # Conflict
+ ('download.txt', 'download.txt', False, True),
+ ])
+ def test_conflicts(self, manager, item1, item2,
+ filename1, filename2, done, conflict):
+ item1._filename = filename1
+ item2._filename = filename2
+ item2.done = done
+ manager.downloads.append(item1)
+ manager.downloads.append(item2)
+ expected = item2 if conflict else None
+ assert item1._get_conflicting_download() is expected
+
+ def test_cancel_conflicting_downloads(self, manager, item1, item2, monkeypatch):
+ item1._filename = 'download.txt'
+ item2._filename = 'download.txt'
+ item2.done = False
+ manager.downloads.append(item1)
+ manager.downloads.append(item2)
+
+ def patched_cancel(remove_data=True):
+ assert not remove_data
+ item2.done = True
+
+ monkeypatch.setattr(item2, 'cancel', patched_cancel)
+ monkeypatch.setattr(item1, '_after_set_filename', lambda: None)
+ item1._cancel_conflicting_download()
+ assert item2.done
diff --git a/tests/unit/browser/test_hints.py b/tests/unit/browser/test_hints.py
index 56e5b980c..3b09ed0fd 100644
--- a/tests/unit/browser/test_hints.py
+++ b/tests/unit/browser/test_hints.py
@@ -30,10 +30,8 @@ import qutebrowser.browser.hints
@pytest.fixture(autouse=True)
-def setup(benchmark, win_registry, mode_manager):
- yield
- # WORKAROUND for https://github.com/ionelmc/pytest-benchmark/issues/125
- benchmark._mode = 'WORKAROUND' # pylint: disable=protected-access
+def setup(win_registry, mode_manager):
+ pass
@pytest.fixture
@@ -46,8 +44,7 @@ def tabbed_browser(tabbed_browser_stubs, web_tab):
return tb
-def test_show_benchmark(benchmark, tabbed_browser, qtbot, message_bridge,
- mode_manager):
+def test_show_benchmark(benchmark, tabbed_browser, qtbot, mode_manager):
"""Benchmark showing/drawing of hint labels."""
tab = tabbed_browser.widget.tabs[0]
@@ -66,8 +63,8 @@ def test_show_benchmark(benchmark, tabbed_browser, qtbot, message_bridge,
benchmark(bench)
-def test_match_benchmark(benchmark, tabbed_browser, qtbot, message_bridge,
- mode_manager, qapp, config_stub):
+def test_match_benchmark(benchmark, tabbed_browser, qtbot, mode_manager, qapp,
+ config_stub):
"""Benchmark matching of hint labels."""
tab = tabbed_browser.widget.tabs[0]
diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py
index c70b858f5..cac167028 100644
--- a/tests/unit/browser/test_history.py
+++ b/tests/unit/browser/test_history.py
@@ -402,30 +402,12 @@ class TestRebuild:
('example.com/2', '', 2),
]
- def test_force_rebuild(self, web_history, stubs):
- """Ensure that completion is regenerated if we force a rebuild."""
- web_history.add_url(QUrl('example.com/1'), redirect=False, atime=1)
- web_history.add_url(QUrl('example.com/2'), redirect=False, atime=2)
- web_history.completion.delete('url', 'example.com/2')
-
- hist2 = history.WebHistory(progress=stubs.FakeHistoryProgress())
- assert list(hist2.completion) == [('example.com/1', '', 1)]
- hist2.metainfo['force_rebuild'] = True
-
- hist3 = history.WebHistory(progress=stubs.FakeHistoryProgress())
- assert list(hist3.completion) == [
- ('example.com/1', '', 1),
- ('example.com/2', '', 2),
- ]
- assert not hist3.metainfo['force_rebuild']
-
def test_exclude(self, config_stub, web_history, stubs):
"""Ensure that patterns in completion.web_history.exclude are ignored.
This setting should only be used for the completion.
"""
config_stub.val.completion.web_history.exclude = ['*.example.org']
- assert web_history.metainfo['force_rebuild']
web_history.add_url(QUrl('http://example.com'),
redirect=False, atime=1)
@@ -435,16 +417,35 @@ class TestRebuild:
hist2 = history.WebHistory(progress=stubs.FakeHistoryProgress())
assert list(hist2.completion) == [('http://example.com', '', 1)]
- def test_unrelated_config_change(self, config_stub, web_history):
- config_stub.val.history_gap_interval = 1234
- assert not web_history.metainfo['force_rebuild']
+ def test_pattern_change_rebuild(self, config_stub, web_history, stubs):
+ """Ensure that completion is rebuilt when exclude patterns change."""
+ config_stub.val.completion.web_history.exclude = ['*.example.org']
+
+ web_history.add_url(QUrl('http://example.com'),
+ redirect=False, atime=1)
+ web_history.add_url(QUrl('http://example.org'),
+ redirect=False, atime=2)
+
+ hist2 = history.WebHistory(progress=stubs.FakeHistoryProgress())
+ assert list(hist2.completion) == [
+ ('http://example.com', '', 1),
+ ]
+
+ config_stub.val.completion.web_history.exclude = []
+
+ hist3 = history.WebHistory(progress=stubs.FakeHistoryProgress())
+ assert list(hist3.completion) == [
+ ('http://example.com', '', 1),
+ ('http://example.org', '', 2)
+ ]
@pytest.mark.parametrize('patch_threshold', [True, False])
def test_progress(self, web_history, config_stub, monkeypatch, stubs,
patch_threshold):
web_history.add_url(QUrl('example.com/1'), redirect=False, atime=1)
web_history.add_url(QUrl('example.com/2'), redirect=False, atime=2)
- web_history.metainfo['force_rebuild'] = True
+ # Change cached patterns to trigger a completion rebuild
+ web_history.metainfo['excluded_patterns'] = 'http://example.org'
if patch_threshold:
monkeypatch.setattr(history.WebHistory, '_PROGRESS_THRESHOLD', 1)
@@ -464,7 +465,8 @@ class TestCompletionMetaInfo:
def test_contains_keyerror(self, metainfo):
with pytest.raises(KeyError):
- 'does_not_exist' in metainfo # pylint: disable=pointless-statement
+ # pylint: disable=pointless-statement
+ 'does_not_exist' in metainfo # noqa: B015
def test_getitem_keyerror(self, metainfo):
with pytest.raises(KeyError):
@@ -475,12 +477,19 @@ class TestCompletionMetaInfo:
metainfo['does_not_exist'] = 42
def test_contains(self, metainfo):
- assert 'force_rebuild' in metainfo
+ assert 'excluded_patterns' in metainfo
+
+ def test_delete_old_key(self, monkeypatch, metainfo):
+ metainfo.insert({'key': 'force_rebuild', 'value': False})
+ info2 = history.CompletionMetaInfo()
+ monkeypatch.setitem(info2.KEYS, 'force_rebuild', False)
+ assert 'force_rebuild' not in info2
def test_modify(self, metainfo):
- assert not metainfo['force_rebuild']
- metainfo['force_rebuild'] = True
- assert metainfo['force_rebuild']
+ assert not metainfo['excluded_patterns']
+ value = 'https://example.com/'
+ metainfo['excluded_patterns'] = value
+ assert metainfo['excluded_patterns'] == value
class TestHistoryProgress:
diff --git a/tests/unit/browser/test_inspector.py b/tests/unit/browser/test_inspector.py
new file mode 100644
index 000000000..8904fad08
--- /dev/null
+++ b/tests/unit/browser/test_inspector.py
@@ -0,0 +1,154 @@
+# 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 PyQt5.QtWidgets import QWidget
+
+from qutebrowser.browser import inspector
+from qutebrowser.misc import miscwidgets
+
+
+class FakeInspector(inspector.AbstractWebInspector):
+
+ def __init__(self,
+ inspector_widget: QWidget,
+ splitter: miscwidgets.InspectorSplitter,
+ win_id: int,
+ parent: QWidget = None) -> None:
+ super().__init__(splitter, win_id, parent)
+ self._set_widget(inspector_widget)
+ self._inspected_page = None
+ self.needs_recreate = False
+
+ def inspect(self, page):
+ self._inspected_page = page
+
+ def _needs_recreate(self):
+ return self.needs_recreate
+
+
+@pytest.fixture
+def webview_widget(blue_widget):
+ return blue_widget
+
+
+@pytest.fixture
+def inspector_widget(red_widget):
+ return red_widget
+
+
+@pytest.fixture
+def splitter(qtbot, webview_widget):
+ splitter = miscwidgets.InspectorSplitter(
+ win_id=0, main_webview=webview_widget)
+ qtbot.add_widget(splitter)
+ return splitter
+
+
+@pytest.fixture
+def fake_inspector(qtbot, splitter, inspector_widget,
+ state_config, mode_manager):
+ insp = FakeInspector(inspector_widget=inspector_widget,
+ splitter=splitter,
+ win_id=0)
+ qtbot.add_widget(insp)
+ return insp
+
+
+@pytest.mark.parametrize('position, splitter_count, window_visible', [
+ (inspector.Position.window, 1, True),
+ (inspector.Position.left, 2, False),
+ (inspector.Position.top, 2, False),
+])
+def test_set_position(position, splitter_count, window_visible,
+ fake_inspector, splitter):
+ fake_inspector.set_position(position)
+ assert splitter.count() == splitter_count
+ assert (fake_inspector.isWindow() and
+ fake_inspector.isVisible()) == window_visible
+
+
+def test_toggle_window(fake_inspector):
+ fake_inspector.set_position(inspector.Position.window)
+ for visible in [True, False, True]:
+ assert (fake_inspector.isWindow() and
+ fake_inspector.isVisible()) == visible
+ fake_inspector.toggle()
+
+
+def test_toggle_docked(fake_inspector, splitter, inspector_widget):
+ fake_inspector.set_position(inspector.Position.right)
+ splitter.show()
+ for visible in [True, False, True]:
+ assert inspector_widget.isVisible() == visible
+ fake_inspector.toggle()
+
+
+def test_implicit_toggling(fake_inspector, splitter, inspector_widget):
+ fake_inspector.set_position(inspector.Position.right)
+ splitter.show()
+ assert inspector_widget.isVisible()
+ fake_inspector.set_position(None)
+ assert not inspector_widget.isVisible()
+
+
+def test_position_saving(fake_inspector, state_config):
+ assert 'position' not in state_config['inspector']
+ fake_inspector.set_position(inspector.Position.left)
+ assert state_config['inspector']['position'] == 'left'
+
+
+@pytest.mark.parametrize('config_value, expected', [
+ (None, inspector.Position.right),
+ ('top', inspector.Position.top),
+])
+def test_position_loading(config_value, expected,
+ fake_inspector, state_config):
+ if config_value is None:
+ assert 'position' not in state_config['inspector']
+ else:
+ state_config['inspector']['position'] = config_value
+
+ fake_inspector.set_position(None)
+ assert fake_inspector._position == expected
+
+
+@pytest.mark.parametrize('hidden_again', [True, False])
+@pytest.mark.parametrize('needs_recreate', [True, False])
+def test_detach_after_toggling(hidden_again, needs_recreate,
+ fake_inspector, inspector_widget, splitter,
+ qtbot):
+ """Make sure we can still detach into a window after showing inline."""
+ fake_inspector.set_position(inspector.Position.right)
+ splitter.show()
+ assert inspector_widget.isVisible()
+
+ if hidden_again:
+ fake_inspector.toggle()
+ assert not inspector_widget.isVisible()
+
+ if needs_recreate:
+ fake_inspector.needs_recreate = True
+ with qtbot.waitSignal(fake_inspector.recreate):
+ fake_inspector.set_position(inspector.Position.window)
+ else:
+ with qtbot.assertNotEmitted(fake_inspector.recreate):
+ fake_inspector.set_position(inspector.Position.window)
+ assert fake_inspector.isVisible() and fake_inspector.isWindow()
diff --git a/tests/unit/browser/test_navigate.py b/tests/unit/browser/test_navigate.py
index efabc3040..5a93a517c 100644
--- a/tests/unit/browser/test_navigate.py
+++ b/tests/unit/browser/test_navigate.py
@@ -172,10 +172,57 @@ class TestIncDec:
def test_invalid_url(self):
with pytest.raises(urlutils.InvalidUrlError):
- navigate.incdec(QUrl(""), 1, "increment")
+ navigate.incdec(QUrl(), 1, "increment")
def test_wrong_mode(self):
"""Test if incdec rejects a wrong parameter for inc_or_dec."""
valid_url = QUrl("http://example.com/0")
with pytest.raises(ValueError):
navigate.incdec(valid_url, 1, "foobar")
+
+
+class TestUp:
+
+ @pytest.mark.parametrize('url_suffix, count, expected_suffix', [
+ ('/one/two/three', 1, '/one/two'),
+ ('/one/two/three?foo=bar', 1, '/one/two'),
+ ('/one/two/three', 2, '/one'),
+ ('/one/two%2Fthree', 1, '/one'),
+ ('/one/two%2Fthree/four', 1, '/one/two%2Fthree'),
+ ])
+ def test_up(self, url_suffix, count, expected_suffix):
+ url_base = 'https://example.com'
+ url = QUrl(url_base + url_suffix)
+ assert url.isValid()
+
+ new = navigate.path_up(url, count)
+ assert new == QUrl(url_base + expected_suffix)
+
+ def test_invalid_url(self):
+ with pytest.raises(urlutils.InvalidUrlError):
+ navigate.path_up(QUrl(), count=1)
+
+
+class TestStrip:
+
+ @pytest.mark.parametrize('url_suffix', [
+ '?foo=bar',
+ '#label',
+ '?foo=bar#label',
+ ])
+ def test_strip(self, url_suffix):
+ url_base = 'https://example.com/test'
+ url = QUrl(url_base + url_suffix)
+ assert url.isValid()
+
+ stripped = navigate.strip(url, count=1)
+ assert stripped.isValid()
+ assert stripped == QUrl(url_base)
+
+ def test_count(self):
+ with pytest.raises(navigate.Error, match='Count is not supported'):
+ navigate.strip(QUrl('https://example.com/'), count=2)
+
+ def test_invalid_url(self):
+ with pytest.raises(urlutils.InvalidUrlError):
+ navigate.strip(QUrl(), count=1)
diff --git a/tests/unit/browser/test_pdfjs.py b/tests/unit/browser/test_pdfjs.py
index e95f665c7..465244819 100644
--- a/tests/unit/browser/test_pdfjs.py
+++ b/tests/unit/browser/test_pdfjs.py
@@ -24,7 +24,7 @@ import pytest
from PyQt5.QtCore import QUrl
from qutebrowser.browser import pdfjs
-from qutebrowser.utils import usertypes, utils, urlmatch
+from qutebrowser.utils import urlmatch
pytestmark = [pytest.mark.usefixtures('data_tmpdir')]
@@ -69,36 +69,6 @@ def test_generate_pdfjs_script(filename, expected):
assert 'PDFView' in actual
-@pytest.mark.parametrize('qt, backend, expected', [
- ('new', usertypes.Backend.QtWebEngine, False),
- ('new', usertypes.Backend.QtWebKit, False),
- ('old', usertypes.Backend.QtWebEngine, True),
- ('old', usertypes.Backend.QtWebKit, False),
- ('5.7', usertypes.Backend.QtWebEngine, False),
- ('5.7', usertypes.Backend.QtWebKit, False),
-])
-def test_generate_pdfjs_script_disable_object_url(monkeypatch,
- qt, backend, expected):
- if qt == 'new':
- monkeypatch.setattr(pdfjs.qtutils, 'version_check',
- lambda version, exact=False, compiled=True:
- version != '5.7.1')
- elif qt == 'old':
- monkeypatch.setattr(pdfjs.qtutils, 'version_check',
- lambda version, exact=False, compiled=True: False)
- elif qt == '5.7':
- monkeypatch.setattr(pdfjs.qtutils, 'version_check',
- lambda version, exact=False, compiled=True:
- version == '5.7.1')
- else:
- raise utils.Unreachable
-
- monkeypatch.setattr(pdfjs.objects, 'backend', backend)
-
- script = pdfjs._generate_pdfjs_script('testfile')
- assert ('PDFJS.disableCreateObjectURL' in script) == expected
-
-
class TestResources:
@pytest.fixture
@@ -157,6 +127,19 @@ class TestResources:
expected = 'OSError while reading PDF.js file: Message'
assert caplog.messages == [expected]
+ def test_broken_installation(self, data_tmpdir, tmpdir, monkeypatch,
+ read_file_mock):
+ """Make sure we don't crash with a broken local installation."""
+ monkeypatch.setattr(pdfjs, '_SYSTEM_PATHS', [])
+ monkeypatch.setattr(pdfjs.os.path, 'expanduser',
+ lambda _in: tmpdir / 'fallback')
+ read_file_mock.side_effect = FileNotFoundError
+
+ (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
+
@pytest.mark.parametrize('path, expected', [
('web/viewer.js', 'viewer.js'),
diff --git a/tests/unit/browser/test_qutescheme.py b/tests/unit/browser/test_qutescheme.py
index 8f2e06148..0d813df02 100644
--- a/tests/unit/browser/test_qutescheme.py
+++ b/tests/unit/browser/test_qutescheme.py
@@ -28,6 +28,7 @@ from PyQt5.QtCore import QUrl, QUrlQuery
import pytest
from qutebrowser.browser import qutescheme, pdfjs, downloads
+from qutebrowser.utils import utils
class TestJavascriptHandler:
@@ -51,7 +52,7 @@ class TestJavascriptHandler:
return content
raise OSError("File not found {}!".format(path))
- monkeypatch.setattr('qutebrowser.utils.utils.read_file', _read_file)
+ monkeypatch.setattr(utils, 'read_file', _read_file)
@pytest.mark.parametrize("filename, content", js_files)
def test_qutejavascript(self, filename, content):
@@ -136,7 +137,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_darkmode.py b/tests/unit/browser/webengine/test_darkmode.py
new file mode 100644
index 000000000..cd84526c3
--- /dev/null
+++ b/tests/unit/browser/webengine/test_darkmode.py
@@ -0,0 +1,263 @@
+# 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 logging
+
+import pytest
+
+from qutebrowser.config import configdata
+from qutebrowser.utils import usertypes, version
+from qutebrowser.browser.webengine import darkmode
+from qutebrowser.misc import objects
+from helpers import utils
+
+
+@pytest.fixture(autouse=True)
+def patch_backend(monkeypatch):
+ monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine)
+
+
+@pytest.mark.parametrize('qversion, enabled, expected', [
+ # Disabled or nothing set
+ ("5.14", False, []),
+ ("5.15.0", False, []),
+ ("5.15.1", False, []),
+ ("5.15.2", False, []),
+
+ # Enabled in configuration
+ ("5.14", True, []),
+ ("5.15.0", True, []),
+ ("5.15.1", True, []),
+ ("5.15.2", True, [("preferredColorScheme", "1")]),
+])
+@utils.qt514
+def test_colorscheme(config_stub, monkeypatch, qversion, enabled, expected):
+ monkeypatch.setattr(darkmode.qtutils, 'qVersion', lambda: qversion)
+ config_stub.val.colors.webpage.prefers_color_scheme_dark = enabled
+ assert list(darkmode.settings()) == expected
+
+
+@pytest.mark.parametrize('settings, expected', [
+ # Disabled
+ ({}, []),
+
+ # Enabled without customization
+ ({'enabled': True}, [('forceDarkModeEnabled', 'true')]),
+
+ # Algorithm
+ (
+ {'enabled': True, 'algorithm': 'brightness-rgb'},
+ [
+ ('forceDarkModeEnabled', 'true'),
+ ('forceDarkModeInversionAlgorithm', '2')
+ ],
+ ),
+])
+def test_basics(config_stub, monkeypatch, settings, expected):
+ for k, v in settings.items():
+ config_stub.set_obj('colors.webpage.darkmode.' + k, v)
+ monkeypatch.setattr(darkmode, '_variant',
+ lambda: darkmode.Variant.qt_515_2)
+
+ if expected:
+ expected.append(('forceDarkModeImagePolicy', '2'))
+
+ assert list(darkmode.settings()) == expected
+
+
+QT_514_SETTINGS = [
+ ('darkMode', '2'),
+ ('darkModeImagePolicy', '2'),
+ ('darkModeGrayscale', 'true'),
+]
+
+
+QT_515_0_SETTINGS = [
+ ('darkModeEnabled', 'true'),
+ ('darkModeInversionAlgorithm', '2'),
+ ('darkModeGrayscale', 'true'),
+]
+
+
+QT_515_1_SETTINGS = [
+ ('darkModeEnabled', 'true'),
+ ('darkModeInversionAlgorithm', '2'),
+ ('darkModeImagePolicy', '2'),
+ ('darkModeGrayscale', 'true'),
+]
+
+
+QT_515_2_SETTINGS = [
+ ('forceDarkModeEnabled', 'true'),
+ ('forceDarkModeInversionAlgorithm', '2'),
+ ('forceDarkModeImagePolicy', '2'),
+ ('forceDarkModeGrayscale', 'true'),
+]
+
+
+@pytest.mark.parametrize('qversion, expected', [
+ ('5.14.0', QT_514_SETTINGS),
+ ('5.14.1', QT_514_SETTINGS),
+ ('5.14.2', QT_514_SETTINGS),
+
+ ('5.15.0', QT_515_0_SETTINGS),
+ ('5.15.1', QT_515_1_SETTINGS),
+
+ ('5.15.2', QT_515_2_SETTINGS),
+])
+def test_qt_version_differences(config_stub, monkeypatch, qversion, expected):
+ monkeypatch.setattr(darkmode.qtutils, 'qVersion', lambda: qversion)
+
+ major, minor, patch = [int(part) for part in qversion.split('.')]
+ hexversion = major << 16 | minor << 8 | patch
+ if major > 5 or minor >= 13:
+ # Added in Qt 5.13
+ monkeypatch.setattr(darkmode, 'PYQT_WEBENGINE_VERSION', hexversion)
+
+ settings = {
+ 'enabled': True,
+ 'algorithm': 'brightness-rgb',
+ 'grayscale.all': True,
+ }
+ for k, v in settings.items():
+ config_stub.set_obj('colors.webpage.darkmode.' + k, v)
+
+ assert list(darkmode.settings()) == expected
+
+
+@utils.qt514
+@pytest.mark.parametrize('setting, value, exp_key, exp_val', [
+ ('contrast', -0.5,
+ 'Contrast', '-0.5'),
+ ('policy.page', 'smart',
+ 'PagePolicy', '1'),
+ ('policy.images', 'smart',
+ 'ImagePolicy', '2'),
+ ('threshold.text', 100,
+ 'TextBrightnessThreshold', '100'),
+ ('threshold.background', 100,
+ 'BackgroundBrightnessThreshold', '100'),
+ ('grayscale.all', True,
+ 'Grayscale', 'true'),
+ ('grayscale.images', 0.5,
+ 'ImageGrayscale', '0.5'),
+])
+def test_customization(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(darkmode, '_variant', lambda: darkmode.Variant.qt_515_2)
+
+ expected = []
+ expected.append(('forceDarkModeEnabled', 'true'))
+ if exp_key != 'ImagePolicy':
+ expected.append(('forceDarkModeImagePolicy', '2'))
+ expected.append(('forceDarkMode' + exp_key, exp_val))
+
+ assert list(darkmode.settings()) == expected
+
+
+@pytest.mark.parametrize('qversion, webengine_version, expected', [
+ # Without PYQT_WEBENGINE_VERSION
+ ('5.12.9', None, darkmode.Variant.qt_511_to_513),
+
+ # With PYQT_WEBENGINE_VERSION
+ (None, 0x050d00, darkmode.Variant.qt_511_to_513),
+ (None, 0x050e00, darkmode.Variant.qt_514),
+ (None, 0x050f00, darkmode.Variant.qt_515_0),
+ (None, 0x050f01, darkmode.Variant.qt_515_1),
+ (None, 0x050f02, darkmode.Variant.qt_515_2),
+ (None, 0x060000, darkmode.Variant.qt_515_2), # Qt 6
+])
+def test_variant(monkeypatch, qversion, webengine_version, expected):
+ monkeypatch.setattr(darkmode.qtutils, 'qVersion', lambda: qversion)
+ monkeypatch.setattr(darkmode, 'PYQT_WEBENGINE_VERSION', webengine_version)
+ assert darkmode._variant() == expected
+
+
+@pytest.mark.parametrize('value, is_valid, expected', [
+ ('invalid_value', False, darkmode.Variant.qt_515_0),
+ ('qt_515_2', True, darkmode.Variant.qt_515_2),
+])
+def test_variant_override(monkeypatch, caplog, value, is_valid, expected):
+ monkeypatch.setattr(darkmode.qtutils, 'qVersion', lambda: None)
+ monkeypatch.setattr(darkmode, 'PYQT_WEBENGINE_VERSION', 0x050f00)
+ monkeypatch.setenv('QUTE_DARKMODE_VARIANT', value)
+
+ with caplog.at_level(logging.WARNING):
+ assert darkmode._variant() == expected
+
+ log_msg = 'Ignoring invalid QUTE_DARKMODE_VARIANT=invalid_value'
+ assert (log_msg in caplog.messages) != is_valid
+
+
+def test_broken_smart_images_policy(config_stub, monkeypatch, caplog):
+ config_stub.val.colors.webpage.darkmode.enabled = True
+ config_stub.val.colors.webpage.darkmode.policy.images = 'smart'
+ monkeypatch.setattr(darkmode, 'PYQT_WEBENGINE_VERSION', 0x050f00)
+
+ with caplog.at_level(logging.WARNING):
+ settings = list(darkmode.settings())
+
+ assert caplog.messages[-1] == (
+ 'Ignoring colors.webpage.darkmode.policy.images = smart because of '
+ 'Qt 5.15.0 bug')
+
+ expected = [
+ [('darkModeEnabled', 'true')], # Qt 5.15
+ [('darkMode', '4')], # Qt 5.14
+ ]
+ assert settings in expected
+
+
+def test_new_chromium():
+ """Fail if we encounter an unknown Chromium version.
+
+ Dark mode in Chromium (or rather, the underlying Blink) is being changed with
+ almost every Chromium release.
+
+ 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
+ '61.0.3163.140', # Qt 5.10
+ '65.0.3325.230', # Qt 5.11
+ '69.0.3497.128', # Qt 5.12
+ '73.0.3683.105', # Qt 5.13
+ '77.0.3865.129', # Qt 5.14
+ '80.0.3987.163', # Qt 5.15.0
+ '83.0.4103.122', # Qt 5.15.2
+ ]
+
+
+def test_options(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
+
+ assert not opt.supports_pattern, name
+ assert opt.restart, name
+
+ if opt.backends:
+ # On older Qt versions, this is an empty list.
+ assert opt.backends == [usertypes.Backend.QtWebEngine], name
+
+ if opt.raw_backends is not None:
+ assert not opt.raw_backends['QtWebKit'], name
+ assert opt.raw_backends['QtWebEngine'] == 'Qt 5.14', name
diff --git a/tests/unit/browser/webengine/test_spell.py b/tests/unit/browser/webengine/test_spell.py
index ab437a37d..f9f092605 100644
--- a/tests/unit/browser/webengine/test_spell.py
+++ b/tests/unit/browser/webengine/test_spell.py
@@ -22,10 +22,9 @@ import logging
import os
import pytest
-from PyQt5.QtCore import QLibraryInfo
from qutebrowser.browser.webengine import spell
-from qutebrowser.utils import usertypes, qtutils, standarddir
+from qutebrowser.utils import usertypes
def test_version(message_mock, caplog):
@@ -39,21 +38,6 @@ def test_version(message_mock, caplog):
assert msg.text == expected
-@pytest.mark.parametrize('qt_version, old, subdir', [
- ('5.9', True, 'global_datapath'),
- ('5.9', False, 'global_datapath'),
- ('5.10', True, 'global_datapath'),
- ('5.10', False, 'user_datapath'),
-])
-def test_dictionary_dir(monkeypatch, qt_version, old, subdir):
- monkeypatch.setattr(qtutils, 'qVersion', lambda: qt_version)
- monkeypatch.setattr(QLibraryInfo, 'location', lambda _: 'global_datapath')
- monkeypatch.setattr(standarddir, 'data', lambda: 'user_datapath')
-
- expected = os.path.join(subdir, 'qtwebengine_dictionaries')
- assert spell.dictionary_dir(old=old) == expected
-
-
def test_local_filename_dictionary_does_not_exist(monkeypatch):
"""Tests retrieving local filename when the dir doesn't exits."""
monkeypatch.setattr(
@@ -104,48 +88,9 @@ class TestInit:
monkeypatch.delenv(self.ENV, raising=False)
@pytest.fixture
- def patch_new_qt(self, monkeypatch):
- monkeypatch.setattr(spell.qtutils, 'version_check',
- lambda _ver, compiled: True)
-
- @pytest.fixture
def dict_dir(self, data_tmpdir):
return data_tmpdir / 'qtwebengine_dictionaries'
- @pytest.fixture
- def old_dict_dir(self, monkeypatch, tmpdir):
- data_dir = tmpdir / 'old'
- dict_dir = data_dir / 'qtwebengine_dictionaries'
- (dict_dir / 'somedict').ensure()
- monkeypatch.setattr(spell.QLibraryInfo, 'location',
- lambda _arg: str(data_dir))
- return dict_dir
-
- def test_old_qt(self, monkeypatch):
- monkeypatch.setattr(spell.qtutils, 'version_check',
- lambda _ver, compiled: False)
- spell.init()
- assert self.ENV not in os.environ
-
- def test_new_qt(self, dict_dir, patch_new_qt):
+ def test_init(self, dict_dir):
spell.init()
assert os.environ[self.ENV] == str(dict_dir)
-
- def test_moving(self, old_dict_dir, dict_dir, patch_new_qt):
- spell.init()
- assert (dict_dir / 'somedict').exists()
-
- def test_moving_oserror(self, mocker, caplog,
- old_dict_dir, dict_dir, patch_new_qt):
- mocker.patch('shutil.copytree', side_effect=OSError)
-
- with caplog.at_level(logging.ERROR):
- spell.init()
-
- assert caplog.messages[0] == 'Failed to copy old dictionaries'
-
- def test_moving_existing_destdir(self, old_dict_dir, dict_dir,
- patch_new_qt):
- dict_dir.ensure(dir=True)
- spell.init()
- assert not (dict_dir / 'somedict').exists()
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..1ac9c5d1a
--- /dev/null
+++ b/tests/unit/browser/webengine/test_webengine_cookies.py
@@ -0,0 +1,114 @@
+# 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():
+ request = QWebEngineCookieStore.FilterRequest()
+ request.firstPartyUrl = QUrl('https://example.com')
+ return request
+
+
+@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
+
+
+@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)
+
+ def test_fake_profile(self, stubs):
+ store = stubs.FakeCookieStore()
+ profile = stubs.FakeWebEngineProfile(cookie_store=store)
+
+ cookies.install_filter(profile)
+
+ assert store.cookie_filter is cookies._accept_cookie
diff --git a/tests/unit/browser/webengine/test_webenginedownloads.py b/tests/unit/browser/webengine/test_webenginedownloads.py
index 3a830bd58..98918571b 100644
--- a/tests/unit/browser/webengine/test_webenginedownloads.py
+++ b/tests/unit/browser/webengine/test_webenginedownloads.py
@@ -18,13 +18,14 @@
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
import os.path
+import base64
import pytest
-
pytest.importorskip('PyQt5.QtWebEngineWidgets')
+from PyQt5.QtWebEngineWidgets import QWebEngineProfile
+from qutebrowser.utils import urlutils
from qutebrowser.browser.webengine import webenginedownloads
-from helpers import utils
@pytest.mark.parametrize('path, expected', [
@@ -34,10 +35,52 @@ from helpers import utils
('foo - 1970-01-01T00:00:00.000Z', 'foo'),
('foo(a)', 'foo(a)'),
('foo1', 'foo1'),
- pytest.param('foo%20bar', 'foo bar', marks=utils.qt58),
- pytest.param('foo%2Fbar', 'bar', marks=utils.qt58),
- pytest.param('foo%20bar', 'foo%20bar', marks=utils.qt59),
- pytest.param('foo%2Fbar', 'foo%2Fbar', marks=utils.qt59),
+ ('foo%20bar', 'foo%20bar'),
+ ('foo%2Fbar', 'foo%2Fbar'),
])
def test_get_suggested_filename(path, expected):
assert webenginedownloads._get_suggested_filename(path) == expected
+
+
+@pytest.mark.parametrize('with_slash', [True, False])
+def test_data_url_workaround_needed(qapp, qtbot, webengineview, with_slash):
+ """With data URLs, we get rather weird base64 filenames back from QtWebEngine.
+
+ This test verifies that our workaround for this is still needed, i.e. if we get
+ those base64-filenames rather than a "download.pdf" like with Chromium.
+ """
+ # https://stackoverflow.com/a/17280876/2085149
+ pdf_source = [
+ '%PDF-1.0',
+ '1 0 obj<</Pages 2 0 R>>endobj',
+ '2 0 obj<</Kids[3 0 R]/Count 1>>endobj',
+ '3 0 obj<</MediaBox[0 0 3 3]>>endobj',
+ 'trailer<</Root 1 0 R>>',
+ ]
+
+ if with_slash:
+ pdf_source.insert(1, '% ?') # this results in a slash in base64
+
+ pdf_data = '\n'.join(pdf_source).encode('ascii')
+ base64_data = base64.b64encode(pdf_data).decode('ascii')
+
+ if with_slash:
+ assert '/' in base64_data
+ expected = base64_data.split('/')[1]
+ else:
+ assert '/' not in base64_data
+ expected = 'pdf' # from the mimetype
+
+ def check_item(item):
+ assert item.mimeType() == 'application/pdf'
+ assert item.url().scheme() == 'data'
+ assert os.path.basename(item.path()) == expected
+ return True
+
+ profile = QWebEngineProfile.defaultProfile()
+ profile.setParent(qapp)
+
+ url = urlutils.data_url('application/pdf', pdf_data)
+
+ with qtbot.waitSignal(profile.downloadRequested, check_params_cb=check_item):
+ webengineview.load(url)
diff --git a/tests/unit/browser/webengine/test_webengineinterceptor.py b/tests/unit/browser/webengine/test_webengineinterceptor.py
index 2c352a6a8..7a4fd918a 100644
--- a/tests/unit/browser/webengine/test_webengineinterceptor.py
+++ b/tests/unit/browser/webengine/test_webengineinterceptor.py
@@ -27,8 +27,6 @@ pytest.importorskip('PyQt5.QtWebEngineWidgets')
from PyQt5.QtWebEngineCore import QWebEngineUrlRequestInfo
from qutebrowser.browser.webengine import interceptor
-from qutebrowser.extensions import interceptors
-from qutebrowser.utils import qtutils
def test_no_missing_resource_types():
@@ -42,8 +40,4 @@ def test_no_missing_resource_types():
def test_resource_type_values():
request_interceptor = interceptor.RequestInterceptor()
for qt_value, qb_item in request_interceptor._resource_types.items():
- if (qtutils.version_check('5.7.1', exact=True, compiled=False) and
- qb_item == interceptors.ResourceType.unknown):
- # Qt 5.7 has ResourceTypeUnknown = 18 instead of 255
- continue
assert qt_value == qb_item.value
diff --git a/tests/unit/browser/webengine/test_webenginesettings.py b/tests/unit/browser/webengine/test_webenginesettings.py
index ec61df7c6..92ac8aca6 100644
--- a/tests/unit/browser/webengine/test_webenginesettings.py
+++ b/tests/unit/browser/webengine/test_webenginesettings.py
@@ -17,39 +17,55 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-import types
import logging
import pytest
-pytest.importorskip('PyQt5.QtWebEngineWidgets')
+QtWebEngineWidgets = pytest.importorskip('PyQt5.QtWebEngineWidgets')
from qutebrowser.browser.webengine import webenginesettings
-from qutebrowser.utils import usertypes, qtutils
-from qutebrowser.misc import objects
+from qutebrowser.utils import usertypes
-@pytest.fixture(autouse=True)
-def init(qapp, config_stub, cache_tmpdir, data_tmpdir, monkeypatch):
- monkeypatch.setattr(webenginesettings.webenginequtescheme, 'init',
- lambda: None)
- monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine)
- init_args = types.SimpleNamespace(enable_webengine_inspector=False)
- webenginesettings.init(init_args)
- config_stub.changed.disconnect(webenginesettings._update_settings)
+@pytest.fixture
+def global_settings(monkeypatch, default_profile):
+ wrapper = webenginesettings._SettingsWrapper()
+ settings = webenginesettings.WebEngineSettings(wrapper)
+ settings.init_settings()
+ monkeypatch.setattr(webenginesettings, '_global_settings', settings)
-def test_big_cache_size(config_stub):
+@pytest.fixture
+def default_profile(monkeypatch):
+ """A profile to use which is set as default_profile.
+
+ Note we use a "private" profile here to avoid actually storing data during tests.
+ """
+ profile = QtWebEngineWidgets.QWebEngineProfile()
+ profile.setter = webenginesettings.ProfileSetter(profile)
+ monkeypatch.setattr(profile, 'isOffTheRecord', lambda: False)
+ monkeypatch.setattr(webenginesettings, 'default_profile', profile)
+ return profile
+
+
+@pytest.fixture
+def private_profile(monkeypatch):
+ """A profile to use which is set as private_profile."""
+ profile = QtWebEngineWidgets.QWebEngineProfile()
+ profile.setter = webenginesettings.ProfileSetter(profile)
+ monkeypatch.setattr(webenginesettings, 'private_profile', profile)
+ return profile
+
+
+def test_big_cache_size(config_stub, default_profile):
"""Make sure a too big cache size is handled correctly."""
config_stub.val.content.cache.size = 2 ** 63 - 1
- profile = webenginesettings.default_profile
- profile.setter.set_http_cache_size()
- assert profile.httpCacheMaximumSize() == 2 ** 31 - 1
+ default_profile.setter.set_http_cache_size()
+ assert default_profile.httpCacheMaximumSize() == 2 ** 31 - 1
-@pytest.mark.skipif(
- not qtutils.version_check('5.8'), reason="Needs Qt 5.8 or newer")
-def test_non_existing_dict(config_stub, monkeypatch, message_mock, caplog):
+def test_non_existing_dict(config_stub, monkeypatch, message_mock, caplog,
+ global_settings):
monkeypatch.setattr(webenginesettings.spell, 'local_filename',
lambda _code: None)
config_stub.val.spellcheck.languages = ['af-ZA']
@@ -63,33 +79,25 @@ def test_non_existing_dict(config_stub, monkeypatch, message_mock, caplog):
assert msg.text == expected
-@pytest.mark.skipif(
- not qtutils.version_check('5.8'), reason="Needs Qt 5.8 or newer")
-def test_existing_dict(config_stub, monkeypatch):
+def test_existing_dict(config_stub, monkeypatch, global_settings,
+ default_profile, private_profile):
monkeypatch.setattr(webenginesettings.spell, 'local_filename',
lambda _code: 'en-US-8-0')
config_stub.val.spellcheck.languages = ['en-US']
webenginesettings._update_settings('spellcheck.languages')
- for profile in [webenginesettings.default_profile,
- webenginesettings.private_profile]:
+ for profile in [default_profile, private_profile]:
assert profile.isSpellCheckEnabled()
assert profile.spellCheckLanguages() == ['en-US-8-0']
-@pytest.mark.skipif(
- not qtutils.version_check('5.8'), reason="Needs Qt 5.8 or newer")
-def test_spell_check_disabled(config_stub, monkeypatch):
+def test_spell_check_disabled(config_stub, monkeypatch, global_settings,
+ default_profile, private_profile):
config_stub.val.spellcheck.languages = []
webenginesettings._update_settings('spellcheck.languages')
- for profile in [webenginesettings.default_profile,
- webenginesettings.private_profile]:
+ for profile in [default_profile, private_profile]:
assert not profile.isSpellCheckEnabled()
-def test_default_user_agent_saved():
- assert webenginesettings.parsed_user_agent is not None
-
-
def test_parsed_user_agent(qapp):
webenginesettings.init_user_agent()
parsed = webenginesettings.parsed_user_agent
diff --git a/tests/unit/browser/webengine/test_webenginetab.py b/tests/unit/browser/webengine/test_webenginetab.py
index 4684921c6..02a034331 100644
--- a/tests/unit/browser/webengine/test_webenginetab.py
+++ b/tests/unit/browser/webengine/test_webenginetab.py
@@ -101,9 +101,6 @@ class TestWebengineScripts:
"""Make sure document-end is forced when needed."""
monkeypatch.setattr(greasemonkey.objects, 'backend',
usertypes.Backend.QtWebEngine)
- monkeypatch.setattr(greasemonkey.qtutils, 'version_check',
- lambda version, exact=False, compiled=True:
- True)
scripts = [
greasemonkey.GreasemonkeyScript([
diff --git a/tests/unit/browser/webkit/network/test_pac.py b/tests/unit/browser/webkit/network/test_pac.py
index f33642ae3..1ad10cc3c 100644
--- a/tests/unit/browser/webkit/network/test_pac.py
+++ b/tests/unit/browser/webkit/network/test_pac.py
@@ -87,7 +87,7 @@ def _pac_noexcept_test(call):
_pac_common_test(test_str_f.format(call))
-# pylint: disable=line-too-long, invalid-name
+# pylint: disable=invalid-name
@pytest.mark.parametrize("domain, expected", [
diff --git a/tests/unit/browser/webkit/test_cache.py b/tests/unit/browser/webkit/test_cache.py
index f0aaf226e..9cd8eab9b 100644
--- a/tests/unit/browser/webkit/test_cache.py
+++ b/tests/unit/browser/webkit/test_cache.py
@@ -23,13 +23,6 @@ from PyQt5.QtCore import QUrl, QDateTime
from PyQt5.QtNetwork import QNetworkDiskCache, QNetworkCacheMetaData
from qutebrowser.browser.webkit import cache
-from qutebrowser.utils import qtutils
-
-
-pytestmark = pytest.mark.skipif(
- qtutils.version_check('5.7.1', compiled=False) and
- not qtutils.version_check('5.9', compiled=False),
- reason="QNetworkDiskCache is broken on Qt 5.7.1 and 5.8")
@pytest.fixture
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/browser/webkit/test_mhtml.py b/tests/unit/browser/webkit/test_mhtml.py
index 8d4289f4b..a30bfe786 100644
--- a/tests/unit/browser/webkit/test_mhtml.py
+++ b/tests/unit/browser/webkit/test_mhtml.py
@@ -21,24 +21,16 @@
import io
import textwrap
import re
+import uuid
import pytest
mhtml = pytest.importorskip('qutebrowser.browser.webkit.mhtml')
-try:
- import cssutils
-except (ImportError, re.error):
- # Catching re.error because cssutils in earlier releases (<= 1.0) is
- # broken on Python 3.5
- # See https://bitbucket.org/cthedot/cssutils/issues/52
- cssutils = None
-
-
@pytest.fixture(autouse=True)
def patch_uuid(monkeypatch):
- monkeypatch.setattr("uuid.uuid4", lambda: "UUID")
+ monkeypatch.setattr(uuid, "uuid4", lambda: "UUID")
class Checker:
@@ -257,34 +249,25 @@ def test_empty_content_type(checker):
""")
-@pytest.mark.parametrize('has_cssutils', [
- pytest.param(True, marks=pytest.mark.skipif(
- cssutils is None, reason="requires cssutils"), id='with_cssutils'),
- pytest.param(False, id='no_cssutils'),
-])
-@pytest.mark.parametrize('inline, style, expected_urls', [
- pytest.param(False, "@import 'default.css'", ['default.css'],
+@pytest.mark.parametrize('style, expected_urls', [
+ pytest.param("@import 'default.css'", ['default.css'],
id='import with apostrophe'),
- pytest.param(False, '@import "default.css"', ['default.css'],
+ pytest.param('@import "default.css"', ['default.css'],
id='import with quote'),
- pytest.param(False, "@import \t 'tabbed.css'", ['tabbed.css'],
+ pytest.param("@import \t 'tabbed.css'", ['tabbed.css'],
id='import with tab'),
- pytest.param(False, "@import url('default.css')", ['default.css'],
+ pytest.param("@import url('default.css')", ['default.css'],
id='import with url()'),
- pytest.param(False, """body {
+ pytest.param("""body {
background: url("/bg-img.png")
}""", ['/bg-img.png'], id='background with body'),
- pytest.param(True, 'background: url(folder/file.png) no-repeat',
+ pytest.param('background: url(folder/file.png) no-repeat',
['folder/file.png'], id='background'),
- pytest.param(True, 'content: url()', [], id='content'),
+ pytest.param('content: url()', [], id='content'),
])
-def test_css_url_scanner(monkeypatch, has_cssutils, inline, style,
- expected_urls):
- if not has_cssutils:
- monkeypatch.setattr(mhtml, '_get_css_imports_cssutils',
- lambda data, inline=False: None)
+def test_css_url_scanner(monkeypatch, style, expected_urls):
expected_urls.sort()
- urls = mhtml._get_css_imports(style, inline=inline)
+ urls = mhtml._get_css_imports(style)
urls.sort()
assert urls == expected_urls
diff --git a/tests/unit/browser/webkit/test_tabhistory.py b/tests/unit/browser/webkit/test_tabhistory.py
index 9325cb7d0..48e0c98fc 100644
--- a/tests/unit/browser/webkit/test_tabhistory.py
+++ b/tests/unit/browser/webkit/test_tabhistory.py
@@ -33,7 +33,8 @@ pytestmark = pytest.mark.qt_log_ignore('QIODevice::read.*: device not open')
ITEMS = [
Item(QUrl('https://www.heise.de/'), 'heise'),
- Item(QUrl('http://example.com/%E2%80%A6'), 'percent', active=True),
+ Item(QUrl('about:blank'), 'blank', active=True),
+ Item(QUrl('http://example.com/%E2%80%A6'), 'percent'),
Item(QUrl('http://example.com/?foo=bar'), 'arg',
original_url=QUrl('http://original.url.example.com/'),
user_data={'foo': 23, 'bar': 42}),
diff --git a/tests/unit/browser/webkit/test_webkitelem.py b/tests/unit/browser/webkit/test_webkitelem.py
index f728a5dcc..37262a7b3 100644
--- a/tests/unit/browser/webkit/test_webkitelem.py
+++ b/tests/unit/browser/webkit/test_webkitelem.py
@@ -263,6 +263,8 @@ class TestWebKitElement:
pytest.param(lambda e: e.remove_blank_target(),
id='remove_blank_target'),
pytest.param(lambda e: e.outer_xml(), id='outer_xml'),
+ pytest.param(lambda e: e.is_content_editable_prop(),
+ id='is_content_editable_prop'),
pytest.param(lambda e: e.tag_name(), id='tag_name'),
pytest.param(lambda e: e.rect_on_view(), id='rect_on_view'),
pytest.param(lambda e: e._is_visible(None), id='is_visible'),
@@ -741,8 +743,8 @@ class TestGetChildFrames:
def test_one_level(self, stubs):
r"""Test get_child_frames with one level of children.
- o parent
- / \
+ o parent
+ / \ ------
child1 o o child2
"""
child1 = stubs.FakeChildrenFrame()
@@ -761,9 +763,9 @@ class TestGetChildFrames:
r"""Test get_child_frames with multiple levels of children.
o root
- / \
+ / \ ------
o o first
- /\ /\
+ /\ /\ ------
o o o o second
"""
second = [stubs.FakeChildrenFrame() for _ in range(4)]
@@ -816,8 +818,20 @@ class TestIsEditable:
])
def test_is_editable(self, tagname, attributes, editable):
elem = get_webelem(tagname=tagname, attributes=attributes)
+ elem._elem.evaluateJavaScript.return_value = False
assert elem.is_editable() == editable
+ @pytest.mark.parametrize('strict, attributes, expected', [
+ (False, {}, True),
+ (False, {'disabled': 'true'}, False),
+ (False, {'readonly': 'true'}, False),
+ (True, {}, False),
+ ])
+ def test_is_editable_content_editable(self, strict, attributes, expected):
+ elem = get_webelem(tagname='foobar', attributes=attributes)
+ elem._elem.evaluateJavaScript.return_value = True
+ assert elem.is_editable(strict=strict) == expected
+
@pytest.mark.parametrize('classes, editable', [
(None, False),
('foo-kix-bar', False),
@@ -827,6 +841,7 @@ class TestIsEditable:
])
def test_is_editable_div(self, classes, editable):
elem = get_webelem(tagname='div', classes=classes)
+ elem._elem.evaluateJavaScript.return_value = False
assert elem.is_editable() == editable
@pytest.mark.parametrize('setting, tagname, attributes, editable', [
@@ -845,6 +860,7 @@ class TestIsEditable:
setting, tagname, attributes, editable):
config_stub.val.input.insert_mode.plugins = setting
elem = get_webelem(tagname=tagname, attributes=attributes)
+ elem._elem.evaluateJavaScript.return_value = False
assert elem.is_editable() == editable
diff --git a/tests/unit/commands/test_argparser.py b/tests/unit/commands/test_argparser.py
index ccf81edd1..69119c4cf 100644
--- a/tests/unit/commands/test_argparser.py
+++ b/tests/unit/commands/test_argparser.py
@@ -28,7 +28,10 @@ from PyQt5.QtCore import QUrl
from qutebrowser.commands import argparser, cmdexc
-Enum = enum.Enum('Enum', ['foo', 'foo_bar'])
+class Enum(enum.Enum):
+
+ foo = enum.auto()
+ foo_bar = enum.auto()
class TestArgumentParser:
diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py
index ee9ec24d9..e667c40cb 100644
--- a/tests/unit/completion/test_completer.py
+++ b/tests/unit/completion/test_completer.py
@@ -30,6 +30,13 @@ from qutebrowser.commands import command
from qutebrowser.api import cmdutils
+@pytest.fixture(autouse=True)
+def setup_cur_tab(tabbed_browser_stubs, fake_web_tab):
+ # Make sure completions can access the current tab
+ tabbed_browser_stubs[0].widget.tabs = [fake_web_tab()]
+ tabbed_browser_stubs[0].widget.current_index = 0
+
+
class FakeCompletionModel(QStandardItemModel):
"""Stub for a completion model."""
diff --git a/tests/unit/completion/test_completiondelegate.py b/tests/unit/completion/test_completiondelegate.py
index ac07e80dc..4732837a1 100644
--- a/tests/unit/completion/test_completiondelegate.py
+++ b/tests/unit/completion/test_completiondelegate.py
@@ -18,6 +18,8 @@
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
from unittest import mock
+import hypothesis
+import hypothesis.strategies
import pytest
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QTextDocument, QColor
@@ -34,7 +36,7 @@ from qutebrowser.completion import completiondelegate
('foo', 'barfoobaz', [(3, 3)]),
('foo', 'barfoobazfoo', [(3, 3), (9, 3)]),
('foo', 'foofoo', [(0, 3), (3, 3)]),
- ('a|b', 'cadb', [(1, 1), (3, 1)]),
+ ('a b', 'cadb', [(1, 1), (3, 1)]),
('foo', '<foo>', [(1, 3)]),
('<a>', "<a>bc", [(0, 3)]),
@@ -42,6 +44,10 @@ from qutebrowser.completion import completiondelegate
('foo', "'foo'", [(1, 3)]),
('x', "'x'", [(1, 1)]),
('lt', "<lt", [(1, 2)]),
+
+ # See https://github.com/qutebrowser/qutebrowser/pull/5111
+ ('bar', '\U0001d65b\U0001d664\U0001d664bar', [(6, 3)]),
+ ('an anomaly', 'an anomaly', [(0, 2), (3, 7)]),
])
def test_highlight(pat, txt, segments):
doc = QTextDocument(txt)
@@ -53,10 +59,29 @@ def test_highlight(pat, txt, segments):
])
+def test_benchmark_highlight(benchmark):
+ txt = 'boofoobar'
+ pat = 'foo bar'
+ doc = QTextDocument(txt)
+
+ def bench():
+ highlighter = completiondelegate._Highlighter(doc, pat, Qt.red)
+ highlighter.highlightBlock(txt)
+
+ benchmark(bench)
+
+
+@hypothesis.given(text=hypothesis.strategies.text())
+def test_pattern_hypothesis(text):
+ """Make sure we can't produce invalid patterns."""
+ doc = QTextDocument()
+ completiondelegate._Highlighter(doc, text, Qt.red)
+
+
def test_highlighted(qtbot):
"""Make sure highlighting works.
- Note that with Qt 5.11.3 and > 5.12.1 we need to call setPlainText *after*
+ Note that with Qt > 5.12.1 we need to call setPlainText *after*
creating the highlighter for highlighting to work. Ideally, we'd test
whether CompletionItemDelegate._get_textdoc() works properly, but testing
that is kind of hard, so we just test it in isolation here.
diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py
index bdeda54b7..c0ef4b47f 100644
--- a/tests/unit/completion/test_completionwidget.py
+++ b/tests/unit/completion/test_completionwidget.py
@@ -22,6 +22,7 @@
from unittest import mock
import pytest
+from PyQt5.QtCore import QRect
from qutebrowser.completion import completionwidget
from qutebrowser.completion.models import completionmodel, listcategory
@@ -42,9 +43,13 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry,
return view
-def test_set_model(completionview):
+@pytest.fixture()
+def model():
+ return completionmodel.CompletionModel()
+
+
+def test_set_model(completionview, model):
"""Ensure set_model actually sets the model and expands all categories."""
- model = completionmodel.CompletionModel()
for _i in range(3):
model.add_category(listcategory.ListCategory('', [('foo',)]))
completionview.set_model(model)
@@ -53,8 +58,7 @@ def test_set_model(completionview):
assert completionview.isExpanded(model.index(i, 0))
-def test_set_pattern(completionview):
- model = completionmodel.CompletionModel()
+def test_set_pattern(completionview, model):
model.set_pattern = mock.Mock(spec=[])
completionview.set_model(model)
completionview.set_pattern('foo')
@@ -116,7 +120,7 @@ def test_maybe_update_geometry(completionview, config_stub, qtbot):
('next-category', [[]], [None, None]),
('prev-category', [[]], [None, None]),
])
-def test_completion_item_focus(which, tree, expected, completionview, qtbot):
+def test_completion_item_focus(which, tree, expected, completionview, model, qtbot):
"""Test that on_next_prev_item moves the selection properly.
Args:
@@ -127,7 +131,6 @@ def test_completion_item_focus(which, tree, expected, completionview, qtbot):
successive movement. None implies no signal should be
emitted.
"""
- model = completionmodel.CompletionModel()
for catdata in tree:
cat = listcategory.ListCategory('', ((x,) for x in catdata))
model.add_category(cat)
@@ -142,23 +145,24 @@ def test_completion_item_focus(which, tree, expected, completionview, qtbot):
assert sig.args == [entry]
-@pytest.mark.parametrize('which', ['next', 'prev', 'next-category',
- 'prev-category'])
-def test_completion_item_focus_no_model(which, completionview, qtbot):
+@pytest.mark.parametrize('which', ['next', 'prev',
+ 'next-category', 'prev-category',
+ 'next-page', 'prev-page'])
+def test_completion_item_focus_no_model(which, completionview, model, qtbot):
"""Test that selectionChanged is not fired when the model is None.
Validates #1812: help completion repeatedly completes
"""
with qtbot.assertNotEmitted(completionview.selection_changed):
completionview.completion_item_focus(which)
- model = completionmodel.CompletionModel()
completionview.set_model(model)
completionview.set_model(None)
with qtbot.assertNotEmitted(completionview.selection_changed):
completionview.completion_item_focus(which)
-def test_completion_item_focus_fetch(completionview, qtbot):
+@pytest.mark.skip("Seems to disagree with reality, see #5897")
+def test_completion_item_focus_fetch(completionview, model, qtbot):
"""Test that on_next_prev_item moves the selection properly.
Args:
@@ -169,7 +173,6 @@ def test_completion_item_focus_fetch(completionview, qtbot):
successive movement. None implies no signal should be
emitted.
"""
- model = completionmodel.CompletionModel()
cat = mock.Mock(spec=[
'layoutChanged', 'layoutAboutToBeChanged', 'canFetchMore',
'fetchMore', 'rowCount', 'index', 'data'])
@@ -190,10 +193,95 @@ def test_completion_item_focus_fetch(completionview, qtbot):
assert cat.fetchMore.called
+class TestCompletionItemFocusPage:
+
+ """Test :completion-item-focus with prev-page/next-page."""
+
+ @pytest.fixture(autouse=True)
+ def patch_heights(self, monkeypatch, completionview):
+ """Patch the item/widget heights so that 10 items are always visible."""
+ monkeypatch.setattr(completionview, 'visualRect',
+ lambda _idx: QRect(0, 0, 100, 20))
+ monkeypatch.setattr(completionview, 'height', lambda: 200)
+
+ @pytest.mark.parametrize('which, expected', [
+ ('prev-page', 'Last Item'),
+ ('next-page', 'First Item'),
+ ])
+ def test_no_selection(self, qtbot, completionview, model, which, expected):
+ """With no selection, the first/last item should be selected."""
+ items = [("First Item",), ("Middle Item",), ("Last Item",)]
+ cat = listcategory.ListCategory('Test', items)
+ model.add_category(cat)
+ completionview.set_model(model)
+ with qtbot.waitSignal(completionview.selection_changed) as blocker:
+ completionview.completion_item_focus(which)
+ assert blocker.args == [expected]
+
+ @pytest.mark.parametrize('steps', [
+ # Select first item and go down
+ [('next', 'Item 1'), ('next-page', 'Item 10')],
+ # Go down twice
+ [('next', 'Item 1'), ('next-page', 'Item 10'), ('next-page', 'Item 19')],
+ # Last item via Page Down
+ [('next', 'Item 1'),
+ ('next-page', 'Item 10'),
+ ('next-page', 'Item 19'),
+ ('next-page', 'Item 24')],
+ # Wrapping around via Page Down
+ [('next', 'Item 1'),
+ ('next-page', 'Item 10'),
+ ('next-page', 'Item 19'),
+ ('next-page', 'Item 24'),
+ ('next-page', 'Item 1')],
+
+ # Select last item and go up
+ [('prev', 'Item 24'), ('prev-page', 'Item 15')],
+ # Go up twice
+ [('prev', 'Item 24'), ('prev-page', 'Item 15'), ('prev-page', 'Item 6')],
+ # Last item via Page Up
+ [('prev', 'Item 24'),
+ ('prev-page', 'Item 15'),
+ ('prev-page', 'Item 6'),
+ ('prev-page', 'Item 1')],
+ # Wrapping around via Page Up
+ [('prev', 'Item 24'),
+ ('prev-page', 'Item 15'),
+ ('prev-page', 'Item 6'),
+ ('prev-page', 'Item 1'),
+ ('prev-page', 'Item 24')],
+ ])
+ def test_steps(self, completionview, qtbot, model, steps):
+ items = [("Item {}".format(i),) for i in range(1, 25)]
+ cat = listcategory.ListCategory('Test', items)
+ model.add_category(cat)
+ completionview.set_model(model)
+
+ for move, item in steps:
+ print('{:9} -> expecting {}'.format(move, item))
+ with qtbot.waitSignal(completionview.selection_changed) as blocker:
+ completionview.completion_item_focus(move)
+ assert blocker.args == [item]
+
+ def test_category_headers(self, completionview, qtbot, model):
+ for name, items in [
+ ("First", [("Item {}".format(i),) for i in range(1, 9)]),
+ ("Second", []),
+ ("Third", [("Target item",)])]:
+ cat = listcategory.ListCategory(name, items)
+ model.add_category(cat)
+ completionview.set_model(model)
+
+ for move, item in [('next', 'Item 1'), ('next-page', 'Target item')]:
+ with qtbot.waitSignal(completionview.selection_changed) as blocker:
+ completionview.completion_item_focus(move)
+ assert blocker.args == [item]
+
+
@pytest.mark.parametrize('show', ['always', 'auto', 'never'])
@pytest.mark.parametrize('rows', [[], ['Aa'], ['Aa', 'Bb']])
@pytest.mark.parametrize('quick_complete', [True, False])
-def test_completion_show(show, rows, quick_complete, completionview,
+def test_completion_show(show, rows, quick_complete, completionview, model,
config_stub):
"""Test that the completion widget is shown at appropriate times.
@@ -205,7 +293,6 @@ def test_completion_show(show, rows, quick_complete, completionview,
config_stub.val.completion.show = show
config_stub.val.completion.quick = quick_complete
- model = completionmodel.CompletionModel()
for name in rows:
cat = listcategory.ListCategory('', [(name,)])
model.add_category(cat)
@@ -222,10 +309,9 @@ def test_completion_show(show, rows, quick_complete, completionview,
assert not completionview.isVisible()
-def test_completion_item_del(completionview):
+def test_completion_item_del(completionview, model):
"""Test that completion_item_del invokes delete_cur_item in the model."""
func = mock.Mock(spec=[])
- model = completionmodel.CompletionModel()
cat = listcategory.ListCategory('', [('foo', 'bar')], delete_func=func)
model.add_category(cat)
completionview.set_model(model)
@@ -234,10 +320,9 @@ def test_completion_item_del(completionview):
func.assert_called_once_with(['foo', 'bar'])
-def test_completion_item_del_no_selection(completionview):
+def test_completion_item_del_no_selection(completionview, model):
"""Test that completion_item_del with an invalid index."""
func = mock.Mock(spec=[])
- model = completionmodel.CompletionModel()
cat = listcategory.ListCategory('', [('foo',)], delete_func=func)
model.add_category(cat)
completionview.set_model(model)
@@ -247,12 +332,11 @@ def test_completion_item_del_no_selection(completionview):
@pytest.mark.parametrize('sel', [True, False])
-def test_completion_item_yank(completionview, mocker, sel):
+def test_completion_item_yank(completionview, model, mocker, sel):
"""Test that completion_item_yank invokes delete_cur_item in the model."""
m = mocker.patch(
'qutebrowser.completion.completionwidget.utils',
autospec=True)
- model = completionmodel.CompletionModel()
cat = listcategory.ListCategory('', [('foo', 'bar')])
model.add_category(cat)
@@ -264,13 +348,12 @@ def test_completion_item_yank(completionview, mocker, sel):
@pytest.mark.parametrize('sel', [True, False])
-def test_completion_item_yank_selected(completionview, status_command_stub,
- mocker, sel):
+def test_completion_item_yank_selected(completionview, model,
+ status_command_stub, mocker, sel):
"""Test that completion_item_yank yanks selected text."""
m = mocker.patch(
'qutebrowser.completion.completionwidget.utils',
autospec=True)
- model = completionmodel.CompletionModel()
cat = listcategory.ListCategory('', [('foo', 'bar')])
model.add_category(cat)
diff --git a/tests/unit/completion/test_listcategory.py b/tests/unit/completion/test_listcategory.py
index 1a54ac814..d2eeaeb24 100644
--- a/tests/unit/completion/test_listcategory.py
+++ b/tests/unit/completion/test_listcategory.py
@@ -19,6 +19,8 @@
"""Tests for CompletionFilterModel."""
+import logging
+
import pytest
from qutebrowser.completion.models import listcategory
@@ -57,3 +59,12 @@ def test_set_pattern(pattern, before, after, after_nosort, model_validator):
model_validator.set_model(cat)
cat.set_pattern(pattern)
model_validator.validate(after_nosort)
+
+
+def test_long_pattern(caplog, model_validator):
+ """Validate that a huge pattern doesn't crash (#5973)."""
+ with caplog.at_level(logging.WARNING):
+ cat = listcategory.ListCategory('Foo', [('a' * 5000, '')])
+ model_validator.set_model(cat)
+ cat.set_pattern('a' * 50000)
+ model_validator.validate([('a' * 5000, '')])
diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py
index f2f3bb47b..082cf714a 100644
--- a/tests/unit/completion/test_models.py
+++ b/tests/unit/completion/test_models.py
@@ -22,20 +22,32 @@
import collections
import random
import string
+import time
from datetime import datetime
+from unittest import mock
+import hypothesis
+import hypothesis.strategies
import pytest
-from PyQt5.QtCore import QUrl
+from PyQt5.QtCore import QUrl, QDateTime
+try:
+ from PyQt5.QtWebEngineWidgets import (
+ QWebEngineHistory, QWebEngineHistoryItem
+ )
+except ImportError:
+ pass
from qutebrowser.misc import objects
from qutebrowser.completion import completer
-from qutebrowser.completion.models import miscmodels, urlmodel, configmodel
+from qutebrowser.completion.models import (
+ miscmodels, urlmodel, configmodel, listcategory)
from qutebrowser.config import configdata, configtypes
from qutebrowser.utils import usertypes
+from qutebrowser.mainwindow import tabbedbrowser
def _check_completions(model, expected):
- """Check that a model contains the expected items in any order.
+ """Check that a model contains the expected items in order.
Args:
expected: A dict of form
@@ -59,7 +71,6 @@ def _check_completions(model, expected):
actual[catname].append((name, desc, misc))
assert actual == expected
# sanity-check the column_widths
- assert len(model.column_widths) == 3
assert sum(model.column_widths) == 100
@@ -116,7 +127,7 @@ def configdata_stub(config_stub, monkeypatch, configdata_init):
no_autoconfig=True)),
('bindings.commands', configdata.Option(
name='bindings.commands',
- description='Default keybindings',
+ description='Custom keybindings',
typ=configtypes.Dict(
keytype=configtypes.String(),
valtype=configtypes.Dict(
@@ -210,7 +221,8 @@ def web_history_populated(web_history):
def info(config_stub, key_config_stub):
return completer.CompletionInfo(config=config_stub,
keyconf=key_config_stub,
- win_id=0)
+ win_id=0,
+ cur_tab=None)
def test_command_completion(qtmodeltester, cmdutils_stub, configdata_stub,
@@ -260,13 +272,38 @@ def test_help_completion(qtmodeltester, cmdutils_stub, key_config_stub,
(':tab-close', 'Close the current tab.', ''),
],
"Settings": [
- ('aliases', 'Aliases for commands.', None),
- ('bindings.commands', 'Default keybindings', None),
- ('bindings.default', 'Default keybindings', None),
- ('completion.open_categories', 'Which categories to show (in '
- 'which order) in the :open completion.', None),
- ('content.javascript.enabled', 'Enable/Disable JavaScript', None),
- ('url.searchengines', 'searchengines list', None),
+ (
+ 'aliases',
+ 'Aliases for commands.',
+ '{"q": "quit"}',
+ ),
+ (
+ 'bindings.commands',
+ 'Custom keybindings',
+ ('{"normal": {"<Ctrl+q>": "quit", "I": "invalid", "ZQ": "quit", '
+ '"d": "scroll down"}}'),
+ ),
+ (
+ 'bindings.default',
+ 'Default keybindings',
+ '{"normal": {"<Ctrl+q>": "quit", "d": "tab-close"}}',
+ ),
+ (
+ 'completion.open_categories',
+ 'Which categories to show (in which order) in the :open completion.',
+ '["searchengines", "quickmarks", "bookmarks", "history"]',
+ ),
+ (
+ 'content.javascript.enabled',
+ 'Enable/Disable JavaScript',
+ 'true'
+ ),
+ (
+ 'url.searchengines',
+ 'searchengines list',
+ ('{"DEFAULT": "https://duckduckgo.com/?q={}", '
+ '"google": "https://google.com/?q={}"}'),
+ ),
],
})
@@ -832,6 +869,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 = [
@@ -865,7 +937,7 @@ def test_setting_option_completion(qtmodeltester, config_stub,
_check_completions(model, {
"Options": [
('aliases', 'Aliases for commands.', '{"q": "quit"}'),
- ('bindings.commands', 'Default keybindings', (
+ ('bindings.commands', 'Custom keybindings', (
'{"normal": {"<Ctrl+q>": "quit", "I": "invalid", '
'"ZQ": "quit", "d": "scroll down"}}')),
('completion.open_categories', 'Which categories to show (in '
@@ -889,7 +961,7 @@ def test_setting_dict_option_completion(qtmodeltester, config_stub,
_check_completions(model, {
"Dict options": [
('aliases', 'Aliases for commands.', '{"q": "quit"}'),
- ('bindings.commands', 'Default keybindings', (
+ ('bindings.commands', 'Custom keybindings', (
'{"normal": {"<Ctrl+q>": "quit", "I": "invalid", '
'"ZQ": "quit", "d": "scroll down"}}')),
('url.searchengines', 'searchengines list',
@@ -1144,3 +1216,145 @@ def test_url_completion_benchmark(benchmark, info,
model.set_pattern('ex 123')
benchmark(bench)
+
+
+@pytest.fixture
+def tab_with_history(fake_web_tab, tabbed_browser_stubs, info, monkeypatch):
+ """Returns a fake tab with some fake history items."""
+ pytest.importorskip('PyQt5.QtWebEngineWidgets')
+ tab = fake_web_tab(QUrl('https://github.com'), 'GitHub', 0)
+ current_idx = 2
+ monkeypatch.setattr(
+ tab.history, 'current_idx',
+ lambda: current_idx,
+ )
+
+ history = []
+ now = time.time()
+ for url, title, ts in [
+ ("http://example.com/index", "list of things", now),
+ ("http://example.com/thing1", "thing1 detail", now+5),
+ ("http://example.com/thing2", "thing2 detail", now+10),
+ ("http://example.com/thing3", "thing3 detail", now+15),
+ ("http://example.com/thing4", "thing4 detail", now+20),
+ ]:
+ entry = mock.Mock(spec=QWebEngineHistoryItem)
+ entry.url.return_value = QUrl(url)
+ entry.title.return_value = title
+ dt = QDateTime.fromMSecsSinceEpoch(int(ts * 1000))
+ entry.lastVisited.return_value = dt
+ history.append(entry)
+ tab.history._history = mock.Mock(spec=QWebEngineHistory)
+ tab.history._history.items.return_value = history
+ monkeypatch.setattr(
+ tab.history, 'back_items',
+ lambda *_args: (
+ entry for idx, entry in enumerate(tab.history._history.items())
+ if idx < current_idx
+ ),
+ )
+ monkeypatch.setattr(
+ tab.history, 'forward_items',
+ lambda *_args: (
+ entry for idx, entry in enumerate(tab.history._history.items())
+ if idx > current_idx
+ ),
+ )
+
+ tabbed_browser_stubs[0].widget.tabs = [tab]
+ tabbed_browser_stubs[0].widget.current_index = 0
+
+ info.cur_tab = tab
+ return tab
+
+
+def test_back_completion(tab_with_history, info):
+ """Test back tab history completion."""
+ model = miscmodels.back(info=info)
+ model.set_pattern('')
+
+ _check_completions(model, {
+ "History": [
+ ("1", "http://example.com/thing1", "thing1 detail"),
+ ("0", "http://example.com/index", "list of things"),
+ ],
+ })
+
+
+def test_forward_completion(tab_with_history, info):
+ """Test forward tab history completion."""
+ model = miscmodels.forward(info=info)
+ model.set_pattern('')
+
+ _check_completions(model, {
+ "History": [
+ ("3", "http://example.com/thing3", "thing3 detail"),
+ ("4", "http://example.com/thing4", "thing4 detail"),
+ ],
+ })
+
+
+def test_undo_completion(tabbed_browser_stubs, info):
+ """Test :undo completion."""
+ entry1 = tabbedbrowser._UndoEntry(url=QUrl('https://example.org/'),
+ history=None, index=None, pinned=None,
+ created_at=datetime(2020, 1, 1))
+ entry2 = tabbedbrowser._UndoEntry(url=QUrl('https://example.com/'),
+ history=None, index=None, pinned=None,
+ created_at=datetime(2020, 1, 2))
+ entry3 = tabbedbrowser._UndoEntry(url=QUrl('https://example.net/'),
+ history=None, index=None, pinned=None,
+ created_at=datetime(2020, 1, 2))
+
+ # Most recently closed is at the end
+ tabbed_browser_stubs[0].undo_stack = [
+ [entry1],
+ [entry2, entry3],
+ ]
+
+ model = miscmodels.undo(info=info)
+ model.set_pattern('')
+
+ # Most recently closed is at the top, indices are used like "-x" for the
+ # undo stack.
+ _check_completions(model, {
+ "Closed tabs": [
+ ("1",
+ "https://example.com/, https://example.net/",
+ "2020-01-02 00:00"),
+ ("2",
+ "https://example.org/",
+ "2020-01-01 00:00"),
+ ],
+ })
+
+
+def undo_completion_retains_sort_order(tabbed_browser_stubs, info):
+ """Test :undo completion sort order with > 10 entries."""
+ created_dt = datetime(2020, 1, 1)
+ created_str = "2020-01-02 00:00"
+
+ tabbed_browser_stubs[0].undo_stack = [
+ tabbedbrowser._UndoEntry(
+ url=QUrl(f'https://example.org/{idx}'),
+ history=None, index=None, pinned=None,
+ created_at=created_dt,
+ )
+ for idx in range(1, 11)
+ ]
+
+ model = miscmodels.undo(info=info)
+ model.set_pattern('')
+
+ expected = [
+ (str(idx), f'https://example.org/{idx}', created_str)
+ for idx in range(1, 11)
+ ]
+ _check_completions(model, {"Closed tabs": expected})
+
+
+@hypothesis.given(text=hypothesis.strategies.text())
+def test_listcategory_hypothesis(text):
+ """Make sure we can't produce invalid patterns."""
+ cat = listcategory.ListCategory("test", [])
+ cat.set_pattern(text)
diff --git a/tests/unit/components/test_adblock.py b/tests/unit/components/test_adblock.py
deleted file mode 100644
index 8dbd0ce29..000000000
--- a/tests/unit/components/test_adblock.py
+++ /dev/null
@@ -1,474 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-#!/usr/bin/env python3
-
-# Copyright 2015 Corentin Julé <corentinjule@gmail.com>
-#
-# This file is part of qutebrowser.
-#
-# qutebrowser is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# qutebrowser is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-
-import os
-import os.path
-import zipfile
-import logging
-
-import pytest
-
-from PyQt5.QtCore import QUrl
-
-from qutebrowser.components import adblock
-from qutebrowser.utils import urlmatch
-from helpers import utils
-
-
-pytestmark = pytest.mark.usefixtures('qapp')
-
-# TODO See ../utils/test_standarddirutils for OSError and caplog assertion
-
-WHITELISTED_HOSTS = ('qutebrowser.org', 'mediumhost.io', 'http://*.edu')
-
-BLOCKLIST_HOSTS = ('localhost',
- 'mediumhost.io',
- 'malware.badhost.org',
- '4-verybadhost.com',
- 'ads.worsthostever.net')
-
-CLEAN_HOSTS = ('goodhost.gov', 'verygoodhost.com')
-
-URLS_TO_CHECK = ('http://localhost',
- 'http://mediumhost.io',
- 'ftp://malware.badhost.org',
- 'http://4-verybadhost.com',
- 'http://ads.worsthostever.net',
- 'http://goodhost.gov',
- 'ftp://verygoodhost.com',
- 'http://qutebrowser.org',
- 'http://veryverygoodhost.edu')
-
-
-@pytest.fixture
-def host_blocker_factory(config_tmpdir, data_tmpdir, download_stub,
- config_stub):
- def factory():
- return adblock.HostBlocker(config_dir=config_tmpdir,
- data_dir=data_tmpdir)
- return factory
-
-
-def create_zipfile(directory, files, zipname='test'):
- """Return a path to a newly created zip file.
-
- Args:
- directory: path object where to create the zip file.
- files: list of filenames (relative to directory) to each file to add.
- zipname: name to give to the zip file.
- """
- zipfile_path = directory / zipname + '.zip'
- with zipfile.ZipFile(str(zipfile_path), 'w') as new_zipfile:
- for file_path in files:
- new_zipfile.write(str(directory / file_path),
- arcname=os.path.basename(str(file_path)))
- # Removes path from file name
- return str(zipname + '.zip')
-
-
-def create_blocklist(directory, blocked_hosts=BLOCKLIST_HOSTS,
- name='hosts', line_format='one_per_line'):
- """Return a path to a blocklist file.
-
- Args:
- directory: path object where to create the blocklist file
- blocked_hosts: an iterable of string hosts to add to the blocklist
- name: name to give to the blocklist file
- line_format: 'etc_hosts' --> /etc/hosts format
- 'one_per_line' --> one host per line format
- 'not_correct' --> Not a correct hosts file format.
- """
- blocklist_file = directory / name
- with open(str(blocklist_file), 'w', encoding='UTF-8') as blocklist:
- # ensure comments are ignored when processing blocklist
- blocklist.write('# Blocked Hosts List #\n\n')
- if line_format == 'etc_hosts': # /etc/hosts like format
- for host in blocked_hosts:
- blocklist.write('127.0.0.1 ' + host + '\n')
- elif line_format == 'one_per_line':
- for host in blocked_hosts:
- blocklist.write(host + '\n')
- elif line_format == 'not_correct':
- for host in blocked_hosts:
- blocklist.write(host + ' This is not a correct hosts file\n')
- else:
- raise ValueError('Incorrect line_format argument')
- return name
-
-
-def assert_urls(host_blocker, blocked=BLOCKLIST_HOSTS,
- whitelisted=WHITELISTED_HOSTS, urls_to_check=URLS_TO_CHECK):
- """Test if Urls to check are blocked or not by HostBlocker.
-
- Ensure URLs in 'blocked' and not in 'whitelisted' are blocked.
- All other URLs must not be blocked.
-
- localhost is an example of a special case that shouldn't be blocked.
- """
- whitelisted = list(whitelisted) + ['localhost']
- for str_url in urls_to_check:
- url = QUrl(str_url)
- host = url.host()
- if host in blocked and host not in whitelisted:
- assert host_blocker._is_blocked(url)
- else:
- assert not host_blocker._is_blocked(url)
-
-
-def blocklist_to_url(filename):
- """Get an example.com-URL with the given filename as path."""
- assert not os.path.isabs(filename), filename
- url = QUrl('http://example.com/')
- url.setPath('/' + filename)
- assert url.isValid(), url.errorString()
- return url
-
-
-def generic_blocklists(directory):
- """Return a generic list of files to be used in hosts-block-lists option.
-
- This list contains :
- - a remote zip file with 1 hosts file and 2 useless files
- - a remote zip file with only useless files
- (Should raise a FileNotFoundError)
- - a remote zip file with only one valid hosts file
- - a local text file with valid hosts
- - a remote text file without valid hosts format.
- """
- # remote zip file with 1 hosts file and 2 useless files
- file1 = create_blocklist(directory, blocked_hosts=CLEAN_HOSTS,
- name='README', line_format='not_correct')
- file2 = create_blocklist(directory, blocked_hosts=BLOCKLIST_HOSTS[:3],
- name='hosts', line_format='etc_hosts')
- file3 = create_blocklist(directory, blocked_hosts=CLEAN_HOSTS,
- name='false_positive', line_format='one_per_line')
- files_to_zip = [file1, file2, file3]
- blocklist1 = blocklist_to_url(
- create_zipfile(directory, files_to_zip, 'block1'))
-
- # remote zip file without file named hosts
- # (Should raise a FileNotFoundError)
- file1 = create_blocklist(directory, blocked_hosts=CLEAN_HOSTS,
- name='md5sum', line_format='etc_hosts')
- file2 = create_blocklist(directory, blocked_hosts=CLEAN_HOSTS,
- name='README', line_format='not_correct')
- file3 = create_blocklist(directory, blocked_hosts=CLEAN_HOSTS,
- name='false_positive', line_format='one_per_line')
- files_to_zip = [file1, file2, file3]
- blocklist2 = blocklist_to_url(
- create_zipfile(directory, files_to_zip, 'block2'))
-
- # remote zip file with only one valid hosts file inside
- file1 = create_blocklist(directory, blocked_hosts=[BLOCKLIST_HOSTS[3]],
- name='malwarelist', line_format='etc_hosts')
- blocklist3 = blocklist_to_url(create_zipfile(directory, [file1], 'block3'))
-
- # local text file with valid hosts
- blocklist4 = QUrl.fromLocalFile(str(directory / create_blocklist(
- directory, blocked_hosts=[BLOCKLIST_HOSTS[4]],
- name='mycustomblocklist', line_format='one_per_line')))
- assert blocklist4.isValid(), blocklist4.errorString()
-
- # remote text file without valid hosts format
- blocklist5 = blocklist_to_url(create_blocklist(
- directory, blocked_hosts=CLEAN_HOSTS, name='notcorrectlist',
- line_format='not_correct'))
-
- return [blocklist1.toString(), blocklist2.toString(),
- blocklist3.toString(), blocklist4.toString(),
- blocklist5.toString()]
-
-
-def test_disabled_blocking_update(config_stub, tmpdir, caplog,
- host_blocker_factory):
- """Ensure no URL is blocked when host blocking is disabled."""
- config_stub.val.content.host_blocking.lists = generic_blocklists(tmpdir)
- config_stub.val.content.host_blocking.enabled = False
-
- host_blocker = host_blocker_factory()
- host_blocker.adblock_update()
- while host_blocker._in_progress:
- current_download = host_blocker._in_progress[0]
- with caplog.at_level(logging.ERROR):
- current_download.successful = True
- current_download.finished.emit()
- host_blocker.read_hosts()
- for str_url in URLS_TO_CHECK:
- assert not host_blocker._is_blocked(QUrl(str_url))
-
-
-def test_disabled_blocking_per_url(config_stub, host_blocker_factory):
- example_com = 'https://www.example.com/'
-
- config_stub.val.content.host_blocking.lists = []
- pattern = urlmatch.UrlPattern(example_com)
- config_stub.set_obj('content.host_blocking.enabled', False,
- pattern=pattern)
-
- url = QUrl('blocked.example.com')
-
- host_blocker = host_blocker_factory()
- host_blocker._blocked_hosts.add(url.host())
-
- assert host_blocker._is_blocked(url)
- assert not host_blocker._is_blocked(url, first_party_url=QUrl(example_com))
-
-
-def test_no_blocklist_update(config_stub, download_stub, host_blocker_factory):
- """Ensure no URL is blocked when no block list exists."""
- config_stub.val.content.host_blocking.lists = None
- config_stub.val.content.host_blocking.enabled = True
-
- host_blocker = host_blocker_factory()
- host_blocker.adblock_update()
- host_blocker.read_hosts()
- for dl in download_stub.downloads:
- dl.successful = True
- for str_url in URLS_TO_CHECK:
- assert not host_blocker._is_blocked(QUrl(str_url))
-
-
-def test_successful_update(config_stub, tmpdir, caplog, host_blocker_factory):
- """Ensure hosts from host_blocking.lists are blocked after an update."""
- config_stub.val.content.host_blocking.lists = generic_blocklists(tmpdir)
- config_stub.val.content.host_blocking.enabled = True
- config_stub.val.content.host_blocking.whitelist = None
-
- host_blocker = host_blocker_factory()
- host_blocker.adblock_update()
- # Simulate download is finished
- while host_blocker._in_progress:
- current_download = host_blocker._in_progress[0]
- with caplog.at_level(logging.ERROR):
- current_download.successful = True
- current_download.finished.emit()
- host_blocker.read_hosts()
- assert_urls(host_blocker, whitelisted=[])
-
-
-def test_parsing_multiple_hosts_on_line(host_blocker_factory):
- """Ensure multiple hosts on a line get parsed correctly."""
- host_blocker = host_blocker_factory()
- bytes_host_line = ' '.join(BLOCKLIST_HOSTS).encode('utf-8')
- parsed_hosts = host_blocker._read_hosts_line(bytes_host_line)
- host_blocker._blocked_hosts |= parsed_hosts
- assert_urls(host_blocker, whitelisted=[])
-
-
-@pytest.mark.parametrize('ip, host', [
- ('127.0.0.1', 'localhost'),
- ('27.0.0.1', 'localhost.localdomain'),
- ('27.0.0.1', 'local'),
- ('55.255.255.255', 'broadcasthost'),
- (':1', 'localhost'),
- (':1', 'ip6-localhost'),
- (':1', 'ip6-loopback'),
- ('e80::1%lo0', 'localhost'),
- ('f00::0', 'ip6-localnet'),
- ('f00::0', 'ip6-mcastprefix'),
- ('f02::1', 'ip6-allnodes'),
- ('f02::2', 'ip6-allrouters'),
- ('ff02::3', 'ip6-allhosts'),
- ('.0.0.0', '0.0.0.0'),
- ('127.0.1.1', 'myhostname'),
- ('127.0.0.53', 'myhostname'),
-])
-def test_whitelisted_lines(host_blocker_factory, ip, host):
- """Make sure we don't block hosts we don't want to."""
- host_blocker = host_blocker_factory()
- line = ('{} {}'.format(ip, host)).encode('ascii')
- parsed_hosts = host_blocker._read_hosts_line(line)
- assert host not in parsed_hosts
-
-
-def test_failed_dl_update(config_stub, tmpdir, caplog, host_blocker_factory):
- """One blocklist fails to download.
-
- Ensure hosts from this list are not blocked.
- """
- dl_fail_blocklist = blocklist_to_url(create_blocklist(
- tmpdir, blocked_hosts=CLEAN_HOSTS, name='download_will_fail',
- line_format='one_per_line'))
- hosts_to_block = (generic_blocklists(tmpdir) +
- [dl_fail_blocklist.toString()])
- config_stub.val.content.host_blocking.lists = hosts_to_block
- config_stub.val.content.host_blocking.enabled = True
- config_stub.val.content.host_blocking.whitelist = None
-
- host_blocker = host_blocker_factory()
- host_blocker.adblock_update()
- while host_blocker._in_progress:
- current_download = host_blocker._in_progress[0]
- # if current download is the file we want to fail, make it fail
- if current_download.name == dl_fail_blocklist.path():
- current_download.successful = False
- else:
- current_download.successful = True
- with caplog.at_level(logging.ERROR):
- current_download.finished.emit()
- host_blocker.read_hosts()
- assert_urls(host_blocker, whitelisted=[])
-
-
-@pytest.mark.parametrize('location', ['content', 'comment'])
-def test_invalid_utf8(config_stub, tmpdir, caplog, host_blocker_factory,
- location):
- """Make sure invalid UTF-8 is handled correctly.
-
- See https://github.com/qutebrowser/qutebrowser/issues/2301
- """
- blocklist = tmpdir / 'blocklist'
- if location == 'comment':
- blocklist.write_binary(b'# nbsp: \xa0\n')
- else:
- assert location == 'content'
- blocklist.write_binary(b'https://www.example.org/\xa0')
- for url in BLOCKLIST_HOSTS:
- blocklist.write(url + '\n', mode='a')
-
- url = blocklist_to_url('blocklist')
- config_stub.val.content.host_blocking.lists = [url.toString()]
- config_stub.val.content.host_blocking.enabled = True
- config_stub.val.content.host_blocking.whitelist = None
-
- host_blocker = host_blocker_factory()
- host_blocker.adblock_update()
- current_download = host_blocker._in_progress[0]
-
- if location == 'content':
- with caplog.at_level(logging.ERROR):
- current_download.successful = True
- current_download.finished.emit()
- expected = (r"Failed to decode: "
- r"b'https://www.example.org/\xa0localhost")
- assert caplog.messages[-2].startswith(expected)
- else:
- current_download.successful = True
- current_download.finished.emit()
-
- host_blocker.read_hosts()
- assert_urls(host_blocker, whitelisted=[])
-
-
-def test_invalid_utf8_compiled(config_stub, config_tmpdir, data_tmpdir,
- monkeypatch, caplog, host_blocker_factory):
- """Make sure invalid UTF-8 in the compiled file is handled."""
- config_stub.val.content.host_blocking.lists = []
-
- # Make sure the HostBlocker doesn't delete blocked-hosts in __init__
- monkeypatch.setattr(adblock.HostBlocker, 'update_files',
- lambda _self: None)
-
- (config_tmpdir / 'blocked-hosts').write_binary(
- b'https://www.example.org/\xa0')
- (data_tmpdir / 'blocked-hosts').ensure()
-
- host_blocker = host_blocker_factory()
- with caplog.at_level(logging.ERROR):
- host_blocker.read_hosts()
- assert caplog.messages[-1] == "Failed to read host blocklist!"
-
-
-def test_blocking_with_whitelist(config_stub, data_tmpdir, host_blocker_factory):
- """Ensure hosts in content.host_blocking.whitelist are never blocked."""
- # Simulate adblock_update has already been run
- # by creating a file named blocked-hosts,
- # Exclude localhost from it as localhost is never blocked via list
- filtered_blocked_hosts = BLOCKLIST_HOSTS[1:]
- blocklist = create_blocklist(data_tmpdir,
- blocked_hosts=filtered_blocked_hosts,
- name='blocked-hosts',
- line_format='one_per_line')
- config_stub.val.content.host_blocking.lists = [blocklist]
- config_stub.val.content.host_blocking.enabled = True
- config_stub.val.content.host_blocking.whitelist = list(WHITELISTED_HOSTS)
-
- host_blocker = host_blocker_factory()
- host_blocker.read_hosts()
- assert_urls(host_blocker)
-
-
-def test_config_change_initial(config_stub, tmpdir, host_blocker_factory):
- """Test emptying host_blocking.lists with existing blocked_hosts.
-
- - A blocklist is present in host_blocking.lists and blocked_hosts is
- populated
- - User quits qutebrowser, empties host_blocking.lists from his config
- - User restarts qutebrowser, does adblock-update
- """
- create_blocklist(tmpdir, blocked_hosts=BLOCKLIST_HOSTS,
- name='blocked-hosts', line_format='one_per_line')
- config_stub.val.content.host_blocking.lists = None
- config_stub.val.content.host_blocking.enabled = True
- config_stub.val.content.host_blocking.whitelist = None
-
- host_blocker = host_blocker_factory()
- host_blocker.read_hosts()
- for str_url in URLS_TO_CHECK:
- assert not host_blocker._is_blocked(QUrl(str_url))
-
-
-def test_config_change(config_stub, tmpdir, host_blocker_factory):
- """Ensure blocked-hosts resets if host-block-list is changed to None."""
- filtered_blocked_hosts = BLOCKLIST_HOSTS[1:] # Exclude localhost
- blocklist = blocklist_to_url(create_blocklist(
- tmpdir, blocked_hosts=filtered_blocked_hosts, name='blocked-hosts',
- line_format='one_per_line'))
- config_stub.val.content.host_blocking.lists = [blocklist.toString()]
- config_stub.val.content.host_blocking.enabled = True
- config_stub.val.content.host_blocking.whitelist = None
-
- host_blocker = host_blocker_factory()
- host_blocker.read_hosts()
- config_stub.val.content.host_blocking.lists = None
- host_blocker.read_hosts()
- for str_url in URLS_TO_CHECK:
- assert not host_blocker._is_blocked(QUrl(str_url))
-
-
-def test_add_directory(config_stub, tmpdir, host_blocker_factory):
- """Ensure adblocker can import all files in a directory."""
- blocklist_hosts2 = []
- for i in BLOCKLIST_HOSTS[1:]:
- blocklist_hosts2.append('1' + i)
-
- create_blocklist(tmpdir, blocked_hosts=BLOCKLIST_HOSTS,
- name='blocked-hosts', line_format='one_per_line')
- create_blocklist(tmpdir, blocked_hosts=blocklist_hosts2,
- name='blocked-hosts2', line_format='one_per_line')
-
- config_stub.val.content.host_blocking.lists = [tmpdir.strpath]
- config_stub.val.content.host_blocking.enabled = True
- host_blocker = host_blocker_factory()
- host_blocker.adblock_update()
- assert len(host_blocker._blocked_hosts) == len(blocklist_hosts2) * 2
-
-
-def test_adblock_benchmark(data_tmpdir, benchmark, host_blocker_factory):
- blocked_hosts = data_tmpdir / 'blocked-hosts'
- blocked_hosts.write_text('\n'.join(utils.blocked_hosts()),
- encoding='utf-8')
-
- url = QUrl('https://www.example.org/')
- blocker = host_blocker_factory()
- blocker.read_hosts()
- assert blocker._blocked_hosts
-
- benchmark(lambda: blocker._is_blocked(url))
diff --git a/tests/unit/components/test_blockutils.py b/tests/unit/components/test_blockutils.py
new file mode 100644
index 000000000..480a6f9eb
--- /dev/null
+++ b/tests/unit/components/test_blockutils.py
@@ -0,0 +1,83 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+#!/usr/bin/env python3
+
+# Copyright 2020 Árni Dagur <arni@dagur.eu>
+#
+# 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 os
+import io
+from typing import IO
+
+from PyQt5.QtCore import QUrl
+
+import pytest
+
+from qutebrowser.components.utils import blockutils
+
+
+@pytest.fixture
+def pretend_blocklists(tmpdir):
+ """Put fake blocklists into a tempdir.
+
+ Put fake blocklists blocklists into a temporary directory, then return
+ both a list containing `file://` urls, and the residing dir.
+ """
+ data = [
+ (["cdn.malwarecorp.is", "evil-industries.com"], "malicious-hosts.txt"),
+ (["news.moms-against-icecream.net"], "blocklist.list"),
+ ]
+ # Add a bunch of automatically generated blocklist as well
+ for n in range(8):
+ data.append(([f"example{n}.com", f"example{n+1}.net"], f"blocklist{n}"))
+
+ bl_dst_dir = tmpdir / "blocklists"
+ bl_dst_dir.mkdir()
+ urls = []
+ for blocklist_lines, filename in data:
+ bl_dst_path = bl_dst_dir / filename
+ with open(bl_dst_path, "w", encoding="utf-8") as f:
+ f.write("\n".join(blocklist_lines))
+ assert os.path.isfile(bl_dst_path)
+ urls.append(QUrl.fromLocalFile(str(bl_dst_path)).toString())
+ return urls, bl_dst_dir
+
+
+def test_blocklist_dl(qtbot, pretend_blocklists):
+ total_expected = 10
+ num_single_dl_called = 0
+
+ def on_single_download(download: IO[bytes]) -> None:
+ nonlocal num_single_dl_called
+ num_single_dl_called += 1
+
+ num_lines = 0
+ with io.TextIOWrapper(download, encoding="utf-8") as dl_io:
+ for line in dl_io:
+ assert line.split(".")[-1].strip() in ("com", "net", "is")
+ num_lines += 1
+ assert num_lines >= 1
+
+ list_qurls = [QUrl(blocklist) for blocklist in pretend_blocklists[0]]
+
+ dl = blockutils.BlocklistDownloads(list_qurls)
+ dl.single_download_finished.connect(on_single_download)
+
+ with qtbot.waitSignal(dl.all_downloads_finished) as blocker:
+ dl.initiate()
+ assert blocker.args == [total_expected]
+
+ assert num_single_dl_called == total_expected
diff --git a/tests/unit/components/test_braveadblock.py b/tests/unit/components/test_braveadblock.py
new file mode 100644
index 000000000..c653ce4e5
--- /dev/null
+++ b/tests/unit/components/test_braveadblock.py
@@ -0,0 +1,368 @@
+# 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 pathlib
+import logging
+import csv
+import os.path
+from typing import Iterable, Tuple
+
+from PyQt5.QtCore import QUrl
+
+import pytest
+
+from qutebrowser.api.interceptor import ResourceType
+from qutebrowser.components import braveadblock
+from qutebrowser.components.utils import blockutils
+from helpers import utils
+
+pytestmark = pytest.mark.usefixtures("qapp")
+
+OKAY_URLS = [
+ (
+ "https://qutebrowser.org/icons/qutebrowser.svg",
+ "https://qutebrowser.org",
+ ResourceType.image,
+ ),
+ (
+ "https://qutebrowser.org/doc/img/main.png",
+ "https://qutebrowser.org",
+ ResourceType.image,
+ ),
+ (
+ "https://qutebrowser.org/media/font.css",
+ "https://qutebrowser.org",
+ ResourceType.stylesheet,
+ ),
+ (
+ "https://www.ruv.is/sites/default/files/styles/2000x1125/public/fr_20180719_091367_1.jpg?itok=0zTNSKKS&timestamp=1561275315",
+ "https://www.ruv.is/frett/2020/04/23/today-is-the-first-day-of-summer",
+ ResourceType.image,
+ ),
+ ("https://easylist.to/easylist/easylist.txt", None, ResourceType.main_frame),
+ ("https://easylist.to/easylist/easyprivacy.txt", None, ResourceType.main_frame),
+]
+
+NOT_OKAY_URLS = [
+ (
+ "https://pagead2.googlesyndication.com/pcs/activeview?xai=AKAOjsvBN5MuZsVQyE7HD18bD-JjK589TD3zkugwCoLE2C5nP26WFNCQb8WwxzZTelPEHwwnhaOCsGxYc8WeFgYZLReqLYl8r9BtAQ6r83OHa04&sig=Cg0ArKJSzKMgXuVbXAD1EAE&adk=1473563476&tt=-1&bs=1431%2C473&mtos=120250,120250,120250,120250,120250&tos=120250,0,0,0,0&p=60,352,150,1080&mcvt=120250&rs=0&ht=0&tfs=5491&tls=125682&mc=1&lte=0&bas=0&bac=0&if=1&met=ie&avms=nio&exg=1&md=2&btr=0&lm=2&rst=1587887205533&dlt=226&rpt=1849&isd=0&msd=0&ext&xdi=0&ps=1431%2C7860&ss=1440%2C810&pt=-1&bin=4&deb=1-0-0-1192-5-1191-1191-0-0-0&tvt=125678&is=728%2C90&iframe_loc=https%3A%2F%2Ftpc.googlesyndication.com%2Fsafeframe%2F1-0-37%2Fhtml%2Fcontainer.html&r=u&id=osdtos&vs=4&uc=1192&upc=1&tgt=DIV&cl=1&cec=1&wf=0&cac=1&cd=0x0&itpl=19&v=20200422",
+ "https://google.com",
+ ResourceType.image,
+ ),
+ (
+ "https://e.deployads.com/e/myanimelist.net",
+ "https://myanimelist.net",
+ ResourceType.xhr,
+ ),
+ (
+ "https://c.amazon-adsystem.com/aax2/apstag.js",
+ "https://www.reddit.com",
+ ResourceType.script,
+ ),
+ (
+ "https://c.aaxads.com/aax.js?pub=AAX763KC6&hst=www.reddit.com&ver=1.2",
+ "https://www.reddit.com",
+ ResourceType.script,
+ ),
+ (
+ "https://pixel.mathtag.com/sync/img/?mt_exid=10009&mt_exuid=&mm_bnc&mm_bct&UUID=c7b65ea6-76cc-4700-b0c7-6dbcd10820ed",
+ "https://damndelicious.net/2019/04/03/easy-slow-cooker-chili/",
+ ResourceType.image,
+ ),
+]
+
+
+def run_function_on_dataset(given_function):
+ """Run the given function on a bunch of urls.
+
+ In the data folder, we have a file called `adblock_dataset.tsv`, which
+ contains tuples of (url, source_url, type) in each line. We give these
+ to values to the given function, row by row.
+ """
+ dataset = utils.adblock_dataset_tsv()
+ reader = csv.DictReader(dataset, delimiter="\t")
+ for row in reader:
+ url = QUrl(row["url"])
+ source_url = QUrl(row["source_url"])
+ resource_type = ResourceType[row["type"]]
+ given_function(url, source_url, resource_type)
+
+
+def assert_none_blocked(ad_blocker):
+ assert_urls(ad_blocker, NOT_OKAY_URLS + OKAY_URLS, False)
+
+ def assert_not_blocked(url, source_url, resource_type):
+ nonlocal ad_blocker
+ assert not ad_blocker._is_blocked(url, source_url, resource_type)
+
+ run_function_on_dataset(assert_not_blocked)
+
+
+@pytest.fixture
+def blocklist_invalid_utf8(tmpdir):
+ dest_path = tmpdir / "invalid_utf8.txt"
+ dest_path.write_binary(b"invalidutf8\xa0")
+ return QUrl.fromLocalFile(str(dest_path)).toString()
+
+
+@pytest.fixture
+def easylist_easyprivacy_both(tmpdir):
+ """Put easyprivacy and easylist blocklists into a tempdir.
+
+ Copy the easyprivacy and easylist blocklists into a temporary directory,
+ then return both a list containing `file://` urls, and the residing dir.
+ """
+ bl_dst_dir = tmpdir / "blocklists"
+ bl_dst_dir.mkdir()
+ urls = []
+ for blocklist, filename in [
+ (utils.easylist_txt(), "easylist.txt"),
+ (utils.easyprivacy_txt(), "easyprivacy.txt"),
+ ]:
+ bl_dst_path = bl_dst_dir / filename
+ with open(bl_dst_path, "w", encoding="utf-8") as f:
+ f.write("\n".join(list(blocklist)))
+ assert os.path.isfile(bl_dst_path)
+ urls.append(QUrl.fromLocalFile(str(bl_dst_path)).toString())
+ return urls, bl_dst_dir
+
+
+@pytest.fixture
+def empty_dir(tmpdir):
+ empty_dir_path = tmpdir / "empty_dir"
+ empty_dir_path.mkdir()
+ return empty_dir_path
+
+
+@pytest.fixture
+def easylist_easyprivacy(easylist_easyprivacy_both):
+ """The first return value of `easylist_easyprivacy_both`."""
+ return easylist_easyprivacy_both[0]
+
+
+@pytest.fixture
+def ad_blocker(config_stub, data_tmpdir):
+ pytest.importorskip("adblock")
+ return braveadblock.BraveAdBlocker(data_dir=pathlib.Path(str(data_tmpdir)))
+
+
+def assert_only_one_success_message(messages):
+ expected_msg = "braveadblock: Filters successfully read"
+ assert len([m for m in messages if m.startswith(expected_msg)]) == 1
+
+
+def assert_urls(
+ ad_blocker: braveadblock.BraveAdBlocker,
+ urls: Iterable[Tuple[str, str, ResourceType]],
+ should_be_blocked: bool,
+) -> None:
+ for (str_url, source_str_url, request_type) in urls:
+ url = QUrl(str_url)
+ source_url = QUrl(source_str_url)
+ is_blocked = ad_blocker._is_blocked(url, source_url, request_type)
+ assert is_blocked == should_be_blocked
+
+
+@pytest.mark.parametrize(
+ "blocking_enabled, method, should_be_blocked",
+ [
+ (True, "auto", True),
+ (True, "adblock", True),
+ (True, "both", True),
+ (True, "hosts", False),
+ (False, "auto", False),
+ (False, "adblock", False),
+ (False, "both", False),
+ (False, "hosts", False),
+ ],
+)
+def test_blocking_enabled(
+ config_stub,
+ easylist_easyprivacy,
+ caplog,
+ ad_blocker,
+ blocking_enabled,
+ method,
+ should_be_blocked,
+):
+ """Tests that the ads are blocked when the adblocker is enabled, and vice versa."""
+ config_stub.val.content.blocking.adblock.lists = easylist_easyprivacy
+ config_stub.val.content.blocking.enabled = blocking_enabled
+ config_stub.val.content.blocking.method = method
+ # Simulate the method-changed hook being run, since it doesn't execute
+ # with pytest.
+ ad_blocker.enabled = braveadblock._should_be_used()
+
+ downloads = ad_blocker.adblock_update()
+ while downloads._in_progress:
+ current_download = downloads._in_progress[0]
+ with caplog.at_level(logging.ERROR):
+ current_download.successful = True
+ current_download.finished.emit()
+ assert_urls(ad_blocker, NOT_OKAY_URLS, should_be_blocked)
+ assert_urls(ad_blocker, OKAY_URLS, False)
+
+
+def test_adblock_cache(config_stub, easylist_easyprivacy, caplog, ad_blocker):
+ config_stub.val.content.blocking.adblock.lists = easylist_easyprivacy
+ config_stub.val.content.blocking.enabled = True
+
+ for i in range(3):
+ print("At cache test iteration {}".format(i))
+ # Trying to read the cache before calling the update command should return
+ # a log message.
+ with caplog.at_level(logging.INFO):
+ ad_blocker.read_cache()
+ caplog.messages[-1].startswith(
+ "Run :brave-adblock-update to get adblock lists."
+ )
+
+ if i == 0:
+ # We haven't initialized the ad blocker yet, so we shouldn't be blocking
+ # anything.
+ assert_none_blocked(ad_blocker)
+
+ # Now we initialize the adblocker.
+ downloads = ad_blocker.adblock_update()
+ while downloads._in_progress:
+ current_download = downloads._in_progress[0]
+ with caplog.at_level(logging.ERROR):
+ current_download.successful = True
+ current_download.finished.emit()
+
+ # After initializing the the adblocker, we should start seeing ads
+ # blocked.
+ assert_urls(ad_blocker, NOT_OKAY_URLS, True)
+ assert_urls(ad_blocker, OKAY_URLS, False)
+
+ # After reading the cache, we should still be seeing ads blocked.
+ ad_blocker.read_cache()
+ assert_urls(ad_blocker, NOT_OKAY_URLS, True)
+ assert_urls(ad_blocker, OKAY_URLS, False)
+
+ # Now we remove the cache file and try all over again...
+ ad_blocker._cache_path.unlink()
+
+
+def test_invalid_utf8(ad_blocker, config_stub, blocklist_invalid_utf8, caplog):
+ """Test that the adblocker handles invalid utf-8 correctly."""
+ config_stub.val.content.blocking.adblock.lists = [blocklist_invalid_utf8]
+ config_stub.val.content.blocking.enabled = True
+
+ with caplog.at_level(logging.INFO):
+ ad_blocker.adblock_update()
+ expected = "braveadblock: Block list is not valid utf-8"
+ assert caplog.messages[-2].startswith(expected)
+
+
+def test_config_changed(ad_blocker, config_stub, easylist_easyprivacy, caplog):
+ """Ensure blocked-hosts resets if host-block-list is changed to None."""
+ config_stub.val.content.blocking.enabled = True
+ config_stub.val.content.blocking.whitelist = None
+
+ for _ in range(2):
+ # We should be blocking like normal, since the block lists are set to
+ # easylist and easyprivacy.
+ config_stub.val.content.blocking.adblock.lists = easylist_easyprivacy
+ downloads = ad_blocker.adblock_update()
+ while downloads._in_progress:
+ current_download = downloads._in_progress[0]
+ with caplog.at_level(logging.ERROR):
+ current_download.successful = True
+ current_download.finished.emit()
+ assert_urls(ad_blocker, NOT_OKAY_URLS, True)
+ assert_urls(ad_blocker, OKAY_URLS, False)
+
+ # After setting the ad blocking lists to None, the ads should still be
+ # blocked, since we haven't run `:brave-adblock-update`.
+ config_stub.val.content.blocking.adblock.lists = None
+ assert_urls(ad_blocker, NOT_OKAY_URLS, True)
+ assert_urls(ad_blocker, OKAY_URLS, False)
+
+ # After updating the adblocker, nothing should be blocked, since we set
+ # the blocklist to None.
+ downloads = ad_blocker.adblock_update()
+ while downloads._in_progress:
+ current_download = downloads._in_progress[0]
+ with caplog.at_level(logging.ERROR):
+ current_download.successful = True
+ current_download.finished.emit()
+ assert_none_blocked(ad_blocker)
+
+
+def test_whitelist_on_dataset(config_stub, easylist_easyprivacy):
+ config_stub.val.content.blocking.adblock.lists = easylist_easyprivacy
+ config_stub.val.content.blocking.enabled = True
+ config_stub.val.content.blocking.whitelist = None
+
+ def assert_whitelisted(url, source_url, resource_type):
+ config_stub.val.content.blocking.whitelist = None
+ assert not blockutils.is_whitelisted_url(url)
+ config_stub.val.content.blocking.whitelist = []
+ assert not blockutils.is_whitelisted_url(url)
+ whitelist_url = url.toString(QUrl.RemovePath) + "/*"
+ config_stub.val.content.blocking.whitelist = [whitelist_url]
+ assert blockutils.is_whitelisted_url(url)
+
+ run_function_on_dataset(assert_whitelisted)
+
+
+def test_update_easylist_easyprivacy_directory(
+ ad_blocker, config_stub, easylist_easyprivacy_both, caplog
+):
+ # This directory should contain two text files, one for easylist, another
+ # for easyprivacy.
+ lists_directory = easylist_easyprivacy_both[1]
+
+ config_stub.val.content.blocking.adblock.lists = [
+ QUrl.fromLocalFile(str(lists_directory)).toString()
+ ]
+ config_stub.val.content.blocking.enabled = True
+ config_stub.val.content.blocking.whitelist = None
+
+ with caplog.at_level(logging.INFO):
+ ad_blocker.adblock_update()
+ assert_only_one_success_message(caplog.messages)
+ assert (
+ caplog.messages[-1]
+ == "braveadblock: Filters successfully read from 2 sources."
+ )
+ assert_urls(ad_blocker, NOT_OKAY_URLS, True)
+ assert_urls(ad_blocker, OKAY_URLS, False)
+
+
+def test_update_empty_directory_blocklist(ad_blocker, config_stub, empty_dir, caplog):
+ tmpdir_url = QUrl.fromLocalFile(str(empty_dir)).toString()
+ config_stub.val.content.blocking.adblock.lists = [tmpdir_url]
+ config_stub.val.content.blocking.enabled = True
+ config_stub.val.content.blocking.whitelist = None
+
+ # The temporary directory we created should be empty
+ assert len(empty_dir.listdir()) == 0
+
+ with caplog.at_level(logging.INFO):
+ ad_blocker.adblock_update()
+ assert_only_one_success_message(caplog.messages)
+ assert (
+ caplog.messages[-1]
+ == "braveadblock: Filters successfully read from 0 sources."
+ )
+
+ # There are no filters, so no ads should be blocked.
+ assert_none_blocked(ad_blocker)
diff --git a/tests/unit/components/test_hostblock.py b/tests/unit/components/test_hostblock.py
new file mode 100644
index 000000000..d1a65ade5
--- /dev/null
+++ b/tests/unit/components/test_hostblock.py
@@ -0,0 +1,567 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+#!/usr/bin/env python3
+
+# Copyright 2015 Corentin Julé <corentinjule@gmail.com>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import os.path
+import zipfile
+import logging
+
+import pytest
+
+from PyQt5.QtCore import QUrl
+
+from qutebrowser.components import hostblock
+from qutebrowser.utils import urlmatch
+from helpers import utils
+
+
+pytestmark = pytest.mark.usefixtures("qapp")
+
+# TODO See ../utils/test_standarddirutils for OSError and caplog assertion
+
+WHITELISTED_HOSTS = ("qutebrowser.org", "mediumhost.io", "http://*.edu")
+
+BLOCKLIST_HOSTS = (
+ "localhost",
+ "mediumhost.io",
+ "malware.badhost.org",
+ "4-verybadhost.com",
+ "ads.worsthostever.net",
+)
+
+CLEAN_HOSTS = ("goodhost.gov", "verygoodhost.com")
+
+URLS_TO_CHECK = (
+ "http://localhost",
+ "http://mediumhost.io",
+ "ftp://malware.badhost.org",
+ "http://4-verybadhost.com",
+ "http://ads.worsthostever.net",
+ "http://goodhost.gov",
+ "ftp://verygoodhost.com",
+ "http://qutebrowser.org",
+ "http://veryverygoodhost.edu",
+)
+
+
+@pytest.fixture
+def host_blocker_factory(config_tmpdir, data_tmpdir, download_stub, config_stub):
+ def factory():
+ return hostblock.HostBlocker(config_dir=config_tmpdir, data_dir=data_tmpdir)
+
+ return factory
+
+
+def create_zipfile(directory, files, zipname="test"):
+ """Return a path to a newly created zip file.
+
+ Args:
+ directory: path object where to create the zip file.
+ files: list of filenames (relative to directory) to each file to add.
+ zipname: name to give to the zip file.
+ """
+ zipfile_path = directory / zipname + ".zip"
+ with zipfile.ZipFile(str(zipfile_path), "w") as new_zipfile:
+ for file_path in files:
+ new_zipfile.write(
+ str(directory / file_path), arcname=os.path.basename(str(file_path))
+ )
+ # Removes path from file name
+ return str(zipname + ".zip")
+
+
+def create_blocklist(
+ directory, blocked_hosts=BLOCKLIST_HOSTS, name="hosts", line_format="one_per_line"
+):
+ """Return a path to a blocklist file.
+
+ Args:
+ directory: path object where to create the blocklist file
+ blocked_hosts: an iterable of string hosts to add to the blocklist
+ name: name to give to the blocklist file
+ line_format: 'etc_hosts' --> /etc/hosts format
+ 'one_per_line' --> one host per line format
+ 'not_correct' --> Not a correct hosts file format.
+ """
+ blocklist_file = directory / name
+ with blocklist_file.open("w", encoding="UTF-8") as blocklist:
+ # ensure comments are ignored when processing blocklist
+ blocklist.write("# Blocked Hosts List #\n\n")
+ if line_format == "etc_hosts": # /etc/hosts like format
+ for host in blocked_hosts:
+ blocklist.write("127.0.0.1 " + host + "\n")
+ elif line_format == "one_per_line":
+ for host in blocked_hosts:
+ blocklist.write(host + "\n")
+ elif line_format == "not_correct":
+ for host in blocked_hosts:
+ blocklist.write(host + " This is not a correct hosts file\n")
+ else:
+ raise ValueError("Incorrect line_format argument")
+ return name
+
+
+def assert_urls(
+ host_blocker,
+ blocked=BLOCKLIST_HOSTS,
+ whitelisted=WHITELISTED_HOSTS,
+ urls_to_check=URLS_TO_CHECK,
+):
+ """Test if Urls to check are blocked or not by HostBlocker.
+
+ Ensure URLs in 'blocked' and not in 'whitelisted' are blocked.
+ All other URLs must not be blocked.
+
+ localhost is an example of a special case that shouldn't be blocked.
+ """
+ whitelisted = list(whitelisted) + ["localhost"]
+ for str_url in urls_to_check:
+ url = QUrl(str_url)
+ host = url.host()
+ if host in blocked and host not in whitelisted:
+ assert host_blocker._is_blocked(url)
+ else:
+ assert not host_blocker._is_blocked(url)
+
+
+def blocklist_to_url(filename):
+ """Get an example.com-URL with the given filename as path."""
+ assert not os.path.isabs(filename), filename
+ url = QUrl("http://example.com/")
+ url.setPath("/" + filename)
+ assert url.isValid(), url.errorString()
+ return url
+
+
+def generic_blocklists(directory):
+ """Return a generic list of files to be used in hosts-block-lists option.
+
+ This list contains :
+ - a remote zip file with 1 hosts file and 2 useless files
+ - a remote zip file with only useless files
+ (Should raise a FileNotFoundError)
+ - a remote zip file with only one valid hosts file
+ - a local text file with valid hosts
+ - a remote text file without valid hosts format.
+ """
+ # remote zip file with 1 hosts file and 2 useless files
+ file1 = create_blocklist(
+ directory, blocked_hosts=CLEAN_HOSTS, name="README", line_format="not_correct"
+ )
+ file2 = create_blocklist(
+ directory,
+ blocked_hosts=BLOCKLIST_HOSTS[:3],
+ name="hosts",
+ line_format="etc_hosts",
+ )
+ file3 = create_blocklist(
+ directory,
+ blocked_hosts=CLEAN_HOSTS,
+ name="false_positive",
+ line_format="one_per_line",
+ )
+ files_to_zip = [file1, file2, file3]
+ blocklist1 = blocklist_to_url(create_zipfile(directory, files_to_zip, "block1"))
+
+ # remote zip file without file named hosts
+ # (Should raise a FileNotFoundError)
+ file1 = create_blocklist(
+ directory, blocked_hosts=CLEAN_HOSTS, name="md5sum", line_format="etc_hosts"
+ )
+ file2 = create_blocklist(
+ directory, blocked_hosts=CLEAN_HOSTS, name="README", line_format="not_correct"
+ )
+ file3 = create_blocklist(
+ directory,
+ blocked_hosts=CLEAN_HOSTS,
+ name="false_positive",
+ line_format="one_per_line",
+ )
+ files_to_zip = [file1, file2, file3]
+ blocklist2 = blocklist_to_url(create_zipfile(directory, files_to_zip, "block2"))
+
+ # remote zip file with only one valid hosts file inside
+ file1 = create_blocklist(
+ directory,
+ blocked_hosts=[BLOCKLIST_HOSTS[3]],
+ name="malwarelist",
+ line_format="etc_hosts",
+ )
+ blocklist3 = blocklist_to_url(create_zipfile(directory, [file1], "block3"))
+
+ # local text file with valid hosts
+ blocklist4 = QUrl.fromLocalFile(
+ str(
+ directory
+ / create_blocklist(
+ directory,
+ blocked_hosts=[BLOCKLIST_HOSTS[4]],
+ name="mycustomblocklist",
+ line_format="one_per_line",
+ )
+ )
+ )
+ assert blocklist4.isValid(), blocklist4.errorString()
+
+ # remote text file without valid hosts format
+ blocklist5 = blocklist_to_url(
+ create_blocklist(
+ directory,
+ blocked_hosts=CLEAN_HOSTS,
+ name="notcorrectlist",
+ line_format="not_correct",
+ )
+ )
+
+ return [
+ blocklist1.toString(),
+ blocklist2.toString(),
+ blocklist3.toString(),
+ blocklist4.toString(),
+ blocklist5.toString(),
+ ]
+
+
+@pytest.mark.parametrize(
+ "blocking_enabled, method",
+ [
+ # Assuming the adblock dependency is installed
+ (True, "auto"),
+ (True, "adblock"),
+ (False, "auto"),
+ (False, "adblock"),
+ (False, "both"),
+ (False, "hosts"),
+ ],
+)
+def test_disabled_blocking_update(
+ config_stub, tmpdir, caplog, host_blocker_factory, blocking_enabled, method
+):
+ """Ensure no URL is blocked when host blocking should be disabled."""
+ if blocking_enabled and method == 'auto':
+ pytest.importorskip('adblock')
+
+ config_stub.val.content.blocking.hosts.lists = generic_blocklists(tmpdir)
+ config_stub.val.content.blocking.enabled = blocking_enabled
+ config_stub.val.content.blocking.method = method
+
+ host_blocker = host_blocker_factory()
+ downloads = host_blocker.adblock_update()
+ while downloads._in_progress:
+ current_download = downloads._in_progress[0]
+ with caplog.at_level(logging.ERROR):
+ current_download.successful = True
+ current_download.finished.emit()
+ host_blocker.read_hosts()
+ for str_url in URLS_TO_CHECK:
+ assert not host_blocker._is_blocked(QUrl(str_url))
+
+
+def test_disabled_blocking_per_url(config_stub, host_blocker_factory):
+ example_com = "https://www.example.com/"
+
+ config_stub.val.content.blocking.method = "hosts"
+ config_stub.val.content.blocking.hosts.lists = []
+ pattern = urlmatch.UrlPattern(example_com)
+ config_stub.set_obj("content.blocking.enabled", False, pattern=pattern)
+
+ url = QUrl("blocked.example.com")
+
+ host_blocker = host_blocker_factory()
+ host_blocker._blocked_hosts.add(url.host())
+
+ assert host_blocker._is_blocked(url)
+ assert not host_blocker._is_blocked(url, first_party_url=QUrl(example_com))
+
+
+def test_no_blocklist_update(config_stub, download_stub, host_blocker_factory):
+ """Ensure no URL is blocked when no block list exists."""
+ config_stub.val.content.blocking.hosts.lists = None
+ config_stub.val.content.blocking.method = "hosts"
+ config_stub.val.content.blocking.enabled = True
+
+ host_blocker = host_blocker_factory()
+ host_blocker.adblock_update()
+ host_blocker.read_hosts()
+ for dl in download_stub.downloads:
+ dl.successful = True
+ for str_url in URLS_TO_CHECK:
+ assert not host_blocker._is_blocked(QUrl(str_url))
+
+
+def test_successful_update(config_stub, tmpdir, caplog, host_blocker_factory):
+ """Ensure hosts from host_blocking.lists are blocked after an update."""
+ config_stub.val.content.blocking.hosts.lists = generic_blocklists(tmpdir)
+ config_stub.val.content.blocking.method = "hosts"
+ config_stub.val.content.blocking.enabled = True
+ config_stub.val.content.blocking.whitelist = None
+
+ host_blocker = host_blocker_factory()
+ downloads = host_blocker.adblock_update()
+ # Simulate download is finished
+ while downloads._in_progress:
+ current_download = downloads._in_progress[0]
+ with caplog.at_level(logging.ERROR):
+ current_download.successful = True
+ current_download.finished.emit()
+ host_blocker.read_hosts()
+ assert_urls(host_blocker, whitelisted=[])
+
+
+def test_parsing_multiple_hosts_on_line(config_stub, host_blocker_factory):
+ """Ensure multiple hosts on a line get parsed correctly."""
+ config_stub.val.content.blocking.method = "hosts"
+ config_stub.val.content.blocking.enabled = True
+
+ host_blocker = host_blocker_factory()
+ bytes_host_line = " ".join(BLOCKLIST_HOSTS).encode("utf-8")
+ parsed_hosts = host_blocker._read_hosts_line(bytes_host_line)
+ host_blocker._blocked_hosts |= parsed_hosts
+ assert_urls(host_blocker, whitelisted=[])
+
+
+@pytest.mark.parametrize(
+ "ip, host",
+ [
+ ("127.0.0.1", "localhost"),
+ ("27.0.0.1", "localhost.localdomain"),
+ ("27.0.0.1", "local"),
+ ("55.255.255.255", "broadcasthost"),
+ (":1", "localhost"),
+ (":1", "ip6-localhost"),
+ (":1", "ip6-loopback"),
+ ("e80::1%lo0", "localhost"),
+ ("f00::0", "ip6-localnet"),
+ ("f00::0", "ip6-mcastprefix"),
+ ("f02::1", "ip6-allnodes"),
+ ("f02::2", "ip6-allrouters"),
+ ("ff02::3", "ip6-allhosts"),
+ (".0.0.0", "0.0.0.0"),
+ ("127.0.1.1", "myhostname"),
+ ("127.0.0.53", "myhostname"),
+ ],
+)
+def test_whitelisted_lines(host_blocker_factory, ip, host):
+ """Make sure we don't block hosts we don't want to."""
+ host_blocker = host_blocker_factory()
+ line = ("{} {}".format(ip, host)).encode("ascii")
+ parsed_hosts = host_blocker._read_hosts_line(line)
+ assert host not in parsed_hosts
+
+
+def test_failed_dl_update(config_stub, tmpdir, caplog, host_blocker_factory):
+ """One blocklist fails to download.
+
+ Ensure hosts from this list are not blocked.
+ """
+ dl_fail_blocklist = blocklist_to_url(
+ create_blocklist(
+ tmpdir,
+ blocked_hosts=CLEAN_HOSTS,
+ name="download_will_fail",
+ line_format="one_per_line",
+ )
+ )
+ hosts_to_block = generic_blocklists(tmpdir) + [dl_fail_blocklist.toString()]
+ config_stub.val.content.blocking.hosts.lists = hosts_to_block
+ config_stub.val.content.blocking.enabled = True
+ config_stub.val.content.blocking.method = "hosts"
+ config_stub.val.content.blocking.whitelist = None
+
+ host_blocker = host_blocker_factory()
+ downloads = host_blocker.adblock_update()
+ while downloads._in_progress:
+ current_download = downloads._in_progress[0]
+ # if current download is the file we want to fail, make it fail
+ if current_download.name == dl_fail_blocklist.path():
+ current_download.successful = False
+ else:
+ current_download.successful = True
+ with caplog.at_level(logging.ERROR):
+ current_download.finished.emit()
+ host_blocker.read_hosts()
+ assert_urls(host_blocker, whitelisted=[])
+
+
+@pytest.mark.parametrize("location", ["content", "comment"])
+def test_invalid_utf8(config_stub, tmpdir, caplog, host_blocker_factory, location):
+ """Make sure invalid UTF-8 is handled correctly.
+
+ See https://github.com/qutebrowser/qutebrowser/issues/2301
+ """
+ blocklist = tmpdir / "blocklist"
+ if location == "comment":
+ blocklist.write_binary(b"# nbsp: \xa0\n")
+ else:
+ assert location == "content"
+ blocklist.write_binary(b"https://www.example.org/\xa0")
+ for url in BLOCKLIST_HOSTS:
+ blocklist.write(url + "\n", mode="a")
+
+ url = blocklist_to_url("blocklist")
+ config_stub.val.content.blocking.hosts.lists = [url.toString()]
+ config_stub.val.content.blocking.enabled = True
+ config_stub.val.content.blocking.method = "hosts"
+ config_stub.val.content.blocking.whitelist = None
+
+ host_blocker = host_blocker_factory()
+ downloads = host_blocker.adblock_update()
+ current_download = downloads._in_progress[0]
+
+ if location == "content":
+ with caplog.at_level(logging.ERROR):
+ current_download.successful = True
+ current_download.finished.emit()
+ expected = r"Failed to decode: " r"b'https://www.example.org/\xa0localhost"
+ assert caplog.messages[-2].startswith(expected)
+ else:
+ current_download.successful = True
+ current_download.finished.emit()
+
+ host_blocker.read_hosts()
+ assert_urls(host_blocker, whitelisted=[])
+
+
+def test_invalid_utf8_compiled(
+ config_stub, config_tmpdir, data_tmpdir, monkeypatch, caplog, host_blocker_factory
+):
+ """Make sure invalid UTF-8 in the compiled file is handled."""
+ config_stub.val.content.blocking.hosts.lists = []
+
+ # Make sure the HostBlocker doesn't delete blocked-hosts in __init__
+ monkeypatch.setattr(hostblock.HostBlocker, "update_files", lambda _self: None)
+
+ (config_tmpdir / "blocked-hosts").write_binary(b"https://www.example.org/\xa0")
+ (data_tmpdir / "blocked-hosts").ensure()
+
+ host_blocker = host_blocker_factory()
+ with caplog.at_level(logging.ERROR):
+ host_blocker.read_hosts()
+ assert caplog.messages[-1] == "Failed to read host blocklist!"
+
+
+def test_blocking_with_whitelist(config_stub, data_tmpdir, host_blocker_factory):
+ """Ensure hosts in content.blocking.whitelist are never blocked."""
+ # Simulate adblock_update has already been run
+ # by creating a file named blocked-hosts,
+ # Exclude localhost from it as localhost is never blocked via list
+ filtered_blocked_hosts = BLOCKLIST_HOSTS[1:]
+ blocklist = create_blocklist(
+ data_tmpdir,
+ blocked_hosts=filtered_blocked_hosts,
+ name="blocked-hosts",
+ line_format="one_per_line",
+ )
+ config_stub.val.content.blocking.hosts.lists = [blocklist]
+ config_stub.val.content.blocking.enabled = True
+ config_stub.val.content.blocking.method = "hosts"
+ config_stub.val.content.blocking.whitelist = list(WHITELISTED_HOSTS)
+
+ host_blocker = host_blocker_factory()
+ host_blocker.read_hosts()
+ assert_urls(host_blocker)
+
+
+def test_config_change_initial(config_stub, tmpdir, host_blocker_factory):
+ """Test emptying host_blocking.lists with existing blocked_hosts.
+
+ - A blocklist is present in host_blocking.lists and blocked_hosts is
+ populated
+ - User quits qutebrowser, empties host_blocking.lists from his config
+ - User restarts qutebrowser, does adblock-update
+ """
+ create_blocklist(
+ tmpdir,
+ blocked_hosts=BLOCKLIST_HOSTS,
+ name="blocked-hosts",
+ line_format="one_per_line",
+ )
+ config_stub.val.content.blocking.hosts.lists = None
+ config_stub.val.content.blocking.enabled = True
+ config_stub.val.content.blocking.method = "hosts"
+ config_stub.val.content.blocking.whitelist = None
+
+ host_blocker = host_blocker_factory()
+ host_blocker.read_hosts()
+ for str_url in URLS_TO_CHECK:
+ assert not host_blocker._is_blocked(QUrl(str_url))
+
+
+def test_config_change(config_stub, tmpdir, host_blocker_factory):
+ """Ensure blocked-hosts resets if host-block-list is changed to None."""
+ filtered_blocked_hosts = BLOCKLIST_HOSTS[1:] # Exclude localhost
+ blocklist = blocklist_to_url(
+ create_blocklist(
+ tmpdir,
+ blocked_hosts=filtered_blocked_hosts,
+ name="blocked-hosts",
+ line_format="one_per_line",
+ )
+ )
+ config_stub.val.content.blocking.hosts.lists = [blocklist.toString()]
+ config_stub.val.content.blocking.enabled = True
+ config_stub.val.content.blocking.method = "hosts"
+ config_stub.val.content.blocking.whitelist = None
+
+ host_blocker = host_blocker_factory()
+ host_blocker.read_hosts()
+ config_stub.val.content.blocking.hosts.lists = None
+ host_blocker.read_hosts()
+ for str_url in URLS_TO_CHECK:
+ assert not host_blocker._is_blocked(QUrl(str_url))
+
+
+def test_add_directory(config_stub, tmpdir, host_blocker_factory):
+ """Ensure adblocker can import all files in a directory."""
+ blocklist_hosts2 = []
+ for i in BLOCKLIST_HOSTS[1:]:
+ blocklist_hosts2.append("1" + i)
+
+ create_blocklist(
+ tmpdir,
+ blocked_hosts=BLOCKLIST_HOSTS,
+ name="blocked-hosts",
+ line_format="one_per_line",
+ )
+ create_blocklist(
+ tmpdir,
+ blocked_hosts=blocklist_hosts2,
+ name="blocked-hosts2",
+ line_format="one_per_line",
+ )
+
+ config_stub.val.content.blocking.hosts.lists = [tmpdir.strpath]
+ config_stub.val.content.blocking.enabled = True
+ config_stub.val.content.blocking.method = "hosts"
+ host_blocker = host_blocker_factory()
+ host_blocker.adblock_update()
+ assert len(host_blocker._blocked_hosts) == len(blocklist_hosts2) * 2
+
+
+def test_adblock_benchmark(data_tmpdir, benchmark, host_blocker_factory):
+ blocked_hosts = data_tmpdir / "blocked-hosts"
+ blocked_hosts.write_text("\n".join(utils.blocked_hosts()), encoding="utf-8")
+
+ url = QUrl("https://www.example.org/")
+ blocker = host_blocker_factory()
+ blocker.read_hosts()
+ assert blocker._blocked_hosts
+
+ benchmark(lambda: blocker._is_blocked(url))
diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py
index f1479bda1..dc62717e5 100644
--- a/tests/unit/config/test_configcommands.py
+++ b/tests/unit/config/test_configcommands.py
@@ -212,6 +212,14 @@ class TestSet:
commands.set(win_id=0, option='foo?')
+def test_diff(commands, tabbed_browser_stubs):
+ """Run ':config-diff'.
+
+ Should open qute://configdiff."""
+ commands.config_diff(win_id=0)
+ assert tabbed_browser_stubs[0].loaded_url == QUrl('qute://configdiff')
+
+
class TestCycle:
"""Test :config-cycle."""
@@ -289,7 +297,7 @@ class TestAdd:
@pytest.mark.parametrize('temp', [True, False])
@pytest.mark.parametrize('value', ['test1', 'test2'])
def test_list_add(self, commands, config_stub, yaml_value, temp, value):
- name = 'content.host_blocking.whitelist'
+ name = 'content.blocking.whitelist'
commands.config_list_add(name, value, temp=temp)
@@ -316,7 +324,7 @@ class TestAdd:
with pytest.raises(
cmdutils.CommandError,
match="Invalid value '{}'".format(value)):
- commands.config_list_add('content.host_blocking.whitelist', value)
+ commands.config_list_add('content.blocking.whitelist', value)
@pytest.mark.parametrize('value', ['test1', 'test2'])
@pytest.mark.parametrize('temp', [True, False])
@@ -489,7 +497,8 @@ class TestSource:
else:
assert False, location
- pyfile.write_text('c.content.javascript.enabled = False\n',
+ pyfile.write_text('\n'.join(['config.load_autoconfig(False)',
+ 'c.content.javascript.enabled = False']),
encoding='utf-8')
commands.config_source(arg, clear=clear)
@@ -501,14 +510,16 @@ class TestSource:
def test_config_py_arg_source(self, commands, config_py_arg, config_stub):
assert config_stub.val.content.javascript.enabled
- config_py_arg.write_text('c.content.javascript.enabled = False\n',
+ config_py_arg.write_text('\n'.join(['config.load_autoconfig(False)',
+ 'c.content.javascript.enabled = False']),
encoding='utf-8')
commands.config_source()
assert not config_stub.val.content.javascript.enabled
def test_errors(self, commands, config_tmpdir):
pyfile = config_tmpdir / 'config.py'
- pyfile.write_text('c.foo = 42', encoding='utf-8')
+ pyfile.write_text('\n'.join(['config.load_autoconfig(False)',
+ 'c.foo = 42']), encoding='utf-8')
with pytest.raises(cmdutils.CommandError) as excinfo:
commands.config_source()
@@ -519,7 +530,8 @@ class TestSource:
def test_invalid_source(self, commands, config_tmpdir):
pyfile = config_tmpdir / 'config.py'
- pyfile.write_text('1/0', encoding='utf-8')
+ pyfile.write_text('\n'.join(['config.load_autoconfig(False)',
+ '1/0']), encoding='utf-8')
with pytest.raises(cmdutils.CommandError) as excinfo:
commands.config_source()
@@ -561,7 +573,8 @@ class TestEdit:
def test_with_sourcing(self, commands, config_stub, patch_editor):
assert config_stub.val.content.javascript.enabled
- mock = patch_editor('c.content.javascript.enabled = False')
+ mock = patch_editor('\n'.join(['config.load_autoconfig(False)',
+ 'c.content.javascript.enabled = False']))
commands.config_edit()
@@ -570,16 +583,16 @@ class TestEdit:
def test_config_py_with_sourcing(self, commands, config_stub, patch_editor, config_py_arg):
assert config_stub.val.content.javascript.enabled
- conf = 'c.content.javascript.enabled = False'
- mock = patch_editor(conf)
+ conf = ['config.load_autoconfig(False)', 'c.content.javascript.enabled = False']
+ mock = patch_editor("\n".join(conf))
commands.config_edit()
mock.assert_called_once_with(unittest.mock.ANY)
assert not config_stub.val.content.javascript.enabled
- assert config_py_arg.read_text('utf-8').splitlines() == [conf]
+ assert config_py_arg.read_text('utf-8').splitlines() == conf
def test_error(self, commands, config_stub, patch_editor, message_mock,
caplog):
- patch_editor('c.foo = 42')
+ patch_editor('\n'.join(['config.load_autoconfig(False)', 'c.foo = 42']))
with caplog.at_level(logging.ERROR):
commands.config_edit()
@@ -625,6 +638,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..ae17cd51b 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):
@@ -279,7 +289,7 @@ class TestParseYamlBackend:
data = self._yaml("""
backend:
QtWebKit: {}
- QtWebEngine: Qt 5.8
+ QtWebEngine: Qt 5.15
""".format('true' if webkit else 'false'))
monkeypatch.setattr(configdata.qtutils, 'version_check',
lambda v: has_new_version)
diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py
index f7512e2a6..a21fa4e71 100644
--- a/tests/unit/config/test_configfiles.py
+++ b/tests/unit/config/test_configfiles.py
@@ -28,7 +28,7 @@ from PyQt5.QtCore import QSettings
from qutebrowser.config import (config, configfiles, configexc, configdata,
configtypes)
-from qutebrowser.utils import utils, usertypes, urlmatch
+from qutebrowser.utils import utils, usertypes, urlmatch, standarddir
from qutebrowser.keyinput import keyutils
@@ -83,6 +83,8 @@ def autoconfig(config_tmpdir):
'version = 1.2.3\n'
'\n'
'[geometry]\n'
+ '\n'
+ '[inspector]\n'
'\n'),
('[general]\n'
'fooled = true',
@@ -92,6 +94,8 @@ def autoconfig(config_tmpdir):
'version = 1.2.3\n'
'\n'
'[geometry]\n'
+ '\n'
+ '[inspector]\n'
'\n'),
('[general]\n'
'foobar = 42',
@@ -102,6 +106,8 @@ def autoconfig(config_tmpdir):
'version = 1.2.3\n'
'\n'
'[geometry]\n'
+ '\n'
+ '[inspector]\n'
'\n'),
(None,
True,
@@ -111,6 +117,8 @@ def autoconfig(config_tmpdir):
'newval = 23\n'
'\n'
'[geometry]\n'
+ '\n'
+ '[inspector]\n'
'\n'),
])
def test_state_config(fake_save_manager, data_tmpdir, monkeypatch,
@@ -322,6 +330,18 @@ class TestYaml:
assert str(error.exception).splitlines()[0] == exception
assert error.traceback is None
+ @pytest.mark.parametrize('value', [
+ 42, # value is not a dict
+ {'https://': True}, # Invalid pattern
+ {True: True}, # No string pattern
+ ])
+ def test_invalid_in_migrations(self, value, yaml, autoconfig):
+ """Make sure migrations work fine with an invalid structure."""
+ config = {key: value for key in configdata.DATA}
+ autoconfig.write(config)
+ with pytest.raises(configexc.ConfigFileErrors):
+ yaml.load()
+
def test_legacy_migration(self, yaml, autoconfig, qtbot):
autoconfig.write_toplevel({
'config_version': 1,
@@ -501,7 +521,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 +598,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 +606,51 @@ 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
+
+ def test_content_media_capture(self, yaml, autoconfig):
+ val = 'ask'
+ autoconfig.write({'content.media_capture': {'global': val}})
+
+ yaml.load()
+ yaml._save()
+
+ data = autoconfig.read()
+ for setting in ['content.media.audio_capture',
+ 'content.media.audio_video_capture',
+ 'content.media.video_capture']:
+ assert data[setting]['global'] == val
+
+ def test_empty_pattern(self, yaml, autoconfig):
+ valid_pattern = 'https://example.com/*'
+ invalid_pattern = '*://*./*'
+ setting = 'content.javascript.enabled'
+
+ autoconfig.write({
+ setting: {
+ 'global': False,
+ invalid_pattern: True,
+ valid_pattern: True,
+ }
+ })
+
+ yaml.load()
+ yaml._save()
+
+ data = autoconfig.read()
+ assert not data[setting]['global']
+ assert invalid_pattern not in data[setting]
+ assert data[setting][valid_pattern]
+
class ConfPy:
@@ -596,6 +659,7 @@ class ConfPy:
def __init__(self, tmpdir, filename: str = "config.py"):
self._file = tmpdir / filename
self.filename = str(self._file)
+ config.instance.warn_autoconfig = False
def write(self, *lines):
text = '\n'.join(lines)
@@ -744,9 +808,7 @@ class TestConfigPy:
])
def test_get(self, confpy, set_first, get_line):
"""Test whether getting options works correctly."""
- # pylint: disable=bad-config-option
- config.val.colors.hints.fg = 'green'
- # pylint: enable=bad-config-option
+ config.val.colors.hints.fg = 'green' # pylint: disable=bad-config-option
if set_first:
confpy.write('c.colors.hints.fg = "red"',
'assert {} == "red"'.format(get_line))
@@ -810,12 +872,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 +949,22 @@ 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_load_autoconfig_warning(self, confpy):
+ confpy.write('')
+ config.instance.warn_autoconfig = True
+ with pytest.raises(configexc.ConfigFileErrors) as excinfo:
+ configfiles.read_config_py(confpy.filename)
+ assert len(excinfo.value.errors) == 1
+ error = excinfo.value.errors[0]
+ assert error.text == "autoconfig loading not specified"
+ exception_text = ('Your config.py should call either `config.load_autoconfig()`'
+ ' (to load settings configured via the GUI) or '
+ '`config.load_autoconfig(False)` (to not do so)')
+ assert str(error.exception) == exception_text
def test_unhandled_exception(self, confpy):
confpy.write("1/0")
@@ -1001,6 +1076,24 @@ class TestConfigPy:
assert not config.instance.get_obj('content.javascript.enabled')
+ def test_source_configpy_arg(self, tmpdir, data_tmpdir, monkeypatch):
+ alt_filename = 'alt-config.py'
+
+ alt_confpy_dir = tmpdir / 'alt-confpy-dir'
+ alt_confpy_dir.ensure(dir=True)
+ monkeypatch.setattr(standarddir, 'config_py',
+ lambda: str(alt_confpy_dir / alt_filename))
+
+ subfile = alt_confpy_dir / 'subfile.py'
+ subfile.write_text("c.content.javascript.enabled = False",
+ encoding='utf-8')
+
+ alt_confpy = ConfPy(alt_confpy_dir, alt_filename)
+ alt_confpy.write("config.source('subfile.py')")
+ alt_confpy.read()
+
+ assert not config.instance.get_obj('content.javascript.enabled')
+
def test_source_errors(self, tmpdir, confpy):
subfile = tmpdir / 'config' / 'subfile.py'
subfile.write_text("c.foo = 42", encoding='utf-8')
@@ -1052,12 +1145,19 @@ 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
- # Uncomment this to still load settings configured via autoconfig.yml
- # config.load_autoconfig()
+ # Change the argument to True to still load settings configured via autoconfig.yml
+ config.load_autoconfig(False)
# This is an option description. Nullam eu ante vel est convallis
# dignissim. Fusce suscipit, wisi nec facilisis facilisis, est dui
@@ -1109,7 +1209,7 @@ class TestConfigPyWriter:
lines = list(writer._gen_lines())
assert "## Autogenerated config.py" in lines
- assert "# config.load_autoconfig()" in lines
+ assert "# config.load_autoconfig(True)" in lines
assert "# c.opt = 'val'" in lines
assert "## Bindings for normal mode" in lines
assert "# config.bind(',x', 'message-info normal')" in lines
@@ -1156,17 +1256,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(False)'
+ 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..23cc890e4 100644
--- a/tests/unit/config/test_configinit.py
+++ b/tests/unit/config/test_configinit.py
@@ -18,18 +18,15 @@
"""Tests for qutebrowser.config.configinit."""
-import os
-import sys
+import builtins
import logging
import unittest.mock
import pytest
-from qutebrowser import qutebrowser
from qutebrowser.config import (config, configexc, configfiles, configinit,
configdata, configtypes)
from qutebrowser.utils import objreg, usertypes
-from helpers import utils
@pytest.fixture
@@ -40,8 +37,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')
@@ -67,7 +64,8 @@ def configdata_init(monkeypatch):
class TestEarlyInit:
def test_config_py_path(self, args, init_patch, config_py_arg):
- config_py_arg.write('c.colors.hints.bg = "red"\n')
+ config_py_arg.write('\n'.join(['config.load_autoconfig()',
+ 'c.colors.hints.bg = "red"']))
configinit.early_init(args)
expected = 'colors.hints.bg = red'
assert config.instance.dump_userconfig() == expected
@@ -79,7 +77,8 @@ class TestEarlyInit:
config_py_file = config_tmpdir / 'config.py'
if config_py:
- config_py_lines = ['c.colors.hints.bg = "red"']
+ config_py_lines = ['c.colors.hints.bg = "red"',
+ 'config.load_autoconfig(False)']
if config_py == 'error':
config_py_lines.append('c.foo = 42')
config_py_file.write_text('\n'.join(config_py_lines),
@@ -112,7 +111,7 @@ class TestEarlyInit:
expected = '<Default configuration>'
assert config.instance.dump_userconfig() == expected
- @pytest.mark.parametrize('load_autoconfig', [True, False]) # noqa
+ @pytest.mark.parametrize('load_autoconfig', [True, False])
@pytest.mark.parametrize('config_py', [True, 'error', False])
@pytest.mark.parametrize('invalid_yaml', ['42', 'list', 'unknown',
'wrong-type', False])
@@ -151,8 +150,7 @@ class TestEarlyInit:
if config_py:
config_py_lines = ['c.colors.hints.bg = "red"']
- if load_autoconfig:
- config_py_lines.append('config.load_autoconfig()')
+ config_py_lines.append('config.load_autoconfig({})'.format(load_autoconfig))
if config_py == 'error':
config_py_lines.append('c.foo = 42')
config_py_file.write_text('\n'.join(config_py_lines),
@@ -238,63 +236,6 @@ class TestEarlyInit:
assert msg.level == usertypes.MessageLevel.error
assert msg.text == "set: NoOptionError - No option 'foo'"
- @pytest.mark.parametrize('config_opt, config_val, envvar, expected', [
- ('qt.force_software_rendering', 'software-opengl',
- 'QT_XCB_FORCE_SOFTWARE_OPENGL', '1'),
- ('qt.force_software_rendering', 'qt-quick',
- 'QT_QUICK_BACKEND', 'software'),
- ('qt.force_software_rendering', 'chromium',
- 'QT_WEBENGINE_DISABLE_NOUVEAU_WORKAROUND', '1'),
- ('qt.force_platform', 'toaster', 'QT_QPA_PLATFORM', 'toaster'),
- ('qt.force_platformtheme', 'lxde', 'QT_QPA_PLATFORMTHEME', 'lxde'),
- ('window.hide_decoration', True,
- 'QT_WAYLAND_DISABLE_WINDOWDECORATION', '1')
- ])
- def test_env_vars(self, monkeypatch, config_stub,
- config_opt, config_val, envvar, expected):
- """Check settings which set an environment variable."""
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebEngine)
- monkeypatch.setenv(envvar, '') # to make sure it gets restored
- monkeypatch.delenv(envvar)
-
- config_stub.set_obj(config_opt, config_val)
- configinit._init_envvars()
-
- assert os.environ[envvar] == expected
-
- @pytest.mark.parametrize('new_qt', [True, False])
- def test_highdpi(self, monkeypatch, config_stub, new_qt):
- """Test HighDPI environment variables.
-
- Depending on the Qt version, there's a different variable which should
- be set...
- """
- new_var = 'QT_ENABLE_HIGHDPI_SCALING'
- old_var = 'QT_AUTO_SCREEN_SCALE_FACTOR'
-
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebEngine)
- monkeypatch.setattr(configinit.qtutils, 'version_check',
- lambda version, exact=False, compiled=True:
- new_qt)
-
- for envvar in [new_var, old_var]:
- monkeypatch.setenv(envvar, '') # to make sure it gets restored
- monkeypatch.delenv(envvar)
-
- config_stub.set_obj('qt.highdpi', True)
- configinit._init_envvars()
-
- envvar = new_var if new_qt else old_var
-
- assert os.environ[envvar] == '1'
-
- def test_env_vars_webkit(self, monkeypatch, config_stub):
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebKit)
- configinit._init_envvars()
-
class TestLateInit:
@@ -344,12 +285,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'),
])
@@ -373,6 +312,7 @@ class TestLateInit:
elif method == 'py':
config_py_file = config_tmpdir / 'config.py'
lines = ["c.{} = '{}'".format(k, v) for k, v in settings]
+ lines.append("config.load_autoconfig(False)")
config_py_file.write_text('\n'.join(lines), 'utf-8', ensure=True)
configinit.early_init(args)
@@ -381,10 +321,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 +341,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
@@ -442,270 +374,6 @@ class TestLateInit:
assert 'fonts.hints' in changed_options
-class TestQtArgs:
-
- @pytest.fixture
- def parser(self, mocker):
- """Fixture to provide an argparser.
-
- Monkey-patches .exit() of the argparser so it doesn't exit on errors.
- """
- parser = qutebrowser.get_argparser()
- mocker.patch.object(parser, 'exit', side_effect=Exception)
- return parser
-
- @pytest.fixture(autouse=True)
- def reduce_args(self, monkeypatch, config_stub):
- """Make sure no --disable-shared-workers/referer argument get added."""
- monkeypatch.setattr(configinit.qtutils, 'version_check',
- lambda version, compiled=False: True)
- config_stub.val.content.headers.referer = 'always'
-
- @pytest.mark.parametrize('args, expected', [
- # No Qt arguments
- (['--debug'], [sys.argv[0]]),
- # Qt flag
- (['--debug', '--qt-flag', 'reverse'], [sys.argv[0], '--reverse']),
- # Qt argument with value
- (['--qt-arg', 'stylesheet', 'foo'],
- [sys.argv[0], '--stylesheet', 'foo']),
- # --qt-arg given twice
- (['--qt-arg', 'stylesheet', 'foo', '--qt-arg', 'geometry', 'bar'],
- [sys.argv[0], '--stylesheet', 'foo', '--geometry', 'bar']),
- # --qt-flag given twice
- (['--qt-flag', 'foo', '--qt-flag', 'bar'],
- [sys.argv[0], '--foo', '--bar']),
- ])
- def test_qt_args(self, config_stub, args, expected, parser):
- """Test commandline with no Qt arguments given."""
- parsed = parser.parse_args(args)
- assert configinit.qt_args(parsed) == expected
-
- def test_qt_both(self, config_stub, parser):
- """Test commandline with a Qt argument and flag."""
- args = parser.parse_args(['--qt-arg', 'stylesheet', 'foobar',
- '--qt-flag', 'reverse'])
- qt_args = configinit.qt_args(args)
- assert qt_args[0] == sys.argv[0]
- assert '--reverse' in qt_args
- assert '--stylesheet' in qt_args
- assert 'foobar' in qt_args
-
- 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']
-
- @pytest.mark.parametrize('backend, expected', [
- (usertypes.Backend.QtWebEngine, True),
- (usertypes.Backend.QtWebKit, False),
- ])
- def test_shared_workers(self, config_stub, monkeypatch, parser,
- backend, expected):
- monkeypatch.setattr(configinit.qtutils, 'version_check',
- lambda version, compiled=False: False)
- monkeypatch.setattr(configinit.objects, 'backend', backend)
- parsed = parser.parse_args([])
- args = configinit.qt_args(parsed)
- assert ('--disable-shared-workers' in args) == expected
-
- @pytest.mark.parametrize('backend, version_check, debug_flag, expected', [
- # Qt >= 5.12.3: Enable with -D stack, do nothing without it.
- (usertypes.Backend.QtWebEngine, True, True, True),
- (usertypes.Backend.QtWebEngine, True, False, None),
- # Qt < 5.12.3: Do nothing with -D stack, disable without it.
- (usertypes.Backend.QtWebEngine, False, True, None),
- (usertypes.Backend.QtWebEngine, False, False, False),
- # QtWebKit: Do nothing
- (usertypes.Backend.QtWebKit, True, True, None),
- (usertypes.Backend.QtWebKit, True, False, None),
- (usertypes.Backend.QtWebKit, False, True, None),
- (usertypes.Backend.QtWebKit, False, False, None),
- ])
- def test_in_process_stack_traces(self, monkeypatch, parser, backend,
- version_check, debug_flag, expected):
- monkeypatch.setattr(configinit.qtutils, 'version_check',
- lambda version, compiled=False: version_check)
- monkeypatch.setattr(configinit.objects, 'backend', backend)
- parsed = parser.parse_args(['--debug-flag', 'stack'] if debug_flag
- else [])
- args = configinit.qt_args(parsed)
-
- if expected is None:
- assert '--disable-in-process-stack-traces' not in args
- assert '--enable-in-process-stack-traces' not in args
- elif expected:
- assert '--disable-in-process-stack-traces' not in args
- assert '--enable-in-process-stack-traces' in args
- else:
- 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']),
- ])
- def test_chromium_debug(self, monkeypatch, parser, flags, expected):
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebEngine)
- parsed = parser.parse_args(flags)
- assert configinit.qt_args(parsed) == [sys.argv[0]] + expected
-
- def test_disable_gpu(self, config_stub, monkeypatch, parser):
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebEngine)
- config_stub.val.qt.force_software_rendering = 'chromium'
- parsed = parser.parse_args([])
- expected = [sys.argv[0], '--disable-gpu']
- assert configinit.qt_args(parsed) == expected
-
- @utils.qt510
- @pytest.mark.parametrize('new_version, autoplay, added', [
- (True, False, False), # new enough to not need it
- (False, True, False), # autoplay enabled
- (False, False, True),
- ])
- def test_autoplay(self, config_stub, monkeypatch, parser,
- new_version, autoplay, added):
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebEngine)
- config_stub.val.content.autoplay = autoplay
- monkeypatch.setattr(configinit.qtutils, 'version_check',
- lambda version, compiled=False: new_version)
-
- parsed = parser.parse_args([])
- args = configinit.qt_args(parsed)
- assert ('--autoplay-policy=user-gesture-required' in args) == added
-
- @utils.qt59
- @pytest.mark.parametrize('policy, arg', [
- ('all-interfaces', None),
-
- ('default-public-and-private-interfaces',
- '--force-webrtc-ip-handling-policy='
- 'default_public_and_private_interfaces'),
-
- ('default-public-interface-only',
- '--force-webrtc-ip-handling-policy='
- 'default_public_interface_only'),
-
- ('disable-non-proxied-udp',
- '--force-webrtc-ip-handling-policy='
- 'disable_non_proxied_udp'),
- ])
- def test_webrtc(self, config_stub, monkeypatch, parser,
- policy, arg):
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebEngine)
- config_stub.val.content.webrtc_ip_handling_policy = policy
-
- parsed = parser.parse_args([])
- args = configinit.qt_args(parsed)
-
- if arg is None:
- assert not any(a.startswith('--force-webrtc-ip-handling-policy=')
- for a in args)
- else:
- assert arg in args
-
- @pytest.mark.parametrize('canvas_reading, added', [
- (True, False), # canvas reading enabled
- (False, True),
- ])
- def test_canvas_reading(self, config_stub, monkeypatch, parser,
- canvas_reading, added):
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebEngine)
-
- config_stub.val.content.canvas_reading = canvas_reading
- parsed = parser.parse_args([])
- args = configinit.qt_args(parsed)
- assert ('--disable-reading-from-canvas' in args) == added
-
- @pytest.mark.parametrize('process_model, added', [
- ('process-per-site-instance', False),
- ('process-per-site', True),
- ('single-process', True),
- ])
- def test_process_model(self, config_stub, monkeypatch, parser,
- process_model, added):
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebEngine)
-
- config_stub.val.qt.process_model = process_model
- parsed = parser.parse_args([])
- args = configinit.qt_args(parsed)
-
- if added:
- assert '--' + process_model in args
- else:
- assert '--process-per-site' not in args
- assert '--single-process' not in args
- assert '--process-per-site-instance' not in args
- assert '--process-per-tab' not in args
-
- @pytest.mark.parametrize('low_end_device_mode, arg', [
- ('auto', None),
- ('always', '--enable-low-end-device-mode'),
- ('never', '--disable-low-end-device-mode'),
- ])
- def test_low_end_device_mode(self, config_stub, monkeypatch, parser,
- low_end_device_mode, arg):
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebEngine)
-
- config_stub.val.qt.low_end_device_mode = low_end_device_mode
- parsed = parser.parse_args([])
- args = configinit.qt_args(parsed)
-
- if arg is None:
- assert '--enable-low-end-device-mode' not in args
- assert '--disable-low-end-device-mode' not in args
- else:
- assert arg in args
-
- @pytest.mark.parametrize('referer, arg', [
- ('always', None),
- ('never', '--no-referrers'),
- ('same-domain', '--reduced-referrer-granularity'),
- ])
- def test_referer(self, config_stub, monkeypatch, parser, referer, arg):
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebEngine)
-
- config_stub.val.content.headers.referer = referer
- parsed = parser.parse_args([])
- args = configinit.qt_args(parsed)
-
- if arg is None:
- assert '--no-referrers' not in args
- assert '--reduced-referrer-granularity' not in args
- else:
- assert arg in args
-
- @pytest.mark.parametrize('dark, new_qt, added', [
- (True, True, True),
- (True, False, False),
- (False, True, False),
- (False, False, False),
- ])
- @utils.qt514
- def test_prefers_color_scheme_dark(self, config_stub, monkeypatch, parser,
- dark, new_qt, added):
- monkeypatch.setattr(configinit.objects, 'backend',
- usertypes.Backend.QtWebEngine)
- monkeypatch.setattr(configinit.qtutils, 'version_check',
- lambda version, exact=False, compiled=True:
- new_qt)
-
- config_stub.val.colors.webpage.prefers_color_scheme_dark = dark
-
- parsed = parser.parse_args([])
- args = configinit.qt_args(parsed)
-
- assert ('--force-dark-mode' in args) == added
-
-
@pytest.mark.parametrize('arg, confval, used', [
# overridden by commandline arg
('webkit', 'webengine', usertypes.Backend.QtWebKit),
@@ -723,6 +391,6 @@ def test_get_backend(monkeypatch, args, config_stub,
args.backend = arg
config_stub.val.backend = confval
- monkeypatch.setattr('builtins.__import__', fake_import)
+ monkeypatch.setattr(builtins, '__import__', fake_import)
assert configinit.get_backend(args) == used
diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py
index fe954ca3d..710018604 100644
--- a/tests/unit/config/test_configtypes.py
+++ b/tests/unit/config/test_configtypes.py
@@ -19,6 +19,7 @@
"""Tests for qutebrowser.config.configtypes."""
import re
+import sys
import json
import math
import warnings
@@ -63,28 +64,6 @@ class Font(QFont):
return utils.get_repr(self, **kwargs)
- @classmethod
- def fromdesc(cls, desc):
- """Get a Font based on a font description."""
- f = cls()
-
- f.setStyle(desc.style)
- f.setWeight(desc.weight)
-
- if desc.pt is not None and desc.pt != -1:
- f.setPointSize(desc.pt)
- if desc.px is not None and desc.pt != -1:
- f.setPixelSize(desc.px)
-
- f.setFamily(desc.family)
- try:
- f.setFamilies([desc.family])
- except AttributeError:
- # Added in Qt 5.13
- pass
-
- return f
-
class RegexEq:
@@ -159,10 +138,16 @@ class TestValidValues:
def test_descriptions(self, klass):
"""Test descriptions."""
- vv = klass(('foo', "foo desc"), ('bar', "bar desc"), 'baz')
- assert vv.descriptions['foo'] == "foo desc"
- assert vv.descriptions['bar'] == "bar desc"
- assert 'baz' not in vv.descriptions
+ vv = klass(
+ ('one-with', "desc 1"),
+ ('two-with', "desc 2"),
+ 'three-without',
+ ('four-without', None)
+ )
+ assert vv.descriptions['one-with'] == "desc 1"
+ assert vv.descriptions['two-with'] == "desc 2"
+ assert 'three-without' not in vv.descriptions
+ assert 'four-without' not in vv.descriptions
@pytest.mark.parametrize('args, expected', [
(['a', 'b'], "<qutebrowser.config.configtypes.ValidValues "
@@ -212,17 +197,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 +240,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,13 +255,11 @@ class TestAll:
configtypes.PercOrInt, # ditto
]:
return
- elif (isinstance(typ, functools.partial) and
- isinstance(typ.func, configtypes.ListOrValue)):
- # "- /" -> "/"
- return
- elif (isinstance(typ, configtypes.ListOrValue) and
- isinstance(typ.valtype, configtypes.Int)):
- # "00" -> "0"
+ elif (isinstance(klass, functools.partial) and klass.func in [
+ configtypes.ListOrValue, configtypes.List, configtypes.Dict]):
+ # ListOrValue: "- /" -> "/"
+ # List: "- /" -> ["/"]
+ # Dict: '{":": "A"}' -> ':: A'
return
assert converted == s
@@ -411,14 +402,11 @@ class MappingSubclass(configtypes.MappingType):
"""A MappingType we use in TestMappingType which is valid/good."""
MAPPING = {
- 'one': 1,
- 'two': 2,
+ 'one': (1, 'one doc'),
+ 'two': (2, 'two doc'),
+ 'three': (3, None),
}
- def __init__(self, none_ok=False):
- super().__init__(none_ok)
- self.valid_values = configtypes.ValidValues('one', 'two')
-
class TestMappingType:
@@ -444,11 +432,12 @@ class TestMappingType:
def test_to_str(self, klass):
assert klass().to_str('one') == 'one'
- @pytest.mark.parametrize('typ', [configtypes.ColorSystem(),
- configtypes.Position(),
- configtypes.SelectOnRemove()])
- def test_mapping_type_matches_valid_values(self, typ):
- assert sorted(typ.MAPPING) == sorted(typ.valid_values)
+ def test_valid_values(self, klass):
+ assert klass().valid_values == configtypes.ValidValues(
+ ('one', 'one doc'),
+ ('two', 'two doc'),
+ ('three', None),
+ )
class TestString:
@@ -1037,6 +1026,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:
@@ -1273,6 +1266,7 @@ class TestQtColor:
@pytest.mark.parametrize('val, expected', [
('#123', QColor('#123')),
('#112233', QColor('#112233')),
+ ('#44112233', QColor('#44112233')),
('#111222333', QColor('#111222333')),
('#111122223333', QColor('#111122223333')),
('red', QColor('red')),
@@ -1319,6 +1313,7 @@ class TestQssColor:
@pytest.mark.parametrize('val', [
'#123',
'#112233',
+ '#44112233',
'#111222333',
'#111122223333',
'red',
@@ -1367,8 +1362,6 @@ class FontDesc:
class TestFont:
- """Test Font/QtFont."""
-
TESTS = {
# (style, weight, pointsize, pixelsize, family
'"Foobar Neue"':
@@ -1416,52 +1409,13 @@ 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
-
@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),
@@ -1480,14 +1434,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:
@@ -1540,27 +1488,19 @@ class TestRegex:
@pytest.mark.parametrize('val', [
pytest.param(r'(foo|bar))?baz[fis]h', id='unmatched parens'),
pytest.param('(' * 500, id='too many parens'),
+ pytest.param(r'foo\Xbar', id='invalid escape X'),
+ pytest.param(r'foo\Cbar', id='invalid escape C'),
+ pytest.param(r'[[]]', id='nested set', marks=pytest.mark.skipif(
+ sys.hexversion < 0x03070000,
+ reason="Warning was added in Python 3.7")),
+ pytest.param(r'[a||b]', id='set operation', marks=pytest.mark.skipif(
+ sys.hexversion < 0x03070000,
+ reason="Warning was added in Python 3.7")),
])
def test_to_py_invalid(self, klass, val):
with pytest.raises(configexc.ValidationError):
klass().to_py(val)
- @pytest.mark.parametrize('val', [
- r'foo\Xbar',
- r'foo\Cbar',
- ])
- def test_to_py_maybe_valid(self, klass, val):
- """Those values are valid on some Python versions (and systems?).
-
- On others, they raise a DeprecationWarning because of an invalid
- escape. This tests makes sure this gets translated to a
- ValidationError.
- """
- try:
- klass().to_py(val)
- except configexc.ValidationError:
- pass
-
@pytest.mark.parametrize('warning', [
Warning('foo'), DeprecationWarning('foo'),
])
@@ -1576,20 +1516,6 @@ class TestRegex:
with pytest.raises(type(warning)):
regex.to_py('foo')
- def test_bad_pattern_warning(self, mocker, klass):
- """Test a simulated bad pattern warning.
-
- This only seems to happen with Python 3.5, so we simulate this for
- better coverage.
- """
- regex = klass()
- m = mocker.patch('qutebrowser.config.configtypes.re')
- m.compile.side_effect = lambda *args: warnings.warn(r'bad escape \C',
- DeprecationWarning)
- m.error = re.error
- with pytest.raises(configexc.ValidationError):
- regex.to_py('foo')
-
@pytest.mark.parametrize('flags, expected', [
(None, 0),
('IGNORECASE', re.IGNORECASE),
@@ -1770,10 +1696,6 @@ class TestFile:
def klass(self, request):
return request.param
- @pytest.fixture
- def file_class(self):
- return configtypes.File
-
def test_to_py_does_not_exist_file(self, os_mock):
"""Test to_py with a file which does not exist (File)."""
os_mock.path.isfile.return_value = False
@@ -2031,7 +1953,7 @@ class TestFuzzyUrl:
assert klass().to_py(val) == expected
@pytest.mark.parametrize('val', [
- '::foo', # invalid URL
+ '', # invalid URL
'foo bar', # invalid search term
])
def test_to_py_invalid(self, klass, val):
diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py
index 8dfa75609..4830340cf 100644
--- a/tests/unit/config/test_configutils.py
+++ b/tests/unit/config/test_configutils.py
@@ -21,9 +21,10 @@ import hypothesis
from hypothesis import strategies
import pytest
from PyQt5.QtCore import QUrl
+from PyQt5.QtWidgets import QLabel
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 +146,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
@@ -359,3 +365,45 @@ class TestFontFamilies:
assert family
str(families)
+
+ def test_system_default_basics(self, qapp):
+ families = configutils.FontFamilies.from_system_default()
+ assert len(families) == 1
+ assert str(families)
+
+ def test_system_default_rendering(self, qtbot):
+ families = configutils.FontFamilies.from_system_default()
+
+ label = QLabel()
+ qtbot.add_widget(label)
+ label.setText("Hello World")
+
+ stylesheet = f'font-family: {families.to_str(quote=True)}'
+ print(stylesheet)
+ label.setStyleSheet(stylesheet)
+
+ with qtbot.waitExposed(label):
+ # Needed so the font gets calculated
+ label.show()
+ info = label.fontInfo()
+
+ # Check the requested font to make sure CSS parsing worked
+ assert label.font().family() == families.family
+
+ # Try to find out whether the monospace font did a fallback on a non-monospace
+ # font...
+ fallback_label = QLabel()
+ qtbot.add_widget(label)
+ fallback_label.setText("fallback")
+
+ with qtbot.waitExposed(fallback_label):
+ # Needed so the font gets calculated
+ fallback_label.show()
+
+ fallback_family = fallback_label.fontInfo().family()
+ print(f'fallback: {fallback_family}')
+ if info.family() == fallback_family:
+ return
+
+ # If we didn't fall back, we should've gotten a fixed-pitch font.
+ assert info.fixedPitch(), info.family()
diff --git a/tests/unit/config/test_qtargs.py b/tests/unit/config/test_qtargs.py
new file mode 100644
index 000000000..b38fab41d
--- /dev/null
+++ b/tests/unit/config/test_qtargs.py
@@ -0,0 +1,502 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+# Copyright 2017-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 sys
+import os
+
+import pytest
+
+from qutebrowser import qutebrowser
+from qutebrowser.config import qtargs
+from qutebrowser.utils import usertypes
+from helpers import utils
+
+
+class TestQtArgs:
+
+ @pytest.fixture
+ def parser(self, mocker):
+ """Fixture to provide an argparser.
+
+ Monkey-patches .exit() of the argparser so it doesn't exit on errors.
+ """
+ parser = qutebrowser.get_argparser()
+ mocker.patch.object(parser, 'exit', side_effect=Exception)
+ return parser
+
+ @pytest.fixture(autouse=True)
+ def reduce_args(self, monkeypatch, config_stub):
+ """Make sure no --disable-shared-workers/referer argument get added."""
+ monkeypatch.setattr(qtargs.qtutils, 'qVersion', lambda: '5.15.0')
+ config_stub.val.content.headers.referer = 'always'
+
+ @pytest.mark.parametrize('args, expected', [
+ # No Qt arguments
+ (['--debug'], [sys.argv[0]]),
+ # Qt flag
+ (['--debug', '--qt-flag', 'reverse'], [sys.argv[0], '--reverse']),
+ # Qt argument with value
+ (['--qt-arg', 'stylesheet', 'foo'],
+ [sys.argv[0], '--stylesheet', 'foo']),
+ # --qt-arg given twice
+ (['--qt-arg', 'stylesheet', 'foo', '--qt-arg', 'geometry', 'bar'],
+ [sys.argv[0], '--stylesheet', 'foo', '--geometry', 'bar']),
+ # --qt-flag given twice
+ (['--qt-flag', 'foo', '--qt-flag', 'bar'],
+ [sys.argv[0], '--foo', '--bar']),
+ ])
+ def test_qt_args(self, monkeypatch, config_stub, args, expected, parser):
+ """Test commandline with no Qt arguments given."""
+ # Avoid scrollbar overlay argument
+ config_stub.val.scrolling.bar = 'never'
+ # Avoid WebRTC pipewire feature
+ monkeypatch.setattr(qtargs.utils, 'is_linux', False)
+
+ parsed = parser.parse_args(args)
+ assert qtargs.qt_args(parsed) == expected
+
+ def test_qt_both(self, config_stub, parser):
+ """Test commandline with a Qt argument and flag."""
+ args = parser.parse_args(['--qt-arg', 'stylesheet', 'foobar',
+ '--qt-flag', 'reverse'])
+ qt_args = qtargs.qt_args(args)
+ assert qt_args[0] == sys.argv[0]
+ assert '--reverse' in qt_args
+ assert '--stylesheet' in qt_args
+ assert 'foobar' in qt_args
+
+ def test_with_settings(self, config_stub, parser):
+ parsed = parser.parse_args(['--qt-flag', 'foo'])
+ config_stub.val.qt.args = ['bar']
+ args = qtargs.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),
+ (usertypes.Backend.QtWebKit, False),
+ ])
+ def test_shared_workers(self, config_stub, monkeypatch, parser,
+ backend, expected):
+ monkeypatch.setattr(qtargs.qtutils, 'qVersion', lambda: '5.14.0')
+ monkeypatch.setattr(qtargs.objects, 'backend', backend)
+ parsed = parser.parse_args([])
+ args = qtargs.qt_args(parsed)
+ assert ('--disable-shared-workers' in args) == expected
+
+ @pytest.mark.parametrize('backend, version_check, debug_flag, expected', [
+ # Qt >= 5.12.3: Enable with -D stack, do nothing without it.
+ (usertypes.Backend.QtWebEngine, True, True, True),
+ (usertypes.Backend.QtWebEngine, True, False, None),
+ # Qt < 5.12.3: Do nothing with -D stack, disable without it.
+ (usertypes.Backend.QtWebEngine, False, True, None),
+ (usertypes.Backend.QtWebEngine, False, False, False),
+ # QtWebKit: Do nothing
+ (usertypes.Backend.QtWebKit, True, True, None),
+ (usertypes.Backend.QtWebKit, True, False, None),
+ (usertypes.Backend.QtWebKit, False, True, None),
+ (usertypes.Backend.QtWebKit, False, False, None),
+ ])
+ def test_in_process_stack_traces(self, monkeypatch, parser, backend,
+ version_check, debug_flag, expected):
+ monkeypatch.setattr(qtargs.qtutils, 'version_check',
+ lambda version, compiled=False, exact=False: version_check)
+ monkeypatch.setattr(qtargs.objects, 'backend', backend)
+ parsed = parser.parse_args(['--debug-flag', 'stack'] if debug_flag
+ else [])
+ args = qtargs.qt_args(parsed)
+
+ if expected is None:
+ assert '--disable-in-process-stack-traces' not in args
+ assert '--enable-in-process-stack-traces' not in args
+ elif expected:
+ assert '--disable-in-process-stack-traces' not in args
+ assert '--enable-in-process-stack-traces' in args
+ else:
+ assert '--disable-in-process-stack-traces' in args
+ assert '--enable-in-process-stack-traces' not in args
+
+ @pytest.mark.parametrize('flags, args', [
+ ([], []),
+ (['--debug-flag', 'chromium'], ['--enable-logging', '--v=1']),
+ (['--debug-flag', 'wait-renderer-process'], ['--renderer-startup-dialog']),
+ ])
+ def test_chromium_flags(self, monkeypatch, parser, flags, args):
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+ parsed = parser.parse_args(flags)
+ args = qtargs.qt_args(parsed)
+
+ if args:
+ for arg in args:
+ assert arg in args
+ else:
+ assert '--enable-logging' not in args
+ assert '--v=1' not in args
+ assert '--renderer-startup-dialog' not in args
+
+ @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(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+ config_stub.val.qt.force_software_rendering = config
+ parsed = parser.parse_args([])
+ args = qtargs.qt_args(parsed)
+ assert ('--disable-gpu' in args) == added
+
+ @pytest.mark.parametrize('policy, arg', [
+ ('all-interfaces', None),
+
+ ('default-public-and-private-interfaces',
+ '--force-webrtc-ip-handling-policy='
+ 'default_public_and_private_interfaces'),
+
+ ('default-public-interface-only',
+ '--force-webrtc-ip-handling-policy='
+ 'default_public_interface_only'),
+
+ ('disable-non-proxied-udp',
+ '--force-webrtc-ip-handling-policy='
+ 'disable_non_proxied_udp'),
+ ])
+ def test_webrtc(self, config_stub, monkeypatch, parser,
+ policy, arg):
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+ config_stub.val.content.webrtc_ip_handling_policy = policy
+
+ parsed = parser.parse_args([])
+ args = qtargs.qt_args(parsed)
+
+ if arg is None:
+ assert not any(a.startswith('--force-webrtc-ip-handling-policy=')
+ for a in args)
+ else:
+ assert arg in args
+
+ @pytest.mark.parametrize('canvas_reading, added', [
+ (True, False), # canvas reading enabled
+ (False, True),
+ ])
+ def test_canvas_reading(self, config_stub, monkeypatch, parser,
+ canvas_reading, added):
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+
+ config_stub.val.content.canvas_reading = canvas_reading
+ parsed = parser.parse_args([])
+ args = qtargs.qt_args(parsed)
+ assert ('--disable-reading-from-canvas' in args) == added
+
+ @pytest.mark.parametrize('process_model, added', [
+ ('process-per-site-instance', False),
+ ('process-per-site', True),
+ ('single-process', True),
+ ])
+ def test_process_model(self, config_stub, monkeypatch, parser,
+ process_model, added):
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+
+ config_stub.val.qt.process_model = process_model
+ parsed = parser.parse_args([])
+ args = qtargs.qt_args(parsed)
+
+ if added:
+ assert '--' + process_model in args
+ else:
+ assert '--process-per-site' not in args
+ assert '--single-process' not in args
+ assert '--process-per-site-instance' not in args
+ assert '--process-per-tab' not in args
+
+ @pytest.mark.parametrize('low_end_device_mode, arg', [
+ ('auto', None),
+ ('always', '--enable-low-end-device-mode'),
+ ('never', '--disable-low-end-device-mode'),
+ ])
+ def test_low_end_device_mode(self, config_stub, monkeypatch, parser,
+ low_end_device_mode, arg):
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+
+ config_stub.val.qt.low_end_device_mode = low_end_device_mode
+ parsed = parser.parse_args([])
+ args = qtargs.qt_args(parsed)
+
+ if arg is None:
+ assert '--enable-low-end-device-mode' not in args
+ assert '--disable-low-end-device-mode' not in args
+ else:
+ assert arg in args
+
+ @pytest.mark.parametrize('qt_version, referer, arg', [
+ # 'always' -> no arguments
+ ('5.15.0', 'always', None),
+
+ # 'never' is handled via interceptor for most Qt versions
+ ('5.12.3', 'never', '--no-referrers'),
+ ('5.12.4', 'never', None),
+ ('5.13.0', 'never', '--no-referrers'),
+ ('5.13.1', 'never', None),
+ ('5.14.0', 'never', None),
+ ('5.15.0', 'never', None),
+
+ # 'same-domain' - arguments depend on Qt versions
+ ('5.13.0', 'same-domain', '--reduced-referrer-granularity'),
+ ('5.14.0', 'same-domain', '--enable-features=ReducedReferrerGranularity'),
+ ('5.15.0', 'same-domain', '--enable-features=ReducedReferrerGranularity'),
+ ])
+ def test_referer(self, config_stub, monkeypatch, parser, qt_version, referer, arg):
+ monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine)
+ monkeypatch.setattr(qtargs.qtutils, 'qVersion', lambda: qt_version)
+
+ # Avoid WebRTC pipewire feature
+ monkeypatch.setattr(qtargs.utils, 'is_linux', False)
+ # Avoid overlay scrollbar feature
+ config_stub.val.scrolling.bar = 'never'
+
+ config_stub.val.content.headers.referer = referer
+ parsed = parser.parse_args([])
+ args = qtargs.qt_args(parsed)
+
+ if arg is None:
+ assert '--no-referrers' not in args
+ assert '--reduced-referrer-granularity' not in args
+ assert '--enable-features=ReducedReferrerGranularity' not in args
+ else:
+ assert arg in args
+
+ @pytest.mark.parametrize('dark, qt_version, added', [
+ (True, "5.13", False), # not supported
+ (True, "5.14", True),
+ (True, "5.15.0", True),
+ (True, "5.15.1", True),
+ (True, "5.15.2", False), # handled via blink setting
+
+ (False, "5.13", False),
+ (False, "5.14", False),
+ (False, "5.15.0", False),
+ (False, "5.15.1", False),
+ (False, "5.15.2", False),
+ ])
+ @utils.qt514
+ def test_prefers_color_scheme_dark(self, config_stub, monkeypatch, parser,
+ dark, qt_version, added):
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+ monkeypatch.setattr(qtargs.qtutils, 'qVersion', lambda: qt_version)
+
+ config_stub.val.colors.webpage.prefers_color_scheme_dark = dark
+
+ parsed = parser.parse_args([])
+ args = qtargs.qt_args(parsed)
+
+ assert ('--force-dark-mode' in args) == added
+
+ @pytest.mark.parametrize('bar, is_mac, added', [
+ # Overlay bar enabled
+ ('overlay', False, True),
+ # No overlay on mac
+ ('overlay', True, False),
+ # Overlay disabled
+ ('when-searching', False, False),
+ ('always', False, False),
+ ('never', False, False),
+ ])
+ def test_overlay_scrollbar(self, config_stub, monkeypatch, parser,
+ bar, is_mac, added):
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+ monkeypatch.setattr(qtargs.utils, 'is_mac', is_mac)
+ # Avoid WebRTC pipewire feature
+ monkeypatch.setattr(qtargs.utils, 'is_linux', False)
+
+ config_stub.val.scrolling.bar = bar
+
+ parsed = parser.parse_args([])
+ args = qtargs.qt_args(parsed)
+
+ assert ('--enable-features=OverlayScrollbar' in args) == added
+
+ @pytest.mark.parametrize('via_commandline', [True, False])
+ @pytest.mark.parametrize('overlay, passed_features, expected_features', [
+ (True,
+ 'CustomFeature',
+ 'CustomFeature,OverlayScrollbar'),
+ (True,
+ 'CustomFeature1,CustomFeature2',
+ 'CustomFeature1,CustomFeature2,OverlayScrollbar'),
+ (False,
+ 'CustomFeature',
+ 'CustomFeature'),
+ ])
+ def test_overlay_features_flag(self, config_stub, monkeypatch, parser,
+ via_commandline, overlay, passed_features,
+ expected_features):
+ """If enable-features is already specified, we should combine both."""
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+ monkeypatch.setattr(qtargs.qtutils, 'version_check',
+ lambda version, exact=False, compiled=True:
+ True)
+ monkeypatch.setattr(qtargs.utils, 'is_mac', False)
+ # Avoid WebRTC pipewire feature
+ monkeypatch.setattr(qtargs.utils, 'is_linux', False)
+
+ stripped_prefix = 'enable-features='
+ config_flag = stripped_prefix + passed_features
+
+ config_stub.val.scrolling.bar = 'overlay' if overlay else 'never'
+ config_stub.val.qt.args = ([] if via_commandline else [config_flag])
+
+ parsed = parser.parse_args(['--qt-flag', config_flag]
+ if via_commandline else [])
+ args = qtargs.qt_args(parsed)
+
+ prefix = '--' + stripped_prefix
+ overlay_flag = prefix + 'OverlayScrollbar'
+ combined_flag = prefix + expected_features
+ assert len([arg for arg in args if arg.startswith(prefix)]) == 1
+ assert combined_flag in args
+ assert overlay_flag not in args
+
+ def test_blink_settings(self, config_stub, monkeypatch, parser):
+ from qutebrowser.browser.webengine import darkmode
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+ monkeypatch.setattr(darkmode, '_variant',
+ lambda: darkmode.Variant.qt_515_2)
+
+ config_stub.val.colors.webpage.darkmode.enabled = True
+
+ parsed = parser.parse_args([])
+ args = qtargs.qt_args(parsed)
+
+ expected = ('--blink-settings=forceDarkModeEnabled=true,'
+ 'forceDarkModeImagePolicy=2')
+
+ assert expected in args
+
+
+class TestEnvVars:
+
+ @pytest.mark.parametrize('config_opt, config_val, envvar, expected', [
+ ('qt.force_software_rendering', 'software-opengl',
+ 'QT_XCB_FORCE_SOFTWARE_OPENGL', '1'),
+ ('qt.force_software_rendering', 'qt-quick',
+ 'QT_QUICK_BACKEND', 'software'),
+ ('qt.force_software_rendering', 'chromium',
+ 'QT_WEBENGINE_DISABLE_NOUVEAU_WORKAROUND', '1'),
+ ('qt.force_platform', 'toaster', 'QT_QPA_PLATFORM', 'toaster'),
+ ('qt.force_platformtheme', 'lxde', 'QT_QPA_PLATFORMTHEME', 'lxde'),
+ ('window.hide_decoration', True,
+ 'QT_WAYLAND_DISABLE_WINDOWDECORATION', '1')
+ ])
+ def test_env_vars(self, monkeypatch, config_stub,
+ config_opt, config_val, envvar, expected):
+ """Check settings which set an environment variable."""
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+ monkeypatch.setenv(envvar, '') # to make sure it gets restored
+ monkeypatch.delenv(envvar)
+
+ config_stub.set_obj(config_opt, config_val)
+ qtargs.init_envvars()
+
+ assert os.environ[envvar] == expected
+
+ @pytest.mark.parametrize('init_val, config_val', [
+ ( # Test changing a set variable
+ {'QT_SCALE_FACTOR': '2'},
+ {'QT_SCALE_FACTOR': '4'},
+ ),
+ ( # Test setting an unset variable
+ {'QT_SCALE_FACTOR': None},
+ {'QT_SCALE_FACTOR': '3'},
+ ),
+ ( # Test unsetting a variable which is set
+ {'QT_SCALE_FACTOR': '3'},
+ {'QT_SCALE_FACTOR': None},
+ ),
+ ( # Test unsetting a variable which is unset
+ {'QT_SCALE_FACTOR': None},
+ {'QT_SCALE_FACTOR': None},
+ ),
+ ( # Test setting multiple variables
+ {'QT_SCALE_FACTOR': '0', 'QT_PLUGIN_PATH': '/usr/bin', 'QT_NEWVAR': None},
+ {'QT_SCALE_FACTOR': '3', 'QT_PLUGIN_PATH': '/tmp/', 'QT_NEWVAR': 'newval'},
+ )
+ ])
+ def test_environ_settings(self, monkeypatch, config_stub,
+ init_val, config_val):
+ """Test setting environment variables using qt.environ."""
+ for var, val in init_val.items():
+ if val is None:
+ monkeypatch.setenv(var, '0')
+ monkeypatch.delenv(var, raising=False)
+ else:
+ monkeypatch.setenv(var, val)
+
+ config_stub.val.qt.environ = config_val
+ qtargs.init_envvars()
+
+ for var, result in config_val.items():
+ if result is None:
+ assert var not in os.environ
+ else:
+ assert os.environ[var] == result
+
+ @pytest.mark.parametrize('new_qt', [True, False])
+ def test_highdpi(self, monkeypatch, config_stub, new_qt):
+ """Test HighDPI environment variables.
+
+ Depending on the Qt version, there's a different variable which should
+ be set...
+ """
+ new_var = 'QT_ENABLE_HIGHDPI_SCALING'
+ old_var = 'QT_AUTO_SCREEN_SCALE_FACTOR'
+
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebEngine)
+ monkeypatch.setattr(qtargs.qtutils, 'version_check',
+ lambda version, exact=False, compiled=True:
+ new_qt)
+
+ for envvar in [new_var, old_var]:
+ monkeypatch.setenv(envvar, '') # to make sure it gets restored
+ monkeypatch.delenv(envvar)
+
+ config_stub.set_obj('qt.highdpi', True)
+ qtargs.init_envvars()
+
+ envvar = new_var if new_qt else old_var
+
+ assert os.environ[envvar] == '1'
+
+ def test_env_vars_webkit(self, monkeypatch, config_stub):
+ monkeypatch.setattr(qtargs.objects, 'backend',
+ usertypes.Backend.QtWebKit)
+ qtargs.init_envvars()
diff --git a/tests/unit/config/test_websettings.py b/tests/unit/config/test_websettings.py
index 651c14aba..e1445efbb 100644
--- a/tests/unit/config/test_websettings.py
+++ b/tests/unit/config/test_websettings.py
@@ -95,10 +95,10 @@ def test_user_agent(monkeypatch, config_stub, qapp):
def test_config_init(request, monkeypatch, config_stub):
if request.config.webengine:
from qutebrowser.browser.webengine import webenginesettings
- monkeypatch.setattr(webenginesettings, 'init', lambda _args: None)
+ monkeypatch.setattr(webenginesettings, 'init', lambda: None)
else:
from qutebrowser.browser.webkit import webkitsettings
- monkeypatch.setattr(webkitsettings, 'init', lambda _args: None)
+ monkeypatch.setattr(webkitsettings, 'init', lambda: None)
websettings.init(args=None)
assert config_stub.dump_userconfig() == '<Default configuration>'
diff --git a/tests/unit/javascript/conftest.py b/tests/unit/javascript/conftest.py
index 509de2985..7f0c8695e 100644
--- a/tests/unit/javascript/conftest.py
+++ b/tests/unit/javascript/conftest.py
@@ -62,9 +62,19 @@ class JSTester:
**kwargs: Passed to jinja's template.render().
"""
template = self._jinja_env.get_template(path)
- with self.qtbot.waitSignal(self.tab.load_finished,
- timeout=2000) as blocker:
- self.tab.set_html(template.render(**kwargs))
+
+ try:
+ with self.qtbot.waitSignal(self.tab.load_finished,
+ timeout=2000) as blocker:
+ self.tab.set_html(template.render(**kwargs))
+ except self.qtbot.TimeoutError:
+ # Sometimes this fails for some odd reason on macOS, let's just try
+ # again.
+ print("Trying to load page again...")
+ with self.qtbot.waitSignal(self.tab.load_finished,
+ timeout=2000) as blocker:
+ self.tab.set_html(template.render(**kwargs))
+
assert blocker.args == [True]
def load_file(self, path: str, force: bool = False):
diff --git a/tests/unit/javascript/stylesheet/test_appendchild.js b/tests/unit/javascript/stylesheet/test_appendchild.js
index d1deadba6..aa1294cb3 100644
--- a/tests/unit/javascript/stylesheet/test_appendchild.js
+++ b/tests/unit/javascript/stylesheet/test_appendchild.js
@@ -9,37 +9,37 @@ var iframe, object;
// svg iframe
iframe = document.createElement('iframe');
iframe.onload = function () { kungFuDeathGrip.title += '1' };
-iframe.src = "svg.xml";
+// iframe.src = "svg.xml";
kungFuDeathGrip.appendChild(iframe);
// object iframe
object = document.createElement('object');
object.onload = function () { kungFuDeathGrip.title += '2' };
-object.data = "svg.xml";
+// object.data = "svg.xml";
kungFuDeathGrip.appendChild(object);
// xml iframe
iframe = document.createElement('iframe');
iframe.onload = function () { kungFuDeathGrip.title += '3' };
-iframe.src = "empty.xml";
+// iframe.src = "empty.xml";
kungFuDeathGrip.appendChild(iframe);
// html iframe
iframe = document.createElement('iframe');
iframe.onload = function () { kungFuDeathGrip.title += '4' };
-iframe.src = "empty.html";
+// iframe.src = "empty.html";
kungFuDeathGrip.appendChild(iframe);
// html iframe
iframe = document.createElement('iframe');
iframe.onload = function () { kungFuDeathGrip.title += '5' };
-iframe.src = "xhtml.1";
+// iframe.src = "xhtml.1";
kungFuDeathGrip.appendChild(iframe);
// html iframe
iframe = document.createElement('iframe');
iframe.onload = function () { kungFuDeathGrip.title += '6' };
-iframe.src = "xhtml.2";
+// iframe.src = "xhtml.2";
kungFuDeathGrip.appendChild(iframe);
// html iframe
iframe = document.createElement('iframe');
iframe.onload = function () { kungFuDeathGrip.title += '7' };
-iframe.src = "xhtml.3";
+// iframe.src = "xhtml.3";
kungFuDeathGrip.appendChild(iframe);
// add the lot to the document
diff --git a/tests/unit/javascript/test_greasemonkey.py b/tests/unit/javascript/test_greasemonkey.py
index 26da7e342..5f2c94b56 100644
--- a/tests/unit/javascript/test_greasemonkey.py
+++ b/tests/unit/javascript/test_greasemonkey.py
@@ -27,6 +27,7 @@ from PyQt5.QtCore import QUrl
from qutebrowser.utils import usertypes
from qutebrowser.browser import greasemonkey
+from qutebrowser.misc import objects
test_gm_script = r"""
// ==UserScript==
@@ -40,12 +41,15 @@ test_gm_script = r"""
console.log("Script is running.");
"""
-pytestmark = pytest.mark.usefixtures('data_tmpdir')
+pytestmark = [
+ pytest.mark.usefixtures('data_tmpdir'),
+ pytest.mark.usefixtures('config_tmpdir')
+]
def _save_script(script_text, filename):
# pylint: disable=no-member
- file_path = py.path.local(greasemonkey._scripts_dir()) / filename
+ file_path = py.path.local(greasemonkey._scripts_dirs()[0]) / filename
# pylint: enable=no-member
file_path.write_text(script_text, encoding='utf-8', ensure=True)
@@ -168,15 +172,6 @@ def test_utf8_bom():
class TestForceDocumentEnd:
- @pytest.fixture
- def patch(self, monkeypatch):
- def _patch(*, backend, qt_512):
- monkeypatch.setattr(greasemonkey.objects, 'backend', backend)
- monkeypatch.setattr(greasemonkey.qtutils, 'version_check',
- lambda version, exact=False, compiled=True:
- qt_512)
- return _patch
-
def _get_script(self, *, namespace, name):
source = textwrap.dedent("""
// ==UserScript==
@@ -192,26 +187,15 @@ class TestForceDocumentEnd:
assert len(scripts) == 1
return scripts[0]
- @pytest.mark.parametrize('backend, qt_512', [
- (usertypes.Backend.QtWebKit, True),
- (usertypes.Backend.QtWebEngine, False),
- ])
- def test_not_applicable(self, patch, backend, qt_512):
- """Test backend/Qt version combinations which don't need a fix."""
- patch(backend=backend, qt_512=qt_512)
- script = self._get_script(namespace='https://github.com/ParticleCore',
- name='Iridium')
- assert not script.needs_document_end_workaround()
-
@pytest.mark.parametrize('namespace, name, force', [
('http://userstyles.org', 'foobar', True),
('https://github.com/ParticleCore', 'Iridium', True),
('https://github.com/ParticleCore', 'Foo', False),
('https://example.org', 'Iridium', False),
])
- def test_matching(self, patch, namespace, name, force):
+ def test_matching(self, monkeypatch, namespace, name, force):
"""Test matching based on namespace/name."""
- patch(backend=usertypes.Backend.QtWebEngine, qt_512=True)
+ monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine)
script = self._get_script(namespace=namespace, name=name)
assert script.needs_document_end_workaround() == force
@@ -230,8 +214,7 @@ def test_required_scripts_are_included(download_stub, tmpdir):
console.log("Script is running.");
""")
_save_script(test_require_script, 'requiring.user.js')
- with open(str(tmpdir / 'test.js'), 'w', encoding='UTF-8') as f:
- f.write("REQUIRED SCRIPT")
+ (tmpdir / 'test.js').write_text('REQUIRED SCRIPT', encoding='UTF-8')
gm_manager = greasemonkey.GreasemonkeyManager()
assert len(gm_manager._in_progress_dls) == 1
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..cd57d33cb 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 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 prompt_keyparser(key_config_stub, keyinput_bindings):
+ return _create_keyparser(usertypes.KeyMode.prompt)
@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 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,51 @@ 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):
- 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
+class TestHandle:
- def test_valid_key_count(self, fake_keyevent, 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)
+ def test_valid_key(self, prompt_keyparser, handle_text):
+ infos = [
+ keyutils.KeyInfo(Qt.Key_A, Qt.ControlModifier),
+ keyutils.KeyInfo(Qt.Key_X, Qt.ControlModifier),
+ ]
+ 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):
+ infos = [
+ keyutils.KeyInfo(Qt.Key_5, Qt.NoModifier),
+ keyutils.KeyInfo(Qt.Key_A, Qt.ControlModifier),
+ ]
+ 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 +146,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 +260,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 +269,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..4c297ef7e 100644
--- a/tests/unit/keyinput/test_keyutils.py
+++ b/tests/unit/keyinput/test_keyutils.py
@@ -28,7 +28,6 @@ from PyQt5.QtWidgets import QWidget
from unit.keyinput import key_data
from qutebrowser.keyinput import keyutils
-from qutebrowser.utils import utils
@pytest.fixture(params=key_data.KEYS, ids=lambda k: k.attribute)
@@ -421,13 +420,10 @@ class TestKeySequence:
('', Qt.Key_Colon, Qt.AltModifier | Qt.ShiftModifier, ':',
'<Alt+Shift+:>'),
- # Swapping Control/Meta on macOS
- ('', Qt.Key_A, Qt.ControlModifier, '',
- '<Meta+A>' if utils.is_mac else '<Ctrl+A>'),
- ('', Qt.Key_A, Qt.ControlModifier | Qt.ShiftModifier, '',
- '<Meta+Shift+A>' if utils.is_mac else '<Ctrl+Shift+A>'),
- ('', Qt.Key_A, Qt.MetaModifier, '',
- '<Ctrl+A>' if utils.is_mac else '<Meta+A>'),
+ # Modifiers
+ ('', Qt.Key_A, Qt.ControlModifier, '', '<Ctrl+A>'),
+ ('', Qt.Key_A, Qt.ControlModifier | Qt.ShiftModifier, '', '<Ctrl+Shift+A>'),
+ ('', Qt.Key_A, Qt.MetaModifier, '', '<Meta+A>'),
# Handling of Backtab
('', Qt.Key_Backtab, Qt.NoModifier, '', '<Backtab>'),
@@ -444,27 +440,6 @@ class TestKeySequence:
new = seq.append_event(event)
assert new == keyutils.KeySequence.parse(expected)
- @pytest.mark.fake_os('mac')
- @pytest.mark.parametrize('modifiers, expected', [
- (Qt.ControlModifier,
- Qt.MetaModifier),
- (Qt.MetaModifier,
- Qt.ControlModifier),
- (Qt.ControlModifier | Qt.MetaModifier,
- Qt.ControlModifier | Qt.MetaModifier),
- (Qt.ControlModifier | Qt.ShiftModifier,
- Qt.MetaModifier | Qt.ShiftModifier),
- (Qt.MetaModifier | Qt.ShiftModifier,
- Qt.ControlModifier | Qt.ShiftModifier),
- (Qt.ShiftModifier, Qt.ShiftModifier),
- ])
- def test_fake_mac(self, fake_keyevent, 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)
- assert new[0] == keyutils.KeyInfo(Qt.Key_A, expected)
-
@pytest.mark.parametrize('key', [Qt.Key_unknown, 0x0])
def test_append_event_invalid(self, key):
seq = keyutils.KeySequence()
@@ -585,28 +560,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/statusbar/test_url.py b/tests/unit/mainwindow/statusbar/test_url.py
index 8bf71aff4..bd37fd2dc 100644
--- a/tests/unit/mainwindow/statusbar/test_url.py
+++ b/tests/unit/mainwindow/statusbar/test_url.py
@@ -26,7 +26,6 @@ from PyQt5.QtCore import QUrl
from qutebrowser.utils import usertypes, urlutils
from qutebrowser.mainwindow.statusbar import url
-from helpers import utils
@pytest.fixture
@@ -47,10 +46,7 @@ def url_widget(qtbot, monkeypatch, config_stub):
('http://username:secret%20password@test.com', 'http://username@test.com'),
('http://example.com%5b/', '(invalid URL!) http://example.com%5b/'),
# https://bugreports.qt.io/browse/QTBUG-60364
- pytest.param('http://www.xn--80ak6aa92e.com',
- '(unparseable URL!) http://www.аррӏе.com', marks=utils.qt58),
- pytest.param('http://www.xn--80ak6aa92e.com',
- 'http://www.xn--80ak6aa92e.com', marks=utils.qt59),
+ ('http://www.xn--80ak6aa92e.com', 'http://www.xn--80ak6aa92e.com'),
# IDN URL
('http://www.ä.com', '(www.xn--4ca.com) http://www.ä.com'),
(None, ''),
diff --git a/tests/unit/mainwindow/test_messageview.py b/tests/unit/mainwindow/test_messageview.py
index 8691bf07f..050788a9e 100644
--- a/tests/unit/mainwindow/test_messageview.py
+++ b/tests/unit/mainwindow/test_messageview.py
@@ -35,6 +35,7 @@ def view(qtbot, config_stub):
@pytest.mark.parametrize('level', [usertypes.MessageLevel.info,
usertypes.MessageLevel.warning,
usertypes.MessageLevel.error])
+@pytest.mark.flaky # on macOS
def test_single_message(qtbot, view, level):
with qtbot.waitExposed(view, timeout=5000):
view.show_message(level, 'test')
@@ -58,6 +59,26 @@ def test_size_hint(view):
assert height2 == height1 * 2
+def test_word_wrap(view, qtbot):
+ """A long message should be wrapped."""
+ with qtbot.waitSignal(view._clear_timer.timeout):
+ view.show_message(usertypes.MessageLevel.info, 'short')
+ height1 = view.sizeHint().height()
+ assert height1 > 0
+
+ text = ("Athene, the bright-eyed goddess, answered him at once: Father of "
+ "us all, Son of Cronos, Highest King, clearly that man deserved to be "
+ "destroyed: so let all be destroyed who act as he did. But my heart aches "
+ "for Odysseus, wise but ill fated, who suffers far from his friends on an "
+ "island deep in the sea.")
+
+ view.show_message(usertypes.MessageLevel.info, text)
+ height2 = view.sizeHint().height()
+
+ assert height2 > height1
+ assert view._messages[0].wordWrap()
+
+
def test_show_message_twice(view):
"""Show the same message twice -> only one should be shown."""
view.show_message(usertypes.MessageLevel.info, 'test')
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/mainwindow/test_tabwidget.py b/tests/unit/mainwindow/test_tabwidget.py
index 659aac7ec..b271c18ab 100644
--- a/tests/unit/mainwindow/test_tabwidget.py
+++ b/tests/unit/mainwindow/test_tabwidget.py
@@ -94,8 +94,9 @@ class TestTabWidget:
config_stub.val.tabs.position = "left"
pinned_num = [1, num_tabs - 1]
- for tab in pinned_num:
- widget.set_tab_pinned(widget.widget(tab), True)
+ for num in pinned_num:
+ tab = widget.widget(num)
+ tab.set_pinned(True)
first_size = widget.tabBar().tabSizeHint(0)
first_size_min = widget.tabBar().minimumTabSizeHint(0)
diff --git a/tests/unit/misc/test_checkpyver.py b/tests/unit/misc/test_checkpyver.py
index 1fb890d1f..2d4da12e8 100644
--- a/tests/unit/misc/test_checkpyver.py
+++ b/tests/unit/misc/test_checkpyver.py
@@ -28,23 +28,24 @@ import pytest
from qutebrowser.misc import checkpyver
-TEXT = (r"At least Python 3.5.2 is required to run qutebrowser, but it's "
- r"running with \d+\.\d+\.\d+.\n")
+TEXT = (r"At least Python 3.6 is required to run qutebrowser, but it's "
+ r"running with \d+\.\d+\.\d+.")
@pytest.mark.not_frozen
-def test_python2():
- """Run checkpyver with python 2."""
+@pytest.mark.parametrize('python', ['python2', 'python3.5'])
+def test_old_python(python):
+ """Run checkpyver with old python versions."""
try:
proc = subprocess.run(
- ['python2', checkpyver.__file__, '--no-err-windows'],
+ [python, checkpyver.__file__, '--no-err-windows'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False)
except FileNotFoundError:
- pytest.skip("python2 not found")
+ pytest.skip(f"{python} not found")
assert not proc.stdout
- stderr = proc.stderr.decode('utf-8')
+ stderr = proc.stderr.decode('utf-8').rstrip()
assert re.fullmatch(TEXT, stderr), stderr
assert proc.returncode == 1
@@ -63,7 +64,9 @@ def test_patched_no_errwindow(capfd, monkeypatch):
monkeypatch.setattr(checkpyver.sys, 'hexversion', 0x03040000)
monkeypatch.setattr(checkpyver.sys, 'exit', lambda status: None)
checkpyver.check_python_version()
+
stdout, stderr = capfd.readouterr()
+ stderr = stderr.rstrip()
assert not stdout
assert re.fullmatch(TEXT, stderr), stderr
diff --git a/tests/unit/misc/test_earlyinit.py b/tests/unit/misc/test_earlyinit.py
index 728b4eb26..af229a40a 100644
--- a/tests/unit/misc/test_earlyinit.py
+++ b/tests/unit/misc/test_earlyinit.py
@@ -31,3 +31,20 @@ def test_init_faulthandler_stderr_none(monkeypatch, attr):
"""Make sure init_faulthandler works when sys.stderr/__stderr__ is None."""
monkeypatch.setattr(sys, attr, None)
earlyinit.init_faulthandler()
+
+
+@pytest.mark.parametrize('same', [True, False])
+def test_qt_version(same):
+ if same:
+ qt_version_str = '5.14.0'
+ expected = '5.14.0'
+ else:
+ qt_version_str = '5.13.0'
+ expected = '5.14.0 (compiled 5.13.0)'
+ actual = earlyinit.qt_version(qversion='5.14.0', qt_version_str=qt_version_str)
+ assert actual == expected
+
+
+def test_qt_version_no_args():
+ """Make sure qt_version without arguments at least works."""
+ earlyinit.qt_version()
diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py
index edd32b5a5..694e6d204 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
@@ -37,9 +37,9 @@ def patch_things(config_stub, monkeypatch, stubs):
stubs.fake_qprocess())
-@pytest.fixture
-def editor(caplog, qtbot):
- ed = editormod.ExternalEditor()
+@pytest.fixture(params=[True, False])
+def editor(caplog, qtbot, request):
+ ed = editormod.ExternalEditor(watch=request.param)
yield ed
with caplog.at_level(logging.ERROR):
ed._remove_file = True
@@ -76,16 +76,18 @@ 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):
- """Test editing an existing file."""
- path = tmpdir / 'foo.txt'
- path.ensure()
+ @pytest.mark.parametrize('touch', [True, False])
+ def test_with_filename(self, editor, tmp_path, touch):
+ """Test editing a file with an explicit path."""
+ path = tmp_path / 'foo.txt'
+ if touch:
+ path.touch()
editor.edit_file(str(path))
editor._proc.finished.emit(0, QProcess.NormalExit)
@@ -95,62 +97,51 @@ 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):
- # Docker container or similar
- pytest.skip("File was still writable")
-
- yield tmpdir
-
- tmpdir.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 +158,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..3f53ca238 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
@@ -197,6 +198,14 @@ class TestSocketName:
socketname = ipc._get_socketname_windows(basedir)
assert socketname == expected
+ def test_windows_broken_getpass(self, monkeypatch):
+ def _fake_username():
+ raise ImportError
+ monkeypatch.setattr(ipc.getpass, 'getuser', _fake_username)
+
+ with pytest.raises(ipc.Error, match='USERNAME'):
+ ipc._get_socketname_windows(basedir=None)
+
@pytest.mark.mac
@pytest.mark.parametrize('basedir, expected', [
(None, 'i-{}'.format(md5('testusername'))),
@@ -297,10 +306,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 +328,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 +356,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 +532,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 +540,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 +549,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 +557,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'))
@@ -705,7 +733,7 @@ class TestSendOrListen:
'',
'title: Error while connecting to running instance!',
'pre_text: ',
- 'post_text: Maybe another instance is running but frozen?',
+ 'post_text: ',
'exception text: {}'.format(exc_msg),
]
assert caplog.messages == ['\n'.join(error_msgs)]
@@ -726,7 +754,7 @@ class TestSendOrListen:
'',
'title: Error while connecting to running instance!',
'pre_text: ',
- 'post_text: Maybe another instance is running but frozen?',
+ 'post_text: ',
('exception text: Error while listening to IPC server: Error '
'string (error 4)'),
]
@@ -739,7 +767,7 @@ def test_long_username(monkeypatch):
"""See https://github.com/qutebrowser/qutebrowser/issues/888."""
username = 'alexandercogneau'
basedir = '/this_is_a_long_basedir'
- monkeypatch.setattr('getpass.getuser', lambda: username)
+ monkeypatch.setattr(getpass, 'getuser', lambda: username)
name = ipc._get_socketname(basedir=basedir)
server = ipc.IPCServer(name)
expected_md5 = md5('{}-{}'.format(username, basedir))
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_miscwidgets.py b/tests/unit/misc/test_miscwidgets.py
index daa556833..cd9fe93b8 100644
--- a/tests/unit/misc/test_miscwidgets.py
+++ b/tests/unit/misc/test_miscwidgets.py
@@ -17,14 +17,14 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-"""Test widgets in miscwidgets module."""
+import logging
-from unittest import mock
from PyQt5.QtCore import Qt, QSize
-from PyQt5.QtWidgets import QApplication, QWidget
+from PyQt5.QtWidgets import QWidget
import pytest
from qutebrowser.misc import miscwidgets
+from qutebrowser.browser import inspector
class TestCommandLineEdit:
@@ -40,19 +40,6 @@ class TestCommandLineEdit:
assert cmd_edit.text() == ''
yield cmd_edit
- @pytest.fixture
- def mock_clipboard(self, mocker):
- """Fixture to mock QApplication.clipboard.
-
- Return:
- The mocked QClipboard object.
- """
- mocker.patch.object(QApplication, 'clipboard')
- clipboard = mock.MagicMock()
- clipboard.supportsSelection.return_value = True
- QApplication.clipboard.return_value = clipboard
- return clipboard
-
def test_position(self, qtbot, cmd_edit):
"""Test cursor position based on the prompt."""
qtbot.keyClicks(cmd_edit, ':hello')
@@ -142,3 +129,166 @@ class TestFullscreenNotification:
qtbot.add_widget(w)
with qtbot.waitSignal(w.destroyed):
w.set_timeout(1)
+
+
+@pytest.mark.usefixtures('state_config')
+class TestInspectorSplitter:
+
+ @pytest.fixture
+ def fake_webview(self, blue_widget):
+ return blue_widget
+
+ @pytest.fixture
+ def fake_inspector(self, red_widget):
+ return red_widget
+
+ @pytest.fixture
+ def splitter(self, qtbot, fake_webview):
+ inspector_splitter = miscwidgets.InspectorSplitter(
+ win_id=0, main_webview=fake_webview)
+ qtbot.add_widget(inspector_splitter)
+ return inspector_splitter
+
+ def test_no_inspector(self, splitter, fake_webview):
+ assert splitter.count() == 1
+ assert splitter.widget(0) is fake_webview
+ assert splitter.focusProxy() is fake_webview
+
+ def test_no_inspector_resize(self, splitter):
+ splitter.show()
+ splitter.resize(800, 600)
+
+ def test_cycle_focus_no_inspector(self, splitter):
+ with pytest.raises(inspector.Error,
+ match='No inspector inside main window'):
+ splitter.cycle_focus()
+
+ @pytest.mark.parametrize(
+ 'position, orientation, inspector_idx, webview_idx', [
+ (inspector.Position.left, Qt.Horizontal, 0, 1),
+ (inspector.Position.right, Qt.Horizontal, 1, 0),
+ (inspector.Position.top, Qt.Vertical, 0, 1),
+ (inspector.Position.bottom, Qt.Vertical, 1, 0),
+ ]
+ )
+ def test_set_inspector(self, position, orientation,
+ inspector_idx, webview_idx,
+ splitter, fake_inspector, fake_webview):
+ splitter.set_inspector(fake_inspector, position)
+
+ assert splitter.indexOf(fake_inspector) == inspector_idx
+ assert splitter._inspector_idx == inspector_idx
+
+ assert splitter.indexOf(fake_webview) == webview_idx
+ assert splitter._main_idx == webview_idx
+
+ assert splitter.orientation() == orientation
+
+ def test_cycle_focus_hidden_inspector(self, splitter, fake_inspector):
+ splitter.set_inspector(fake_inspector, inspector.Position.right)
+ splitter.show()
+ fake_inspector.hide()
+ with pytest.raises(inspector.Error,
+ match='No inspector inside main window'):
+ splitter.cycle_focus()
+
+ @pytest.mark.parametrize(
+ 'config, width, height, position, expected_size', [
+ # No config but enough big window
+ (None, 1024, 768, inspector.Position.left, 512),
+ (None, 1024, 768, inspector.Position.top, 384),
+ # No config and small window
+ (None, 320, 240, inspector.Position.left, 300),
+ (None, 320, 240, inspector.Position.top, 300),
+ # Invalid config
+ ('verybig', 1024, 768, inspector.Position.left, 512),
+ # Value from config
+ ('666', 1024, 768, inspector.Position.left, 666),
+ ]
+ )
+ def test_read_size(self, config, width, height, position, expected_size,
+ state_config, splitter, fake_inspector, caplog):
+ if config is not None:
+ state_config['inspector'] = {position.name: config}
+
+ splitter.resize(width, height)
+ assert splitter.size() == QSize(width, height)
+
+ with caplog.at_level(logging.ERROR):
+ splitter.set_inspector(fake_inspector, position)
+
+ assert splitter._preferred_size == expected_size
+
+ if config == {'left': 'verybig'}:
+ assert caplog.messages == ["Could not read inspector size: "
+ "invalid literal for int() with "
+ "base 10: 'verybig'"]
+
+ @pytest.mark.parametrize('position', [
+ inspector.Position.left,
+ inspector.Position.right,
+ inspector.Position.top,
+ inspector.Position.bottom,
+ ])
+ def test_save_size(self, position, state_config, splitter, fake_inspector):
+ splitter.set_inspector(fake_inspector, position)
+ splitter._preferred_size = 1337
+ splitter._save_preferred_size()
+ assert state_config['inspector'][position.name] == '1337'
+
+ @pytest.mark.parametrize(
+ 'old_window_size, preferred_size, new_window_size, '
+ 'exp_inspector_size', [
+ # Plenty of space -> Keep inspector at configured absolute size
+ (600, 300, # 1/2 of window
+ 500, 300), # 300px of 600px -> 300px of 500px
+
+ # Slowly running out of space -> Reserve space for website
+ (600, 450, # 3/4 of window
+ 500, 350), # 450px of 600px -> 350px of 500px
+ # (so website has 150px)
+
+ # Very small window -> Keep ratio distribution
+ (600, 300, # 1/2 of window
+ 200, 100), # 300px of 600px -> 100px of 200px (1/2)
+ ]
+ )
+ @pytest.mark.parametrize('position', [
+ inspector.Position.left, inspector.Position.right,
+ inspector.Position.top, inspector.Position.bottom])
+ def test_adjust_size(self, old_window_size, preferred_size,
+ new_window_size, exp_inspector_size,
+ position, splitter, fake_inspector, qtbot):
+ def resize(dim):
+ size = (QSize(dim, 666) if splitter.orientation() == Qt.Horizontal
+ else QSize(666, dim))
+ splitter.resize(size)
+ if splitter.size() != size:
+ pytest.skip("Resizing window failed")
+
+ splitter.set_inspector(fake_inspector, position)
+ splitter.show()
+ resize(old_window_size)
+
+ handle_width = 4
+ splitter.setHandleWidth(handle_width)
+
+ splitter_idx = 1
+ if position in [inspector.Position.left, inspector.Position.top]:
+ splitter_pos = preferred_size - handle_width//2
+ else:
+ splitter_pos = old_window_size - preferred_size - handle_width//2
+ splitter.moveSplitter(splitter_pos, splitter_idx)
+
+ resize(new_window_size)
+
+ sizes = splitter.sizes()
+ inspector_size = sizes[splitter._inspector_idx]
+ main_size = sizes[splitter._main_idx]
+ exp_main_size = new_window_size - exp_inspector_size
+
+ exp_main_size -= handle_width // 2
+ exp_inspector_size -= handle_width // 2
+
+ assert (inspector_size, main_size) == (exp_inspector_size,
+ exp_main_size)
diff --git a/tests/unit/misc/test_sessions.py b/tests/unit/misc/test_sessions.py
index 4aec19dc5..a6e86efa9 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'
@@ -181,12 +182,6 @@ def test_get_session_name(config_stub, sess_man, arg, config, current,
class TestSave:
@pytest.fixture
- def state_config(self, monkeypatch):
- state = {'general': {}}
- monkeypatch.setattr(sessions.configfiles, 'state', state)
- return state
-
- @pytest.fixture
def fake_history(self, stubs, tabbed_browser_stubs, monkeypatch, webview):
"""Fixture which provides a window with a fake history."""
win = FakeMainWindow(b'fake-geometry-0', win_id=0)
@@ -208,13 +203,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 +217,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 +227,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 +351,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_sql.py b/tests/unit/misc/test_sql.py
index 2cb55b891..5f14ebec4 100644
--- a/tests/unit/misc/test_sql.py
+++ b/tests/unit/misc/test_sql.py
@@ -49,22 +49,8 @@ class TestSqlError:
with pytest.raises(exception):
sql.raise_sqlite_error("Message", sql_err)
- def test_qtbug_70506(self):
- """Test Qt's wrong handling of errors while opening the database.
-
- Due to https://bugreports.qt.io/browse/QTBUG-70506 we get an error with
- "out of memory" as string and -1 as error code.
- """
- sql_err = QSqlError("Error opening database",
- "out of memory",
- QSqlError.UnknownError,
- sql.SqliteErrorCode.UNKNOWN)
- with pytest.raises(sql.KnownError):
- sql.raise_sqlite_error("Message", sql_err)
-
def test_logging(self, caplog):
- sql_err = QSqlError("driver text", "db text", QSqlError.UnknownError,
- '23')
+ sql_err = QSqlError("driver text", "db text", QSqlError.UnknownError, '23')
with pytest.raises(sql.BugError):
sql.raise_sqlite_error("Message", sql_err)
@@ -190,6 +176,11 @@ def test_delete(qtbot):
assert not list(table)
+def test_delete_optional(qtbot):
+ table = sql.SqlTable('Foo', ['name', 'val'])
+ table.delete('name', 'doesnotexist', optional=True)
+
+
def test_len():
table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
assert len(table) == 0
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/misc/userscripts/test_qute_lastpass.py b/tests/unit/misc/userscripts/test_qute_lastpass.py
new file mode 100644
index 000000000..229fcf09e
--- /dev/null
+++ b/tests/unit/misc/userscripts/test_qute_lastpass.py
@@ -0,0 +1,347 @@
+# 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 misc.userscripts.qute-lastpass."""
+
+import json
+from types import SimpleNamespace
+from unittest.mock import ANY, call
+
+import attr
+import pytest
+
+from helpers import utils
+
+qute_lastpass = utils.import_userscript('qute-lastpass')
+
+default_lpass_match = [
+ {
+ "id": "12345",
+ "name": "www.example.com",
+ "username": "fake@fake.com",
+ "password": "foobar",
+ "url": "https://www.example.com",
+ }
+]
+
+
+@attr.s
+class FakeOutput:
+ stdout = attr.ib(default='', converter=str.encode)
+ stderr = attr.ib(default='', converter=str.encode)
+
+
+@pytest.fixture
+def subprocess_mock(mocker):
+ return mocker.patch('subprocess.run')
+
+
+@pytest.fixture
+def qutecommand_mock(mocker):
+ return mocker.patch.object(qute_lastpass, 'qute_command')
+
+
+@pytest.fixture
+def stderr_mock(mocker):
+ return mocker.patch.object(qute_lastpass, 'stderr')
+
+
+# Default arguments passed to qute-lastpass
+@pytest.fixture
+def arguments_mock():
+ arguments = SimpleNamespace()
+ arguments.url = ''
+ arguments.dmenu_invocation = 'rofi -dmenu'
+ arguments.insert_mode = True
+ arguments.io_encoding = 'UTF-8'
+ arguments.merge_candidates = False
+ arguments.password_only = False
+ arguments.username_only = False
+
+ return arguments
+
+
+class TestQuteLastPassComponents:
+ """Test qute-lastpass components."""
+
+ def test_fake_key_raw(self, qutecommand_mock):
+ """Test if fake_key_raw properly escapes characters."""
+ qute_lastpass.fake_key_raw('john.<<doe>>@example.com ')
+
+ qutecommand_mock.assert_called_once_with(
+ 'fake-key \\j\\o\\h\\n\\.<less><less>\\d\\o\\e<greater><greater>\\@'
+ '\\e\\x\\a\\m\\p\\l\\e\\.\\c\\o\\m" "'
+ )
+
+ def test_dmenu(self, subprocess_mock):
+ """Test if dmenu command receives properly formatted lpass entries."""
+ entries = [
+ "1234 | example.com | https://www.example.com | john.doe@example.com",
+ "2345 | example2.com | https://www.example2.com | jane.doe@example.com",
+ ]
+
+ subprocess_mock.return_value = FakeOutput(stdout=entries[1])
+
+ selected = qute_lastpass.dmenu(entries, 'rofi -dmenu', 'UTF-8')
+
+ subprocess_mock.assert_called_once_with(
+ ['rofi', '-dmenu'],
+ input='\n'.join(entries).encode(),
+ stdout=ANY)
+
+ assert selected == entries[1]
+
+ def test_pass_subprocess_args(self, subprocess_mock):
+ """Test if pass_ calls subprocess with correct arguments."""
+ subprocess_mock.return_value = FakeOutput(stdout='[{}]')
+
+ qute_lastpass.pass_('example.com', 'utf-8')
+
+ subprocess_mock.assert_called_once_with(
+ ['lpass', 'show', '-x', '-j', '-G', '\\bexample\\.com'],
+ stdout=ANY, stderr=ANY)
+
+ def test_pass_returns_candidates(self, subprocess_mock):
+ """Test if pass_ returns expected lpass site entry."""
+ subprocess_mock.return_value = FakeOutput(
+ stdout=json.dumps(default_lpass_match))
+
+ response = qute_lastpass.pass_('www.example.com', 'utf-8')
+ assert response[1] == ''
+
+ candidates = response[0]
+
+ assert len(candidates) == 1
+ assert candidates[0] == default_lpass_match[0]
+
+ def test_pass_no_accounts(self, subprocess_mock):
+ """Test if pass_ handles no accounts as an empty lpass result."""
+ error_message = 'Error: Could not find specified account(s).'
+ subprocess_mock.return_value = FakeOutput(stderr=error_message)
+
+ response = qute_lastpass.pass_('www.example.com', 'utf-8')
+ assert response[0] == []
+ assert response[1] == ''
+
+ def test_pass_returns_error(self, subprocess_mock):
+ """Test if pass_ returns error from lpass."""
+ # pylint: disable=line-too-long
+ error_message = 'Error: Could not find decryption key. Perhaps you need to login with `lpass login`.'
+ subprocess_mock.return_value = FakeOutput(stderr=error_message)
+
+ response = qute_lastpass.pass_('www.example.com', 'utf-8')
+ assert response[0] == []
+ assert response[1] == error_message
+
+
+class TestQuteLastPassMain:
+ """Test qute-lastpass main."""
+
+ def test_main_happy_path(self, subprocess_mock, arguments_mock,
+ qutecommand_mock):
+ """Test sending username/password to qutebrowser on *single* match."""
+ subprocess_mock.return_value = FakeOutput(
+ stdout=json.dumps(default_lpass_match))
+
+ arguments_mock.url = default_lpass_match[0]['url']
+ exit_code = qute_lastpass.main(arguments_mock)
+
+ assert exit_code == qute_lastpass.ExitCodes.SUCCESS
+
+ qutecommand_mock.assert_has_calls([
+ call('fake-key \\f\\a\\k\\e\\@\\f\\a\\k\\e\\.\\c\\o\\m'),
+ call('fake-key <Tab>'),
+ call('fake-key \\f\\o\\o\\b\\a\\r'),
+ call('enter-mode insert')
+ ])
+
+ def test_main_no_candidates(self, subprocess_mock, arguments_mock,
+ stderr_mock,
+ qutecommand_mock):
+ """Test correct exit code and message returned on no entries."""
+ error_message = 'Error: Could not find specified account(s).'
+ subprocess_mock.return_value = FakeOutput(stderr=error_message)
+
+ arguments_mock.url = default_lpass_match[0]['url']
+ exit_code = qute_lastpass.main(arguments_mock)
+
+ assert exit_code == qute_lastpass.ExitCodes.NO_PASS_CANDIDATES
+ stderr_mock.assert_called_with(
+ "No pass candidates for URL 'https://www.example.com' found!")
+ qutecommand_mock.assert_not_called()
+
+ def test_main_lpass_failure(self, subprocess_mock, arguments_mock,
+ stderr_mock,
+ qutecommand_mock):
+ """Test correct exit code and message on lpass failure."""
+ # pylint: disable=line-too-long
+ error_message = 'Error: Could not find decryption key. Perhaps you need to login with `lpass login`.'
+ subprocess_mock.return_value = FakeOutput(stderr=error_message)
+
+ arguments_mock.url = default_lpass_match[0]['url']
+ exit_code = qute_lastpass.main(arguments_mock)
+
+ assert exit_code == qute_lastpass.ExitCodes.FAILURE
+ # pylint: disable=line-too-long
+ stderr_mock.assert_called_with(
+ "LastPass CLI returned for www.example.com - Error: Could not find decryption key. Perhaps you need to login with `lpass login`.")
+ qutecommand_mock.assert_not_called()
+
+ def test_main_username_only_flag(self, subprocess_mock, arguments_mock,
+ qutecommand_mock):
+ """Test if --username-only flag sends username only."""
+ subprocess_mock.return_value = FakeOutput(
+ stdout=json.dumps(default_lpass_match))
+
+ arguments_mock.url = default_lpass_match[0]['url']
+ arguments_mock.username_only = True
+ qute_lastpass.main(arguments_mock)
+
+ qutecommand_mock.assert_has_calls([
+ call('fake-key \\f\\a\\k\\e\\@\\f\\a\\k\\e\\.\\c\\o\\m'),
+ call('enter-mode insert')
+ ])
+
+ def test_main_password_only_flag(self, subprocess_mock, arguments_mock,
+ qutecommand_mock):
+ """Test if --password-only flag sends password only."""
+ subprocess_mock.return_value = FakeOutput(
+ stdout=json.dumps(default_lpass_match))
+
+ arguments_mock.url = default_lpass_match[0]['url']
+ arguments_mock.password_only = True
+ qute_lastpass.main(arguments_mock)
+
+ qutecommand_mock.assert_has_calls([
+ call('fake-key \\f\\o\\o\\b\\a\\r'),
+ call('enter-mode insert')
+ ])
+
+ def test_main_multiple_candidates(self, subprocess_mock, arguments_mock,
+ qutecommand_mock):
+ """Test dmenu-invocation when lpass returns multiple candidates."""
+ multiple_matches = default_lpass_match.copy()
+ multiple_matches.append(
+ {
+ "id": "23456",
+ "name": "Sites/www.example.com",
+ "username": "john.doe@fake.com",
+ "password": "barfoo",
+ "url": "https://www.example.com",
+ }
+ )
+
+ lpass_response = FakeOutput(stdout=json.dumps(multiple_matches))
+ dmenu_response = FakeOutput(
+ stdout='23456 | Sites/www.example.com | https://www.example.com | john.doe@fake.com')
+
+ subprocess_mock.side_effect = [lpass_response, dmenu_response]
+
+ arguments_mock.url = multiple_matches[0]['url']
+ exit_code = qute_lastpass.main(arguments_mock)
+
+ assert exit_code == qute_lastpass.ExitCodes.SUCCESS
+
+ subprocess_mock.assert_has_calls([
+ call(['lpass', 'show', '-x', '-j', '-G', '\\bwww\\.example\\.com'],
+ stdout=ANY, stderr=ANY),
+ call(['rofi', '-dmenu'],
+ input=b'12345 | www.example.com | https://www.example.com | fake@fake.com\n23456 | Sites/www.example.com | https://www.example.com | john.doe@fake.com',
+ stdout=ANY)
+ ])
+
+ qutecommand_mock.assert_has_calls([
+ call(
+ 'fake-key \\j\\o\\h\\n\\.\\d\\o\\e\\@\\f\\a\\k\\e\\.\\c\\o\\m'),
+ call('fake-key <Tab>'),
+ call('fake-key \\b\\a\\r\\f\\o\\o'),
+ call('enter-mode insert')
+ ])
+
+ def test_main_merge_candidates(self, subprocess_mock, arguments_mock,
+ qutecommand_mock):
+ """Test merge of multiple responses from lpass."""
+ fqdn_matches = default_lpass_match.copy()
+ fqdn_matches.append(
+ {
+ "id": "23456",
+ "name": "Sites/www.example.com",
+ "username": "john.doe@fake.com",
+ "password": "barfoo",
+ "url": "https://www.example.com",
+ }
+ )
+
+ domain_matches = [
+ {
+ "id": "345",
+ "name": "example.com",
+ "username": "joe.doe@fake.com",
+ "password": "barfoo1",
+ "url": "https://example.com",
+ },
+ {
+ "id": "456",
+ "name": "Sites/example.com",
+ "username": "jane.doe@fake.com",
+ "password": "foofoo2",
+ "url": "http://example.com",
+ }
+ ]
+
+ fqdn_response = FakeOutput(stdout=json.dumps(fqdn_matches))
+ domain_response = FakeOutput(stdout=json.dumps(domain_matches))
+ no_response = FakeOutput(
+ stderr='Error: Could not find specified account(s).')
+ dmenu_response = FakeOutput(
+ stdout='23456 | Sites/www.example.com | https://www.example.com | john.doe@fake.com')
+
+ # lpass command will return results for search against
+ # www.example.com, example.com, but not wwwexample.com and its ipv4
+ subprocess_mock.side_effect = [fqdn_response, domain_response,
+ no_response, no_response,
+ dmenu_response]
+
+ arguments_mock.url = fqdn_matches[0]['url']
+ arguments_mock.merge_candidates = True
+ exit_code = qute_lastpass.main(arguments_mock)
+
+ assert exit_code == qute_lastpass.ExitCodes.SUCCESS
+
+ subprocess_mock.assert_has_calls([
+ call(['lpass', 'show', '-x', '-j', '-G', '\\bwww\\.example\\.com'],
+ stdout=ANY, stderr=ANY),
+ call(['lpass', 'show', '-x', '-j', '-G', '\\bexample\\.com'],
+ stdout=ANY, stderr=ANY),
+ call(['lpass', 'show', '-x', '-j', '-G', '\\bwwwexample'],
+ stdout=ANY, stderr=ANY),
+ call(['lpass', 'show', '-x', '-j', '-G', '\\bexample'],
+ stdout=ANY, stderr=ANY),
+ call(['rofi', '-dmenu'],
+ input=b'12345 | www.example.com | https://www.example.com | fake@fake.com\n23456 | Sites/www.example.com | https://www.example.com | john.doe@fake.com\n345 | example.com | https://example.com | joe.doe@fake.com\n456 | Sites/example.com | http://example.com | jane.doe@fake.com',
+ stdout=ANY)
+ ])
+
+ qutecommand_mock.assert_has_calls([
+ call(
+ 'fake-key \\j\\o\\h\\n\\.\\d\\o\\e\\@\\f\\a\\k\\e\\.\\c\\o\\m'),
+ call('fake-key <Tab>'),
+ call('fake-key \\b\\a\\r\\f\\o\\o'),
+ call('enter-mode insert')
+ ])
diff --git a/tests/unit/scripts/importer_sample/netscape/bookmarks b/tests/unit/scripts/importer_sample/html/bookmarks
index 89d8ed8e9..89d8ed8e9 100644
--- a/tests/unit/scripts/importer_sample/netscape/bookmarks
+++ b/tests/unit/scripts/importer_sample/html/bookmarks
diff --git a/tests/unit/scripts/importer_sample/netscape/config_py b/tests/unit/scripts/importer_sample/html/config_py
index 9232bc372..9232bc372 100644
--- a/tests/unit/scripts/importer_sample/netscape/config_py
+++ b/tests/unit/scripts/importer_sample/html/config_py
diff --git a/tests/unit/scripts/importer_sample/netscape/input b/tests/unit/scripts/importer_sample/html/input
index 1e3cdec31..1e3cdec31 100644
--- a/tests/unit/scripts/importer_sample/netscape/input
+++ b/tests/unit/scripts/importer_sample/html/input
diff --git a/tests/unit/scripts/importer_sample/netscape/quickmarks b/tests/unit/scripts/importer_sample/html/quickmarks
index a43bb338d..a43bb338d 100644
--- a/tests/unit/scripts/importer_sample/netscape/quickmarks
+++ b/tests/unit/scripts/importer_sample/html/quickmarks
diff --git a/tests/unit/scripts/test_check_coverage.py b/tests/unit/scripts/test_check_coverage.py
index bb23db512..ec9d9666b 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
@@ -51,10 +51,12 @@ class CovtestHelper:
"""Run pytest with coverage for the given module.py."""
coveragerc = str(self._testdir.tmpdir / 'coveragerc')
self._monkeypatch.delenv('PYTEST_ADDOPTS', raising=False)
- return self._testdir.runpytest('--cov=module',
- '--cov-config={}'.format(coveragerc),
- '--cov-report=xml',
- plugins=['no:faulthandler'])
+ res = self._testdir.runpytest('--cov=module',
+ '--cov-config={}'.format(coveragerc),
+ '--cov-report=xml',
+ plugins=['no:faulthandler', 'no:xvfb'])
+ assert res.ret == 0
+ return res
def check(self, perfect_files=None):
"""Run check_coverage.py and run its return value."""
@@ -92,6 +94,15 @@ def covtest(testdir, monkeypatch):
def test_module():
func()
""")
+
+ # Check if coverage plugin is available
+ res = testdir.runpytest('--version', '--version')
+ assert res.ret == 0
+ output = res.stderr.str()
+ assert 'This is pytest version' in output
+ if 'pytest-cov' not in output:
+ pytest.skip("cov plugin not available")
+
return CovtestHelper(testdir, monkeypatch)
@@ -216,15 +227,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(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(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..fbf27a074 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():
@@ -100,25 +94,25 @@ def test_chrome_searches(capsys):
assert imported == search_expected('chrome')
-def test_netscape_bookmarks(capsys):
- importer.import_netscape_bookmarks(
- sample_input('netscape'), ['bookmark', 'keyword'], 'bookmark')
+def test_html_bookmarks(capsys):
+ importer.import_html_bookmarks(
+ sample_input('html'), ['bookmark', 'keyword'], 'bookmark')
imported = capsys.readouterr()[0]
- assert imported == bm_expected('netscape')
+ assert imported == bm_expected('html')
-def test_netscape_quickmarks(capsys):
- importer.import_netscape_bookmarks(
- sample_input('netscape'), ['bookmark', 'keyword'], 'quickmark')
+def test_html_quickmarks(capsys):
+ importer.import_html_bookmarks(
+ sample_input('html'), ['bookmark', 'keyword'], 'quickmark')
imported = capsys.readouterr()[0]
- assert imported == qm_expected('netscape')
+ assert imported == qm_expected('html')
-def test_netscape_searches(capsys):
- importer.import_netscape_bookmarks(
- sample_input('netscape'), ['search'], 'search')
+def test_html_searches(capsys):
+ importer.import_html_bookmarks(
+ sample_input('html'), ['search'], 'search')
imported = capsys.readouterr()[0]
- assert imported == search_expected('netscape')
+ assert imported == search_expected('html')
def test_mozilla_bookmarks(capsys):
diff --git a/tests/unit/scripts/test_problemmatchers.py b/tests/unit/scripts/test_problemmatchers.py
new file mode 100644
index 000000000..98bd9c7a5
--- /dev/null
+++ b/tests/unit/scripts/test_problemmatchers.py
@@ -0,0 +1,38 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2015-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 re
+
+import pytest
+
+from scripts.dev.ci import problemmatchers
+
+
+@pytest.mark.parametrize('matcher_name', list(problemmatchers.MATCHERS))
+def test_patterns(matcher_name):
+ """Make sure all regexps are valid.
+
+ They aren't actually Python syntax, but hopefully close enough to it to compile with
+ Python's re anyways.
+ """
+ for matcher in problemmatchers.MATCHERS[matcher_name]:
+ for pattern in matcher['pattern']:
+ regexp = pattern['regexp']
+ print(regexp)
+ re.compile(regexp)
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_javascript.py b/tests/unit/utils/test_javascript.py
index 005b8f86c..fc8267435 100644
--- a/tests/unit/utils/test_javascript.py
+++ b/tests/unit/utils/test_javascript.py
@@ -22,57 +22,76 @@
import pytest
import hypothesis
import hypothesis.strategies
+import attr
-from qutebrowser.utils import javascript
+from qutebrowser.utils import javascript, usertypes
+
+
+@attr.s
+class Case:
+
+ original = attr.ib()
+ replacement = attr.ib()
+ webkit_only = attr.ib(False)
+
+ def __str__(self):
+ return self.original
class TestStringEscape:
- TESTS = {
- 'foo\\bar': r'foo\\bar',
- 'foo\nbar': r'foo\nbar',
- 'foo\rbar': r'foo\rbar',
- "foo'bar": r"foo\'bar",
- 'foo"bar': r'foo\"bar',
- 'one\\two\rthree\nfour\'five"six': r'one\\two\rthree\nfour\'five\"six',
- '\x00': r'\x00',
- 'hellö': 'hellö',
- '☃': '☃',
- '\x80Ā': '\x80Ā',
- '𐀀\x00𐀀\x00': r'𐀀\x00𐀀\x00',
- '𐀀\ufeff': r'𐀀\ufeff',
- '\ufeff': r'\ufeff',
+ TESTS = [
+ Case('foo\\bar', r'foo\\bar'),
+ Case('foo\nbar', r'foo\nbar'),
+ Case('foo\rbar', r'foo\rbar'),
+ Case("foo'bar", r"foo\'bar"),
+ Case('foo"bar', r'foo\"bar'),
+ Case('one\\two\rthree\nfour\'five"six', r'one\\two\rthree\nfour\'five\"six'),
+ Case('\x00', r'\x00', webkit_only=True),
+ Case('hellö', 'hellö'),
+ Case('☃', '☃'),
+ Case('\x80Ā', '\x80Ā'),
+ Case('𐀀\x00𐀀\x00', r'𐀀\x00𐀀\x00', webkit_only=True),
+ Case('𐀀\ufeff', r'𐀀\ufeff'),
+ Case('\ufeff', r'\ufeff', webkit_only=True),
# http://stackoverflow.com/questions/2965293/
- '\u2028': r'\u2028',
- '\u2029': r'\u2029',
- }
+ Case('\u2028', r'\u2028'),
+ Case('\u2029', r'\u2029'),
+ ]
# Once there was this warning here:
# load glyph failed err=6 face=0x2680ba0, glyph=1912
# http://qutebrowser.org:8010/builders/debian-jessie/builds/765/steps/unittests/
# Should that be ignored?
- @pytest.mark.parametrize('before, after', sorted(TESTS.items()), ids=repr)
- def test_fake_escape(self, before, after):
+ @pytest.mark.parametrize('case', TESTS, ids=str)
+ def test_fake_escape(self, case):
"""Test javascript escaping with some expected outcomes."""
- assert javascript.string_escape(before) == after
+ assert javascript.string_escape(case.original) == case.replacement
- def _test_escape(self, text, webframe):
- """Test conversion by using evaluateJavaScript."""
+ def _test_escape(self, text, web_tab, qtbot):
+ """Test conversion by running JS in a tab."""
escaped = javascript.string_escape(text)
- result = webframe.evaluateJavaScript('"{}";'.format(escaped))
- assert result == text
- @pytest.mark.parametrize('text', sorted(TESTS), ids=repr)
- def test_real_escape(self, webframe, text):
+ with qtbot.waitCallback() as cb:
+ web_tab.run_js_async('"{}";'.format(escaped), cb)
+
+ cb.assert_called_with(text)
+
+ @pytest.mark.parametrize('case', TESTS, ids=str)
+ def test_real_escape(self, web_tab, qtbot, case):
"""Test javascript escaping with a real QWebPage."""
- self._test_escape(text, webframe)
+ if web_tab.backend == usertypes.Backend.QtWebEngine and case.webkit_only:
+ pytest.xfail("Not supported with QtWebEngine")
+ self._test_escape(case.original, web_tab, qtbot)
@pytest.mark.qt_log_ignore('^OpenType support missing for script')
@hypothesis.given(hypothesis.strategies.text())
- def test_real_escape_hypothesis(self, webframe, text):
+ def test_real_escape_hypothesis(self, web_tab, qtbot, text):
"""Test javascript escaping with a real QWebPage and hypothesis."""
- self._test_escape(text, webframe)
+ if web_tab.backend == usertypes.Backend.QtWebEngine:
+ hypothesis.assume('\x00' not in text)
+ self._test_escape(text, web_tab, qtbot)
@pytest.mark.parametrize('arg, expected', [
diff --git a/tests/unit/utils/test_jinja.py b/tests/unit/utils/test_jinja.py
index c398118cb..b64b8e0fe 100644
--- a/tests/unit/utils/test_jinja.py
+++ b/tests/unit/utils/test_jinja.py
@@ -115,7 +115,7 @@ def test_not_found(caplog):
def test_utf8():
- """Test rendering with an UTF8 template.
+ """Test rendering with a UTF8 template.
This was an attempt to get a failing test case for #127 but it seems
the issue is elsewhere.
diff --git a/tests/unit/utils/test_log.py b/tests/unit/utils/test_log.py
index a74d81600..8e8fa47a4 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:
@@ -294,9 +384,9 @@ def test_stub(caplog, suffix, expected):
assert caplog.messages == [expected]
-def test_ignore_py_warnings(caplog):
+def test_py_warning_filter(caplog):
logging.captureWarnings(True)
- with log.ignore_py_warnings(category=UserWarning):
+ with log.py_warning_filter(category=UserWarning):
warnings.warn("hidden", UserWarning)
with caplog.at_level(logging.WARNING):
warnings.warn("not hidden", UserWarning)
@@ -305,6 +395,21 @@ def test_ignore_py_warnings(caplog):
assert msg.endswith("UserWarning: not hidden")
+def test_py_warning_filter_error(caplog):
+ warnings.simplefilter('ignore')
+ warnings.warn("hidden", UserWarning)
+
+ with log.py_warning_filter('error'):
+ with pytest.raises(UserWarning):
+ warnings.warn("error", UserWarning)
+
+
+def test_warning_still_errors():
+ # Mainly a sanity check after the tests messing with warnings above.
+ with pytest.raises(UserWarning):
+ warnings.warn("error", UserWarning)
+
+
class TestQtMessageHandler:
@attr.s
@@ -317,6 +422,12 @@ class TestQtMessageHandler:
file = attr.ib(default=None)
line = attr.ib(default=None)
+ @pytest.fixture(autouse=True)
+ def init_args(self):
+ parser = qutebrowser.get_argparser()
+ args = parser.parse_args([])
+ log.init_log(args)
+
def test_empty_message(self, caplog):
"""Make sure there's no crash with an empty message."""
log.qt_message_handler(QtCore.QtDebugMsg, self.Context(), "")
diff --git a/tests/unit/utils/test_qtutils.py b/tests/unit/utils/test_qtutils.py
index 150a03f6e..2e54fb42e 100644
--- a/tests/unit/utils/test_qtutils.py
+++ b/tests/unit/utils/test_qtutils.py
@@ -26,6 +26,7 @@ import os.path
import unittest
import unittest.mock
+import attr
import pytest
from PyQt5.QtCore import (QDataStream, QPoint, QUrl, QByteArray, QIODevice,
QTimer, QBuffer, QFile, QProcess, QFileDevice)
@@ -53,23 +54,25 @@ else:
@pytest.mark.parametrize(['qversion', 'compiled', 'pyqt', 'version', 'exact',
'expected'], [
# equal versions
- ('5.4.0', None, None, '5.4.0', False, True),
- ('5.4.0', None, None, '5.4.0', True, True), # exact=True
- ('5.4.0', None, None, '5.4', True, True), # without trailing 0
+ ('5.14.0', None, None, '5.14.0', False, True),
+ ('5.14.0', None, None, '5.14.0', True, True), # exact=True
+ ('5.14.0', None, None, '5.14', True, True), # without trailing 0
# newer version installed
- ('5.4.1', None, None, '5.4', False, True),
- ('5.4.1', None, None, '5.4', True, False), # exact=True
+ ('5.14.1', None, None, '5.14', False, True),
+ ('5.14.1', None, None, '5.14', True, False), # exact=True
# older version installed
- ('5.3.2', None, None, '5.4', False, False),
- ('5.3.0', None, None, '5.3.2', False, False),
- ('5.3.0', None, None, '5.3.2', True, False), # exact=True
+ ('5.13.2', None, None, '5.14', False, False),
+ ('5.13.0', None, None, '5.13.2', False, False),
+ ('5.13.0', None, None, '5.13.2', True, False), # exact=True
# compiled=True
# new Qt runtime, but compiled against older version
- ('5.4.0', '5.3.0', '5.4.0', '5.4.0', False, False),
+ ('5.14.0', '5.13.0', '5.14.0', '5.14.0', False, False),
# new Qt runtime, compiled against new version, but old PyQt
- ('5.4.0', '5.4.0', '5.3.0', '5.4.0', False, False),
+ ('5.14.0', '5.14.0', '5.13.0', '5.14.0', False, False),
# all up-to-date
- ('5.4.0', '5.4.0', '5.4.0', '5.4.0', False, True),
+ ('5.14.0', '5.14.0', '5.14.0', '5.14.0', False, True),
+ # dev suffix
+ ('5.15.1', '5.15.1', '5.15.2.dev2009281246', '5.15.0', False, True),
])
# pylint: enable=bad-continuation
def test_version_check(monkeypatch, qversion, compiled, pyqt, version, exact,
@@ -467,9 +470,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):
@@ -936,3 +939,107 @@ class TestEventLoop:
QTimer.singleShot(400, self.loop.quit)
self.loop.exec_()
assert not self.loop._executing
+
+
+class Color(QColor):
+
+ """A QColor with a nicer repr()."""
+
+ def __repr__(self):
+ return utils.get_repr(self, constructor=True, red=self.red(),
+ green=self.green(), blue=self.blue(),
+ alpha=self.alpha())
+
+
+class TestInterpolateColor:
+
+ @attr.s
+ class Colors:
+
+ white = attr.ib()
+ black = attr.ib()
+
+ @pytest.fixture
+ def colors(self):
+ """Example colors to be used."""
+ return self.Colors(Color('white'), Color('black'))
+
+ def test_invalid_start(self, colors):
+ """Test an invalid start color."""
+ with pytest.raises(qtutils.QtValueError):
+ qtutils.interpolate_color(Color(), colors.white, 0)
+
+ def test_invalid_end(self, colors):
+ """Test an invalid end color."""
+ with pytest.raises(qtutils.QtValueError):
+ qtutils.interpolate_color(colors.white, Color(), 0)
+
+ @pytest.mark.parametrize('perc', [-1, 101])
+ def test_invalid_percentage(self, colors, perc):
+ """Test an invalid percentage."""
+ with pytest.raises(ValueError):
+ qtutils.interpolate_color(colors.white, colors.white, perc)
+
+ def test_invalid_colorspace(self, colors):
+ """Test an invalid colorspace."""
+ with pytest.raises(ValueError):
+ qtutils.interpolate_color(colors.white, colors.black, 10, QColor.Cmyk)
+
+ @pytest.mark.parametrize('colorspace', [QColor.Rgb, QColor.Hsv,
+ QColor.Hsl])
+ def test_0_100(self, colors, colorspace):
+ """Test 0% and 100% in different colorspaces."""
+ white = qtutils.interpolate_color(colors.white, colors.black, 0, colorspace)
+ black = qtutils.interpolate_color(colors.white, colors.black, 100, colorspace)
+ assert Color(white) == colors.white
+ assert Color(black) == colors.black
+
+ def test_interpolation_rgb(self):
+ """Test an interpolation in the RGB colorspace."""
+ color = qtutils.interpolate_color(
+ Color(0, 40, 100), Color(0, 20, 200), 50, QColor.Rgb)
+ assert Color(color) == Color(0, 30, 150)
+
+ def test_interpolation_hsv(self):
+ """Test an interpolation in the HSV colorspace."""
+ start = Color()
+ stop = Color()
+ start.setHsv(0, 40, 100)
+ stop.setHsv(0, 20, 200)
+ color = qtutils.interpolate_color(start, stop, 50, QColor.Hsv)
+ expected = Color()
+ expected.setHsv(0, 30, 150)
+ assert Color(color) == expected
+
+ def test_interpolation_hsl(self):
+ """Test an interpolation in the HSL colorspace."""
+ start = Color()
+ stop = Color()
+ start.setHsl(0, 40, 100)
+ stop.setHsl(0, 20, 200)
+ color = qtutils.interpolate_color(start, stop, 50, QColor.Hsl)
+ expected = Color()
+ expected.setHsl(0, 30, 150)
+ assert Color(color) == expected
+
+ @pytest.mark.parametrize('colorspace', [QColor.Rgb, QColor.Hsv,
+ QColor.Hsl])
+ def test_interpolation_alpha(self, colorspace):
+ """Test interpolation of colorspace's alpha."""
+ start = Color(0, 0, 0, 30)
+ stop = Color(0, 0, 0, 100)
+ color = qtutils.interpolate_color(start, stop, 50, colorspace)
+ expected = Color(0, 0, 0, 65)
+ assert Color(color) == expected
+
+ @pytest.mark.parametrize('percentage, expected', [
+ (0, (0, 0, 0)),
+ (99, (0, 0, 0)),
+ (100, (255, 255, 255)),
+ ])
+ def test_interpolation_none(self, percentage, expected):
+ """Test an interpolation with a gradient turned off."""
+ color = qtutils.interpolate_color(
+ Color(0, 0, 0), Color(255, 255, 255), percentage, None)
+ assert isinstance(color, QColor)
+ assert Color(color) == Color(*expected)
diff --git a/tests/unit/utils/test_standarddir.py b/tests/unit/utils/test_standarddir.py
index e4d8575fe..dbc8831f6 100644
--- a/tests/unit/utils/test_standarddir.py
+++ b/tests/unit/utils/test_standarddir.py
@@ -28,7 +28,6 @@ import textwrap
import logging
import subprocess
-import attr
from PyQt5.QtCore import QStandardPaths
import pytest
@@ -79,6 +78,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))
@@ -107,7 +107,7 @@ def test_fake_windows(tmpdir, monkeypatch, what):
def test_fake_haiku(tmpdir, monkeypatch):
"""Test getting data dir on HaikuOS."""
locations = {
- QStandardPaths.DataLocation: '',
+ QStandardPaths.AppDataLocation: '',
QStandardPaths.ConfigLocation: str(tmpdir / 'config' / APPNAME),
}
monkeypatch.setattr(standarddir.QStandardPaths, 'writableLocation',
@@ -128,40 +128,43 @@ class TestWritableLocation:
'qutebrowser.utils.standarddir.QStandardPaths.writableLocation',
lambda typ: '')
with pytest.raises(standarddir.EmptyValueError):
- standarddir._writable_location(QStandardPaths.DataLocation)
+ standarddir._writable_location(QStandardPaths.AppDataLocation)
def test_sep(self, monkeypatch):
"""Make sure the right kind of separator is used."""
monkeypatch.setattr(standarddir.os, 'sep', '\\')
monkeypatch.setattr(standarddir.os.path, 'join',
lambda *parts: '\\'.join(parts))
- loc = standarddir._writable_location(QStandardPaths.DataLocation)
+ loc = standarddir._writable_location(QStandardPaths.AppDataLocation)
assert '/' not in loc
assert '\\' in loc
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 +195,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')
@@ -304,14 +307,12 @@ class TestInitCacheDirTag:
# http://www.brynosaurus.com/cachedir/
""").lstrip()
- def test_open_oserror(self, caplog, tmpdir, mocker, monkeypatch):
+ def test_open_oserror(self, caplog, unwritable_tmp_path, monkeypatch):
"""Test creating a new CACHEDIR.TAG."""
- monkeypatch.setattr(standarddir, 'cache', lambda: str(tmpdir))
- mocker.patch('builtins.open', side_effect=OSError)
+ monkeypatch.setattr(standarddir, 'cache', lambda: str(unwritable_tmp_path))
with caplog.at_level(logging.ERROR, 'init'):
standarddir._init_cachedir_tag()
assert caplog.messages == ['Failed to create CACHEDIR.TAG']
- assert not tmpdir.listdir()
class TestCreatingDir:
@@ -370,10 +371,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,148 +384,28 @@ 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,
fake_args):
"""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)
+ monkeypatch.setattr(sys, 'platform', 'potato')
+ standarddir._init_data(args=fake_args)
assert standarddir.data(system=True) == standarddir.data()
-class TestMoveWindowsAndMacOS:
-
- """Test other invocations of _move_data."""
-
- @pytest.fixture(autouse=True)
- def patch_standardpaths(self, files, monkeypatch):
- locations = {
- QStandardPaths.DataLocation: str(files.local_data_dir),
- QStandardPaths.AppDataLocation: str(files.roaming_data_dir),
- }
- monkeypatch.setattr(standarddir.QStandardPaths, 'writableLocation',
- locations.get)
- monkeypatch.setattr(
- standarddir, 'config', lambda auto=False:
- str(files.auto_config_dir if auto else files.config_dir))
-
- @pytest.fixture
- def files(self, tmpdir):
-
- @attr.s
- class Files:
-
- auto_config_dir = attr.ib()
- config_dir = attr.ib()
- local_data_dir = attr.ib()
- roaming_data_dir = attr.ib()
-
- return Files(
- auto_config_dir=tmpdir / 'auto_config' / APPNAME,
- config_dir=tmpdir / 'config' / APPNAME,
- local_data_dir=tmpdir / 'data' / APPNAME,
- roaming_data_dir=tmpdir / 'roaming-data' / APPNAME,
- )
-
- def test_move_macos(self, files):
- """Test moving configs on macOS."""
- (files.auto_config_dir / 'autoconfig.yml').ensure()
- (files.auto_config_dir / 'quickmarks').ensure()
- files.config_dir.ensure(dir=True)
-
- standarddir._move_macos()
-
- assert (files.auto_config_dir / 'autoconfig.yml').exists()
- assert not (files.config_dir / 'autoconfig.yml').exists()
- assert not (files.auto_config_dir / 'quickmarks').exists()
- assert (files.config_dir / 'quickmarks').exists()
-
- def test_move_windows(self, files):
- """Test moving configs on Windows."""
- (files.local_data_dir / 'data' / 'blocked-hosts').ensure()
- (files.local_data_dir / 'qutebrowser.conf').ensure()
- (files.local_data_dir / 'cache' / 'cachefile').ensure()
-
- standarddir._move_windows()
-
- assert (files.roaming_data_dir / 'data' / 'blocked-hosts').exists()
- assert (files.roaming_data_dir / 'config' /
- 'qutebrowser.conf').exists()
- assert not (files.roaming_data_dir / 'cache').exists()
- assert (files.local_data_dir / 'cache' / 'cachefile').exists()
-
-
-class TestMove:
-
- @pytest.fixture
- def dirs(self, tmpdir):
- @attr.s
- class Dirs:
-
- old = attr.ib()
- new = attr.ib()
- old_file = attr.ib()
- new_file = attr.ib()
-
- old_dir = tmpdir / 'old'
- new_dir = tmpdir / 'new'
- return Dirs(old=old_dir, new=new_dir,
- old_file=old_dir / 'file', new_file=new_dir / 'file')
-
- def test_no_old_dir(self, dirs, caplog):
- """Nothing should happen without any old directory."""
- standarddir._move_data(str(dirs.old), str(dirs.new))
- assert not any(message.startswith('Migrating data from')
- for message in caplog.messages)
-
- @pytest.mark.parametrize('empty_dest', [True, False])
- def test_moving_data(self, dirs, empty_dest):
- dirs.old_file.ensure()
- if empty_dest:
- dirs.new.ensure(dir=True)
-
- standarddir._move_data(str(dirs.old), str(dirs.new))
- assert not dirs.old_file.exists()
- assert dirs.new_file.exists()
-
- def test_already_existing(self, dirs, caplog):
- dirs.old_file.ensure()
- dirs.new_file.ensure()
-
- with caplog.at_level(logging.ERROR):
- standarddir._move_data(str(dirs.old), str(dirs.new))
-
- expected = "Failed to move data from {} as {} is non-empty!".format(
- dirs.old, dirs.new)
- assert caplog.messages[-1] == expected
-
- def test_deleting_error(self, dirs, monkeypatch, mocker, caplog):
- """When there was an error it should be logged."""
- mock = mocker.Mock(side_effect=OSError('error'))
- monkeypatch.setattr(standarddir.shutil, 'move', mock)
- dirs.old_file.ensure()
-
- with caplog.at_level(logging.ERROR):
- standarddir._move_data(str(dirs.old), str(dirs.new))
-
- expected = "Failed to move data from {} to {}: error".format(
- dirs.old, dirs.new)
- assert caplog.messages[-1] == expected
-
-
@pytest.mark.parametrize('args_kind', ['basedir', 'normal', 'none'])
-def test_init(mocker, tmpdir, args_kind):
+def test_init(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 == {}
- m_windows = mocker.patch('qutebrowser.utils.standarddir._move_windows')
- m_mac = mocker.patch('qutebrowser.utils.standarddir._move_macos')
+ monkeypatch.setenv('HOME', str(tmpdir))
+
if args_kind == 'normal':
args = types.SimpleNamespace(basedir=None)
elif args_kind == 'basedir':
@@ -535,19 +417,6 @@ def test_init(mocker, tmpdir, args_kind):
standarddir.init(args)
assert standarddir._locations != {}
- if args_kind == 'normal':
- if utils.is_mac:
- m_windows.assert_not_called()
- assert m_mac.called
- elif utils.is_windows:
- assert m_windows.called
- m_mac.assert_not_called()
- else:
- m_windows.assert_not_called()
- m_mac.assert_not_called()
- else:
- m_windows.assert_not_called()
- m_mac.assert_not_called()
@pytest.mark.linux
@@ -562,11 +431,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 +453,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_urlmatch.py b/tests/unit/utils/test_urlmatch.py
index 416079130..c38794c40 100644
--- a/tests/unit/utils/test_urlmatch.py
+++ b/tests/unit/utils/test_urlmatch.py
@@ -23,13 +23,11 @@ The tests are mostly inspired by Chromium's:
https://cs.chromium.org/chromium/src/extensions/common/url_pattern_unittest.cc
Currently not tested:
-- The match_effective_tld attribute as it doesn't exist yet.
- Nested filesystem:// URLs as we don't have those.
- Unicode matching because QUrl doesn't like those URLs.
- Any other features we don't need, such as .GetAsString() or set operations.
"""
-import sys
import string
import pytest
@@ -41,55 +39,68 @@ from qutebrowser.utils import urlmatch
@pytest.mark.parametrize('pattern, error', [
- # Chromium: PARSE_ERROR_MISSING_SCHEME_SEPARATOR
+ ### Chromium: kMissingSchemeSeparator
+ ## TEST(ExtensionURLPatternTest, ParseInvalid)
# ("http", "No scheme given"),
("http:", "Invalid port: Port is empty"),
("http:/", "Invalid port: Port is empty"),
("about://", "Pattern without path"),
("http:/bar", "Invalid port: Port is empty"),
- # Chromium: PARSE_ERROR_EMPTY_HOST
+ ### Chromium: kEmptyHost
+ ## TEST(ExtensionURLPatternTest, ParseInvalid)
("http://", "Pattern without host"),
("http:///", "Pattern without host"),
- ("http:// /", "Pattern without host"),
("http://:1234/", "Pattern without host"),
+ ("http://*./", "Pattern without host"),
+ ## TEST(ExtensionURLPatternTest, IPv6Patterns)
+ ("http://[]:8888/*", "Pattern without host"),
- # Chromium: PARSE_ERROR_EMPTY_PATH
+ ### Chromium: kEmptyPath
+ ## TEST(ExtensionURLPatternTest, ParseInvalid)
# We deviate from Chromium and allow this for ease of use
# ("http://bar", "..."),
- # Chromium: PARSE_ERROR_INVALID_HOST
+ ### Chromium: kInvalidHost
+ ## TEST(ExtensionURLPatternTest, ParseInvalid)
("http://\0www/", "May not contain NUL byte"),
-
- # Chromium: PARSE_ERROR_INVALID_HOST_WILDCARD
+ ## TEST(ExtensionURLPatternTest, IPv6Patterns)
+ # No closing bracket (`]`).
+ ("http://[2607:f8b0:4005:805::200e/*", "Invalid IPv6 URL"),
+ # Two closing brackets (`]]`).
+ pytest.param("http://[2607:f8b0:4005:805::200e]]/*", "Invalid IPv6 URL", marks=pytest.mark.xfail(reason="https://bugs.python.org/issue34360")),
+ # Two open brackets (`[[`).
+ ("http://[[2607:f8b0:4005:805::200e]/*", r"""Expected '\]' to match '\[' in hostname; source was "\[2607:f8b0:4005:805::200e"; host = """""),
+ # Too few colons in the last chunk.
+ ("http://[2607:f8b0:4005:805:200e]/*", 'Invalid IPv6 address; source was "2607:f8b0:4005:805:200e"; host = ""'),
+ # Non-hex piece.
+ ("http://[2607:f8b0:4005:805:200e:12:bogus]/*", 'Invalid IPv6 address; source was "2607:f8b0:4005:805:200e:12:bogus"; host = ""'),
+
+ ### Chromium: kInvalidHostWildcard
+ ## TEST(ExtensionURLPatternTest, ParseInvalid)
("http://*foo/bar", "Invalid host wildcard"),
("http://foo.*.bar/baz", "Invalid host wildcard"),
("http://fo.*.ba:123/baz", "Invalid host wildcard"),
- ("http://foo.*/bar", "TLD wildcards are not implemented yet"),
+ ("http://foo.*/bar", "Invalid host wildcard"),
- # Chromium: PARSE_ERROR_INVALID_PORT
+ ### Chromium: kInvalidPort
+ ## TEST(ExtensionURLPatternTest, Ports)
("http://foo:/", "Invalid port: Port is empty"),
("http://*.foo:/", "Invalid port: Port is empty"),
("http://foo:com/", "Invalid port: .* 'com'"),
- pytest.param("http://foo:123456/",
- "Invalid port: Port out of range 0-65535",
- marks=pytest.mark.skipif(
- sys.hexversion < 0x03060000,
- reason="Doesn't show an error on Python 3.5")),
+ ("http://foo:123456/", "Invalid port: Port out of range 0-65535"),
("http://foo:80:80/monkey", "Invalid port: .* '80:80'"),
("chrome://foo:1234/bar", "Ports are unsupported with chrome scheme"),
+ # No port specified, but port separator.
+ ("http://[2607:f8b0:4005:805::200e]:/*", "Invalid port: Port is empty"),
- # Additional tests
+ ### Additional tests
("http://[", "Invalid IPv6 URL"),
- ("http://[fc2e:bb88::edac]:", "Invalid port: Port is empty"),
("http://[fc2e::bb88::edac]", 'Invalid IPv6 address; source was "fc2e::bb88::edac"; host = ""'),
("http://[fc2e:0e35:bb88::edac:fc2e:0e35:bb88:edac]", 'Invalid IPv6 address; source was "fc2e:0e35:bb88::edac:fc2e:0e35:bb88:edac"; host = ""'),
("http://[fc2e:0e35:bb88:af:edac:fc2e:0e35:bb88:edac]", 'Invalid IPv6 address; source was "fc2e:0e35:bb88:af:edac:fc2e:0e35:bb88:edac"; host = ""'),
("http://[127.0.0.1:fc2e::bb88:edac]", r'Invalid IPv6 address; source was "127\.0\.0\.1:fc2e::bb88:edac'),
- ("http://[]:20", "Pattern without host"),
("http://[fc2e::bb88", "Invalid IPv6 URL"),
- ("http://[[fc2e::bb88:edac]", r"""Expected '\]' to match '\[' in hostname; source was "\[fc2e::bb88:edac"; host = """""),
- pytest.param("http://[fc2e::bb88:edac]]", "Invalid IPv6 URL", marks=pytest.mark.xfail(reason="https://bugs.python.org/issue34360")),
("http://[fc2e:bb88:edac]", 'Invalid IPv6 address; source was "fc2e:bb88:edac"; host = ""'),
("http://[fc2e:bb88:edac::z]", 'Invalid IPv6 address; source was "fc2e:bb88:edac::z"; host = ""'),
("http://[fc2e:bb88:edac::2]:2a2", "Invalid port: .* '2a2'"),
@@ -100,7 +111,23 @@ def test_invalid_patterns(pattern, error):
urlmatch.UrlPattern(pattern)
+@pytest.mark.parametrize('host', ['.', ' ', ' .', '. ', '. .', '. . .', ' . '])
+def test_whitespace_hosts(host):
+ """Test that whitespace dot hosts are invalid.
+
+ This is a deviation from Chromium.
+ """
+ template = 'https://{}/*'
+ url = QUrl(template.format(host))
+ assert not url.isValid()
+
+ with pytest.raises(urlmatch.ParseError,
+ match='Invalid host|Pattern without host'):
+ urlmatch.UrlPattern(template.format(host))
+
+
@pytest.mark.parametrize('pattern, port', [
+ ## TEST(ExtensionURLPatternTest, Ports)
("http://foo:1234/", 1234),
("http://foo:1234/bar", 1234),
("http://*.foo:1234/", 1234),
@@ -109,13 +136,10 @@ def test_invalid_patterns(pattern, error):
("http://*:*/", None),
("http://foo:*/", None),
("file://foo:1234/bar", None),
-
# Port-like strings in the path should not trigger a warning.
("http://*/:1234", None),
("http://*.foo/bar:1234", None),
("http://foo/bar:1234/path", None),
- # We don't implement ALLOW_WILDCARD_FOR_EFFECTIVE_TLD yet.
- # ("http://*.foo.*/:1234", None),
])
def test_port(pattern, port):
up = urlmatch.UrlPattern(pattern)
@@ -151,6 +175,8 @@ def test_lightweight_patterns(pattern, scheme, host, path):
class TestMatchAllPagesForGivenScheme:
+ """Based on TEST(ExtensionURLPatternTest, Match1)."""
+
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("http://*/*")
@@ -164,12 +190,13 @@ class TestMatchAllPagesForGivenScheme:
@pytest.mark.parametrize('url, expected', [
("http://google.com", True),
- ("http://google.com:80", True),
- ("http://google.com.", True),
("http://yahoo.com", True),
("http://google.com/foo", True),
("https://google.com", False),
("http://74.125.127.100/search", True),
+ # Additional tests
+ ("http://google.com:80", True),
+ ("http://google.com.", True),
("http://[fc2e:0e35:bb88::edac]", True),
("http://[fc2e:e35:bb88::edac]", True),
("http://[fc2e:e35:bb88::127.0.0.1]", True),
@@ -181,6 +208,8 @@ class TestMatchAllPagesForGivenScheme:
class TestMatchAllDomains:
+ """Based on TEST(ExtensionURLPatternTest, Match2)."""
+
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("https://*/foo*")
@@ -204,6 +233,8 @@ class TestMatchAllDomains:
class TestMatchSubdomains:
+ """Based on TEST(ExtensionURLPatternTest, Match3)."""
+
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("http://*.google.com/foo*bar")
@@ -229,6 +260,8 @@ class TestMatchSubdomains:
class TestMatchGlobEscaping:
+ """Based on TEST(ExtensionURLPatternTest, Match5)."""
+
@pytest.fixture
def up(self):
return urlmatch.UrlPattern(r"file:///foo-bar\*baz")
@@ -241,6 +274,7 @@ class TestMatchGlobEscaping:
assert up._path == r'/foo-bar\*baz'
@pytest.mark.parametrize('url, expected', [
+ ## TEST(ExtensionURLPatternTest, Match5)
# We use - instead of ? so it doesn't get treated as query
(r"file:///foo-bar\hellobaz", True),
(r"file:///fooXbar\hellobaz", False),
@@ -251,9 +285,12 @@ class TestMatchGlobEscaping:
class TestMatchIpAddresses:
+ """Based on TEST(ExtensionURLPatternTest, Match6/7)."""
+
@pytest.mark.parametrize('pattern, host, match_subdomains', [
("http://127.0.0.1/*", "127.0.0.1", False),
("http://*.0.0.1/*", "0.0.1", True),
+ ## Others
("http://[::1]/*", "::1", False),
("http://[0::1]/*", "::1", False),
("http://[::01]/*", "::1", False),
@@ -277,8 +314,13 @@ class TestMatchIpAddresses:
assert up.matches(QUrl("http://127.0.0.1")) == expected
+## FIXME Missing TEST(ExtensionURLPatternTest, Match8) (unicode)?
+
+
class TestMatchChromeUrls:
+ """Based on TEST(ExtensionURLPatternTest, Match9/10)."""
+
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("chrome://favicon/*")
@@ -301,6 +343,8 @@ class TestMatchChromeUrls:
class TestMatchAnything:
+ """Based on TEST(ExtensionURLPatternTest, Match10/11)."""
+
@pytest.fixture(params=['*://*/*', '*://*:*/*', '<all_urls>', '*://*'])
def up(self, request):
return urlmatch.UrlPattern(request.param)
@@ -329,6 +373,7 @@ class TestMatchAnything:
"qute://version",
"about:blank",
"data:text/html;charset=utf-8,<html>asdf</html>",
+ "javascript:",
])
def test_urls(self, up, url):
assert up.matches(QUrl(url))
@@ -343,11 +388,14 @@ class TestMatchAnything:
("data:*", "about:blank", False),
])
def test_special_schemes(pattern, url, expected):
+ """Based on TEST(ExtensionURLPatternTest, Match13)."""
assert urlmatch.UrlPattern(pattern).matches(QUrl(url)) == expected
class TestFileScheme:
+ """Based on TEST(ExtensionURLPatternTest, Match14/15/16)."""
+
@pytest.fixture(params=[
'file:///foo*',
'file://foo*',
@@ -378,6 +426,8 @@ class TestFileScheme:
class TestMatchSpecificPort:
+ """Based on TEST(ExtensionURLPatternTest, Match17)."""
+
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("http://www.example.com:80/foo")
@@ -401,6 +451,8 @@ class TestMatchSpecificPort:
class TestExplicitPortWildcard:
+ """Based on TEST(ExtensionURLPatternTest, Match18)."""
+
@pytest.fixture
def up(self):
return urlmatch.UrlPattern("http://www.example.com:*/foo")
@@ -423,6 +475,7 @@ class TestExplicitPortWildcard:
def test_ignore_missing_slashes():
+ """Based on TEST(ExtensionURLPatternTest, IgnoreMissingBackslashes)."""
pattern1 = urlmatch.UrlPattern("http://www.example.com/example")
pattern2 = urlmatch.UrlPattern("http://www.example.com/example/*")
url1 = QUrl('http://www.example.com/example')
@@ -468,6 +521,70 @@ def test_trailing_dot_domain(pattern, url):
assert urlmatch.UrlPattern(pattern).matches(QUrl(url))
+class TestUncanonicalizedUrl:
+
+ """Test that URLPattern properly canonicalizes uncanonicalized hosts.
+
+ Equivalent to Chromium's TEST(ExtensionURLPatternTest, UncanonicalizedUrl).
+ """
+
+ @pytest.mark.parametrize('url', [
+ 'https://google.com',
+ 'https://maps.google.com',
+ ])
+ def test_lowercase(self, url):
+ """Simple case: canonicalization should lowercase the host.
+
+ This is important, since gOoGle.com would never be matched in
+ practice.
+ """
+ pattern = urlmatch.UrlPattern('*://*.gOoGle.com/*')
+ assert pattern.matches(QUrl(url))
+
+ @pytest.mark.parametrize('url', [
+ 'https://ɡoogle.com',
+ 'https://xn--oogle-qmc.com/',
+ ])
+ def test_punycode(self, url):
+ """Trickier case: internationalization with UTF8 characters.
+
+ The first 'g' isn't actually a 'g'.
+ """
+ pattern = urlmatch.UrlPattern('https://*.ɡoogle.com/*')
+ assert pattern.matches(QUrl(url))
+
+ @pytest.mark.xfail(reason="Gets accepted by urllib.parse")
+ def test_failing_canonicalization(self):
+ """Sometimes, canonicalization can fail.
+
+ Such as here, where we have invalid unicode characters. In that case,
+ URLPattern parsing should also fail.
+
+ This fails in Chromium, but Python's urllib.parse.urlparse happily
+ tries to parse it...
+ """
+ with pytest.raises(urlmatch.ParseError):
+ urlmatch.UrlPattern('https://\xef\xb7\x90zyx.com/*')
+
+ @pytest.mark.xfail(reason="We return the original string")
+ @pytest.mark.parametrize('pattern_str, string, host', [
+ ('*://*.gOoGle.com/*',
+ '*://*.google.com/*',
+ 'google.com'),
+ ('https://*.ɡoogle.com/*',
+ 'https://*.xn--oogle-qmc.com/*',
+ 'xn--oogle-qmc.com'),
+ ])
+ def test_str(self, pattern_str, string, host):
+ """Test that str() and .host get the canonicalized string.
+
+ Contrary to Chromium, we return the original values here.
+ """
+ pattern = urlmatch.UrlPattern(pattern_str)
+ assert str(pattern) == string
+ assert pattern.host == host
+
+
def test_urlpattern_benchmark(benchmark):
url = QUrl('https://www.example.com/barfoobar')
@@ -484,9 +601,9 @@ URL_TEXT = hst.text(alphabet=string.ascii_letters)
@hypothesis.given(pattern=hst.builds(
lambda *a: ''.join(a),
# Scheme
- hst.one_of(hst.just('*'), hst.just('http'), hst.just('file')),
+ hst.sampled_from(['*', 'http', 'file']),
# Separator
- hst.one_of(hst.just(':'), hst.just('://')),
+ hst.sampled_from([':', '://']),
# Host
hst.one_of(hst.just('*'),
hst.builds(lambda *a: ''.join(a), hst.just('*.'), URL_TEXT),
diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py
index 39a43479b..13c94d00e 100644
--- a/tests/unit/utils/test_urlutils.py
+++ b/tests/unit/utils/test_urlutils.py
@@ -21,16 +21,18 @@
import os.path
import logging
+import urllib.parse
import attr
from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkProxy
import pytest
+import hypothesis
+import hypothesis.strategies
from qutebrowser.api import cmdutils
from qutebrowser.browser.network import pac
from qutebrowser.utils import utils, urlutils, usertypes
-from helpers import utils as testutils
class FakeDNS:
@@ -213,15 +215,11 @@ class TestFuzzyUrl:
assert url == QUrl('http://foo')
@pytest.mark.parametrize('do_search', [True, False])
- def test_invalid_url(self, do_search, is_url_mock, monkeypatch,
- caplog):
+ def test_invalid_url(self, do_search, caplog):
"""Test with an invalid URL."""
- is_url_mock.return_value = True
- monkeypatch.setattr(urlutils, 'qurl_from_user_input',
- lambda url: QUrl())
with pytest.raises(urlutils.InvalidUrlError):
with caplog.at_level(logging.ERROR):
- urlutils.fuzzy_url('foo', do_search=do_search)
+ urlutils.fuzzy_url('', do_search=do_search)
@pytest.mark.parametrize('url', ['', ' '])
def test_empty(self, url):
@@ -352,78 +350,101 @@ def test_get_search_url_invalid(url):
urlutils._get_search_url(url)
-@pytest.mark.parametrize('is_url, is_url_no_autosearch, uses_dns, url', [
+@attr.s
+class UrlParams:
+
+ url = attr.ib()
+ is_url = attr.ib(True)
+ is_url_no_autosearch = attr.ib(True)
+ use_dns = attr.ib(True)
+ is_url_in_schemeless = attr.ib(False)
+
+
+@pytest.mark.parametrize('auto_search',
+ ['dns', 'naive', 'schemeless', 'never'])
+@pytest.mark.parametrize('url_params', [
# Normal hosts
- (True, True, False, 'http://foobar'),
- (True, True, False, 'localhost:8080'),
- (True, True, True, 'qutebrowser.org'),
- (True, True, True, ' qutebrowser.org '),
- (True, True, False, 'http://user:password@example.com/foo?bar=baz#fish'),
- (True, True, True, 'existing-tld.domains'),
+ UrlParams('http://foobar', use_dns=False, is_url_in_schemeless=True),
+ UrlParams('localhost:8080', use_dns=False, is_url_in_schemeless=True),
+ UrlParams('qutebrowser.org'),
+ UrlParams(' qutebrowser.org '),
+ UrlParams('http://user:password@example.com/foo?bar=baz#fish',
+ use_dns=False, is_url_in_schemeless=True),
+ UrlParams('existing-tld.domains'),
# Internationalized domain names
- (True, True, True, '\u4E2D\u56FD.\u4E2D\u56FD'), # Chinese TLD
- (True, True, True, 'xn--fiqs8s.xn--fiqs8s'), # The same in punycode
+ UrlParams('\u4E2D\u56FD.\u4E2D\u56FD'), # Chinese TLD
+ UrlParams('xn--fiqs8s.xn--fiqs8s'), # The same in punycode
# Encoded space in explicit url
- (True, True, False, 'http://sharepoint/sites/it/IT%20Documentation/Forms/AllItems.aspx'),
+ UrlParams('http://sharepoint/sites/it/IT%20Documentation/Forms/AllItems.aspx', use_dns=False, is_url_in_schemeless=True),
# IPs
- (True, True, False, '127.0.0.1'),
- (True, True, False, '::1'),
- (True, True, True, '2001:41d0:2:6c11::1'),
- (True, True, True, '[2001:41d0:2:6c11::1]:8000'),
- (True, True, True, '94.23.233.17'),
- (True, True, True, '94.23.233.17:8000'),
+ UrlParams('127.0.0.1', use_dns=False),
+ UrlParams('::1', use_dns=False),
+ UrlParams('2001:41d0:2:6c11::1'),
+ UrlParams('[2001:41d0:2:6c11::1]:8000'),
+ UrlParams('94.23.233.17'),
+ UrlParams('94.23.233.17:8000'),
# Special URLs
- (True, True, False, 'file:///tmp/foo'),
- (True, True, False, 'about:blank'),
- (True, True, False, 'qute:version'),
- (True, True, False, 'qute://version'),
- (True, True, False, 'localhost'),
+ UrlParams('file:///tmp/foo', use_dns=False, is_url_in_schemeless=True),
+ UrlParams('about:blank', use_dns=False, is_url_in_schemeless=True),
+ UrlParams('qute:version', use_dns=False, is_url_in_schemeless=True),
+ UrlParams('qute://version', use_dns=False, is_url_in_schemeless=True),
+ UrlParams('localhost', use_dns=False),
# _has_explicit_scheme False, special_url True
- (True, True, False, 'qute::foo'),
- (True, True, False, 'qute:://foo'),
+ UrlParams('qute::foo', use_dns=False),
+ UrlParams('qute:://foo', use_dns=False),
# Invalid URLs
- (False, False, False, ''),
- (False, True, False, 'onlyscheme:'),
- (False, True, False, 'http:foo:0'),
+ UrlParams('', is_url=False, is_url_no_autosearch=False, use_dns=False),
+ UrlParams('onlyscheme:', is_url=False, use_dns=False),
+ UrlParams('http:foo:0', is_url=False, use_dns=False),
# Not URLs
- (False, True, False, 'foo bar'), # no DNS because of space
- (False, True, False, 'localhost test'), # no DNS because of space
- (False, True, False, 'another . test'), # no DNS because of space
- (False, True, True, 'foo'),
- (False, True, False, 'this is: not a URL'), # no DNS because of space
- (False, True, False, 'foo user@host.tld'), # no DNS because of space
- (False, True, False, '23.42'), # no DNS because bogus-IP
- (False, True, False, '1337'), # no DNS because bogus-IP
- (False, True, True, 'deadbeef'),
- (False, True, True, 'hello.'),
- (False, True, False, 'site:cookies.com oatmeal raisin'),
- (False, True, True, 'example.search_string'),
- (False, True, True, 'example_search.string'),
+ UrlParams('foo bar', is_url=False, use_dns=False), # no DNS b/c of space
+ UrlParams('localhost test', is_url=False, use_dns=False), # no DNS b/c spc
+ UrlParams('another . test', is_url=False, use_dns=False), # no DNS b/c spc
+ UrlParams('foo', is_url=False),
+ UrlParams('this is: not a URL', is_url=False, use_dns=False), # no DNS spc
+ UrlParams('foo user@host.tld', is_url=False, use_dns=False), # no DNS, spc
+ UrlParams('23.42', is_url=False, use_dns=False), # no DNS b/c bogus-IP
+ UrlParams('1337', is_url=False, use_dns=False), # no DNS b/c bogus-IP
+ UrlParams('deadbeef', is_url=False),
+ UrlParams('hello.', is_url=False),
+ UrlParams('site:cookies.com oatmeal raisin', is_url=False, use_dns=False),
+ UrlParams('example.search_string', is_url=False),
+ UrlParams('example_search.string', is_url=False),
# no DNS because there is no host
- (False, True, False, 'foo::bar'),
+ UrlParams('foo::bar', is_url=False, use_dns=False),
# Valid search term with autosearch
- (False, False, False, 'test foo'),
- (False, False, False, 'test user@host.tld'),
+ UrlParams('test foo', is_url=False,
+ is_url_no_autosearch=False, use_dns=False),
+ UrlParams('test user@host.tld', is_url=False,
+ is_url_no_autosearch=False, use_dns=False),
# autosearch = False
- (False, True, False, 'This is a URL without autosearch'),
-])
-@pytest.mark.parametrize('auto_search', ['dns', 'naive', 'never'])
-def test_is_url(config_stub, fake_dns,
- is_url, is_url_no_autosearch, uses_dns, url, auto_search):
+ UrlParams('This is a URL without autosearch', is_url=False, use_dns=False),
+], ids=lambda param: 'URL: ' + param.url)
+def test_is_url(config_stub, fake_dns, auto_search, url_params):
"""Test is_url().
Args:
- is_url: Whether the given string is a URL with auto_search dns/naive.
- is_url_no_autosearch: Whether the given string is a URL with
- auto_search false.
- uses_dns: Whether the given string should fire a DNS request for the
- given URL.
- url: The URL to test, as a string.
auto_search: With which auto_search setting to test
+ url_params: instance of UrlParams; each containing the following attrs
+ * url: The URL to test, as a string.
+ * is_url: Whether the given string is considered a URL when auto_search
+ is either dns or naive. [default: True]
+ * is_url_no_autosearch: Whether the given string is a URL with
+ auto_search false. [default: True]
+ * use_dns: Whether the given string should fire a DNS request for the
+ given URL. [default: True]
+ * is_url_in_schemeless: Whether the given string is treated as a URL
+ when auto_search=schemeless. [default: False]
"""
+ url = url_params.url
+ is_url = url_params.is_url
+ is_url_no_autosearch = url_params.is_url_no_autosearch
+ use_dns = url_params.use_dns
+ is_url_in_schemeless = url_params.is_url_in_schemeless
+
config_stub.val.url.auto_search = auto_search
if auto_search == 'dns':
- if uses_dns:
+ if use_dns:
fake_dns.answer = True
result = urlutils.is_url(url)
assert fake_dns.used
@@ -438,6 +459,9 @@ def test_is_url(config_stub, fake_dns,
result = urlutils.is_url(url)
assert not fake_dns.used
assert result == is_url
+ elif auto_search == 'schemeless':
+ assert urlutils.is_url(url) == is_url_in_schemeless
+ assert not fake_dns.used
elif auto_search == 'naive':
assert urlutils.is_url(url) == is_url
assert not fake_dns.used
@@ -461,29 +485,6 @@ def test_searchengine_is_url(config_stub, auto_search, open_base_url, is_url):
assert urlutils.is_url('test') == is_url
-@pytest.mark.parametrize('user_input, output', [
- ('qutebrowser.org', 'http://qutebrowser.org'),
- ('http://qutebrowser.org', 'http://qutebrowser.org'),
- ('::1/foo', 'http://[::1]/foo'),
- ('[::1]/foo', 'http://[::1]/foo'),
- ('http://[::1]', 'http://[::1]'),
- ('qutebrowser.org', 'http://qutebrowser.org'),
- ('http://qutebrowser.org', 'http://qutebrowser.org'),
- ('::1/foo', 'http://[::1]/foo'),
- ('[::1]/foo', 'http://[::1]/foo'),
- ('http://[::1]', 'http://[::1]'),
-])
-def test_qurl_from_user_input(user_input, output):
- """Test qurl_from_user_input.
-
- Args:
- user_input: The string to pass to qurl_from_user_input.
- output: The expected QUrl string.
- """
- url = urlutils.qurl_from_user_input(user_input)
- assert url.toString() == output
-
-
@pytest.mark.parametrize('url, valid, has_err_string', [
('http://www.example.com/', True, False),
('', False, False),
@@ -551,11 +552,22 @@ def test_raise_cmdexc_if_invalid(url, valid, has_err_string):
(QUrl('http://user:password@qutebrowser.org/foo?bar=baz#fish'), 'foo'),
(QUrl('http://qutebrowser.org/'), 'qutebrowser.org.html'),
(QUrl('qute://'), None),
+ # data URL support
+ (QUrl('data:text/plain,'), 'download.txt'),
+ (QUrl('data:application/pdf,'), 'download.pdf'),
+ (QUrl('data:foo/bar,'), 'download'), # unknown extension
+ (QUrl('data:text/xul,'), 'download.xul'), # strict=False
+ (QUrl('data:'), None), # invalid data URL
])
def test_filename_from_url(qurl, output):
assert urlutils.filename_from_url(qurl) == output
+@pytest.mark.parametrize('qurl', [QUrl(), QUrl('qute://'), QUrl('data:')])
+def test_filename_from_url_fallback(qurl):
+ assert urlutils.filename_from_url(qurl, fallback='fallback') == 'fallback'
+
+
@pytest.mark.parametrize('qurl, expected', [
(QUrl('ftp://example.com/'), ('ftp', 'example.com', 21)),
(QUrl('ftp://example.com:2121/'), ('ftp', 'example.com', 2121)),
@@ -614,7 +626,7 @@ class TestInvalidUrlError:
@pytest.mark.parametrize('are_same, url1, url2', [
(True, 'http://example.com', 'http://www.example.com'),
- (True, 'http://bbc.co.uk', 'https://www.bbc.co.uk'),
+ (True, 'http://bbc.co.uk', 'http://www.bbc.co.uk'),
(True, 'http://many.levels.of.domains.example.com', 'http://www.example.com'),
(True, 'http://idn.иком.museum', 'http://idn2.иком.museum'),
(True, 'http://one.not_a_valid_tld', 'http://one.not_a_valid_tld'),
@@ -623,6 +635,9 @@ class TestInvalidUrlError:
(False, 'https://example.kids.museum', 'http://example.kunst.museum'),
(False, 'http://idn.иком.museum', 'http://idn.ירושלים.museum'),
(False, 'http://one.not_a_valid_tld', 'http://two.not_a_valid_tld'),
+
+ (False, 'http://example.org', 'https://example.org'), # different scheme
+ (False, 'http://example.org:80', 'http://example.org:8080'), # different port
])
def test_same_domain(are_same, url1, url2):
"""Test same_domain."""
@@ -673,12 +688,8 @@ def test_data_url():
(QUrl('http://www.example.xn--p1ai'),
'(www.example.xn--p1ai) http://www.example.рф'),
# https://bugreports.qt.io/browse/QTBUG-60364
- pytest.param(QUrl('http://www.xn--80ak6aa92e.com'),
- '(unparseable URL!) http://www.аррӏе.com',
- marks=testutils.qt58),
- pytest.param(QUrl('http://www.xn--80ak6aa92e.com'),
- 'http://www.xn--80ak6aa92e.com',
- marks=testutils.qt59),
+ (QUrl('http://www.xn--80ak6aa92e.com'),
+ 'http://www.xn--80ak6aa92e.com'),
])
def test_safe_display_string(url, expected):
assert urlutils.safe_display_string(url) == expected
@@ -689,11 +700,6 @@ def test_safe_display_string_invalid():
urlutils.safe_display_string(QUrl())
-def test_query_string():
- url = QUrl('https://www.example.com/?foo=bar')
- assert urlutils.query_string(url) == 'foo=bar'
-
-
class TestProxyFromUrl:
@pytest.mark.parametrize('url, expected', [
@@ -731,3 +737,44 @@ class TestProxyFromUrl:
def test_invalid(self, url, exception):
with pytest.raises(exception):
urlutils.proxy_from_url(QUrl(url))
+
+
+class TestParseJavascriptUrl:
+
+ @pytest.mark.parametrize('url, message', [
+ (QUrl(), ""),
+ (QUrl('https://example.com'), "Expected a javascript:... URL"),
+ (QUrl('javascript://example.com'),
+ "URL contains unexpected components: example.com"),
+ (QUrl('javascript://foo:bar@example.com:1234'),
+ "URL contains unexpected components: foo:bar@example.com:1234"),
+ ])
+ def test_invalid(self, url, message):
+ with pytest.raises(urlutils.Error, match=message):
+ urlutils.parse_javascript_url(url)
+
+ @pytest.mark.parametrize('url, source', [
+ (QUrl('javascript:"hello" %0a "world"'), '"hello" \n "world"'),
+ (QUrl('javascript:/'), '/'),
+ (QUrl('javascript:///'), '///'),
+ # https://github.com/web-platform-tests/wpt/blob/master/html/browsers/browsing-the-web/navigating-across-documents/javascript-url-query-fragment-components.html
+ (QUrl('javascript:"nope" ? "yep" : "what";'), '"nope" ? "yep" : "what";'),
+ (QUrl('javascript:"wrong"; // # %0a "ok";'), '"wrong"; // # \n "ok";'),
+ (QUrl('javascript:"%252525 ? %252525 # %252525"'),
+ '"%2525 ? %2525 # %2525"'),
+ ])
+ def test_valid(self, url, source):
+ assert urlutils.parse_javascript_url(url) == source
+
+ @hypothesis.given(source=hypothesis.strategies.text())
+ def test_hypothesis(self, source):
+ scheme = 'javascript:'
+ url = QUrl(scheme + urllib.parse.quote(source))
+ hypothesis.assume(url.isValid())
+
+ try:
+ parsed = urlutils.parse_javascript_url(url)
+ except urlutils.Error:
+ pass
+ else:
+ assert parsed == source
diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py
index 18abd444e..2b1392664 100644
--- a/tests/unit/utils/test_utils.py
+++ b/tests/unit/utils/test_utils.py
@@ -25,37 +25,25 @@ import os.path
import io
import logging
import functools
-import socket
import re
import shlex
import math
-import pkg_resources
-import attr
from PyQt5.QtCore import QUrl
-from PyQt5.QtGui import QColor, QClipboard
+from PyQt5.QtGui import QClipboard
import pytest
import hypothesis
from hypothesis import strategies
+import yaml
import qutebrowser
import qutebrowser.utils # for test_qualname
-from qutebrowser.utils import utils, qtutils, version, usertypes
+from qutebrowser.utils import utils, version, usertypes
ELLIPSIS = '\u2026'
-class Color(QColor):
-
- """A QColor with a nicer repr()."""
-
- def __repr__(self):
- return utils.get_repr(self, constructor=True, red=self.red(),
- green=self.green(), blue=self.blue(),
- alpha=self.alpha())
-
-
class TestCompactText:
"""Test compact_text."""
@@ -160,100 +148,6 @@ def test_resource_filename():
assert f.read().splitlines()[0] == "Hello World!"
-class TestInterpolateColor:
-
- """Tests for interpolate_color.
-
- Attributes:
- white: The Color white as a valid Color for tests.
- white: The Color black as a valid Color for tests.
- """
-
- @attr.s
- class Colors:
-
- white = attr.ib()
- black = attr.ib()
-
- @pytest.fixture
- def colors(self):
- """Example colors to be used."""
- return self.Colors(Color('white'), Color('black'))
-
- def test_invalid_start(self, colors):
- """Test an invalid start color."""
- with pytest.raises(qtutils.QtValueError):
- utils.interpolate_color(Color(), colors.white, 0)
-
- def test_invalid_end(self, colors):
- """Test an invalid end color."""
- with pytest.raises(qtutils.QtValueError):
- utils.interpolate_color(colors.white, Color(), 0)
-
- @pytest.mark.parametrize('perc', [-1, 101])
- def test_invalid_percentage(self, colors, perc):
- """Test an invalid percentage."""
- with pytest.raises(ValueError):
- utils.interpolate_color(colors.white, colors.white, perc)
-
- def test_invalid_colorspace(self, colors):
- """Test an invalid colorspace."""
- with pytest.raises(ValueError):
- utils.interpolate_color(colors.white, colors.black, 10,
- QColor.Cmyk)
-
- @pytest.mark.parametrize('colorspace', [QColor.Rgb, QColor.Hsv,
- QColor.Hsl])
- def test_0_100(self, colors, colorspace):
- """Test 0% and 100% in different colorspaces."""
- white = utils.interpolate_color(colors.white, colors.black, 0,
- colorspace)
- black = utils.interpolate_color(colors.white, colors.black, 100,
- colorspace)
- assert Color(white) == colors.white
- assert Color(black) == colors.black
-
- def test_interpolation_rgb(self):
- """Test an interpolation in the RGB colorspace."""
- color = utils.interpolate_color(Color(0, 40, 100), Color(0, 20, 200),
- 50, QColor.Rgb)
- assert Color(color) == Color(0, 30, 150)
-
- def test_interpolation_hsv(self):
- """Test an interpolation in the HSV colorspace."""
- start = Color()
- stop = Color()
- start.setHsv(0, 40, 100)
- stop.setHsv(0, 20, 200)
- color = utils.interpolate_color(start, stop, 50, QColor.Hsv)
- expected = Color()
- expected.setHsv(0, 30, 150)
- assert Color(color) == expected
-
- def test_interpolation_hsl(self):
- """Test an interpolation in the HSL colorspace."""
- start = Color()
- stop = Color()
- start.setHsl(0, 40, 100)
- stop.setHsl(0, 20, 200)
- color = utils.interpolate_color(start, stop, 50, QColor.Hsl)
- expected = Color()
- expected.setHsl(0, 30, 150)
- assert Color(color) == expected
-
- @pytest.mark.parametrize('percentage, expected', [
- (0, (0, 0, 0)),
- (99, (0, 0, 0)),
- (100, (255, 255, 255)),
- ])
- def test_interpolation_none(self, percentage, expected):
- """Test an interpolation with a gradient turned off."""
- color = utils.interpolate_color(Color(0, 0, 0), Color(255, 255, 255),
- percentage, None)
- assert isinstance(color, QColor)
- assert Color(color) == Color(*expected)
-
-
@pytest.mark.parametrize('seconds, out', [
(-1, '-0:01'),
(0, '0:00'),
@@ -560,8 +454,12 @@ class TestIsEnum:
def test_enum(self):
"""Test is_enum with an enum."""
- e = enum.Enum('Foo', 'bar, baz')
- assert utils.is_enum(e)
+ class Foo(enum.Enum):
+
+ bar = enum.auto()
+ baz = enum.auto()
+
+ assert utils.is_enum(Foo)
def test_class(self):
"""Test is_enum with a non-enum class."""
@@ -622,26 +520,57 @@ def test_force_encoding(inp, enc, expected):
assert utils.force_encoding(inp, enc) == expected
-@pytest.mark.parametrize('inp, expected', [
- pytest.param('normal.txt', 'normal.txt',
- marks=pytest.mark.fake_os('windows')),
- pytest.param('user/repo issues.mht', 'user_repo issues.mht',
- marks=pytest.mark.fake_os('windows')),
- pytest.param('<Test\\File> - "*?:|', '_Test_File_ - _____',
- marks=pytest.mark.fake_os('windows')),
- pytest.param('<Test\\File> - "*?:|', '<Test\\File> - "*?_|',
- marks=pytest.mark.fake_os('mac')),
- pytest.param('<Test\\File> - "*?:|', '<Test\\File> - "*?:|',
- marks=pytest.mark.fake_os('posix')),
-])
-def test_sanitize_filename(inp, expected, monkeypatch):
- assert utils.sanitize_filename(inp) == expected
+class TestSanitizeFilename:
+
+ LONG_FILENAME = ("this is a very long filename which is probably longer "
+ "than 255 bytes if I continue typing some more nonsense "
+ "I will find out that a lot of nonsense actually fits in "
+ "those 255 bytes still not finished wow okay only about "
+ "50 to go and 30 now finally enough.txt")
+
+ LONG_EXTENSION = (LONG_FILENAME.replace("filename", ".extension")
+ .replace(".txt", ""))
+
+ @pytest.mark.parametrize('inp, expected', [
+ pytest.param('normal.txt', 'normal.txt',
+ marks=pytest.mark.fake_os('windows')),
+ pytest.param('user/repo issues.mht', 'user_repo issues.mht',
+ marks=pytest.mark.fake_os('windows')),
+ pytest.param('<Test\\File> - "*?:|', '_Test_File_ - _____',
+ marks=pytest.mark.fake_os('windows')),
+ pytest.param('<Test\\File> - "*?:|', '<Test\\File> - "*?_|',
+ marks=pytest.mark.fake_os('mac')),
+ pytest.param('<Test\\File> - "*?:|', '<Test\\File> - "*?:|',
+ marks=pytest.mark.fake_os('posix')),
+ (LONG_FILENAME, LONG_FILENAME), # no shortening
+ ])
+ def test_special_chars(self, inp, expected):
+ assert utils.sanitize_filename(inp) == expected
+
+ @pytest.mark.parametrize('inp, expected', [
+ (
+ LONG_FILENAME,
+ LONG_FILENAME.replace("now finally enough.txt", "n.txt")
+ ),
+ (
+ LONG_EXTENSION,
+ LONG_EXTENSION.replace("this is a very long .extension",
+ "this .extension"),
+ ),
+ ])
+ @pytest.mark.linux
+ def test_shorten(self, inp, expected):
+ assert utils.sanitize_filename(inp, shorten=True) == expected
+ @pytest.mark.fake_os('windows')
+ def test_empty_replacement(self):
+ name = '/<Bad File>/'
+ assert utils.sanitize_filename(name, replacement=None) == 'Bad File'
-@pytest.mark.fake_os('windows')
-def test_sanitize_filename_empty_replacement():
- name = '/<Bad File>/'
- assert utils.sanitize_filename(name, replacement=None) == 'Bad File'
+ @hypothesis.given(filename=strategies.text(min_size=100))
+ def test_invariants(self, filename):
+ sanitized = utils.sanitize_filename(filename, shorten=True)
+ assert len(os.fsencode(sanitized)) <= 255 - len("(123).download")
class TestGetSetClipboard:
@@ -714,13 +643,6 @@ class TestGetSetClipboard:
utils.get_clipboard(fallback=True)
-def test_random_port():
- port = utils.random_port()
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.bind(('localhost', port))
- sock.close()
-
-
class TestOpenFile:
@pytest.mark.not_frozen
@@ -769,7 +691,7 @@ class TestOpenFile:
info = version.DistributionInfo(
id='org.kde.Platform',
parsed=version.Distribution.kde_flatpak,
- version=pkg_resources.parse_version('5.12'),
+ version=utils.parse_version('5.12'),
pretty='Unknown')
monkeypatch.setattr(version, 'distribution',
lambda: info)
@@ -821,6 +743,10 @@ class TestYaml:
def test_load(self):
assert utils.yaml_load("[1, 2]") == [1, 2]
+ def test_load_float_bug(self):
+ with pytest.raises(yaml.YAMLError):
+ utils.yaml_load("._")
+
def test_load_file(self, tmpdir):
tmpfile = tmpdir / 'foo.yml'
tmpfile.write('[1, 2]')
@@ -885,3 +811,70 @@ 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.
+
+
+@pytest.mark.parametrize('duration, out', [
+ ("0", 0),
+ ("0s", 0),
+ ("0.5s", 500),
+ ("59s", 59000),
+ ("60", 60),
+ ("60.4s", 60400),
+ ("1m1s", 61000),
+ ("1.5m", 90000),
+ ("1m", 60000),
+ ("1h", 3_600_000),
+ ("0.5h", 1_800_000),
+ ("1h1s", 3_601_000),
+ ("1h 1s", 3_601_000),
+ ("1h1m", 3_660_000),
+ ("1h1m1s", 3_661_000),
+ ("1h1m10s", 3_670_000),
+ ("10h1m10s", 36_070_000),
+])
+def test_parse_duration(duration, out):
+ assert utils.parse_duration(duration) == out
+
+
+@pytest.mark.parametrize('duration', [
+ "-1s", # No sense to wait for negative seconds
+ "-1",
+ "34ss",
+ "",
+ "h",
+ "1.s",
+ "1.1.1s",
+ ".1s",
+ ".s",
+ "10e5s",
+ "5s10m",
+])
+def test_parse_duration_invalid(duration):
+ with pytest.raises(ValueError, match='Invalid duration'):
+ utils.parse_duration(duration)
+
+
+@hypothesis.given(strategies.text())
+def test_parse_duration_hypothesis(duration):
+ try:
+ utils.parse_duration(duration)
+ except ValueError:
+ pass
+
+
+@pytest.mark.parametrize('mimetype, extension', [
+ ('application/pdf', '.pdf'), # handled by Python
+ ('text/plain', '.txt'), # wrong in Python 3.6, overridden
+ ('application/manifest+json', '.webmanifest'), # newer
+ ('text/xul', '.xul'), # strict=False
+ ('doesnot/exist', None),
+])
+def test_mimetype_extension(mimetype, extension):
+ assert utils.mimetype_extension(mimetype) == extension
diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py
index 0a3c5e4aa..e16bd2318 100644
--- a/tests/unit/utils/test_version.py
+++ b/tests/unit/utils/test_version.py
@@ -25,7 +25,7 @@ import collections
import os.path
import subprocess
import contextlib
-import builtins # noqa https://github.com/JBKahn/flake8-debugger/issues/20
+import builtins
import types
import importlib
import logging
@@ -33,13 +33,14 @@ import textwrap
import datetime
import attr
-import pkg_resources
import pytest
+import hypothesis
+import hypothesis.strategies
import qutebrowser
from qutebrowser.config import config
from qutebrowser.utils import version, usertypes, utils, standarddir
-from qutebrowser.misc import pastebin
+from qutebrowser.misc import pastebin, objects
from qutebrowser.browser import pdfjs
@@ -75,7 +76,7 @@ from qutebrowser.browser import pdfjs
""",
version.DistributionInfo(
id='ubuntu', parsed=version.Distribution.ubuntu,
- version=pkg_resources.parse_version('14.4'),
+ version=utils.parse_version('14.4'),
pretty='Ubuntu 14.04.5 LTS')),
# Ubuntu 17.04
("""
@@ -88,7 +89,7 @@ from qutebrowser.browser import pdfjs
""",
version.DistributionInfo(
id='ubuntu', parsed=version.Distribution.ubuntu,
- version=pkg_resources.parse_version('17.4'),
+ version=utils.parse_version('17.4'),
pretty='Ubuntu 17.04')),
# Debian Jessie
("""
@@ -100,7 +101,7 @@ from qutebrowser.browser import pdfjs
""",
version.DistributionInfo(
id='debian', parsed=version.Distribution.debian,
- version=pkg_resources.parse_version('8'),
+ version=utils.parse_version('8'),
pretty='Debian GNU/Linux 8 (jessie)')),
# Void Linux
("""
@@ -131,7 +132,7 @@ from qutebrowser.browser import pdfjs
""",
version.DistributionInfo(
id='fedora', parsed=version.Distribution.fedora,
- version=pkg_resources.parse_version('25'),
+ version=utils.parse_version('25'),
pretty='Fedora 25 (Twenty Five)')),
# OpenSUSE
("""
@@ -144,7 +145,7 @@ from qutebrowser.browser import pdfjs
""",
version.DistributionInfo(
id='opensuse', parsed=version.Distribution.opensuse,
- version=pkg_resources.parse_version('42.2'),
+ version=utils.parse_version('42.2'),
pretty='openSUSE Leap 42.2')),
# Linux Mint
("""
@@ -157,7 +158,7 @@ from qutebrowser.browser import pdfjs
""",
version.DistributionInfo(
id='linuxmint', parsed=version.Distribution.linuxmint,
- version=pkg_resources.parse_version('18.1'),
+ version=utils.parse_version('18.1'),
pretty='Linux Mint 18.1')),
# Manjaro
("""
@@ -186,7 +187,7 @@ from qutebrowser.browser import pdfjs
""",
version.DistributionInfo(
id='org.kde.Platform', parsed=version.Distribution.kde_flatpak,
- version=pkg_resources.parse_version('5.12'),
+ version=utils.parse_version('5.12'),
pretty='KDE')),
# No PRETTY_NAME
("""
@@ -219,7 +220,7 @@ def test_distribution(tmpdir, monkeypatch, os_release, expected):
(None, False),
(version.DistributionInfo(
id='org.kde.Platform', parsed=version.Distribution.kde_flatpak,
- version=pkg_resources.parse_version('5.12'),
+ version=utils.parse_version('5.12'),
pretty='Unknown'), True),
(version.DistributionInfo(
id='arch', parsed=version.Distribution.arch, version=None,
@@ -401,7 +402,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."""
@@ -543,7 +544,7 @@ class ImportFake:
Attributes:
modules: A dict mapping module names to bools. If True, the import will
- success. Otherwise, it'll fail with ImportError.
+ succeed. Otherwise, it'll fail with ImportError.
version_attribute: The name to use in the fake modules for the version
attribute.
version: The version to use for the modules.
@@ -552,23 +553,8 @@ class ImportFake:
"""
def __init__(self):
- self.modules = collections.OrderedDict([
- ('sip', True),
- ('colorama', True),
- ('pypeg2', True),
- ('jinja2', True),
- ('pygments', True),
- ('yaml', True),
- ('cssutils', True),
- ('attr', True),
- ('PyQt5.QtWebEngineWidgets', True),
- ('PyQt5.QtWebEngine', True),
- ('PyQt5.QtWebKitWidgets', True),
- ])
- self.no_version_attribute = ['sip',
- 'PyQt5.QtWebEngineWidgets',
- 'PyQt5.QtWebKitWidgets',
- 'PyQt5.QtWebEngine']
+ self.modules = collections.OrderedDict(
+ [(mod, True) for mod in version.MODULE_INFO])
self.version_attribute = '__version__'
self.version = '1.2.3'
self._real_import = builtins.__import__
@@ -613,7 +599,7 @@ class ImportFake:
def import_fake(monkeypatch):
"""Fixture to patch imports using ImportFake."""
fake = ImportFake()
- monkeypatch.setattr('builtins.__import__', fake.fake_import)
+ monkeypatch.setattr(builtins, '__import__', fake.fake_import)
monkeypatch.setattr(version.importlib, 'import_module',
fake.fake_importlib_import)
return fake
@@ -621,13 +607,14 @@ def import_fake(monkeypatch):
class TestModuleVersions:
- """Tests for _module_versions()."""
+ """Tests for _module_versions() and ModuleInfo."""
def test_all_present(self, import_fake):
"""Test with all modules present in version 1.2.3."""
expected = []
for name in import_fake.modules:
- if name in import_fake.no_version_attribute:
+ version.MODULE_INFO[name]._reset_cache()
+ if '__version__' not in version.MODULE_INFO[name]._version_attributes:
expected.append('{}: yes'.format(name))
else:
expected.append('{}: 1.2.3'.format(name))
@@ -635,7 +622,7 @@ class TestModuleVersions:
@pytest.mark.parametrize('module, idx, expected', [
('colorama', 1, 'colorama: no'),
- ('cssutils', 6, 'cssutils: no'),
+ ('adblock', 6, 'adblock: no'),
])
def test_missing_module(self, module, idx, expected, import_fake):
"""Test with a module missing.
@@ -646,8 +633,45 @@ class TestModuleVersions:
expected: The expected text.
"""
import_fake.modules[module] = False
+ # Needed after mocking the module
+ mod_info = version.MODULE_INFO[module]
+ mod_info._reset_cache()
+
assert version._module_versions()[idx] == expected
+ for method_name, expected_result in [
+ ("is_installed", False),
+ ("is_usable", False),
+ ("get_version", None),
+ ("is_outdated", None)
+ ]:
+ method = getattr(mod_info, method_name)
+ # With hot cache
+ mod_info._initialize_info()
+ assert method() == expected_result
+ # With cold cache
+ mod_info._reset_cache()
+ assert method() == expected_result
+
+ def test_outdated_adblock(self, import_fake):
+ """Test that warning is shown when adblock module is outdated."""
+ mod_info = version.MODULE_INFO["adblock"]
+ fake_version = "0.1.0"
+
+ # Needed after mocking version attribute
+ mod_info._reset_cache()
+
+ assert mod_info.min_version is not None
+ assert fake_version < mod_info.min_version
+ import_fake.version = fake_version
+
+ assert mod_info.is_installed()
+ assert mod_info.is_outdated()
+ assert not mod_info.is_usable()
+
+ expected = f"adblock: {fake_version} (< {mod_info.min_version}, outdated)"
+ assert version._module_versions()[6] == expected
+
@pytest.mark.parametrize('attribute, expected_modules', [
('VERSION', ['colorama']),
('SIP_VERSION_STR', ['sip']),
@@ -664,12 +688,22 @@ class TestModuleVersions:
expected: The expected return value.
"""
import_fake.version_attribute = attribute
+
+ for mod_info in version.MODULE_INFO.values():
+ # Invalidate the "version cache" since we just mocked some of the
+ # attributes.
+ mod_info._reset_cache()
+
expected = []
for name in import_fake.modules:
+ mod_info = version.MODULE_INFO[name]
if name in expected_modules:
+ assert mod_info.get_version() == "1.2.3"
expected.append('{}: 1.2.3'.format(name))
else:
+ assert mod_info.get_version() is None
expected.append('{}: yes'.format(name))
+
assert version._module_versions() == expected
@pytest.mark.parametrize('name, has_version', [
@@ -679,7 +713,7 @@ class TestModuleVersions:
('jinja2', True),
('pygments', True),
('yaml', True),
- ('cssutils', True),
+ ('adblock', True),
('attr', True),
])
def test_existing_attributes(self, name, has_version):
@@ -692,9 +726,7 @@ class TestModuleVersions:
name: The name of the module to check.
has_version: Whether a __version__ attribute is expected.
"""
- if name == 'cssutils':
- pytest.importorskip(name)
- module = importlib.import_module(name)
+ module = pytest.importorskip(name)
assert hasattr(module, '__version__') == has_version
def test_existing_sip_attribute(self):
@@ -804,8 +836,9 @@ class TestPDFJSVersion:
assert version._pdfjs_version() == 'unknown (bundled)'
@pytest.mark.parametrize('varname', [
- 'PDFJS.version', # older versions
- 'var pdfjsVersion', # newer versions
+ 'PDFJS.version', # v1.10.100 and older
+ 'var pdfjsVersion', # v2.0.943
+ 'const pdfjsVersion', # v2.5.207
])
def test_known(self, monkeypatch, varname):
pdfjs_code = textwrap.dedent("""
@@ -865,39 +898,49 @@ _QTWE_USER_AGENT = ("Mozilla/5.0 (X11; Linux x86_64) "
"QtWebEngine/5.14.0 Chrome/{} Safari/537.36")
-def test_chromium_version(monkeypatch, caplog):
- pytest.importorskip('PyQt5.QtWebEngineWidgets')
+class TestChromiumVersion:
- ver = '77.0.3865.98'
- version.webenginesettings._init_user_agent_str(
- _QTWE_USER_AGENT.format(ver))
+ @pytest.fixture(autouse=True)
+ def clear_parsed_ua(self, monkeypatch):
+ if version.webenginesettings is not None:
+ # Not available with QtWebKit
+ monkeypatch.setattr(version.webenginesettings, 'parsed_user_agent', None)
- assert version._chromium_version() == ver
+ def test_fake_ua(self, monkeypatch, caplog):
+ pytest.importorskip('PyQt5.QtWebEngineWidgets')
+ ver = '77.0.3865.98'
+ version.webenginesettings._init_user_agent_str(
+ _QTWE_USER_AGENT.format(ver))
-def test_chromium_version_no_webengine(monkeypatch):
- monkeypatch.setattr(version, 'webenginesettings', None)
- assert version._chromium_version() == 'unavailable'
+ assert version._chromium_version() == ver
+ def test_no_webengine(self, monkeypatch):
+ monkeypatch.setattr(version, 'webenginesettings', None)
+ assert version._chromium_version() == 'unavailable'
-def test_chromium_version_prefers_saved_user_agent(monkeypatch):
- pytest.importorskip('PyQt5.QtWebEngineWidgets')
- version.webenginesettings._init_user_agent_str(_QTWE_USER_AGENT)
+ def test_prefers_saved_user_agent(self, monkeypatch):
+ pytest.importorskip('PyQt5.QtWebEngineWidgets')
+ version.webenginesettings._init_user_agent_str(_QTWE_USER_AGENT)
- class FakeProfile:
- def defaultProfile(self):
- raise AssertionError("Should not be called")
+ class FakeProfile:
+ def defaultProfile(self):
+ raise AssertionError("Should not be called")
- monkeypatch.setattr(version.webenginesettings, 'QWebEngineProfile',
- FakeProfile())
+ monkeypatch.setattr(version.webenginesettings, 'QWebEngineProfile',
+ FakeProfile())
- version._chromium_version()
+ version._chromium_version()
+ def test_unpatched(self, qapp, cache_tmpdir, data_tmpdir, config_stub):
+ pytest.importorskip('PyQt5.QtWebEngineWidgets')
+ unexpected = ['', 'unknown', 'unavailable', 'avoided']
+ assert version._chromium_version() not in unexpected
-def test_chromium_version_unpatched(qapp, cache_tmpdir, data_tmpdir,
- config_stub):
- pytest.importorskip('PyQt5.QtWebEngineWidgets')
- assert version._chromium_version() not in ['', 'unknown', 'unavailable']
+ def test_avoided(self, monkeypatch):
+ pytest.importorskip('PyQt5.QtWebEngineWidgets')
+ monkeypatch.setattr(objects, 'debug_flags', ['avoid-chromium-init'])
+ assert version._chromium_version() == 'avoided'
@attr.s
@@ -925,8 +968,8 @@ class VersionParams:
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')
@@ -956,11 +999,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.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,
@@ -1006,14 +1053,14 @@ def test_version_output(params, stubs, monkeypatch, config_stub):
substitutions['ssl'] = 'SSL VERSION' if params.ssl_support else 'no'
for name, val in patches.items():
- monkeypatch.setattr('qutebrowser.utils.version.' + name, val)
+ monkeypatch.setattr(f'qutebrowser.utils.version.{name}', val)
if params.frozen:
monkeypatch.setattr(sys, 'frozen', True, raising=False)
else:
monkeypatch.delattr(sys, 'frozen', raising=False)
- template = textwrap.dedent("""
+ template = version._LOGO.lstrip('\n') + textwrap.dedent("""
qutebrowser vVERSION{git_commit}
Backend: {backend}
Qt: {qt}
@@ -1026,7 +1073,7 @@ def test_version_output(params, stubs, monkeypatch, config_stub):
pdf.js: PDFJS VERSION
sqlite: SQLITE VERSION
QtNetwork SSL: {ssl}
- {style}{platform_plugin}
+ {style}{platform_plugin}{opengl}
Platform: PLATFORM, ARCHITECTURE{linuxdist}
Frozen: {frozen}
Imported from {import_path}
@@ -1042,13 +1089,73 @@ 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)
+
+ @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
@@ -1061,9 +1168,8 @@ 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',
- lambda: "dummy")
- monkeypatch.setattr('qutebrowser.utils.utils.log_clipboard', True)
+ monkeypatch.setattr(version, 'version_info', lambda: 'dummy')
+ monkeypatch.setattr(utils, 'log_clipboard', True)
version.pastebin_version(pbclient)
pbclient.success.emit("https://www.example.com/\n")
@@ -1076,8 +1182,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',
- lambda: "dummy")
+ monkeypatch.setattr(version, 'version_info', lambda: 'dummy')
version.pastebin_version(pbclient)
pbclient.success.emit("https://www.example.com/\n")
@@ -1094,8 +1199,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',
- lambda: "dummy")
+ monkeypatch.setattr(version, 'version_info', lambda: 'dummy')
version.pastebin_url = None
with caplog.at_level(logging.ERROR):
@@ -1115,7 +1219,7 @@ def test_uptime(monkeypatch, qapp):
class FakeDateTime(datetime.datetime):
now = lambda x=datetime.datetime(1, 1, 1, 1, 1, 1, 2): x
- monkeypatch.setattr('datetime.datetime', FakeDateTime)
+ monkeypatch.setattr(datetime, 'datetime', FakeDateTime)
uptime_delta = version._uptime()
assert uptime_delta == datetime.timedelta(0)
diff --git a/tox.ini b/tox.ini
index 17fde13b9..204607959 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,34 +4,34 @@
# and then run "tox" from this directory.
[tox]
-envlist = py37-pyqt514-cov,misc,vulture,flake8,pylint,pyroma,check-manifest,eslint
+envlist = py38-pyqt515-cov,mypy,misc,vulture,flake8,pylint,pyroma,check-manifest,eslint,yamllint
distshare = {toxworkdir}
skipsdist = true
+minversion = 3.15
[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{,512,513,514,515,5150}: LINK_PYQT_SKIP=true
+ pyqt{,512,513,514,515,5150}: 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
+passenv = PYTHON DISPLAY XAUTHORITY HOME USERNAME USER CI XDG_* QUTE_* DOCKER QT_QUICK_BACKEND PY_COLORS
basepython =
- py35: {env:PYTHON:python3.5}
+ py: {env:PYTHON:python3}
+ py3: {env:PYTHON:python3}
py36: {env:PYTHON:python3.6}
py37: {env:PYTHON:python3.7}
py38: {env:PYTHON:python3.8}
-pip_version = pip
+ py39: {env:PYTHON:python3.9}
deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/misc/requirements/requirements-tests.txt
pyqt: -r{toxinidir}/misc/requirements/requirements-pyqt.txt
- pyqt57: -r{toxinidir}/misc/requirements/requirements-pyqt-5.7.txt
- pyqt59: -r{toxinidir}/misc/requirements/requirements-pyqt-5.9.txt
- pyqt510: -r{toxinidir}/misc/requirements/requirements-pyqt-5.10.txt
- pyqt511: -r{toxinidir}/misc/requirements/requirements-pyqt-5.11.txt
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
+ pyqt5150: -r{toxinidir}/misc/requirements/requirements-pyqt-5.15.0.txt
commands =
{envpython} scripts/link_pyqt.py --tox {envdir}
{envpython} -bb -m pytest {posargs:tests}
@@ -39,42 +39,17 @@ 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}
-pip_version = pip
# For global .gitignore files
passenv = HOME
deps =
commands =
- {envpython} scripts/dev/misc_checks.py git
- {envpython} scripts/dev/misc_checks.py vcs
- {envpython} scripts/dev/misc_checks.py spelling
- {envpython} scripts/dev/misc_checks.py userscripts
+ {envpython} scripts/dev/misc_checks.py {posargs:all}
[testenv:vulture]
basepython = {env:PYTHON:python3}
-pip_version = pip
deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/misc/requirements/requirements-vulture.txt
@@ -85,7 +60,6 @@ commands =
[testenv:vulture-pyqtlink]
basepython = {env:PYTHON:python3}
-pip_version = pip
deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/misc/requirements/requirements-vulture.txt
@@ -95,8 +69,7 @@ commands =
{[testenv:vulture]commands}
[testenv:pylint]
-basepython = {env:PYTHON:python3}
-pip_version = pip
+basepython = {env:PYTHON:python3.8}
ignore_errors = true
passenv =
deps =
@@ -110,7 +83,6 @@ commands =
[testenv:pylint-pyqtlink]
basepython = {env:PYTHON:python3}
-pip_version = pip
ignore_errors = true
passenv =
deps =
@@ -124,7 +96,6 @@ commands =
[testenv:pylint-master]
basepython = {env:PYTHON:python3}
-pip_version = pip
passenv = {[testenv:pylint]passenv}
deps =
-r{toxinidir}/requirements.txt
@@ -137,7 +108,6 @@ commands =
[testenv:flake8]
basepython = {env:PYTHON:python3}
-pip_version = pip
passenv =
deps =
-r{toxinidir}/requirements.txt
@@ -147,7 +117,6 @@ commands =
[testenv:pyroma]
basepython = {env:PYTHON:python3}
-pip_version = pip
passenv =
deps =
-r{toxinidir}/misc/requirements/requirements-pyroma.txt
@@ -156,18 +125,16 @@ commands =
[testenv:check-manifest]
basepython = {env:PYTHON:python3}
-pip_version = pip
passenv =
deps =
-r{toxinidir}/misc/requirements/requirements-check-manifest.txt
commands =
- {envdir}/bin/check-manifest --ignore 'qutebrowser/git-commit-id,qutebrowser/html/doc,qutebrowser/html/doc/*,*/__pycache__'
+ {envdir}/bin/check-manifest --ignore 'qutebrowser/git-commit-id,qutebrowser/html/doc,qutebrowser/html/doc/*,qutebrowser/html/doc/img/cheatsheet-*.png,*/__pycache__'
[testenv:docs]
basepython = {env:PYTHON:python3}
-pip_version = pip
whitelist_externals = git
-passenv = TRAVIS TRAVIS_PULL_REQUEST
+passenv = CI GITHUB_REF
deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/misc/requirements/requirements-pyqt.txt
@@ -176,10 +143,9 @@ commands =
{envpython} scripts/dev/check_doc_changes.py {posargs}
{envpython} scripts/asciidoc2html.py {posargs}
-[testenv:pyinstaller]
+[testenv:pyinstaller-{64,32}]
basepython = {env:PYTHON:python3}
-pip_version = pip
-passenv = APPDATA HOME
+passenv = APPDATA HOME PYINSTALLER_DEBUG
deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/misc/requirements/requirements-pyinstaller.txt
@@ -188,30 +154,46 @@ commands =
{envbindir}/pyinstaller --noconfirm misc/qutebrowser.spec
[testenv:eslint]
-# This is duplicated in travis_run.sh for Travis CI because we can't get tox in
-# the JavaScript environment easily.
basepython = python3
deps =
+passenv = TERM
whitelist_externals = eslint
changedir = {toxinidir}/qutebrowser/javascript
-commands = eslint --color --report-unused-disable-directives .
+commands = eslint --report-unused-disable-directives .
+
+[testenv:shellcheck]
+basepython = python3
+deps =
+whitelist_externals = bash
+commands = bash scripts/dev/run_shellcheck.sh {posargs}
[testenv:mypy]
basepython = {env:PYTHON:python3}
-pip_version = pip
-passenv = TERM
+passenv = TERM MYPY_FORCE_TERMINAL_WIDTH
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}
+[testenv:yamllint]
+basepython = {env:PYTHON:python3}
+deps = -r{toxinidir}/misc/requirements/requirements-yamllint.txt
+commands =
+ {envpython} -m yamllint -f colored --strict . {posargs}
+
+[testenv:mypy-diff]
+basepython = {env:PYTHON:python3}
+passenv = {[testenv:mypy]passenv}
+deps = {[testenv:mypy]deps}
+commands =
+ {envpython} -m mypy --cobertura-xml-report {envtmpdir} qutebrowser tests {posargs}
+ {envdir}/bin/diff-cover --fail-under=100 --compare-branch={env:DIFF_BRANCH:origin/{env:GITHUB_BASE_REF:master}} {envtmpdir}/cobertura.xml
+
[testenv:sphinx]
basepython = {env:PYTHON:python3}
-pip_version = pip
passenv =
usedevelop = true
deps =
@@ -220,3 +202,16 @@ deps =
-r{toxinidir}/misc/requirements/requirements-sphinx.txt
commands =
{envpython} -m sphinx -jauto -W --color {posargs} {toxinidir}/doc/extapi/ {toxinidir}/doc/extapi/_build/
+
+[testenv:build-release]
+basepython = {env:PYTHON:python3}
+passenv = *
+usedevelop = true
+deps =
+ -r{toxinidir}/requirements.txt
+ -r{toxinidir}/misc/requirements/requirements-tox.txt
+ -r{toxinidir}/misc/requirements/requirements-pyqt.txt
+ -r{toxinidir}/misc/requirements/requirements-dev.txt
+ -r{toxinidir}/misc/requirements/requirements-pyinstaller.txt
+commands =
+ {envpython} {toxinidir}/scripts/dev/build_release.py {posargs}