diff options
94 files changed, 1463 insertions, 641 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c292d7986..5978f1f97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: linters: if: "!contains(github.event.head_commit.message, '[ci skip]')" timeout-minutes: 10 - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 strategy: fail-fast: false matrix: @@ -90,7 +90,7 @@ jobs: - uses: actions/checkout@v2 - name: Set up problem matchers run: "python scripts/dev/ci/problemmatchers.py py38 ${{ runner.temp }}" - - run: tox -e py38 + - run: tox -e py tests: if: "!contains(github.event.head_commit.message, '[ci skip]')" @@ -112,6 +112,10 @@ jobs: - 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 @@ -121,6 +125,11 @@ jobs: 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 @@ -164,7 +173,7 @@ jobs: codeql: if: "!contains(github.event.head_commit.message, '[ci skip]')" timeout-minutes: 30 - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Checkout repository uses: actions/checkout@v2 @@ -186,7 +195,7 @@ jobs: irc: timeout-minutes: 2 continue-on-error: true - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 needs: [linters, tests, tests-docker, codeql] if: "always() && github.repository_owner == 'qutebrowser'" steps: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..03510ad6e --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,61 @@ +name: Rebuild Docker CI images + +on: + workflow_dispatch: + schedule: + - cron: "23 5 * * *" # daily at 5:23 + +jobs: + docker: + 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 index c41f67810..c939aa81d 100644 --- a/.github/workflows/recompile-requirements.yml +++ b/.github/workflows/recompile-requirements.yml @@ -31,7 +31,7 @@ jobs: run: "python3 scripts/dev/recompile_requirements.py ${{ github.events.input.environments }}" id: requirements - name: Create pull request - uses: peter-evans/create-pull-request@v2 + uses: peter-evans/create-pull-request@v3 with: committer: qutebrowser bot <bot@qutebrowser.org> author: qutebrowser bot <bot@qutebrowser.org> @@ -74,7 +74,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 @@ -9,6 +9,7 @@ ignore: | rules: document-start: disable line-length: + max: 88 ignore: | /.github/*.yml /.github/workflows/*.yml diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index f7ba4b42f..9cfa73806 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -35,6 +35,7 @@ Major changes 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. +- 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 @@ -56,12 +57,21 @@ Removed - The `:inspector` command which was deprecated in v1.13.0 (in favor of `:devtools`) is now removed. +Added +~~~~~ + +- 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. + 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 @@ -77,39 +87,98 @@ Changed Fixed ~~~~~ -- The `open_url_instance.sh` userscript now complains when `socat` is not - installed, rather than silencing the error. - With interpolated color settings (`colors.tabs.indicator.*` and `colors.downloads.*`), the alpha channel is now handled correctly. v1.14.1 (unreleased) -------------------- +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 ~~~~~~~ -- (TODO) 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.111. This includes +- 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 - * Proper support for screen sharing + * 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 ~~~~~ -- Fix for a crash introduced in v1.14.0 when closing qutebrowser after opening a - download with PDF.js. -- New site-specific quirk to polyfill `Object.fromEntries` on Qt 5.12, thus - fixing https://www.vr.fi/en and possibly other websites. +- 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. +- Minor performance improvements. - (TODO) Fix for various functionality breaking in private windows with v1.14.0, after the last private window is closed. This includes: * Ad blocking diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 646d2f27b..0714b4fa1 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -1310,7 +1310,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. diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index ddd5df44c..309f1ab1d 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -327,6 +327,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. @@ -4206,6 +4207,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. 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/requirements/requirements-check-manifest.txt b/misc/requirements/requirements-check-manifest.txt index 8b8f6ba1a..e9376f0b1 100644 --- a/misc/requirements/requirements-check-manifest.txt +++ b/misc/requirements/requirements-check-manifest.txt @@ -2,8 +2,7 @@ build==0.1.0 check-manifest==0.45 -packaging==20.4 +packaging==20.7 pep517==0.9.1 pyparsing==2.4.7 -six==1.15.0 toml==0.10.2 diff --git a/misc/requirements/requirements-dev.txt b/misc/requirements/requirements-dev.txt index 13956d51f..578ab2b64 100644 --- a/misc/requirements/requirements-dev.txt +++ b/misc/requirements/requirements-dev.txt @@ -2,7 +2,7 @@ bump2version==1.0.1 certifi==2020.11.8 -cffi==1.14.3 +cffi==1.14.4 chardet==3.0.4 colorama==0.4.4 cryptography==3.2.1 @@ -11,15 +11,15 @@ hunter==3.3.1 idna==2.10 jwcrypto==0.8 manhole==1.6.0 -packaging==20.4 +packaging==20.7 pycparser==2.20 Pympler==0.9 pyparsing==2.4.7 -PyQt-builder==1.5.0 +PyQt-builder==1.6.0 python-dateutil==2.8.1 -requests==2.24.0 -sip==5.4.0 +requests==2.25.0 +sip==5.5.0 six==1.15.0 toml==0.10.2 uritemplate==3.0.1 -# urllib3==1.25.11 +# urllib3==1.26.2 diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt index 8bad8cb2d..3427c0c69 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -2,11 +2,11 @@ attrs==20.3.0 flake8==3.8.4 -flake8-bugbear==20.1.4 +flake8-bugbear==20.11.1 flake8-builtins==1.5.3 flake8-comprehensions==3.3.0 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 diff --git a/misc/requirements/requirements-mypy.txt b/misc/requirements/requirements-mypy.txt index 8509c5bea..a046a0b5e 100644 --- a/misc/requirements/requirements-mypy.txt +++ b/misc/requirements/requirements-mypy.txt @@ -1,10 +1,10 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py diff-cover==4.0.1 -inflect==4.1.0 +inflect==5.0.2 Jinja2==2.11.2 jinja2-pluralize==0.3.0 -lxml==4.6.1 +lxml==4.6.2 MarkupSafe==1.1.1 mypy==0.790 mypy-extensions==0.4.3 diff --git a/misc/requirements/requirements-pyinstaller.txt b/misc/requirements/requirements-pyinstaller.txt index a48e80fde..b1a3e98ee 100644 --- a/misc/requirements/requirements-pyinstaller.txt +++ b/misc/requirements/requirements-pyinstaller.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py altgraph==0.17 -pyinstaller==4.0 +pyinstaller==4.1 pyinstaller-hooks-contrib==2020.10 diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt index d82495cb9..e3856a40a 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -2,7 +2,7 @@ astroid==2.3.3 # rq.filter: < 2.4 certifi==2020.11.8 -cffi==1.14.3 +cffi==1.14.4 chardet==3.0.4 cryptography==3.2.1 github3.py==1.3.0 @@ -15,9 +15,9 @@ pycparser==2.20 pylint==2.4.4 # rq.filter: < 2.5 python-dateutil==2.8.1 ./scripts/dev/pylint_checkers -requests==2.24.0 +requests==2.25.0 six==1.15.0 typed-ast==1.4.1 ; python_version<"3.8" uritemplate==3.0.1 -# urllib3==1.25.11 +# urllib3==1.26.2 wrapt==1.11.2 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..53a3782ae --- /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.2 # rq.filter: < 6 +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..a9d16f08f --- /dev/null +++ b/misc/requirements/requirements-pyqt-5.15.0.txt-raw @@ -0,0 +1,4 @@ +#@ filter: PyQt5 < 6 +#@ filter: PyQtWebEngine == 5.15.0 +PyQt5 >= 5.15, < 6 +PyQtWebEngine == 5.15.0 diff --git a/misc/requirements/requirements-pyqt-5.15.txt b/misc/requirements/requirements-pyqt-5.15.txt index 21745c814..e791bb323 100644 --- a/misc/requirements/requirements-pyqt-5.15.txt +++ b/misc/requirements/requirements-pyqt-5.15.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -PyQt5==5.15.1 # rq.filter: < 6 +PyQt5==5.15.2 # rq.filter: < 6 PyQt5-sip==12.8.1 -PyQtWebEngine==5.15.1 # rq.filter: < 6 +PyQtWebEngine==5.15.2 # rq.filter: < 6 diff --git a/misc/requirements/requirements-pyqt.txt b/misc/requirements/requirements-pyqt.txt index 148e8d8bb..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.15.1 +PyQt5==5.15.2 PyQt5-sip==12.8.1 -PyQtWebEngine==5.15.0 +PyQtWebEngine==5.15.2 diff --git a/misc/requirements/requirements-pyqt.txt-raw b/misc/requirements/requirements-pyqt.txt-raw index 83ebc7671..9c6afbf16 100644 --- a/misc/requirements/requirements-pyqt.txt-raw +++ b/misc/requirements/requirements-pyqt.txt-raw @@ -1,2 +1,2 @@ PyQt5 -PyQtWebEngine!=5.15.1 +PyQtWebEngine diff --git a/misc/requirements/requirements-sphinx.txt b/misc/requirements/requirements-sphinx.txt index 83184aa09..164311235 100644 --- a/misc/requirements/requirements-sphinx.txt +++ b/misc/requirements/requirements-sphinx.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py alabaster==0.7.12 -Babel==2.8.0 +Babel==2.9.0 certifi==2020.11.8 chardet==3.0.4 docutils==0.16 @@ -9,18 +9,17 @@ idna==2.10 imagesize==1.2.0 Jinja2==2.11.2 MarkupSafe==1.1.1 -packaging==20.4 +packaging==20.7 Pygments==2.7.2 pyparsing==2.4.7 pytz==2020.4 -requests==2.24.0 -six==1.15.0 +requests==2.25.0 snowballstemmer==2.0.0 -Sphinx==3.3.0 +Sphinx==3.3.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.11 +urllib3==1.26.2 diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 21b290737..bd77427d4 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -5,7 +5,7 @@ attrs==20.3.0 beautifulsoup4==4.9.3 certifi==2020.11.8 chardet==3.0.4 -cheroot==8.4.5 +cheroot==8.4.7 click==7.1.2 # colorama==0.4.4 coverage==5.3 @@ -15,7 +15,7 @@ filelock==3.0.12 Flask==1.1.2 glob2==0.7 hunter==3.3.1 -hypothesis==5.41.2 +hypothesis==5.41.4 icdiff==1.9.1 idna==2.10 iniconfig==1.1.1 @@ -26,7 +26,7 @@ Mako==1.1.3 manhole==1.6.0 # MarkupSafe==1.1.1 more-itertools==8.6.0 -packaging==20.4 +packaging==20.7 parse==1.18.0 parse-type==0.5.2 pluggy==0.13.1 @@ -50,13 +50,13 @@ pytest-rerunfailures==9.1.1 pytest-xdist==2.1.0 pytest-xvfb==2.0.0 PyVirtualDisplay==1.3.2 -requests==2.24.0 +requests==2.25.0 requests-file==1.5.1 six==1.15.0 sortedcontainers==2.3.0 soupsieve==2.0.1 termcolor==1.1.0 -tldextract==3.0.2 +tldextract==3.1.0 toml==0.10.2 urllib3==1.25.11 vulture==2.1 diff --git a/misc/requirements/requirements-tests.txt-raw b/misc/requirements/requirements-tests.txt-raw index fd346d475..0c9e3928f 100644 --- a/misc/requirements/requirements-tests.txt-raw +++ b/misc/requirements/requirements-tests.txt-raw @@ -1,5 +1,6 @@ beautifulsoup4 -cheroot +# https://github.com/cherrypy/cheroot/issues/341 +cheroot!=8.4.8 coverage Flask hypothesis @@ -33,5 +34,7 @@ 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 #@ ignore: Jinja2, MarkupSafe, colorama diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt index 9241f07a6..86b3997f4 100644 --- a/misc/requirements/requirements-tox.txt +++ b/misc/requirements/requirements-tox.txt @@ -3,13 +3,11 @@ appdirs==1.4.4 distlib==0.3.1 filelock==3.0.12 -packaging==20.4 +packaging==20.7 pluggy==0.13.1 py==1.9.0 pyparsing==2.4.7 six==1.15.0 toml==0.10.2 tox==3.20.1 -tox-pip-version==0.0.7 -tox-venv==0.4.0 -virtualenv==20.1.0 +virtualenv==20.2.1 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/userscripts/README.md b/misc/userscripts/README.md index a17f7164c..669bfa664 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,9 @@ 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. [castnow]: https://github.com/xat/castnow [youtube-dl]: https://rg3.github.io/youtube-dl/ @@ -67,6 +70,8 @@ The following userscripts can be found on their own repositories. 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/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/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/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/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/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/browser/commands.py b/qutebrowser/browser/commands.py index 34c078d89..18777e250 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -1057,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. """ diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 31a9d7f29..96220897c 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -560,8 +560,8 @@ class AbstractDownloadItem(QObject): 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.""" diff --git a/qutebrowser/browser/navigate.py b/qutebrowser/browser/navigate.py index b852ab29e..bace6fa6a 100644 --- a/qutebrowser/browser/navigate.py +++ b/qutebrowser/browser/navigate.py @@ -134,13 +134,13 @@ def path_up(url, count): """ urlutils.ensure_valid(url) url = url.adjusted(QUrl.RemoveFragment | QUrl.RemoveQuery) - path = url.path() + 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 diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py index c5bfc07e6..0bf165965 100644 --- a/qutebrowser/browser/qtnetworkdownloads.py +++ b/qutebrowser/browser/qtnetworkdownloads.py @@ -419,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: @@ -437,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): diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py index 193a2a0e0..9234e82d8 100644 --- a/qutebrowser/browser/shared.py +++ b/qutebrowser/browser/shared.py @@ -82,7 +82,7 @@ def javascript_confirm(url, js_msg, abort_on): raise CallSuper 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, @@ -99,7 +99,7 @@ def javascript_prompt(url, js_msg, default, abort_on): return (False, "") 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, @@ -122,7 +122,7 @@ def javascript_alert(url, js_msg, abort_on): return 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) diff --git a/qutebrowser/browser/webengine/darkmode.py b/qutebrowser/browser/webengine/darkmode.py index 5795364b0..630a7bf9e 100644 --- a/qutebrowser/browser/webengine/darkmode.py +++ b/qutebrowser/browser/webengine/darkmode.py @@ -73,6 +73,7 @@ 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 @@ -90,7 +91,6 @@ class Variant(enum.Enum): """A dark mode variant.""" - unavailable = enum.auto() qt_511_to_513 = enum.auto() qt_514 = enum.auto() qt_515_0 = enum.auto() @@ -159,8 +159,6 @@ _QT_514_SETTINGS = [ # workaround warning below if the setting wasn't explicitly customized. _DARK_MODE_DEFINITIONS: Mapping[Variant, _DarkModeDefinitionType] = { - Variant.unavailable: ([], set()), - Variant.qt_515_2: ([ # 'darkMode' renamed to 'forceDarkMode' ('enabled', 'forceDarkModeEnabled', _BOOLS), @@ -235,6 +233,13 @@ _DARK_MODE_DEFINITIONS: Mapping[Variant, _DarkModeDefinitionType] = { 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: diff --git a/qutebrowser/browser/webengine/interceptor.py b/qutebrowser/browser/webengine/interceptor.py index 95e01588b..b27509552 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 @@ -204,5 +204,11 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor): for header, value in shared.custom_headers(url=url): 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/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py index a8e58b8a2..4e1290f82 100644 --- a/qutebrowser/completion/completiondelegate.py +++ b/qutebrowser/completion/completiondelegate.py @@ -47,6 +47,7 @@ class _Highlighter(QSyntaxHighlighter): self._expression = QRegularExpression( pat, QRegularExpression.CaseInsensitiveOption ) + qtutils.ensure_valid(self._expression) def highlightBlock(self, text): """Override highlightBlock for custom highlighting.""" diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index 1f5304b61..86de688a0 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -331,7 +331,8 @@ class CompletionView(QTreeView): 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() diff --git a/qutebrowser/completion/models/listcategory.py b/qutebrowser/completion/models/listcategory.py index f0cc21da0..79dc0770a 100644 --- a/qutebrowser/completion/models/listcategory.py +++ b/qutebrowser/completion/models/listcategory.py @@ -64,6 +64,7 @@ class ListCategory(QSortFilterProxyModel): val = re.escape(val) val = val.replace(r'\ ', '.*') rx = QRegExp(val, Qt.CaseInsensitive) + qtutils.ensure_valid(rx) self.setFilterRegExp(rx) self.invalidate() sortcol = 0 diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index da64c0f6a..645342767 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2074,6 +2074,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: diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 9185ee6ef..6328c3140 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -56,8 +56,8 @@ from typing import (Any, Callable, Dict as DictType, Iterable, Iterator, import attr import yaml from PyQt5.QtCore import QUrl, Qt -from PyQt5.QtGui import QColor, 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 @@ -96,9 +96,15 @@ class ValidValues: generate_docs: Whether to show the values in the docs. """ - def __init__(self, - *values: Union[str, DictType[str, str], 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: DictType[str, str] = {} @@ -107,17 +113,18 @@ class ValidValues: 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 @@ -308,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) @@ -329,14 +329,15 @@ 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: DictType[str, 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: Any) -> Any: self._basic_py_validation(value, str) @@ -345,7 +346,8 @@ class MappingType(BaseType): 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, @@ -998,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."), } @@ -1019,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." + ), } @@ -1172,50 +1159,11 @@ class FontBase(BaseType): 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 @@ -1753,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): @@ -1828,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): diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index 7c2d4ee8c..e7a60a7eb 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -29,6 +29,8 @@ from typing import ( MutableMapping) from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QFontDatabase +from PyQt5.QtWidgets import QApplication from qutebrowser.utils import utils, urlmatch, usertypes, qtutils from qutebrowser.config import configexc @@ -280,6 +282,9 @@ class FontFamilies: 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) @@ -288,7 +293,7 @@ class FontFamilies: 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: @@ -296,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 index 790fbaca3..8ab93c904 100644 --- a/qutebrowser/config/qtargs.py +++ b/qutebrowser/config/qtargs.py @@ -109,6 +109,16 @@ def _qtwebengine_enabled_features(feature_flags: Sequence[str]) -> Iterator[str] 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, @@ -145,8 +155,7 @@ def _qtwebengine_args( from qutebrowser.browser.webengine import darkmode blink_settings = list(darkmode.settings()) if blink_settings: - yield '--blink-settings=' + ','.join('{}={}'.format(k, v) - for k, v in 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: @@ -191,16 +200,26 @@ def _qtwebengine_settings_args() -> Iterator[str]: }, 'content.headers.referer': { 'always': None, - 'never': '--no-referrers', - 'same-domain': '--reduced-referrer-granularity', } } - if qtutils.version_check('5.14'): + referrer_setting = settings['content.headers.referer'] + if qtutils.version_check('5.14', compiled=False): settings['colors.webpage.prefers_color_scheme_dark'] = { True: '--force-dark-mode', False: None, } + # 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)] diff --git a/qutebrowser/javascript/.eslintrc.yaml b/qutebrowser/javascript/.eslintrc.yaml index b84dbeb96..939500aa3 100644 --- a/qutebrowser/javascript/.eslintrc.yaml +++ b/qutebrowser/javascript/.eslintrc.yaml @@ -41,7 +41,7 @@ rules: func-names: "off" sort-keys: "off" no-warning-comments: "off" - max-len: ["error", {"ignoreUrls": true}] + max-len: ["error", {"ignoreUrls": true, "code": 88}] capitalized-comments: "off" prefer-destructuring: "off" line-comment-position: "off" diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index b8228545a..6273b3382 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -203,8 +203,10 @@ class MainWindow(QWidget): from qutebrowser.mainwindow.statusbar import bar self.setAttribute(Qt.WA_DeleteOnClose) - self.setAttribute(Qt.WA_TranslucentBackground) - self.palette().setColor(QPalette.Window, Qt.transparent) + 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() diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 9bb8d34ce..c67e5fa0e 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -863,7 +863,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(): @@ -880,7 +880,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) diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index b40c59bd5..f853f8fd9 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -639,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/misc/crashdialog.py b/qutebrowser/misc/crashdialog.py index 2bdb790e7..52cb8ad0c 100644 --- a/qutebrowser/misc/crashdialog.py +++ b/qutebrowser/misc/crashdialog.py @@ -30,7 +30,6 @@ import datetime import enum 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, @@ -361,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{}, " diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index 92920c72c..d1c57760e 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -170,13 +170,16 @@ 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 - parsed_qversion = parse_version(qVersion()) - - if (QT_VERSION < 0x050C00 or PYQT_VERSION < 0x050C00 or - parsed_qversion < parse_version('5.12.0')): + 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)) diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py index 275da7c4c..cd6ea2b32 100644 --- a/qutebrowser/utils/qtutils.py +++ b/qutebrowser/utils/qtutils.py @@ -31,9 +31,8 @@ Module attributes: import io import operator import contextlib -from typing import TYPE_CHECKING, BinaryIO, IO, Iterator, Optional, Union, cast +from typing import TYPE_CHECKING, BinaryIO, IO, Iterator, Optional, Union, Tuple, cast -import pkg_resources from PyQt5.QtCore import (qVersion, QEventLoop, QDataStream, QByteArray, QIODevice, QFileDevice, QSaveFile, QT_VERSION_STR, PYQT_VERSION_STR, QObject, QUrl) @@ -48,7 +47,7 @@ if TYPE_CHECKING: from PyQt5.QtWebEngineWidgets import QWebEngineHistory from qutebrowser.misc import objects -from qutebrowser.utils import usertypes +from qutebrowser.utils import usertypes, utils MAXVALS = { @@ -100,15 +99,15 @@ 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) + result = op(utils.parse_version(PYQT_VERSION_STR), parsed) return result @@ -118,8 +117,8 @@ 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: @@ -157,19 +156,15 @@ def check_overflow(arg: int, ctype: str, fatal: bool = True) -> int: return arg -if TYPE_CHECKING: - # Protocol was added in Python 3.8 - from typing import Protocol - - class Validatable(Protocol): +class Validatable(utils.Protocol): - """An object with an isValid() method (e.g. QUrl).""" + """An object with an isValid() method (e.g. QUrl).""" - def isValid(self) -> bool: - ... + def isValid(self) -> bool: + ... -def ensure_valid(obj: 'Validatable') -> None: +def ensure_valid(obj: Validatable) -> None: """Ensure a Qt object with an .isValid() method is valid.""" if not obj.isValid(): raise QtValueError(obj) @@ -440,7 +435,7 @@ class QtValueError(ValueError): """Exception which gets raised by ensure_valid.""" - def __init__(self, obj: 'Validatable') -> None: + def __init__(self, obj: Validatable) -> None: try: self.reason = obj.errorString() # type: ignore[attr-defined] except AttributeError: @@ -474,3 +469,78 @@ class EventLoop(QEventLoop): 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/urlutils.py b/qutebrowser/utils/urlutils.py index 977bd7cc6..41d20e734 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -467,12 +467,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: diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 9ecae9e92..893dae877 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -21,7 +21,7 @@ import operator import enum -from typing import TYPE_CHECKING, Any, Optional, Sequence, TypeVar, Union +from typing import Any, Optional, Sequence, TypeVar, Union import attr from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QTimer @@ -30,19 +30,7 @@ from PyQt5.QtCore import QUrl from qutebrowser.utils import log, qtutils, utils -if TYPE_CHECKING: - # Protocol was added in Python 3.8 - from typing import Protocol - - class SupportsLessThan(Protocol): - - """Protocol for the _T TypeVar below.""" - - def __lt__(self, other: Any) -> bool: - ... - - -_T = TypeVar('_T', bound='SupportsLessThan') +_T = TypeVar('_T', bound=utils.SupportsLessThan) class Unset: diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 7c2bf843d..31ff5bf50 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -36,10 +36,11 @@ import glob import mimetypes import ctypes import ctypes.util -from typing import Any, Callable, IO, Iterator, Optional, Sequence, Tuple, Type, Union +from typing import (Any, Callable, IO, Iterator, Optional, Sequence, Tuple, Type, Union, + TYPE_CHECKING, cast) -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 import pkg_resources import yaml @@ -53,7 +54,7 @@ except ImportError: # pragma: no cover YAML_C_EXT = False import qutebrowser -from qutebrowser.utils import qtutils, log +from qutebrowser.utils import log fake_clipboard = None @@ -66,6 +67,34 @@ is_windows = sys.platform.startswith('win') is_posix = os.name == 'posix' +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.""" + + +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.""" @@ -210,79 +239,10 @@ def resource_filename(filename: str) -> str: return pkg_resources.resource_filename(qutebrowser.__name__, filename) -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. - """ - 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: - 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()) - 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: diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 032563478..64efe4c4f 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -34,7 +34,6 @@ 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, @@ -84,7 +83,7 @@ class DistributionInfo: id: Optional[str] = attr.ib() parsed: 'Distribution' = attr.ib() - version: Optional[Tuple[str, ...]] = attr.ib() + version: Optional[utils.VersionNumber] = attr.ib() pretty: str = attr.ib() @@ -139,8 +138,8 @@ def distribution() -> Optional[DistributionInfo]: assert pretty is not None if 'VERSION_ID' in info: - dist_version: Optional[Tuple[str, ...]] = pkg_resources.parse_version( - info['VERSION_ID']) + version_id = info['VERSION_ID'] + dist_version: Optional[utils.VersionNumber] = utils.parse_version(version_id) else: dist_version = None @@ -401,7 +400,7 @@ def _chromium_version() -> str: 5.15.1: Security fixes up to 85.0.4183.83 (2020-08-25) 5.15.2: Updated to 83.0.4103.122 (~2020-06-24) - Security fixes up to 86.0.4240.111 (2020-10-20) + Security fixes up to 86.0.4240.183 (2020-11-02) Also see: diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index 8030d61a1..6044a1e18 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -252,7 +252,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) @@ -289,6 +289,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__), @@ -301,7 +309,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'), @@ -465,7 +473,9 @@ def main(): "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() @@ -487,7 +497,7 @@ def main(): 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/ci/docker/Dockerfile.j2 b/scripts/dev/ci/docker/Dockerfile.j2 new file mode 100644 index 000000000..1835f0a2f --- /dev/null +++ b/scripts/dev/ci/docker/Dockerfile.j2 @@ -0,0 +1,27 @@ +FROM thecompiler/archlinux +MAINTAINER Florian Bruhin <me@the-compiler.org> + +{% 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 + +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/misc_checks.py b/scripts/dev/misc_checks.py index 14373f94f..7b9ce769b 100644 --- a/scripts/dev/misc_checks.py +++ b/scripts/dev/misc_checks.py @@ -274,12 +274,35 @@ def check_userscripts_descriptions(_args: argparse.Namespace = None) -> bool: return ok +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, - 'userscripts': check_userscripts_descriptions, + 'userscript-descriptions': check_userscripts_descriptions, + 'userscript-shebangs': check_userscript_shebangs, 'changelog-urls': check_changelog_urls, } diff --git a/scripts/dev/recompile_requirements.py b/scripts/dev/recompile_requirements.py index c63441047..87740c5bb 100644 --- a/scripts/dev/recompile_requirements.py +++ b/scripts/dev/recompile_requirements.py @@ -73,7 +73,7 @@ CHANGELOG_URLS = { '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://pypi.org/project/packaging/', + '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', @@ -82,7 +82,7 @@ CHANGELOG_URLS = { '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', + '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', @@ -105,7 +105,7 @@ CHANGELOG_URLS = { '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': 'http://babel.pocoo.org/en/latest/changelog.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/', @@ -130,9 +130,8 @@ CHANGELOG_URLS = { '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/4.6/changes-4.6.0.html', + 'lxml': 'https://lxml.de/index.html#old-versions', 'jwcrypto': 'https://github.com/latchset/jwcrypto/commits/master', - 'tox-pip-version': 'https://github.com/pglass/tox-pip-version/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', @@ -170,7 +169,6 @@ CHANGELOG_URLS = { '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', - 'tox-venv': 'https://github.com/tox-dev/tox-venv/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', @@ -359,6 +357,7 @@ def _get_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() diff --git a/scripts/mkvenv.py b/scripts/mkvenv.py index f582e23b0..bfddff736 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 shutil -import venv +import venv as pyvenv import subprocess -from typing import List, Optional, Tuple +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,7 +39,22 @@ 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('--keep', @@ -74,7 +90,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument('--tox-error', action='store_true', help=argparse.SUPPRESS) - return parser.parse_args() + return parser.parse_args(argv) def pyqt_versions() -> List[str]: @@ -93,22 +109,40 @@ def pyqt_versions() -> 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) @@ -125,27 +159,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: @@ -202,6 +234,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") @@ -233,27 +388,24 @@ 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: +def run(args) -> 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.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) + 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 not args.keep: utils.print_title("Creating virtual environment") @@ -275,6 +427,10 @@ def main() -> None: else: raise AssertionError + apply_xcb_util_workaround(venv_dir, args.pyqt_type, args.pyqt_version) + if args.pyqt_type != 'skip': + run_qt_smoke_test(venv_dir) + install_requirements(venv_dir) install_qutebrowser(venv_dir) if args.dev: @@ -284,5 +440,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/tests/end2end/conftest.py b/tests/end2end/conftest.py index 11066fb92..17b457521 100644 --- a/tests/end2end/conftest.py +++ b/tests/end2end/conftest.py @@ -35,7 +35,7 @@ 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 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/fixtures/testprocess.py b/tests/end2end/fixtures/testprocess.py index 85778c3e0..51352c539 100644 --- a/tests/end2end/fixtures/testprocess.py +++ b/tests/end2end/fixtures/testprocess.py @@ -101,7 +101,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 @@ -114,11 +114,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): diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 9f4383b35..d40739724 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -192,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 cf8ffd006..e38d64bb8 100644 --- a/tests/end2end/fixtures/webserver_sub.py +++ b/tests/end2end/fixtures/webserver_sub.py @@ -29,8 +29,8 @@ parameters or headers with the same name properly. import sys import json import time -import os import threading +import pathlib from http import HTTPStatus import cheroot.wsgi @@ -40,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.""" @@ -54,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) @@ -248,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.""" @@ -273,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 @@ -321,9 +321,9 @@ 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) server.start() 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_invocations.py b/tests/end2end/test_invocations.py index abffc9350..e34bd912d 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -23,6 +23,7 @@ import subprocess import sys import logging import re +import json import pytest from PyQt5.QtCore import QProcess @@ -383,3 +384,36 @@ 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 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/unit/browser/test_history.py b/tests/unit/browser/test_history.py index c70b858f5..c1990de0d 100644 --- a/tests/unit/browser/test_history.py +++ b/tests/unit/browser/test_history.py @@ -464,7 +464,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): diff --git a/tests/unit/browser/test_navigate.py b/tests/unit/browser/test_navigate.py index 5fe0acbf6..5a93a517c 100644 --- a/tests/unit/browser/test_navigate.py +++ b/tests/unit/browser/test_navigate.py @@ -187,6 +187,8 @@ class TestUp: ('/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' diff --git a/tests/unit/browser/webengine/test_darkmode.py b/tests/unit/browser/webengine/test_darkmode.py index 05cd7737a..3e62000d2 100644 --- a/tests/unit/browser/webengine/test_darkmode.py +++ b/tests/unit/browser/webengine/test_darkmode.py @@ -169,6 +169,22 @@ def test_variant(monkeypatch, qversion, webengine_version, expected): 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' diff --git a/tests/unit/browser/webkit/network/test_networkreply.py b/tests/unit/browser/webkit/network/test_networkreply.py index 0aa3943e7..e1c9d04f8 100644 --- a/tests/unit/browser/webkit/network/test_networkreply.py +++ b/tests/unit/browser/webkit/network/test_networkreply.py @@ -52,9 +52,8 @@ class TestFixedDataNetworkReply: b'Hello World! This is a test.']) def test_data(self, qtbot, req, data): reply = networkreply.FixedDataNetworkReply(req, data, 'test/foo') - with qtbot.waitSignal(reply.metaDataChanged), \ - qtbot.waitSignal(reply.readyRead), \ - qtbot.waitSignal(reply.finished): + with qtbot.waitSignals([reply.metaDataChanged, reply.readyRead, + reply.finished], order='strict'): pass assert reply.bytesAvailable() == len(data) @@ -79,7 +78,7 @@ def test_error_network_reply(qtbot, req): reply = networkreply.ErrorNetworkReply( req, "This is an error", QNetworkReply.UnknownNetworkError) - with qtbot.waitSignal(reply.error), qtbot.waitSignal(reply.finished): + with qtbot.waitSignals([reply.error, reply.finished], order='strict'): pass reply.abort() # shouldn't do anything diff --git a/tests/unit/completion/test_completiondelegate.py b/tests/unit/completion/test_completiondelegate.py index 41dfee98b..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 @@ -69,6 +71,13 @@ def test_benchmark_highlight(benchmark): 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. diff --git a/tests/unit/completion/test_completionmodel.py b/tests/unit/completion/test_completionmodel.py index 1fc0b4d73..98e70dc01 100644 --- a/tests/unit/completion/test_completionmodel.py +++ b/tests/unit/completion/test_completionmodel.py @@ -77,8 +77,8 @@ def test_set_pattern(pat, qtbot): for c in cats: c.set_pattern = mock.Mock(spec=[]) model.add_category(c) - with qtbot.waitSignal(model.layoutAboutToBeChanged), \ - qtbot.waitSignal(model.layoutChanged): + with qtbot.waitSignals([model.layoutAboutToBeChanged, model.layoutChanged], + order='strict'): model.set_pattern(pat) for c in cats: c.set_pattern.assert_called_with(pat) diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index 356b854c5..c0ef4b47f 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -161,6 +161,7 @@ def test_completion_item_focus_no_model(which, completionview, model, qtbot): completionview.completion_item_focus(which) +@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. diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 2ac7084dd..8b4653b58 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -26,6 +26,8 @@ import time from datetime import datetime from unittest import mock +import hypothesis +import hypothesis.strategies import pytest from PyQt5.QtCore import QUrl, QDateTime try: @@ -37,7 +39,8 @@ except ImportError: 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 @@ -1324,3 +1327,10 @@ def test_undo_completion(tabbed_browser_stubs, info): "2020-01-01 00:00"), ], }) + + +@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/config/test_config.py b/tests/unit/config/test_config.py index 588e4a5cf..b30ab4bee 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -432,12 +432,11 @@ class TestConfig: assert conf.get_obj(name1) == 'never' assert conf.get_obj(name2) is True - with qtbot.waitSignal(conf.changed), qtbot.waitSignal(conf.changed): + with qtbot.waitSignals([conf.changed, conf.changed]) as blocker: conf.clear(save_yaml=save_yaml) - # Doesn't work with PyQt 5.15.1 workaround - # options = {blocker1.args[0], blocker2.args[0]} - # assert options == {name1, name2} + options = {e.args[0] for e in blocker.all_signals_and_args} + assert options == {name1, name2} if save_yaml: assert yaml_value(name1) is usertypes.UNSET diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 76d3ac094..710018604 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -138,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 " @@ -396,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: @@ -429,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: diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py index 7e1a7c744..4830340cf 100644 --- a/tests/unit/config/test_configutils.py +++ b/tests/unit/config/test_configutils.py @@ -21,6 +21,7 @@ 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, qtutils @@ -364,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 index dc6b655b3..051956a00 100644 --- a/tests/unit/config/test_qtargs.py +++ b/tests/unit/config/test_qtargs.py @@ -116,7 +116,7 @@ class TestQtArgs: 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: 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 []) @@ -252,14 +252,31 @@ class TestQtArgs: else: assert arg in args - @pytest.mark.parametrize('referer, arg', [ - ('always', None), - ('never', '--no-referrers'), - ('same-domain', '--reduced-referrer-granularity'), + @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, referer, arg): - monkeypatch.setattr(qtargs.objects, 'backend', - usertypes.Backend.QtWebEngine) + 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([]) @@ -268,6 +285,7 @@ class TestQtArgs: 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 diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 88d9ae66c..cd57d33cb 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -21,7 +21,7 @@ from unittest import mock -from PyQt5.QtCore import Qt, PYQT_VERSION +from PyQt5.QtCore import Qt import pytest from qutebrowser.keyinput import basekeyparser, keyutils @@ -305,8 +305,6 @@ class TestCount: # https://github.com/qutebrowser/qutebrowser/issues/3743 handle_text(prompt_keyparser, Qt.Key_twosuperior, Qt.Key_B, Qt.Key_A) - @pytest.mark.skipif(PYQT_VERSION == 0x050F01, - reason='waitSignals is broken in PyQt 5.15.1') def test_count_keystring_update(self, qtbot, handle_text, prompt_keyparser): """Make sure the keystring is updated correctly when entering count.""" 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_guiprocess.py b/tests/unit/misc/test_guiprocess.py index 7e71478ce..4ed19f64e 100644 --- a/tests/unit/misc/test_guiprocess.py +++ b/tests/unit/misc/test_guiprocess.py @@ -54,8 +54,8 @@ def fake_proc(monkeypatch, stubs): def test_start(proc, qtbot, message_mock, py_proc): """Test simply starting a process.""" - with qtbot.waitSignal(proc.started, timeout=10000), \ - qtbot.waitSignal(proc.finished, timeout=10000): + with qtbot.waitSignals([proc.started, proc.finished], timeout=10000, + order='strict'): argv = py_proc("import sys; print('test'); sys.exit(0)") proc.start(*argv) @@ -70,8 +70,8 @@ def test_start_verbose(proc, qtbot, message_mock, py_proc): """Test starting a process verbosely.""" proc.verbose = True - with qtbot.waitSignal(proc.started, timeout=10000), \ - qtbot.waitSignal(proc.finished, timeout=10000): + with qtbot.waitSignals([proc.started, proc.finished], timeout=10000, + order='strict'): argv = py_proc("import sys; print('test'); sys.exit(0)") proc.start(*argv) @@ -99,8 +99,9 @@ def test_start_output_message(proc, qtbot, caplog, message_mock, py_proc, code.append("sys.exit(0)") with caplog.at_level(logging.ERROR, 'message'): - with qtbot.waitSignal(proc.started, timeout=10000), \ - qtbot.waitSignal(proc.finished, timeout=10000): + with qtbot.waitSignals([proc.started, proc.finished], + timeout=10000, + order='strict'): argv = py_proc(';'.join(code)) proc.start(*argv) @@ -146,8 +147,8 @@ def test_start_env(monkeypatch, qtbot, py_proc): sys.exit(0) """) - with qtbot.waitSignal(proc.started, timeout=10000), \ - qtbot.waitSignal(proc.finished, timeout=10000): + with qtbot.waitSignals([proc.started, proc.finished], timeout=10000, + order='strict'): proc.start(*argv) data = qutescheme.spawn_output @@ -186,12 +187,12 @@ def test_double_start(qtbot, proc, py_proc): def test_double_start_finished(qtbot, proc, py_proc): """Test starting a GUIProcess twice (with the first call finished).""" - with qtbot.waitSignal(proc.started, timeout=10000), \ - qtbot.waitSignal(proc.finished, timeout=10000): + with qtbot.waitSignals([proc.started, proc.finished], timeout=10000, + order='strict'): argv = py_proc("import sys; sys.exit(0)") proc.start(*argv) - with qtbot.waitSignal(proc.started, timeout=10000), \ - qtbot.waitSignal(proc.finished, timeout=10000): + with qtbot.waitSignals([proc.started, proc.finished], timeout=10000, + order='strict'): argv = py_proc("import sys; sys.exit(0)") proc.start(*argv) @@ -266,8 +267,8 @@ def test_exit_successful_output(qtbot, proc, py_proc, stream): def test_stdout_not_decodable(proc, qtbot, message_mock, py_proc): """Test handling malformed utf-8 in stdout.""" - with qtbot.waitSignal(proc.started, timeout=10000), \ - qtbot.waitSignal(proc.finished, timeout=10000): + with qtbot.waitSignals([proc.started, proc.finished], timeout=10000, + order='strict'): argv = py_proc(r""" import sys # Using \x81 because it's invalid in UTF-8 and CP1252 diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py index 2c9f7ea7f..3f53ca238 100644 --- a/tests/unit/misc/test_ipc.py +++ b/tests/unit/misc/test_ipc.py @@ -493,10 +493,10 @@ NEW_VERSION = str(ipc.PROTOCOL_VERSION + 1).encode('utf-8') (b'{"args": [], "target_arg": null}\n', 'invalid version'), ]) def test_invalid_data(qtbot, ipc_server, connected_socket, caplog, data, msg): + signals = [ipc_server.got_invalid_data, connected_socket.disconnected] with caplog.at_level(logging.ERROR): with qtbot.assertNotEmitted(ipc_server.got_args): - with qtbot.waitSignal(ipc_server.got_invalid_data), \ - qtbot.waitSignal(connected_socket.disconnected): + with qtbot.waitSignals(signals, order='strict'): connected_socket.write(data) invalid_msg = 'Ignoring invalid IPC data from socket ' @@ -514,8 +514,8 @@ def test_multiline(qtbot, ipc_server, connected_socket): version=ipc.PROTOCOL_VERSION)) with qtbot.assertNotEmitted(ipc_server.got_invalid_data): - with qtbot.waitSignal(ipc_server.got_args), \ - qtbot.waitSignal(ipc_server.got_args): + with qtbot.waitSignals([ipc_server.got_args, ipc_server.got_args], + order='strict'): connected_socket.write(data.encode('utf-8')) assert len(spy) == 2 diff --git a/tests/unit/utils/test_qtutils.py b/tests/unit/utils/test_qtutils.py index 81d198946..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, @@ -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_urlutils.py b/tests/unit/utils/test_urlutils.py index eac2b6c1d..0167f6cee 100644 --- a/tests/unit/utils/test_urlutils.py +++ b/tests/unit/utils/test_urlutils.py @@ -615,7 +615,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'), @@ -624,6 +624,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.""" diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index 0c39ad183..ac7ed5ce7 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -29,10 +29,8 @@ 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 @@ -40,22 +38,12 @@ 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,110 +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('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 = utils.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 = 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'), @@ -807,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) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 2bfdf10d7..593557ae8 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -33,7 +33,6 @@ import textwrap import datetime import attr -import pkg_resources import pytest import hypothesis import hypothesis.strategies @@ -77,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 (""" @@ -90,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 (""" @@ -102,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 (""" @@ -133,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 (""" @@ -146,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 (""" @@ -159,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 (""" @@ -188,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 (""" @@ -221,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, diff --git a/tests/unit/utils/usertypes/test_question.py b/tests/unit/utils/usertypes/test_question.py index 014ef7f0c..59c3f7d43 100644 --- a/tests/unit/utils/usertypes/test_question.py +++ b/tests/unit/utils/usertypes/test_question.py @@ -53,25 +53,23 @@ def test_done(mode, answer, signal_names, question, qtbot): question.mode = mode question.answer = answer signals = [getattr(question, name) for name in signal_names] - blockers = [qtbot.waitSignal(signal) for signal in signals] - - question.done() - for blocker in blockers: - blocker.wait() - + with qtbot.waitSignals(signals, order='strict'): + question.done() assert not question.is_aborted def test_cancel(question, qtbot): """Test Question.cancel().""" - with qtbot.waitSignal(question.cancelled), qtbot.waitSignal(question.completed): + with qtbot.waitSignals([question.cancelled, question.completed], + order='strict'): question.cancel() assert not question.is_aborted def test_abort(question, qtbot): """Test Question.abort().""" - with qtbot.waitSignal(question.aborted), qtbot.waitSignal(question.completed): + with qtbot.waitSignals([question.aborted, question.completed], + order='strict'): question.abort() assert question.is_aborted @@ -12,17 +12,17 @@ minversion = 3.15 [testenv] setenv = PYTEST_QT_API=pyqt5 - pyqt{,512,513,514,515}: LINK_PYQT_SKIP=true - pyqt{,512,513,514,515}: 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 XDG_* QUTE_* DOCKER QT_QUICK_BACKEND PY_COLORS basepython = + py: {env:PYTHON:python3} py3: {env:PYTHON:python3} py36: {env:PYTHON:python3.6} py37: {env:PYTHON:python3.7} py38: {env:PYTHON:python3.8} py39: {env:PYTHON:python3.9} -pip_version = pip deps = -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-tests.txt @@ -31,6 +31,7 @@ deps = 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} @@ -41,16 +42,14 @@ commands = [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 all + {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 @@ -61,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 @@ -72,7 +70,6 @@ commands = [testenv:pylint] basepython = {env:PYTHON:python3} -pip_version = pip ignore_errors = true passenv = deps = @@ -86,7 +83,6 @@ commands = [testenv:pylint-pyqtlink] basepython = {env:PYTHON:python3} -pip_version = pip ignore_errors = true passenv = deps = @@ -100,7 +96,6 @@ commands = [testenv:pylint-master] basepython = {env:PYTHON:python3} -pip_version = pip passenv = {[testenv:pylint]passenv} deps = -r{toxinidir}/requirements.txt @@ -113,7 +108,6 @@ commands = [testenv:flake8] basepython = {env:PYTHON:python3} -pip_version = pip passenv = deps = -r{toxinidir}/requirements.txt @@ -123,7 +117,6 @@ commands = [testenv:pyroma] basepython = {env:PYTHON:python3} -pip_version = pip passenv = deps = -r{toxinidir}/misc/requirements/requirements-pyroma.txt @@ -132,7 +125,6 @@ commands = [testenv:check-manifest] basepython = {env:PYTHON:python3} -pip_version = pip passenv = deps = -r{toxinidir}/misc/requirements/requirements-check-manifest.txt @@ -141,7 +133,6 @@ commands = [testenv:docs] basepython = {env:PYTHON:python3} -pip_version = pip whitelist_externals = git passenv = CI GITHUB_REF deps = @@ -154,7 +145,6 @@ commands = [testenv:pyinstaller-{64,32}] basepython = {env:PYTHON:python3} -pip_version = pip passenv = APPDATA HOME PYINSTALLER_DEBUG deps = -r{toxinidir}/requirements.txt @@ -179,7 +169,6 @@ commands = bash scripts/dev/run_shellcheck.sh {posargs} [testenv:mypy] basepython = {env:PYTHON:python3} -pip_version = pip passenv = TERM MYPY_FORCE_TERMINAL_WIDTH deps = -r{toxinidir}/requirements.txt @@ -191,14 +180,12 @@ commands = [testenv:yamllint] basepython = {env:PYTHON:python3} -pip_version = pip deps = -r{toxinidir}/misc/requirements/requirements-yamllint.txt commands = {envpython} -m yamllint -f colored --strict . {posargs} [testenv:mypy-diff] basepython = {env:PYTHON:python3} -pip_version = pip passenv = {[testenv:mypy]passenv} deps = {[testenv:mypy]deps} commands = @@ -207,7 +194,6 @@ commands = [testenv:sphinx] basepython = {env:PYTHON:python3} -pip_version = pip passenv = usedevelop = true deps = @@ -219,7 +205,6 @@ commands = [testenv:build-release] basepython = {env:PYTHON:python3} -pip_version = pip passenv = * usedevelop = true deps = |