diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 23de6e95a..000000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,19 +0,0 @@ -[bumpversion] -current_version = 3.1.0 -commit = True -message = Release v{new_version} -tag = True -sign_tags = True -tag_name = v{new_version} - -[bumpversion:file:qutebrowser/__init__.py] -parse = __version__ = (?P\d+)\.(?P\d+)\.(?P\d+) - -[bumpversion:file:misc/org.qutebrowser.qutebrowser.appdata.xml] -search = -replace = - - -[bumpversion:file:doc/changelog.asciidoc] -search = (unreleased) -replace = ({now:%Y-%m-%d}) diff --git a/.bumpversion.toml b/.bumpversion.toml new file mode 100644 index 000000000..51524959d --- /dev/null +++ b/.bumpversion.toml @@ -0,0 +1,26 @@ +[tool.bumpversion] +current_version = "3.4.0" +commit = true +message = "Release v{new_version}" +tag = true +sign_tags = true +tag_name = "v{new_version}" +parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" +serialize = ["{major}.{minor}.{patch}"] +allow_dirty = false + +[[tool.bumpversion.files]] +filename = "qutebrowser/__init__.py" +search = "__version__ = \"{current_version}\"" +replace = "__version__ = \"{new_version}\"" + +[[tool.bumpversion.files]] +filename = "misc/org.qutebrowser.qutebrowser.appdata.xml" +search = "" +replace = """ +""" + +[[tool.bumpversion.files]] +filename = "doc/changelog.asciidoc" +search = "(unreleased)" +replace = "({now:%Y-%m-%d})" diff --git a/.flake8 b/.flake8 index 6c4dd923e..8bf2b3efd 100644 --- a/.flake8 +++ b/.flake8 @@ -58,7 +58,7 @@ ignore = PT004, PT011, PT012 -min-version = 3.8.0 +min-version = 3.9.0 max-complexity = 12 per-file-ignores = qutebrowser/api/hook.py : N801 diff --git a/.github/workflows/bleeding.yml b/.github/workflows/bleeding.yml index 2587d832b..98c43dc7b 100644 --- a/.github/workflows/bleeding.yml +++ b/.github/workflows/bleeding.yml @@ -10,7 +10,7 @@ on: jobs: tests: if: "github.repository == 'qutebrowser/qutebrowser'" - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 timeout-minutes: 45 strategy: fail-fast: false @@ -27,6 +27,7 @@ jobs: PY_COLORS: "1" DOCKER: "${{ matrix.image }}" CI: true + TMPDIR: "${{ runner.temp }}" volumes: # Hardcoded because we can't use ${{ runner.temp }} here apparently. - /home/runner/work/_temp/:/home/runner/work/_temp/ @@ -37,12 +38,30 @@ jobs: persist-credentials: false - name: Set up problem matchers run: "python scripts/dev/ci/problemmatchers.py py3 ${{ runner.temp }}" + - name: Upgrade 3rd party assets + run: "tox exec -e ${{ matrix.testenv }} -- python scripts/dev/update_3rdparty.py --gh-token ${{ secrets.GITHUB_TOKEN }} --modern-pdfjs" + if: "endsWith(matrix.image, '-qt6')" - name: Run tox run: dbus-run-session tox -e ${{ matrix.testenv }} + - name: Gather info + id: info + run: | + echo "date=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" + echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + shell: bash + if: failure() + - name: Upload screenshots + uses: actions/upload-artifact@v4 + with: + name: "end2end-screenshots-${{ steps.info.outputs.date }}-${{ steps.info.outputs.sha_short }}-${{ matrix.image }}" + path: | + ${{ runner.temp }}/pytest-of-user/pytest-current/pytest-screenshots/*.png + if-no-files-found: ignore + if: failure() irc: timeout-minutes: 2 continue-on-error: true - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 needs: [tests] if: "always() && github.repository == 'qutebrowser/qutebrowser'" steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 599ba3b1b..0ec73024f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: linters: if: "!contains(github.event.head_commit.message, '[ci skip]')" timeout-minutes: 10 - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -50,15 +50,15 @@ jobs: python-version: '3.10' - uses: actions/setup-node@v4 with: - node-version: '16.x' + node-version: '22.x' if: "matrix.testenv == 'eslint'" - name: Set up problem matchers run: "python scripts/dev/ci/problemmatchers.py ${{ matrix.testenv }} ${{ runner.temp }}" - name: Install dependencies run: | [[ ${{ matrix.testenv }} == eslint ]] && npm install -g 'eslint@<9.0.0' - [[ ${{ matrix.testenv }} == docs ]] && sudo apt-get update && sudo apt-get install --no-install-recommends asciidoc libegl1-mesa - [[ ${{ matrix.testenv }} == vulture || ${{ matrix.testenv }} == pylint ]] && sudo apt-get update && sudo apt-get install --no-install-recommends libegl1-mesa + [[ ${{ matrix.testenv }} == docs ]] && sudo apt-get update && sudo apt-get install --no-install-recommends asciidoc libegl1 + [[ ${{ matrix.testenv }} == vulture || ${{ matrix.testenv }} == pylint ]] && sudo apt-get update && sudo apt-get install --no-install-recommends libegl1 if [[ ${{ matrix.testenv }} == shellcheck ]]; then scversion="stable" bindir="$HOME/.local/bin" @@ -86,7 +86,7 @@ jobs: tests-docker: if: "!contains(github.event.head_commit.message, '[ci skip]')" timeout-minutes: 45 - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 # not 24.04 because sandboxing fails by default (#8424) strategy: fail-fast: false matrix: @@ -107,6 +107,7 @@ jobs: DOCKER: "${{ matrix.image }}" CI: true PYTEST_ADDOPTS: "--color=yes" + TMPDIR: "${{ runner.temp }}" volumes: # Hardcoded because we can't use ${{ runner.temp }} here apparently. - /home/runner/work/_temp/:/home/runner/work/_temp/ @@ -119,6 +120,21 @@ jobs: run: "python scripts/dev/ci/problemmatchers.py tests ${{ runner.temp }}" - name: Run tox run: "dbus-run-session -- tox -e ${{ matrix.testenv }}" + - name: Gather info + id: info + run: | + echo "date=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" + echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + shell: bash + if: failure() + - name: Upload screenshots + uses: actions/upload-artifact@v4 + with: + name: "end2end-screenshots-${{ steps.info.outputs.date }}-${{ steps.info.outputs.sha_short }}-${{ matrix.image }}" + path: | + ${{ runner.temp }}/pytest-of-user/pytest-current/pytest-screenshots/*.png + if-no-files-found: ignore + if: failure() tests: if: "!contains(github.event.head_commit.message, '[ci skip]')" @@ -128,10 +144,10 @@ jobs: fail-fast: false matrix: include: - ### PyQt 5.15.2 (Python 3.8) - - testenv: py37-pyqt5152 - os: ubuntu-20.04 - python: "3.8" + ### PyQt 5.15.2 (Python 3.9) + - testenv: py39-pyqt5152 + os: ubuntu-22.04 + python: "3.9" ### PyQt 5.15 (Python 3.10, with coverage) # FIXME:qt6 # - testenv: py310-pyqt515-cov @@ -139,19 +155,19 @@ jobs: # python: "3.10" ### PyQt 5.15 (Python 3.11) - testenv: py311-pyqt515 - os: ubuntu-20.04 + os: ubuntu-22.04 python: "3.11" - ### PyQt 6.2 (Python 3.8) - - testenv: py37-pyqt62 - os: ubuntu-20.04 - python: "3.8" - ### PyQt 6.3 (Python 3.8) - - testenv: py38-pyqt63 - os: ubuntu-20.04 - python: "3.8" + ### PyQt 6.2 (Python 3.9) + - testenv: py39-pyqt62 + os: ubuntu-22.04 + python: "3.9" + ### PyQt 6.3 (Python 3.9) + - testenv: py39-pyqt63 + os: ubuntu-22.04 + python: "3.9" ## PyQt 6.4 (Python 3.9) - testenv: py39-pyqt64 - os: ubuntu-20.04 + os: ubuntu-22.04 python: "3.9" ### PyQt 6.5 (Python 3.10) - testenv: py310-pyqt65 @@ -165,20 +181,32 @@ jobs: - testenv: py312-pyqt66 os: ubuntu-22.04 python: "3.12" - ### macOS Big Sur - - testenv: py312-pyqt66 - os: macos-11 + ### PyQt 6.7 (Python 3.11) + - testenv: py311-pyqt67 + os: ubuntu-22.04 + python: "3.11" + ### PyQt 6.7 (Python 3.12) + - testenv: py312-pyqt67 + os: ubuntu-22.04 python: "3.12" + ### PyQt 6.8 (Python 3.13) + - testenv: py313-pyqt68 + os: ubuntu-24.04 + python: "3.13" + ### macOS Ventura + - testenv: py313-pyqt68 + os: macos-13 + python: "3.13" args: "tests/unit" # Only run unit tests on macOS - ### macOS Monterey - - testenv: py312-pyqt66 - os: macos-12 - python: "3.12" + ### macOS Sonoma (M1 runner) + - testenv: py313-pyqt68 + os: macos-14 + python: "3.13" args: "tests/unit" # Only run unit tests on macOS ### Windows - - testenv: py312-pyqt66 + - testenv: py313-pyqt68 os: windows-2019 - python: "3.12" + python: "3.13" runs-on: "${{ matrix.os }}" steps: - uses: actions/checkout@v4 @@ -200,12 +228,17 @@ jobs: - name: Install apt dependencies run: | sudo apt-get update - sudo apt-get install --no-install-recommends libyaml-dev libegl1-mesa libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-shape0 libxcb-cursor0 + sudo apt-get install --no-install-recommends libyaml-dev libegl1 libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-shape0 libxcb-cursor0 if: "startsWith(matrix.os, 'ubuntu-')" - name: Install dependencies run: | python -m pip install -U pip python -m pip install -U -r misc/requirements/requirements-tox.txt + - name: Upgrade 3rd party assets + run: "tox exec -e ${{ matrix.testenv }} -- python scripts/dev/update_3rdparty.py --gh-token ${{ secrets.GITHUB_TOKEN }}" + if: "startsWith(matrix.os, 'windows-')" + - name: "Set TMPDIR for pytest" + run: 'echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"' - name: "Run ${{ matrix.testenv }}" run: "dbus-run-session -- tox -e ${{ matrix.testenv }} -- ${{ matrix.args }}" if: "startsWith(matrix.os, 'ubuntu-')" @@ -217,16 +250,31 @@ jobs: if: "failure()" - name: Upload coverage if: "endsWith(matrix.testenv, '-cov')" - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: name: "${{ matrix.testenv }}" + - name: Gather info + id: info + run: | + echo "date=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" + echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + shell: bash + if: failure() + - name: Upload screenshots + uses: actions/upload-artifact@v4 + with: + name: "end2end-screenshots-${{ steps.info.outputs.date }}-${{ steps.info.outputs.sha_short }}-${{ matrix.testenv }}-${{ matrix.os }}" + path: | + ${{ runner.temp }}/pytest-of-runner/pytest-current/pytest-screenshots/*.png + if-no-files-found: ignore + if: failure() codeql: if: "!contains(github.event.head_commit.message, '[ci skip]')" permissions: security-events: write timeout-minutes: 15 - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -243,7 +291,7 @@ jobs: irc: timeout-minutes: 2 continue-on-error: true - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.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 index 9dc925e29..75f9c9082 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -8,7 +8,7 @@ on: jobs: docker: if: "github.repository == 'qutebrowser/qutebrowser'" - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -32,7 +32,7 @@ jobs: with: username: qutebrowser password: ${{ secrets.DOCKER_TOKEN }} - - uses: docker/build-push-action@v5 + - uses: docker/build-push-action@v6 with: file: scripts/dev/ci/docker/Dockerfile context: . @@ -42,7 +42,7 @@ jobs: irc: timeout-minutes: 2 continue-on-error: true - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 needs: [docker] if: "always() && github.repository == 'qutebrowser/qutebrowser'" steps: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index b326c2ad6..daf148368 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -14,30 +14,22 @@ jobs: fail-fast: false matrix: include: - - os: macos-11 - toxenv: build-release-qt5 - name: qt5-macos - - os: windows-2019 - toxenv: build-release-qt5 - name: qt5-windows - - os: macos-11 - args: --debug - toxenv: build-release-qt5 - name: qt5-macos-debug - - os: windows-2019 - args: --debug - toxenv: build-release-qt5 - name: qt5-windows-debug - - os: macos-11 + - os: macos-13 toxenv: build-release - name: macos + name: macos-intel + - os: macos-14 + toxenv: build-release + name: macos-apple-silicon - os: windows-2019 toxenv: build-release name: windows - - os: macos-11 + - os: macos-13 args: --debug toxenv: build-release - name: macos-debug + name: macos-debug-intel + - os: macos-14 + toxenv: build-release + name: macos-debug-apple-silicon - os: windows-2019 args: --debug toxenv: build-release @@ -51,7 +43,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.13" - name: Install dependencies run: | python -m pip install -U pip @@ -83,7 +75,7 @@ jobs: irc: timeout-minutes: 2 continue-on-error: true - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 needs: [pyinstaller] if: "always() && github.repository == 'qutebrowser/qutebrowser'" steps: diff --git a/.github/workflows/recompile-requirements.yml b/.github/workflows/recompile-requirements.yml index 6d42c3137..74cce17e0 100644 --- a/.github/workflows/recompile-requirements.yml +++ b/.github/workflows/recompile-requirements.yml @@ -21,17 +21,17 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: '3.8' + python-version: '3.9' - name: Recompile requirements run: "python3 scripts/dev/recompile_requirements.py ${{ github.event.input.environments }}" id: requirements - name: Install apt dependencies run: | sudo apt-get update - sudo apt-get install --no-install-recommends libyaml-dev libegl1-mesa libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-shape0 libxcb-cursor0 asciidoc python3-venv xvfb + sudo apt-get install --no-install-recommends libyaml-dev libegl1 libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-shape0 libxcb-cursor0 asciidoc python3-venv xvfb - name: Install dependencies run: | python -m pip install -U pip @@ -41,7 +41,7 @@ jobs: - name: Run qutebrowser smoke test run: "xvfb-run .venv/bin/python3 -m qutebrowser --no-err-windows --nowindow --temp-basedir about:blank ':later 500 quit'" - name: Create pull request - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@v7 with: committer: qutebrowser bot author: qutebrowser bot diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa8b3b2ef..a5a64ff02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,17 +16,17 @@ on: python_version: description: 'Python version' required: true - default: '3.12' + default: '3.13' type: choice options: - - '3.8' - '3.9' - '3.10' - '3.11' - '3.12' + - '3.13' jobs: prepare: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: version: ${{ steps.bump.outputs.version }} @@ -126,9 +126,10 @@ jobs: strategy: matrix: include: - - os: macos-11 + - os: macos-13 + - os: macos-14 - os: windows-2019 - - os: ubuntu-20.04 + - os: ubuntu-24.04 runs-on: "${{ matrix.os }}" timeout-minutes: 45 needs: [prepare] @@ -158,7 +159,7 @@ jobs: if: ${{ startsWith(matrix.os, 'ubuntu-') }} run: | sudo apt-get update - sudo apt-get install --no-install-recommends libegl1-mesa libxml2-utils docbook-xml xsltproc docbook-xsl + sudo apt-get install --no-install-recommends libegl1 libxml2-utils docbook-xml xsltproc docbook-xsl - name: Install dependencies run: | python -m pip install -U pip @@ -172,7 +173,7 @@ jobs: TWINE_PASSWORD: ${{ secrets.QUTEBROWSER_BOT_PYPI_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} finalize: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 timeout-minutes: 5 needs: [prepare, release] permissions: @@ -192,7 +193,7 @@ jobs: irc: timeout-minutes: 2 continue-on-error: true - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 needs: [prepare, release, finalize] if: "${{ always() }}" steps: diff --git a/.mypy.ini b/.mypy.ini index 81f69a09e..e315f0b13 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.8 +python_version = 3.9 ### --strict warn_unused_configs = True @@ -20,6 +20,7 @@ strict_equality = True warn_unreachable = True disallow_any_unimported = True enable_error_code = ignore-without-code +strict_bytes = True ### Output show_error_context = True diff --git a/.pylintrc b/.pylintrc index a6784c0e4..1cb4e0f1b 100644 --- a/.pylintrc +++ b/.pylintrc @@ -16,7 +16,7 @@ load-plugins=qute_pylint.config, pylint.extensions.dunder persistent=n -py-version=3.8 +py-version=3.9 [MESSAGES CONTROL] enable=all @@ -71,7 +71,8 @@ argument-rgx=[a-z_][a-z0-9_]{0,30}$ variable-rgx=[a-z_][a-z0-9_]{0,30}$ docstring-min-length=3 no-docstring-rgx=(^_|^main$) -class-const-naming-style = snake_case +class-const-naming-style=snake_case +max-positional-arguments=7 [FORMAT] # FIXME:v4 (lint) down to 88 again once we use black diff --git a/README.asciidoc b/README.asciidoc index cbb3fdd64..ccb4ca72a 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -44,7 +44,7 @@ image:doc/img/hints.png["screenshot 4",width=300,link="doc/img/hints.png"] Downloads --------- -See the https://github.com/qutebrowser/qutebrowser/releases[github releases +See the https://github.com/qutebrowser/qutebrowser/releases[GitHub releases page] for available downloads and the link:doc/install.asciidoc[INSTALL] file for detailed instructions on how to get qutebrowser running on various platforms. @@ -84,7 +84,7 @@ Requirements The following software and libraries are required to run qutebrowser: -* https://www.python.org/[Python] 3.8 or newer +* https://www.python.org/[Python] 3.9 or newer * https://www.qt.io/[Qt], either 6.2.0 or newer, or 5.15.0 or newer, with the following modules: - QtCore / qtbase - QtQuick (part of qtbase or qtdeclarative in some distributions) @@ -105,10 +105,6 @@ websites and using it for transmission of sensitive data._ * https://palletsprojects.com/p/jinja/[jinja2] * https://github.com/yaml/pyyaml[PyYAML] -On Python 3.8, the following backport is also required: - -* https://importlib-resources.readthedocs.io/[importlib_resources] - On macOS, the following libraries are also required: * https://pyobjc.readthedocs.io/en/latest/[pyobjc-core and pyobjc-framework-Cocoa] @@ -252,9 +248,9 @@ main inspiration for qutebrowser) https://github.com/akhodakivskiy/VimFx[VimFx] (seems to offer a https://gir.st/blog/legacyfox.htm[hack] to run on modern Firefox releases), https://github.com/shinglyu/QuantumVim[QuantumVim], - https://github.com/ueokande/vim-vixen[Vim Vixen] (ESR only), + https://github.com/ueokande/vim-vixen[Vim Vixen], https://github.com/amedama41/vvimpulation[VVimpulation], - https://krabby.netlify.com/[Krabby] + https://krabby.netlify.app/[Krabby] * Chrome/Chromium addons: https://github.com/k2nr/ViChrome/[ViChrome], https://github.com/jinzhu/vrome[Vrome], diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index b6385b015..ded12a6b9 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -15,23 +15,181 @@ breaking changes (such as renamed commands) can happen in minor releases. // `Fixed` for any bug fixes. // `Security` to invite users to upgrade in case of vulnerabilities. -[[v3.2.0]] -v3.2.0 (unreleased) +[[v3.4.1]] +v3.4.1 (unreleased) ------------------- +Changed +~~~~~~~ + +- The `content.headers.user_agent` setting now has a new + `{upstream_browser_version_short}` template field, which is the + upstream/Chromium version but shortened to only major version. + +Fixed +~~~~~ + +- Crash when trying to use the `DocumentPictureInPicture` JS API, such as done + by the new Google Workspaces Huddle feature. The API is unsupported by + QtWebEngine and now correctly disabled on the JS side. (#8449) +- Crash when a buggy notification presenter returns a duplicate ID (now an + error is shown instead). +- The default user agent now only contains the shortened Chromium version + number, which fixes overzealous blocking on ScienceDirect. + +[[v3.4.0]] +v3.4.0 (2024-12-14) +------------------- + +Removed +~~~~~~~ + +- Support for Python 3.8 is dropped, and Python 3.9 is now required. (#8325) +- Support for macOS 12 Monterey is now dropped, and binaries will be built on + macOS 13 Ventura. (#8327) +- When using the installer on Windows 10, build 1809 or newer is now required + (previous versions required 1607 or newer, but that's not officialy supported by + Qt upstream). (#8336) + +Changed +~~~~~~~ + +- Windows/macOS binaries are now built with Qt 6.8.1. (#8242) + - Based on Chromium 122.0.6261.171 + - With security patches up to 131.0.6778.70 +- Windows/macOS binaries are now using Python 3.13. (#8205) +- The `.desktop` file now also declares qutebrowser as a valid viewer for + `image/webp`. (#8340) +- Updated mimetype information for getting a suitable extension when downloading + a `data:` URL. +- The `content.javascript.clipboard` setting now defaults to "ask", which on + Qt 6.8+ will prompt the user to grant clipboard access. On older Qt versions, + this is still equivalent to `"none"` and needs to be set manually. (#8348) +- If a XHR request made via JS sets a custom `Accept-Language` header, it now + correctly has precedence over the global `content.headers.accept_language` + setting (but not per-domain overrides). This fixes subtle JS issues on + websites that rely on the custom header being sent for those requests, and + e.g. block the requests server-side otherwise. (#8370) +- Our packaging scripts now prefer the "legacy"/"for older browsers" PDF.js + build as their normal release only supports the latest Chromium version and + might break in qutebrowser on updates. **Note to packagers:** If there's a + PDF.js package in your distribution as an (optional) qutebrowser dependency, + consider also switching to this variant (same code, built differently). + +Fixed +~~~~~ + +- Crash with recent Jinja/Markupsafe versions when viewing a finished userscript + (or potentially editor) process via `:process`. +- `scripts/open_url_in_instance.sh` now avoids `echo -n`, thus running + correctly on POSIX sh. (#8409) +- Added a workaround for a bogus QtWebEngine warning about missing spell + checking dictionaries. (#8330) + + +[[v3.3.1]] +v3.3.1 (2024-10-12) +------------------- + +Fixed +~~~~~ + +- Updated the workaround for Google sign-in issues. + +[[v3.3.0]] +v3.3.0 (2024-10-12) +------------------- + +Added +~~~~~ + +- Added the `qt.workarounds.disable_hangouts_extension` setting, + for disabling the Google Hangouts extension built into Chromium/QtWebEngine. +- Failed end2end tests will now save screenshots of the browser window when + run under xvfb (the default on linux). Screenshots will be under + `$TEMP/pytest-current/pytest-screenshots/` or attached to the GitHub actions + run as an artifact. (#7625) + +Removed +~~~~~~~ + +- Support for macOS 11 Big Sur is dropped. Binaries are now built on macOS 12 + Monterey and are unlikely to still run on older macOS versions. + +Changed +~~~~~~~ + +- The qute-pass userscript now has better support for internationalized domain + names when using the pass backend - both domain names and secret paths are + normalized before comparing (#8133) +- Ignored URL query parameters (via `url.yank_ignored_parameters`) are now + respected when yanking any URL (for example, through hints with `hint links + yank`). The `{url:yank}` substitution has also been added as a version of + `{url}` that respects ignored URL query parameters. (#7879) +- Windows and macOS releases now bundle Qt 6.7.3, which includes security fixes + up to Chromium 129.0.6668.58. + +Fixed +~~~~~ + +- A minor memory leak of QItemSelectionModels triggered by closing the + completion dialog has been resolved. (#7950) +- The link to the chrome https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns/[URL match pattern] + documentation in our settings docs now loads a live page again. (#8268) +- A rare crash when on Qt 6, a renderer process terminates with an unknown + termination reason. +- Updated the workaround for Google sign-in issues. + +[[v3.2.1]] +v3.2.1 (2024-06-25) +------------------- + +Added +~~~~~ + +- There is now a separate macOS release built for Apple Silicon. A Universal + Binary might follow with a later release. + +Changed +~~~~~~~ + +- Windows and macOS releases now bundle Qt 6.7.2, which includes security fixes + up to Chromium 125.0.6422.142. + +Fixed +~~~~~ + +- When the selected Qt wrapper is unavailable, qutebrowser now again shows a + GUI error message instead of only an exception in the terminal. + +[[v3.2.0]] +v3.2.0 (2024-06-03) +------------------- + +Deprecated +~~~~~~~~~~ + +- This will be the last feature release supporting macOS 11 Big Sur. + Starting with qutebrowser v3.3.0, macOS 12 Monterey will be the oldest + supported version. + Added ~~~~~ - When qutebrowser receives a SIGHUP it will now reload any config.py file in use (same as the `:config-source` command does). (#8108) - The Chromium security patch version is now shown in the backend string in - --version and :version. This reflects the latest Chromium version that + `--version` and `:version`. This reflects the latest Chromium version that security fixes have been backported to the base QtWebEngine version from. (#7187) Changed ~~~~~~~ +- Windows and macOS releases now ship with Qt 6.7.1, which is based on Chromium + 118.0.5993.220 with security patches up to 124.0.6367.202. +- With QtWebEngine 6.7+, the `colors.webpage.darkmode.enabled` setting can now + be changed at runtime and supports URL patterns (#8182). - A few more completions will now match search terms in any order: `:quickmark-*`, `:bookmark-*`, `:tab-take` and `:tab-select` (for the quick and bookmark categories). (#7955) @@ -44,14 +202,19 @@ Fixed ~~~~~ - `input.insert_mode.auto_load` sometimes not triggering due to a race - condition. + condition. (#8145) - Worked around qutebrowser quitting when closing a KDE file dialog due to a Qt - bug. - -[[v3.1.1]] -v3.1.1 (unreleased) -------------------- - + bug. (#8143) +- Trying to use qutebrowser after it's been deleted/moved on disk (e.g. after a + Python upgrade) should now not crash anymore. +- When the QtWebEngine resources dir couldn't be found, qutebrowser now doesn't + crash anymore (but QtWebEngine still might). +- Fixed a rare crash in the completion widget when there was no selection model + when we went to clear that, probably when leaving a mode. (#7901) +- Worked around a minor issue around QTimers on Windows where the IPC server + could close the socket early. (#8191) +- The latest PDF.js release (v4.2.67) is now supported when backed by + QtWebEngine 6.6+ (#8170) [[v3.1.0]] v3.1.0 (2023-12-08) @@ -3669,7 +3832,7 @@ Fixed - Continuing a search after clearing it now works correctly. - The tabbar and completion should now be more consistently and correctly styled with various system styles. -- Applying styiles in `qt5ct` now shouldn't crash anymore. +- Applying styles in `qt5ct` now shouldn't crash anymore. - The validation for colors in stylesheets is now less strict, allowing for all valid Qt values. - `data:` URLs now aren't added to the history anymore. diff --git a/doc/contributing.asciidoc b/doc/contributing.asciidoc index 144117677..75d663a8d 100644 --- a/doc/contributing.asciidoc +++ b/doc/contributing.asciidoc @@ -41,7 +41,7 @@ If you want to find something useful to do, check the https://github.com/qutebrowser/qutebrowser/issues[issue tracker]. Some pointers: -* https://github.com/qutebrowser/qutebrowser/labels/easy[Issues which should +* https://github.com/qutebrowser/qutebrowser/contribute[Issues which should be easy to solve] * https://github.com/qutebrowser/qutebrowser/labels/component%3A%20docs[Documentation issues which require little/no coding] @@ -111,9 +111,9 @@ unittests and several linters/checkers. Currently, the following tox environments are available: * Tests using https://www.pytest.org[pytest]: - - `py38`, `py39`, ...: Run pytest for python 3.8/3.9/... with the system-wide PyQt. - - `py38-pyqt515`, ..., `py38-pyqt65`: Run pytest with the given PyQt version (`py39-*` etc. also works). - - `py38-pyqt515-cov`: Run with coverage support (other Python/PyQt versions work too). + - `py39`, `py310`, ...: Run pytest for python 3.9/3.10/... with the system-wide PyQt. + - `py39-pyqt515`, ..., `py39-pyqt65`: Run pytest with the given PyQt version (`py310-*` etc. also works). + - `py39-pyqt515-cov`: Run with coverage support (other Python/PyQt versions work too). * `flake8`: Run various linting checks via https://pypi.python.org/pypi/flake8[flake8]. * `vulture`: Run https://pypi.python.org/pypi/vulture[vulture] to find unused code portions. @@ -171,16 +171,16 @@ Examples: ---- # run only pytest tests which failed in last run: -tox -e py38 -- --lf +tox -e py39 -- --lf # run only the end2end feature tests: -tox -e py38 -- tests/end2end/features +tox -e py39 -- tests/end2end/features # run everything with undo in the generated name, based on the scenario text -tox -e py38 -- tests/end2end/features/test_tabs_bdd.py -k undo +tox -e py39 -- tests/end2end/features/test_tabs_bdd.py -k undo # run coverage test for specific file (updates htmlcov/index.html) -tox -e py38-cov -- tests/unit/browser/test_webelem.py +tox -e py39-cov -- tests/unit/browser/test_webelem.py ---- Specifying the backend for tests @@ -192,6 +192,28 @@ specific one you can set either of a) the environment variable QUTE_TESTS_BACKEN , or b) the command line argument --qute-backend, to the desired backend (webkit/webengine). +If you need an environment with webkit installed to do testing while we still +support it (see #4039) you can re-use the docker container used for the CI +test runs which has PyQt5Webkit installed from the archlinux package archives. +Examples: + +---- +# Get a bash shell in the docker container with +# a) the current directory mounted at /work in the container +# b) the container using the X11 display :27 (for example, a Xephyr instance) from the host +# c) the tox and hypothesis dirs set to somewhere in the container that it can write to +# d) the system site packages available in the tox venv so you can use PyQt +# from the OS without having to run the link_pyqt script +docker run -it -v $PWD:/work:ro -w /work -e QUTE_TESTS_BACKEND=webkit -e DISPLAY=:27 -v /tmp/.X11-unix:/tmp/.X11-unix -e TOX_WORK_DIR="/home/user/.tox" -e HYPOTHESIS_EXAMPLES_DIR="/home/user/.hypothesis/examples" -e VIRTUALENV_SYSTEM_SITE_PACKAGES=True qutebrowser/ci:archlinux-webkit bash + +# Start a qutebrowser temporary basedir in the appropriate tox environment to +# play with +tox exec -e py-qt5 -- python3 -m qutebrowser -T --backend webkit + +# Run tests, passing positional args through to pytest. +tox -e py-qt5 -- tests/unit +---- + Profiling ~~~~~~~~~ @@ -767,10 +789,12 @@ New PyQt release qutebrowser release ~~~~~~~~~~~~~~~~~~~ -* Make sure there are no unstaged changes and the tests are green. +* Make sure there are no unstaged or unpushed changes. +* Make sure CI is reasonably green. * Make sure all issues with the related milestone are closed. * Mark the https://github.com/qutebrowser/qutebrowser/milestones[milestone] as closed. -* Consider updating the completions for `content.headers.user_agent` in `configdata.yml`. +* Consider updating the completions for `content.headers.user_agent` in `configdata.yml` + and the Firefox UA in `qutebrowser/browser/webengine/webenginesettings.py`. * Minor release: Consider updating some files from main: - `misc/requirements/` and `requirements.txt` - `scripts/` diff --git a/doc/faq.asciidoc b/doc/faq.asciidoc index b5edb3646..bd75d7d30 100644 --- a/doc/faq.asciidoc +++ b/doc/faq.asciidoc @@ -430,7 +430,7 @@ allowing him to work part-time on qutebrowser. If you keep your donation level for long enough, you can get some qutebrowser stickers! Why GitHub Sponsors?:: - GitHub Sponsors is a crowdfundign platform nicely integrated with + GitHub Sponsors is a crowdfunding platform nicely integrated with qutebrowser's existing GitHub page and a better offering than alternatives such as Patreon or Liberapay. + diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 1c4c47e7f..b896730de 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -17,6 +17,8 @@ For command arguments, there are also some variables you can use: - `{url:host}`, `{url:domain}`, `{url:auth}`, `{url:scheme}`, `{url:username}`, `{url:password}`, `{url:port}`, `{url:path}` and `{url:query}` expand to the respective parts of the current URL +- `{url:yank}` expands to the URL of the current page but strips all the query + parameters in the `url.yank_ignored_parameters` setting. - `{title}` expands to the current page's title - `{clipboard}` expands to the clipboard contents - `{primary}` expands to the primary selection contents diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index d61743040..7f4b2396c 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -31,7 +31,7 @@ patterns. The link:settings{outfilesuffix}[settings documentation] marks such settings with "This setting supports URL patterns. The syntax is based on Chromium's -https://developer.chrome.com/docs/extensions/mv3/match_patterns/[URL pattern syntax]. +https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns/[URL pattern syntax]. As an extension, the scheme and path can be left off as a short-hand syntax, so `example.com` is equivalent to `*://example.com/*`. @@ -416,6 +416,7 @@ Pre-built colorschemes - https://github.com/gicrisf/qute-city-lights[City Lights (matte dark)] - https://github.com/catppuccin/qutebrowser[Catppuccin] - https://github.com/iruzo/matrix-qutebrowser[Matrix] +- https://github.com/harmtemolder/qutebrowser-solarized[Solarized] Avoiding flake8 errors ^^^^^^^^^^^^^^^^^^^^^^ @@ -452,7 +453,7 @@ Various emacs/conkeror-like keybinding configs exist: - https://gitlab.com/Kaligule/qutebrowser-emacs-config/blob/master/config.py[Kaligule] - https://web.archive.org/web/20210512185023/https://me0w.net/pit/1540882719[nm0i] - https://www.reddit.com/r/qutebrowser/comments/eh10i7/config_share_qute_with_emacs_keybindings/[jasonsun0310] -- https://git.sr.ht/~willvaughn/dots/tree/mjolnir/item/.config/qutebrowser/qutemacs.py[willvaughn] +- https://git.sr.ht/~willvaughn/dots/tree/main/item/.config/qutebrowser/qutemacs.py[willvaughn] It's also mostly possible to get rid of modal keybindings by setting `input.insert_mode.auto_enter` to `false`, and `input.forward_unbound_keys` to diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 73646e541..7f8775bf5 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -302,6 +302,7 @@ |<>|Force software rendering for QtWebEngine. |<>|Turn on Qt HighDPI scaling. |<>|Disable accelerated 2d canvas to avoid graphical glitches. +|<>|Disable the Hangouts extension. |<>|Work around locale parsing issues in QtWebEngine 5.15.3. |<>|Delete the QtWebEngine Service Worker directory on every start. |<>|When/how to show the scrollbar. @@ -361,7 +362,7 @@ |<>|Open base URL of the searchengine if a searchengine shortcut is invoked without parameters. |<>|Search engines which can be used via the address bar. |<>|Page(s) to open at the start. -|<>|URL parameters to strip with `:yank url`. +|<>|URL parameters to strip when yanking a URL. |<>|Hide the window decoration. |<>|Format to use for the window title. The same placeholders like for |<>|Set the main window background to transparent. @@ -745,12 +746,12 @@ Default: * +pass:[xO]+: +pass:[cmd-set-text :open -b -r {url:pretty}]+ * +pass:[xo]+: +pass:[cmd-set-text -s :open -b]+ * +pass:[yD]+: +pass:[yank domain -s]+ -* +pass:[yM]+: +pass:[yank inline [{title}\]({url}) -s]+ +* +pass:[yM]+: +pass:[yank inline [{title}\]({url:yank}) -s]+ * +pass:[yP]+: +pass:[yank pretty-url -s]+ * +pass:[yT]+: +pass:[yank title -s]+ * +pass:[yY]+: +pass:[yank -s]+ * +pass:[yd]+: +pass:[yank domain]+ -* +pass:[ym]+: +pass:[yank inline [{title}\]({url})]+ +* +pass:[ym]+: +pass:[yank inline [{title}\]({url:yank})]+ * +pass:[yp]+: +pass:[yank pretty-url]+ * +pass:[yt]+: +pass:[yank title]+ * +pass:[yy]+: +pass:[yank]+ @@ -1695,6 +1696,7 @@ Default: +pass:[0.0]+ [[colors.webpage.darkmode.enabled]] === colors.webpage.darkmode.enabled Render all web contents using a dark theme. +On QtWebEngine < 6.7, this setting requires a restart and does not support URL patterns, only the global setting is applied. Example configurations from Chromium's `chrome://flags`: - "With simple HSL/CIELAB/RGB-based inversion": Set `colors.webpage.darkmode.algorithm` accordingly, and @@ -1702,7 +1704,7 @@ Example configurations from Chromium's `chrome://flags`: - "With selective image inversion": qutebrowser default settings. -This setting requires a restart. +This setting supports link:configuring{outfilesuffix}#patterns[URL patterns]. This setting is only available with the QtWebEngine backend. @@ -2290,6 +2292,8 @@ The following placeholders are defined: * `{upstream_browser_key}`: "Version" for QtWebKit, "Chrome" for QtWebEngine. * `{upstream_browser_version}`: The corresponding Safari/Chrome version. +* `{upstream_browser_version_short}`: The corresponding Safari/Chrome + version, but only with its major version. * `{qutebrowser_version}`: The currently running qutebrowser version. The default value is equal to the unchanged user agent of @@ -2304,7 +2308,7 @@ This setting supports link:configuring{outfilesuffix}#patterns[URL patterns]. Type: <> -Default: +pass:[Mozilla/5.0 ({os_info}) AppleWebKit/{webkit_version} (KHTML, like Gecko) {qt_key}/{qt_version} {upstream_browser_key}/{upstream_browser_version} Safari/{webkit_version}]+ +Default: +pass:[Mozilla/5.0 ({os_info}) AppleWebKit/{webkit_version} (KHTML, like Gecko) {qt_key}/{qt_version} {upstream_browser_key}/{upstream_browser_version_short} Safari/{webkit_version}]+ [[content.hyperlink_auditing]] === content.hyperlink_auditing @@ -2360,18 +2364,20 @@ Default: +pass:[false]+ === content.javascript.clipboard Allow JavaScript to read from or write to the clipboard. With QtWebEngine, writing the clipboard as response to a user interaction is always allowed. +On Qt < 6.8, the `ask` setting is equivalent to `none` and permission needs to be granted manually via this setting. This setting supports link:configuring{outfilesuffix}#patterns[URL patterns]. -Type: <> +Type: <> Valid values: * +none+: Disable access to clipboard. * +access+: Allow reading from and writing to the clipboard. * +access-paste+: Allow accessing the clipboard and pasting clipboard content. + * +ask+: Prompt when requested (grants 'access-paste' permission). -Default: +pass:[none]+ +Default: +pass:[ask]+ [[content.javascript.enabled]] === content.javascript.enabled @@ -3906,7 +3912,7 @@ Chromium has various sandboxing layers, which should be enabled for normal brows Open `chrome://sandbox` to see the current sandbox status. Changing this setting is only recommended if you know what you're doing, as it **disables one of Chromium's security layers**. To avoid sandboxing being accidentally disabled persistently, this setting can only be set via `config.py`, not via `:set`. See the Chromium documentation for more details: -- https://chromium.googlesource.com/chromium/src/\+/HEAD/docs/linux/sandboxing.md[Linux] - https://chromium.googlesource.com/chromium/src/\+/HEAD/docs/design/sandbox.md[Windows] - https://chromium.googlesource.com/chromium/src/\+/HEAD/docs/design/sandbox_faq.md[FAQ (Windows-centric)] +- https://chromium.googlesource.com/chromium/src/\+/HEAD/sandbox/linux/README.md[Linux] - https://chromium.googlesource.com/chromium/src/\+/HEAD/docs/design/sandbox.md[Windows] - https://chromium.googlesource.com/chromium/src/\+/HEAD/sandbox/mac/README.md[Mac] - https://chromium.googlesource.com/chromium/src/\+/HEAD/docs/design/sandbox_faq.md[FAQ (Windows-centric)] This setting requires a restart. @@ -4009,6 +4015,21 @@ Valid values: Default: +pass:[auto]+ +[[qt.workarounds.disable_hangouts_extension]] +=== qt.workarounds.disable_hangouts_extension +Disable the Hangouts extension. +The Hangouts extension provides additional APIs for Google domains only. +Hangouts has been replaced with Meet, which appears to work without this extension. +Note this setting gets ignored and the Hangouts extension is always disabled to avoid crashes on Qt 6.5.0 to 6.5.3 if dark mode is enabled, as well as on Qt 6.6.0. + +This setting requires a restart. + +This setting is only available with the QtWebEngine backend. + +Type: <> + +Default: +pass:[false]+ + [[qt.workarounds.locale]] === qt.workarounds.locale Work around locale parsing issues in QtWebEngine 5.15.3. @@ -4792,7 +4813,7 @@ Default: +pass:[https://start.duckduckgo.com]+ [[url.yank_ignored_parameters]] === url.yank_ignored_parameters -URL parameters to strip with `:yank url`. +URL parameters to strip when yanking a URL. Type: <> @@ -4927,6 +4948,7 @@ Lists with duplicate flags are invalid. Each item is checked against the valid v |FuzzyUrl|A URL which gets interpreted as search if needed. |IgnoreCase|Whether to search case insensitively. |Int|Base class for an integer setting. +|JSClipboardPermission|Permission for page JS to access the system clipboard. |Key|A name of a key. |List|A list of values. @@ -4966,6 +4988,6 @@ See the setting's valid values for more information on allowed values. |Url|A URL as a string. |UrlPattern|A match pattern for a URL. -See https://developer.chrome.com/apps/match_patterns for the allowed syntax. +See https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns for the allowed syntax. |VerticalPosition|The position of the download bar. |============== diff --git a/doc/install.asciidoc b/doc/install.asciidoc index 98cc6fb05..1e1920196 100644 --- a/doc/install.asciidoc +++ b/doc/install.asciidoc @@ -45,7 +45,7 @@ by Debian's security support. It's recommended to <> with a newer PyQt/Qt binary instead. If you need proprietary codec support or use an architecture not supported by Qt binaries, starting with Ubuntu 22.04 and Debian Bookworm, it's possible to -install Qt 6 via apt. By using `mkvenv.py` with `--pyqt-type link` you get a +install Qt 6 via apt. By using `scripts/mkvenv.py` with `--pyqt-type link` you get a newer qutebrowser running with: - Ubuntu 22.04, Linux Mint 21: QtWebEngine 6.2.4 (based on Chromium 90 from mid-2021) @@ -54,7 +54,7 @@ newer qutebrowser running with: Note you'll need some basic libraries to use the virtualenv-installed PyQt: ---- -# apt install --no-install-recommends git ca-certificates python3 python3-venv libgl1 libxkbcommon-x11-0 libegl1-mesa libfontconfig1 libglib2.0-0 libdbus-1-3 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 libnss3 libxcomposite1 libxdamage1 libxrender1 libxrandr2 libxtst6 libxi6 libasound2 +# apt install --no-install-recommends git ca-certificates python3 python3-venv libgl1 libxkbcommon-x11-0 libegl1 libfontconfig1 libglib2.0-0 libdbus-1-3 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 libnss3 libxcomposite1 libxdamage1 libxrender1 libxrandr2 libxtst6 libxi6 libasound2 ---- Additional hints @@ -64,9 +64,9 @@ Additional hints However, Qt 6.5 https://www.qt.io/blog/moving-to-openssl-3-in-binary-builds-starting-from-qt-6.5-beta-2[moved to OpenSSL 3] for its binary builds. Thus, you will either need to live with `:adblock-update` and `:download` being broken, or use `--pyqt-version 6.4` for - the `mkvenv.py` script to get an older Qt. + the `scripts/mkvenv.py` script to get an older Qt. - If running from git, run the following to generate the documentation for the - `:help` command (the `mkvenv.py` script used with a virtualenv install already does + `:help` command (the `scripts/mkvenv.py` script used with a virtualenv install already does this for you): + ---- @@ -103,12 +103,18 @@ To be able to play videos with proprietary codecs with QtWebEngine, you will need to install an additional package from the RPM Fusion Free repository. For more information see https://rpmfusion.org/Configuration. +With Qt 6 (recommended): + +----- +# dnf install libavcodec-freeworld +----- + +With Qt 5: + ----- # dnf install qt5-qtwebengine-freeworld ----- -It's currently unknown what the Qt 6 equivalent of this is. - On Archlinux ------------ @@ -162,6 +168,13 @@ need to turn off the `bindist` flag for `dev-qt/qtwebengine`. See the https://wiki.gentoo.org/wiki/Qutebrowser#USE_flags[Gentoo Wiki] for more information. +To be able to use Kerberos authentication, you will need to turn on the +`kerberos` USE-flag system-wide and re-emerge `dev-qt/qtwebengine` after that. + +See the +https://wiki.gentoo.org/wiki/Qutebrowser#Kerberos_authentication_does_not_work[ +Troubleshooting section in Gentoo Wiki] for more information. + On Void Linux ------------- @@ -398,7 +411,7 @@ location for a particular application, rather than being installed globally. The `scripts/mkvenv.py` script in this repository can be used to create a virtualenv for qutebrowser and install it (including all dependencies) there. The next couple of sections will explain the most common use-cases - run -`mkvenv.py` with `--help` to see all available options. +`scripts/mkvenv.py` with `--help` to see all available options. Getting the repository ~~~~~~~~~~~~~~~~~~~~~~ @@ -431,7 +444,7 @@ This installs all needed Python dependencies in a `.venv` subfolder This comes with an up-to-date Qt/PyQt including a pre-compiled QtWebEngine binary, but has a few caveats: -- Make sure your `python3` is Python 3.8 or newer, otherwise you'll get a "No +- Make sure your `python3` is Python 3.9 or newer, otherwise you'll get a "No matching distribution found" error and/or qutebrowser will not run. - It only works on 64-bit x86 systems, with other architectures you'll get the same error. @@ -442,8 +455,8 @@ See the next section for an alternative install method which might help with those issues but result in an older Qt version. You can specify a Qt/PyQt version with the `--pyqt-version` flag, see -`mkvenv.py --help` for a list of available versions. By default, the latest -version which plays well with qutebrowser is used. +`scripts/mkvenv.py --help` for a list of available versions. By default, the +latest version which plays well with qutebrowser is used. NOTE: If the Qt smoke test fails with a _"This application failed to start because no Qt platform plugin could be initialized."_ message, most likely a @@ -453,22 +466,24 @@ failed on ..._ line for details. Installing dependencies (system-wide Qt) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Alternatively, you can use `mkvenv.py --pyqt-type link` to symlink your local -PyQt/Qt install instead of installing PyQt in the virtualenv. However, unless -you have a new QtWebKit or QtWebEngine available, qutebrowser will not work. It -also typically means you'll be using an older release of QtWebEngine. +Alternatively, you can use `scripts/mkvenv.py --pyqt-type link` to symlink +your local PyQt/Qt install instead of installing PyQt in the virtualenv. +However, unless you have a new QtWebKit or QtWebEngine available, qutebrowser +will not work. It also typically means you'll be using an older release of +QtWebEngine. On Windows, run `set PYTHON=C:\path\to\python.exe` (CMD) or `$Env:PYTHON = "..."` (Powershell) first. -There is a third mode, `mkvenv.py --pyqt-type source` which uses a system-wide -Qt but builds PyQt from source. In most scenarios, this shouldn't be needed. +There is a third mode, `scripts/mkvenv.py --pyqt-type source` which uses a +system-wide Qt but builds PyQt from source. In most scenarios, this shouldn't +be needed. Creating a wrapper script ~~~~~~~~~~~~~~~~~~~~~~~~~ -Running `mkvenv.py` does not install a system-wide `qutebrowser` script. You can -launch qutebrowser by doing: +Running `scripts/mkvenv.py` does not install a system-wide `qutebrowser` +script. You can launch qutebrowser by doing: ---- .venv/bin/python3 -m qutebrowser @@ -485,9 +500,9 @@ You can create a simple wrapper script to start qutebrowser somewhere in your Updating ~~~~~~~~ -If you cloned the git repository, run `mkvenv.py --update` which will take care -of updating the code (via `git pull`) and recreating the environment with the -newest dependencies. +If you cloned the git repository, run `scripts/mkvenv.py --update` which will +take care of updating the code (via `git pull`) and recreating the environment +with the newest dependencies. Alternatively, you can update your local copy of the code (e.g. by pulling the git repo, or extracting a new version) and the virtualenv should automatically diff --git a/misc/nsis/install.nsh b/misc/nsis/install.nsh index 282a254eb..5c23c4ace 100755 --- a/misc/nsis/install.nsh +++ b/misc/nsis/install.nsh @@ -137,7 +137,7 @@ var KeepReg ; Functions Function CheckInstallation ; if there's an installed version, uninstall it first (I chose not to start the uninstaller silently, so that user sees what failed) - ; if both per-user and per-machine versions are installed, unistall the one that matches $MultiUser.InstallMode + ; if both per-user and per-machine versions are installed, uninstall the one that matches $MultiUser.InstallMode StrCpy $0 "" ${if} $HasCurrentModeInstallation = 1 StrCpy $0 "$MultiUser.InstallMode" @@ -432,32 +432,18 @@ Function .onInit StrCpy $KeepReg 1 ; OS version check - ${If} ${RunningX64} - ; https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-osversioninfoa#remarks - GetWinVer $R0 Major - !if "${QT5}" == "True" - IntCmpU $R0 6 0 _os_check_fail _os_check_pass - GetWinVer $R1 Minor - IntCmpU $R1 2 _os_check_pass _os_check_fail _os_check_pass - !else - IntCmpU $R0 10 0 _os_check_fail _os_check_pass - GetWinVer $R1 Build - ${If} $R1 >= 22000 ; Windows 11 21H2 - Goto _os_check_pass - ${ElseIf} $R1 >= 14393 ; Windows 10 1607 - ${AndIf} ${IsNativeAMD64} ; Windows 10 has no x86_64 emulation on arm64 - Goto _os_check_pass - ${EndIf} - !endif + ; https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-osversioninfoa#remarks + ; https://learn.microsoft.com/en-us/windows/release-health/release-information + ; https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information + ${If} ${AtLeastWin11} + Goto _os_check_pass + ${ElseIf} ${IsNativeAMD64} ; Windows 10 has no x86_64 emulation on arm64 + ${AndIf} ${AtLeastWin10} + ${AndIf} ${AtLeastBuild} 17763 ; Windows 10 1809 (also in error message below) + Goto _os_check_pass ${EndIf} - _os_check_fail: - !if "${QT5}" == "True" - MessageBox MB_OK|MB_ICONSTOP "This version of ${PRODUCT_NAME} requires a 64-bit$\r$\n\ - version of Windows 8 or later." - !else - MessageBox MB_OK|MB_ICONSTOP "This version of ${PRODUCT_NAME} requires a 64-bit$\r$\n\ - version of Windows 10 1607 or later." - !endif + MessageBox MB_OK|MB_ICONSTOP "This version of ${PRODUCT_NAME} requires a 64-bit$\r$\n\ + version of Windows 10 1809 or later." Abort _os_check_pass: diff --git a/misc/nsis/qutebrowser.nsi b/misc/nsis/qutebrowser.nsi index bd5156e83..dcdb047f6 100755 --- a/misc/nsis/qutebrowser.nsi +++ b/misc/nsis/qutebrowser.nsi @@ -131,9 +131,6 @@ ShowUninstDetails hide !define /ifndef DIST_DIR ".\..\..\dist\${PRODUCT_NAME}-${VERSION}" !endif -; If not defined, assume Qt6 (requires a more recent windows version) -!define /ifndef QT5 "False" - ; Pack the exe header with upx if UPX is defined. !ifdef UPX !packhdr "$%TEMP%\exehead.tmp" '"upx" "--ultra-brute" "$%TEMP%\exehead.tmp"' diff --git a/misc/org.qutebrowser.qutebrowser.appdata.xml b/misc/org.qutebrowser.qutebrowser.appdata.xml index 017303345..1fcc18cbe 100644 --- a/misc/org.qutebrowser.qutebrowser.appdata.xml +++ b/misc/org.qutebrowser.qutebrowser.appdata.xml @@ -44,6 +44,11 @@ + + + + + diff --git a/misc/org.qutebrowser.qutebrowser.desktop b/misc/org.qutebrowser.qutebrowser.desktop index 741a00371..71097a353 100644 --- a/misc/org.qutebrowser.qutebrowser.desktop +++ b/misc/org.qutebrowser.qutebrowser.desktop @@ -48,7 +48,7 @@ Categories=Network;WebBrowser; Exec=qutebrowser --untrusted-args %u Terminal=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; +MimeType=text/html;text/xml;application/xhtml+xml;application/xml;application/rdf+xml;image/gif;image/webp;image/jpeg;image/png;x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/qute; Keywords=Browser Actions=new-window;preferences; diff --git a/misc/qutebrowser.spec b/misc/qutebrowser.spec index 652f69bfb..630406486 100644 --- a/misc/qutebrowser.spec +++ b/misc/qutebrowser.spec @@ -25,24 +25,66 @@ INFO_PLIST_UPDATES = { "CFBundleURLName": "local file URL", "CFBundleURLSchemes": ["file"] }], - 'CFBundleDocumentTypes': [{ - "CFBundleTypeExtensions": ["html", "htm"], - "CFBundleTypeMIMETypes": ["text/html"], - "CFBundleTypeName": "HTML document", - "CFBundleTypeOSTypes": ["HTML"], - "CFBundleTypeRole": "Viewer", - }, { - "CFBundleTypeExtensions": ["xhtml"], - "CFBundleTypeMIMETypes": ["text/xhtml"], - "CFBundleTypeName": "XHTML document", - "CFBundleTypeRole": "Viewer", - }, { - "CFBundleTypeExtensions": ["mhtml"], - "CFBundleTypeMIMETypes": ["multipart/related", "application/x-mimearchive", "message/rfc822"], - "CFBundleTypeName": "MHTML document", - "CFBundleTypeRole": "Viewer", - }], - + 'CFBundleDocumentTypes': [ + { + "CFBundleTypeIconFile": "document.icns", + "CFBundleTypeName": name, + "CFBundleTypeRole": "Viewer", + "LSItemContentTypes": [content_type], + } + for name, content_type in [ + ("GIF image", "com.compuserve.gif"), + ("HTML document", "public.html"), + ("XHTML document", "public.xhtml"), + ("JavaScript script", "com.netscape.javascript-source"), + ("JPEG image", "public.jpeg"), + ("MHTML document", "org.ietf.mhtml"), + ("HTML5 Audio (Ogg)", "org.xiph.ogg-audio"), + ("HTML5 Video (Ogg)", "org.xiph.oggv"), + ("PNG image", "public.png"), + ("SVG document", "public.svg-image"), + ("Plain text document", "public.text"), + ("HTML5 Video (WebM)", "org.webmproject.webm"), + ("WebP image", "org.webmproject.webp"), + ("PDF Document", "com.adobe.pdf"), + ] + ], + 'UTImportedTypeDeclarations': [ + { + "UTTypeConformsTo": ["public.data", "public.content"], + "UTTypeDescription": "MIME HTML document", + "UTTypeIconFile": "document.icns", + "UTTypeIdentifier": "org.ietf.mhtml", + "UTTypeReferenceURL": "https://www.ietf.org/rfc/rfc2557", + "UTTypeTagSpecification": { + "com.apple.ostype": "MHTM", + "public.filename-extension": ["mht", "mhtml"], + "public.mime-type": ["multipart/related", "application/x-mimearchive"], + }, + }, + { + "UTTypeConformsTo": ["public.audio"], + "UTTypeDescription": "Ogg Audio", + "UTTypeIconFile": "document.icns", + "UTTypeIdentifier": "org.xiph.ogg-audio", + "UTTypeReferenceURL": "https://xiph.org/ogg/", + "UTTypeTagSpecification": { + "public.filename-extension": ["ogg", "oga"], + "public.mime-type": ["audio/ogg"], + }, + }, + { + "UTTypeConformsTo": ["public.movie"], + "UTTypeDescription": "Ogg Video", + "UTTypeIconFile": "document.icns", + "UTTypeIdentifier": "org.xiph.ogv", + "UTTypeReferenceURL": "https://xiph.org/ogg/", + "UTTypeTagSpecification": { + "public.filename-extension": ["ogm", "ogv"], + "public.mime-type": ["video/ogg"], + }, + }, + ], # https://developer.apple.com/documentation/avfoundation/cameras_and_media_capture/requesting_authorization_for_media_capture_on_macos # # Keys based on Google Chrome's .app, except Bluetooth keys which seem to diff --git a/misc/requirements/requirements-check-manifest.txt b/misc/requirements/requirements-check-manifest.txt index b0993ea58..1aede53ae 100644 --- a/misc/requirements/requirements-check-manifest.txt +++ b/misc/requirements/requirements-check-manifest.txt @@ -1,9 +1,9 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -build==1.2.1 -check-manifest==0.49 -importlib_metadata==7.1.0 -packaging==24.0 -pyproject_hooks==1.0.0 -tomli==2.0.1 -zipp==3.18.1 +build==1.2.2.post1 +check-manifest==0.50 +importlib_metadata==8.6.1 +packaging==24.2 +pyproject_hooks==1.2.0 +tomli==2.2.1 +zipp==3.21.0 diff --git a/misc/requirements/requirements-dev.txt b/misc/requirements/requirements-dev.txt index 59a1f8a03..fc290a9f6 100644 --- a/misc/requirements/requirements-dev.txt +++ b/misc/requirements/requirements-dev.txt @@ -1,48 +1,73 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -backports.tarfile==1.0.0 -build==1.2.1 -bump2version==1.0.1 -certifi==2024.2.2 -cffi==1.16.0 -charset-normalizer==3.3.2 -cryptography==42.0.5 -docutils==0.20.1 +annotated-types==0.7.0 +anyio==4.8.0 +autocommand==2.2.2 +backports.tarfile==1.2.0 +bracex==2.5.post1 +build==1.2.2.post1 +bump-my-version==1.0.2 +certifi==2025.1.31 +cffi==1.17.1 +charset-normalizer==3.4.1 +click==8.1.8 +cryptography==44.0.2 +docutils==0.21.2 +exceptiongroup==1.2.2 github3.py==4.0.1 -hunter==3.6.1 -idna==3.7 -importlib_metadata==7.1.0 -importlib_resources==6.4.0 +h11==0.14.0 +httpcore==1.0.7 +httpx==0.28.1 +hunter==3.7.0 +id==1.5.0 +idna==3.10 +importlib_metadata==8.6.1 +importlib_resources==6.5.2 +inflect==7.3.1 jaraco.classes==3.4.0 -jaraco.context==5.3.0 -jaraco.functools==4.0.0 -jeepney==0.8.0 -keyring==25.1.0 -manhole==1.8.0 +jaraco.collections==5.1.0 +jaraco.context==6.0.1 +jaraco.functools==4.1.0 +jaraco.text==3.12.1 +jeepney==0.9.0 +keyring==25.6.0 +manhole==1.8.1 markdown-it-py==3.0.0 mdurl==0.1.2 -more-itertools==10.2.0 -nh3==0.2.17 -packaging==24.0 -pkginfo==1.10.0 +more-itertools==10.6.0 +nh3==0.2.21 +packaging==24.2 +platformdirs==4.3.6 +prompt_toolkit==3.0.50 pycparser==2.22 -Pygments==2.17.2 -PyJWT==2.8.0 -Pympler==1.0.1 -pyproject_hooks==1.0.0 -PyQt-builder==1.16.0 +pydantic==2.10.6 +pydantic-settings==2.8.1 +pydantic_core==2.27.2 +Pygments==2.19.1 +PyJWT==2.10.1 +Pympler==1.1 +pyproject_hooks==1.2.0 +PyQt-builder==1.18.1 python-dateutil==2.9.0.post0 -readme_renderer==43.0 -requests==2.31.0 +python-dotenv==1.0.1 +questionary==2.1.0 +readme_renderer==44.0 +requests==2.32.3 requests-toolbelt==1.0.0 rfc3986==2.0.0 -rich==13.7.1 +rich==13.9.4 +rich-click==1.8.8 SecretStorage==3.3.3 -sip==6.8.3 -six==1.16.0 -tomli==2.0.1 -twine==5.0.0 -typing_extensions==4.11.0 +sip==6.10.0 +six==1.17.0 +sniffio==1.3.1 +tomli==2.2.1 +tomlkit==0.13.2 +twine==6.1.0 +typeguard==4.3.0 +typing_extensions==4.12.2 uritemplate==4.1.1 -# urllib3==2.2.1 -zipp==3.18.1 +# urllib3==2.3.0 +wcmatch==10.0 +wcwidth==0.2.13 +zipp==3.21.0 diff --git a/misc/requirements/requirements-dev.txt-raw b/misc/requirements/requirements-dev.txt-raw index 261f4459f..0db7cb3f4 100644 --- a/misc/requirements/requirements-dev.txt-raw +++ b/misc/requirements/requirements-dev.txt-raw @@ -1,11 +1,16 @@ hunter pympler github3.py -bump2version +bump-my-version requests pyqt-builder build twine +# Included to override setuptools' vendored version that is being included in +# the lock file by pip freeze. +importlib_resources +platformdirs + # Already included via test requirements #@ ignore: urllib3 diff --git a/misc/requirements/requirements-docs.txt b/misc/requirements/requirements-docs.txt index d2d35d758..50a00d64d 100644 --- a/misc/requirements/requirements-docs.txt +++ b/misc/requirements/requirements-docs.txt @@ -1,3 +1,3 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -asciidoc==10.2.0 +asciidoc==10.2.1 diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt index fa541e4a8..5f41db35f 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -1,23 +1,23 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -attrs==23.2.0 -flake8==7.0.0 -flake8-bugbear==24.2.6 +attrs==25.1.0 +flake8==7.1.2 +flake8-bugbear==24.12.12 flake8-builtins==2.5.0 -flake8-comprehensions==3.14.0 +flake8-comprehensions==3.16.0 flake8-debugger==4.1.2 flake8-deprecated==2.2.1 flake8-docstrings==1.7.0 flake8-future-import==0.4.7 flake8-plugin-utils==1.3.3 -flake8-pytest-style==2.0.0 +flake8-pytest-style==2.1.0 flake8-string-format==0.3.0 -flake8-tidy-imports==4.10.0 +flake8-tidy-imports==4.11.0 flake8-tuple==0.4.1 mccabe==0.7.0 -pep8-naming==0.13.3 -pycodestyle==2.11.1 +pep8-naming==0.14.1 +pycodestyle==2.12.1 pydocstyle==6.3.0 pyflakes==3.2.0 -six==1.16.0 +six==1.17.0 snowballstemmer==2.2.0 diff --git a/misc/requirements/requirements-mypy.txt b/misc/requirements/requirements-mypy.txt index 9bb872d13..de5f4a05f 100644 --- a/misc/requirements/requirements-mypy.txt +++ b/misc/requirements/requirements-mypy.txt @@ -1,21 +1,18 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py chardet==5.2.0 -diff_cover==9.0.0 -importlib_resources==6.4.0 -Jinja2==3.1.3 -lxml==5.2.1 -MarkupSafe==2.1.5 -mypy==1.9.0 +diff_cover==9.2.4 +Jinja2==3.1.6 +lxml==5.3.1 +MarkupSafe==3.0.2 +mypy==1.15.0 mypy-extensions==1.0.0 -pluggy==1.4.0 -Pygments==2.17.2 +pluggy==1.5.0 +Pygments==2.19.1 PyQt5-stubs==5.15.6.0 -tomli==2.0.1 +tomli==2.2.1 types-colorama==0.4.15.20240311 -types-docutils==0.20.0.20240406 -types-Pygments==2.17.0.20240310 -types-PyYAML==6.0.12.20240311 -types-setuptools==69.5.0.20240415 -typing_extensions==4.11.0 -zipp==3.18.1 +types-docutils==0.21.0.20241128 +types-Pygments==2.19.0.20250305 +types-PyYAML==6.0.12.20241230 +typing_extensions==4.12.2 diff --git a/misc/requirements/requirements-mypy.txt-raw b/misc/requirements/requirements-mypy.txt-raw index 027f4fef6..683e8bec7 100644 --- a/misc/requirements/requirements-mypy.txt-raw +++ b/misc/requirements/requirements-mypy.txt-raw @@ -6,6 +6,3 @@ PyQt5-stubs types-PyYAML types-colorama types-Pygments - -# So stubs are available even on newer Python versions -importlib_resources diff --git a/misc/requirements/requirements-pyinstaller.txt b/misc/requirements/requirements-pyinstaller.txt index 6c4b1d43b..e2439cd84 100644 --- a/misc/requirements/requirements-pyinstaller.txt +++ b/misc/requirements/requirements-pyinstaller.txt @@ -1,8 +1,8 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py altgraph==0.17.4 -importlib_metadata==7.1.0 -packaging==24.0 -pyinstaller==6.6.0 -pyinstaller-hooks-contrib==2024.4 -zipp==3.18.1 +importlib_metadata==8.6.1 +packaging==24.2 +pyinstaller==6.12.0 +pyinstaller-hooks-contrib==2025.1 +zipp==3.21.0 diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt index 94aac82a0..fb6f3f090 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -1,26 +1,26 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -astroid==3.1.0 -certifi==2024.2.2 -cffi==1.16.0 -charset-normalizer==3.3.2 -cryptography==42.0.5 -dill==0.3.8 +astroid==3.3.9 +certifi==2025.1.31 +cffi==1.17.1 +charset-normalizer==3.4.1 +cryptography==44.0.2 +dill==0.3.9 github3.py==4.0.1 -idna==3.7 -isort==5.13.2 +idna==3.10 +isort==6.0.1 mccabe==0.7.0 -pefile==2023.2.7 -platformdirs==4.2.0 +pefile==2024.8.26 +platformdirs==4.3.6 pycparser==2.22 -PyJWT==2.8.0 -pylint==3.1.0 +PyJWT==2.10.1 +pylint==3.3.5 python-dateutil==2.9.0.post0 ./scripts/dev/pylint_checkers -requests==2.31.0 -six==1.16.0 -tomli==2.0.1 -tomlkit==0.12.4 -typing_extensions==4.11.0 +requests==2.32.3 +six==1.17.0 +tomli==2.2.1 +tomlkit==0.13.2 +typing_extensions==4.12.2 uritemplate==4.1.1 -# urllib3==2.2.1 +# urllib3==2.3.0 diff --git a/misc/requirements/requirements-pylint.txt-raw b/misc/requirements/requirements-pylint.txt-raw index 99a2cf02f..78da3a1a3 100644 --- a/misc/requirements/requirements-pylint.txt-raw +++ b/misc/requirements/requirements-pylint.txt-raw @@ -7,7 +7,6 @@ pefile # fix qute-pylint location #@ replace: qute[_-]pylint.* ./scripts/dev/pylint_checkers -#@ markers: typed-ast python_version<"3.8" # Already included via test requirements #@ ignore: urllib3 diff --git a/misc/requirements/requirements-pyqt-5.15.2.txt b/misc/requirements/requirements-pyqt-5.15.2.txt index 41f75871e..8f9dfb937 100644 --- a/misc/requirements/requirements-pyqt-5.15.2.txt +++ b/misc/requirements/requirements-pyqt-5.15.2.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py PyQt5==5.15.2 # rq.filter: == 5.15.2 -PyQt5-sip==12.13.0 +PyQt5_sip==12.17.0 PyQtWebEngine==5.15.2 # rq.filter: == 5.15.2 diff --git a/misc/requirements/requirements-pyqt-5.15.txt b/misc/requirements/requirements-pyqt-5.15.txt index 5f9e4828e..927508839 100644 --- a/misc/requirements/requirements-pyqt-5.15.txt +++ b/misc/requirements/requirements-pyqt-5.15.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -PyQt5==5.15.10 # rq.filter: < 5.16 -PyQt5-Qt5==5.15.2 -PyQt5-sip==12.13.0 -PyQtWebEngine==5.15.6 # rq.filter: < 5.16 -PyQtWebEngine-Qt5==5.15.2 +PyQt5==5.15.11 # rq.filter: < 5.16 +PyQt5-Qt5==5.15.16 +PyQt5_sip==12.17.0 +PyQtWebEngine==5.15.7 # rq.filter: < 5.16 +PyQtWebEngine-Qt5==5.15.16 diff --git a/misc/requirements/requirements-pyqt-5.txt b/misc/requirements/requirements-pyqt-5.txt index e8ee2b9c7..292f35e7d 100644 --- a/misc/requirements/requirements-pyqt-5.txt +++ b/misc/requirements/requirements-pyqt-5.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -PyQt5==5.15.10 -PyQt5-Qt5==5.15.2 -PyQt5-sip==12.13.0 -PyQtWebEngine==5.15.6 -PyQtWebEngine-Qt5==5.15.2 +PyQt5==5.15.11 +PyQt5-Qt5==5.15.16 +PyQt5_sip==12.17.0 +PyQtWebEngine==5.15.7 +PyQtWebEngine-Qt5==5.15.16 diff --git a/misc/requirements/requirements-pyqt-6.2.txt b/misc/requirements/requirements-pyqt-6.2.txt index e90769ddd..49a247a16 100644 --- a/misc/requirements/requirements-pyqt-6.2.txt +++ b/misc/requirements/requirements-pyqt-6.2.txt @@ -2,6 +2,6 @@ PyQt6==6.2.3 PyQt6-Qt6==6.2.4 -PyQt6-sip==13.6.0 PyQt6-WebEngine==6.2.1 PyQt6-WebEngine-Qt6==6.2.4 +PyQt6_sip==13.10.0 diff --git a/misc/requirements/requirements-pyqt-6.3.txt b/misc/requirements/requirements-pyqt-6.3.txt index d82c623c3..aa42e285b 100644 --- a/misc/requirements/requirements-pyqt-6.3.txt +++ b/misc/requirements/requirements-pyqt-6.3.txt @@ -2,6 +2,6 @@ PyQt6==6.3.1 PyQt6-Qt6==6.3.2 -PyQt6-sip==13.6.0 PyQt6-WebEngine==6.3.1 PyQt6-WebEngine-Qt6==6.3.2 +PyQt6_sip==13.10.0 diff --git a/misc/requirements/requirements-pyqt-6.4.txt b/misc/requirements/requirements-pyqt-6.4.txt index b52e8a511..134840e15 100644 --- a/misc/requirements/requirements-pyqt-6.4.txt +++ b/misc/requirements/requirements-pyqt-6.4.txt @@ -2,6 +2,6 @@ PyQt6==6.4.2 PyQt6-Qt6==6.4.3 -PyQt6-sip==13.6.0 PyQt6-WebEngine==6.4.0 PyQt6-WebEngine-Qt6==6.4.3 +PyQt6_sip==13.10.0 diff --git a/misc/requirements/requirements-pyqt-6.5.txt b/misc/requirements/requirements-pyqt-6.5.txt index 5dca9ab74..47d2626a8 100644 --- a/misc/requirements/requirements-pyqt-6.5.txt +++ b/misc/requirements/requirements-pyqt-6.5.txt @@ -2,6 +2,6 @@ PyQt6==6.5.3 PyQt6-Qt6==6.5.3 -PyQt6-sip==13.6.0 PyQt6-WebEngine==6.5.0 PyQt6-WebEngine-Qt6==6.5.3 +PyQt6_sip==13.10.0 diff --git a/misc/requirements/requirements-pyqt-6.6.txt b/misc/requirements/requirements-pyqt-6.6.txt index 02f1a325f..da1b2dc3c 100644 --- a/misc/requirements/requirements-pyqt-6.6.txt +++ b/misc/requirements/requirements-pyqt-6.6.txt @@ -2,6 +2,6 @@ PyQt6==6.6.1 PyQt6-Qt6==6.6.3 -PyQt6-sip==13.6.0 PyQt6-WebEngine==6.6.0 PyQt6-WebEngine-Qt6==6.6.3 +PyQt6_sip==13.10.0 diff --git a/misc/requirements/requirements-pyqt-6.7.txt b/misc/requirements/requirements-pyqt-6.7.txt new file mode 100644 index 000000000..463f8f6b9 --- /dev/null +++ b/misc/requirements/requirements-pyqt-6.7.txt @@ -0,0 +1,8 @@ +# This file is automatically generated by scripts/dev/recompile_requirements.py + +PyQt6==6.7.1 +PyQt6-Qt6==6.7.3 +PyQt6-WebEngine==6.7.0 +PyQt6-WebEngine-Qt6==6.7.3 +PyQt6-WebEngineSubwheel-Qt6==6.7.3 +PyQt6_sip==13.10.0 diff --git a/misc/requirements/requirements-pyqt-6.7.txt-raw b/misc/requirements/requirements-pyqt-6.7.txt-raw new file mode 100644 index 000000000..98b1340b2 --- /dev/null +++ b/misc/requirements/requirements-pyqt-6.7.txt-raw @@ -0,0 +1,4 @@ +PyQt6 >= 6.7, < 6.8 +PyQt6-Qt6 >= 6.7, < 6.8 +PyQt6-WebEngine >= 6.7, < 6.8 +PyQt6-WebEngine-Qt6 >= 6.7, < 6.8 diff --git a/misc/requirements/requirements-pyqt-6.8.txt b/misc/requirements/requirements-pyqt-6.8.txt new file mode 100644 index 000000000..84c0d21f7 --- /dev/null +++ b/misc/requirements/requirements-pyqt-6.8.txt @@ -0,0 +1,7 @@ +# This file is automatically generated by scripts/dev/recompile_requirements.py + +PyQt6==6.8.1 +PyQt6-Qt6==6.8.2 +PyQt6-WebEngine==6.8.0 +PyQt6-WebEngine-Qt6==6.8.2 +PyQt6_sip==13.10.0 diff --git a/misc/requirements/requirements-pyqt-6.8.txt-raw b/misc/requirements/requirements-pyqt-6.8.txt-raw new file mode 100644 index 000000000..f34b75fe9 --- /dev/null +++ b/misc/requirements/requirements-pyqt-6.8.txt-raw @@ -0,0 +1,4 @@ +PyQt6 >= 6.8, < 6.9 +PyQt6-Qt6 >= 6.8, < 6.9 +PyQt6-WebEngine >= 6.8, < 6.9 +PyQt6-WebEngine-Qt6 >= 6.8, < 6.9 diff --git a/misc/requirements/requirements-pyqt-6.txt b/misc/requirements/requirements-pyqt-6.txt index 02f1a325f..84c0d21f7 100644 --- a/misc/requirements/requirements-pyqt-6.txt +++ b/misc/requirements/requirements-pyqt-6.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -PyQt6==6.6.1 -PyQt6-Qt6==6.6.3 -PyQt6-sip==13.6.0 -PyQt6-WebEngine==6.6.0 -PyQt6-WebEngine-Qt6==6.6.3 +PyQt6==6.8.1 +PyQt6-Qt6==6.8.2 +PyQt6-WebEngine==6.8.0 +PyQt6-WebEngine-Qt6==6.8.2 +PyQt6_sip==13.10.0 diff --git a/misc/requirements/requirements-pyqt.txt b/misc/requirements/requirements-pyqt.txt index 02f1a325f..84c0d21f7 100644 --- a/misc/requirements/requirements-pyqt.txt +++ b/misc/requirements/requirements-pyqt.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -PyQt6==6.6.1 -PyQt6-Qt6==6.6.3 -PyQt6-sip==13.6.0 -PyQt6-WebEngine==6.6.0 -PyQt6-WebEngine-Qt6==6.6.3 +PyQt6==6.8.1 +PyQt6-Qt6==6.8.2 +PyQt6-WebEngine==6.8.0 +PyQt6-WebEngine-Qt6==6.8.2 +PyQt6_sip==13.10.0 diff --git a/misc/requirements/requirements-pyroma.txt b/misc/requirements/requirements-pyroma.txt index 141faf1cb..7224c4cd5 100644 --- a/misc/requirements/requirements-pyroma.txt +++ b/misc/requirements/requirements-pyroma.txt @@ -1,17 +1,17 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -build==1.2.1 -certifi==2024.2.2 -charset-normalizer==3.3.2 -docutils==0.20.1 -idna==3.7 -importlib_metadata==7.1.0 -packaging==24.0 -Pygments==2.17.2 -pyproject_hooks==1.0.0 +build==1.2.2.post1 +certifi==2025.1.31 +charset-normalizer==3.4.1 +docutils==0.21.2 +idna==3.10 +importlib_metadata==8.6.1 +packaging==24.2 +Pygments==2.19.1 +pyproject_hooks==1.2.0 pyroma==4.2 -requests==2.31.0 -tomli==2.0.1 -trove-classifiers==2024.4.10 -urllib3==2.2.1 -zipp==3.18.1 +requests==2.32.3 +tomli==2.2.1 +trove-classifiers==2025.3.3.18 +urllib3==2.3.0 +zipp==3.21.0 diff --git a/misc/requirements/requirements-qutebrowser.txt-raw b/misc/requirements/requirements-qutebrowser.txt-raw index ca4081d1d..586049b82 100644 --- a/misc/requirements/requirements-qutebrowser.txt-raw +++ b/misc/requirements/requirements-qutebrowser.txt-raw @@ -12,12 +12,7 @@ PyYAML #@ add: pyobjc-core ; sys_platform=="darwin" #@ add: pyobjc-framework-Cocoa ; sys_platform=="darwin" -## stdlib backports -importlib_resources - ## Optional dependencies Pygments # For :view-source --pygments or on QtWebKit colorama # Colored log output on Windows adblock # Improved adblocking - -#@ markers: importlib_resources python_version=="3.8.*" diff --git a/misc/requirements/requirements-sphinx.txt b/misc/requirements/requirements-sphinx.txt index 7bb66c6a0..abe6d9c69 100644 --- a/misc/requirements/requirements-sphinx.txt +++ b/misc/requirements/requirements-sphinx.txt @@ -1,26 +1,26 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -alabaster==0.7.13 -Babel==2.14.0 -certifi==2024.2.2 -charset-normalizer==3.3.2 -docutils==0.20.1 -idna==3.7 +alabaster==0.7.16 +babel==2.17.0 +certifi==2025.1.31 +charset-normalizer==3.4.1 +docutils==0.21.2 +idna==3.10 imagesize==1.4.1 -importlib_metadata==7.1.0 -Jinja2==3.1.3 -MarkupSafe==2.1.5 -packaging==24.0 -Pygments==2.17.2 -pytz==2024.1 -requests==2.31.0 +importlib_metadata==8.6.1 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +packaging==24.2 +Pygments==2.19.1 +requests==2.32.3 snowballstemmer==2.2.0 -Sphinx==7.1.2 -sphinxcontrib-applehelp==1.0.4 -sphinxcontrib-devhelp==1.0.2 -sphinxcontrib-htmlhelp==2.0.1 +Sphinx==7.4.7 +sphinxcontrib-applehelp==2.0.0 +sphinxcontrib-devhelp==2.0.0 +sphinxcontrib-htmlhelp==2.1.0 sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.3 -sphinxcontrib-serializinghtml==1.1.5 -urllib3==2.2.1 -zipp==3.18.1 +sphinxcontrib-qthelp==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 +tomli==2.2.1 +urllib3==2.3.0 +zipp==3.21.0 diff --git a/misc/requirements/requirements-tests-bleeding.txt b/misc/requirements/requirements-tests-bleeding.txt index f1ad30158..def3a596c 100644 --- a/misc/requirements/requirements-tests-bleeding.txt +++ b/misc/requirements/requirements-tests-bleeding.txt @@ -2,12 +2,13 @@ # bzr+lp:beautifulsoup beautifulsoup4 git+https://github.com/cherrypy/cheroot.git -git+https://github.com/nedbat/coveragepy.git#egg=coverage[toml] +coverage[toml] @ git+https://github.com/nedbat/coveragepy.git git+https://github.com/pallets/flask.git git+https://github.com/pallets/werkzeug.git # transitive dep, but needed to work git+https://github.com/HypothesisWorks/hypothesis.git#subdirectory=hypothesis-python git+https://github.com/pytest-dev/pytest.git git+https://github.com/pytest-dev/pytest-bdd.git +gherkin-official<31.0.0 # https://github.com/cucumber/gherkin/issues/373 git+https://github.com/ionelmc/pytest-benchmark.git git+https://github.com/pytest-dev/pytest-instafail.git git+https://github.com/pytest-dev/pytest-mock.git @@ -20,6 +21,7 @@ git+https://github.com/pygments/pygments.git git+https://github.com/pytest-dev/pytest-repeat.git git+https://github.com/pytest-dev/pytest-cov.git git+https://github.com/The-Compiler/pytest-xvfb.git +git+https://github.com/python-pillow/Pillow.git git+https://github.com/pytest-dev/pytest-xdist.git git+https://github.com/john-kurkowski/tldextract diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 0d8aa9bc4..7646a5970 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -1,56 +1,67 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -attrs==23.2.0 -beautifulsoup4==4.12.3 -blinker==1.7.0 -certifi==2024.2.2 -charset-normalizer==3.3.2 -cheroot==10.0.0 -click==8.1.7 -coverage==7.4.4 -exceptiongroup==1.2.0 +attrs==25.1.0 +autocommand==2.2.2 +backports.tarfile==1.2.0 +beautifulsoup4==4.13.3 +blinker==1.9.0 +certifi==2025.1.31 +charset-normalizer==3.4.1 +cheroot==10.0.1 +click==8.1.8 +coverage==7.6.12 +exceptiongroup==1.2.2 execnet==2.1.1 -filelock==3.13.4 -Flask==3.0.3 -hunter==3.6.1 -hypothesis==6.100.1 -idna==3.7 -importlib_metadata==7.1.0 +filelock==3.17.0 +Flask==3.1.0 +gherkin-official==29.0.0 +hunter==3.7.0 +hypothesis==6.128.1 +idna==3.10 +importlib_metadata==8.6.1 +importlib_resources==6.5.2 +inflect==7.3.1 iniconfig==2.0.0 -itsdangerous==2.1.2 -jaraco.functools==4.0.0 -# Jinja2==3.1.3 -Mako==1.3.3 -manhole==1.8.0 -# MarkupSafe==2.1.5 -more-itertools==10.2.0 -packaging==24.0 -parse==1.20.1 -parse-type==0.6.2 -pluggy==1.4.0 +itsdangerous==2.2.0 +jaraco.collections==5.1.0 +jaraco.context==6.0.1 +jaraco.functools==4.1.0 +jaraco.text==3.12.1 +# Jinja2==3.1.6 +Mako==1.3.9 +manhole==1.8.1 +# MarkupSafe==3.0.2 +more-itertools==10.6.0 +packaging==24.2 +parse==1.20.2 +parse_type==0.6.4 +pillow==11.1.0 +platformdirs==4.3.6 +pluggy==1.5.0 py-cpuinfo==9.0.0 -Pygments==2.17.2 -pytest==8.1.1 -pytest-bdd==7.1.2 -pytest-benchmark==4.0.0 -pytest-cov==5.0.0 +Pygments==2.19.1 +pytest==8.3.5 +pytest-bdd==8.1.0 +pytest-benchmark==5.1.0 +pytest-cov==6.0.0 pytest-instafail==0.5.0 pytest-mock==3.14.0 pytest-qt==4.4.0 pytest-repeat==0.9.3 -pytest-rerunfailures==14.0 -pytest-xdist==3.5.0 -pytest-xvfb==3.0.0 +pytest-rerunfailures==15.0 +pytest-xdist==3.6.1 +pytest-xvfb==3.1.1 PyVirtualDisplay==3.0 -requests==2.31.0 -requests-file==2.0.0 -six==1.16.0 +requests==2.32.3 +requests-file==2.1.0 +six==1.17.0 sortedcontainers==2.4.0 -soupsieve==2.5 -tldextract==5.1.2 -tomli==2.0.1 -typing_extensions==4.11.0 -urllib3==2.2.1 -vulture==2.11 -Werkzeug==3.0.2 -zipp==3.18.1 +soupsieve==2.6 +tldextract==5.1.3 +tomli==2.2.1 +typeguard==4.3.0 +typing_extensions==4.12.2 +urllib3==2.3.0 +vulture==2.14 +Werkzeug==3.1.3 +zipp==3.21.0 diff --git a/misc/requirements/requirements-tests.txt-raw b/misc/requirements/requirements-tests.txt-raw index 54e036106..1df954e53 100644 --- a/misc/requirements/requirements-tests.txt-raw +++ b/misc/requirements/requirements-tests.txt-raw @@ -25,10 +25,21 @@ pytest-cov # To avoid windows from popping up pytest-xvfb PyVirtualDisplay +pillow # To run on multiple cores with -n pytest-xdist # Needed to test misc/userscripts/qute-lastpass tldextract +# importlib_resources==6.4.0, jaraco.context and platformdirs are being +# included in the lock file via setuptools' vendored dependencies and +# conflicting with the more up to date one pulled down by other requirements +# files. +# Include them here even though we don't need them to make sure we at least +# get an up to date version. +importlib_resources +jaraco.context +platformdirs + #@ ignore: Jinja2, MarkupSafe, colorama diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt index afea097d0..169109940 100644 --- a/misc/requirements/requirements-tox.txt +++ b/misc/requirements/requirements-tox.txt @@ -1,17 +1,18 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -cachetools==5.3.3 +cachetools==5.5.2 chardet==5.2.0 colorama==0.4.6 -distlib==0.3.8 -filelock==3.13.4 -packaging==24.0 -pip==24.0 -platformdirs==4.2.0 -pluggy==1.4.0 -pyproject-api==1.6.1 -setuptools==69.5.1 -tomli==2.0.1 -tox==4.14.2 -virtualenv==20.25.1 -wheel==0.43.0 +distlib==0.3.9 +filelock==3.17.0 +packaging==24.2 +pip==25.0.1 +platformdirs==4.3.6 +pluggy==1.5.0 +pyproject-api==1.9.0 +setuptools==76.0.0 +tomli==2.2.1 +tox==4.24.2 +typing_extensions==4.12.2 +virtualenv==20.29.3 +wheel==0.45.1 diff --git a/misc/requirements/requirements-vulture.txt b/misc/requirements/requirements-vulture.txt index a7d37e73a..8c58f3fc7 100644 --- a/misc/requirements/requirements-vulture.txt +++ b/misc/requirements/requirements-vulture.txt @@ -1,4 +1,4 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -tomli==2.0.1 -vulture==2.11 +tomli==2.2.1 +vulture==2.14 diff --git a/misc/requirements/requirements-yamllint.txt b/misc/requirements/requirements-yamllint.txt index 4fb649ec4..a501b1d56 100644 --- a/misc/requirements/requirements-yamllint.txt +++ b/misc/requirements/requirements-yamllint.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py pathspec==0.12.1 -PyYAML==6.0.1 +PyYAML==6.0.2 yamllint==1.35.1 diff --git a/misc/userscripts/README.md b/misc/userscripts/README.md index 6cc66dfb2..d389a39bb 100644 --- a/misc/userscripts/README.md +++ b/misc/userscripts/README.md @@ -106,6 +106,8 @@ The following userscripts can be found on their own repositories. More powerfully manage single window sessions - [qutebrowser-url-mutator](https://codeberg.org/mister_monster/qutebrowser-url-mutator): automatically mutates input URLs based on configurable rules +- [qute-translate-popup](https://github.com/JohnBardoe/qute-translate-popup): + selected text translation, with a qute popup! [Zotero]: https://www.zotero.org/ [Pocket]: https://getpocket.com/ diff --git a/misc/userscripts/qute-bitwarden b/misc/userscripts/qute-bitwarden index f9637bae9..ad17ec08f 100755 --- a/misc/userscripts/qute-bitwarden +++ b/misc/userscripts/qute-bitwarden @@ -96,7 +96,8 @@ def ask_password(password_prompt_invocation): raise Exception('Could not unlock vault') master_pass = process.stdout.strip() return subprocess.check_output( - ['bw', 'unlock', '--raw', master_pass], + ['bw', 'unlock', '--raw', '--passwordenv', 'BW_MASTERPASS'], + env={**os.environ, 'BW_MASTERPASS': master_pass}, text=True, ).strip() diff --git a/misc/userscripts/qute-pass b/misc/userscripts/qute-pass index 70a497b63..0b483c0e2 100755 --- a/misc/userscripts/qute-pass +++ b/misc/userscripts/qute-pass @@ -40,11 +40,13 @@ import argparse import enum import fnmatch import functools +import idna import os import re import shlex import subprocess import sys +import unicodedata from urllib.parse import urlparse import tldextract @@ -116,6 +118,23 @@ def qute_command(command): fifo.write(command + '\n') fifo.flush() +# Encode candidate string parts as Internationalized Domain Name, doing +# Unicode normalization before. This allows to properly match (non-ASCII) +# pass entries with the corresponding domain names. +def idna_encode(name): + # Do Unicode normalization first, we use form NFKC because: + # 1. Use the compatibility normalization because these sequences have "the same meaning in some contexts" + # 2. idna.encode() below requires the Unicode strings to be in normalization form C + # See https://en.wikipedia.org/wiki/Unicode_equivalence#Normal_forms + unicode_normalized = unicodedata.normalize("NFKC", name) + # Empty strings can not be encoded, they appear for example as empty + # parts in split_path. If something like this happens, we just fall back + # to the unicode representation (which may already be ASCII then). + try: + idna_encoded = idna.encode(unicode_normalized) + except idna.IDNAError: + idna_encoded = unicode_normalized + return idna_encoded def find_pass_candidates(domain, unfiltered=False): candidates = [] @@ -130,6 +149,7 @@ def find_pass_candidates(domain, unfiltered=False): if unfiltered or domain in password: candidates.append(password) else: + idna_domain = idna_encode(domain) for path, directories, file_names in os.walk(arguments.password_store, followlinks=True): secrets = fnmatch.filter(file_names, '*.gpg') if not secrets: @@ -138,11 +158,14 @@ def find_pass_candidates(domain, unfiltered=False): # Strip password store path prefix to get the relative pass path pass_path = path[len(arguments.password_store):] split_path = pass_path.split(os.path.sep) + idna_split_path = [idna_encode(part) for part in split_path] for secret in secrets: secret_base = os.path.splitext(secret)[0] - if not unfiltered and domain not in (split_path + [secret_base]): + idna_secret_base = idna_encode(secret_base) + if not unfiltered and idna_domain not in (idna_split_path + [idna_secret_base]): continue + # Append the unencoded Unicode path/name since this is how pass uses them candidates.append(os.path.join(pass_path, secret_base)) return candidates diff --git a/misc/userscripts/ripbang b/misc/userscripts/ripbang index 2f867c838..21317fe02 100755 --- a/misc/userscripts/ripbang +++ b/misc/userscripts/ripbang @@ -9,18 +9,16 @@ # :spawn --userscript ripbang amazon maps # -import os, re, requests, sys -from urllib.parse import urlparse, parse_qs +import os, requests, sys for argument in sys.argv[1:]: bang = '!' + argument - r = requests.get('https://duckduckgo.com/', + r = requests.get('https://html.duckduckgo.com/html/', + allow_redirects=False, params={'q': bang + ' SEARCHTEXT'}, headers={'user-agent': 'qutebrowser ripbang'}) - searchengine = re.search("url=([^']+)", r.text).group(1) - searchengine = urlparse(searchengine).query - searchengine = parse_qs(searchengine)['uddg'][0] + searchengine = r.headers['location'] searchengine = searchengine.replace('SEARCHTEXT', '{}') if os.getenv('QUTE_FIFO'): diff --git a/pytest.ini b/pytest.ini index 2de880eae..4b7649c13 100644 --- a/pytest.ini +++ b/pytest.ini @@ -19,6 +19,7 @@ markers = not_frozen: Tests which can't be run if sys.frozen is True. not_flatpak: Tests which can't be run if running with Flatpak. no_xvfb: Tests which can't be run with Xvfb. + no_offscreen: Tests which can't be run with the offscreen platform plugin. frozen: Tests which can only be run if sys.frozen is True. integration: Tests which test a bigger portion of code end2end: End to end tests which run qutebrowser as subprocess @@ -41,6 +42,7 @@ markers = qt6_only: Tests which should only run with Qt 6 qt5_xfail: Tests which fail with Qt 5 qt6_xfail: Tests which fail with Qt 6 + qt69_ci_flaky: Tests which are flaky with Qt 6.9 on CI qt_log_level_fail = WARNING qt_log_ignore = # GitHub Actions @@ -70,12 +72,28 @@ qt_log_ignore = # The last part of the outer message gets bumped down to a line on its own, so hopefully this # catches that. And we don't see any other weird permutations of this. ^[^ ]*qtwebengine_dictionaries'$ + # Qt 5 on Archlinux + ^QSslSocket: cannot resolve .* + # Seems to happen after we try to complete immediately after clearing a + # model, for example, when no completion function is available for the + # current text pattern. + QItemSelectionModel: Selecting when no model has been set will result in a no-op. + ^QSaveFile::commit: File \(.*[/\\]test_failing_flush0[/\\]foo\) is not open$ + ^The following paths were searched for Qt WebEngine dictionaries:.* + # Qt 6.9 with Xvfb + ^Backend texture is not a Vulkan texture\.$ + ^Compositor returned null texture$ + # With offscreen platform plugin + ^This plugin does not support (raise\(\)|propagateSizeHints\(\)|createPlatformVulkanInstance|grabbing the keyboard)$ + ^QRhiGles2: Failed to create (temporary )?context$ + ^QVulkanInstance: Failed to initialize Vulkan$ + ^Unable to detect GPU vendor\.$ xfail_strict = true filterwarnings = error default:Test process .* failed to terminate!:UserWarning - # Python 3.12: https://github.com/jendrikseipp/vulture/issues/314 - ignore:ast\.Str is deprecated and will be removed in Python 3\.14; use ast\.Constant instead:DeprecationWarning:vulture\.core - # Python 3.12: https://github.com/ionelmc/pytest-benchmark/issues/240 - ignore:(datetime\.)?datetime\.utcnow\(\) is deprecated and scheduled for removal in a future version\. Use timezone-aware objects to represent datetimes in UTC. (datetime\.)?datetime\.now\(datetime\.UTC\)\.:DeprecationWarning:pytest_benchmark\.utils + # https://github.com/cucumber/gherkin/commit/2f4830093149eae7ff7bd82f683b3d3bb7320d39 + # https://github.com/pytest-dev/pytest-bdd/issues/752 + ignore:'maxsplit' is passed as positional argument:DeprecationWarning:gherkin.gherkin_line faulthandler_timeout = 90 +xvfb_colordepth = 24 diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py index 5df8d7a31..c2dc1c775 100644 --- a/qutebrowser/__init__.py +++ b/qutebrowser/__init__.py @@ -5,13 +5,16 @@ """A keyboard-driven, vim-like browser based on Python and Qt.""" import os.path +import datetime + +_year = datetime.date.today().year __author__ = "Florian Bruhin" -__copyright__ = "Copyright 2014-2021 Florian Bruhin (The Compiler)" +__copyright__ = "Copyright 2013-{} Florian Bruhin (The Compiler)".format(_year) __license__ = "GPL" __maintainer__ = __author__ __email__ = "mail@qutebrowser.org" -__version__ = "3.1.0" +__version__ = "3.4.0" __version_info__ = tuple(int(part) for part in __version__.split('.')) __description__ = "A keyboard-driven, vim-like browser based on Python and Qt." diff --git a/qutebrowser/api/cmdutils.py b/qutebrowser/api/cmdutils.py index e5466f072..3939dbb0a 100644 --- a/qutebrowser/api/cmdutils.py +++ b/qutebrowser/api/cmdutils.py @@ -35,7 +35,8 @@ Possible values: import inspect -from typing import Any, Callable, Iterable, Protocol, Optional, Dict, cast +from typing import Any, Protocol, Optional, cast +from collections.abc import Iterable, Callable from qutebrowser.utils import qtutils from qutebrowser.commands import command, cmdexc @@ -101,7 +102,7 @@ class _CmdHandlerType(Protocol): Below, we cast the decorated function to _CmdHandlerType to make mypy aware of this. """ - qute_args: Optional[Dict[str, 'command.ArgInfo']] + qute_args: Optional[dict[str, 'command.ArgInfo']] def __call__(self, *args: Any, **kwargs: Any) -> Any: ... diff --git a/qutebrowser/api/hook.py b/qutebrowser/api/hook.py index 9a1a7bc9c..f62514e6a 100644 --- a/qutebrowser/api/hook.py +++ b/qutebrowser/api/hook.py @@ -7,7 +7,8 @@ """Hooks for extensions.""" import importlib -from typing import Callable, Any +from typing import Any +from collections.abc import Callable from qutebrowser.extensions import loader diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 51603a2b9..66bd485fc 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -29,7 +29,8 @@ import tempfile import pathlib import datetime import argparse -from typing import Iterable, Optional, List, Tuple +from typing import Optional +from collections.abc import Iterable from qutebrowser.qt import machinery from qutebrowser.qt.widgets import QApplication, QWidget @@ -330,7 +331,7 @@ def _open_special_pages(args): tabbed_browser = objreg.get('tabbed-browser', scope='window', window='last-focused') - pages: List[Tuple[str, bool, str]] = [ + pages: list[tuple[str, bool, str]] = [ # state, condition, URL ('quickstart-done', True, diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index e7e56a061..ac7d73c27 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -9,8 +9,8 @@ import pathlib import itertools import functools import dataclasses -from typing import (cast, TYPE_CHECKING, Any, Callable, Iterable, List, Optional, - Sequence, Set, Type, Union, Tuple) +from typing import (cast, TYPE_CHECKING, Any, Optional, Union) +from collections.abc import Iterable, Sequence, Callable from qutebrowser.qt import machinery from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, QUrl, QObject, QSizeF, Qt, @@ -62,7 +62,7 @@ def create(win_id: int, mode_manager = modeman.instance(win_id) if objects.backend == usertypes.Backend.QtWebEngine: from qutebrowser.browser.webengine import webenginetab - tab_class: Type[AbstractTab] = webenginetab.WebEngineTab + tab_class: type[AbstractTab] = webenginetab.WebEngineTab elif objects.backend == usertypes.Backend.QtWebKit: from qutebrowser.browser.webkit import webkittab tab_class = webkittab.WebKitTab @@ -144,7 +144,7 @@ class AbstractAction: """Attribute ``action`` of AbstractTab for Qt WebActions.""" - action_base: Type[Union['QWebPage.WebAction', 'QWebEnginePage.WebAction']] + action_base: type[Union['QWebPage.WebAction', 'QWebEnginePage.WebAction']] def __init__(self, tab: 'AbstractTab') -> None: self._widget = cast(_WidgetType, None) @@ -641,7 +641,7 @@ class AbstractScroller(QObject): def pos_px(self) -> QPoint: raise NotImplementedError - def pos_perc(self) -> Tuple[int, int]: + def pos_perc(self) -> tuple[int, int]: raise NotImplementedError def to_perc(self, x: float = None, y: float = None) -> None: @@ -767,10 +767,10 @@ class AbstractHistory: def _go_to_item(self, item: Any) -> None: raise NotImplementedError - def back_items(self) -> List[Any]: + def back_items(self) -> list[Any]: raise NotImplementedError - def forward_items(self) -> List[Any]: + def forward_items(self) -> list[Any]: raise NotImplementedError @@ -1020,7 +1020,7 @@ class AbstractTab(QWidget): # Note that we remember hosts here, without scheme/port: # QtWebEngine/Chromium also only remembers hostnames, and certificates are # for a given hostname anyways. - _insecure_hosts: Set[str] = set() + _insecure_hosts: set[str] = set() # Sub-APIs initialized by subclasses history: AbstractHistory diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 6784f0c1d..ca003c532 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -2,13 +2,16 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +# pylint: disable=too-many-positional-arguments + """Command dispatcher for TabbedBrowser.""" import os.path import shlex import functools import urllib.parse -from typing import cast, Callable, Dict, Union, Optional +from typing import cast, Union, Optional +from collections.abc import Callable from qutebrowser.qt.widgets import QApplication, QTabBar from qutebrowser.qt.core import Qt, QUrl, QEvent, QUrlQuery @@ -709,7 +712,7 @@ class CommandDispatcher: widget = self._current_widget() url = self._current_url() - handlers: Dict[str, Callable[..., QUrl]] = { + handlers: dict[str, Callable[..., QUrl]] = { 'prev': functools.partial(navigate.prevnext, prev=True), 'next': functools.partial(navigate.prevnext, prev=False), 'up': navigate.path_up, @@ -771,28 +774,6 @@ class CommandDispatcher: "Numeric argument is too large for internal int " "representation.") - def _yank_url(self, what): - """Helper method for yank() to get the URL to copy.""" - assert what in ['url', 'pretty-url'], what - - if what == 'pretty-url': - flags = urlutils.FormatOption.DECODE_RESERVED - else: - flags = urlutils.FormatOption.ENCODED - flags |= urlutils.FormatOption.REMOVE_PASSWORD - - url = QUrl(self._current_url()) - url_query = QUrlQuery() - url_query_str = url.query() - if '&' not in url_query_str and ';' in url_query_str: - url_query.setQueryDelimiters('=', ';') - url_query.setQuery(url_query_str) - for key in dict(url_query.queryItems()): - if key in config.val.url.yank_ignored_parameters: - url_query.removeQueryItem(key) - url.setQuery(url_query) - return url.toString(flags) - @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('what', choices=['selection', 'url', 'pretty-url', 'title', 'domain', 'inline']) @@ -827,7 +808,9 @@ class CommandDispatcher: self._current_url().host(), ':' + str(port) if port > -1 else '') elif what in ['url', 'pretty-url']: - s = self._yank_url(what) + url = self._current_url() + pretty = what == 'pretty-url' + s = urlutils.get_url_yank_text(url, pretty=pretty) what = 'URL' # For printing elif what == 'selection': def _selection_callback(s): @@ -1057,6 +1040,8 @@ class CommandDispatcher: "No window specified and couldn't find active window!") assert isinstance(active_win, mainwindow.MainWindow), active_win win_id = active_win.win_id + else: + raise utils.Unreachable(index_parts) if win_id not in objreg.window_registry: raise cmdutils.CommandError( @@ -1325,8 +1310,7 @@ class CommandDispatcher: else: cmd = os.path.expanduser(cmd) proc = guiprocess.GUIProcess(what='command', verbose=verbose, - output_messages=output_messages, - parent=self._tabbed_browser) + output_messages=output_messages) if detach: ok = proc.start_detached(cmd, args) if not ok: diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 28f20b0ef..bdbd910db 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -13,7 +13,8 @@ import functools import pathlib import tempfile import enum -from typing import Any, Dict, IO, List, MutableSequence, Optional, Union +from typing import Any, IO, Optional, Union +from collections.abc import MutableSequence from qutebrowser.qt.core import (pyqtSlot, pyqtSignal, Qt, QObject, QModelIndex, QTimer, QAbstractListModel, QUrl) @@ -187,15 +188,22 @@ def transform_path(path): """ if not utils.is_windows: return path + path = utils.expand_windows_drive(path) # Drive dependent working directories are not supported, e.g. # E:filename is invalid if re.search(r'^[A-Z]:[^\\]', path, re.IGNORECASE): return None + # Paths like COM1, ... # See https://github.com/qutebrowser/qutebrowser/issues/82 - if pathlib.Path(path).is_reserved(): - return None + if sys.version_info[:2] >= (3, 13): + if os.path.isreserved(path): # pylint: disable=no-member + return None + else: + if pathlib.Path(path).is_reserved(): # pylint: disable=else-if-used + return None + return path @@ -447,7 +455,7 @@ class AbstractDownloadItem(QObject): UnsupportedAttribute, IO[bytes], None ] = UnsupportedAttribute() self.raw_headers: Union[ - UnsupportedAttribute, Dict[bytes, bytes] + UnsupportedAttribute, dict[bytes, bytes] ] = UnsupportedAttribute() self._filename: Optional[str] = None @@ -899,7 +907,7 @@ class AbstractDownloadManager(QObject): def __init__(self, parent=None): super().__init__(parent) - self.downloads: List[AbstractDownloadItem] = [] + self.downloads: list[AbstractDownloadItem] = [] self._update_timer = usertypes.Timer(self, 'download-update') self._update_timer.timeout.connect(self._update_gui) self._update_timer.setInterval(_REFRESH_INTERVAL) @@ -1264,7 +1272,7 @@ class DownloadModel(QAbstractListModel): else: return "" - def data(self, index, role): + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: """Download data from DownloadManager.""" if not index.isValid(): return None diff --git a/qutebrowser/browser/downloadview.py b/qutebrowser/browser/downloadview.py index 4b6a8b2c8..5f67b344d 100644 --- a/qutebrowser/browser/downloadview.py +++ b/qutebrowser/browser/downloadview.py @@ -5,7 +5,8 @@ """The ListView to display downloads in.""" import functools -from typing import Callable, MutableSequence, Tuple, Union +from typing import Union +from collections.abc import MutableSequence, Callable from qutebrowser.qt.core import pyqtSlot, QSize, Qt from qutebrowser.qt.widgets import QListView, QSizePolicy, QMenu, QStyleFactory @@ -17,8 +18,8 @@ from qutebrowser.utils import qtutils, utils _ActionListType = MutableSequence[ Union[ - Tuple[None, None], # separator - Tuple[str, Callable[[], None]], + tuple[None, None], # separator + tuple[str, Callable[[], None]], ] ] diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index d41d46361..ab63046db 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -12,7 +12,8 @@ import functools import glob import textwrap import dataclasses -from typing import cast, List, Sequence, Tuple, Optional +from typing import cast, Optional +from collections.abc import Sequence from qutebrowser.qt.core import pyqtSignal, QObject, QUrl @@ -207,9 +208,9 @@ class MatchingScripts: """All userscripts registered to run on a particular url.""" url: QUrl - start: List[GreasemonkeyScript] = dataclasses.field(default_factory=list) - end: List[GreasemonkeyScript] = dataclasses.field(default_factory=list) - idle: List[GreasemonkeyScript] = dataclasses.field(default_factory=list) + start: list[GreasemonkeyScript] = dataclasses.field(default_factory=list) + end: list[GreasemonkeyScript] = dataclasses.field(default_factory=list) + idle: list[GreasemonkeyScript] = dataclasses.field(default_factory=list) @dataclasses.dataclass @@ -217,8 +218,8 @@ class LoadResults: """The results of loading all Greasemonkey scripts.""" - successful: List[GreasemonkeyScript] = dataclasses.field(default_factory=list) - errors: List[Tuple[str, str]] = dataclasses.field(default_factory=list) + successful: list[GreasemonkeyScript] = dataclasses.field(default_factory=list) + errors: list[tuple[str, str]] = dataclasses.field(default_factory=list) def successful_str(self) -> str: """Get a string with all successfully loaded scripts. @@ -294,10 +295,10 @@ class GreasemonkeyManager(QObject): def __init__(self, parent=None): super().__init__(parent) - self._run_start: List[GreasemonkeyScript] = [] - self._run_end: List[GreasemonkeyScript] = [] - self._run_idle: List[GreasemonkeyScript] = [] - self._in_progress_dls: List[downloads.AbstractDownloadItem] = [] + self._run_start: list[GreasemonkeyScript] = [] + self._run_end: list[GreasemonkeyScript] = [] + self._run_idle: list[GreasemonkeyScript] = [] + self._in_progress_dls: list[downloads.AbstractDownloadItem] = [] def load_scripts(self, *, force: bool = False) -> LoadResults: """Re-read Greasemonkey scripts from disk. diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index e32567e4d..b3f45610d 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -12,8 +12,15 @@ import html import enum import dataclasses from string import ascii_lowercase -from typing import (TYPE_CHECKING, Callable, Dict, Iterable, Iterator, List, Mapping, - MutableSequence, Optional, Sequence, Set) +from typing import (TYPE_CHECKING, Optional) +from collections.abc import ( + Iterable, + Iterator, + Mapping, + MutableSequence, + Sequence, + Callable, +) from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QObject, Qt, QUrl from qutebrowser.qt.widgets import QLabel @@ -175,11 +182,11 @@ class HintContext: add_history: bool first: bool baseurl: QUrl - args: List[str] + args: list[str] group: str - all_labels: List[HintLabel] = dataclasses.field(default_factory=list) - labels: Dict[str, HintLabel] = dataclasses.field(default_factory=dict) + all_labels: list[HintLabel] = dataclasses.field(default_factory=list) + labels: dict[str, HintLabel] = dataclasses.field(default_factory=dict) to_follow: Optional[str] = None first_run: bool = True filterstr: Optional[str] = None @@ -237,11 +244,7 @@ class HintActions: sel = (context.target == Target.yank_primary and utils.supports_selection()) - flags = urlutils.FormatOption.ENCODED | urlutils.FormatOption.REMOVE_PASSWORD - if url.scheme() == 'mailto': - flags |= urlutils.FormatOption.REMOVE_SCHEME - urlstr = url.toString(flags) - + urlstr = urlutils.get_url_yank_text(url, pretty=False) new_content = urlstr # only second and consecutive yanks are to append to the clipboard @@ -1037,7 +1040,7 @@ class WordHinter: def __init__(self) -> None: # will be initialized on first use. - self.words: Set[str] = set() + self.words: set[str] = set() self.dictionary = None def ensure_initialized(self) -> None: @@ -1147,7 +1150,7 @@ class WordHinter: """ self.ensure_initialized() hints = [] - used_hints: Set[str] = set() + used_hints: set[str] = set() words = iter(self.words) for elem in elems: hint = self.new_hint_for(elem, used_hints, words) diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index 45bfeddbf..ebcd26e72 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -8,7 +8,8 @@ import os import time import contextlib import pathlib -from typing import cast, Mapping, MutableSequence, Optional +from typing import cast, Optional +from collections.abc import Mapping, MutableSequence from qutebrowser.qt import machinery from qutebrowser.qt.core import pyqtSlot, QUrl, QObject, pyqtSignal diff --git a/qutebrowser/browser/navigate.py b/qutebrowser/browser/navigate.py index e75365bcd..956f222b4 100644 --- a/qutebrowser/browser/navigate.py +++ b/qutebrowser/browser/navigate.py @@ -6,7 +6,7 @@ import re import posixpath -from typing import Optional, Set +from typing import Optional from qutebrowser.qt.core import QUrl @@ -79,7 +79,7 @@ def incdec(url, count, inc_or_dec): inc_or_dec: Either 'increment' or 'decrement'. """ urlutils.ensure_valid(url) - segments: Optional[Set[str]] = ( + segments: Optional[set[str]] = ( set(config.val.url.incdec_segments) ) diff --git a/qutebrowser/browser/network/pac.py b/qutebrowser/browser/network/pac.py index 656c620db..20516366e 100644 --- a/qutebrowser/browser/network/pac.py +++ b/qutebrowser/browser/network/pac.py @@ -242,7 +242,7 @@ class PACFetcher(QObject): pac_prefix = "pac+" assert url.scheme().startswith(pac_prefix) - url.setScheme(url.scheme()[len(pac_prefix):]) + url.setScheme(url.scheme().removeprefix(pac_prefix)) self._pac_url = url with qtlog.disable_qt_msghandler(): diff --git a/qutebrowser/browser/network/proxy.py b/qutebrowser/browser/network/proxy.py index 62872d68e..3e549dfb7 100644 --- a/qutebrowser/browser/network/proxy.py +++ b/qutebrowser/browser/network/proxy.py @@ -7,7 +7,7 @@ from typing import Optional from qutebrowser.qt.core import QUrl, pyqtSlot -from qutebrowser.qt.network import QNetworkProxy, QNetworkProxyFactory +from qutebrowser.qt.network import QNetworkProxy, QNetworkProxyFactory, QNetworkProxyQuery from qutebrowser.config import config, configtypes from qutebrowser.utils import message, usertypes, urlutils, utils, qtutils @@ -71,7 +71,7 @@ class ProxyFactory(QNetworkProxyFactory): capabilities &= ~lookup_cap proxy.setCapabilities(capabilities) - def queryProxy(self, query): + def queryProxy(self, query: QNetworkProxyQuery = QNetworkProxyQuery()) -> list[QNetworkProxy]: """Get the QNetworkProxies for a query. Args: diff --git a/qutebrowser/browser/pdfjs.py b/qutebrowser/browser/pdfjs.py index 841285deb..78982d98d 100644 --- a/qutebrowser/browser/pdfjs.py +++ b/qutebrowser/browser/pdfjs.py @@ -69,6 +69,10 @@ def generate_pdfjs_page(filename, url): return html +def _get_polyfills() -> str: + return resources.read_file("javascript/pdfjs_polyfills.js") + + def _generate_pdfjs_script(filename): """Generate the script that shows the pdf with pdf.js. @@ -83,6 +87,8 @@ def _generate_pdfjs_script(filename): js_url = javascript.to_js(url.toString(urlutils.FormatOption.ENCODED)) return jinja.js_environment.from_string(""" + {{ polyfills }} + document.addEventListener("DOMContentLoaded", function() { if (typeof window.PDFJS !== 'undefined') { // v1.x @@ -104,7 +110,7 @@ def _generate_pdfjs_script(filename): }); } }); - """).render(url=js_url) + """).render(url=js_url, polyfills=_get_polyfills()) def get_pdfjs_res_and_path(path): @@ -148,6 +154,14 @@ def get_pdfjs_res_and_path(path): log.misc.warning("OSError while reading PDF.js file: {}".format(e)) raise PDFJSNotFound(path) from None + if path == "build/pdf.worker.mjs": + content = b"\n".join( + [ + _get_polyfills().encode("ascii"), + content, + ] + ) + return content, file_path diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py index 0360eed66..3d3c0475a 100644 --- a/qutebrowser/browser/qtnetworkdownloads.py +++ b/qutebrowser/browser/qtnetworkdownloads.py @@ -9,7 +9,7 @@ import os.path import shutil import functools import dataclasses -from typing import Dict, IO, Optional +from typing import IO, Optional from qutebrowser.qt.core import pyqtSlot, pyqtSignal, QTimer, QUrl from qutebrowser.qt.widgets import QApplication @@ -73,7 +73,7 @@ class DownloadItem(downloads.AbstractDownloadItem): """ super().__init__(manager=manager, parent=manager) self.fileobj: Optional[IO[bytes]] = None - self.raw_headers: Dict[bytes, bytes] = {} + self.raw_headers: dict[bytes, bytes] = {} self._autoclose = True self._retry_info = None @@ -124,7 +124,7 @@ class DownloadItem(downloads.AbstractDownloadItem): log.downloads.exception("Error while closing file object") if pos == 0: - # Emtpy remaining file + # Empty remaining file filename = self._get_open_filename() log.downloads.debug(f"Removing empty file at {filename}") try: diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index e7bb27d8d..531bf396b 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -18,7 +18,8 @@ import textwrap import urllib import collections import secrets -from typing import TypeVar, Callable, Dict, List, Optional, Union, Sequence, Tuple +from typing import TypeVar, Optional, Union +from collections.abc import Sequence, Callable from qutebrowser.qt.core import QUrlQuery, QUrl @@ -35,7 +36,7 @@ pyeval_output = ":pyeval was never called" csrf_token: Optional[str] = None -_HANDLERS: Dict[str, "_HandlerCallable"] = {} +_HANDLERS: dict[str, "_HandlerCallable"] = {} class Error(Exception): @@ -77,7 +78,7 @@ class Redirect(Exception): # Return value: (mimetype, data) (encoded as utf-8 if a str is returned) -_HandlerRet = Tuple[str, Union[str, bytes]] +_HandlerRet = tuple[str, Union[str, bytes]] _HandlerCallable = Callable[[QUrl], _HandlerRet] _Handler = TypeVar('_Handler', bound=_HandlerCallable) @@ -105,7 +106,7 @@ class add_handler: # noqa: N801,N806 pylint: disable=invalid-name return self._function(url) -def data_for_url(url: QUrl) -> Tuple[str, bytes]: +def data_for_url(url: QUrl) -> tuple[str, bytes]: """Get the data to show for the given URL. Args: @@ -180,7 +181,7 @@ def qute_bookmarks(_url: QUrl) -> _HandlerRet: @add_handler('tabs') def qute_tabs(_url: QUrl) -> _HandlerRet: """Handler for qute://tabs. Display information about all open tabs.""" - tabs: Dict[str, List[Tuple[str, str]]] = collections.defaultdict(list) + tabs: dict[str, list[tuple[str, str]]] = collections.defaultdict(list) for win_id, window in objreg.window_registry.items(): if sip.isdeleted(window): continue @@ -201,7 +202,7 @@ def qute_tabs(_url: QUrl) -> _HandlerRet: def history_data( start_time: float, offset: int = None -) -> Sequence[Dict[str, Union[str, int]]]: +) -> Sequence[dict[str, Union[str, int]]]: """Return history data. Arguments: diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py index 358af6d95..ab72690b2 100644 --- a/qutebrowser/browser/shared.py +++ b/qutebrowser/browser/shared.py @@ -10,11 +10,12 @@ import html import enum import netrc import tempfile -from typing import Callable, Mapping, List, Optional, Iterable, Iterator +from typing import Optional +from collections.abc import Mapping, Iterable, Iterator, Callable from qutebrowser.qt.core import QUrl, pyqtBoundSignal -from qutebrowser.config import config +from qutebrowser.config import config, configtypes from qutebrowser.utils import (usertypes, message, log, objreg, jinja, utils, qtutils, version, urlutils) from qutebrowser.mainwindow import mainwindow @@ -25,8 +26,15 @@ class CallSuper(Exception): """Raised when the caller should call the superclass instead.""" -def custom_headers(url): - """Get the combined custom headers.""" +def custom_headers( + url: QUrl, *, fallback_accept_language: bool = True +) -> list[tuple[bytes, bytes]]: + """Get the combined custom headers. + + Arguments: + fallback_accept_language: Whether to include the global (rather than + per-domain override) accept language header as well. + """ headers = {} dnt_config = config.instance.get('content.headers.do_not_track', url=url) @@ -40,9 +48,17 @@ def custom_headers(url): encoded_value = b"" if value is None else value.encode('ascii') headers[encoded_header] = encoded_value + # On QtWebEngine, we have fallback_accept_language set to False here for XHR + # requests, so that we don't end up overriding headers that are set via the XHR API. + # + # The global Accept-Language header is set via + # QWebEngineProfile::setHttpAcceptLanguage already anyways, so we only need + # to take care of URL pattern overrides here. + # + # note: Once we drop QtWebKit, we could hardcode fallback_accept_language to False. accept_language = config.instance.get('content.headers.accept_language', - url=url) - if accept_language is not None: + url=url, fallback=fallback_accept_language) + if accept_language is not None and not isinstance(accept_language, usertypes.Unset): headers[b'Accept-Language'] = accept_language.encode('ascii') return sorted(headers.items()) @@ -303,6 +319,7 @@ def feature_permission(url, option, msg, yes_action, no_action, abort_on, None otherwise. """ config_val = config.instance.get(option, url=url) + opt = config.instance.get_opt(option) if config_val == 'ask': if url.isValid(): urlstr = url.toString(QUrl.UrlFormattingOption.RemovePassword | QUrl.ComponentFormattingOption.FullyEncoded) @@ -328,12 +345,21 @@ def feature_permission(url, option, msg, yes_action, no_action, abort_on, cancel_action=no_action, abort_on=abort_on, title='Permission request', text=text, url=urlstr, option=option) - elif config_val: + + if isinstance(opt.typ, configtypes.AsBool): + config_val = opt.typ.to_bool(config_val) + + if config_val is True: yes_action() return None - else: + elif config_val is False: no_action() return None + else: + raise AssertionError( + f"Unsupported value for permission prompt setting ({option}), expected boolean or " + f"'ask', got: {config_val} ({type(config_val)})" + ) def get_tab(win_id, target): @@ -445,7 +471,7 @@ class FileSelectionMode(enum.Enum): folder = enum.auto() -def choose_file(qb_mode: FileSelectionMode) -> List[str]: +def choose_file(qb_mode: FileSelectionMode) -> list[str]: """Select file(s)/folder for up-/downloading, using an external command. Args: @@ -485,10 +511,10 @@ def choose_file(qb_mode: FileSelectionMode) -> List[str]: def _execute_fileselect_command( - command: List[str], + command: list[str], qb_mode: FileSelectionMode, tmpfilename: Optional[str] = None -) -> List[str]: +) -> list[str]: """Execute external command to choose file. Args: @@ -522,7 +548,7 @@ def _execute_fileselect_command( def _validated_selected_files( qb_mode: FileSelectionMode, - selected_files: List[str], + selected_files: list[str], ) -> Iterator[str]: """Validates selected files if they are. diff --git a/qutebrowser/browser/urlmarks.py b/qutebrowser/browser/urlmarks.py index 2d2563a1a..f9879274b 100644 --- a/qutebrowser/browser/urlmarks.py +++ b/qutebrowser/browser/urlmarks.py @@ -15,7 +15,7 @@ import os.path import html import functools import collections -from typing import MutableMapping +from collections.abc import MutableMapping from qutebrowser.qt.core import pyqtSignal, QUrl, QObject diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index 2356ad086..82960cc8d 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -4,7 +4,8 @@ """Generic web element related code.""" -from typing import Iterator, Optional, Set, TYPE_CHECKING, Union, Dict +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterator import collections.abc from qutebrowser.qt import machinery @@ -22,9 +23,9 @@ if TYPE_CHECKING: JsValueType = Union[int, float, str, None] if machinery.IS_QT6: - KeybordModifierType = Qt.KeyboardModifier + KeyboardModifierType = Qt.KeyboardModifier else: - KeybordModifierType = Union[Qt.KeyboardModifiers, Qt.KeyboardModifier] + KeyboardModifierType = Union[Qt.KeyboardModifiers, Qt.KeyboardModifier] class Error(Exception): @@ -93,7 +94,7 @@ class AbstractWebElement(collections.abc.MutableMapping): # type: ignore[type-a """Get the geometry for this element.""" raise NotImplementedError - def classes(self) -> Set[str]: + def classes(self) -> set[str]: """Get a set of classes assigned to this element.""" raise NotImplementedError @@ -336,7 +337,7 @@ class AbstractWebElement(collections.abc.MutableMapping): # type: ignore[type-a log.webelem.debug("Sending fake click to {!r} at position {} with " "target {}".format(self, pos, click_target)) - target_modifiers: Dict[usertypes.ClickTarget, KeybordModifierType] = { + target_modifiers: dict[usertypes.ClickTarget, KeyboardModifierType] = { usertypes.ClickTarget.normal: Qt.KeyboardModifier.NoModifier, usertypes.ClickTarget.window: Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ShiftModifier, usertypes.ClickTarget.tab: Qt.KeyboardModifier.ControlModifier, @@ -355,10 +356,14 @@ class AbstractWebElement(collections.abc.MutableMapping): # type: ignore[type-a QMouseEvent(QEvent.Type.MouseButtonRelease, pos, button, Qt.MouseButton.NoButton, modifiers), ] - for evt in events: - self._tab.send_event(evt) + def _send_events_after_delay() -> None: + """Delay clicks to workaround timing issue in e2e tests on 6.7.""" + for evt in events: + self._tab.send_event(evt) - QTimer.singleShot(0, self._move_text_cursor) + QTimer.singleShot(0, self._move_text_cursor) + + QTimer.singleShot(10, _send_events_after_delay) def _click_editable(self, click_target: usertypes.ClickTarget) -> None: """Fake a click on an editable input field.""" diff --git a/qutebrowser/browser/webengine/darkmode.py b/qutebrowser/browser/webengine/darkmode.py index b1b81c61e..88b71a8fe 100644 --- a/qutebrowser/browser/webengine/darkmode.py +++ b/qutebrowser/browser/webengine/darkmode.py @@ -113,6 +113,11 @@ Qt 6.6 - New alternative image classifier: https://chromium-review.googlesource.com/c/chromium/src/+/3987823 + +Qt 6.7 +------ + +Enabling dark mode can now be done at runtime via QWebEngineSettings. """ import os @@ -120,12 +125,16 @@ import copy import enum import dataclasses import collections -from typing import (Any, Iterator, Mapping, MutableMapping, Optional, Set, Tuple, Union, - Sequence, List) +from typing import (Any, Optional, Union) +from collections.abc import Iterator, Mapping, MutableMapping, Sequence from qutebrowser.config import config from qutebrowser.utils import usertypes, utils, log, version +# Note: We *cannot* initialize QtWebEngine (even implicitly) in here, but checking for +# the enum attribute seems to be okay. +from qutebrowser.qt.webenginecore import QWebEngineSettings + _BLINK_SETTINGS = 'blink-settings' @@ -138,6 +147,7 @@ class Variant(enum.Enum): qt_515_3 = enum.auto() qt_64 = enum.auto() qt_66 = enum.auto() + qt_67 = enum.auto() # Mapping from a colors.webpage.darkmode.algorithm setting value to @@ -187,11 +197,6 @@ _BOOLS = { False: 'false', } -_INT_BOOLS = { - True: '1', - False: '0', -} - @dataclasses.dataclass class _Setting: @@ -207,7 +212,7 @@ class _Setting: return str(value) return str(self.mapping[value]) - def chromium_tuple(self, value: Any) -> Optional[Tuple[str, str]]: + def chromium_tuple(self, value: Any) -> Optional[tuple[str, str]]: """Get the Chromium key and value, or None if no value should be set.""" if self.mapping is not None and self.mapping[value] is None: return None @@ -237,7 +242,7 @@ class _Definition: def __init__( self, *args: _Setting, - mandatory: Set[str], + mandatory: set[str], prefix: str, switch_names: Mapping[Optional[str], str] = None, ) -> None: @@ -250,7 +255,7 @@ class _Definition: else: self._switch_names = {None: _BLINK_SETTINGS} - def prefixed_settings(self) -> Iterator[Tuple[str, _Setting]]: + def prefixed_settings(self) -> Iterator[tuple[str, _Setting]]: """Get all "prepared" settings. Yields tuples which contain the Chromium setting key (e.g. 'blink-settings' or @@ -260,26 +265,25 @@ class _Definition: switch = self._switch_names.get(setting.option, self._switch_names[None]) yield switch, setting.with_prefix(self.prefix) - def copy_with(self, attr: str, value: Any) -> '_Definition': - """Get a new _Definition object with a changed attribute. - - NOTE: This does *not* copy the settings list. Both objects will reference the - same (immutable) tuple. - """ - new = copy.copy(self) - setattr(new, attr, value) - return new - def copy_add_setting(self, setting: _Setting) -> '_Definition': """Get a new _Definition object with an additional setting.""" new = copy.copy(self) new._settings = self._settings + (setting,) # pylint: disable=protected-access return new + def copy_remove_setting(self, name: str) -> '_Definition': + """Get a new _Definition object with a setting removed.""" + new = copy.copy(self) + filtered_settings = tuple(s for s in self._settings if s.option != name) + if len(filtered_settings) == len(self._settings): + raise ValueError(f"Setting {name} not found in {self}") + new._settings = filtered_settings # pylint: disable=protected-access + return new + def copy_replace_setting(self, option: str, chromium_key: str) -> '_Definition': """Get a new _Definition object with `old` replaced by `new`. - If `old` is not in the settings list, return the old _Definition object. + If `old` is not in the settings list, raise ValueError. """ new = copy.deepcopy(self) @@ -332,6 +336,8 @@ _DEFINITIONS[Variant.qt_64] = _DEFINITIONS[Variant.qt_515_3].copy_replace_settin _DEFINITIONS[Variant.qt_66] = _DEFINITIONS[Variant.qt_64].copy_add_setting( _Setting('policy.images', 'ImageClassifierPolicy', _IMAGE_CLASSIFIERS), ) +# Qt 6.7: Enabled is now handled dynamically via QWebEngineSettings +_DEFINITIONS[Variant.qt_67] = _DEFINITIONS[Variant.qt_66].copy_remove_setting('enabled') _SettingValType = Union[str, usertypes.Unset] @@ -367,7 +373,14 @@ def _variant(versions: version.WebEngineVersions) -> Variant: except KeyError: log.init.warning(f"Ignoring invalid QUTE_DARKMODE_VARIANT={env_var}") - if versions.webengine >= utils.VersionNumber(6, 6): + if ( + # We need a PyQt 6.7 as well with the API available, otherwise we can't turn on + # dark mode later in webenginesettings.py. + versions.webengine >= utils.VersionNumber(6, 7) and + hasattr(QWebEngineSettings.WebAttribute, 'ForceDarkMode') + ): + return Variant.qt_67 + elif versions.webengine >= utils.VersionNumber(6, 6): return Variant.qt_66 elif versions.webengine >= utils.VersionNumber(6, 4): return Variant.qt_64 @@ -386,7 +399,7 @@ def settings( *, versions: version.WebEngineVersions, special_flags: Sequence[str], -) -> Mapping[str, Sequence[Tuple[str, str]]]: +) -> Mapping[str, Sequence[tuple[str, str]]]: """Get necessary blink settings to configure dark mode for QtWebEngine. Args: @@ -400,12 +413,12 @@ def settings( variant = _variant(versions) log.init.debug(f"Darkmode variant: {variant.name}") - result: Mapping[str, List[Tuple[str, str]]] = collections.defaultdict(list) + result: Mapping[str, list[tuple[str, str]]] = collections.defaultdict(list) blink_settings_flag = f'--{_BLINK_SETTINGS}=' for flag in special_flags: if flag.startswith(blink_settings_flag): - for pair in flag[len(blink_settings_flag):].split(','): + for pair in flag.removeprefix(blink_settings_flag).split(','): key, val = pair.split('=', maxsplit=1) result[_BLINK_SETTINGS].append((key, val)) diff --git a/qutebrowser/browser/webengine/interceptor.py b/qutebrowser/browser/webengine/interceptor.py index 161f5ffab..fefe70f79 100644 --- a/qutebrowser/browser/webengine/interceptor.py +++ b/qutebrowser/browser/webengine/interceptor.py @@ -109,6 +109,7 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor): } new_types = { "WebSocket": interceptors.ResourceType.websocket, # added in Qt 6.4 + "Json": interceptors.ResourceType.json, # added in Qt 6.8 } for qt_name, qb_value in new_types.items(): qt_value = getattr( @@ -187,7 +188,9 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor): if request.is_blocked: info.block(True) - for header, value in shared.custom_headers(url=url): + for header, value in shared.custom_headers( + url=url, fallback_accept_language=not is_xhr + ): if header.lower() == b'accept' and is_xhr: # https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/setRequestHeader # says: "If no Accept header has been set using this, an Accept header diff --git a/qutebrowser/browser/webengine/notification.py b/qutebrowser/browser/webengine/notification.py index e8b2e27f1..9037ff214 100644 --- a/qutebrowser/browser/webengine/notification.py +++ b/qutebrowser/browser/webengine/notification.py @@ -33,7 +33,8 @@ import dataclasses import itertools import functools import subprocess -from typing import Any, List, Dict, Optional, Iterator, Type, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING +from collections.abc import Iterator from qutebrowser.qt import machinery from qutebrowser.qt.core import (Qt, QObject, QVariant, QMetaType, QByteArray, pyqtSlot, @@ -195,7 +196,7 @@ class NotificationBridgePresenter(QObject): def __init__(self, parent: QObject = None) -> None: super().__init__(parent) - self._active_notifications: Dict[int, 'QWebEngineNotification'] = {} + self._active_notifications: dict[int, 'QWebEngineNotification'] = {} self._adapter: Optional[AbstractNotificationAdapter] = None config.instance.changed.connect(self._init_adapter) @@ -232,8 +233,8 @@ class NotificationBridgePresenter(QObject): def _get_adapter_candidates( self, setting: str, - ) -> List[Type[AbstractNotificationAdapter]]: - candidates: Dict[str, List[Type[AbstractNotificationAdapter]]] = { + ) -> list[type[AbstractNotificationAdapter]]: + candidates: dict[str, list[type[AbstractNotificationAdapter]]] = { "libnotify": [ DBusNotificationAdapter, SystrayNotificationAdapter, @@ -285,7 +286,10 @@ class NotificationBridgePresenter(QObject): if replaces_id is None: if notification_id in self._active_notifications: - raise Error(f"Got duplicate id {notification_id}") + message.error(f"Got duplicate notification id {notification_id} " + f"from {self._adapter.NAME}") + self._drop_adapter() + return qt_notification.show() self._active_notifications[notification_id] = qt_notification @@ -665,7 +669,7 @@ class _ServerCapabilities: kde_origin_name: bool @classmethod - def from_list(cls, capabilities: List[str]) -> "_ServerCapabilities": + def from_list(cls, capabilities: list[str]) -> "_ServerCapabilities": return cls( actions='actions' in capabilities, body_markup='body-markup' in capabilities, @@ -951,10 +955,10 @@ class DBusNotificationAdapter(AbstractNotificationAdapter): qtutils.extract_enum_val(QMetaType.Type.QStringList), ) - def _get_hints_arg(self, *, origin_url: QUrl, icon: QImage) -> Dict[str, Any]: + def _get_hints_arg(self, *, origin_url: QUrl, icon: QImage) -> dict[str, Any]: """Get the hints argument for present().""" origin_url_str = origin_url.toDisplayString() - hints: Dict[str, Any] = { + hints: dict[str, Any] = { # Include the origin in case the user wants to do different things # with different origin's notifications. "x-qutebrowser-origin": origin_url_str, @@ -984,7 +988,7 @@ class DBusNotificationAdapter(AbstractNotificationAdapter): title: str, body: str, actions: QDBusArgument, - hints: Dict[str, Any], + hints: dict[str, Any], timeout: int, ) -> Any: """Wrapper around DBus call to use keyword args.""" diff --git a/qutebrowser/browser/webengine/webengineelem.py b/qutebrowser/browser/webengine/webengineelem.py index c387ebbcf..f65044998 100644 --- a/qutebrowser/browser/webengine/webengineelem.py +++ b/qutebrowser/browser/webengine/webengineelem.py @@ -5,7 +5,8 @@ """QtWebEngine specific part of the web element API.""" from typing import ( - TYPE_CHECKING, Any, Callable, Dict, Iterator, Optional, Set, Tuple, Union) + TYPE_CHECKING, Any, Optional, Union) +from collections.abc import Iterator, Callable from qutebrowser.qt.core import QRect, QEventLoop from qutebrowser.qt.widgets import QApplication @@ -24,11 +25,11 @@ class WebEngineElement(webelem.AbstractWebElement): _tab: "webenginetab.WebEngineTab" - def __init__(self, js_dict: Dict[str, Any], + def __init__(self, js_dict: dict[str, Any], tab: 'webenginetab.WebEngineTab') -> None: super().__init__(tab) # Do some sanity checks on the data we get from JS - js_dict_types: Dict[str, Union[type, Tuple[type, ...]]] = { + js_dict_types: dict[str, Union[type, tuple[type, ...]]] = { 'id': int, 'text': str, 'value': (str, int, float), @@ -105,7 +106,7 @@ class WebEngineElement(webelem.AbstractWebElement): log.stub() return QRect() - def classes(self) -> Set[str]: + def classes(self) -> set[str]: """Get a list of classes assigned to this element.""" return set(self._js_dict['class_name'].split()) diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 78a4946ad..721360f46 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -12,7 +12,7 @@ Module attributes: import os import operator import pathlib -from typing import cast, Any, List, Optional, Tuple, Union, TYPE_CHECKING +from typing import cast, Any, Optional, Union, TYPE_CHECKING from qutebrowser.qt import machinery from qutebrowser.qt.gui import QFont @@ -148,12 +148,20 @@ class WebEngineSettings(websettings.AbstractSettings): Attr(QWebEngineSettings.WebAttribute.AutoLoadIconsForPage, converter=lambda val: val != 'never'), } - try: - _ATTRIBUTES['content.canvas_reading'] = Attr( - QWebEngineSettings.WebAttribute.ReadingFromCanvasEnabled) # type: ignore[attr-defined,unused-ignore] - except AttributeError: - # Added in QtWebEngine 6.6 - pass + + if machinery.IS_QT6: + try: + _ATTRIBUTES['content.canvas_reading'] = Attr( + QWebEngineSettings.WebAttribute.ReadingFromCanvasEnabled) + except AttributeError: + # Added in QtWebEngine 6.6 + pass + try: + _ATTRIBUTES['colors.webpage.darkmode.enabled'] = Attr( + QWebEngineSettings.WebAttribute.ForceDarkMode) + except AttributeError: + # Added in QtWebEngine 6.7 + pass _FONT_SIZES = { 'fonts.web.size.minimum': @@ -208,6 +216,10 @@ class WebEngineSettings(websettings.AbstractSettings): QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard: True, QWebEngineSettings.WebAttribute.JavascriptCanPaste: True, }, + 'ask': { + QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard: False, + QWebEngineSettings.WebAttribute.JavascriptCanPaste: False, + }, } def set_unknown_url_scheme_policy( @@ -273,6 +285,7 @@ class ProfileSetter: self._set_hardcoded_settings() self.set_persistent_cookie_policy() self.set_dictionary_language() + self.disable_persistent_permissions_policy() def _set_hardcoded_settings(self): """Set up settings with a fixed value.""" @@ -335,7 +348,23 @@ class ProfileSetter: log.config.debug("Found dicts: {}".format(filenames)) self._profile.setSpellCheckLanguages(filenames) - self._profile.setSpellCheckEnabled(bool(filenames)) + + should_enable = bool(filenames) + if self._profile.isSpellCheckEnabled() != should_enable: + # Only setting conditionally as a WORKAROUND for a bogus Qt error message: + # https://bugreports.qt.io/browse/QTBUG-131969 + self._profile.setSpellCheckEnabled(should_enable) + + def disable_persistent_permissions_policy(self): + """Disable webengine's permission persistence.""" + if machinery.IS_QT6: # for mypy + try: + # New in WebEngine 6.8.0 + self._profile.setPersistentPermissionsPolicy( + QWebEngineProfile.PersistentPermissionsPolicy.AskEveryTime + ) + except AttributeError: + pass def _update_settings(option): @@ -349,6 +378,12 @@ def _update_settings(option): def _init_user_agent_str(ua): global parsed_user_agent parsed_user_agent = websettings.UserAgent.parse(ua) + if parsed_user_agent.upstream_browser_version.endswith(".0.0.0"): + # https://codereview.qt-project.org/c/qt/qtwebengine/+/616314 + # but we still want the full version available to users if they want it. + qtwe_versions = version.qtwebengine_versions() + assert qtwe_versions.chromium is not None + parsed_user_agent.upstream_browser_version = qtwe_versions.chromium def init_user_agent(): @@ -384,6 +419,25 @@ def _init_profile(profile: QWebEngineProfile) -> None: _global_settings.init_settings() +def _clear_webengine_permissions_json(): + """Remove QtWebEngine's persistent permissions file, if present. + + We have our own permissions feature and don't integrate with their one. + This only needs to be called when you are on Qt6.8 but PyQt<6.8, since if + we have access to the `setPersistentPermissionsPolicy()` we will use that + to disable the Qt feature. + This needs to be called before we call `setPersistentStoragePath()` + because Qt will load the file during that. + """ + permissions_file = pathlib.Path(standarddir.data()) / "webengine" / "permissions.json" + try: + permissions_file.unlink(missing_ok=True) + except OSError as err: + log.init.warning( + f"Error while cleaning up webengine permissions file: {err}" + ) + + def _init_default_profile(): """Init the default QWebEngineProfile.""" global default_profile @@ -406,6 +460,7 @@ def _init_default_profile(): f" Early version: {non_ua_version}\n" f" Real version: {ua_version}") + _clear_webengine_permissions_json() default_profile.setCachePath( os.path.join(standarddir.cache(), 'webengine')) default_profile.setPersistentStoragePath( @@ -438,13 +493,13 @@ def _init_site_specific_quirks(): # default_ua = ("Mozilla/5.0 ({os_info}) " # "AppleWebKit/{webkit_version} (KHTML, like Gecko) " # "{qt_key}/{qt_version} " - # "{upstream_browser_key}/{upstream_browser_version} " + # "{upstream_browser_key}/{upstream_browser_version_short} " # "Safari/{webkit_version}") no_qtwe_ua = ("Mozilla/5.0 ({os_info}) " "AppleWebKit/{webkit_version} (KHTML, like Gecko) " - "{upstream_browser_key}/{upstream_browser_version} " + "{upstream_browser_key}/{upstream_browser_version_short} " "Safari/{webkit_version}") - firefox_ua = "Mozilla/5.0 ({os_info}; rv:90.0) Gecko/20100101 Firefox/90.0" + firefox_ua = "Mozilla/5.0 ({os_info}; rv:136.0) Gecko/20100101 Firefox/136.0" def maybe_newer_chrome_ua(at_least_version): """Return a new UA if our current chrome version isn't at least at_least_version.""" @@ -501,7 +556,7 @@ def _init_default_settings(): - Make sure the devtools always get images/JS permissions. - On Qt 6, make sure files in the data path can load external resources. """ - devtools_settings: List[Tuple[str, Any]] = [ + devtools_settings: list[tuple[str, Any]] = [ ('content.javascript.enabled', True), ('content.images', True), ('content.cookies.accept', 'all'), @@ -514,7 +569,7 @@ def _init_default_settings(): hide_userconfig=True) if machinery.IS_QT6: - userscripts_settings: List[Tuple[str, Any]] = [ + userscripts_settings: list[tuple[str, Any]] = [ ("content.local_content_can_access_remote_urls", True), ("content.local_content_can_access_file_urls", False), ] diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 02d912a50..8142c071e 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -12,7 +12,7 @@ import re import html as html_utils from typing import cast, Union, Optional -from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QTimer, QUrl, +from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QUrl, QObject, QByteArray) from qutebrowser.qt.network import QAuthenticator from qutebrowser.qt.webenginecore import QWebEnginePage, QWebEngineScript, QWebEngineHistory @@ -816,7 +816,7 @@ class WebEngineAudio(browsertab.AbstractAudio): # Implements the intended two-second delay specified at # https://doc.qt.io/archives/qt-5.14/qwebenginepage.html#recentlyAudibleChanged delay_ms = 2000 - self._silence_timer = QTimer(self) + self._silence_timer = usertypes.Timer(self) self._silence_timer.setSingleShot(True) self._silence_timer.setInterval(delay_ms) @@ -886,6 +886,8 @@ class _WebEnginePermissions(QObject): QWebEnginePage.Feature.MouseLock: 'content.mouse_lock', QWebEnginePage.Feature.DesktopVideoCapture: 'content.desktop_capture', QWebEnginePage.Feature.DesktopAudioVideoCapture: 'content.desktop_capture', + # 8 == ClipboardReadWrite, new in 6.8 + QWebEnginePage.Feature(8): 'content.javascript.clipboard', } _messages = { @@ -897,6 +899,7 @@ class _WebEnginePermissions(QObject): QWebEnginePage.Feature.MouseLock: 'hide your mouse pointer', QWebEnginePage.Feature.DesktopVideoCapture: 'capture your desktop', QWebEnginePage.Feature.DesktopAudioVideoCapture: 'capture your desktop and audio', + QWebEnginePage.Feature(8): 'read and write your clipboard', } def __init__(self, tab, parent=None): @@ -1477,9 +1480,9 @@ class WebEngineTab(browsertab.AbstractTab): log.network.debug("Asking for credentials") answer = shared.authentication_required( url, authenticator, abort_on=[self.abort_questions]) - if not netrc_success and answer is None: - log.network.debug("Aborting auth") - sip.assign(authenticator, QAuthenticator()) + if answer is None: + log.network.debug("Aborting auth") + sip.assign(authenticator, QAuthenticator()) @pyqtSlot() def _on_load_started(self): @@ -1513,7 +1516,7 @@ class WebEngineTab(browsertab.AbstractTab): browsertab.TerminationStatus.crashed, QWebEnginePage.RenderProcessTerminationStatus.KilledTerminationStatus: browsertab.TerminationStatus.killed, - -1: + QWebEnginePage.RenderProcessTerminationStatus(-1): browsertab.TerminationStatus.unknown, } self.renderer_process_terminated.emit(status_map[status], exitcode) diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index 96c0c97e5..362f00ca0 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -5,7 +5,8 @@ """The main browser widget for QtWebEngine.""" import mimetypes -from typing import List, Iterable, Optional +from typing import Optional +from collections.abc import Iterable from qutebrowser.qt import machinery from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QUrl @@ -135,8 +136,7 @@ class WebEngineView(QWebEngineView): def page(self) -> "WebEnginePage": """Return the page for this view.""" maybe_page = super().page() - assert maybe_page is not None - assert isinstance(maybe_page, WebEnginePage) + assert isinstance(maybe_page, WebEnginePage), maybe_page return maybe_page def settings(self) -> "QWebEngineSettings": @@ -316,7 +316,7 @@ class WebEnginePage(QWebEnginePage): mode: QWebEnginePage.FileSelectionMode, old_files: Iterable[Optional[str]], accepted_mimetypes: Iterable[Optional[str]], - ) -> List[str]: + ) -> list[str]: """Override chooseFiles to (optionally) invoke custom file uploader.""" accepted_mimetypes_filtered = [m for m in accepted_mimetypes if m is not None] old_files_filtered = [f for f in old_files if f is not None] diff --git a/qutebrowser/browser/webkit/certificateerror.py b/qutebrowser/browser/webkit/certificateerror.py index 59d9cc897..2c18af62e 100644 --- a/qutebrowser/browser/webkit/certificateerror.py +++ b/qutebrowser/browser/webkit/certificateerror.py @@ -4,7 +4,8 @@ """A wrapper over a list of QSslErrors.""" -from typing import Sequence, Optional +from typing import Optional +from collections.abc import Sequence from qutebrowser.qt.network import QSslError, QNetworkReply diff --git a/qutebrowser/browser/webkit/cookies.py b/qutebrowser/browser/webkit/cookies.py index 9e6ae2f1b..af881175d 100644 --- a/qutebrowser/browser/webkit/cookies.py +++ b/qutebrowser/browser/webkit/cookies.py @@ -4,7 +4,7 @@ """Handling of HTTP cookies.""" -from typing import Sequence +from collections.abc import Sequence from qutebrowser.qt.network import QNetworkCookie, QNetworkCookieJar from qutebrowser.qt.core import pyqtSignal, QDateTime diff --git a/qutebrowser/browser/webkit/httpheaders.py b/qutebrowser/browser/webkit/httpheaders.py index 95b7b7104..5c22405e0 100644 --- a/qutebrowser/browser/webkit/httpheaders.py +++ b/qutebrowser/browser/webkit/httpheaders.py @@ -8,7 +8,6 @@ import email.headerregistry import email.errors import dataclasses import os.path -from typing import Type from qutebrowser.qt.network import QNetworkRequest @@ -25,7 +24,7 @@ class DefectWrapper: """Wrapper around a email.error for comparison.""" - error_class: Type[email.errors.MessageDefect] + error_class: type[email.errors.MessageDefect] line: str def __eq__(self, other): diff --git a/qutebrowser/browser/webkit/mhtml.py b/qutebrowser/browser/webkit/mhtml.py index 692689b0a..11e381929 100644 --- a/qutebrowser/browser/webkit/mhtml.py +++ b/qutebrowser/browser/webkit/mhtml.py @@ -19,7 +19,7 @@ import email.mime.multipart import email.message import quopri import dataclasses -from typing import MutableMapping, Set, Tuple, Callable +from collections.abc import MutableMapping, Callable from qutebrowser.qt.core import QUrl @@ -177,7 +177,7 @@ class MHTMLWriter: return msg -_PendingDownloadType = Set[Tuple[QUrl, downloads.AbstractDownloadItem]] +_PendingDownloadType = set[tuple[QUrl, downloads.AbstractDownloadItem]] class _Downloader: diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py index 06402a547..a950d4239 100644 --- a/qutebrowser/browser/webkit/network/networkmanager.py +++ b/qutebrowser/browser/webkit/network/networkmanager.py @@ -7,7 +7,8 @@ import collections import html import dataclasses -from typing import TYPE_CHECKING, Dict, MutableMapping, Optional, Set +from typing import TYPE_CHECKING, Optional +from collections.abc import MutableMapping from qutebrowser.qt.core import pyqtSlot, pyqtSignal, QUrl, QByteArray from qutebrowser.qt.network import (QNetworkAccessManager, QNetworkReply, QSslConfiguration, @@ -29,7 +30,7 @@ if TYPE_CHECKING: HOSTBLOCK_ERROR_STRING = '%HOSTBLOCK%' -_proxy_auth_cache: Dict['ProxyId', 'prompt.AuthInfo'] = {} +_proxy_auth_cache: dict['ProxyId', 'prompt.AuthInfo'] = {} @dataclasses.dataclass(frozen=True) @@ -110,7 +111,7 @@ def init(): _SavedErrorsType = MutableMapping[ urlutils.HostTupleType, - Set[certificateerror.CertificateErrorWrapper], + set[certificateerror.CertificateErrorWrapper], ] diff --git a/qutebrowser/browser/webkit/tabhistory.py b/qutebrowser/browser/webkit/tabhistory.py index 80a572385..458f493d1 100644 --- a/qutebrowser/browser/webkit/tabhistory.py +++ b/qutebrowser/browser/webkit/tabhistory.py @@ -4,7 +4,8 @@ """Utilities related to QWebHistory.""" -from typing import Any, List, Mapping +from typing import Any +from collections.abc import Mapping from qutebrowser.qt.core import QByteArray, QDataStream, QIODevice, QUrl @@ -66,7 +67,7 @@ def serialize(items): """ data = QByteArray() stream = QDataStream(data, QIODevice.OpenModeFlag.ReadWrite) - user_data: List[Mapping[str, Any]] = [] + user_data: list[Mapping[str, Any]] = [] current_idx = None diff --git a/qutebrowser/browser/webkit/webkitelem.py b/qutebrowser/browser/webkit/webkitelem.py index 0400358af..6088a29d3 100644 --- a/qutebrowser/browser/webkit/webkitelem.py +++ b/qutebrowser/browser/webkit/webkitelem.py @@ -4,7 +4,8 @@ """QtWebKit specific part of the web element API.""" -from typing import cast, TYPE_CHECKING, Iterator, List, Optional, Set +from typing import cast, TYPE_CHECKING, Optional +from collections.abc import Iterator from qutebrowser.qt.core import QRect, Qt # pylint: disable=no-name-in-module @@ -90,7 +91,7 @@ class WebKitElement(webelem.AbstractWebElement): self._check_vanished() return self._elem.geometry() - def classes(self) -> Set[str]: + def classes(self) -> set[str]: self._check_vanished() return set(self._elem.classes()) @@ -364,7 +365,7 @@ class WebKitElement(webelem.AbstractWebElement): super()._click_fake_event(click_target) -def get_child_frames(startframe: QWebFrame) -> List[QWebFrame]: +def get_child_frames(startframe: QWebFrame) -> list[QWebFrame]: """Get all children recursively of a given QWebFrame. Loosely based on https://blog.nextgenetics.net/?e=64 @@ -378,7 +379,7 @@ def get_child_frames(startframe: QWebFrame) -> List[QWebFrame]: results = [] frames = [startframe] while frames: - new_frames: List[QWebFrame] = [] + new_frames: list[QWebFrame] = [] for frame in frames: results.append(frame) new_frames += frame.childFrames() diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 1ae976bea..d89295440 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -7,7 +7,8 @@ import re import functools import xml.etree.ElementTree -from typing import cast, Iterable, Optional +from typing import cast, Optional +from collections.abc import Iterable from qutebrowser.qt.core import pyqtSlot, Qt, QUrl, QPoint, QTimer, QSizeF, QSize from qutebrowser.qt.gui import QIcon diff --git a/qutebrowser/commands/__init__.py b/qutebrowser/commands/__init__.py index 7c6249371..fe2730e4d 100644 --- a/qutebrowser/commands/__init__.py +++ b/qutebrowser/commands/__init__.py @@ -14,6 +14,8 @@ For command arguments, there are also some variables you can use: - `{url:host}`, `{url:domain}`, `{url:auth}`, `{url:scheme}`, `{url:username}`, `{url:password}`, `{url:port}`, `{url:path}` and `{url:query}` expand to the respective parts of the current URL +- `{url:yank}` expands to the URL of the current page but strips all the query + parameters in the `url.yank_ignored_parameters` setting. - `{title}` expands to the current page's title - `{clipboard}` expands to the clipboard contents - `{primary}` expands to the primary selection contents diff --git a/qutebrowser/commands/cmdexc.py b/qutebrowser/commands/cmdexc.py index 4335a10e6..9bb5decc3 100644 --- a/qutebrowser/commands/cmdexc.py +++ b/qutebrowser/commands/cmdexc.py @@ -7,7 +7,6 @@ Defined here to avoid circular dependency hell. """ -from typing import List import difflib @@ -21,7 +20,7 @@ class NoSuchCommandError(Error): """Raised when a command isn't found.""" @classmethod - def for_cmd(cls, cmd: str, all_commands: List[str] = None) -> "NoSuchCommandError": + def for_cmd(cls, cmd: str, all_commands: list[str] = None) -> "NoSuchCommandError": """Raise an exception for the given command.""" suffix = '' if all_commands: diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index aaf1763e2..6759203e8 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -9,8 +9,8 @@ import collections import traceback import typing import dataclasses -from typing import (Any, MutableMapping, MutableSequence, Tuple, Union, List, Optional, - Callable) +from typing import (Any, Union, Optional) +from collections.abc import MutableMapping, MutableSequence, Callable from qutebrowser.api import cmdutils from qutebrowser.commands import cmdexc, argparser @@ -30,7 +30,7 @@ class ArgInfo: metavar: Optional[str] = None flag: Optional[str] = None completion: Optional[Callable[..., completionmodel.CompletionModel]] = None - choices: Optional[List[str]] = None + choices: Optional[list[str]] = None class Command: @@ -107,10 +107,10 @@ class Command: self.parser.add_argument('-h', '--help', action=argparser.HelpAction, default=argparser.SUPPRESS, nargs=0, help=argparser.SUPPRESS) - self.opt_args: MutableMapping[str, Tuple[str, str]] = collections.OrderedDict() + self.opt_args: MutableMapping[str, tuple[str, str]] = collections.OrderedDict() self.namespace = None self._count = None - self.pos_args: MutableSequence[Tuple[str, str]] = [] + self.pos_args: MutableSequence[tuple[str, str]] = [] self.flags_with_args: MutableSequence[str] = [] self._has_vararg = False diff --git a/qutebrowser/commands/parser.py b/qutebrowser/commands/parser.py index d45a18aea..00e5c9083 100644 --- a/qutebrowser/commands/parser.py +++ b/qutebrowser/commands/parser.py @@ -5,7 +5,7 @@ """Module for parsing commands entered into the browser.""" import dataclasses -from typing import List, Iterator +from collections.abc import Iterator from qutebrowser.commands import cmdexc, command from qutebrowser.misc import split, objects @@ -18,8 +18,8 @@ class ParseResult: """The result of parsing a commandline.""" cmd: command.Command - args: List[str] - cmdline: List[str] + args: list[str] + cmdline: list[str] class CommandParser: @@ -107,7 +107,7 @@ class CommandParser: for sub in sub_texts: yield self.parse(sub, **kwargs) - def parse_all(self, text: str, **kwargs: bool) -> List[ParseResult]: + def parse_all(self, text: str, **kwargs: bool) -> list[ParseResult]: """Wrapper over _parse_all_gen.""" return list(self._parse_all_gen(text, **kwargs)) @@ -161,7 +161,7 @@ class CommandParser: cmdstr = matches[0] return cmdstr - def _split_args(self, cmd: command.Command, argstr: str, keep: bool) -> List[str]: + def _split_args(self, cmd: command.Command, argstr: str, keep: bool) -> list[str]: """Split the arguments from an arg string. Args: diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 0d63d0021..636f1bf6b 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -7,13 +7,14 @@ import traceback import re import contextlib -from typing import TYPE_CHECKING, Callable, Dict, Tuple, Iterator, Mapping, MutableMapping +from typing import TYPE_CHECKING +from collections.abc import Iterator, Mapping, MutableMapping, Callable from qutebrowser.qt.core import pyqtSlot, QUrl, QObject from qutebrowser.api import cmdutils from qutebrowser.commands import cmdexc, parser -from qutebrowser.utils import message, objreg, qtutils, usertypes, utils +from qutebrowser.utils import message, objreg, qtutils, usertypes, utils, urlutils from qutebrowser.keyinput import macros, modeman if TYPE_CHECKING: @@ -21,7 +22,7 @@ if TYPE_CHECKING: _ReplacementFunction = Callable[['tabbedbrowser.TabbedBrowser'], str] -last_command: Dict[usertypes.KeyMode, Tuple[str, int]] = {} +last_command: dict[usertypes.KeyMode, tuple[str, int]] = {} def _url(tabbed_browser): @@ -38,7 +39,7 @@ def _url(tabbed_browser): def _init_variable_replacements() -> Mapping[str, _ReplacementFunction]: """Return a dict from variable replacements to fns processing them.""" - replacements: Dict[str, _ReplacementFunction] = { + replacements: dict[str, _ReplacementFunction] = { 'url': lambda tb: _url(tb).toString( QUrl.ComponentFormattingOption.FullyEncoded | QUrl.UrlFormattingOption.RemovePassword), 'url:pretty': lambda tb: _url(tb).toString( @@ -57,6 +58,8 @@ def _init_variable_replacements() -> Mapping[str, _ReplacementFunction]: _url(tb).port()) if _url(tb).port() != -1 else "", 'url:path': lambda tb: _url(tb).path(), 'url:query': lambda tb: _url(tb).query(), + 'url:yank': lambda tb: urlutils.get_url_yank_text(_url(tb), + pretty=False), 'title': lambda tb: tb.widget.page_title(tb.widget.currentIndex()), 'clipboard': lambda _: utils.get_clipboard(), 'primary': lambda _: utils.get_clipboard(selection=True), diff --git a/qutebrowser/commands/userscripts.py b/qutebrowser/commands/userscripts.py index f4ddd2bf4..e929de3d2 100644 --- a/qutebrowser/commands/userscripts.py +++ b/qutebrowser/commands/userscripts.py @@ -7,7 +7,8 @@ import os import os.path import tempfile -from typing import cast, Any, MutableMapping, Tuple +from typing import cast, Any +from collections.abc import MutableMapping from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QObject, QSocketNotifier @@ -106,7 +107,7 @@ class _BaseUserscriptRunner(QObject): self._env: MutableMapping[str, str] = {} self._text_stored = False self._html_stored = False - self._args: Tuple[Any, ...] = () + self._args: tuple[Any, ...] = () self._kwargs = {} def store_text(self, text): @@ -155,7 +156,7 @@ class _BaseUserscriptRunner(QObject): self.proc = guiprocess.GUIProcess( 'userscript', additional_env=self._env, - output_messages=output_messages, verbose=verbose, parent=self) + output_messages=output_messages, verbose=verbose) self.proc.finished.connect(self.on_proc_finished) self.proc.error.connect(self.on_proc_error) self.proc.start(cmd, args) @@ -330,7 +331,6 @@ class _WindowsUserscriptRunner(_BaseUserscriptRunner): self._filepath = handle.name except OSError as e: message.error("Error while creating tempfile: {}".format(e)) - return class Error(Exception): diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 49a97c9cb..408660c3a 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -7,12 +7,12 @@ import dataclasses from typing import TYPE_CHECKING -from qutebrowser.qt.core import pyqtSlot, QObject, QTimer +from qutebrowser.qt.core import pyqtSlot, QObject from qutebrowser.config import config from qutebrowser.commands import parser, cmdexc from qutebrowser.misc import objects, split -from qutebrowser.utils import log, utils, debug, objreg +from qutebrowser.utils import log, utils, debug, objreg, usertypes from qutebrowser.completion.models import miscmodels from qutebrowser.completion import completionwidget if TYPE_CHECKING: @@ -49,7 +49,7 @@ class Completer(QObject): super().__init__(parent) self._cmd = cmd self._win_id = win_id - self._timer = QTimer() + self._timer = usertypes.Timer() self._timer.setSingleShot(True) self._timer.setInterval(0) self._timer.timeout.connect(self._update_completion) @@ -127,7 +127,7 @@ class Completer(QObject): Return: ([parts_before_cursor], 'part_under_cursor', [parts_after_cursor]) """ - text = self._cmd.text()[len(self._cmd.prefix()):] + text = self._cmd.text().removeprefix(self._cmd.prefix()) if not text or not text.strip(): # Only ":", empty part under the cursor with nothing before/after return [], '', [] diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index 0f5dc0de9..c7e549f46 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -4,7 +4,7 @@ """Completion view for statusbar command section. -Defines a CompletionView which uses CompletionFiterModel and CompletionModel +Defines a CompletionView which uses CompletionFilterModel and CompletionModel subclasses to provide completions. """ @@ -364,15 +364,13 @@ class CompletionView(QTreeView): old_model = self.model() if old_model is not None and model is not old_model: old_model.deleteLater() - self._selection_model().deleteLater() - - self.setModel(model) if model is None: self._active = False self.hide() return + self.setModel(model) model.setParent(self) self._active = True self.pattern = None @@ -414,7 +412,7 @@ class CompletionView(QTreeView): def on_clear_completion_selection(self): """Clear the selection model when an item is activated.""" self.hide() - selmod = self._selection_model() + selmod = self.selectionModel() if selmod is not None: selmod.clearSelection() selmod.clearCurrentIndex() diff --git a/qutebrowser/completion/models/__init__.py b/qutebrowser/completion/models/__init__.py index 5a19f438f..a55a91215 100644 --- a/qutebrowser/completion/models/__init__.py +++ b/qutebrowser/completion/models/__init__.py @@ -3,3 +3,22 @@ # SPDX-License-Identifier: GPL-3.0-or-later """Models for the command completion.""" + +from typing import Optional +from collections.abc import Sequence +from qutebrowser.completion.models.util import DeleteFuncType +from qutebrowser.qt.core import QAbstractItemModel + + +class BaseCategory(QAbstractItemModel): + """Abstract base class for categories of CompletionModels. + + Extends QAbstractItemModel with a few attributes we expect to be present. + + TODO: actually enforce that child classes set these variables, either via + mypy (how) or turning these variables into abstract properties, eg https://stackoverflow.com/a/50381071 + """ + + name: str + columns_to_filter: Sequence[int] + delete_func: Optional[DeleteFuncType] = None diff --git a/qutebrowser/completion/models/completionmodel.py b/qutebrowser/completion/models/completionmodel.py index 6ddf27dcf..a4eed93d1 100644 --- a/qutebrowser/completion/models/completionmodel.py +++ b/qutebrowser/completion/models/completionmodel.py @@ -4,12 +4,21 @@ """A model that proxies access to one or more completion categories.""" -from typing import MutableSequence +from typing import overload, Optional, Any, cast +from collections.abc import MutableSequence -from qutebrowser.qt.core import Qt, QModelIndex, QAbstractItemModel +from qutebrowser.qt import machinery +from qutebrowser.qt.core import Qt, QModelIndex, QAbstractItemModel, QObject from qutebrowser.utils import log, qtutils, utils from qutebrowser.api import cmdutils +from qutebrowser.completion.models import BaseCategory + + +if machinery.IS_QT5: + _FlagType = Qt.ItemFlags +else: + _FlagType = Qt.ItemFlag class CompletionModel(QAbstractItemModel): @@ -28,9 +37,9 @@ class CompletionModel(QAbstractItemModel): def __init__(self, *, column_widths=(30, 70, 0), parent=None): super().__init__(parent) self.column_widths = column_widths - self._categories: MutableSequence[QAbstractItemModel] = [] + self._categories: MutableSequence[BaseCategory] = [] - def _cat_from_idx(self, index): + def _cat_from_idx(self, index: QModelIndex) -> Optional[BaseCategory]: """Return the category pointed to by the given index. Args: @@ -44,11 +53,11 @@ class CompletionModel(QAbstractItemModel): return self._categories[index.row()] return None - def add_category(self, cat): + def add_category(self, cat: BaseCategory) -> None: """Add a completion category to the model.""" self._categories.append(cat) - def data(self, index, role=Qt.ItemDataRole.DisplayRole): + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: """Return the item data for index. Override QAbstractItemModel::data. @@ -74,7 +83,7 @@ class CompletionModel(QAbstractItemModel): idx = cat.index(index.row(), index.column()) return cat.data(idx) - def flags(self, index): + def flags(self, index: QModelIndex) -> _FlagType: """Return the item flags for index. Override QAbstractItemModel::flags. @@ -82,16 +91,16 @@ class CompletionModel(QAbstractItemModel): Return: The item flags, or Qt.ItemFlag.NoItemFlags on error. """ if not index.isValid(): - return Qt.ItemFlag.NoItemFlags + return cast(_FlagType, Qt.ItemFlag.NoItemFlags) if index.parent().isValid(): # item return (Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemNeverHasChildren) else: # category - return Qt.ItemFlag.NoItemFlags + return cast(_FlagType, Qt.ItemFlag.NoItemFlags) - def index(self, row, col, parent=QModelIndex()): + def index(self, row: int, col: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: """Get an index into the model. Override QAbstractItemModel::index. @@ -108,7 +117,21 @@ class CompletionModel(QAbstractItemModel): return self.createIndex(row, col, self._categories[parent.row()]) return self.createIndex(row, col, None) - def parent(self, index): + @overload + def parent(self, index: QModelIndex) -> QModelIndex: + ... + + if machinery.IS_QT5: + @overload + def parent(self) -> QObject: + ... + + else: + @overload + def parent(self) -> Optional[QObject]: + ... + + def parent(self, index=None): """Get an index to the parent of the given index. Override QAbstractItemModel::parent. @@ -116,6 +139,9 @@ class CompletionModel(QAbstractItemModel): Args: index: The QModelIndex to get the parent index for. """ + if not index: + return QObject.parent(self) + parent_cat = index.internalPointer() if not parent_cat: # categories have no parent diff --git a/qutebrowser/completion/models/filepathcategory.py b/qutebrowser/completion/models/filepathcategory.py index 1f4c04dea..0b2d887b8 100644 --- a/qutebrowser/completion/models/filepathcategory.py +++ b/qutebrowser/completion/models/filepathcategory.py @@ -14,20 +14,22 @@ is harder to achieve via pathlib. import glob import os import os.path -from typing import List, Optional, Iterable +from typing import Optional +from collections.abc import Iterable from qutebrowser.qt.core import QAbstractListModel, QModelIndex, QObject, Qt, QUrl +from qutebrowser.completion.models import BaseCategory from qutebrowser.config import config from qutebrowser.utils import log -class FilePathCategory(QAbstractListModel): +class FilePathCategory(QAbstractListModel, BaseCategory): """Represent filesystem paths matching a pattern.""" def __init__(self, name: str, parent: QObject = None) -> None: super().__init__(parent) - self._paths: List[str] = [] + self._paths: list[str] = [] self.name = name self.columns_to_filter = [0] diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py index 2e54eae91..5b79b4ade 100644 --- a/qutebrowser/completion/models/histcategory.py +++ b/qutebrowser/completion/models/histcategory.py @@ -12,10 +12,10 @@ from qutebrowser.qt.widgets import QWidget from qutebrowser.misc import sql from qutebrowser.utils import debug, message, log from qutebrowser.config import config -from qutebrowser.completion.models import util +from qutebrowser.completion.models import util, BaseCategory -class HistoryCategory(QSqlQueryModel): +class HistoryCategory(QSqlQueryModel, BaseCategory): """A completion category that queries the SQL history store.""" diff --git a/qutebrowser/completion/models/listcategory.py b/qutebrowser/completion/models/listcategory.py index f92679cc6..088f93791 100644 --- a/qutebrowser/completion/models/listcategory.py +++ b/qutebrowser/completion/models/listcategory.py @@ -5,23 +5,23 @@ """Completion category that uses a list of tuples as a data source.""" import re -from typing import Iterable, Tuple +from collections.abc import Iterable from qutebrowser.qt.core import QSortFilterProxyModel, QRegularExpression from qutebrowser.qt.gui import QStandardItem, QStandardItemModel from qutebrowser.qt.widgets import QWidget -from qutebrowser.completion.models import util +from qutebrowser.completion.models import util, BaseCategory from qutebrowser.utils import qtutils, log -class ListCategory(QSortFilterProxyModel): +class ListCategory(QSortFilterProxyModel, BaseCategory): """Expose a list of items as a category for the CompletionModel.""" def __init__(self, name: str, - items: Iterable[Tuple[str, ...]], + items: Iterable[tuple[str, ...]], sort: bool = True, delete_func: util.DeleteFuncType = None, parent: QWidget = None): diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py index ea3febe4d..da7c65094 100644 --- a/qutebrowser/completion/models/miscmodels.py +++ b/qutebrowser/completion/models/miscmodels.py @@ -6,7 +6,7 @@ import datetime import itertools -from typing import List, Sequence, Tuple +from collections.abc import Sequence from qutebrowser.config import config, configdata from qutebrowser.utils import objreg, log, utils @@ -113,7 +113,7 @@ def _tabs(*, win_id_filter=lambda _win_id: True, add_win_id=True, cur_win_id=Non tabs_are_windows = config.val.tabs.tabs_are_windows # list storing all single-tabbed windows when tabs_are_windows - windows: List[Tuple[str, str, str, str]] = [] + windows: list[tuple[str, str, str, str]] = [] for win_id in objreg.window_registry: if not win_id_filter(win_id): @@ -123,7 +123,7 @@ def _tabs(*, win_id_filter=lambda _win_id: True, add_win_id=True, cur_win_id=Non window=win_id) if tabbed_browser.is_shutting_down: continue - tab_entries: List[Tuple[str, str, str, str]] = [] + tab_entries: list[tuple[str, str, str, str]] = [] for idx in range(tabbed_browser.widget.count()): tab = tabbed_browser.widget.widget(idx) tab_str = ("{}/{}".format(win_id, idx + 1) if add_win_id diff --git a/qutebrowser/completion/models/urlmodel.py b/qutebrowser/completion/models/urlmodel.py index 0d6428348..7532428f1 100644 --- a/qutebrowser/completion/models/urlmodel.py +++ b/qutebrowser/completion/models/urlmodel.py @@ -4,12 +4,11 @@ """Function to return the url completion model for the `open` command.""" -from typing import Dict, Sequence - -from qutebrowser.qt.core import QAbstractItemModel +from collections.abc import Sequence from qutebrowser.completion.models import (completionmodel, filepathcategory, - listcategory, histcategory) + listcategory, histcategory, + BaseCategory) from qutebrowser.browser import history from qutebrowser.utils import log, objreg from qutebrowser.config import config @@ -59,7 +58,7 @@ def url(*, info): in sorted(config.val.url.searchengines.items()) if k != 'DEFAULT'] categories = config.val.completion.open_categories - models: Dict[str, QAbstractItemModel] = {} + models: dict[str, BaseCategory] = {} if searchengines and 'searchengines' in categories: models['searchengines'] = listcategory.ListCategory( diff --git a/qutebrowser/completion/models/util.py b/qutebrowser/completion/models/util.py index 4957dc6c9..237eba6e8 100644 --- a/qutebrowser/completion/models/util.py +++ b/qutebrowser/completion/models/util.py @@ -4,7 +4,7 @@ """Utility functions for completion models.""" -from typing import Callable, Sequence +from collections.abc import Sequence, Callable from qutebrowser.utils import usertypes from qutebrowser.misc import objects diff --git a/qutebrowser/components/braveadblock.py b/qutebrowser/components/braveadblock.py index a827eb546..e140248a3 100644 --- a/qutebrowser/components/braveadblock.py +++ b/qutebrowser/components/braveadblock.py @@ -10,7 +10,8 @@ import pathlib import functools import contextlib import subprocess -from typing import Optional, IO, Iterator +from typing import Optional, IO +from collections.abc import Iterator from qutebrowser.qt.core import QUrl @@ -95,6 +96,7 @@ _RESOURCE_TYPE_STRINGS = { ResourceType.preload_main_frame: "other", ResourceType.preload_sub_frame: "other", ResourceType.websocket: "websocket", + ResourceType.json: "other", ResourceType.unknown: "other", None: "", } diff --git a/qutebrowser/components/hostblock.py b/qutebrowser/components/hostblock.py index 672a530df..7777e1429 100644 --- a/qutebrowser/components/hostblock.py +++ b/qutebrowser/components/hostblock.py @@ -9,7 +9,7 @@ import posixpath import zipfile import logging import pathlib -from typing import cast, IO, Set +from typing import cast, IO from qutebrowser.qt.core import QUrl @@ -92,8 +92,8 @@ class HostBlocker: ) -> None: self.enabled = _should_be_used() self._has_basedir = has_basedir - self._blocked_hosts: Set[str] = set() - self._config_blocked_hosts: Set[str] = set() + self._blocked_hosts: set[str] = set() + self._config_blocked_hosts: set[str] = set() self._local_hosts_file = str(data_dir / "blocked-hosts") self.update_files() @@ -139,7 +139,7 @@ class HostBlocker: ) info.block() - def _read_hosts_line(self, raw_line: bytes) -> Set[str]: + def _read_hosts_line(self, raw_line: bytes) -> set[str]: """Read hosts from the given line. Args: @@ -175,7 +175,7 @@ class HostBlocker: return filtered_hosts - def _read_hosts_file(self, filename: str, target: Set[str]) -> bool: + def _read_hosts_file(self, filename: str, target: set[str]) -> bool: """Read hosts from the given filename. Args: diff --git a/qutebrowser/components/misccommands.py b/qutebrowser/components/misccommands.py index e3ffb82d0..b4eaa55d1 100644 --- a/qutebrowser/components/misccommands.py +++ b/qutebrowser/components/misccommands.py @@ -9,10 +9,10 @@ import os import signal -import functools import logging import pathlib -from typing import Optional, Sequence, Callable +from typing import Optional +from collections.abc import Sequence, Callable try: import hunter @@ -60,10 +60,6 @@ def stop(tab: Optional[apitypes.Tab]) -> None: def _print_preview(tab: apitypes.Tab) -> None: """Show a print preview.""" - def print_callback(ok: bool) -> None: - if not ok: - message.error("Printing failed!") - tab.printing.check_preview_support() diag = QPrintPreviewDialog(tab) diag.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) @@ -71,8 +67,7 @@ def _print_preview(tab: apitypes.Tab) -> None: diag.windowFlags() | Qt.WindowType.WindowMaximizeButtonHint | Qt.WindowType.WindowMinimizeButtonHint) - diag.paintRequested.connect(functools.partial( - tab.printing.to_printer, callback=print_callback)) + diag.paintRequested.connect(tab.printing.to_printer) diag.exec() diff --git a/qutebrowser/components/readlinecommands.py b/qutebrowser/components/readlinecommands.py index a9626637d..a26f7ea4c 100644 --- a/qutebrowser/components/readlinecommands.py +++ b/qutebrowser/components/readlinecommands.py @@ -5,7 +5,8 @@ """Bridge to provide readline-like shortcuts for QLineEdits.""" import os -from typing import Iterable, Optional, MutableMapping, Any, Callable +from typing import Optional, Any +from collections.abc import Iterable, MutableMapping, Callable from qutebrowser.qt.widgets import QApplication, QLineEdit diff --git a/qutebrowser/components/scrollcommands.py b/qutebrowser/components/scrollcommands.py index 3ee525535..da4544bd7 100644 --- a/qutebrowser/components/scrollcommands.py +++ b/qutebrowser/components/scrollcommands.py @@ -4,7 +4,7 @@ """Scrolling-related commands.""" -from typing import Dict, Callable +from collections.abc import Callable from qutebrowser.api import cmdutils, apitypes @@ -41,7 +41,7 @@ def scroll(tab: apitypes.Tab, direction: str, count: int = 1) -> None: count: multiplier """ # FIXME:mypy Use a callback protocol to enforce having 'count'? - funcs: Dict[str, Callable[..., None]] = { + funcs: dict[str, Callable[..., None]] = { 'up': tab.scroller.up, 'down': tab.scroller.down, 'left': tab.scroller.left, diff --git a/qutebrowser/components/utils/blockutils.py b/qutebrowser/components/utils/blockutils.py index 369b0eee5..154c04317 100644 --- a/qutebrowser/components/utils/blockutils.py +++ b/qutebrowser/components/utils/blockutils.py @@ -6,7 +6,7 @@ import os import functools -from typing import IO, List, Optional +from typing import IO, Optional from qutebrowser.qt.core import QUrl, QObject, pyqtSignal @@ -47,11 +47,11 @@ class BlocklistDownloads(QObject): single_download_finished = pyqtSignal(object) # arg: the file object all_downloads_finished = pyqtSignal(int) # arg: download count - def __init__(self, urls: List[QUrl], parent: Optional[QObject] = None) -> None: + def __init__(self, urls: list[QUrl], parent: Optional[QObject] = None) -> None: super().__init__(parent) self._urls = urls - self._in_progress: List[downloads.TempDownload] = [] + self._in_progress: list[downloads.TempDownload] = [] self._done_count = 0 self._finished_registering_downloads = False self._started = False @@ -75,7 +75,7 @@ class BlocklistDownloads(QObject): if not self._in_progress and not self._finished: # The in-progress list is empty but we still haven't called the # completion callback yet. This happens when all downloads finish - # before we've set `_finished_registering_dowloads` to False. + # before we've set `_finished_registering_downloads` to False. self._finished = True self.all_downloads_finished.emit(self._done_count) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index cb7fe77b3..d286bf733 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -7,8 +7,8 @@ import copy import contextlib import functools -from typing import (TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Mapping, - MutableMapping, MutableSequence, Optional, Tuple, cast) +from typing import (TYPE_CHECKING, Any, Optional, cast) +from collections.abc import Iterator, Mapping, MutableMapping, MutableSequence, Callable from qutebrowser.qt.core import pyqtSignal, QObject, QUrl @@ -29,7 +29,7 @@ key_instance = cast('KeyConfig', None) cache = cast('configcache.ConfigCache', None) # Keeping track of all change filters to validate them later. -change_filters: List["change_filter"] = [] +change_filters: list["change_filter"] = [] # Sentinel UNSET = object() @@ -131,7 +131,7 @@ class KeyConfig: _config: The Config object to be used. """ - _ReverseBindings = Dict[str, MutableSequence[str]] + _ReverseBindings = dict[str, MutableSequence[str]] def __init__(self, config: 'Config') -> None: self._config = config @@ -143,7 +143,7 @@ class KeyConfig: if mode not in configdata.DATA['bindings.default'].default: raise configexc.KeybindingError("Invalid mode {}!".format(mode)) - def get_bindings_for(self, mode: str) -> Dict[keyutils.KeySequence, str]: + def get_bindings_for(self, mode: str) -> dict[keyutils.KeySequence, str]: """Get the combined bindings for the given mode.""" bindings = dict(val.bindings.default[mode]) for key, binding in val.bindings.commands[mode].items(): @@ -291,7 +291,7 @@ class Config(QObject): yaml_config: 'configfiles.YamlConfig', parent: QObject = None) -> None: super().__init__(parent) - self._mutables: MutableMapping[str, Tuple[Any, Any]] = {} + self._mutables: MutableMapping[str, tuple[Any, Any]] = {} self._yaml = yaml_config self._init_values() self.yaml_loaded = False @@ -554,7 +554,7 @@ class Config(QObject): Return: The changed config part as string. """ - lines: List[str] = [] + lines: list[str] = [] for values in sorted(self, key=lambda v: v.opt.name): lines += values.dump(include_hidden=include_hidden) diff --git a/qutebrowser/config/configcache.py b/qutebrowser/config/configcache.py index 9e76466d9..13ddce227 100644 --- a/qutebrowser/config/configcache.py +++ b/qutebrowser/config/configcache.py @@ -4,7 +4,7 @@ """Implementation of a basic config cache.""" -from typing import Any, Dict +from typing import Any from qutebrowser.config import config @@ -22,7 +22,7 @@ class ConfigCache: """ def __init__(self) -> None: - self._cache: Dict[str, Any] = {} + self._cache: dict[str, Any] = {} config.instance.changed.connect(self._on_config_changed) def _on_config_changed(self, attr: str) -> None: diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index c4065ceb9..9012cc2c4 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -6,7 +6,8 @@ import os.path import contextlib -from typing import TYPE_CHECKING, Iterator, List, Optional, Any, Tuple +from typing import TYPE_CHECKING, Optional, Any +from collections.abc import Iterator from qutebrowser.qt.core import QUrl, QUrlQuery @@ -473,7 +474,7 @@ class ConfigCommands: raise cmdutils.CommandError("{} already exists - use --force to " "overwrite!".format(filename)) - options: List[Tuple[Optional[urlmatch.UrlPattern], configdata.Option, Any]] = [] + options: list[tuple[Optional[urlmatch.UrlPattern], configdata.Option, Any]] = [] if defaults: options = [(None, opt, opt.default) for _name, opt in sorted(configdata.DATA.items())] diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 9535dd727..d939f7ea6 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -9,8 +9,8 @@ Module attributes: DATA: A dict of Option objects after init() has been called. """ -from typing import (Any, Dict, Iterable, List, Mapping, MutableMapping, Optional, - Sequence, Tuple, Union, cast) +from typing import (Any, Optional, Union, NoReturn, cast) +from collections.abc import Iterable, Mapping, MutableMapping, Sequence import functools import dataclasses @@ -53,11 +53,11 @@ class Migrations: deleted: A list of option names which have been removed. """ - renamed: Dict[str, str] = dataclasses.field(default_factory=dict) - deleted: List[str] = dataclasses.field(default_factory=list) + renamed: dict[str, str] = dataclasses.field(default_factory=dict) + deleted: list[str] = dataclasses.field(default_factory=list) -def _raise_invalid_node(name: str, what: str, node: Any) -> None: +def _raise_invalid_node(name: str, what: str, node: Any) -> NoReturn: """Raise an exception for an invalid configdata YAML node. Args: @@ -182,12 +182,11 @@ def _parse_yaml_backends( elif isinstance(node, dict): return _parse_yaml_backends_dict(name, node) _raise_invalid_node(name, 'backends', node) - raise utils.Unreachable def _read_yaml( yaml_data: str, -) -> Tuple[Mapping[str, Option], Migrations]: +) -> tuple[Mapping[str, Option], Migrations]: """Read config data from a YAML file. Args: diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 8abc0b7d2..641a8d274 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -322,8 +322,9 @@ qt.chromium.sandboxing: See the Chromium documentation for more details: - - https://chromium.googlesource.com/chromium/src/\+/HEAD/docs/linux/sandboxing.md[Linux] + - https://chromium.googlesource.com/chromium/src/\+/HEAD/sandbox/linux/README.md[Linux] - https://chromium.googlesource.com/chromium/src/\+/HEAD/docs/design/sandbox.md[Windows] + - https://chromium.googlesource.com/chromium/src/\+/HEAD/sandbox/mac/README.md[Mac] - https://chromium.googlesource.com/chromium/src/\+/HEAD/docs/design/sandbox_faq.md[FAQ (Windows-centric)] # yamllint enable rule:line-length @@ -404,6 +405,23 @@ qt.workarounds.disable_accelerated_2d_canvas: So far these glitches only occur on some Intel graphics devices. +qt.workarounds.disable_hangouts_extension: + type: Bool + default: false + backend: QtWebEngine + restart: true + desc: >- + Disable the Hangouts extension. + + The Hangouts extension provides additional APIs for Google domains only. + + Hangouts has been replaced with Meet, + which appears to work without this extension. + + Note this setting gets ignored and the Hangouts extension is always + disabled to avoid crashes on Qt 6.5.0 to 6.5.3 if dark mode is enabled, + as well as on Qt 6.6.0. + ## auto_save auto_save.interval: @@ -734,7 +752,7 @@ content.headers.referer: content.headers.user_agent: default: 'Mozilla/5.0 ({os_info}) AppleWebKit/{webkit_version} (KHTML, like Gecko) - {qt_key}/{qt_version} {upstream_browser_key}/{upstream_browser_version} + {qt_key}/{qt_version} {upstream_browser_key}/{upstream_browser_version_short} Safari/{webkit_version}' type: name: FormatString @@ -746,6 +764,7 @@ content.headers.user_agent: - qt_version - upstream_browser_key - upstream_browser_version + - upstream_browser_version_short - qutebrowser_version completions: # See https://techblog.willshouse.com/2012/01/03/most-common-user-agents/ @@ -755,14 +774,14 @@ content.headers.user_agent: # Vim-protip: Place your cursor below this comment and run # :r!python scripts/dev/ua_fetch.py - - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36" - - Chrome 117 macOS + (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" + - Chrome 135 macOS - - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, - like Gecko) Chrome/117.0.0.0 Safari/537.36" - - Chrome 117 Win10 + like Gecko) Chrome/135.0.0.0 Safari/537.36" + - Chrome 135 Win10 - - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like - Gecko) Chrome/117.0.0.0 Safari/537.36" - - Chrome 117 Linux + Gecko) Chrome/135.0.0.0 Safari/537.36" + - Chrome 135 Linux supports_pattern: true desc: | User agent to send. @@ -777,6 +796,8 @@ content.headers.user_agent: * `{upstream_browser_key}`: "Version" for QtWebKit, "Chrome" for QtWebEngine. * `{upstream_browser_version}`: The corresponding Safari/Chrome version. + * `{upstream_browser_version_short}`: The corresponding Safari/Chrome + version, but only with its major version. * `{qutebrowser_version}`: The currently running qutebrowser version. The default value is equal to the unchanged user agent of @@ -906,13 +927,14 @@ content.javascript.alert: desc: Show javascript alerts. content.javascript.clipboard: - default: none + default: ask type: - name: String + name: JSClipboardPermission valid_values: - none: Disable access to clipboard. - access: Allow reading from and writing to the clipboard. - access-paste: Allow accessing the clipboard and pasting clipboard content. + - ask: Prompt when requested (grants 'access-paste' permission). supports_pattern: true desc: >- Allow JavaScript to read from or write to the clipboard. @@ -920,6 +942,9 @@ content.javascript.clipboard: With QtWebEngine, writing the clipboard as response to a user interaction is always allowed. + On Qt < 6.8, the `ask` setting is equivalent to `none` and permission needs + to be granted manually via this setting. + content.javascript.can_close_tabs: default: false type: Bool @@ -2648,7 +2673,7 @@ url.yank_ignored_parameters: - utm_term - utm_content - utm_name - desc: URL parameters to strip with `:yank url`. + desc: URL parameters to strip when yanking a URL. ## window @@ -3318,6 +3343,9 @@ colors.webpage.darkmode.enabled: desc: >- Render all web contents using a dark theme. + On QtWebEngine < 6.7, this setting requires a restart and does not support + URL patterns, only the global setting is applied. + Example configurations from Chromium's `chrome://flags`: - "With simple HSL/CIELAB/RGB-based inversion": Set @@ -3325,7 +3353,7 @@ colors.webpage.darkmode.enabled: set `colors.webpage.darkmode.policy.images` to `never`. - "With selective image inversion": qutebrowser default settings. - restart: true + supports_pattern: true backend: QtWebEngine colors.webpage.darkmode.algorithm: @@ -3765,8 +3793,8 @@ bindings.default: yD: yank domain -s yp: yank pretty-url yP: yank pretty-url -s - ym: yank inline [{title}]({url}) - yM: yank inline [{title}]({url}) -s + ym: yank inline [{title}]({url:yank}) + yM: yank inline [{title}]({url:yank}) -s pp: open -- {clipboard} pP: open -- {primary} Pp: open -t -- {clipboard} diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index 4c8291580..85845f6fc 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -6,7 +6,8 @@ import difflib import dataclasses -from typing import Any, Mapping, Optional, Sequence, Union, List +from typing import Any, Optional, Union +from collections.abc import Mapping, Sequence from qutebrowser.utils import usertypes, log @@ -77,7 +78,7 @@ class NoOptionError(Error): """Raised when an option was not found.""" def __init__(self, option: str, *, - all_names: List[str] = None, + all_names: list[str] = None, deleted: bool = False, renamed: str = None) -> None: if deleted: diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 0680cd0e7..3fcdd53d8 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -14,8 +14,8 @@ import traceback import configparser import contextlib import re -from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Mapping, - MutableMapping, Optional, Tuple, cast) +from typing import (TYPE_CHECKING, Any, Optional, cast) +from collections.abc import Iterable, Iterator, Mapping, MutableMapping import yaml from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QObject, QSettings, qVersion @@ -34,7 +34,7 @@ if TYPE_CHECKING: state = cast('StateConfig', None) -_SettingsType = Dict[str, Dict[str, Any]] +_SettingsType = dict[str, dict[str, Any]] class VersionChange(enum.Enum): @@ -55,7 +55,7 @@ class VersionChange(enum.Enum): This is intended to use filters like "major" (show major only), "minor" (show major/minor) or "patch" (show all changes). """ - allowed_values: Dict[str, List[VersionChange]] = { + allowed_values: dict[str, list[VersionChange]] = { 'major': [VersionChange.major], 'minor': [VersionChange.major, VersionChange.minor], 'patch': [VersionChange.major, VersionChange.minor, VersionChange.patch], @@ -183,9 +183,9 @@ class StateConfig(configparser.ConfigParser): return old_chromium_version_str = self['general'].get('chromium_version', None) - if old_chromium_version_str in ['no', None]: + if old_chromium_version_str == "no" or old_chromium_version_str is None: old_qtwe_version = self['general'].get('qtwe_version', None) - if old_qtwe_version in ['no', None]: + if old_qtwe_version == "no" or old_qtwe_version is None: return try: @@ -250,7 +250,7 @@ class YamlConfig(QObject): 'autoconfig.yml') self._dirty = False - self._values: Dict[str, configutils.Values] = {} + self._values: dict[str, configutils.Values] = {} for name, opt in configdata.DATA.items(): self._values[name] = configutils.Values(opt) @@ -702,7 +702,7 @@ class ConfigAPI: ): self._config = conf self._keyconfig = keyconfig - self.errors: List[configexc.ConfigErrorDesc] = [] + self.errors: list[configexc.ConfigErrorDesc] = [] self.configdir = pathlib.Path(standarddir.config()) self.datadir = pathlib.Path(standarddir.data()) self._warn_autoconfig = warn_autoconfig @@ -803,8 +803,8 @@ class ConfigPyWriter: def __init__( self, - options: List[ - Tuple[ + options: list[ + tuple[ Optional[urlmatch.UrlPattern], configdata.Option, Any diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 41f139e14..ea9f9edc5 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -36,8 +36,9 @@ import functools import operator import json import dataclasses -from typing import (Any, Callable, Dict as DictType, Iterable, Iterator, - List as ListType, Optional, Pattern, Sequence, Tuple, Union) +from typing import Any, Optional, Union +from re import Pattern +from collections.abc import Iterable, Iterator, Sequence, Callable import yaml from qutebrowser.qt.core import QUrl, Qt @@ -65,7 +66,7 @@ BOOLEAN_STATES = {'1': True, 'yes': True, 'true': True, 'on': True, '0': False, 'no': False, 'false': False, 'off': False} -_Completions = Optional[Iterable[Tuple[str, str]]] +_Completions = Optional[Iterable[tuple[str, str]]] _StrUnset = Union[str, usertypes.Unset] _UnsetNone = Union[None, usertypes.Unset] _StrUnsetNone = Union[str, _UnsetNone] @@ -102,16 +103,16 @@ class ValidValues: self, *values: Union[ str, - DictType[str, Optional[str]], - Tuple[str, Optional[str]], + dict[str, Optional[str]], + tuple[str, Optional[str]], ], generate_docs: bool = True, others_permitted: bool = False ) -> None: if not values: raise ValueError("ValidValues with no values makes no sense!") - self.descriptions: DictType[str, str] = {} - self.values: ListType[str] = [] + self.descriptions: dict[str, str] = {} + self.values: list[str] = [] self.generate_docs = generate_docs self.others_permitted = others_permitted for value in values: @@ -146,6 +147,17 @@ class ValidValues: self.descriptions == other.descriptions) +class AsBool: + + """A non-Bool type that can be converted to bool.""" + + def to_bool(self, value: Any) -> bool: + raise NotImplementedError + + def from_bool(self, value: bool) -> Any: + raise NotImplementedError + + class BaseType: """A type used for a setting value. @@ -178,7 +190,7 @@ class BaseType: def _basic_py_validation( self, value: Any, - pytype: Union[type, Tuple[type, ...]]) -> None: + pytype: Union[type, tuple[type, ...]]) -> None: """Do some basic validation for Python values (emptyness, type). Arguments: @@ -344,7 +356,7 @@ class MappingType(BaseType): MAPPING: A mapping from config values to (translated_value, docs) tuples. """ - MAPPING: DictType[str, Tuple[Any, Optional[str]]] = {} + MAPPING: dict[str, tuple[Any, Optional[str]]] = {} def __init__( self, *, @@ -496,7 +508,7 @@ class List(BaseType): def get_valid_values(self) -> Optional[ValidValues]: return self.valtype.get_valid_values() - def from_str(self, value: str) -> Optional[ListType]: + def from_str(self, value: str) -> Optional[list]: self._basic_str_validation(value) if not value: return None @@ -511,15 +523,15 @@ class List(BaseType): self.to_py(yaml_val) return yaml_val - def from_obj(self, value: Optional[ListType]) -> ListType: + def from_obj(self, value: Optional[list]) -> list: if value is None: return [] return [self.valtype.from_obj(v) for v in value] def to_py( self, - value: Union[ListType, usertypes.Unset] - ) -> Union[ListType, usertypes.Unset]: + value: Union[list, usertypes.Unset] + ) -> Union[list, usertypes.Unset]: self._basic_py_validation(value, list) if isinstance(value, usertypes.Unset): return value @@ -534,13 +546,13 @@ class List(BaseType): "be set!".format(self.length)) return [self.valtype.to_py(v) for v in value] - def to_str(self, value: ListType) -> str: + def to_str(self, value: list) -> str: if not value: # An empty list is treated just like None -> empty string return '' return json.dumps(value) - def to_doc(self, value: ListType, indent: int = 0) -> str: + def to_doc(self, value: list, indent: int = 0) -> str: if not value: return 'empty' @@ -585,7 +597,7 @@ class ListOrValue(BaseType): self.listtype = List(valtype=valtype, none_ok=none_ok, **kwargs) self.valtype = valtype - def _val_and_type(self, value: Any) -> Tuple[Any, BaseType]: + def _val_and_type(self, value: Any) -> tuple[Any, BaseType]: """Get the value and type to use for to_str/to_doc/from_str.""" if isinstance(value, list): if len(value) == 1: @@ -666,15 +678,15 @@ class FlagList(List): ) self.valtype.valid_values = valid_values - def _check_duplicates(self, values: ListType) -> None: + def _check_duplicates(self, values: list) -> None: if len(set(values)) != len(values): raise configexc.ValidationError( values, "List contains duplicate values!") def to_py( self, - value: Union[usertypes.Unset, ListType], - ) -> Union[usertypes.Unset, ListType]: + value: Union[usertypes.Unset, list], + ) -> Union[usertypes.Unset, list]: vals = super().to_py(value) if not isinstance(vals, usertypes.Unset): self._check_duplicates(vals) @@ -1110,7 +1122,7 @@ class QtColor(BaseType): kind = value[:openparen] vals = value[openparen+1:-1].split(',') - converters: DictType[str, Callable[..., QColor]] = { + converters: dict[str, Callable[..., QColor]] = { 'rgba': QColor.fromRgb, 'rgb': QColor.fromRgb, 'hsva': QColor.fromHsv, @@ -1200,7 +1212,7 @@ class FontBase(BaseType): (?P.+) # mandatory font family""", re.VERBOSE) @classmethod - def set_defaults(cls, default_family: ListType[str], default_size: str) -> None: + def set_defaults(cls, default_family: list[str], default_size: str) -> None: """Make sure default_family/default_size are available. If the given family value (fonts.default_family in the config) is @@ -1373,7 +1385,7 @@ class Dict(BaseType): self.fixed_keys = fixed_keys self.required_keys = required_keys - def _validate_keys(self, value: DictType) -> None: + def _validate_keys(self, value: dict) -> None: if (self.fixed_keys is not None and not set(value.keys()).issubset(self.fixed_keys)): raise configexc.ValidationError( @@ -1384,7 +1396,7 @@ class Dict(BaseType): raise configexc.ValidationError( value, "Required keys {}".format(self.required_keys)) - def from_str(self, value: str) -> Optional[DictType]: + def from_str(self, value: str) -> Optional[dict]: self._basic_str_validation(value) if not value: return None @@ -1399,14 +1411,14 @@ class Dict(BaseType): self.to_py(yaml_val) return yaml_val - def from_obj(self, value: Optional[DictType]) -> DictType: + def from_obj(self, value: Optional[dict]) -> dict: if value is None: return {} return {self.keytype.from_obj(key): self.valtype.from_obj(val) for key, val in value.items()} - def _fill_fixed_keys(self, value: DictType) -> DictType: + def _fill_fixed_keys(self, value: dict) -> dict: """Fill missing fixed keys with a None-value.""" if self.fixed_keys is None: return value @@ -1417,8 +1429,8 @@ class Dict(BaseType): def to_py( self, - value: Union[DictType, _UnsetNone] - ) -> Union[DictType, usertypes.Unset]: + value: Union[dict, _UnsetNone] + ) -> Union[dict, usertypes.Unset]: self._basic_py_validation(value, dict) if isinstance(value, usertypes.Unset): return value @@ -1434,13 +1446,13 @@ class Dict(BaseType): for key, val in value.items()} return self._fill_fixed_keys(d) - def to_str(self, value: DictType) -> str: + def to_str(self, value: dict) -> str: if not value: # An empty Dict is treated just like None -> empty string return '' return json.dumps(value, sort_keys=True) - def to_doc(self, value: DictType, indent: int = 0) -> str: + def to_doc(self, value: dict, indent: int = 0) -> str: if not value: return 'empty' lines = ['\n'] @@ -1558,7 +1570,7 @@ class FormatString(BaseType): _validate_encoding(self.encoding, value) try: - value.format(**{k: '' for k in self.fields}) + value.format(**dict.fromkeys(self.fields, "")) except (KeyError, IndexError, AttributeError) as e: raise configexc.ValidationError(value, "Invalid placeholder " "{}".format(e)) @@ -1594,8 +1606,8 @@ class ShellCommand(List): def to_py( self, - value: Union[ListType, usertypes.Unset], - ) -> Union[ListType, usertypes.Unset]: + value: Union[list, usertypes.Unset], + ) -> Union[list, usertypes.Unset]: py_value = super().to_py(value) if isinstance(py_value, usertypes.Unset): return py_value @@ -1752,7 +1764,7 @@ class Padding(Dict): def to_py( # type: ignore[override] self, - value: Union[DictType, _UnsetNone], + value: Union[dict, _UnsetNone], ) -> Union[usertypes.Unset, PaddingValues]: d = super().to_py(value) if isinstance(d, usertypes.Unset): @@ -1905,8 +1917,8 @@ class ConfirmQuit(FlagList): def to_py( self, - value: Union[usertypes.Unset, ListType], - ) -> Union[ListType, usertypes.Unset]: + value: Union[usertypes.Unset, list], + ) -> Union[list, usertypes.Unset]: values = super().to_py(value) if isinstance(values, usertypes.Unset): return values @@ -1999,8 +2011,9 @@ class UrlPattern(BaseType): """A match pattern for a URL. - See https://developer.chrome.com/apps/match_patterns for the allowed - syntax. + See + https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns + for the allowed syntax. """ def to_py( @@ -2030,3 +2043,14 @@ class StatusbarWidget(String): if value.startswith("text:") or value.startswith("clock:"): return super()._validate_valid_values(value) + + +class JSClipboardPermission(String, AsBool): + + """Permission for page JS to access the system clipboard.""" + + def to_bool(self, value: str) -> bool: + return value == "access-paste" + + def from_bool(self, value: bool) -> str: + return "access-paste" if value else "none" diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index fda9552dd..2aaef7a97 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -9,8 +9,8 @@ import collections import itertools import operator from typing import ( - TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Sequence, Set, Union, - MutableMapping) + TYPE_CHECKING, Any, Optional, Union) +from collections.abc import Iterator, Sequence, MutableMapping from qutebrowser.qt.core import QUrl from qutebrowser.qt.gui import QFontDatabase @@ -78,8 +78,8 @@ class Values: self._vmap: MutableMapping[ Values._VmapKeyType, ScopedValue] = collections.OrderedDict() # A map from domain parts to rules that fall under them. - self._domain_map: Dict[ - Optional[str], Set[ScopedValue]] = collections.defaultdict(set) + self._domain_map: dict[ + Optional[str], set[ScopedValue]] = collections.defaultdict(set) for scoped in values: self._add_scoped(scoped) @@ -203,7 +203,7 @@ class Values: return self._get_fallback(fallback) qtutils.ensure_valid(url) - candidates: List[ScopedValue] = [] + candidates: list[ScopedValue] = [] # Urls trailing with '.' are equivalent to non-trailing types. # urlutils strips them, so in order to match we will need to as well. widened_hosts = urlutils.widened_hostnames(url.host().rstrip('.')) diff --git a/qutebrowser/config/qtargs.py b/qutebrowser/config/qtargs.py index 3a648524e..b02899e28 100644 --- a/qutebrowser/config/qtargs.py +++ b/qutebrowser/config/qtargs.py @@ -8,7 +8,11 @@ import os import sys import argparse import pathlib -from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union, Callable +# Using deprecated typing.Callable as a WORKAROUND because +# collections.abc.Callable inside Optional[...]/Union[...] +# is broken on Python 3.9.0 and 3.9.1 +from typing import Any, Optional, Union, Callable +from collections.abc import Iterator, Sequence from qutebrowser.qt import machinery from qutebrowser.qt.core import QLocale @@ -23,7 +27,7 @@ _DISABLE_FEATURES = '--disable-features=' _BLINK_SETTINGS = '--blink-settings=' -def qt_args(namespace: argparse.Namespace) -> List[str]: +def qt_args(namespace: argparse.Namespace) -> list[str]: """Get the Qt QApplication arguments based on an argparse namespace. Args: @@ -77,7 +81,7 @@ def qt_args(namespace: argparse.Namespace) -> List[str]: def _qtwebengine_features( versions: version.WebEngineVersions, special_flags: Sequence[str], -) -> Tuple[Sequence[str], Sequence[str]]: +) -> tuple[Sequence[str], Sequence[str]]: """Get a tuple of --enable-features/--disable-features flags for QtWebEngine. Args: @@ -91,10 +95,10 @@ def _qtwebengine_features( for flag in special_flags: if flag.startswith(_ENABLE_FEATURES): - flag = flag[len(_ENABLE_FEATURES):] + flag = flag.removeprefix(_ENABLE_FEATURES) enabled_features += flag.split(',') elif flag.startswith(_DISABLE_FEATURES): - flag = flag[len(_DISABLE_FEATURES):] + flag = flag.removeprefix(_DISABLE_FEATURES) disabled_features += flag.split(',') elif flag.startswith(_BLINK_SETTINGS): pass @@ -150,6 +154,11 @@ def _qtwebengine_features( # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-89740 disabled_features.append('InstalledApp') + if versions.webengine >= utils.VersionNumber(6, 7): + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-132681 + # TODO adjust if fixed in Qt 6.8.2/.3 or 6.9.0/.1 + disabled_features.append('DocumentPictureInPictureAPI') + if not config.val.input.media_keys: disabled_features.append('HardwareMediaKeyHandling') @@ -167,7 +176,7 @@ def _get_pak_name(locale_name: str) -> str: Based on Chromium's behavior in l10n_util::CheckAndResolveLocale: https://source.chromium.org/chromium/chromium/src/+/master:ui/base/l10n/l10n_util.cc;l=344-428;drc=43d5378f7f363dab9271ca37774c71176c9e7b69 """ - if locale_name in {'en', 'en-PH', 'en-LR'}: + if locale_name in {'en', 'en-POSIX', 'en-PH', 'en-LR'}: return 'en-US' elif locale_name.startswith('en-'): return 'en-GB' @@ -285,7 +294,7 @@ _SettingValueType = Union[ Optional[str], ], ] -_WEBENGINE_SETTINGS: Dict[str, Dict[Any, Optional[_SettingValueType]]] = { +_WEBENGINE_SETTINGS: dict[str, dict[Any, Optional[_SettingValueType]]] = { 'qt.force_software_rendering': { 'software-opengl': None, 'qt-quick': None, diff --git a/qutebrowser/config/stylesheet.py b/qutebrowser/config/stylesheet.py index d9032e2a9..258e26002 100644 --- a/qutebrowser/config/stylesheet.py +++ b/qutebrowser/config/stylesheet.py @@ -5,7 +5,7 @@ """Handling of Qt qss stylesheets.""" import functools -from typing import Optional, FrozenSet +from typing import Optional from qutebrowser.qt.core import pyqtSlot, QObject from qutebrowser.qt.widgets import QWidget @@ -72,7 +72,7 @@ class _StyleSheetObserver(QObject): self._stylesheet = stylesheet if update: - self._options: Optional[FrozenSet[str]] = jinja.template_config_variables( + self._options: Optional[frozenset[str]] = jinja.template_config_variables( self._stylesheet) else: self._options = None diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 7824ae258..3913cb24e 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -8,7 +8,8 @@ import re import argparse import functools import dataclasses -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Optional, Union +from collections.abc import Callable from qutebrowser.qt.core import QUrl, pyqtSlot, qVersion from qutebrowser.qt.gui import QFont @@ -33,6 +34,13 @@ class UserAgent: qt_key: str qt_version: Optional[str] + @property + def upstream_browser_version_short(self) -> str: + """Return a shortened version of the upstream browser version.""" + major, *rest = self.upstream_browser_version.split('.') + shortened = [major] + ["0"] * (len(rest)) + return ".".join(shortened) + @classmethod def parse(cls, ua: str) -> 'UserAgent': """Parse a user agent string into its components.""" @@ -86,10 +94,10 @@ class AbstractSettings: """Abstract base class for settings set via QWeb(Engine)Settings.""" - _ATTRIBUTES: Dict[str, AttributeInfo] = {} - _FONT_SIZES: Dict[str, Any] = {} - _FONT_FAMILIES: Dict[str, Any] = {} - _FONT_TO_QFONT: Dict[Any, QFont.StyleHint] = {} + _ATTRIBUTES: dict[str, AttributeInfo] = {} + _FONT_SIZES: dict[str, Any] = {} + _FONT_FAMILIES: dict[str, Any] = {} + _FONT_TO_QFONT: dict[Any, QFont.StyleHint] = {} def __init__(self, settings: Any) -> None: self._settings = settings @@ -206,6 +214,7 @@ def _format_user_agent(template: str, backend: usertypes.Backend) -> str: qt_version=qVersion(), upstream_browser_key=parsed.upstream_browser_key, upstream_browser_version=parsed.upstream_browser_version, + upstream_browser_version_short=parsed.upstream_browser_version_short, qutebrowser_version=qutebrowser.__version__, ) diff --git a/qutebrowser/extensions/interceptors.py b/qutebrowser/extensions/interceptors.py index 8aaa9b28c..31fc94e0d 100644 --- a/qutebrowser/extensions/interceptors.py +++ b/qutebrowser/extensions/interceptors.py @@ -6,7 +6,8 @@ import enum import dataclasses -from typing import Callable, List, Optional +from typing import Optional +from collections.abc import Callable from qutebrowser.qt.core import QUrl @@ -39,6 +40,7 @@ class ResourceType(enum.Enum): # 18 is "preload", deprecated in Chromium preload_main_frame = 19 preload_sub_frame = 20 + json = 21 websocket = 254 unknown = 255 @@ -89,7 +91,7 @@ class Request: InterceptorType = Callable[[Request], None] -_interceptors: List[InterceptorType] = [] +_interceptors: list[InterceptorType] = [] def register(interceptor: InterceptorType) -> None: diff --git a/qutebrowser/extensions/loader.py b/qutebrowser/extensions/loader.py index ff9974d9d..a6917be35 100644 --- a/qutebrowser/extensions/loader.py +++ b/qutebrowser/extensions/loader.py @@ -10,7 +10,8 @@ import pathlib import importlib import argparse import dataclasses -from typing import Callable, Iterator, List, Optional, Tuple +from typing import Optional +from collections.abc import Iterator, Callable from qutebrowser.qt.core import pyqtSlot @@ -21,7 +22,7 @@ from qutebrowser.misc import objects # ModuleInfo objects for all loaded plugins -_module_infos: List["ModuleInfo"] = [] +_module_infos: list["ModuleInfo"] = [] InitHookType = Callable[['InitContext'], None] ConfigChangedHookType = Callable[[], None] @@ -47,8 +48,8 @@ class ModuleInfo: skip_hooks: bool = False init_hook: Optional[InitHookType] = None - config_changed_hooks: List[ - Tuple[ + config_changed_hooks: list[ + tuple[ Optional[str], ConfigChangedHookType, ] diff --git a/qutebrowser/html/no_pdfjs.html b/qutebrowser/html/no_pdfjs.html index 7b2d9bdf7..ca6795e27 100644 --- a/qutebrowser/html/no_pdfjs.html +++ b/qutebrowser/html/no_pdfjs.html @@ -110,9 +110,11 @@ li {
  • - You can manually download the pdf.js archive - here - and extract it to {{ pdfjs_dir }} + You can manually + download pdf.js + and extract it to {{ pdfjs_dir }}. Note the "older + browsers" ("legacy") build is recommended, the "modern browsers" + build only supports the latest Chromium release and might break.
    Warning: Using this method you are responsible for yourself to keep the installation updated! If a diff --git a/qutebrowser/html/version.html b/qutebrowser/html/version.html index 643929088..666414b26 100644 --- a/qutebrowser/html/version.html +++ b/qutebrowser/html/version.html @@ -19,8 +19,8 @@ html { margin-left: 10px; } {% block content %} {{ super() }}

    Version info

    -
    {{ version }}
    +
    {{ version }}

    Copyright info

    {{ copyright }}

    diff --git a/qutebrowser/javascript/.eslintrc.yaml b/qutebrowser/javascript/.eslintrc.yaml index 566304c27..c8880c3cc 100644 --- a/qutebrowser/javascript/.eslintrc.yaml +++ b/qutebrowser/javascript/.eslintrc.yaml @@ -62,3 +62,4 @@ rules: function-call-argument-newline: "off" no-negated-condition: "off" no-console: "off" + sort-vars: "off" diff --git a/qutebrowser/javascript/pdfjs_polyfills.js b/qutebrowser/javascript/pdfjs_polyfills.js new file mode 100644 index 000000000..54e71652d --- /dev/null +++ b/qutebrowser/javascript/pdfjs_polyfills.js @@ -0,0 +1,22 @@ +/* eslint-disable strict */ +/* (this file gets used as a snippet) */ + +/* +SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +SPDX-License-Identifier: GPL-3.0-or-later +*/ + +(function() { + // Chromium 119 / QtWebEngine 6.8 + // https://caniuse.com/mdn-javascript_builtins_promise_withresolvers + if (typeof Promise.withResolvers === "undefined") { + Promise.withResolvers = function() { + let resolve, reject + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } + } + } +})(); diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index df6b66f7f..c97570369 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -8,7 +8,8 @@ import string import types import dataclasses import traceback -from typing import Mapping, MutableMapping, Optional, Sequence +from typing import Optional +from collections.abc import Mapping, MutableMapping, Sequence from qutebrowser.qt.core import QObject, pyqtSignal from qutebrowser.qt.gui import QKeySequence, QKeyEvent diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 54b6e88b1..8bb63bbe6 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -18,7 +18,8 @@ handle what we actually think we do. import itertools import dataclasses -from typing import Iterator, Iterable, List, Mapping, Optional, Union, overload, cast +from typing import Optional, Union, overload, cast +from collections.abc import Iterator, Iterable, Mapping from qutebrowser.qt import machinery from qutebrowser.qt.core import Qt, QEvent @@ -359,6 +360,8 @@ class KeyInfo: modifier_classes = (Qt.KeyboardModifier, Qt.KeyboardModifiers) elif machinery.IS_QT6: modifier_classes = Qt.KeyboardModifier + else: + raise utils.Unreachable() assert isinstance(self.key, Qt.Key), self.key assert isinstance(self.modifiers, modifier_classes), self.modifiers @@ -521,7 +524,7 @@ class KeySequence: _MAX_LEN = 4 def __init__(self, *keys: KeyInfo) -> None: - self._sequences: List[QKeySequence] = [] + self._sequences: list[QKeySequence] = [] for sub in utils.chunk(keys, self._MAX_LEN): try: args = [info.to_qt() for info in sub] @@ -544,7 +547,7 @@ class KeySequence: """Iterate over KeyInfo objects.""" # FIXME:mypy Stubs seem to be unaware that iterating a QKeySequence produces # _KeyInfoType - sequences = cast(List[Iterable[_KeyInfoType]], self._sequences) + sequences = cast(list[Iterable[_KeyInfoType]], self._sequences) for combination in itertools.chain.from_iterable(sequences): yield KeyInfo.from_qt(combination) @@ -717,7 +720,7 @@ class KeySequence: mappings: Mapping['KeySequence', 'KeySequence'] ) -> 'KeySequence': """Get a new KeySequence with the given mappings applied.""" - infos: List[KeyInfo] = [] + infos: list[KeyInfo] = [] for info in self: key_seq = KeySequence(info) if key_seq in mappings: diff --git a/qutebrowser/keyinput/macros.py b/qutebrowser/keyinput/macros.py index 69198ecfb..0eb7244d6 100644 --- a/qutebrowser/keyinput/macros.py +++ b/qutebrowser/keyinput/macros.py @@ -5,7 +5,7 @@ """Keyboard macro system.""" -from typing import cast, Dict, List, Optional, Tuple +from typing import cast, Optional from qutebrowser.commands import runners from qutebrowser.api import cmdutils @@ -13,7 +13,7 @@ from qutebrowser.keyinput import modeman from qutebrowser.utils import message, objreg, usertypes -_CommandType = Tuple[str, int] # command, type +_CommandType = tuple[str, int] # command, type macro_recorder = cast('MacroRecorder', None) @@ -32,9 +32,9 @@ class MacroRecorder: """ def __init__(self) -> None: - self._macros: Dict[str, List[_CommandType]] = {} + self._macros: dict[str, list[_CommandType]] = {} self._recording_macro: Optional[str] = None - self._macro_count: Dict[int, int] = {} + self._macro_count: dict[int, int] = {} self._last_register: Optional[str] = None @cmdutils.register(instance='macro-recorder') diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index f0337ec88..681deeff6 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -6,7 +6,8 @@ import functools import dataclasses -from typing import Mapping, Callable, MutableMapping, Union, Set, cast +from typing import Union, cast +from collections.abc import Mapping, MutableMapping, Callable from qutebrowser.qt import machinery from qutebrowser.qt.core import pyqtSlot, pyqtSignal, Qt, QObject, QEvent @@ -252,7 +253,7 @@ class ModeManager(QObject): self.parsers: ParserDictType = {} self._prev_mode = usertypes.KeyMode.normal self.mode = usertypes.KeyMode.normal - self._releaseevents_to_pass: Set[KeyEvent] = set() + self._releaseevents_to_pass: set[KeyEvent] = set() # Set after __init__ self.hintmanager = cast(hints.HintManager, None) diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 05e560111..b9e5951db 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -10,7 +10,8 @@ Module attributes: import traceback import enum -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING +from collections.abc import Sequence from qutebrowser.qt.core import pyqtSlot, Qt, QObject from qutebrowser.qt.gui import QKeySequence, QKeyEvent diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 9251131e3..ea5151e83 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -8,7 +8,8 @@ import binascii import base64 import itertools import functools -from typing import List, MutableSequence, Optional, Tuple, cast, Union +from typing import Optional, cast, Union +from collections.abc import MutableSequence from qutebrowser.qt import machinery from qutebrowser.qt.core import (pyqtBoundSignal, pyqtSlot, QRect, QPoint, QTimer, Qt, @@ -100,7 +101,7 @@ def get_target_window(): return None -_OverlayInfoType = Tuple[QWidget, pyqtBoundSignal, bool, str] +_OverlayInfoType = tuple[QWidget, pyqtBoundSignal, bool, str] class MainWindow(QWidget): @@ -420,7 +421,7 @@ class MainWindow(QWidget): self._vbox.removeWidget(self.tabbed_browser.widget) self._vbox.removeWidget(self._downloadview) self._vbox.removeWidget(self.status) - widgets: List[QWidget] = [self.tabbed_browser.widget] + widgets: list[QWidget] = [self.tabbed_browser.widget] downloads_position = config.val.downloads.position if downloads_position == 'top': diff --git a/qutebrowser/mainwindow/messageview.py b/qutebrowser/mainwindow/messageview.py index 38d2a2f9e..66d065360 100644 --- a/qutebrowser/mainwindow/messageview.py +++ b/qutebrowser/mainwindow/messageview.py @@ -4,9 +4,10 @@ """Showing messages above the statusbar.""" -from typing import MutableSequence, Optional +from typing import Optional +from collections.abc import MutableSequence -from qutebrowser.qt.core import pyqtSlot, pyqtSignal, QTimer, Qt +from qutebrowser.qt.core import pyqtSlot, pyqtSignal, Qt from qutebrowser.qt.widgets import QWidget, QVBoxLayout, QLabel, QSizePolicy from qutebrowser.config import config, stylesheet @@ -101,7 +102,7 @@ class MessageView(QWidget): self._vbox.setSpacing(0) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self._clear_timer = QTimer() + self._clear_timer = usertypes.Timer() self._clear_timer.timeout.connect(self.clear_messages) config.instance.changed.connect(self._set_clear_timer_interval) diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py index 92d3cc2ea..2e797970f 100644 --- a/qutebrowser/mainwindow/prompt.py +++ b/qutebrowser/mainwindow/prompt.py @@ -9,10 +9,11 @@ import html import collections import functools import dataclasses -from typing import Deque, MutableSequence, Optional, cast +from typing import Optional, cast +from collections.abc import MutableSequence from qutebrowser.qt.core import (pyqtSlot, pyqtSignal, Qt, QTimer, QDir, QModelIndex, - QItemSelectionModel, QObject, QEventLoop) + QItemSelectionModel, QObject, QEventLoop, QUrl) from qutebrowser.qt.widgets import (QWidget, QGridLayout, QVBoxLayout, QLineEdit, QLabel, QTreeView, QSizePolicy, QSpacerItem, QFileIconProvider) @@ -23,7 +24,7 @@ from qutebrowser.config import config, configtypes, configexc, stylesheet from qutebrowser.utils import usertypes, log, utils, qtutils, objreg, message from qutebrowser.keyinput import modeman from qutebrowser.api import cmdutils -from qutebrowser.utils import urlmatch +from qutebrowser.utils import urlmatch, urlutils prompt_queue = cast('PromptQueue', None) @@ -89,7 +90,7 @@ class PromptQueue(QObject): self._question = None self._shutting_down = False self._loops: MutableSequence[qtutils.EventLoop] = [] - self._queue: Deque[usertypes.Question] = collections.deque() + self._queue: collections.deque[usertypes.Question] = collections.deque() message.global_bridge.mode_left.connect(self._on_mode_left) def __repr__(self): @@ -452,8 +453,9 @@ class PromptContainer(QWidget): else: sel = False target = 'clipboard' - utils.set_clipboard(question.url, sel) - message.info("Yanked to {}: {}".format(target, question.url)) + url_str = urlutils.get_url_yank_text(QUrl(question.url), pretty=False) + utils.set_clipboard(url_str, sel) + message.info("Yanked to {}: {}".format(target, url_str)) @cmdutils.register( instance='prompt-container', scope='window', @@ -975,12 +977,22 @@ class YesNoPrompt(_BasePrompt): raise Error("Invalid value {} - expected yes/no!".format(value)) if save: + value = self.question.answer opt = config.instance.get_opt(self.question.option) - assert isinstance(opt.typ, configtypes.Bool) + if isinstance(opt.typ, configtypes.Bool): + pass + elif isinstance(opt.typ, configtypes.AsBool): + value = opt.typ.from_bool(value) + else: + raise AssertionError( + f"Cannot save prompt answer ({opt.name}). Expected 'Bool' or 'AsBool' " + f"type option, got: value={value} type={type(opt.typ)}" + ) + pattern = urlmatch.UrlPattern(self.question.url) try: - config.instance.set_obj(opt.name, self.question.answer, + config.instance.set_obj(opt.name, value, pattern=pattern, save_yaml=True) except configexc.Error as e: raise Error(str(e)) diff --git a/qutebrowser/mainwindow/statusbar/clock.py b/qutebrowser/mainwindow/statusbar/clock.py index aa2afe8a0..604243935 100644 --- a/qutebrowser/mainwindow/statusbar/clock.py +++ b/qutebrowser/mainwindow/statusbar/clock.py @@ -5,9 +5,10 @@ """Clock displayed in the statusbar.""" from datetime import datetime -from qutebrowser.qt.core import Qt, QTimer +from qutebrowser.qt.core import Qt from qutebrowser.mainwindow.statusbar import textbase +from qutebrowser.utils import usertypes class Clock(textbase.TextBase): @@ -20,7 +21,7 @@ class Clock(textbase.TextBase): super().__init__(parent, elidemode=Qt.TextElideMode.ElideNone) self.format = "" - self.timer = QTimer(self) + self.timer = usertypes.Timer(self) self.timer.timeout.connect(self._show_time) def _show_time(self): diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index a635d803e..f20a59c60 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -4,13 +4,15 @@ """The main tabbed browser widget.""" +import os +import signal import collections import functools import weakref import datetime import dataclasses -from typing import (Any, Deque, List, Mapping, Union, - MutableMapping, MutableSequence, Optional, Tuple) +from typing import Any, Optional, Union +from collections.abc import Mapping, MutableMapping, MutableSequence from qutebrowser.qt.widgets import QSizePolicy, QWidget, QApplication from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QTimer, QUrl, QPoint @@ -47,7 +49,7 @@ class _UndoEntry: @classmethod def from_tab( cls, tab: browsertab.AbstractTab, idx: int - ) -> Union["_UndoEntry", List["_UndoEntry"]]: + ) -> Union["_UndoEntry", list["_UndoEntry"]]: """Generate an undo entry from `tab`.""" try: history_data = tab.history.private_api.serialize() @@ -83,10 +85,10 @@ class TabDeque: size = config.val.tabs.focus_stack_size if size < 0: size = None - self._stack: Deque[weakref.ReferenceType[browsertab.AbstractTab]] = ( + self._stack: collections.deque[weakref.ReferenceType[browsertab.AbstractTab]] = ( collections.deque(maxlen=size)) # Items that have been removed from the primary stack. - self._stack_deleted: List[weakref.ReferenceType[browsertab.AbstractTab]] = [] + self._stack_deleted: list[weakref.ReferenceType[browsertab.AbstractTab]] = [] self._ignore_next = False self._keep_deleted_next = False @@ -263,7 +265,7 @@ class TabbedBrowser(QWidget): self.search_text = None self.search_options: Mapping[str, Any] = {} self._local_marks: MutableMapping[QUrl, MutableMapping[str, QPoint]] = {} - self._global_marks: MutableMapping[str, Tuple[QPoint, QUrl]] = {} + self._global_marks: MutableMapping[str, tuple[QPoint, QUrl]] = {} self.default_window_icon = self._window().windowIcon() self.is_private = private self.tab_deque = TabDeque() @@ -311,7 +313,7 @@ class TabbedBrowser(QWidget): raise TabDeletedError("index is -1!") return idx - def widgets(self) -> List[browsertab.AbstractTab]: + def widgets(self) -> list[browsertab.AbstractTab]: """Get a list of open tab widgets. Consider using `tabs()` instead of this method. @@ -331,7 +333,7 @@ class TabbedBrowser(QWidget): def tabs( self, include_hidden: bool = False, # pylint: disable=unused-argument - ) -> List[browsertab.AbstractTab]: + ) -> list[browsertab.AbstractTab]: """Get a list of tabs in this browser. Args: @@ -1067,20 +1069,26 @@ class TabbedBrowser(QWidget): browsertab.TerminationStatus.killed: "Renderer process was killed", browsertab.TerminationStatus.unknown: "Renderer process did not start", } - msg = messages[status] + f" (status {code})" + + sig = None + try: + if os.WIFSIGNALED(code): + sig = signal.Signals(os.WTERMSIG(code)) + except (AttributeError, ValueError): + pass + + if sig is not None: + msg = messages[status] + f" (status {code}: {sig.name})" + else: + msg = messages[status] + f" (status {code})" # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-91715 versions = version.qtwebengine_versions() - is_qtbug_91715 = ( + if ( status == browsertab.TerminationStatus.unknown and code == 1002 and - versions.webengine == utils.VersionNumber(5, 15, 3)) - - def show_error_page(html): - tab.set_html(html) - log.webview.error(msg) - - if is_qtbug_91715: + versions.webengine == utils.VersionNumber(5, 15, 3) + ): log.webview.error(msg) log.webview.error('') log.webview.error( @@ -1094,12 +1102,17 @@ class TabbedBrowser(QWidget): 'A proper fix is likely available in QtWebEngine soon (which is why ' 'the workaround is disabled by default).') log.webview.error('') - else: - url_string = tab.url(requested=True).toDisplayString() - error_page = jinja.render( - 'error.html', title="Error loading {}".format(url_string), - url=url_string, error=msg) - QTimer.singleShot(100, lambda: show_error_page(error_page)) + return + + def show_error_page(html): + tab.set_html(html) + log.webview.error(msg) + + url_string = tab.url(requested=True).toDisplayString() + error_page = jinja.render( + 'error.html', title="Error loading {}".format(url_string), + url=url_string, error=msg) + QTimer.singleShot(100, lambda: show_error_page(error_page)) def resizeEvent(self, e): """Extend resizeEvent of QWidget to emit a resized signal afterwards. diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index be1c696cf..8649403dd 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -7,7 +7,7 @@ import functools import contextlib import dataclasses -from typing import Optional, Dict, Any +from typing import Optional, Any from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QSize, QRect, QPoint, QTimer, QUrl) @@ -176,7 +176,7 @@ class TabWidget(QTabWidget): page_title = self.page_title(idx) - fields: Dict[str, Any] = {} + fields: dict[str, Any] = {} fields['id'] = tab.tab_id fields['current_title'] = page_title fields['title_sep'] = ' - ' if page_title else '' @@ -417,7 +417,7 @@ class TabBar(QTabBar): self.setStyle(self._our_style) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.vertical = False - self._auto_hide_timer = QTimer() + self._auto_hide_timer = usertypes.Timer() self._auto_hide_timer.setSingleShot(True) self._auto_hide_timer.timeout.connect(self.maybe_hide) self._on_show_switching_delay_changed() @@ -715,7 +715,7 @@ class TabBar(QTabBar): # Re-do the text elision that the base QTabBar does, but using a text # rectangle computed by out TabBarStyle. With Qt6 the base class ends - # up using QCommonStyle directly for that which has a different opinon + # up using QCommonStyle directly for that which has a different opinion # of how vertical tabs should work. text_rect = self._our_style.subElementRect( QStyle.SubElement.SE_TabBarTabText, diff --git a/qutebrowser/mainwindow/treetabbedbrowser.py b/qutebrowser/mainwindow/treetabbedbrowser.py index ddbe91c97..b74c84261 100644 --- a/qutebrowser/mainwindow/treetabbedbrowser.py +++ b/qutebrowser/mainwindow/treetabbedbrowser.py @@ -6,7 +6,7 @@ import collections import dataclasses -from typing import List, Dict, Union +from typing import Union from qutebrowser.qt.core import pyqtSlot, QUrl from qutebrowser.config import config @@ -22,7 +22,7 @@ class _TreeUndoEntry(_UndoEntry): uid: int parent_node_uid: int - children_node_uids: List[int] + children_node_uids: list[int] local_index: int # index of the tab relative to its siblings def restore_into_tab(self, tab: browsertab.AbstractTab) -> None: @@ -58,7 +58,7 @@ class _TreeUndoEntry(_UndoEntry): tab: browsertab.AbstractTab, idx: int, recursing: bool = False, - ) -> Union["_TreeUndoEntry", List["_TreeUndoEntry"]]: + ) -> Union["_TreeUndoEntry", list["_TreeUndoEntry"]]: """Make a TreeUndoEntry from a Node.""" node = tab.node url = node.value.url() @@ -179,7 +179,7 @@ class TreeTabbedBrowser(TabbedBrowser): def tabs( self, include_hidden: bool = False, - ) -> List[browsertab.AbstractTab]: + ) -> list[browsertab.AbstractTab]: """Get a list of tabs in this browser. Args: @@ -334,7 +334,7 @@ class TreeTabbedBrowser(TabbedBrowser): def rel_depth(n): return n.depth - node.depth - levels: Dict[int, list] = collections.defaultdict(list) + levels: dict[int, list] = collections.defaultdict(list) for d in node.traverse(render_collapsed=False): r_depth = rel_depth(d) levels[r_depth].append(d) diff --git a/qutebrowser/mainwindow/windowundo.py b/qutebrowser/mainwindow/windowundo.py index 46ff3c8c5..5efb77c32 100644 --- a/qutebrowser/mainwindow/windowundo.py +++ b/qutebrowser/mainwindow/windowundo.py @@ -6,7 +6,8 @@ import collections import dataclasses -from typing import MutableSequence, cast, TYPE_CHECKING +from typing import cast, TYPE_CHECKING +from collections.abc import MutableSequence from qutebrowser.qt.core import QObject, QByteArray diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index 51d3a35c3..9d9aef35c 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -13,7 +13,8 @@ import shutil import os.path import argparse import dataclasses -from typing import Any, Optional, Sequence, Tuple +from typing import Any, Optional +from collections.abc import Sequence from qutebrowser.qt import machinery from qutebrowser.qt.core import Qt @@ -48,7 +49,7 @@ class _Button: default: bool = False -def _other_backend(backend: usertypes.Backend) -> Tuple[usertypes.Backend, str]: +def _other_backend(backend: usertypes.Backend) -> tuple[usertypes.Backend, str]: """Get the other backend enum/setting for a given backend.""" other_backend = { usertypes.Backend.QtWebKit: usertypes.Backend.QtWebEngine, diff --git a/qutebrowser/misc/binparsing.py b/qutebrowser/misc/binparsing.py index 81e2e6dbb..acb4cc5f8 100644 --- a/qutebrowser/misc/binparsing.py +++ b/qutebrowser/misc/binparsing.py @@ -8,7 +8,7 @@ Used by elf.py as well as pakjoy.py. """ import struct -from typing import Any, IO, Tuple +from typing import Any, IO class ParseError(Exception): @@ -16,7 +16,7 @@ class ParseError(Exception): """Raised when the file can't be parsed.""" -def unpack(fmt: str, fobj: IO[bytes]) -> Tuple[Any, ...]: +def unpack(fmt: str, fobj: IO[bytes]) -> tuple[Any, ...]: """Unpack the given struct format from the given file.""" size = struct.calcsize(fmt) data = safe_read(fobj, size) diff --git a/qutebrowser/misc/checkpyver.py b/qutebrowser/misc/checkpyver.py index 596a7803a..e93a124e5 100644 --- a/qutebrowser/misc/checkpyver.py +++ b/qutebrowser/misc/checkpyver.py @@ -28,11 +28,11 @@ except ImportError: # pragma: no cover # to stderr. def check_python_version(): """Check if correct python version is run.""" - if sys.hexversion < 0x03080000: + if sys.hexversion < 0x03090000: # We don't use .format() and print_function here just in case someone # still has < 2.6 installed. version_str = '.'.join(map(str, sys.version_info[:3])) - text = ("At least Python 3.8 is required to run qutebrowser, but " + + text = ("At least Python 3.9 is required to run qutebrowser, but " + "it's running with " + version_str + ".\n") show_errors = '--no-err-windows' not in sys.argv diff --git a/qutebrowser/misc/cmdhistory.py b/qutebrowser/misc/cmdhistory.py index aa2df63e0..e52dd77dd 100644 --- a/qutebrowser/misc/cmdhistory.py +++ b/qutebrowser/misc/cmdhistory.py @@ -4,7 +4,7 @@ """Command history for the status bar.""" -from typing import MutableSequence +from collections.abc import MutableSequence from qutebrowser.qt.core import pyqtSlot, pyqtSignal, QObject diff --git a/qutebrowser/misc/consolewidget.py b/qutebrowser/misc/consolewidget.py index 08f5dc5ff..d74478b4e 100644 --- a/qutebrowser/misc/consolewidget.py +++ b/qutebrowser/misc/consolewidget.py @@ -6,7 +6,8 @@ import sys import code -from typing import MutableSequence, Optional +from typing import Optional +from collections.abc import MutableSequence from qutebrowser.qt.core import pyqtSignal, pyqtSlot, Qt from qutebrowser.qt.widgets import QTextEdit, QWidget, QVBoxLayout, QApplication diff --git a/qutebrowser/misc/crashdialog.py b/qutebrowser/misc/crashdialog.py index ad9ce83a7..5b940a8a3 100644 --- a/qutebrowser/misc/crashdialog.py +++ b/qutebrowser/misc/crashdialog.py @@ -13,7 +13,6 @@ import fnmatch import traceback import datetime import enum -from typing import List, Tuple from qutebrowser.qt.core import pyqtSlot, Qt, QSize from qutebrowser.qt.widgets import (QDialog, QLabel, QTextEdit, QPushButton, @@ -103,7 +102,7 @@ class _CrashDialog(QDialog): super().__init__(parent) # We don't set WA_DeleteOnClose here as on an exception, we'll get # closed anyways, and it only could have unintended side-effects. - self._crash_info: List[Tuple[str, str]] = [] + self._crash_info: list[tuple[str, str]] = [] self._btn_box = None self._paste_text = None self.setWindowTitle("Whoops!") diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py index 05e5806df..1b31536f8 100644 --- a/qutebrowser/misc/crashsignal.py +++ b/qutebrowser/misc/crashsignal.py @@ -9,13 +9,15 @@ import os.path import sys import bdb import pdb # noqa: T002 +import types import signal import argparse import functools import threading import faulthandler import dataclasses -from typing import TYPE_CHECKING, Optional, MutableMapping, cast, List +from typing import TYPE_CHECKING, Optional, cast +from collections.abc import Callable, MutableMapping from qutebrowser.qt.core import (pyqtSlot, qInstallMessageHandler, QObject, QSocketNotifier, QTimer, QUrl) @@ -35,8 +37,8 @@ class ExceptionInfo: """Information stored when there was an exception.""" - pages: List[List[str]] - cmd_history: List[str] + pages: list[list[str]] + cmd_history: list[str] objects: str @@ -178,7 +180,7 @@ class CrashHandler(QObject): if sys.__stderr__ is not None: faulthandler.enable(sys.__stderr__) else: - faulthandler.disable() # type: ignore[unreachable] + faulthandler.disable() try: self._crash_log_file.close() os.remove(self._crash_log_file.name) @@ -323,7 +325,9 @@ class SignalHandler(QObject): self._activated = False self._orig_wakeup_fd: Optional[int] = None - self._handlers = { + self._handlers: dict[ + signal.Signals, Callable[[int, Optional[types.FrameType]], None] + ] = { signal.SIGINT: self.interrupt, signal.SIGTERM: self.interrupt, } @@ -331,8 +335,10 @@ class SignalHandler(QObject): "SIGHUP": self.reload_config, } for sig_str, handler in platform_dependant_handlers.items(): - if hasattr(signal.Signals, sig_str): + try: self._handlers[signal.Signals[sig_str]] = handler + except KeyError: + pass def activate(self): """Set up signal handlers. diff --git a/qutebrowser/misc/debugcachestats.py b/qutebrowser/misc/debugcachestats.py index d3ac9819b..b12995c5c 100644 --- a/qutebrowser/misc/debugcachestats.py +++ b/qutebrowser/misc/debugcachestats.py @@ -9,14 +9,14 @@ dependencies as possible to avoid cyclic dependencies. """ import weakref -import sys -from typing import Any, Callable, Optional, TypeVar, Mapping +from typing import Any, Optional, TypeVar +from collections.abc import MutableMapping, Callable from qutebrowser.utils import log # The callable should be a lru_cache wrapped function -_CACHE_FUNCTIONS: Mapping[str, Any] = weakref.WeakValueDictionary() +_CACHE_FUNCTIONS: MutableMapping[str, Any] = weakref.WeakValueDictionary() _T = TypeVar('_T', bound=Callable[..., Any]) @@ -26,16 +26,8 @@ def register(name: Optional[str] = None) -> Callable[[_T], _T]: """Register a lru_cache wrapped function for debug_cache_stats.""" def wrapper(fn: _T) -> _T: fn_name = fn.__name__ if name is None else name - if sys.version_info < (3, 9): - log.misc.vdebug( # type: ignore[attr-defined] - "debugcachestats not supported on python < 3.9, not adding '%s'", - fn_name, - ) - return fn - - else: - _CACHE_FUNCTIONS[fn_name] = fn - return fn + _CACHE_FUNCTIONS[fn_name] = fn + return fn return wrapper diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index a7bdb8252..60d2c7c09 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -4,7 +4,7 @@ """Things which need to be done really early (e.g. before importing Qt). -At this point we can be sure we have all python 3.8 features available. +At this point we can be sure we have all python 3.9 features available. """ try: @@ -175,7 +175,7 @@ def qt_version(qversion=None, qt_version_str=None): def get_qt_version(): - """Get the Qt version, or None if too old for QLibaryInfo.version().""" + """Get the Qt version, or None if too old for QLibraryInfo.version().""" try: from qutebrowser.qt.core import QLibraryInfo return QLibraryInfo.version().normalized() @@ -246,10 +246,6 @@ def check_libraries(): package = f'{machinery.INFO.wrapper}.{subpkg}' modules[package] = _missing_str(package) - if sys.version_info < (3, 9): - # Backport required - modules['importlib_resources'] = _missing_str("importlib_resources") - if sys.platform.startswith('darwin'): from qutebrowser.qt.core import QVersionNumber qt_ver = get_qt_version() @@ -337,11 +333,11 @@ def early_init(args): init_faulthandler() # Then we configure the selected Qt wrapper info = machinery.init(args) - # Init Qt logging after machinery is initialized - init_qtlog(args) # Here we check if QtCore is available, and if not, print a message to the # console or via Tk. check_qt_available(info) + # Init Qt logging after machinery is initialized + init_qtlog(args) # Now we can be sure QtCore is available, so we can print dialogs on # errors, so people only using the GUI notice them as well. check_libraries() diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py index 948b4ab9e..9f77fa75e 100644 --- a/qutebrowser/misc/editor.py +++ b/qutebrowser/misc/editor.py @@ -180,7 +180,7 @@ class ExternalEditor(QObject): line: the line number to pass to the editor column: the column number to pass to the editor """ - self._proc = guiprocess.GUIProcess(what='editor', parent=self) + self._proc = guiprocess.GUIProcess(what='editor') self._proc.finished.connect(self._on_proc_closed) self._proc.error.connect(self._on_proc_error) editor = config.val.editor.command diff --git a/qutebrowser/misc/elf.py b/qutebrowser/misc/elf.py index 35af5af28..a012f4c69 100644 --- a/qutebrowser/misc/elf.py +++ b/qutebrowser/misc/elf.py @@ -49,7 +49,7 @@ import re import dataclasses import mmap import pathlib -from typing import IO, ClassVar, Dict, Optional, cast +from typing import IO, ClassVar, Optional, cast from qutebrowser.qt import machinery from qutebrowser.utils import log, version, qtutils @@ -131,7 +131,7 @@ class Header: shnum: int shstrndx: int - _FORMATS: ClassVar[Dict[Bitness, str]] = { + _FORMATS: ClassVar[dict[Bitness, str]] = { Bitness.x64: ' Versions: # Here it gets even more crazy: Sometimes, we don't have the full UA in one piece # in the string table somehow (?!). However, Qt 6.2 added a separate # qWebEngineChromiumVersion(), with PyQt wrappers following later. And *that* - # apperently stores the full version in the string table separately from the UA. + # apparently stores the full version in the string table separately from the UA. # As we clearly didn't have enough crazy heuristics yet so far, let's hunt for it! # We first get the partial Chromium version from the UA: diff --git a/qutebrowser/misc/guiprocess.py b/qutebrowser/misc/guiprocess.py index d20b4ba0f..2e4f33748 100644 --- a/qutebrowser/misc/guiprocess.py +++ b/qutebrowser/misc/guiprocess.py @@ -9,7 +9,8 @@ import locale import shlex import shutil import signal -from typing import Mapping, Sequence, Dict, Optional +from typing import Optional +from collections.abc import Mapping, Sequence from qutebrowser.qt.core import (pyqtSlot, pyqtSignal, QObject, QProcess, QProcessEnvironment, QByteArray, QUrl, Qt) @@ -19,7 +20,7 @@ from qutebrowser.api import cmdutils, apitypes from qutebrowser.completion.models import miscmodels -all_processes: Dict[int, Optional['GUIProcess']] = {} +all_processes: dict[int, Optional['GUIProcess']] = {} last_pid: Optional[int] = None @@ -176,9 +177,10 @@ class GUIProcess(QObject): verbose: bool = False, additional_env: Mapping[str, str] = None, output_messages: bool = False, - parent: QObject = None, ): - super().__init__(parent) + # We do not accept a parent, as GUIProcesses keep track of themselves + # (see all_processes and _post_start() / _on_cleanup_timer()) + super().__init__() self.what = what self.verbose = verbose self._output_messages = output_messages diff --git a/qutebrowser/misc/httpclient.py b/qutebrowser/misc/httpclient.py index 6e1a0f577..097fdcd43 100644 --- a/qutebrowser/misc/httpclient.py +++ b/qutebrowser/misc/httpclient.py @@ -6,17 +6,17 @@ import functools import urllib.parse -from typing import MutableMapping +from collections.abc import MutableMapping from qutebrowser.qt.core import pyqtSignal, QObject, QTimer from qutebrowser.qt.network import (QNetworkAccessManager, QNetworkRequest, QNetworkReply) -from qutebrowser.utils import qtlog +from qutebrowser.utils import qtlog, usertypes class HTTPRequest(QNetworkRequest): - """A QNetworkRquest that follows (secure) redirects by default.""" + """A QNetworkRequest that follows (secure) redirects by default.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -85,7 +85,7 @@ class HTTPClient(QObject): if reply.isFinished(): self.on_reply_finished(reply) else: - timer = QTimer(self) + timer = usertypes.Timer(self) timer.setInterval(10000) timer.timeout.connect(reply.abort) timer.start() diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py index 21a3352d6..eefa2e3f3 100644 --- a/qutebrowser/misc/ipc.py +++ b/qutebrowser/misc/ipc.py @@ -391,6 +391,11 @@ class IPCServer(QObject): def on_timeout(self): """Cancel the current connection if it was idle for too long.""" assert self._socket is not None + if not self._timer.check_timeout_validity(): + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-124496 + log.ipc.debug("Ignoring early on_timeout call") + return + log.ipc.error("IPC connection timed out " "(socket 0x{:x}).".format(id(self._socket))) self._socket.disconnectFromServer() diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 32867c17a..5662763b8 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -123,7 +123,7 @@ class KeyHintView(QLabel): ).format( html.escape(prefix), suffix_color, - html.escape(str(seq)[len(prefix):]), + html.escape(str(seq).removeprefix(prefix)), html.escape(cmd) ) text = '{}
    '.format(text) diff --git a/qutebrowser/misc/lineparser.py b/qutebrowser/misc/lineparser.py index c96109e9e..c253c3ef5 100644 --- a/qutebrowser/misc/lineparser.py +++ b/qutebrowser/misc/lineparser.py @@ -7,7 +7,7 @@ import os import os.path import contextlib -from typing import Sequence +from collections.abc import Sequence from qutebrowser.qt.core import pyqtSlot, pyqtSignal, QObject diff --git a/qutebrowser/misc/nativeeventfilter.py b/qutebrowser/misc/nativeeventfilter.py index 06533bd42..1933dc63f 100644 --- a/qutebrowser/misc/nativeeventfilter.py +++ b/qutebrowser/misc/nativeeventfilter.py @@ -7,7 +7,7 @@ This entire file is a giant WORKAROUND for https://bugreports.qt.io/browse/QTBUG-114334. """ -from typing import Tuple, Union, cast, Optional +from typing import Union, cast, Optional import enum import ctypes import ctypes.util @@ -137,8 +137,10 @@ class NativeEventFilter(QAbstractNativeEventFilter): xcb.xcb_disconnect(conn) def nativeEventFilter( - self, evtype: Union[bytes, QByteArray], message: Optional[sip.voidptr] - ) -> Tuple[bool, _PointerRetType]: + self, + evtype: Union[QByteArray, bytes, bytearray, memoryview], + message: Optional[sip.voidptr], + ) -> tuple[bool, _PointerRetType]: """Handle XCB events.""" # We're only installed when the platform plugin is xcb assert evtype == b"xcb_generic_event_t", evtype diff --git a/qutebrowser/misc/notree.py b/qutebrowser/misc/notree.py index 6388a3e77..67699e198 100644 --- a/qutebrowser/misc/notree.py +++ b/qutebrowser/misc/notree.py @@ -32,7 +32,7 @@ render_tree(root) > ('└─', 'baz') """ import enum -from typing import Optional, TypeVar, Sequence, List, Tuple, Iterable, Generic +from typing import Optional, TypeVar, Sequence, Iterable, Generic import itertools # For Node.render @@ -99,12 +99,12 @@ class Node(Generic[T]): self.value = value # set initial values so there's no need for AttributeError checks self.__parent: Optional['Node[T]'] = None - self.__children: List['Node[T]'] = [] + self.__children: list['Node[T]'] = [] # For render memoization self.__modified = False self.__set_modified() # not the same as line above - self.__rendered: Optional[List[Tuple[str, 'Node[T]']]] = None + self.__rendered: Optional[list[tuple[str, 'Node[T]']]] = None if parent: self.parent = parent # calls setter @@ -155,7 +155,7 @@ class Node(Generic[T]): self.__set_modified() @property - def path(self) -> List['Node[T]']: + def path(self) -> list['Node[T]']: """Get a list of all nodes from the root node to self.""" if self.parent is None: return [self] @@ -189,7 +189,7 @@ class Node(Generic[T]): for node in self.path: node.__modified = True # pylint: disable=protected-access,unused-private-member - def render(self) -> List[Tuple[str, 'Node[T]']]: + def render(self) -> list[tuple[str, 'Node[T]']]: """Render a tree with ascii symbols. Tabs appear in the same order as in traverse() with TraverseOrder.PRE diff --git a/qutebrowser/misc/objects.py b/qutebrowser/misc/objects.py index 1b91c6fdd..4a997ffd2 100644 --- a/qutebrowser/misc/objects.py +++ b/qutebrowser/misc/objects.py @@ -8,7 +8,7 @@ # earlyinit. import argparse -from typing import TYPE_CHECKING, Any, Dict, Set, Union, cast +from typing import TYPE_CHECKING, Any, Union, cast if TYPE_CHECKING: from qutebrowser import app @@ -29,7 +29,7 @@ class NoBackend: backend: Union['usertypes.Backend', NoBackend] = NoBackend() -commands: Dict[str, 'command.Command'] = {} -debug_flags: Set[str] = set() +commands: dict[str, 'command.Command'] = {} +debug_flags: set[str] = set() args = cast(argparse.Namespace, None) qapp = cast('app.Application', None) diff --git a/qutebrowser/misc/pakjoy.py b/qutebrowser/misc/pakjoy.py index 2bcde7ce9..a74f3c15b 100644 --- a/qutebrowser/misc/pakjoy.py +++ b/qutebrowser/misc/pakjoy.py @@ -30,16 +30,28 @@ import shutil import pathlib import dataclasses import contextlib -from typing import ClassVar, IO, Optional, Dict, Tuple, Iterator +from typing import ClassVar, IO, Optional +from collections.abc import Iterator from qutebrowser.config import config from qutebrowser.misc import binparsing, objects -from qutebrowser.utils import qtutils, standarddir, version, utils, log +from qutebrowser.utils import qtutils, standarddir, version, utils, log, message HANGOUTS_MARKER = b"// Extension ID: nkeimhogjdpnpccoofpliimaahmaaome" HANGOUTS_IDS = [ - 36197, # QtWebEngine 6.5, as found by toofar - 34897, # QtWebEngine 6.4 + # Linux + 47222, # QtWebEngine 6.9 Beta 3 + 43932, # QtWebEngine 6.9 Beta 1 + 43722, # QtWebEngine 6.8 + 41262, # QtWebEngine 6.7 + 36197, # QtWebEngine 6.6 + 34897, # QtWebEngine 6.5 + 32707, # QtWebEngine 6.4 + 27537, # QtWebEngine 6.3 + 23607, # QtWebEngine 6.2 + + 248, # macOS + 381, # Windows ] PAK_VERSION = 5 RESOURCES_ENV_VAR = "QTWEBENGINE_RESOURCES_PATH" @@ -119,7 +131,7 @@ class PakParser: return data - def _read_header(self) -> Dict[int, PakEntry]: + def _read_header(self) -> dict[int, PakEntry]: """Read the header and entry index from the .pak file.""" entries = [] @@ -138,7 +150,7 @@ class PakParser: return {entry.resource_id: entry for entry in entries} - def _find_manifest(self, entries: Dict[int, PakEntry]) -> Tuple[PakEntry, bytes]: + def _find_manifest(self, entries: dict[int, PakEntry]) -> tuple[PakEntry, bytes]: to_check = list(entries.values()) for hangouts_id in HANGOUTS_IDS: if hangouts_id in entries: @@ -166,9 +178,9 @@ def _find_webengine_resources() -> pathlib.Path: qt_data_path = qtutils.library_path(qtutils.LibraryPath.data) if utils.is_mac: # pragma: no cover # I'm not sure how to arrive at this path without hardcoding it - # ourselves. importlib_resources("PyQt6.Qt6") can serve as a + # ourselves. importlib.resources.files("PyQt6.Qt6") can serve as a # replacement for the qtutils bit but it doesn't seem to help find the - # actuall Resources folder. + # actual Resources folder. candidates.append( qt_data_path / "lib" / "QtWebEngineCore.framework" / "Resources" ) @@ -184,7 +196,9 @@ def _find_webengine_resources() -> pathlib.Path: if (candidate / PAK_FILENAME).exists(): return candidate - raise binparsing.ParseError("Couldn't find webengine resources dir") + candidates_str = "\n".join(f" {p}" for p in candidates) + raise FileNotFoundError( + f"Couldn't find webengine resources dir, candidates:\n{candidates_str}") def copy_webengine_resources() -> Optional[pathlib.Path]: @@ -206,6 +220,8 @@ def copy_webengine_resources() -> Optional[pathlib.Path]: and versions.webengine < utils.VersionNumber(6, 5, 3) and config.val.colors.webpage.darkmode.enabled ) + # https://github.com/qutebrowser/qutebrowser/issues/8257 + or config.val.qt.workarounds.disable_hangouts_extension ): # No patching needed return None @@ -222,7 +238,8 @@ def copy_webengine_resources() -> Optional[pathlib.Path]: def _patch(file_to_patch: pathlib.Path) -> None: """Apply any patches to the given pak file.""" if not file_to_patch.exists(): - log.misc.error( + _error( + None, "Resource pak doesn't exist at expected location! " f"Not applying quirks. Expected location: {file_to_patch}" ) @@ -235,8 +252,22 @@ def _patch(file_to_patch: pathlib.Path) -> None: offset = parser.find_patch_offset() binparsing.safe_seek(f, offset) f.write(REPLACEMENT_URL) - except binparsing.ParseError: - log.misc.exception("Failed to apply quirk to resources pak.") + except binparsing.ParseError as e: + _error(e, "Failed to apply quirk to resources pak.") + + +def _error(exc: Optional[BaseException], text: str) -> None: + if config.val.qt.workarounds.disable_hangouts_extension: + # Explicitly requested -> hard error + lines = ["Failed to disable Hangouts extension:", text] + if exc is None: + lines.append(str(exc)) + message.error("\n".join(lines)) + elif exc is None: + # Best effort -> just log + log.misc.error(text) + else: + log.misc.exception(text) @contextlib.contextmanager @@ -251,8 +282,8 @@ def patch_webengine() -> Iterator[None]: # Still calling this on Qt != 6.6 so that the directory is cleaned up # when not needed anymore. webengine_resources_path = copy_webengine_resources() - except OSError: - log.misc.exception("Failed to copy webengine resources, not applying quirk") + except OSError as e: + _error(e, "Failed to copy webengine resources, not applying quirk") yield return diff --git a/qutebrowser/misc/quitter.py b/qutebrowser/misc/quitter.py index 825acfcd8..62438001f 100644 --- a/qutebrowser/misc/quitter.py +++ b/qutebrowser/misc/quitter.py @@ -15,7 +15,8 @@ import tokenize import functools import warnings import subprocess -from typing import Iterable, Mapping, MutableSequence, Sequence, cast +from typing import cast +from collections.abc import Iterable, Mapping, MutableSequence, Sequence from qutebrowser.qt.core import QObject, pyqtSignal, QTimer try: @@ -177,10 +178,17 @@ class Quitter(QObject): assert ipc.server is not None ipc.server.shutdown() + if hasattr(sys, 'frozen'): + # https://pyinstaller.org/en/stable/common-issues-and-pitfalls.html#independent-subprocess + env = os.environ.copy() + env["PYINSTALLER_RESET_ENVIRONMENT"] = "1" + else: + env = None + # Open a new process and immediately shutdown the existing one try: args = self._get_restart_args(pages, session, override_args) - proc = subprocess.Popen(args) # pylint: disable=consider-using-with + proc = subprocess.Popen(args, env=env) # pylint: disable=consider-using-with except OSError: log.destroy.exception("Failed to restart") return False diff --git a/qutebrowser/misc/savemanager.py b/qutebrowser/misc/savemanager.py index 6017b3d2a..567cba803 100644 --- a/qutebrowser/misc/savemanager.py +++ b/qutebrowser/misc/savemanager.py @@ -6,7 +6,7 @@ import os.path import collections -from typing import MutableMapping +from collections.abc import MutableMapping from qutebrowser.qt.core import pyqtSlot, QObject, QTimer diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index 35c36f203..881fde19d 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -10,7 +10,8 @@ import itertools import urllib import shutil import pathlib -from typing import Any, Iterable, MutableMapping, MutableSequence, Optional, Union, cast +from typing import Any, Optional, Union, cast +from collections.abc import Iterable, MutableMapping, MutableSequence from qutebrowser.qt.core import Qt, QUrl, QObject, QPoint, QTimer, QDateTime import yaml diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py index b23b862a3..e2140c242 100644 --- a/qutebrowser/misc/sql.py +++ b/qutebrowser/misc/sql.py @@ -9,7 +9,8 @@ import collections import contextlib import dataclasses import types -from typing import Any, Dict, Iterator, List, Mapping, MutableSequence, Optional, Type, Union +from typing import Any, Optional, Union +from collections.abc import Iterator, Mapping, MutableSequence from qutebrowser.qt.core import QObject, pyqtSignal from qutebrowser.qt.sql import QSqlDatabase, QSqlError, QSqlQuery @@ -226,8 +227,8 @@ class Database: """Return a Query instance linked to this Database.""" return Query(self, querystr, forward_only) - def table(self, name: str, fields: List[str], - constraints: Optional[Dict[str, str]] = None, + def table(self, name: str, fields: list[str], + constraints: Optional[dict[str, str]] = None, parent: Optional[QObject] = None) -> 'SqlTable': """Return a SqlTable instance linked to this Database.""" return SqlTable(self, name, fields, constraints, parent) @@ -276,7 +277,7 @@ class Transaction(contextlib.AbstractContextManager): # type: ignore[type-arg] raise_sqlite_error(msg, error) def __exit__(self, - _exc_type: Optional[Type[BaseException]], + _exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], _exc_tb: Optional[types.TracebackType]) -> None: db = self._database.qt_database() @@ -313,7 +314,7 @@ class Query: ok = self.query.prepare(querystr) self._check_ok('prepare', ok) self.query.setForwardOnly(forward_only) - self._placeholders: List[str] = [] + self._placeholders: list[str] = [] def __iter__(self) -> Iterator[Any]: if not self.query.isActive(): @@ -348,7 +349,7 @@ class Query: if None in values: raise BugError("Missing bound values!") - def _bind_values(self, values: Mapping[str, Any]) -> Dict[str, Any]: + def _bind_values(self, values: Mapping[str, Any]) -> dict[str, Any]: self._placeholders = list(values) for key, val in values.items(): self.query.bindValue(f':{key}', val) @@ -404,7 +405,7 @@ class Query: assert rows != -1 return rows - def bound_values(self) -> Dict[str, Any]: + def bound_values(self) -> dict[str, Any]: return { f":{key}": self.query.boundValue(f":{key}") for key in self._placeholders @@ -426,8 +427,8 @@ class SqlTable(QObject): changed = pyqtSignal() database: Database - def __init__(self, database: Database, name: str, fields: List[str], - constraints: Optional[Dict[str, str]] = None, + def __init__(self, database: Database, name: str, fields: list[str], + constraints: Optional[dict[str, str]] = None, parent: Optional[QObject] = None) -> None: """Wrapper over a table in the SQL database. @@ -442,7 +443,7 @@ class SqlTable(QObject): self.database = database self._create_table(fields, constraints) - def _create_table(self, fields: List[str], constraints: Optional[Dict[str, str]], + def _create_table(self, fields: list[str], constraints: Optional[dict[str, str]], *, force: bool = False) -> None: """Create the table if the database is uninitialized. diff --git a/qutebrowser/misc/throttle.py b/qutebrowser/misc/throttle.py index 43325fb08..78bc7f29b 100644 --- a/qutebrowser/misc/throttle.py +++ b/qutebrowser/misc/throttle.py @@ -6,7 +6,8 @@ import dataclasses import time -from typing import Any, Callable, Mapping, Optional, Sequence +from typing import Any, Optional +from collections.abc import Mapping, Sequence, Callable from qutebrowser.qt.core import QObject diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index 6689ad074..548c1e54b 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -8,7 +8,6 @@ import functools import os -import sys import traceback from typing import Optional @@ -114,9 +113,7 @@ def debug_all_objects() -> None: @cmdutils.register(debug=True) def debug_cache_stats() -> None: """Print LRU cache stats.""" - if sys.version_info < (3, 9): - raise cmdutils.CommandError('debugcachestats not supported on python < 3.9') - debugcachestats.debug_cache_stats() # type: ignore[unreachable] + debugcachestats.debug_cache_stats() @cmdutils.register(debug=True) diff --git a/qutebrowser/qt/__init__.py b/qutebrowser/qt/__init__.py index e69de29bb..113e06b0b 100644 --- a/qutebrowser/qt/__init__.py +++ b/qutebrowser/qt/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/qutebrowser/qt/_core_pyqtproperty.py b/qutebrowser/qt/_core_pyqtproperty.py index c2078c403..c2b034df3 100644 --- a/qutebrowser/qt/_core_pyqtproperty.py +++ b/qutebrowser/qt/_core_pyqtproperty.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + """WORKAROUND for missing pyqtProperty typing, ported from PyQt5-stubs: FIXME:mypy PyQt6-stubs issue @@ -5,7 +9,7 @@ https://github.com/python-qt-tools/PyQt5-stubs/blob/5.15.6.0/PyQt5-stubs/QtCore. """ # flake8: noqa -# pylint: disable=invalid-name,missing-class-docstring,too-many-arguments,redefined-builtin,unused-argument +# pylint: disable=invalid-name,missing-class-docstring,too-many-arguments,redefined-builtin,unused-argument,deprecated-typing-alias import typing from PyQt6.QtCore import QObject, pyqtSignal @@ -29,7 +33,7 @@ if typing.TYPE_CHECKING: ) class pyqtProperty: - def __init__( + def __init__( # pylint: disable=too-many-positional-arguments self, type: typing.Union[type, str], fget: typing.Optional[typing.Callable[[QObjectT], TPropertyTypeVal]] = None, diff --git a/qutebrowser/qt/core.py b/qutebrowser/qt/core.py index 87a253218..f6e8b5a93 100644 --- a/qutebrowser/qt/core.py +++ b/qutebrowser/qt/core.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-wildcard-import """Wrapped Qt imports for Qt Core. diff --git a/qutebrowser/qt/dbus.py b/qutebrowser/qt/dbus.py index d3b22a747..81658faf0 100644 --- a/qutebrowser/qt/dbus.py +++ b/qutebrowser/qt/dbus.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-wildcard-import """Wrapped Qt imports for Qt DBus. diff --git a/qutebrowser/qt/gui.py b/qutebrowser/qt/gui.py index dc5fbb23c..5f35f694e 100644 --- a/qutebrowser/qt/gui.py +++ b/qutebrowser/qt/gui.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-wildcard-import,unused-import """Wrapped Qt imports for Qt Gui. diff --git a/qutebrowser/qt/machinery.py b/qutebrowser/qt/machinery.py index 9f45dd6ce..f39fb7d7f 100644 --- a/qutebrowser/qt/machinery.py +++ b/qutebrowser/qt/machinery.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pyright: reportConstantRedefinition=false """Qt wrapper selection. @@ -26,7 +30,7 @@ import argparse import warnings import importlib import dataclasses -from typing import Optional, Dict +from typing import Optional from qutebrowser.utils import log @@ -48,7 +52,7 @@ class Error(Exception): """Base class for all exceptions in this module.""" -class Unavailable(Error, ImportError): +class Unavailable(Error, ModuleNotFoundError): """Raised when a module is unavailable with the given wrapper.""" @@ -102,7 +106,7 @@ class SelectionInfo: """Information about outcomes of importing Qt wrappers.""" wrapper: Optional[str] = None - outcomes: Dict[str, str] = dataclasses.field(default_factory=dict) + outcomes: dict[str, str] = dataclasses.field(default_factory=dict) reason: SelectionReason = SelectionReason.unknown def set_module_error(self, name: str, error: Exception) -> None: diff --git a/qutebrowser/qt/network.py b/qutebrowser/qt/network.py index 7b194affc..dad42f733 100644 --- a/qutebrowser/qt/network.py +++ b/qutebrowser/qt/network.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-wildcard-import """Wrapped Qt imports for Qt Network. diff --git a/qutebrowser/qt/opengl.py b/qutebrowser/qt/opengl.py index bc5a31c11..8191f03d7 100644 --- a/qutebrowser/qt/opengl.py +++ b/qutebrowser/qt/opengl.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-import,unused-wildcard-import """Wrapped Qt imports for Qt OpenGL. diff --git a/qutebrowser/qt/printsupport.py b/qutebrowser/qt/printsupport.py index 08358d417..af0dc1c25 100644 --- a/qutebrowser/qt/printsupport.py +++ b/qutebrowser/qt/printsupport.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-wildcard-import """Wrapped Qt imports for Qt Print Support. diff --git a/qutebrowser/qt/qml.py b/qutebrowser/qt/qml.py index 9202667e2..112003f57 100644 --- a/qutebrowser/qt/qml.py +++ b/qutebrowser/qt/qml.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-wildcard-import """Wrapped Qt imports for Qt QML. diff --git a/qutebrowser/qt/sip.py b/qutebrowser/qt/sip.py index 1eb21bc27..3616b3505 100644 --- a/qutebrowser/qt/sip.py +++ b/qutebrowser/qt/sip.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=wildcard-import,unused-wildcard-import """Wrapped Qt imports for PyQt5.sip/PyQt6.sip. diff --git a/qutebrowser/qt/sql.py b/qutebrowser/qt/sql.py index 4d969936b..ea617668c 100644 --- a/qutebrowser/qt/sql.py +++ b/qutebrowser/qt/sql.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-wildcard-import """Wrapped Qt imports for Qt SQL. diff --git a/qutebrowser/qt/test.py b/qutebrowser/qt/test.py index 3c1bcfdff..2ec4488ae 100644 --- a/qutebrowser/qt/test.py +++ b/qutebrowser/qt/test.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-wildcard-import """Wrapped Qt imports for Qt Test. diff --git a/qutebrowser/qt/webenginecore.py b/qutebrowser/qt/webenginecore.py index afd76e38c..026e9af32 100644 --- a/qutebrowser/qt/webenginecore.py +++ b/qutebrowser/qt/webenginecore.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-wildcard-import,unused-import """Wrapped Qt imports for Qt WebEngine Core. diff --git a/qutebrowser/qt/webenginewidgets.py b/qutebrowser/qt/webenginewidgets.py index b8833e9c8..a6d512fd6 100644 --- a/qutebrowser/qt/webenginewidgets.py +++ b/qutebrowser/qt/webenginewidgets.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-wildcard-import """Wrapped Qt imports for Qt WebEngine Widgets. @@ -27,6 +31,7 @@ else: if machinery.IS_QT5: + # pylint: disable=undefined-variable # moved to WebEngineCore in Qt 6 del QWebEngineSettings del QWebEngineProfile diff --git a/qutebrowser/qt/webkit.py b/qutebrowser/qt/webkit.py index c4b0bb7ae..79aa9dba1 100644 --- a/qutebrowser/qt/webkit.py +++ b/qutebrowser/qt/webkit.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=wildcard-import """Wrapped Qt imports for Qt WebKit. diff --git a/qutebrowser/qt/webkitwidgets.py b/qutebrowser/qt/webkitwidgets.py index 5b790dcc7..a040a45f8 100644 --- a/qutebrowser/qt/webkitwidgets.py +++ b/qutebrowser/qt/webkitwidgets.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=wildcard-import,no-else-raise """Wrapped Qt imports for Qt WebKit Widgets. diff --git a/qutebrowser/qt/widgets.py b/qutebrowser/qt/widgets.py index eac8cafbb..1e77412f4 100644 --- a/qutebrowser/qt/widgets.py +++ b/qutebrowser/qt/widgets.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + # pylint: disable=import-error,wildcard-import,unused-wildcard-import """Wrapped Qt imports for Qt Widgets. @@ -26,4 +30,5 @@ else: raise machinery.UnknownWrapper() if machinery.IS_QT5: + # pylint: disable=undefined-variable del QFileSystemModel # moved to QtGui in Qt 6 diff --git a/qutebrowser/utils/debug.py b/qutebrowser/utils/debug.py index 433e2274f..de7f87f1e 100644 --- a/qutebrowser/utils/debug.py +++ b/qutebrowser/utils/debug.py @@ -12,7 +12,8 @@ import functools import datetime import types from typing import ( - Any, Callable, List, Mapping, MutableSequence, Optional, Sequence, Type, Union) + Any, Optional, Union) +from collections.abc import Mapping, MutableSequence, Sequence, Callable from qutebrowser.qt.core import Qt, QEvent, QMetaMethod, QObject, pyqtBoundSignal @@ -21,7 +22,7 @@ from qutebrowser.misc import objects from qutebrowser.qt import sip, machinery -def log_events(klass: Type[QObject]) -> Type[QObject]: +def log_events(klass: type[QObject]) -> type[QObject]: """Class decorator to log Qt events.""" old_event = klass.event @@ -38,7 +39,7 @@ def log_events(klass: Type[QObject]) -> Type[QObject]: return klass -def log_signals(obj: QObject) -> QObject: +def log_signals(obj: Union[QObject, type[QObject]]) -> Union[QObject, type[QObject]]: """Log all signals of an object or class. Can be used as class decorator. @@ -80,6 +81,7 @@ def log_signals(obj: QObject) -> QObject: obj.__init__ = new_init else: + assert isinstance(obj, QObject) connect_log_slot(obj) return obj @@ -93,7 +95,7 @@ else: def _qenum_key_python( value: _EnumValueType, - klass: Type[_EnumValueType], + klass: type[_EnumValueType], ) -> Optional[str]: """New-style PyQt6: Try getting value from Python enum.""" if isinstance(value, enum.Enum) and value.name: @@ -113,9 +115,9 @@ def _qenum_key_python( def _qenum_key_qt( - base: Type[sip.simplewrapper], + base: type[sip.simplewrapper], value: _EnumValueType, - klass: Type[_EnumValueType], + klass: type[_EnumValueType], ) -> Optional[str]: # On PyQt5, or PyQt6 with int passed: Try to ask Qt's introspection. # However, not every Qt enum value has a staticMetaObject @@ -138,9 +140,9 @@ def _qenum_key_qt( def qenum_key( - base: Type[sip.simplewrapper], + base: type[sip.simplewrapper], value: _EnumValueType, - klass: Type[_EnumValueType] = None, + klass: type[_EnumValueType] = None, ) -> str: """Convert a Qt Enum value to its key as a string. @@ -172,9 +174,9 @@ def qenum_key( return '0x{:04x}'.format(int(value)) # type: ignore[arg-type] -def qflags_key(base: Type[sip.simplewrapper], +def qflags_key(base: type[sip.simplewrapper], value: _EnumValueType, - klass: Type[_EnumValueType] = None) -> str: + klass: type[_EnumValueType] = None) -> str: """Convert a Qt QFlags value to its keys as string. Note: Passing a combined value (such as Qt.AlignmentFlag.AlignCenter) will get the names @@ -324,7 +326,7 @@ class log_time: # noqa: N801,N806 pylint: disable=invalid-name self._started = datetime.datetime.now() def __exit__(self, - _exc_type: Optional[Type[BaseException]], + _exc_type: Optional[type[BaseException]], _exc_val: Optional[BaseException], _exc_tb: Optional[types.TracebackType]) -> None: assert self._started is not None @@ -371,7 +373,7 @@ def get_all_objects(start_obj: QObject = None) -> str: if start_obj is None: start_obj = objects.qapp - pyqt_lines: List[str] = [] + pyqt_lines: list[str] = [] _get_pyqt_objects(pyqt_lines, start_obj) pyqt_lines = [' ' + e for e in pyqt_lines] pyqt_lines.insert(0, 'Qt objects - {} objects:'.format(len(pyqt_lines))) diff --git a/qutebrowser/utils/docutils.py b/qutebrowser/utils/docutils.py index 6cd16730c..c357a2cd4 100644 --- a/qutebrowser/utils/docutils.py +++ b/qutebrowser/utils/docutils.py @@ -10,7 +10,8 @@ import inspect import os.path import collections import enum -from typing import Any, Callable, MutableMapping, Optional, List, Union +from typing import Any, Optional, Union +from collections.abc import MutableMapping, Callable import qutebrowser from qutebrowser.utils import log, utils @@ -81,10 +82,10 @@ class DocstringParser: """ self._state = self.State.short self._cur_arg_name: Optional[str] = None - self._short_desc_parts: List[str] = [] - self._long_desc_parts: List[str] = [] + self._short_desc_parts: list[str] = [] + self._long_desc_parts: list[str] = [] self.arg_descs: MutableMapping[ - str, Union[str, List[str]]] = collections.OrderedDict() + str, Union[str, list[str]]] = collections.OrderedDict() doc = inspect.getdoc(func) handlers = { self.State.short: self._parse_short, diff --git a/qutebrowser/utils/error.py b/qutebrowser/utils/error.py index 010970861..10dad90f7 100644 --- a/qutebrowser/utils/error.py +++ b/qutebrowser/utils/error.py @@ -11,11 +11,11 @@ from qutebrowser.utils import log, utils def _get_name(exc: BaseException) -> str: """Get a suitable exception name as a string.""" - prefixes = ['qutebrowser', 'builtins'] + prefixes = ['qutebrowser.', 'builtins.'] name = utils.qualname(exc.__class__) for prefix in prefixes: if name.startswith(prefix): - name = name[len(prefix) + 1:] + name = name.removeprefix(prefix) break return name diff --git a/qutebrowser/utils/javascript.py b/qutebrowser/utils/javascript.py index 9890be446..66470155a 100644 --- a/qutebrowser/utils/javascript.py +++ b/qutebrowser/utils/javascript.py @@ -4,7 +4,8 @@ """Utilities related to javascript interaction.""" -from typing import Sequence, Union +from typing import Union +from collections.abc import Sequence _InnerJsArgType = Union[None, str, bool, int, float] _JsArgType = Union[_InnerJsArgType, Sequence[_InnerJsArgType]] diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py index d7c261942..c12bac5aa 100644 --- a/qutebrowser/utils/jinja.py +++ b/qutebrowser/utils/jinja.py @@ -10,7 +10,8 @@ import posixpath import functools import contextlib import html -from typing import Any, Callable, FrozenSet, Iterator, List, Set, Tuple +from typing import Any +from collections.abc import Iterator, Callable import jinja2 import jinja2.nodes @@ -54,7 +55,7 @@ class Loader(jinja2.BaseLoader): self, _env: jinja2.Environment, template: str - ) -> Tuple[str, str, Callable[[], bool]]: + ) -> tuple[str, str, Callable[[], bool]]: path = os.path.join(self._subdir, template) try: source = resources.read_file(path) @@ -128,10 +129,10 @@ js_environment = jinja2.Environment(loader=Loader('javascript')) @debugcachestats.register() @functools.lru_cache -def template_config_variables(template: str) -> FrozenSet[str]: +def template_config_variables(template: str) -> frozenset[str]: """Return the config variables used in the template.""" - unvisted_nodes: List[jinja2.nodes.Node] = [environment.parse(template)] - result: Set[str] = set() + unvisted_nodes: list[jinja2.nodes.Node] = [environment.parse(template)] + result: set[str] = set() while unvisted_nodes: node = unvisted_nodes.pop() if not isinstance(node, jinja2.nodes.Getattr): @@ -140,7 +141,7 @@ def template_config_variables(template: str) -> FrozenSet[str]: # List of attribute names in reverse order. # For example it's ['ab', 'c', 'd'] for 'conf.d.c.ab'. - attrlist: List[str] = [] + attrlist: list[str] = [] while isinstance(node, jinja2.nodes.Getattr): attrlist.append(node.attr) node = node.node diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index 9695ec5a2..01701b3b5 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -15,8 +15,9 @@ import warnings import json import inspect import argparse -from typing import (TYPE_CHECKING, Any, Iterator, Mapping, MutableSequence, - Optional, Set, Tuple, Union, TextIO, Literal, cast) +from typing import (TYPE_CHECKING, Any, + Optional, Union, TextIO, Literal, cast) +from collections.abc import Iterator, Mapping, MutableSequence # NOTE: This is a Qt-free zone! All imports related to Qt logging should be done in # qutebrowser.utils.qtlog (see https://github.com/qutebrowser/qutebrowser/issues/7769). @@ -240,7 +241,7 @@ def _init_handlers( force_color: bool, json_logging: bool, ram_capacity: int -) -> Tuple["logging.StreamHandler[TextIO]", Optional['RAMHandler']]: +) -> tuple[Optional["logging.StreamHandler[TextIO]"], Optional['RAMHandler']]: """Init log handlers. Args: @@ -255,7 +256,7 @@ def _init_handlers( level, color, force_color, json_logging) if sys.stderr is None: - console_handler = None # type: ignore[unreachable] + console_handler = None else: strip = False if force_color else None if use_colorama: @@ -293,9 +294,13 @@ def _init_formatters( level: int, color: bool, force_color: bool, - json_logging: bool -) -> Tuple[Union['JSONFormatter', 'ColoredFormatter'], - 'ColoredFormatter', 'HTMLFormatter', bool]: + json_logging: bool, +) -> tuple[ + Union['JSONFormatter', 'ColoredFormatter', None], + 'ColoredFormatter', + 'HTMLFormatter', + bool, +]: """Init log formatters. Args: @@ -318,7 +323,7 @@ def _init_formatters( use_colorama = False if sys.stderr is None: - console_formatter = None # type: ignore[unreachable] + console_formatter = None return console_formatter, ram_formatter, html_formatter, use_colorama if json_logging: @@ -392,7 +397,7 @@ class InvalidLogFilterError(Exception): """Raised when an invalid filter string is passed to LogFilter.parse().""" - def __init__(self, names: Set[str]): + def __init__(self, names: set[str]): invalid = names - set(LOGGER_NAMES) super().__init__("Invalid log category {} - valid categories: {}" .format(', '.join(sorted(invalid)), @@ -413,7 +418,7 @@ class LogFilter(logging.Filter): than debug. """ - def __init__(self, names: Set[str], *, negated: bool = False, + def __init__(self, names: set[str], *, negated: bool = False, only_debug: bool = True) -> None: super().__init__() self.names = names @@ -547,7 +552,7 @@ class ColoredFormatter(logging.Formatter): log_color = LOG_COLORS[record.levelname] color_dict['log_color'] = COLOR_ESCAPES[log_color] else: - color_dict = {color: '' for color in COLOR_ESCAPES} + color_dict = dict.fromkeys(COLOR_ESCAPES, "") color_dict['reset'] = '' color_dict['log_color'] = '' record.__dict__.update(color_dict) diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py index 275ed2f3d..8fc8f6fbe 100644 --- a/qutebrowser/utils/message.py +++ b/qutebrowser/utils/message.py @@ -10,7 +10,8 @@ import dataclasses import traceback -from typing import Any, Callable, Iterable, List, Union, Optional +from typing import Any, Union, Optional +from collections.abc import Iterable, Callable from qutebrowser.qt.core import pyqtSignal, pyqtBoundSignal, QObject @@ -239,7 +240,7 @@ class GlobalMessageBridge(QObject): def __init__(self, parent: QObject = None) -> None: super().__init__(parent) self._connected = False - self._cache: List[MessageInfo] = [] + self._cache: list[MessageInfo] = [] def ask(self, question: usertypes.Question, blocking: bool, *, diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py index c0715d90a..c027b3cf6 100644 --- a/qutebrowser/utils/objreg.py +++ b/qutebrowser/utils/objreg.py @@ -7,8 +7,9 @@ import collections import functools -from typing import (TYPE_CHECKING, Any, Callable, MutableMapping, MutableSequence, - Optional, Sequence, Union) +from typing import (TYPE_CHECKING, Any, + Optional, Union) +from collections.abc import MutableMapping, MutableSequence, Sequence, Callable from qutebrowser.qt.core import QObject, QTimer from qutebrowser.qt.widgets import QApplication @@ -77,7 +78,7 @@ class ObjectRegistry(collections.UserDict): # type: ignore[type-arg] super().__setitem__(name, obj) - def __delitem__(self, name: str) -> None: + def __delitem__(self, name: _IndexType) -> None: """Extend __delitem__ to disconnect the destroyed signal.""" self._disconnect_destroyed(name) super().__delitem__(name) @@ -101,7 +102,7 @@ class ObjectRegistry(collections.UserDict): # type: ignore[type-arg] pass del partial_objs[name] - def on_destroyed(self, name: str) -> None: + def on_destroyed(self, name: _IndexType) -> None: """Schedule removing of a destroyed QObject. We don't remove the destroyed object immediately because it might still @@ -111,7 +112,7 @@ class ObjectRegistry(collections.UserDict): # type: ignore[type-arg] log.destroy.debug("schedule removal: {}".format(name)) QTimer.singleShot(0, functools.partial(self._on_destroyed, name)) - def _on_destroyed(self, name: str) -> None: + def _on_destroyed(self, name: _IndexType) -> None: """Remove a destroyed QObject.""" log.destroy.debug("removed: {}".format(name)) if not hasattr(self, 'data'): @@ -240,6 +241,7 @@ def get(name: str, def register(name: str, obj: Any, + *, update: bool = False, scope: str = None, registry: ObjectRegistry = None, diff --git a/qutebrowser/utils/qtlog.py b/qutebrowser/utils/qtlog.py index 78b48ebee..215123f4a 100644 --- a/qutebrowser/utils/qtlog.py +++ b/qutebrowser/utils/qtlog.py @@ -10,7 +10,8 @@ import faulthandler import logging import sys import traceback -from typing import Iterator, Optional +from typing import Optional +from collections.abc import Iterator from qutebrowser.qt import core as qtcore from qutebrowser.utils import log diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py index 21f3b8478..a027db74a 100644 --- a/qutebrowser/utils/qtutils.py +++ b/qutebrowser/utils/qtutils.py @@ -18,8 +18,9 @@ import enum import pathlib import operator import contextlib -from typing import (Any, TYPE_CHECKING, BinaryIO, IO, Iterator, Literal, - Optional, Union, Tuple, Protocol, cast, overload, TypeVar) +from typing import (Any, TYPE_CHECKING, BinaryIO, IO, Literal, + Optional, Union, Protocol, cast, overload, TypeVar) +from collections.abc import Iterator from qutebrowser.qt import machinery, sip from qutebrowser.qt.core import (qVersion, QEventLoop, QDataStream, QByteArray, @@ -33,7 +34,6 @@ except ImportError: # pragma: no cover if TYPE_CHECKING: from qutebrowser.qt.webkit import QWebHistory from qutebrowser.qt.webenginecore import QWebEngineHistory - from typing_extensions import TypeGuard # added in Python 3.10 from qutebrowser.misc import objects from qutebrowser.utils import usertypes, utils @@ -193,14 +193,15 @@ def check_qdatastream(stream: QDataStream) -> None: QDataStream.Status.WriteFailed: ("The data stream cannot write to the " "underlying device."), } - try: - status_to_str[QDataStream.Status.SizeLimitExceeded] = ( # type: ignore[attr-defined] - "The data stream cannot read or write the data because its size is larger " - "than supported by the current platform." - ) - except AttributeError: - # Added in Qt 6.7 - pass + if machinery.IS_QT6: + try: + status_to_str[QDataStream.Status.SizeLimitExceeded] = ( + "The data stream cannot read or write the data because its size is larger " + "than supported by the current platform." + ) + except AttributeError: + # Added in Qt 6.7 + pass if stream.status() != QDataStream.Status.Ok: raise OSError(status_to_str[stream.status()]) @@ -528,9 +529,11 @@ class EventLoop(QEventLoop): 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]: +def _get_color_percentage( # pylint: disable=too-many-positional-arguments + 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: diff --git a/qutebrowser/utils/resources.py b/qutebrowser/utils/resources.py index a40f9d2bd..35fd62f75 100644 --- a/qutebrowser/utils/resources.py +++ b/qutebrowser/utils/resources.py @@ -9,23 +9,19 @@ import sys import contextlib import posixpath import pathlib -from typing import Iterator, Iterable, Union, Dict +import importlib.resources +from typing import Union +from collections.abc import Iterator, Iterable - -# We cannot use the stdlib version on 3.8 because we need the files() API. if sys.version_info >= (3, 11): # pragma: no cover # https://github.com/python/cpython/issues/90276 - import importlib.resources as importlib_resources from importlib.resources.abc import Traversable -elif sys.version_info >= (3, 9): - import importlib.resources as importlib_resources +else: from importlib.abc import Traversable -else: # pragma: no cover - import importlib_resources - from importlib_resources.abc import Traversable import qutebrowser -_cache: Dict[str, str] = {} +_cache: dict[str, str] = {} +_bin_cache: dict[str, bytes] = {} _ResourceType = Union[Traversable, pathlib.Path] @@ -36,7 +32,7 @@ def _path(filename: str) -> _ResourceType: assert not posixpath.isabs(filename), filename assert os.path.pardir not in filename.split(posixpath.sep), filename - return importlib_resources.files(qutebrowser) / filename + return importlib.resources.files(qutebrowser) / filename @contextlib.contextmanager def _keyerror_workaround() -> Iterator[None]: @@ -45,7 +41,7 @@ def _keyerror_workaround() -> Iterator[None]: WORKAROUND for zipfile.Path resources raising KeyError when a file was notfound: https://bugs.python.org/issue43063 - Only needed for Python 3.8 and 3.9. + Only needed for Python 3.9. """ try: yield @@ -70,7 +66,7 @@ def _glob( assert isinstance(glob_path, pathlib.Path) for full_path in glob_path.glob(f'*{ext}'): # . is contained in ext yield full_path.relative_to(resource_path).as_posix() - else: # zipfile.Path or other importlib_resources.abc.Traversable + else: # zipfile.Path or other importlib.resources.abc.Traversable assert glob_path.is_dir(), glob_path for subpath in glob_path.iterdir(): if subpath.name.endswith(ext): @@ -88,6 +84,10 @@ def preload() -> None: for name in _glob(resource_path, subdir, ext): _cache[name] = read_file(name) + for name in _glob(resource_path, 'img', '.png'): + # e.g. broken_qutebrowser_logo.png + _bin_cache[name] = read_file_binary(name) + def read_file(filename: str) -> str: """Get the contents of a file contained with qutebrowser. @@ -115,6 +115,9 @@ def read_file_binary(filename: str) -> bytes: Return: The file contents as a bytes object. """ + if filename in _bin_cache: + return _bin_cache[filename] + path = _path(filename) with _keyerror_workaround(): return path.read_bytes() diff --git a/qutebrowser/utils/standarddir.py b/qutebrowser/utils/standarddir.py index 026376dc2..b82845a96 100644 --- a/qutebrowser/utils/standarddir.py +++ b/qutebrowser/utils/standarddir.py @@ -10,7 +10,9 @@ import sys import contextlib import enum import argparse -from typing import Iterator, Optional, Dict +import tempfile +from typing import Optional +from collections.abc import Iterator from qutebrowser.qt.core import QStandardPaths from qutebrowser.qt.widgets import QApplication @@ -18,7 +20,7 @@ from qutebrowser.qt.widgets import QApplication from qutebrowser.utils import log, debug, utils, version, qtutils # The cached locations -_locations: Dict["_Location", str] = {} +_locations: dict["_Location", str] = {} class _Location(enum.Enum): @@ -311,14 +313,15 @@ def _create(path: str) -> None: 0700. If the destination directory exists already the permissions should not be changed. """ - if APPNAME == 'qute_test' and path.startswith('/home'): # pragma: no cover - for k, v in os.environ.items(): - if k == 'HOME' or k.startswith('XDG_'): - log.init.debug(f"{k} = {v}") - raise AssertionError( - "Trying to create directory inside /home during " - "tests, this should not happen." - ) + if APPNAME == 'qute_test': + if path.startswith('/home') and not path.startswith(tempfile.gettempdir()): # pragma: no cover + for k, v in os.environ.items(): + if k == 'HOME' or k.startswith('XDG_'): + log.init.debug(f"{k} = {v}") + raise AssertionError( + "Trying to create directory inside /home during " + "tests, this should not happen." + ) os.makedirs(path, 0o700, exist_ok=True) diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index 620e4d143..1a558f307 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -5,7 +5,7 @@ """A Chromium-like URL matching pattern. See: -https://developer.chrome.com/apps/match_patterns +https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns https://cs.chromium.org/chromium/src/extensions/common/url_pattern.cc https://cs.chromium.org/chromium/src/extensions/common/url_pattern.h @@ -17,7 +17,7 @@ https://chromium.googlesource.com/chromium/src/+/6f4a6681eae01c2036336c18b06303e import ipaddress import fnmatch import urllib.parse -from typing import Any, Optional, Tuple +from typing import Any, Optional from qutebrowser.qt.core import QUrl @@ -89,7 +89,7 @@ class UrlPattern: self._init_path(parsed) self._init_port(parsed) - def _to_tuple(self) -> Tuple[ + def _to_tuple(self) -> tuple[ bool, # _match_all bool, # _match_subdomains Optional[str], # _scheme @@ -128,7 +128,7 @@ class UrlPattern: # FIXME This doesn't actually strip the hostname correctly. if (pattern.startswith('file://') and not pattern.startswith('file:///')): - pattern = 'file:///' + pattern[len("file://"):] + pattern = 'file:///' + pattern.removeprefix("file://") return pattern diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index 7b613c0a2..839fdbe84 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -11,10 +11,11 @@ import ipaddress import posixpath import urllib.parse import mimetypes -from typing import Optional, Tuple, Union, Iterable, cast +from typing import Optional, Union, cast +from collections.abc import Iterable from qutebrowser.qt import machinery -from qutebrowser.qt.core import QUrl +from qutebrowser.qt.core import QUrl, QUrlQuery from qutebrowser.qt.network import QHostInfo, QHostAddress, QNetworkProxy from qutebrowser.api import cmdutils @@ -111,7 +112,7 @@ class InvalidUrlError(Error): super().__init__(self.msg) -def _parse_search_term(s: str) -> Tuple[Optional[str], Optional[str]]: +def _parse_search_term(s: str) -> tuple[Optional[str], Optional[str]]: """Get a search engine name and search term from a string. Args: @@ -464,7 +465,7 @@ def filename_from_url(url: QUrl, fallback: str = None) -> Optional[str]: return fallback -HostTupleType = Tuple[str, str, int] +HostTupleType = tuple[str, str, int] def host_tuple(url: QUrl) -> HostTupleType: @@ -553,8 +554,8 @@ def same_domain(url1: QUrl, url2: QUrl) -> bool: if suffix1 != suffix2: return False - domain1 = url1.host()[:-len(suffix1)].split('.')[-1] - domain2 = url2.host()[:-len(suffix2)].split('.')[-1] + domain1 = url1.host().removesuffix(suffix1).split('.')[-1] + domain2 = url2.host().removesuffix(suffix2).split('.')[-1] return domain1 == domain2 @@ -668,7 +669,7 @@ def parse_javascript_url(url: QUrl) -> str: urlstr = url.toString(FormatOption.ENCODED) urlstr = urllib.parse.unquote(urlstr) - code = urlstr[len('javascript:'):] + code = urlstr.removeprefix('javascript:') if not code: raise Error("Resulted in empty JavaScript code") @@ -682,3 +683,25 @@ def widened_hostnames(hostname: str) -> Iterable[str]: while hostname: yield hostname hostname = hostname.partition(".")[-1] + + +def get_url_yank_text(url: QUrl, *, pretty: bool) -> str: + """Get the text that should be yanked for the given URL.""" + flags = FormatOption.REMOVE_PASSWORD + if url.scheme() == 'mailto': + flags |= FormatOption.REMOVE_SCHEME + if pretty: + flags |= FormatOption.DECODE_RESERVED + else: + flags |= FormatOption.ENCODED + + url_query = QUrlQuery() + url_query_str = url.query() + if '&' not in url_query_str and ';' in url_query_str: + url_query.setQueryDelimiters('=', ';') + url_query.setQuery(url_query_str) + for key in dict(url_query.queryItems()): + if key in config.val.url.yank_ignored_parameters: + url_query.removeQueryItem(key) + url.setQuery(url_query) + return url.toString(flags) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 95b1f3a01..c8e92bf17 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -7,8 +7,11 @@ import html import operator import enum +import time import dataclasses -from typing import Optional, Sequence, TypeVar, Union +import logging +from typing import Optional, TypeVar, Union +from collections.abc import Sequence from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QObject, QTimer from qutebrowser.qt.core import QUrl @@ -443,6 +446,8 @@ class Timer(QTimer): def __init__(self, parent: QObject = None, name: str = None) -> None: super().__init__(parent) + self._start_time: Optional[float] = None + self.timeout.connect(self._validity_check_handler) if name is None: self._name = "unnamed" else: @@ -452,6 +457,39 @@ class Timer(QTimer): def __repr__(self) -> str: return utils.get_repr(self, name=self._name) + @pyqtSlot() + def _validity_check_handler(self) -> None: + if not self.check_timeout_validity() and self._start_time is not None: + elapsed = time.monotonic() - self._start_time + level = logging.WARNING + if utils.is_windows and self._name == "ipc-timeout": + level = logging.DEBUG + log.misc.log( + level, + ( + f"Timer {self._name} (id {self.timerId()}) triggered too early: " + f"interval {self.interval()} but only {elapsed:.3f}s passed" + ) + ) + + def check_timeout_validity(self) -> bool: + """Check to see if the timeout signal was fired at the expected time. + + WORKAROUND for https://bugreports.qt.io/browse/QTBUG-124496 + """ + if self._start_time is None: + # manual emission? + return True + + elapsed = time.monotonic() - self._start_time + # Checking for half the interval is pretty arbitrary. In the bug case + # the timer typically fires immediately since the expiry event is + # already pending when it is created. + if elapsed < self.interval() / 1000 / 2: + return False + + return True + def setInterval(self, msec: int) -> None: """Extend setInterval to check for overflows.""" qtutils.check_overflow(msec, 'int') @@ -459,6 +497,7 @@ class Timer(QTimer): def start(self, msec: int = None) -> None: """Extend start to check for overflows.""" + self._start_time = time.monotonic() if msec is not None: qtutils.check_overflow(msec, 'int') super().start(msec) diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 11c160c9e..9c506471d 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -17,10 +17,12 @@ import traceback import functools import contextlib import shlex +import sysconfig import mimetypes -from typing import (Any, Callable, IO, Iterator, - Optional, Sequence, Tuple, List, Type, Union, +from typing import (Any, IO, + Optional, Union, TypeVar, Protocol) +from collections.abc import Iterator, Sequence, Callable from qutebrowser.qt.core import QUrl, QVersionNumber, QRect, QPoint from qutebrowser.qt.gui import QClipboard, QDesktopServices @@ -264,16 +266,16 @@ def fake_io(write_func: Callable[[str], int]) -> Iterator[None]: old_stderr = sys.stderr fake_stderr = FakeIOStream(write_func) fake_stdout = FakeIOStream(write_func) - sys.stderr = fake_stderr # type: ignore[assignment] - sys.stdout = fake_stdout # type: ignore[assignment] + sys.stderr = fake_stderr + sys.stdout = fake_stdout try: yield finally: # If the code we did run did change sys.stdout/sys.stderr, we leave it # unchanged. Otherwise, we reset it. - if sys.stdout is fake_stdout: # type: ignore[comparison-overlap] + if sys.stdout is fake_stdout: sys.stdout = old_stdout - if sys.stderr is fake_stderr: # type: ignore[comparison-overlap] + if sys.stderr is fake_stderr: sys.stderr = old_stderr @@ -406,7 +408,7 @@ def qualname(obj: Any) -> str: return repr(obj) -_ExceptionType = Union[Type[BaseException], Tuple[Type[BaseException]]] +_ExceptionType = Union[type[BaseException], tuple[type[BaseException]]] def raises(exc: _ExceptionType, func: Callable[..., Any], *args: Any) -> bool: @@ -636,7 +638,7 @@ def expand_windows_drive(path: str) -> str: path: The path to expand. """ # Usually, "E:" on Windows refers to the current working directory on drive - # E:\. The correct way to specifify drive E: is "E:\", but most users + # E:\. The correct way to specify drive E: is "E:\", but most users # probably don't use the "multiple working directories" feature and expect # "E:" and "E:\" to be equal. if re.fullmatch(r'[A-Z]:', path, re.IGNORECASE): @@ -666,7 +668,10 @@ def yaml_load(f: Union[str, IO[str]]) -> Any: end = datetime.datetime.now() delta = (end - start).total_seconds() - deadline = 10 if 'CI' in os.environ else 2 + if "CI" in os.environ or sysconfig.get_config_var("Py_DEBUG"): + deadline = 10 + else: + deadline = 2 if delta > deadline: # pragma: no cover log.misc.warning( "YAML load took unusually long, please report this at " @@ -772,14 +777,38 @@ def mimetype_extension(mimetype: str) -> Optional[str]: This mostly delegates to Python's mimetypes.guess_extension(), but backports some changes (via a simple override dict) which are missing from earlier Python versions. - Most likely, this can be dropped once the minimum Python version is raised to 3.10. """ - overrides = { - # Added in 3.10 - "application/x-hdf5": ".h5", - # Added around 3.8 - "application/manifest+json": ".webmanifest", - } + overrides = {} + if sys.version_info[:2] < (3, 13): + overrides.update({ + "text/rtf": ".rtf", + "text/markdown": ".md", + "text/x-rst": ".rst", + }) + if sys.version_info[:2] < (3, 12): + overrides.update({ + "text/javascript": ".js", + }) + if sys.version_info[:2] < (3, 11): + overrides.update({ + "application/n-quads": ".nq", + "application/n-triples": ".nt", + "application/trig": ".trig", + "image/avif": ".avif", + "image/webp": ".webp", + "text/n3": ".n3", + "text/vtt": ".vtt", + }) + if sys.version_info[:2] < (3, 10): + overrides.update({ + "application/x-hdf5": ".h5", + "audio/3gpp": ".3gp", + "audio/3gpp2": ".3g2", + "audio/aac": ".aac", + "audio/opus": ".opus", + "image/heic": ".heic", + "image/heif": ".heif", + }) if mimetype in overrides: return overrides[mimetype] return mimetypes.guess_extension(mimetype, strict=False) @@ -842,7 +871,7 @@ def parse_point(s: str) -> QPoint: raise ValueError(e) -def match_globs(patterns: List[str], value: str) -> Optional[str]: +def match_globs(patterns: list[str], value: str) -> Optional[str]: """Match a list of glob-like patterns against a value. Return: diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 32d5357db..5a88b8c2b 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -19,8 +19,9 @@ import getpass import functools import dataclasses import importlib.metadata -from typing import (Mapping, Optional, Sequence, Tuple, ClassVar, Dict, Any, +from typing import (Optional, ClassVar, Any, TYPE_CHECKING) +from collections.abc import Mapping, Sequence from qutebrowser.qt import machinery from qutebrowser.qt.core import PYQT_VERSION_STR @@ -105,7 +106,7 @@ class Distribution(enum.Enum): solus = enum.auto() -def _parse_os_release() -> Optional[Dict[str, str]]: +def _parse_os_release() -> Optional[dict[str, str]]: """Parse an /etc/os-release file.""" filename = os.environ.get('QUTE_FAKE_OS_RELEASE', '/etc/os-release') info = {} @@ -250,7 +251,7 @@ def _git_str_subprocess(gitpath: str) -> Optional[str]: return None -def _release_info() -> Sequence[Tuple[str, str]]: +def _release_info() -> Sequence[tuple[str, str]]: """Try to gather distribution release information. Return: @@ -379,7 +380,7 @@ class ModuleInfo: return text -def _create_module_info() -> Dict[str, ModuleInfo]: +def _create_module_info() -> dict[str, ModuleInfo]: packages = [ ('colorama', ['VERSION', '__version__']), ('jinja2', ['__version__']), @@ -486,7 +487,7 @@ def _pdfjs_version() -> str: else: pdfjs_file = pdfjs_file.decode('utf-8') version_re = re.compile( - r"^ *(PDFJS\.version|(var|const) pdfjsVersion) = '(?P[^']+)';$", + r"""^ *(PDFJS\.version|(var|const) pdfjsVersion) = ['"](?P[^'"]+)['"];$""", re.MULTILINE) match = version_re.search(pdfjs_file) @@ -538,7 +539,23 @@ class WebEngineVersions: chromium_security: Optional[str] = None chromium_major: Optional[int] = dataclasses.field(init=False) - _CHROMIUM_VERSIONS: ClassVar[Dict[utils.VersionNumber, str]] = { + # Dates based on https://chromium.googlesource.com/chromium/src/+refs + _BASES: ClassVar[dict[int, str]] = { + 83: '83.0.4103.122', # 2020-06-27, Qt 5.15.2 + 87: '87.0.4280.144', # 2021-01-08, Qt 5.15 + 90: '90.0.4430.228', # 2021-06-22, Qt 6.2 + 94: '94.0.4606.126', # 2021-11-17, Qt 6.3 + 102: '102.0.5005.177', # 2022-09-01, Qt 6.4 + # (.220 claimed by code, .181 claimed by CHROMIUM_VERSION) + 108: '108.0.5359.220', # 2023-01-27, Qt 6.5 + 112: '112.0.5615.213', # 2023-05-24, Qt 6.6 + 118: '118.0.5993.220', # 2024-01-25, Qt 6.7 + 122: '122.0.6261.171', # 2024-04-15, Qt 6.8 + 130: '130.0.6723.192', # 2025-01-06, Qt 6.9 + } + + # Dates based on https://chromereleases.googleblog.com/ + _CHROMIUM_VERSIONS: ClassVar[dict[utils.VersionNumber, tuple[str, Optional[str]]]] = { # ====== UNSUPPORTED ===== # Qt 5.12: Chromium 69 @@ -559,73 +576,72 @@ class WebEngineVersions: # 5.15.1: Security fixes up to 85.0.4183.83 (2020-08-25) # ====== SUPPORTED ===== + # base security + ## Qt 5.15 + utils.VersionNumber(5, 15, 2): (_BASES[83], '86.0.4240.183'), # 2020-11-02 + utils.VersionNumber(5, 15): (_BASES[87], None), # >= 5.15.3 + utils.VersionNumber(5, 15, 3): (_BASES[87], '88.0.4324.150'), # 2021-02-04 + # 5.15.4 to 5.15.6: unknown security fixes + utils.VersionNumber(5, 15, 7): (_BASES[87], '94.0.4606.61'), # 2021-09-24 + utils.VersionNumber(5, 15, 8): (_BASES[87], '96.0.4664.110'), # 2021-12-13 + utils.VersionNumber(5, 15, 9): (_BASES[87], '98.0.4758.102'), # 2022-02-14 + utils.VersionNumber(5, 15, 10): (_BASES[87], '98.0.4758.102'), # (?) 2022-02-14 + utils.VersionNumber(5, 15, 11): (_BASES[87], '98.0.4758.102'), # (?) 2022-02-14 + utils.VersionNumber(5, 15, 12): (_BASES[87], '98.0.4758.102'), # (?) 2022-02-14 + utils.VersionNumber(5, 15, 13): (_BASES[87], '108.0.5359.124'), # 2022-12-13 + utils.VersionNumber(5, 15, 14): (_BASES[87], '113.0.5672.64'), # 2023-05-02 + # 5.15.15: unknown security fixes + utils.VersionNumber(5, 15, 16): (_BASES[87], '119.0.6045.123'), # 2023-11-07 + utils.VersionNumber(5, 15, 17): (_BASES[87], '123.0.6312.58'), # 2024-03-19 + utils.VersionNumber(5, 15, 18): (_BASES[87], '130.0.6723.59'), # 2024-10-14 - # Qt 5.15.2: Chromium 83 - # 83.0.4103.122 (~2020-06-24) - # 5.15.2: Security fixes up to 86.0.4240.183 (2020-11-02) - utils.VersionNumber(5, 15, 2): '83.0.4103.122', - # Qt 5.15.3: Chromium 87 - # 87.0.4280.144 (~2020-12-02) - # 5.15.3: Security fixes up to 88.0.4324.150 (2021-02-04) - # 5.15.4: Security fixes up to ??? - # 5.15.5: Security fixes up to ??? - # 5.15.6: Security fixes up to ??? - # 5.15.7: Security fixes up to 94.0.4606.61 (2021-09-24) - # 5.15.8: Security fixes up to 96.0.4664.110 (2021-12-13) - # 5.15.9: Security fixes up to 98.0.4758.102 (2022-02-14) - # 5.15.10: Security fixes up to ??? - # 5.15.11: Security fixes up to ??? - utils.VersionNumber(5, 15): '87.0.4280.144', # >= 5.15.3 + ## Qt 6.2 + utils.VersionNumber(6, 2): (_BASES[90], '93.0.4577.63'), # 2021-08-31 + utils.VersionNumber(6, 2, 1): (_BASES[90], '94.0.4606.61'), # 2021-09-24 + utils.VersionNumber(6, 2, 2): (_BASES[90], '96.0.4664.45'), # 2021-11-15 + utils.VersionNumber(6, 2, 3): (_BASES[90], '96.0.4664.45'), # 2021-11-15 + utils.VersionNumber(6, 2, 4): (_BASES[90], '98.0.4758.102'), # 2022-02-14 + # 6.2.5 / 6.2.6: unknown security fixes + utils.VersionNumber(6, 2, 7): (_BASES[90], '107.0.5304.110'), # 2022-11-08 + utils.VersionNumber(6, 2, 8): (_BASES[90], '111.0.5563.110'), # 2023-03-21 - # Qt 6.2: Chromium 90 - # 90.0.4430.228 (2021-06-22) - # 6.2.0: Security fixes up to 93.0.4577.63 (2021-08-31) - # 6.2.1: Security fixes up to 94.0.4606.61 (2021-09-24) - # 6.2.2: Security fixes up to 96.0.4664.45 (2021-11-15) - # 6.2.3: Security fixes up to 96.0.4664.45 (2021-11-15) - # 6.2.4: Security fixes up to 98.0.4758.102 (2022-02-14) - # 6.2.5: Security fixes up to ??? - # 6.2.6: Security fixes up to ??? - # 6.2.7: Security fixes up to ??? - utils.VersionNumber(6, 2): '90.0.4430.228', + ## Qt 6.3 + utils.VersionNumber(6, 3): (_BASES[94], '99.0.4844.84'), # 2022-03-25 + utils.VersionNumber(6, 3, 1): (_BASES[94], '101.0.4951.64'), # 2022-05-10 + utils.VersionNumber(6, 3, 2): (_BASES[94], '104.0.5112.81'), # 2022-08-01 - # Qt 6.3: Chromium 94 - # 94.0.4606.126 (2021-11-17) - # 6.3.0: Security fixes up to 99.0.4844.84 (2022-03-25) - # 6.3.1: Security fixes up to 101.0.4951.64 (2022-05-10) - # 6.3.2: Security fixes up to 104.0.5112.81 (2022-08-01) - utils.VersionNumber(6, 3): '94.0.4606.126', + ## Qt 6.4 + utils.VersionNumber(6, 4): (_BASES[102], '104.0.5112.102'), # 2022-08-16 + utils.VersionNumber(6, 4, 1): (_BASES[102], '107.0.5304.88'), # 2022-10-27 + utils.VersionNumber(6, 4, 2): (_BASES[102], '108.0.5359.94'), # 2022-12-02 + utils.VersionNumber(6, 4, 3): (_BASES[102], '110.0.5481.78'), # 2023-02-07 - # Qt 6.4: Chromium 102 - # 102.0.5005.177 (~2022-05-24) - # 6.4.0: Security fixes up to 104.0.5112.102 (2022-08-16) - # 6.4.1: Security fixes up to 107.0.5304.88 (2022-10-27) - # 6.4.2: Security fixes up to 108.0.5359.94 (2022-12-02) - # 6.4.3: Security fixes up to 110.0.5481.78 (2023-02-07) - utils.VersionNumber(6, 4): '102.0.5005.177', + ## Qt 6.5 + utils.VersionNumber(6, 5): (_BASES[108], '110.0.5481.104'), # 2023-02-16 + utils.VersionNumber(6, 5, 1): (_BASES[108], '112.0.5615.138'), # 2023-04-18 + utils.VersionNumber(6, 5, 2): (_BASES[108], '114.0.5735.133'), # 2023-06-13 + utils.VersionNumber(6, 5, 3): (_BASES[108], '117.0.5938.63'), # 2023-09-12 - # Qt 6.5: Chromium 108 - # 108.0.5359.220 (~2022-12-23) - # (.220 claimed by code, .181 claimed by CHROMIUM_VERSION) - # 6.5.0: Security fixes up to 110.0.5481.104 (2023-02-16) - # 6.5.1: Security fixes up to 112.0.5615.138 (2023-04-18) - # 6.5.2: Security fixes up to 114.0.5735.133 (2023-06-13) - # 6.5.3: Security fixes up to 117.0.5938.63 (2023-09-12) - utils.VersionNumber(6, 5): '108.0.5359.220', + ## Qt 6.6 + utils.VersionNumber(6, 6): (_BASES[112], '117.0.5938.63'), # 2023-09-12 + utils.VersionNumber(6, 6, 1): (_BASES[112], '119.0.6045.123'), # 2023-11-07 + utils.VersionNumber(6, 6, 2): (_BASES[112], '121.0.6167.160'), # 2024-02-06 + utils.VersionNumber(6, 6, 3): (_BASES[112], '122.0.6261.128'), # 2024-03-12 - # Qt 6.6: Chromium 112 - # 112.0.5615.213 (~2023-04-18) - # 6.6.0: Security fixes up to 117.0.5938.63 (2023-09-12) - # 6.6.1: Security fixes up to 119.0.6045.123 (2023-11-07) - # 6.6.2: Security fixes up to 121.0.6167.160 (2024-02-06) - # 6.6.3: Security fixes up to 122.0.6261.128 (2024-03-12) - utils.VersionNumber(6, 6): '112.0.5615.213', + ## Qt 6.7 + utils.VersionNumber(6, 7): (_BASES[118], '122.0.6261.128'), # 2024-03-12 + utils.VersionNumber(6, 7, 1): (_BASES[118], '124.0.6367.202'), # 2024-05-09 + utils.VersionNumber(6, 7, 2): (_BASES[118], '125.0.6422.142'), # 2024-05-30 + utils.VersionNumber(6, 7, 3): (_BASES[118], '129.0.6668.58'), # 2024-09-17 - # Qt 6.7: Chromium 118 - # 118.0.5993.220 (~2023-10-24) - # 6.6.0: Security fixes up to 122.0.6261.128 (?) (2024-03-12) - utils.VersionNumber(6, 7): '118.0.5993.220', + ## Qt 6.8 + utils.VersionNumber(6, 8): (_BASES[122], '129.0.6668.70'), # 2024-09-24 + utils.VersionNumber(6, 8, 1): (_BASES[122], '131.0.6778.70'), # 2024-11-12 + utils.VersionNumber(6, 8, 2): (_BASES[122], '132.0.6834.111'), # 2025-01-22 + + ## Qt 6.9 (Beta 3) + utils.VersionNumber(6, 9): (_BASES[130], '133.0.6943.141'), # 2025-02-25 } def __post_init__(self) -> None: @@ -636,27 +652,37 @@ class WebEngineVersions: self.chromium_major = int(self.chromium.split('.')[0]) def __str__(self) -> str: - s = f'QtWebEngine {self.webengine}' + lines = [f'QtWebEngine {self.webengine}'] if self.chromium is not None: - s += f', based on Chromium {self.chromium}' + lines.append(f' based on Chromium {self.chromium}') if self.chromium_security is not None: - s += f', with security patches up to {self.chromium_security} (plus any distribution patches)' - if self.source != 'UA': - s += f' (from {self.source})' - return s + lines.append(f' with security patches up to {self.chromium_security} (plus any distribution patches)') + lines.append(f' (source: {self.source})') + return "\n".join(lines) @classmethod def from_ua(cls, ua: 'websettings.UserAgent') -> 'WebEngineVersions': """Get the versions parsed from a user agent. - This is the most reliable and "default" way to get this information (at least - until QtWebEngine adds an API for it). However, it needs a fully initialized - QtWebEngine, and we sometimes need this information before that is available. + This is the most reliable and "default" way to get this information for + older Qt versions that don't provide an API for it. However, it needs a + fully initialized QtWebEngine, and we sometimes need this information + before that is available. """ assert ua.qt_version is not None, ua + webengine = utils.VersionNumber.parse(ua.qt_version) + chromium_inferred, chromium_security = cls._infer_chromium_version(webengine) + if ua.upstream_browser_version != chromium_inferred: # pragma: no cover + # should never happen, but let's play it safe + log.misc.debug( + f"Chromium version mismatch: {ua.upstream_browser_version} (UA) != " + f"{chromium_inferred} (inferred)") + chromium_security = None + return cls( - webengine=utils.VersionNumber.parse(ua.qt_version), + webengine=webengine, chromium=ua.upstream_browser_version, + chromium_security=chromium_security, source='UA', ) @@ -671,9 +697,19 @@ class WebEngineVersions: sometimes mix and match Qt/QtWebEngine versions, so this is a more reliable (though hackish) way to get a more accurate result. """ + webengine = utils.VersionNumber.parse(versions.webengine) + chromium_inferred, chromium_security = cls._infer_chromium_version(webengine) + if versions.chromium != chromium_inferred: # pragma: no cover + # should never happen, but let's play it safe + log.misc.debug( + f"Chromium version mismatch: {versions.chromium} (ELF) != " + f"{chromium_inferred} (inferred)") + chromium_security = None + return cls( - webengine=utils.VersionNumber.parse(versions.webengine), + webengine=webengine, chromium=versions.chromium, + chromium_security=chromium_security, source='ELF', ) @@ -681,21 +717,29 @@ class WebEngineVersions: def _infer_chromium_version( cls, pyqt_webengine_version: utils.VersionNumber, - ) -> Optional[str]: - """Infer the Chromium version based on the PyQtWebEngine version.""" - chromium_version = cls._CHROMIUM_VERSIONS.get(pyqt_webengine_version) + ) -> tuple[Optional[str], Optional[str]]: + """Infer the Chromium version based on the PyQtWebEngine version. + + Returns: + A tuple of the Chromium version and the security patch version. + """ + chromium_version, security_version = cls._CHROMIUM_VERSIONS.get( + pyqt_webengine_version, (None, None)) if chromium_version is not None: - return chromium_version + return chromium_version, security_version # 5.15 patch versions change their QtWebEngine version, but no changes are # expected after 5.15.3 and 5.15.[01] are unsupported. - if pyqt_webengine_version == utils.VersionNumber(5, 15, 2): - minor_version = pyqt_webengine_version - else: - # e.g. 5.14.2 -> 5.14 - minor_version = pyqt_webengine_version.strip_patch() + assert pyqt_webengine_version != utils.VersionNumber(5, 15, 2) - return cls._CHROMIUM_VERSIONS.get(minor_version) + # e.g. 5.15.4 -> 5.15 + # we ignore the security version as that one will have changed from .0 + # and is thus unknown. + minor_version = pyqt_webengine_version.strip_patch() + chromium_ver, _security_ver = cls._CHROMIUM_VERSIONS.get( + minor_version, (None, None)) + + return chromium_ver, None @classmethod def from_api( @@ -730,9 +774,11 @@ class WebEngineVersions: a PyQtWebEngine-Qt{,5} package from PyPI, so we could query its exact version. """ parsed = utils.VersionNumber.parse(pyqt_webengine_qt_version) + chromium, chromium_security = cls._infer_chromium_version(parsed) return cls( webengine=parsed, - chromium=cls._infer_chromium_version(parsed), + chromium=chromium, + chromium_security=chromium_security, source=source, ) @@ -775,9 +821,12 @@ class WebEngineVersions: if frozen: parsed = utils.VersionNumber(5, 15, 2) + chromium, chromium_security = cls._infer_chromium_version(parsed) + return cls( webengine=parsed, - chromium=cls._infer_chromium_version(parsed), + chromium=chromium, + chromium_security=chromium_security, source=source, ) @@ -978,7 +1027,7 @@ class OpenGLInfo: version_str: Optional[str] = None # The parsed version as a (major, minor) tuple of ints - version: Optional[Tuple[int, ...]] = None + version: Optional[tuple[int, ...]] = None # The vendor specific information following the version number vendor_specific: Optional[str] = None diff --git a/requirements.txt b/requirements.txt index 229f9e9c8..25c4f316d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,12 +2,10 @@ adblock==0.6.0 colorama==0.4.6 -importlib_resources==6.4.0 ; python_version=="3.8.*" -Jinja2==3.1.3 -MarkupSafe==2.1.5 -Pygments==2.17.2 -PyYAML==6.0.1 -zipp==3.18.1 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +Pygments==2.19.1 +PyYAML==6.0.2 # Unpinned due to recompile_requirements.py limitations pyobjc-core ; sys_platform=="darwin" pyobjc-framework-Cocoa ; sys_platform=="darwin" diff --git a/scripts/dev/Makefile-dmg b/scripts/dev/Makefile-dmg index 48743967d..8427a7ba5 100644 --- a/scripts/dev/Makefile-dmg +++ b/scripts/dev/Makefile-dmg @@ -41,7 +41,7 @@ $(TEMPLATE_DMG): @echo @echo --------------------- Generating empty template -------------------- mkdir template - hdiutil create -fs HFSX -layout SPUD -size $(TEMPLATE_SIZE) "$(TEMPLATE_DMG)" -srcfolder template -format UDRW -volname "$(NAME)" -quiet + for i in {1..30}; do hdiutil create -fs HFSX -layout SPUD -size $(TEMPLATE_SIZE) "$(TEMPLATE_DMG)" -srcfolder template -format UDRW -volname "$(NAME)" -quiet && break; sleep 2; done rmdir template $(WC_DMG): $(TEMPLATE_DMG) @@ -60,7 +60,7 @@ $(MASTER_DMG): $(WC_DMG) $(addprefix $(SOURCE_DIR)/,$(SOURCE_FILES)) #rm -f "$@" #hdiutil create -srcfolder "$(WC_DIR)" -format UDZO -imagekey zlib-level=9 "$@" -volname "$(NAME) $(VERSION)" -scrub -quiet WC_DEV=`hdiutil info | grep "$(WC_DIR)" | grep "Apple_HFS" | awk '{print $$1}'` && \ - hdiutil detach $$WC_DEV -force || { sleep 30; hdiutil detach $$WC_DEV -force -debug; } + for i in {1..30}; do hdiutil detach $$WC_DEV -force -debug && break; sleep 2; done rm -f "$(MASTER_DMG)" hdiutil convert "$(WC_DMG)" -quiet -format UDZO -imagekey zlib-level=9 -o "$@" rm -rf $(WC_DIR) diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index 40cedc2e8..2f1ab26c0 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -16,10 +16,12 @@ import subprocess import argparse import tarfile import tempfile +import platform import collections import dataclasses import re -from typing import Iterable, List, Optional +from typing import Optional +from collections.abc import Iterable try: import winreg @@ -124,7 +126,7 @@ def _smoke_test_run( return subprocess.run(argv, check=True, capture_output=True) -def smoke_test(executable: pathlib.Path, debug: bool, qt5: bool) -> None: +def smoke_test(executable: pathlib.Path, debug: bool) -> None: """Try starting the given qutebrowser executable.""" stdout_whitelist = [] stderr_whitelist = [ @@ -163,18 +165,15 @@ def smoke_test(executable: pathlib.Path, debug: bool, qt5: bool) -> None: (r'\[.*:ERROR:command_buffer_proxy_impl.cc\([0-9]*\)\] ' r'ContextResult::kTransientFailure: Failed to send ' r'.*CreateCommandBuffer\.'), + # FIXME:qt6 Qt 6.3 on macOS + r'[0-9:]* WARNING: Incompatible version of OpenSSL', + r'[0-9:]* WARNING: Qt WebEngine resources not found at .*', + (r'[0-9:]* WARNING: Installed Qt WebEngine locales directory not found at ' + r'location /qtwebengine_locales\. Trying application directory\.\.\.'), + # Qt 6.7, only seen on macos for some reason + (r'.*Path override failed for key base::DIR_APP_DICTIONARIES ' + r"and path '.*/qtwebengine_dictionaries'"), ]) - if not qt5: - stderr_whitelist.extend([ - # FIXME:qt6 Qt 6.3 on macOS - r'[0-9:]* WARNING: Incompatible version of OpenSSL', - r'[0-9:]* WARNING: Qt WebEngine resources not found at .*', - (r'[0-9:]* WARNING: Installed Qt WebEngine locales directory not found at ' - r'location /qtwebengine_locales\. Trying application directory\.\.\.'), - # Qt 6.7, only seen on macos for some reason - (r'.*Path override failed for key base::DIR_APP_DICTIONARIES ' - r"and path '.*/qtwebengine_dictionaries'"), - ]) elif IS_WINDOWS: stderr_whitelist.extend([ # Windows N: @@ -266,10 +265,9 @@ def _mac_bin_path(base: pathlib.Path) -> pathlib.Path: def build_mac( *, gh_token: Optional[str], - qt5: bool, skip_packaging: bool, debug: bool, -) -> List[Artifact]: +) -> list[Artifact]: """Build macOS .dmg/.app.""" utils.print_title("Cleaning up...") for f in ['wc.dmg', 'template.dmg']: @@ -281,18 +279,18 @@ def build_mac( shutil.rmtree(d, ignore_errors=True) utils.print_title("Updating 3rdparty content") - update_3rdparty.run(ace=False, pdfjs=True, legacy_pdfjs=qt5, fancy_dmg=False, + update_3rdparty.run(ace=False, pdfjs=True, modern_pdfjs=False, fancy_dmg=False, gh_token=gh_token) utils.print_title("Building .app via pyinstaller") - call_tox(f'pyinstaller{"-qt5" if qt5 else ""}', '-r', debug=debug) + call_tox('pyinstaller', '-r', debug=debug) utils.print_title("Verifying .app") verify_mac_app() dist_path = pathlib.Path("dist") utils.print_title("Running pre-dmg smoke test") - smoke_test(_mac_bin_path(dist_path), debug=debug, qt5=qt5) + smoke_test(_mac_bin_path(dist_path), debug=debug) if skip_packaging: return [] @@ -301,8 +299,9 @@ def build_mac( dmg_makefile_path = REPO_ROOT / "scripts" / "dev" / "Makefile-dmg" subprocess.run(['make', '-f', dmg_makefile_path], check=True) + arch = platform.machine() suffix = "-debug" if debug else "" - suffix += "-qt5" if qt5 else "" + suffix += f"-{arch}" dmg_path = dist_path / f'qutebrowser-{qutebrowser.__version__}{suffix}.dmg' pathlib.Path('qutebrowser.dmg').rename(dmg_path) @@ -314,7 +313,7 @@ def build_mac( subprocess.run(['hdiutil', 'attach', dmg_path, '-mountpoint', tmp_path], check=True) try: - smoke_test(_mac_bin_path(tmp_path), debug=debug, qt5=qt5) + smoke_test(_mac_bin_path(tmp_path), debug=debug) finally: print("Waiting 10s for dmg to be detachable...") time.sleep(10) @@ -322,11 +321,14 @@ def build_mac( except PermissionError as e: print(f"Failed to remove tempdir: {e}") + arch_to_desc = {"x86_64": "Intel", "arm64": "Apple Silicon"} + desc_arch = arch_to_desc[arch] + return [ Artifact( path=dmg_path, mimetype='application/x-apple-diskimage', - description='macOS .dmg' + description=f'macOS .dmg ({desc_arch})' ) ] @@ -349,10 +351,9 @@ def _get_windows_python_path() -> pathlib.Path: def _build_windows_single( *, - qt5: bool, skip_packaging: bool, debug: bool, -) -> List[Artifact]: +) -> list[Artifact]: """Build on Windows for a single build type.""" utils.print_title("Running pyinstaller") dist_path = pathlib.Path("dist") @@ -361,9 +362,7 @@ def _build_windows_single( _maybe_remove(out_path) python = _get_windows_python_path() - # FIXME:qt6 does this regress 391623d5ec983ecfc4512c7305c4b7a293ac3872? - suffix = "-qt5" if qt5 else "" - call_tox(f'pyinstaller{suffix}', '-r', python=python, debug=debug) + call_tox('pyinstaller', '-r', python=python, debug=debug) out_pyinstaller = dist_path / "qutebrowser" shutil.move(out_pyinstaller, out_path) @@ -373,7 +372,7 @@ def _build_windows_single( verify_windows_exe(exe_path) utils.print_title("Running smoke test") - smoke_test(exe_path, debug=debug, qt5=qt5) + smoke_test(exe_path, debug=debug) if skip_packaging: return [] @@ -382,19 +381,17 @@ def _build_windows_single( return _package_windows_single( out_path=out_path, debug=debug, - qt5=qt5, ) def build_windows( *, gh_token: str, skip_packaging: bool, - qt5: bool, debug: bool, -) -> List[Artifact]: +) -> list[Artifact]: """Build windows executables/setups.""" utils.print_title("Updating 3rdparty content") - update_3rdparty.run(nsis=True, ace=False, pdfjs=True, legacy_pdfjs=qt5, + update_3rdparty.run(nsis=True, ace=False, pdfjs=True, modern_pdfjs=False, fancy_dmg=False, gh_token=gh_token) utils.print_title("Building Windows binaries") @@ -406,7 +403,6 @@ def build_windows( artifacts = _build_windows_single( skip_packaging=skip_packaging, debug=debug, - qt5=qt5, ) return artifacts @@ -415,8 +411,7 @@ def _package_windows_single( *, out_path: pathlib.Path, debug: bool, - qt5: bool, -) -> List[Artifact]: +) -> list[Artifact]: """Build the given installer/zip for windows.""" artifacts = [] @@ -424,7 +419,6 @@ def _package_windows_single( utils.print_subtitle("Building installer...") subprocess.run(['makensis.exe', f'/DVERSION={qutebrowser.__version__}', - f'/DQT5={qt5}', 'misc/nsis/qutebrowser.nsi'], check=True) name_parts = [ @@ -433,8 +427,6 @@ def _package_windows_single( ] if debug: name_parts.append('debug') - if qt5: - name_parts.append('qt5') name_parts.append('amd64') # FIXME:qt6 temporary until new installer name = '-'.join(name_parts) + '.exe' @@ -454,8 +446,6 @@ def _package_windows_single( ] if debug: zip_name_parts.append('debug') - if qt5: - zip_name_parts.append('qt5') zip_name = '-'.join(zip_name_parts) + '.zip' zip_path = dist_path / zip_name @@ -469,7 +459,7 @@ def _package_windows_single( return artifacts -def build_sdist() -> List[Artifact]: +def build_sdist() -> list[Artifact]: """Build an sdist and list the contents.""" utils.print_title("Building sdist") @@ -559,7 +549,7 @@ def read_github_token( def github_upload( - artifacts: List[Artifact], + artifacts: list[Artifact], tag: str, gh_token: str, experimental: bool, @@ -637,7 +627,7 @@ def github_upload( break -def pypi_upload(artifacts: List[Artifact], experimental: bool) -> None: +def pypi_upload(artifacts: list[Artifact], experimental: bool) -> None: """Upload the given artifacts to PyPI using twine.""" # https://blog.pypi.org/posts/2023-05-23-removing-pgp/ artifacts = [a for a in artifacts if a.mimetype != 'application/pgp-signature'] @@ -649,13 +639,13 @@ def pypi_upload(artifacts: List[Artifact], experimental: bool) -> None: run_twine('upload', artifacts) -def twine_check(artifacts: List[Artifact]) -> None: +def twine_check(artifacts: list[Artifact]) -> None: """Check packages using 'twine check'.""" utils.print_title("Running twine check...") run_twine('check', artifacts, '--strict') -def run_twine(command: str, artifacts: List[Artifact], *args: str) -> None: +def run_twine(command: str, artifacts: list[Artifact], *args: str) -> None: paths = [a.path for a in artifacts] subprocess.run([sys.executable, '-m', 'twine', command, *args, *paths], check=True) @@ -674,10 +664,9 @@ def main() -> None: help="Skip Windows installer/zip generation or macOS DMG.") parser.add_argument('--debug', action='store_true', required=False, help="Build a debug build.") - parser.add_argument('--qt5', action='store_true', required=False, - help="Build against PyQt5") parser.add_argument('--experimental', action='store_true', required=False, - help="Upload to experiments repo and test PyPI") + default=os.environ.get("GITHUB_REPOSITORY") == "qutebrowser/experiments", + help="Upload to experiments repo and test PyPI. Set automatically if on qutebrowser/experiments CI.") args = parser.parse_args() utils.change_cwd() @@ -705,14 +694,12 @@ def main() -> None: artifacts = build_windows( gh_token=gh_token, skip_packaging=args.skip_packaging, - qt5=args.qt5, debug=args.debug, ) elif IS_MACOS: artifacts = build_mac( gh_token=gh_token, skip_packaging=args.skip_packaging, - qt5=args.qt5, debug=args.debug, ) else: diff --git a/scripts/dev/changelog_urls.json b/scripts/dev/changelog_urls.json index 645bb6385..d225919d3 100644 --- a/scripts/dev/changelog_urls.json +++ b/scripts/dev/changelog_urls.json @@ -2,7 +2,7 @@ "pylint": "https://pylint.pycqa.org/en/latest/whatsnew/3/index.html", "tomlkit": "https://github.com/sdispater/tomlkit/blob/master/CHANGELOG.md", "dill": "https://github.com/uqfoundation/dill/commits/master", - "isort": "https://github.com/PyCQA/isort/blob/main/CHANGELOG.md", + "isort": "https://github.com/PyCQA/isort/releases", "mccabe": "https://github.com/PyCQA/mccabe#changes", "pytest-cov": "https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst", "pytest-xdist": "https://github.com/pytest-dev/pytest-xdist/blob/master/CHANGELOG.rst", @@ -16,18 +16,17 @@ "Werkzeug": "https://werkzeug.palletsprojects.com/en/latest/changes/", "click": "https://click.palletsprojects.com/en/latest/changes/", "itsdangerous": "https://itsdangerous.palletsprojects.com/en/latest/changes/", - "parse-type": "https://github.com/jenisys/parse_type/blob/main/CHANGES.txt", + "parse_type": "https://github.com/jenisys/parse_type/blob/main/CHANGES.txt", "sortedcontainers": "https://github.com/grantjenks/python-sortedcontainers/blob/master/HISTORY.rst", "soupsieve": "https://facelessuser.github.io/soupsieve/about/changelog/", "Flask": "https://flask.palletsprojects.com/en/latest/changes/", "Mako": "https://docs.makotemplates.org/en/latest/changelog.html", "hypothesis": "https://hypothesis.readthedocs.io/en/latest/changes.html", - "mypy": "https://mypy-lang.blogspot.com/", + "mypy": "https://github.com/python/mypy/blob/master/CHANGELOG.md", "types-PyYAML": "https://github.com/python/typeshed/commits/main/stubs/PyYAML", "types-colorama": "https://github.com/python/typeshed/commits/main/stubs/colorama", "types-docutils": "https://github.com/python/typeshed/commits/main/stubs/docutils", "types-Pygments": "https://github.com/python/typeshed/commits/main/stubs/Pygments", - "types-setuptools": "https://github.com/python/typeshed/commits/main/stubs/setuptools", "pytest": "https://docs.pytest.org/en/latest/changelog.html", "iniconfig": "https://github.com/pytest-dev/iniconfig/blob/master/CHANGELOG", "tox": "https://tox.readthedocs.io/en/latest/changelog.html", @@ -35,6 +34,7 @@ "pyproject-api": "https://github.com/tox-dev/pyproject-api/releases", "PyYAML": "https://github.com/yaml/pyyaml/blob/master/CHANGES", "pytest-bdd": "https://github.com/pytest-dev/pytest-bdd/blob/master/CHANGES.rst", + "gherkin-official": "https://github.com/cucumber/gherkin/releases", "snowballstemmer": "https://github.com/snowballstem/snowball/blob/master/NEWS", "virtualenv": "https://github.com/pypa/virtualenv/blob/main/docs/changelog.rst", "packaging": "https://packaging.pypa.io/en/latest/changelog.html", @@ -68,10 +68,9 @@ "more-itertools": "https://github.com/more-itertools/more-itertools/blob/master/docs/versions.rst", "pydocstyle": "https://www.pydocstyle.org/en/latest/release_notes.html", "Sphinx": "https://www.sphinx-doc.org/en/master/changes.html", - "Babel": "https://github.com/python-babel/babel/blob/master/CHANGES.rst", + "babel": "https://github.com/python-babel/babel/blob/master/CHANGES.rst", "alabaster": "https://alabaster.readthedocs.io/en/latest/changelog.html", "imagesize": "https://github.com/shibukawa/imagesize_py/commits/master", - "pytz": "https://mm.icann.org/pipermail/tz-announce/", "sphinxcontrib-applehelp": "https://www.sphinx-doc.org/en/master/changes.html", "sphinxcontrib-devhelp": "https://www.sphinx-doc.org/en/master/changes.html", "sphinxcontrib-htmlhelp": "https://www.sphinx-doc.org/en/master/changes.html", @@ -86,7 +85,23 @@ "pyinstaller-hooks-contrib": "https://github.com/pyinstaller/pyinstaller-hooks-contrib/blob/master/CHANGELOG.rst", "pytest-benchmark": "https://pytest-benchmark.readthedocs.io/en/stable/changelog.html", "docutils": "https://docutils.sourceforge.io/RELEASE-NOTES.html", - "bump2version": "https://github.com/c4urself/bump2version/blob/master/CHANGELOG.md", + "bump-my-version": "https://github.com/callowayproject/bump-my-version/blob/master/CHANGELOG.md", + "annotated-types": "https://github.com/annotated-types/annotated-types/releases", + "bracex": "https://github.com/facelessuser/bracex/releases", + "prompt_toolkit": "https://github.com/prompt-toolkit/python-prompt-toolkit/releases", + "pydantic": "https://docs.pydantic.dev/latest/changelog/", + "pydantic-settings": "https://github.com/pydantic/pydantic-settings/releases", + "pydantic_core": "https://github.com/pydantic/pydantic-core/releases", + "python-dotenv": "https://saurabh-kumar.com/python-dotenv/changelog/", + "questionary": "https://github.com/tmbo/questionary/blob/master/docs/pages/changelog.rst", + "rich-click": "https://ewels.github.io/rich-click/changelog/", + "wcmatch": "https://github.com/facelessuser/wcmatch/releases", + "wcwidth": "https://github.com/jquast/wcwidth/releases", + "anyio": "https://anyio.readthedocs.io/en/stable/versionhistory.html", + "h11": "https://h11.readthedocs.io/en/latest/changes.html", + "httpcore": "https://github.com/encode/httpcore/blob/master/CHANGELOG.md", + "httpx": "https://github.com/encode/httpx/blob/master/CHANGELOG.md", + "sniffio": "https://sniffio.readthedocs.io/en/latest/history.html", "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/main/CHANGES.rst", @@ -98,14 +113,15 @@ "PyQtWebEngine": "https://www.riverbankcomputing.com/news", "PyQtWebEngine-Qt5": "https://www.riverbankcomputing.com/news", "PyQt-builder": "https://pyqt-builder.readthedocs.io/en/stable/releases.html", - "PyQt5-sip": "https://www.riverbankcomputing.com/news", + "PyQt5_sip": "https://www.riverbankcomputing.com/news", "PyQt5-stubs": "https://github.com/python-qt-tools/PyQt5-stubs/blob/master/CHANGELOG.md", - "sip": "https://www.riverbankcomputing.com/news", + "sip": "https://python-sip.readthedocs.io/en/stable/releases.html", "PyQt6": "https://www.riverbankcomputing.com/news", "PyQt6-Qt6": "https://www.riverbankcomputing.com/news", "PyQt6-WebEngine": "https://www.riverbankcomputing.com/news", "PyQt6-WebEngine-Qt6": "https://www.riverbankcomputing.com/news", - "PyQt6-sip": "https://www.riverbankcomputing.com/news", + "PyQt6-WebEngineSubwheel-Qt6": "https://www.riverbankcomputing.com/news", + "PyQt6_sip": "https://www.riverbankcomputing.com/news", "Pygments": "https://pygments.org/docs/changelog/", "vulture": "https://github.com/jendrikseipp/vulture/blob/main/CHANGELOG.md", "distlib": "https://github.com/pypa/distlib/blob/master/CHANGES.rst", @@ -145,8 +161,13 @@ "jaraco.classes": "https://jaracoclasses.readthedocs.io/en/latest/history.html", "jaraco.context": "https://jaracocontext.readthedocs.io/en/latest/history.html", "jaraco.functools": "https://jaracofunctools.readthedocs.io/en/latest/history.html", + "jaraco.text": "https://jaracotext.readthedocs.io/en/latest/history.html", + "jaraco.collections": "https://jaracocollections.readthedocs.io/en/latest/history.html", + "autocommand": "https://github.com/Lucretiel/autocommand/releases", + "inflect": "https://inflect.readthedocs.io/en/latest/history.html", + "typeguard": "https://typeguard.readthedocs.io/en/latest/versionhistory.html", "backports.tarfile": "https://github.com/jaraco/backports.tarfile/blob/main/NEWS.rst", - "pkginfo": "https://bazaar.launchpad.net/~tseaver/pkginfo/trunk/view/head:/CHANGES.txt", + "id": "https://github.com/di/id/blob/main/CHANGELOG.md", "readme_renderer": "https://github.com/pypa/readme_renderer/blob/main/CHANGES.rst", "requests-toolbelt": "https://github.com/requests/toolbelt/blob/master/HISTORY.rst", "rfc3986": "https://rfc3986.readthedocs.io/en/latest/release-notes/index.html", @@ -162,5 +183,6 @@ "mdurl": "https://github.com/executablebooks/mdurl/commits/master", "blinker": "https://blinker.readthedocs.io/en/stable/#changes", "exceptiongroup": "https://github.com/agronholm/exceptiongroup/blob/main/CHANGES.rst", - "nh3": "https://github.com/messense/nh3/commits/main" + "nh3": "https://github.com/messense/nh3/commits/main", + "pillow": "https://github.com/python-pillow/Pillow/releases" } diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index e1d0d8642..6de04703f 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -242,7 +242,7 @@ def _get_filename(filename): os.path.join(os.path.dirname(__file__), '..', '..')) common_path = os.path.commonprefix([basedir, filename]) if common_path: - filename = filename[len(common_path):].lstrip('/') + filename = filename.removeprefix(common_path).lstrip('/') return filename diff --git a/scripts/dev/ci/docker/Dockerfile.j2 b/scripts/dev/ci/docker/Dockerfile.j2 index 4b958babd..7a27ea813 100644 --- a/scripts/dev/ci/docker/Dockerfile.j2 +++ b/scripts/dev/ci/docker/Dockerfile.j2 @@ -2,7 +2,7 @@ FROM archlinux:latest RUN pacman-key --init && pacman-key --populate {% if unstable %} -RUN sed -i '/^# after the header/a[kde-unstable]\nInclude = /etc/pacman.d/mirrorlist\n\n[testing]\nInclude = /etc/pacman.d/mirrorlist\n\n[community-testing]\nInclude = /etc/pacman.d/mirrorlist' /etc/pacman.conf +RUN sed -i '/^# after the header/a[kde-unstable]\nInclude = /etc/pacman.d/mirrorlist\n\n[core-testing]\nInclude = /etc/pacman.d/mirrorlist\n\n[extra-testing]\nInclude = /etc/pacman.d/mirrorlist' /etc/pacman.conf {% endif %} RUN pacman -Sy --noconfirm archlinux-keyring RUN pacman -Su --noconfirm \ @@ -16,11 +16,13 @@ RUN pacman -Su --noconfirm \ qt6-declarative \ {% if webengine %} qt6-webengine python-pyqt6-webengine \ + pdfjs \ {% else %}{{ 1/0 }}{% endif %} python-pyqt6 \ {% else %} qt5-base \ qt5-declarative \ + openssl-1.1 \ {% if webengine %} qt5-webengine \ python-pyqtwebengine \ @@ -34,22 +36,34 @@ RUN pacman -Su --noconfirm \ libyaml \ xorg-xdpyinfo +RUN useradd user -u 1001 && \ + mkdir /home/user && \ + chown user:users /home/user + {% if not webengine %} RUN pacman -U --noconfirm \ https://archive.archlinux.org/packages/q/qt5-webkit/qt5-webkit-5.212.0alpha4-18-x86_64.pkg.tar.zst \ https://archive.archlinux.org/packages/p/python-pyqt5/python-pyqt5-5.15.7-2-x86_64.pkg.tar.zst \ - https://archive.archlinux.org/packages/p/python/python-3.10.10-1-x86_64.pkg.tar.zst \ https://archive.archlinux.org/packages/i/icu/icu-72.1-2-x86_64.pkg.tar.zst \ https://archive.archlinux.org/packages/l/libxml2/libxml2-2.10.4-4-x86_64.pkg.tar.zst \ + https://archive.archlinux.org/packages/l/libxslt/libxslt-1.1.42-2-x86_64.pkg.tar.zst \ https://archive.archlinux.org/packages/q/qt5-base/qt5-base-5.15.10%2Bkde%2Br129-3-x86_64.pkg.tar.zst \ https://archive.archlinux.org/packages/q/qt5-declarative/qt5-declarative-5.15.10%2Bkde%2Br31-1-x86_64.pkg.tar.zst \ https://archive.archlinux.org/packages/q/qt5-translations/qt5-translations-5.15.10-1-any.pkg.tar.zst \ https://archive.archlinux.org/packages/q/qt5-sensors/qt5-sensors-5.15.10-1-x86_64.pkg.tar.zst \ https://archive.archlinux.org/packages/q/qt5-location/qt5-location-5.15.10%2Bkde%2Br5-1-x86_64.pkg.tar.zst \ https://archive.archlinux.org/packages/q/qt5-webchannel/qt5-webchannel-5.15.10%2Bkde%2Br3-1-x86_64.pkg.tar.zst +RUN pacman -S --noconfirm base-devel -RUN python3 -m ensurepip -RUN python3 -m pip install tox pyqt5-sip +RUN echo 'user ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers # needed for makepkg +USER user +RUN cd ~ && \ + git clone https://aur.archlinux.org/python310.git && \ + cd python310 && \ + makepkg -si --noconfirm +USER root +RUN python3.10 -m ensurepip +RUN python3.10 -m pip install tox pyqt5-sip {% endif %} {% if qt6 %} @@ -58,17 +72,17 @@ RUN python3 -m pip install tox pyqt5-sip {% set pyqt_module = 'PyQt5' %} {% endif %} {% if webengine %} - RUN python3 -c "from {{ pyqt_module }} import QtWebEngineCore, QtWebEngineWidgets" + {% set python = 'python3' %} + RUN {{ python }} -c "from {{ pyqt_module }} import QtWebEngineCore, QtWebEngineWidgets" {% else %} - RUN python3 -c "from {{ pyqt_module }} import QtWebKit, QtWebKitWidgets" + {% set python = 'python3.10' %} + RUN {{ python }} -c "from {{ pyqt_module }} import QtWebKit, QtWebKitWidgets" {% endif %} -RUN useradd user -u 1001 && \ - mkdir /home/user && \ - chown user:users /home/user USER user WORKDIR /home/user +RUN git config --global --add safe.directory /outside/.git CMD git clone /outside qutebrowser.git && \ cd qutebrowser.git && \ - tox -e {% if qt6 %}py-qt6{% else %}py-qt5{% endif %} + {{ python }} -m tox -e {% if qt6 %}py-qt6{% else %}py-qt5{% endif %} diff --git a/scripts/dev/misc_checks.py b/scripts/dev/misc_checks.py index 7b6404cf6..5ffeb6019 100644 --- a/scripts/dev/misc_checks.py +++ b/scripts/dev/misc_checks.py @@ -15,7 +15,8 @@ import subprocess import tokenize import traceback import pathlib -from typing import List, Iterator, Optional, Tuple +from typing import Optional +from collections.abc import Iterator REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) @@ -30,7 +31,7 @@ BINARY_EXTS = {'.png', '.icns', '.ico', '.bmp', '.gz', '.bin', '.pdf', def _get_files( *, verbose: bool, - ignored: List[pathlib.Path] = None + ignored: list[pathlib.Path] = None ) -> Iterator[pathlib.Path]: """Iterate over all files and yield filenames.""" filenames = subprocess.run( @@ -75,7 +76,7 @@ def check_changelog_urls(_args: argparse.Namespace = None) -> bool: with open(outfile, 'r', encoding='utf-8') as f: for line in f: line = line.strip() - if line.startswith('#') or not line: + if line.startswith(('#', '--')) or not line: continue req, _version = recompile_requirements.parse_versioned_line(line) if req.startswith('./'): @@ -142,8 +143,8 @@ def _check_spelling_file(path, fobj, patterns): def _check_spelling_all( args: argparse.Namespace, - ignored: List[pathlib.Path], - patterns: List[Tuple[re.Pattern, str]], + ignored: list[pathlib.Path], + patterns: list[tuple[re.Pattern, str]], ) -> Optional[bool]: try: ok = True @@ -274,6 +275,10 @@ def check_spelling(args: argparse.Namespace) -> Optional[bool]: re.compile(r'qutebrowser is free software: you can redistribute'), "use 'SPDX-License-Identifier: GPL-3.0-or-later' instead", ), + ( + re.compile(r'QTimer\(.*\)$'), + "use usertypes.Timer() instead of a plain QTimer", + ), ] # Files which should be ignored, e.g. because they come from another diff --git a/scripts/dev/pylint_checkers/qute_pylint/config.py b/scripts/dev/pylint_checkers/qute_pylint/config.py index be5bae082..6effc8836 100644 --- a/scripts/dev/pylint_checkers/qute_pylint/config.py +++ b/scripts/dev/pylint_checkers/qute_pylint/config.py @@ -50,7 +50,7 @@ class ConfigChecker(checkers.BaseChecker): node_str = node.as_string() prefix = 'config.val.' if node_str.startswith(prefix): - self._check_config(node, node_str[len(prefix):]) + self._check_config(node, node_str.removeprefix(prefix)) def _check_config(self, node, name): """Check that we're accessing proper config options.""" diff --git a/scripts/dev/recompile_requirements.py b/scripts/dev/recompile_requirements.py index 838e75931..d499affe9 100644 --- a/scripts/dev/recompile_requirements.py +++ b/scripts/dev/recompile_requirements.py @@ -114,7 +114,7 @@ def get_all_names(): """Get all requirement names based on filenames.""" for filename in glob.glob(os.path.join(REQ_DIR, 'requirements-*.txt-raw')): basename = os.path.basename(filename) - yield basename[len('requirements-'):-len('.txt-raw')] + yield basename.removeprefix('requirements-').removesuffix('.txt-raw') def run_pip(venv_dir, *args, quiet=False, **kwargs): @@ -161,8 +161,11 @@ def parse_args(): def git_diff(*args): """Run a git diff command.""" - command = (['git', '--no-pager', 'diff'] + list(args) + [ - '--', 'requirements.txt', 'misc/requirements/requirements-*.txt']) + command = ( + ["git", "--no-pager", "-c", "diff.mnemonicPrefix=false", "diff"] + + list(args) + + ["--", "requirements.txt", "misc/requirements/requirements-*.txt"] + ) proc = subprocess.run(command, stdout=subprocess.PIPE, encoding='utf-8', @@ -231,7 +234,7 @@ def extract_requirement_name(path: pathlib.Path) -> str: prefix = "requirements-" assert path.suffix == ".txt", path assert path.stem.startswith(prefix), path - return path.stem[len(prefix):] + return path.stem.removeprefix(prefix) def parse_versioned_line(line): @@ -274,11 +277,11 @@ def _get_changes(diff): continue elif line.startswith('--- '): prefix = '--- a/' - current_path = pathlib.Path(line[len(prefix):]) + current_path = pathlib.Path(line.removeprefix(prefix)) continue elif line.startswith('+++ '): prefix = '+++ b/' - new_path = pathlib.Path(line[len(prefix):]) + new_path = pathlib.Path(line.removeprefix(prefix)) assert current_path == new_path, (current_path, new_path) continue elif not line.strip(): @@ -407,8 +410,8 @@ def test_tox(): check=True) -def test_requirements(name, outfile, *, force=False): - """Test a resulting requirements file.""" +def install_requirements(name, outfile, *, force=False): + """Test install a resulting requirements file.""" print() utils.print_subtitle("Testing") @@ -443,7 +446,7 @@ def main(): for name in names: utils.print_title(name) outfile = build_requirements(name) - test_requirements(name, outfile, force=args.force_test) + install_requirements(name, outfile, force=args.force_test) if name == 'pylint': cleanup_pylint_build() diff --git a/scripts/dev/run_pylint_on_tests.py b/scripts/dev/run_pylint_on_tests.py index 580ef988f..d55caaf36 100644 --- a/scripts/dev/run_pylint_on_tests.py +++ b/scripts/dev/run_pylint_on_tests.py @@ -40,6 +40,7 @@ def main(): 'redefined-outer-name', 'unused-argument', 'too-many-arguments', + 'too-many-positional-arguments', # things which are okay in tests 'missing-docstring', 'protected-access', diff --git a/scripts/dev/run_shellcheck.sh b/scripts/dev/run_shellcheck.sh index 4d7281733..1333d1f81 100644 --- a/scripts/dev/run_shellcheck.sh +++ b/scripts/dev/run_shellcheck.sh @@ -8,7 +8,7 @@ set -e script_list=$(mktemp) -find scripts/dev/ -name '*.sh' > "$script_list" +find scripts/ -name '*.sh' > "$script_list" find misc/userscripts/ -type f -exec grep -lE '[/ ][bd]ash$|[/ ]sh$|[/ ]ksh$' {} + >> "$script_list" mapfile -t scripts < "$script_list" rm -f "$script_list" diff --git a/scripts/dev/ua_fetch.py b/scripts/dev/ua_fetch.py index 2f094476e..956a0ed4c 100644 --- a/scripts/dev/ua_fetch.py +++ b/scripts/dev/ua_fetch.py @@ -7,7 +7,7 @@ """Fetch and print the most common user agents. This script fetches the most common user agents according to -https://github.com/Kikobeats/top-user-agents, and prints the most recent +https://github.com/microlinkhq/top-user-agents, and prints the most recent Chrome user agent for Windows, macOS and Linux. """ @@ -29,7 +29,7 @@ def wrap(ini, sub, string): # pylint: disable-next=missing-timeout -response = requests.get('https://raw.githubusercontent.com/Kikobeats/top-user-agents/master/index.json') +response = requests.get('https://raw.githubusercontent.com/microlinkhq/top-user-agents/master/src/index.json') if response.status_code != 200: print('Unable to fetch the user agent index', file=sys.stderr) diff --git a/scripts/dev/update_3rdparty.py b/scripts/dev/update_3rdparty.py index e47d01cb7..7fd30ebca 100755 --- a/scripts/dev/update_3rdparty.py +++ b/scripts/dev/update_3rdparty.py @@ -17,8 +17,6 @@ import sys sys.path.insert( 0, os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) -from scripts import dictcli -from qutebrowser.config import configdata def download_nsis_plugins(): @@ -91,14 +89,15 @@ def get_latest_pdfjs_url(gh_token, legacy): return (version_name, download_url) -def update_pdfjs(target_version=None, legacy=False, gh_token=None): +def update_pdfjs(target_version=None, legacy=True, gh_token=None): """Download and extract the latest pdf.js version. If target_version is not None, download the given version instead. Args: target_version: None or version string ('x.y.z') - legacy: Whether to download the legacy build for 83-based. + legacy: Whether to download the "legacy" build (the normal build only + supports the latest Chromium release). gh_token: GitHub token to use for the API. Optional except on CI. """ if target_version is None: @@ -106,8 +105,7 @@ def update_pdfjs(target_version=None, legacy=False, gh_token=None): else: # We need target_version as x.y.z, without the 'v' prefix, though the # user might give it on the command line - if target_version.startswith('v'): - target_version = target_version[1:] + target_version = target_version.removeprefix('v') # version should have the prefix to be consistent with the return value # of get_latest_pdfjs_url() version = 'v' + target_version @@ -118,7 +116,8 @@ def update_pdfjs(target_version=None, legacy=False, gh_token=None): os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..')) target_path = os.path.join('qutebrowser', '3rdparty', 'pdfjs') - print(f"=> Downloading pdf.js {version}{' (legacy)' if legacy else ''}") + version_suffix = '' if legacy else ' (modern browsers version)' + print(f"=> Downloading pdf.js {version}{version_suffix}") try: (archive_path, _headers) = urllib.request.urlretrieve(url) except urllib.error.HTTPError as error: @@ -160,6 +159,8 @@ def update_ace(): def test_dicts(): """Test available dictionaries.""" + from scripts import dictcli + from qutebrowser.config import configdata configdata.init() for lang in dictcli.available_languages(): print('Testing dictionary {}... '.format(lang.code), end='') @@ -172,13 +173,13 @@ def test_dicts(): print('ERROR: {}'.format(response.status)) -def run(nsis=False, ace=False, pdfjs=True, legacy_pdfjs=False, fancy_dmg=False, +def run(*, nsis=False, ace=False, pdfjs=True, modern_pdfjs=False, fancy_dmg=False, pdfjs_version=None, dicts=False, gh_token=None): """Update components based on the given arguments.""" if nsis: download_nsis_plugins() if pdfjs: - update_pdfjs(pdfjs_version, legacy=legacy_pdfjs, gh_token=gh_token) + update_pdfjs(pdfjs_version, legacy=not modern_pdfjs, gh_token=gh_token) if ace: update_ace() if fancy_dmg: @@ -196,8 +197,8 @@ def main(): help='Specify pdfjs version. If not given, ' 'the latest version is used.', required=False, metavar='VERSION') - parser.add_argument("--legacy-pdfjs", - help="Use legacy PDF.js build (for 83-based)", + parser.add_argument("--modern-pdfjs", + help="Use PDF.js modern build (only supports latest Chromium)", action='store_true') parser.add_argument('--fancy-dmg', help="Update fancy-dmg Makefile", action='store_true') @@ -210,7 +211,7 @@ def main(): '--gh-token', help="GitHub token to use.", nargs='?') args = parser.parse_args() run(nsis=False, ace=True, pdfjs=True, fancy_dmg=args.fancy_dmg, - pdfjs_version=args.pdfjs, legacy_pdfjs=args.legacy_pdfjs, + pdfjs_version=args.pdfjs, modern_pdfjs=args.modern_pdfjs, dicts=args.dicts, gh_token=args.gh_token) diff --git a/scripts/dev/update_version.py b/scripts/dev/update_version.py index b0f48710e..fa242617f 100644 --- a/scripts/dev/update_version.py +++ b/scripts/dev/update_version.py @@ -6,7 +6,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later -"""Update version numbers using bump2version.""" +"""Update version numbers using bump-my-version.""" import re import sys @@ -45,7 +45,7 @@ def bump_version(version_leap="patch"): version_leap: define the jump between versions ("major", "minor", "patch") """ - subprocess.run([sys.executable, '-m', 'bumpversion', version_leap], + subprocess.run([sys.executable, '-m', 'bumpversion', 'bump', version_leap], check=True) diff --git a/scripts/importer.py b/scripts/importer.py index cf084d178..57e210dad 100755 --- a/scripts/importer.py +++ b/scripts/importer.py @@ -12,7 +12,7 @@ Currently importing bookmarks from Netscape HTML Bookmark files, Chrome profiles, and Mozilla profiles is supported. """ - +import contextlib import argparse import textwrap import sqlite3 @@ -267,14 +267,15 @@ def import_moz_places(profile, bookmark_types, output_format): def search_conv(url): return search_escape(url).replace('%s', '{}') - places = sqlite3.connect(os.path.join(profile, "places.sqlite")) - places.create_function('search_conv', 1, search_conv) - places.row_factory = sqlite3.Row - c = places.cursor() - for typ in bookmark_types: - c.execute(place_query[typ]) - for row in c: - print(out_template[output_format][typ].format(**row)) + places_sqlite = os.path.join(profile, "places.sqlite") + with contextlib.closing(sqlite3.connect(places_sqlite)) as places: + places.create_function('search_conv', 1, search_conv) + places.row_factory = sqlite3.Row + c = places.cursor() + for typ in bookmark_types: + c.execute(place_query[typ]) + for row in c: + print(out_template[output_format][typ].format(**row)) def import_chrome(profile, bookmark_types, output_format): @@ -292,17 +293,18 @@ def import_chrome(profile, bookmark_types, output_format): } if 'search' in bookmark_types: - webdata = sqlite3.connect(os.path.join(profile, 'Web Data')) - c = webdata.cursor() - c.execute('SELECT keyword,url FROM keywords;') - for keyword, url in c: - try: - url = opensearch_convert(url) - print(out_template[output_format].format( - keyword=keyword, url=url)) - except KeyError: - print('# Unsupported parameter in url for {}; skipping....'. - format(keyword)) + webdata_db = os.path.join(profile, 'Web Data') + with contextlib.closing(sqlite3.connect(webdata_db)) as webdata: + c = webdata.cursor() + c.execute('SELECT keyword,url FROM keywords;') + for keyword, url in c: + try: + url = opensearch_convert(url) + print(out_template[output_format].format( + keyword=keyword, url=url)) + except KeyError: + print('# Unsupported parameter in url for {}; skipping....'. + format(keyword)) else: with open(os.path.join(profile, 'Bookmarks'), encoding='utf-8') as f: diff --git a/scripts/link_pyqt.py b/scripts/link_pyqt.py index 108696317..b0d6bcf3c 100644 --- a/scripts/link_pyqt.py +++ b/scripts/link_pyqt.py @@ -128,6 +128,8 @@ def link_pyqt(executable, venv_path, *, version): if version not in ["5", "6"]: raise ValueError(f"Invalid version {version}") + pyqt_dir = os.path.dirname(get_lib_path(executable, f'PyQt{version}.QtCore')) + try: get_lib_path(executable, f'PyQt{version}.sip') except Error: @@ -138,7 +140,6 @@ def link_pyqt(executable, venv_path, *, version): sip_file = None sipconfig_file = get_lib_path(executable, 'sipconfig', required=False) - pyqt_dir = os.path.dirname(get_lib_path(executable, f'PyQt{version}.QtCore')) for path in [sip_file, sipconfig_file, pyqt_dir]: if path is None: diff --git a/scripts/mkvenv.py b/scripts/mkvenv.py index 4ab5d8c10..dfdb0a853 100755 --- a/scripts/mkvenv.py +++ b/scripts/mkvenv.py @@ -17,7 +17,7 @@ import shutil import venv as pyvenv import subprocess import platform -from typing import List, Tuple, Dict, Union +from typing import Union sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) from scripts import utils, link_pyqt @@ -49,7 +49,7 @@ def print_command(*cmd: Union[str, pathlib.Path], venv: bool) -> None: utils.print_col(prefix + ' '.join([str(e) for e in cmd]), 'blue') -def parse_args(argv: List[str] = None) -> argparse.Namespace: +def parse_args(argv: list[str] = None) -> argparse.Namespace: """Parse commandline arguments.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--update', @@ -105,7 +105,7 @@ def _version_key(v): return (999,) -def pyqt_versions() -> List[str]: +def pyqt_versions() -> list[str]: """Get a list of all available PyQt versions. The list is based on the filenames of misc/requirements/ files. @@ -227,8 +227,8 @@ def install_pyqt_binary(venv_dir: pathlib.Path, version: str) -> None: if _is_qt6_version(version): supported_archs = { - 'linux': {'x86_64'}, - 'win32': {'AMD64'}, + 'linux': {'x86_64', 'aarch64'}, # ARM since PyQt 6.8 + 'win32': {'AMD64', 'arm64'}, # ARM since PyQt 6.8 'darwin': {'x86_64', 'arm64'}, } else: @@ -276,7 +276,7 @@ def install_pyqt_wheels(venv_dir: pathlib.Path, pip_install(venv_dir, *wheels) -def install_pyqt_snapshot(venv_dir: pathlib.Path, packages: List[str]) -> None: +def install_pyqt_snapshot(venv_dir: pathlib.Path, packages: list[str]) -> None: """Install PyQt packages from the snapshot server.""" utils.print_title("Installing PyQt snapshots") pip_install(venv_dir, '-U', *packages, '--no-deps', '--pre', @@ -348,9 +348,9 @@ def apply_xcb_util_workaround( link_path.symlink_to(libxcb_util_path) -def _find_libs() -> Dict[Tuple[str, str], List[str]]: +def _find_libs() -> dict[tuple[str, str], list[str]]: """Find all system-wide .so libraries.""" - all_libs: Dict[Tuple[str, str], List[str]] = {} + all_libs: dict[tuple[str, str], list[str]] = {} if pathlib.Path("/sbin/ldconfig").exists(): # /sbin might not be in PATH on e.g. Debian diff --git a/scripts/open_url_in_instance.sh b/scripts/open_url_in_instance.sh index 0d6edef51..002826492 100755 --- a/scripts/open_url_in_instance.sh +++ b/scripts/open_url_in_instance.sh @@ -5,7 +5,7 @@ _url="$1" _qb_version='1.0.4' _proto_version=1 -_ipc_socket="${XDG_RUNTIME_DIR}/qutebrowser/ipc-$(echo -n "$USER" | md5sum | cut -d' ' -f1)" +_ipc_socket="${XDG_RUNTIME_DIR}/qutebrowser/ipc-$(printf '%s' "$USER" | md5sum | cut -d' ' -f1)" _qute_bin="/usr/bin/qutebrowser" printf '{"args": ["%s"], "target_arg": null, "version": "%s", "protocol_version": %d, "cwd": "%s"}\n' \ diff --git a/setup.py b/setup.py index feb949595..97a87a65c 100755 --- a/setup.py +++ b/setup.py @@ -57,9 +57,8 @@ try: entry_points={'gui_scripts': ['qutebrowser = qutebrowser.qutebrowser:main']}, zip_safe=True, - install_requires=['jinja2', 'PyYAML', - 'importlib_resources>=1.1.0; python_version < "3.9"'], - python_requires='>=3.8', + install_requires=['jinja2', 'PyYAML'], + python_requires='>=3.9', name='qutebrowser', version=_get_constant('version'), description=_get_constant('description'), @@ -81,10 +80,11 @@ try: 'Operating System :: MacOS', 'Operating System :: POSIX :: BSD', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Topic :: Internet', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP :: Browsers', diff --git a/tests/conftest.py b/tests/conftest.py index ddacc3db1..210f23fad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ import ssl import pytest import hypothesis +import hypothesis.database pytest.register_assert_rewrite('helpers') @@ -33,19 +34,28 @@ _qute_scheme_handler = None # Set hypothesis settings +hypothesis_optional_kwargs = {} +if "HYPOTHESIS_EXAMPLES_DIR" in os.environ: + hypothesis_optional_kwargs[ + "database" + ] = hypothesis.database.DirectoryBasedExampleDatabase( + os.environ["HYPOTHESIS_EXAMPLES_DIR"] + ) + hypothesis.settings.register_profile( 'default', hypothesis.settings( deadline=600, suppress_health_check=[hypothesis.HealthCheck.function_scoped_fixture], + **hypothesis_optional_kwargs, ) ) hypothesis.settings.register_profile( 'ci', hypothesis.settings( - deadline=None, + hypothesis.settings.get_profile('ci'), suppress_health_check=[ hypothesis.HealthCheck.function_scoped_fixture, - hypothesis.HealthCheck.too_slow - ] + ], + **hypothesis_optional_kwargs, ) ) hypothesis.settings.load_profile('ci' if testutils.ON_CI else 'default') @@ -94,6 +104,10 @@ def _apply_platform_markers(config, item): pytest.mark.skipif, testutils.ON_CI, "Skipped on CI."), + ('no_offscreen', + pytest.mark.skipif, + testutils.offscreen_plugin_enabled(), + "Skipped with offscreen platform plugin."), ('unicode_locale', pytest.mark.skipif, sys.getfilesystemencoding() == 'ascii', @@ -112,6 +126,17 @@ def _apply_platform_markers(config, item): pytest.mark.skipif, not config.webengine and ssl.OPENSSL_VERSION_INFO[0] == 3, "Failing due to cheroot: https://github.com/cherrypy/cheroot/issues/346"), + ( + "qt69_ci_flaky", # WORKAROUND: https://github.com/qutebrowser/qutebrowser/issues/8444#issuecomment-2569610110 + pytest.mark.flaky, + ( + config.webengine + and version.qtwebengine_versions(avoid_init=True).webengine + == utils.VersionNumber(6, 9) + and testutils.ON_CI + ), + "Flaky with QtWebEngine 6.9 on CI", + ), ] for searched_marker, new_marker_kind, condition, default_reason in markers: @@ -192,16 +217,20 @@ def pytest_ignore_collect(collection_path: pathlib.Path) -> bool: @pytest.fixture(scope='session') -def qapp_args(): - """Make QtWebEngine unit tests run on older Qt versions + newer kernels.""" +def qapp_args() -> list[str]: + """Work around various issues when running QtWebEngine tests.""" + args = [sys.argv[0], "--webEngineArgs"] if testutils.disable_seccomp_bpf_sandbox(): - return [sys.argv[0], testutils.DISABLE_SECCOMP_BPF_FLAG] + args.append(testutils.DISABLE_SECCOMP_BPF_FLAG) + if testutils.use_software_rendering(): + args.append(testutils.SOFTWARE_RENDERING_FLAG) # Disabling PaintHoldingCrossOrigin makes tests needing UI interaction with # QtWebEngine more reliable. # Only needed with QtWebEngine and Qt 6.5, but Qt just ignores arguments it # doesn't know about anyways. - return [sys.argv[0], "--webEngineArgs", "--disable-features=PaintHoldingCrossOrigin"] + args.append("--disable-features=PaintHoldingCrossOrigin") + return args @pytest.fixture(scope='session') @@ -218,6 +247,8 @@ def pytest_addoption(parser): help="Delay (in ms) after qutebrowser process started.") parser.addoption('--qute-profile-subprocs', action='store_true', default=False, help="Run cProfile for subprocesses.") + parser.addoption('--qute-strace-subprocs', action='store_true', + default=False, help="Run strace for subprocesses.") parser.addoption('--qute-backend', action='store', choices=['webkit', 'webengine'], help='Set backend for BDD tests') @@ -290,10 +321,19 @@ def pytest_report_header(config): @pytest.fixture(scope='session', autouse=True) def check_display(request): - if utils.is_linux and not os.environ.get('DISPLAY', ''): + if ( + utils.is_linux + and not os.environ.get("DISPLAY", "") + and not testutils.offscreen_plugin_enabled() + ): raise RuntimeError("No display and no Xvfb available!") +def pytest_xvfb_disable() -> bool: + """Disable Xvfb if the offscreen plugin is in use.""" + return testutils.offscreen_plugin_enabled() + + @pytest.fixture(autouse=True) def set_backend(monkeypatch, request): """Make sure the backend global is set.""" @@ -339,15 +379,26 @@ def apply_fake_os(monkeypatch, request): @pytest.fixture(scope='session', autouse=True) def check_yaml_c_exts(): - """Make sure PyYAML C extensions are available on CI. - - Not available yet with a nightly Python, see: - https://github.com/yaml/pyyaml/issues/630 - """ - if testutils.ON_CI and sys.version_info[:2] != (3, 11): + """Make sure PyYAML C extensions are available on CI.""" + if testutils.ON_CI: from yaml import CLoader # pylint: disable=unused-import +@pytest.fixture(scope="session", autouse=True) +def init_qtwe_dict_path( + tmp_path_factory: pytest.TempPathFactory, request: pytest.FixtureRequest, +) -> None: + """Initialize spell checking dictionaries for QtWebEngine. + + QtWebEngine stores the dictionary path in a static variable, so we can't do + this per-test. Hence the session-scope on this fixture. + """ + if request.config.webengine: # type: ignore[att-defined] + # Set an empty directory path, this is enough for QtWebEngine to not complain. + dictionary_dir = tmp_path_factory.mktemp("qtwebengine_dictionaries") + os.environ["QTWEBENGINE_DICTIONARIES_PATH"] = str(dictionary_dir) + + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): """Make test information available in fixtures. @@ -361,7 +412,8 @@ def pytest_runtest_makereport(item, call): @pytest.hookimpl(hookwrapper=True) def pytest_terminal_summary(terminalreporter): - """Group benchmark results on CI.""" + """Add custom pytest summary sections.""" + # Group benchmark results on CI. if testutils.ON_CI: terminalreporter.write_line( testutils.gha_group_begin('Benchmark results')) @@ -369,3 +421,21 @@ def pytest_terminal_summary(terminalreporter): terminalreporter.write_line(testutils.gha_group_end()) else: yield + + # List any screenshots of failed end2end tests that were generated during + # the run. Screenshots are captured from QuteProc.after_test() + properties = lambda report: dict(report.user_properties) + reports = [ + report + for report in terminalreporter.getreports("") + if "screenshot" in properties(report) + ] + screenshots = [ + pathlib.Path(properties(report)["screenshot"]) + for report in reports + ] + + if screenshots: + terminalreporter.ensure_newline() + screenshot_dir = screenshots[0].parent + terminalreporter.section(f"End2end screenshots available in: {screenshot_dir}", sep="-", blue=True, bold=True) diff --git a/tests/end2end/conftest.py b/tests/end2end/conftest.py index ab973175d..ac0b94fa2 100644 --- a/tests/end2end/conftest.py +++ b/tests/end2end/conftest.py @@ -17,10 +17,15 @@ from qutebrowser.qt.core import PYQT_VERSION, QCoreApplication pytest.register_assert_rewrite('end2end.fixtures') # pylint: disable=unused-import +# Import fixtures that the bdd tests rely on. from end2end.fixtures.notificationserver import notification_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.quteprocess import ( + quteproc_process, quteproc, + quteproc_new, + screenshot_dir, + take_x11_screenshot, +) from end2end.fixtures.testprocess import pytest_runtest_makereport # pylint: enable=unused-import from qutebrowser.utils import qtutils, utils @@ -133,32 +138,16 @@ def _get_version_tag(tag): raise utils.Unreachable(package) -def _get_backend_tag(tag): - """Handle a @qtwebengine_*/@qtwebkit_skip tag.""" - pytest_marks = { - 'qtwebengine_todo': pytest.mark.qtwebengine_todo, - 'qtwebengine_skip': pytest.mark.qtwebengine_skip, - 'qtwebkit_skip': pytest.mark.qtwebkit_skip, - } - if not any(tag.startswith(t + ':') for t in pytest_marks): - return None - name, desc = tag.split(':', maxsplit=1) - return pytest_marks[name](desc) - - if not getattr(sys, 'frozen', False): def pytest_bdd_apply_tag(tag, function): """Handle custom tags for BDD tests. - This tries various functions, and if none knows how to handle this tag, - it returns None so it falls back to pytest-bdd's implementation. + If we return None, this falls back to pytest-bdd's implementation. """ - funcs = [_get_version_tag, _get_backend_tag] - for func in funcs: - mark = func(tag) - if mark is not None: - mark(function) - return True + mark = _get_version_tag(tag) + if mark is not None: + mark(function) + return True return None diff --git a/tests/end2end/data/crashers/document_picture_in_picture.html b/tests/end2end/data/crashers/document_picture_in_picture.html new file mode 100644 index 000000000..94362e991 --- /dev/null +++ b/tests/end2end/data/crashers/document_picture_in_picture.html @@ -0,0 +1,21 @@ + + + + + Document Picture-in-Picture API Crasher + + + + + + + + diff --git a/tests/end2end/data/misc/xhr_headers.html b/tests/end2end/data/misc/xhr_headers.html index eda129e68..71c53eb30 100644 --- a/tests/end2end/data/misc/xhr_headers.html +++ b/tests/end2end/data/misc/xhr_headers.html @@ -8,6 +8,7 @@ const xhr = new XMLHttpRequest(); xhr.open("GET", "/headers"); xhr.setRequestHeader("X-Qute-Test", "from XHR"); + xhr.setRequestHeader("Accept-Language", "from XHR"); const elem = document.getElementById("output"); xhr.addEventListener("load", function(event) { diff --git a/tests/end2end/data/prompt/clipboard.html b/tests/end2end/data/prompt/clipboard.html new file mode 100644 index 000000000..fff148343 --- /dev/null +++ b/tests/end2end/data/prompt/clipboard.html @@ -0,0 +1,136 @@ + + + + + + + +
    +
    + + + +

    Permissions:

    + + +
    +
    +
    +        
    +
    + + + + diff --git a/tests/end2end/features/backforward.feature b/tests/end2end/features/backforward.feature index d60fde645..d7389848d 100644 --- a/tests/end2end/features/backforward.feature +++ b/tests/end2end/features/backforward.feature @@ -1,24 +1,25 @@ Feature: Going back and forward. Testing the :back/:forward commands. - @skip # Too flaky Scenario: Going back/forward Given I open data/backforward/1.txt When I open data/backforward/2.txt And I run :tab-only And I run :back And I wait until data/backforward/1.txt is loaded - And I reload + And I reload data/backforward/1.txt And I run :forward And I wait until data/backforward/2.txt is loaded - And I reload + And I reload data/backforward/2.txt Then the session should look like: - windows: - - tabs: - - history: - - url: http://localhost:*/data/backforward/1.txt - - active: true - url: http://localhost:*/data/backforward/2.txt + """ + windows: + - tabs: + - history: + - url: http://localhost:*/data/backforward/1.txt + - active: true + url: http://localhost:*/data/backforward/2.txt + """ # https://travis-ci.org/qutebrowser/qutebrowser/jobs/157941720 @qtwebengine_flaky @@ -29,30 +30,34 @@ Feature: Going back and forward. And I run :back -t And I wait until data/backforward/1.txt is loaded Then the session should look like: - windows: - - tabs: - - history: - - url: http://localhost:*/data/backforward/1.txt - - active: true - url: http://localhost:*/data/backforward/2.txt - - active: true - history: - - active: true - url: http://localhost:*/data/backforward/1.txt - - url: http://localhost:*/data/backforward/2.txt + """ + windows: + - tabs: + - history: + - url: http://localhost:*/data/backforward/1.txt + - active: true + url: http://localhost:*/data/backforward/2.txt + - active: true + history: + - active: true + url: http://localhost:*/data/backforward/1.txt + - url: http://localhost:*/data/backforward/2.txt + """ Scenario: Going back in a new tab without history Given I open data/backforward/1.txt When I run :tab-only And I run :back -t Then the error "At beginning of history." should be shown - Then the session should look like: - windows: - - tabs: - - active: true - history: - - active: true - url: http://localhost:*/data/backforward/1.txt + And the session should look like: + """ + windows: + - tabs: + - active: true + history: + - active: true + url: http://localhost:*/data/backforward/1.txt + """ Scenario: Going back in a new background tab Given I open data/backforward/1.txt @@ -61,17 +66,19 @@ Feature: Going back and forward. And I run :back -b And I wait until data/backforward/1.txt is loaded Then the session should look like: - windows: - - tabs: - - active: true - history: - - url: http://localhost:*/data/backforward/1.txt - - active: true - url: http://localhost:*/data/backforward/2.txt - - history: - - active: true - url: http://localhost:*/data/backforward/1.txt - - url: http://localhost:*/data/backforward/2.txt + """ + windows: + - tabs: + - active: true + history: + - url: http://localhost:*/data/backforward/1.txt + - active: true + url: http://localhost:*/data/backforward/2.txt + - history: + - active: true + url: http://localhost:*/data/backforward/1.txt + - url: http://localhost:*/data/backforward/2.txt + """ @flaky Scenario: Going back with count. @@ -81,15 +88,17 @@ Feature: Going back and forward. And I run :tab-only And I run :back with count 2 And I wait until data/backforward/1.txt is loaded - And I reload + And I reload data/backforward/1.txt Then the session should look like: - windows: - - tabs: - - history: - - active: true - url: http://localhost:*/data/backforward/1.txt - - url: http://localhost:*/data/backforward/2.txt - - url: http://localhost:*/data/backforward/3.txt + """ + windows: + - tabs: + - history: + - active: true + url: http://localhost:*/data/backforward/1.txt + - url: http://localhost:*/data/backforward/2.txt + - url: http://localhost:*/data/backforward/3.txt + """ Scenario: Going back too much with count. Given I open data/backforward/1.txt @@ -114,21 +123,23 @@ Feature: Going back and forward. And I run :back -w And I wait until data/backforward/1.txt is loaded Then the session should look like: - windows: - - tabs: - - active: true - history: - - url: about:blank - - url: http://localhost:*/data/backforward/1.txt - - active: true - url: http://localhost:*/data/backforward/2.txt - - tabs: - - active: true - history: - - url: about:blank - - active: true - url: http://localhost:*/data/backforward/1.txt - - url: http://localhost:*/data/backforward/2.txt + """ + windows: + - tabs: + - active: true + history: + - url: about:blank + - url: http://localhost:*/data/backforward/1.txt + - active: true + url: http://localhost:*/data/backforward/2.txt + - tabs: + - active: true + history: + - url: about:blank + - active: true + url: http://localhost:*/data/backforward/1.txt + - url: http://localhost:*/data/backforward/2.txt + """ Scenario: Going back without history Given I open data/backforward/1.txt @@ -150,7 +161,7 @@ Feature: Going back and forward. When I run :forward --quiet Then "At end of history." should be logged - @qtwebengine_skip: Getting 'at beginning of history' when going back + @qtwebengine_skip # Getting 'at beginning of history' when going back Scenario: Going forward too much with count. Given I open data/backforward/1.txt When I open data/backforward/2.txt diff --git a/tests/end2end/features/caret.feature b/tests/end2end/features/caret.feature index d6e65440c..04f88e743 100644 --- a/tests/end2end/features/caret.feature +++ b/tests/end2end/features/caret.feature @@ -53,8 +53,10 @@ Feature: Caret mode And I run :selection-follow --tab Then data/hello.txt should be loaded And the following tabs should be open: + """ - data/caret.html - data/hello.txt (active) + """ Scenario: :selection-follow with --tab (without JS) When I set content.javascript.enabled to false @@ -65,8 +67,10 @@ Feature: Caret mode And I run :selection-follow --tab Then data/hello.txt should be loaded And the following tabs should be open: + """ - data/caret.html - data/hello.txt + """ @flaky Scenario: :selection-follow with link tabbing (without JS) diff --git a/tests/end2end/features/completion.feature b/tests/end2end/features/completion.feature index 800858c4c..782fe1988 100644 --- a/tests/end2end/features/completion.feature +++ b/tests/end2end/features/completion.feature @@ -80,7 +80,9 @@ Feature: Using completion And I wait for "setting text = ':tab-select 0/2', *" in the log And I run :completion-item-del Then the following tabs should be open: + """ - data/hello.txt (active) + """ Scenario: Go to tab after moving a tab Given I have a fresh instance @@ -91,8 +93,10 @@ Feature: Using completion And I run :tab-move 1 And I run :tab-select hello2.txt Then the following tabs should be open: + """ - data/hello2.txt (active) - data/hello.txt + """ Scenario: Space updates completion model after selecting full command When I run :cmd-set-text :set diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py index ce31125b6..f43852afb 100644 --- a/tests/end2end/features/conftest.py +++ b/tests/end2end/features/conftest.py @@ -226,7 +226,7 @@ def open_path(quteproc, server, path, default_kwargs: dict = None): while True: for suffix, value in suffixes.items(): if path.endswith(suffix): - path = path[:-len(suffix) - 1] + path = path.removesuffix(suffix) update_from_value(value, kwargs) break else: @@ -261,7 +261,7 @@ def run_command(quteproc, server, tmpdir, command): invalid_tag = ' (invalid command)' if command.endswith(invalid_tag): - command = command[:-len(invalid_tag)] + command = command.removesuffix(invalid_tag) invalid = True else: invalid = False @@ -270,16 +270,18 @@ def run_command(quteproc, server, tmpdir, command): command = testutils.substitute_testdata(command) command = command.replace('(tmpdir)', str(tmpdir)) command = command.replace('(dirsep)', os.sep) + command = command.replace('(rootpath)', 'C:\\' if utils.is_windows else '/') command = command.replace('(echo-exe)', _get_echo_exe_path()) quteproc.send_cmd(command, count=count, invalid=invalid) -@bdd.when(bdd.parsers.parse("I reload")) -def reload(qtbot, server, quteproc): +@bdd.when(bdd.parsers.parse("I reload {path}")) +def reload(qtbot, server, quteproc, path): """Reload and wait until a new request is received.""" with qtbot.wait_signal(server.new_request): quteproc.send_cmd(':reload') + quteproc.wait_for_load_finished(path) @bdd.when(bdd.parsers.parse("I wait until {path} is loaded")) @@ -347,10 +349,9 @@ def fill_clipboard(quteproc, server, what, content): @bdd.when(bdd.parsers.re(r'I put the following lines into the ' - r'(?Pprimary selection|clipboard):\n' - r'(?P.+)$', flags=re.DOTALL)) -def fill_clipboard_multiline(quteproc, server, what, content): - fill_clipboard(quteproc, server, what, textwrap.dedent(content)) + r'(?Pprimary selection|clipboard):', flags=re.DOTALL)) +def fill_clipboard_multiline(quteproc, server, what, docstring): + fill_clipboard(quteproc, server, what, textwrap.dedent(docstring)) @bdd.when(bdd.parsers.parse('I hint with args "{args}"')) @@ -453,20 +454,20 @@ def path_should_be_requested(server, path): server.wait_for(verb='GET', path='/' + path) -@bdd.then(bdd.parsers.parse("The requests should be:\n{pages}")) -def list_of_requests(server, pages): +@bdd.then(bdd.parsers.parse("The requests should be:")) +def list_of_requests(server, docstring): """Make sure the given requests were done from the webserver.""" expected_requests = [server.ExpectedRequest('GET', '/' + path.strip()) - for path in pages.split('\n')] + for path in docstring.split('\n')] actual_requests = server.get_requests() assert actual_requests == expected_requests -@bdd.then(bdd.parsers.parse("The unordered requests should be:\n{pages}")) -def list_of_requests_unordered(server, pages): +@bdd.then(bdd.parsers.parse("The unordered requests should be:")) +def list_of_requests_unordered(server, docstring): """Make sure the given requests were done (in no particular order).""" expected_requests = [server.ExpectedRequest('GET', '/' + path.strip()) - for path in pages.split('\n')] + for path in docstring.split('\n')] actual_requests = server.get_requests() # Requests are not hashable, we need to convert to ExpectedRequests actual_requests = [server.ExpectedRequest.from_request(req) @@ -530,21 +531,21 @@ def javascript_message_not_logged(quteproc, message): message='[*] {}'.format(message)) -@bdd.then(bdd.parsers.parse("The session should look like:\n{expected}")) -def compare_session(quteproc, expected): +@bdd.then(bdd.parsers.parse("The session should look like:")) +def compare_session(quteproc, docstring): """Compare the current sessions against the given template. partial_compare is used, which means only the keys/values listed will be compared. """ - quteproc.compare_session(expected) + quteproc.compare_session(docstring) @bdd.then( - bdd.parsers.parse("The session saved with {flags} should look like:\n{expected}")) -def compare_session_flags(quteproc, flags, expected): + bdd.parsers.parse("The session saved with {flags} should look like:")) +def compare_session_flags(quteproc, flags, docstring): """Compare the current session saved with custom flags.""" - quteproc.compare_session(expected, flags=flags) + quteproc.compare_session(docstring, flags=flags) @bdd.then("no crash should happen") @@ -597,17 +598,17 @@ def check_not_contents_plain(quteproc, text): assert text not in content -@bdd.then(bdd.parsers.parse('the json on the page should be:\n{text}')) -def check_contents_json(quteproc, text): +@bdd.then(bdd.parsers.parse('the json on the page should be:')) +def check_contents_json(quteproc, docstring): """Check the current page's content as json.""" content = quteproc.get_content().strip() - expected = json.loads(text) + expected = json.loads(docstring) actual = json.loads(content) assert actual == expected -@bdd.then(bdd.parsers.parse("the following tabs should be open:\n{expected_tabs}")) -def check_open_tabs(quteproc, request, expected_tabs): +@bdd.then(bdd.parsers.parse("the following tabs should be open:")) +def check_open_tabs(quteproc, docstring): """Check the list of open tabs in a one window session. This is a lightweight alternative for "The session should look like: ...". @@ -624,7 +625,7 @@ def check_open_tabs(quteproc, request, expected_tabs): (collapsed) """ session = quteproc.get_session() - expected_tabs = expected_tabs.splitlines() + expected_tabs = docstring.splitlines() assert len(session['windows']) == 1 window = session['windows'][0] assert len(window['tabs']) == len(expected_tabs) @@ -716,9 +717,9 @@ def clipboard_contains(quteproc, server, what, content): what, json.dumps(expected))) -@bdd.then(bdd.parsers.parse('the clipboard should contain:\n{content}')) -def clipboard_contains_multiline(quteproc, server, content): - expected = textwrap.dedent(content).replace('(port)', str(server.port)) +@bdd.then(bdd.parsers.parse('the clipboard should contain:')) +def clipboard_contains_multiline(quteproc, server, docstring): + expected = textwrap.dedent(docstring).replace('(port)', str(server.port)) quteproc.wait_for(message='Setting fake clipboard: {}'.format( json.dumps(expected))) @@ -785,7 +786,7 @@ def set_up_fileselector(quteproc, py_proc, tmpdir, kind, files, output_type): tmp_file = None for i, arg in enumerate(sys.argv): if arg.startswith('--file='): - tmp_file = arg[len('--file='):] + tmp_file = arg.removeprefix('--file=') sys.argv.pop(i) break selected_files = sys.argv[1:] @@ -805,3 +806,9 @@ def set_up_fileselector(quteproc, py_proc, tmpdir, kind, files, output_type): fileselect_cmd = json.dumps([cmd, *args]) quteproc.set_setting('fileselect.handler', 'external') quteproc.set_setting(f'fileselect.{kind}.command', fileselect_cmd) + + +@bdd.then(bdd.parsers.parse("I run {command}")) +def run_command_then(quteproc, command): + """Run a qutebrowser command.""" + quteproc.send_cmd(command) diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature index c2f359f14..d07e587ed 100644 --- a/tests/end2end/features/downloads.feature +++ b/tests/end2end/features/downloads.feature @@ -186,8 +186,10 @@ Feature: Downloading things from a website. And I run :download-retry And I wait for the error "Download error: * - server replied: NOT FOUND" Then the requests should be: + """ does-not-exist does-not-exist + """ @flaky Scenario: Retrying with count @@ -197,9 +199,11 @@ Feature: Downloading things from a website. And I run :download-retry with count 2 And I wait for the error "Download error: * - server replied: NOT FOUND" Then the requests should be: + """ data/downloads/download.bin does-not-exist does-not-exist + """ Scenario: Retrying with two failed downloads When I run :download http://localhost:(port)/does-not-exist @@ -209,9 +213,11 @@ Feature: Downloading things from a website. And I run :download-retry And I wait for the error "Download error: * - server replied: NOT FOUND" Then the requests should be: + """ does-not-exist does-not-exist-2 does-not-exist + """ Scenario: Retrying a download which does not exist When I run :download-retry with count 42 @@ -257,14 +263,14 @@ Feature: Downloading things from a website. And I wait for "File successfully written." in the log Then the downloaded file Test title.mhtml should exist - @qtwebengine_skip: QtWebEngine refuses to load this + @qtwebengine_skip # QtWebEngine refuses to load this Scenario: Downloading as mhtml with non-ASCII headers When I open response-headers?Content-Type=text%2Fpl%C3%A4in And I run :download --mhtml --dest mhtml-response-headers.mhtml And I wait for "File successfully written." in the log Then the downloaded file mhtml-response-headers.mhtml should exist - @qtwebengine_skip: https://github.com/qutebrowser/qutebrowser/issues/2288 + @qtwebengine_skip # https://github.com/qutebrowser/qutebrowser/issues/2288 Scenario: Overwriting existing mhtml file When I set downloads.location.prompt to true And I open data/title.html @@ -324,7 +330,7 @@ Feature: Downloading things from a website. And "cancelled" should be logged # https://github.com/qutebrowser/qutebrowser/issues/1535 - @qtwebengine_todo: :download --mhtml is not implemented yet + @qtwebengine_todo # :download --mhtml is not implemented yet Scenario: Cancelling an MHTML download (issue 1535) When I open data/downloads/issue1535.html And I run :download --mhtml @@ -645,7 +651,7 @@ Feature: Downloading things from a website. And I set content.pdfjs to true And I open data/misc/test.pdf without waiting And I wait until PDF.js is ready - And I run :click-element id download + And I run :jseval (document.getElementById("downloadButton") || document.getElementById("download")).click() And I clear the log And I wait until the download is finished # We get viewer.html as name on QtWebKit... @@ -663,14 +669,14 @@ Feature: Downloading things from a website. Then the downloaded file download.bin should exist And the downloaded file download2.bin should not exist - @qtwebengine_skip: We can't get the UA from the page there + @qtwebengine_skip # We can't get the UA from the page there Scenario: user-agent when using :download When I open user-agent And I run :download --dest user-agent And I wait until the download is finished Then the downloaded file user-agent should contain Safari/ - @qtwebengine_skip: We can't get the UA from the page there + @qtwebengine_skip # We can't get the UA from the page there Scenario: user-agent when using hints When I open / And I run :hint links download @@ -678,7 +684,7 @@ Feature: Downloading things from a website. And I wait until the download is finished Then the downloaded file user-agent should contain Safari/ - @qtwebengine_skip: Handled by QtWebEngine, not by us + @qtwebengine_skip # Handled by QtWebEngine, not by us Scenario: Downloading a "Internal server error" with disposition: inline (#2304) When I set downloads.location.prompt to false And I open 500-inline @@ -712,3 +718,4 @@ Feature: Downloading things from a website. And I wait for "Asking question *" in the log And I run :prompt-fileselect-external Then the error "Can only launch external fileselect for FilenamePrompt, not LineEditPrompt" should be shown + And I run :mode-leave diff --git a/tests/end2end/features/editor.feature b/tests/end2end/features/editor.feature index 9ca855d27..1cc21c73c 100644 --- a/tests/end2end/features/editor.feature +++ b/tests/end2end/features/editor.feature @@ -15,8 +15,10 @@ Feature: Opening external editors And I run :edit-url -t Then data/numbers/2.txt should be loaded And the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (active) + """ Scenario: Editing a URL with -rt When I set tabs.new_position.related to prev @@ -26,8 +28,10 @@ Feature: Opening external editors And I run :edit-url -rt Then data/numbers/2.txt should be loaded And the following tabs should be open: + """ - data/numbers/2.txt (active) - data/numbers/1.txt + """ Scenario: Editing a URL with -b When I run :tab-only @@ -36,8 +40,10 @@ Feature: Opening external editors And I run :edit-url -b Then data/numbers/2.txt should be loaded And the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/2.txt + """ Scenario: Editing a URL with -w When I run :window-only @@ -47,6 +53,7 @@ Feature: Opening external editors And I run :edit-url -w Then data/numbers/2.txt should be loaded And the session should look like: + """ windows: - tabs: - active: true @@ -58,6 +65,7 @@ Feature: Opening external editors history: - active: true url: http://localhost:*/data/numbers/2.txt + """ Scenario: Editing a URL with -p When I open data/numbers/1.txt in a new tab @@ -67,6 +75,7 @@ Feature: Opening external editors And I run :edit-url -p Then data/numbers/2.txt should be loaded And the session should look like: + """ windows: - tabs: - active: true @@ -79,6 +88,7 @@ Feature: Opening external editors - active: true url: http://localhost:*/data/numbers/2.txt private: true + """ Scenario: Editing a URL with -t and -b When I run :edit-url -t -b @@ -188,6 +198,8 @@ Feature: Opening external editors And I run :cmd-edit Then the error "command must start with one of :/?" should be shown And "Leaving mode KeyMode.command *" should not be logged + And I run :mode-leave + And "Leaving mode KeyMode.command *" should be logged ## select single file diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index ddf42132f..fb22170d7 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -34,15 +34,19 @@ Feature: Using hints And I hint with args "links current" and follow a And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hello.txt (active) + """ Scenario: Following a hint and allow to open in new tab. When I open data/hints/link_blank.html And I hint with args "links normal" and follow a And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hints/link_blank.html - data/hello.txt + """ # https://github.com/qutebrowser/qutebrowser/issues/7842 @qtwebkit_skip @@ -56,7 +60,9 @@ Feature: Using hints And I hint with args "links current" and follow a And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hello.txt (active) + """ Scenario: Entering and leaving hinting mode (issue 1464) When I open data/hints/html/simple.html @@ -160,6 +166,7 @@ Feature: Using hints # We should check what the active tab is, but for some reason that makes # the test flaky Then the session should look like: + """ windows: - tabs: - history: @@ -168,6 +175,7 @@ Feature: Using hints - url: http://localhost:*/data/hello.txt - history: - url: http://localhost:*/data/hello2.txt + """ Scenario: Using hint --rapid to hit multiple buttons When I open data/hints/buttons.html @@ -279,6 +287,7 @@ Feature: Using hints When I open data/hints/iframe_scroll.html And I wait for "* simple loaded" in the log And I hint with args "all normal" and follow a + And I wait for "Clicked non-editable element!" in the log And I run :scroll bottom And I hint with args "links normal" and follow a Then "navigation request: url http://localhost:*/data/hello2.txt (current http://localhost:*/data/hints/iframe_scroll.html), type link_clicked, *" should be logged @@ -294,8 +303,10 @@ Feature: Using hints And I hint with args "links tab" and follow s And I wait until data/hello2.txt is loaded Then the following tabs should be open: + """ - data/hints/iframe_target.html (active) - data/hello2.txt + """ Scenario: Clicking on iframe with :hint all current When I open data/hints/iframe.html diff --git a/tests/end2end/features/history.feature b/tests/end2end/features/history.feature index caec8017b..27352df54 100644 --- a/tests/end2end/features/history.feature +++ b/tests/end2end/features/history.feature @@ -9,45 +9,59 @@ Feature: Page history When I open data/numbers/1.txt And I open data/numbers/2.txt Then the history should contain: + """ http://localhost:(port)/data/numbers/1.txt http://localhost:(port)/data/numbers/2.txt + """ Scenario: History item with title When I open data/title.html Then the history should contain: + """ http://localhost:(port)/data/title.html Test title + """ Scenario: History item with redirect When I open redirect-to?url=data/title.html without waiting And I wait until data/title.html is loaded Then the history should contain: + """ r http://localhost:(port)/redirect-to?url=data/title.html Test title http://localhost:(port)/data/title.html Test title + """ Scenario: History item with spaces in URL When I open data/title with spaces.html Then the history should contain: + """ http://localhost:(port)/data/title%20with%20spaces.html Test title + """ @unicode_locale Scenario: History item with umlauts When I open data/äöü.html Then the history should contain: + """ http://localhost:(port)/data/%C3%A4%C3%B6%C3%BC.html Chäschüechli + """ - @flaky @qtwebengine_todo: Error page message is not implemented + @flaky @qtwebengine_todo # Error page message is not implemented Scenario: History with an error When I run :open file:///does/not/exist And I wait for "Error while loading file:///does/not/exist: Error opening /does/not/exist: *" in the log Then the history should contain: + """ file:///does/not/exist Error loading page: file:///does/not/exist + """ - @qtwebengine_todo: Error page message is not implemented + @qtwebengine_todo # Error page message is not implemented Scenario: History with a 404 When I open 404 without waiting And I wait for "Error while loading http://localhost:*/404: NOT FOUND" in the log Then the history should contain: + """ http://localhost:(port)/404 Error loading page: http://localhost:(port)/404 + """ Scenario: History with invalid URL When I run :tab-only @@ -72,8 +86,10 @@ Feature: Page history When I open data/hints/html/simple.html And I hint with args "--add-history links yank" and follow a Then the history should contain: + """ http://localhost:(port)/data/hints/html/simple.html Simple link http://localhost:(port)/data/hello.txt + """ @flaky Scenario: Listing history diff --git a/tests/end2end/features/invoke.feature b/tests/end2end/features/invoke.feature index d9c472ec4..64d5a2e98 100644 --- a/tests/end2end/features/invoke.feature +++ b/tests/end2end/features/invoke.feature @@ -9,22 +9,27 @@ Feature: Invoking a new process And I open data/title.html And I open data/search.html as a URL Then the following tabs should be open: + """ - data/title.html - data/search.html (active) + """ Scenario: Using new_instance_open_target = tab-bg When I set new_instance_open_target to tab-bg And I open data/title.html And I open data/search.html as a URL Then the following tabs should be open: + """ - data/title.html (active) - data/search.html + """ Scenario: Using new_instance_open_target = window When I set new_instance_open_target to window And I open data/title.html And I open data/search.html as a URL Then the session should look like: + """ windows: - tabs: - history: @@ -33,12 +38,14 @@ Feature: Invoking a new process - tabs: - history: - url: http://localhost:*/data/search.html + """ Scenario: Using new_instance_open_target = private-window When I set new_instance_open_target to private-window And I open data/title.html And I open data/search.html as a URL Then the session should look like: + """ windows: - tabs: - history: @@ -48,6 +55,7 @@ Feature: Invoking a new process tabs: - history: - url: http://localhost:*/data/search.html + """ Scenario: Using new_instance_open_target_window = last-opened When I set new_instance_open_target to tab @@ -56,6 +64,7 @@ Feature: Invoking a new process And I open data/search.html in a new window And I open data/hello.txt as a URL Then the session should look like: + """ windows: - tabs: - history: @@ -66,6 +75,7 @@ Feature: Invoking a new process - url: http://localhost:*/data/search.html - history: - url: http://localhost:*/data/hello.txt + """ Scenario: Using new_instance_open_target_window = first-opened When I set new_instance_open_target to tab @@ -74,6 +84,7 @@ Feature: Invoking a new process And I open data/search.html in a new window And I open data/hello.txt as a URL Then the session should look like: + """ windows: - tabs: - history: @@ -84,6 +95,7 @@ Feature: Invoking a new process - tabs: - history: - url: http://localhost:*/data/search.html + """ # issue #1060 @@ -96,6 +108,7 @@ Feature: Invoking a new process And I wait until data/search.html is loaded And I open data/hello.txt as a URL Then the session should look like: + """ windows: - tabs: - history: @@ -106,6 +119,7 @@ Feature: Invoking a new process - tabs: - history: - url: http://localhost:*/data/search.html + """ Scenario: Opening a new qutebrowser instance with no parameters When I set new_instance_open_target to tab @@ -114,6 +128,7 @@ Feature: Invoking a new process And I spawn a new window And I wait until data/hello.txt is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -122,3 +137,4 @@ Feature: Invoking a new process - tabs: - history: - url: http://localhost:*/data/hello.txt + """ diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature index c3ec6ad8a..baba5b527 100644 --- a/tests/end2end/features/javascript.feature +++ b/tests/end2end/features/javascript.feature @@ -17,7 +17,9 @@ Feature: Javascript stuff And I wait for "[*] window closed" in the log Then "Focus object changed: *" should be logged And the following tabs should be open: + """ - data/javascript/window_open.html (active) + """ @skip # Too flaky Scenario: Opening/closing a modal window via JS @@ -31,7 +33,9 @@ Feature: Javascript stuff Then "Focus object changed: *" should be logged And "Web*Dialog requested, but we don't support that!" should be logged And the following tabs should be open: + """ - data/javascript/window_open.html (active) + """ # https://github.com/qutebrowser/qutebrowser/issues/906 diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index 3ab5d2434..f7f354def 100644 --- a/tests/end2end/features/keyinput.feature +++ b/tests/end2end/features/keyinput.feature @@ -32,6 +32,7 @@ Feature: Keyboard input Scenario: :fake-key sending key to the website When I open data/keyinput/log.html + And I wait 0.01s And I run :fake-key x Then the javascript message "key press: 88" should be logged And the javascript message "key release: 88" should be logged @@ -48,12 +49,14 @@ Feature: Keyboard input Scenario: :fake-key sending special key to the website When I open data/keyinput/log.html + And I wait 0.01s And I run :fake-key Then the javascript message "key press: 27" should be logged And the javascript message "key release: 27" should be logged Scenario: :fake-key sending keychain to the website When I open data/keyinput/log.html + And I wait 0.01s And I run :fake-key xy" " Then the javascript message "key press: 88" should be logged And the javascript message "key release: 88" should be logged diff --git a/tests/end2end/features/marks.feature b/tests/end2end/features/marks.feature index c6821c62a..bf05c9f72 100644 --- a/tests/end2end/features/marks.feature +++ b/tests/end2end/features/marks.feature @@ -71,7 +71,7 @@ Feature: Setting positional marks And I run :jump-mark b Then the error "Mark b is not set" should be shown - @qtwebengine_skip: Does not emit loaded signal for fragments? + @qtwebengine_skip # Does not emit loaded signal for fragments? Scenario: Jumping to a local mark after changing fragments When I open data/marks.html#top And I run :scroll 'top' @@ -84,7 +84,7 @@ Feature: Setting positional marks And I wait until the scroll position changed to 10/10 Then the page should be scrolled to 10 10 - @qtwebengine_skip: Does not emit loaded signal for fragments? + @qtwebengine_skip # Does not emit loaded signal for fragments? Scenario: Jumping back after following a link When I hint with args "links normal" and follow s And I wait until data/marks.html#bottom is loaded diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 90ce5334a..6825621ea 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -137,8 +137,8 @@ Feature: Various utility commands. And "No output or error" should be logged Scenario: :jseval --file using a file that doesn't exist as js-code - When I run :jseval --file /nonexistentfile - Then the error "[Errno 2] *: '/nonexistentfile'" should be shown + When I run :jseval --file (rootpath)nonexistentfile + Then the error "[Errno 2] *: '*nonexistentfile'" should be shown And "No output or error" should not be logged @qtwebkit_skip @@ -151,7 +151,7 @@ Feature: Various utility commands. When I load a third-party iframe # rerun set_css in stylesheet.js And I set content.user_stylesheets to [] - Then the javascript message "Failed to style frame: Blocked a frame with origin * from accessing *" should be logged + Then the javascript message "Failed to style frame:* Blocked a frame with origin * from accessing *" should be logged # :debug-webaction @@ -162,12 +162,14 @@ Feature: Various utility commands. And I run :debug-webaction Back And I wait until data/backforward/1.txt is loaded Then the session should look like: + """ windows: - tabs: - history: - active: true url: http://localhost:*/data/backforward/1.txt - url: http://localhost:*/data/backforward/2.txt + """ Scenario: :debug-webaction with invalid value When I open data/hello.txt @@ -212,8 +214,10 @@ Feature: Various utility commands. And I open redirect-later-continue in a new tab And I wait 1s Then the unordered requests should be: + """ redirect-later-continue redirect-later?delay=-1 + """ # no request on / because we stopped the redirect Scenario: :stop with wrong count @@ -227,8 +231,10 @@ Feature: Various utility commands. And I run :reload And I wait until data/reload.txt is loaded Then the requests should be: + """ data/reload.txt data/reload.txt + """ Scenario: :reload with force When I open headers @@ -251,6 +257,7 @@ Feature: Various utility commands. When I run :tab-only And I run :view-source Then the session should look like: + """ windows: - tabs: - history: @@ -258,6 +265,7 @@ Feature: Various utility commands. url: http://localhost:*/data/hello.txt - active: true history: [] + """ And the page should contain the html "/* Literal.Number.Integer */" # Flaky due to :view-source being async? @@ -387,9 +395,11 @@ Feature: Various utility commands. @qtwebkit_skip Scenario: Custom headers via XHR When I set content.headers.custom to {"Accept": "config-value", "X-Qute-Test": "config-value"} + When I set content.headers.accept_language to "config-value" And I open data/misc/xhr_headers.html And I wait for the javascript message "Got headers via XHR" Then the header Accept should be set to '*/*' + And the header Accept-Language should be set to 'from XHR' And the header X-Qute-Test should be set to config-value ## https://github.com/qutebrowser/qutebrowser/issues/1523 @@ -438,11 +448,13 @@ Feature: Various utility commands. And I wait for "Closing window *" in the log And I wait for "removed: main-window" in the log Then the session should look like: + """ windows: - tabs: - active: true history: - url: http://localhost:*/data/hello3.txt + """ ## :click-element @@ -467,8 +479,10 @@ Feature: Various utility commands. And I run :click-element id link --target=tab Then data/hello.txt should be loaded And the following tabs should be open: + """ - data/click_element.html - data/hello.txt (active) + """ Scenario: Clicking an element by CSS selector When I open data/click_element.html @@ -507,7 +521,7 @@ Feature: Various utility commands. Scenario: Clicking on focused element When I open data/click_element.html - And I run :fake-key + And I run :jseval document.getElementById("qute-input").focus() And I wait for the javascript message "qute-input focused" And I run :click-element focused Then "Entering mode KeyMode.insert (reason: clicking input)" should be logged @@ -630,3 +644,10 @@ Feature: Various utility commands. And I open data/scroll/simple.html And I run :fake-key "" Then the page should be scrolled vertically + + @qtwebkit_skip + Scenario: Using DocumentPictureInPicture API + When I set content.javascript.can_open_tabs_automatically to true + And I open data/crashers/document_picture_in_picture.html + And I run :click-element id toggle + Then the javascript message "documentPictureInPicture support disabled!" should be logged diff --git a/tests/end2end/features/navigate.feature b/tests/end2end/features/navigate.feature index 8d224256c..c61833ea4 100644 --- a/tests/end2end/features/navigate.feature +++ b/tests/end2end/features/navigate.feature @@ -73,7 +73,7 @@ Feature: Using :navigate # increment/decrement - @qtwebengine_todo: Doesn't find any elements + @qtwebengine_todo # Doesn't find any elements Scenario: Navigating multiline links When I open data/navigate/multilinelinks.html And I run :navigate next diff --git a/tests/end2end/features/notifications.feature b/tests/end2end/features/notifications.feature index 8f12afd6a..5f6e493a7 100644 --- a/tests/end2end/features/notifications.feature +++ b/tests/end2end/features/notifications.feature @@ -12,7 +12,8 @@ Feature: Notifications When I run :click-element id show-button Then the javascript message "notification shown" should be logged And 1 notification should be presented - And the notification should have image dimensions 64x64 # qutebrowser logo + # qutebrowser logo + And the notification should have image dimensions 64x64 Scenario: Notification containing escaped characters Given the notification server supports body markup @@ -90,7 +91,8 @@ Feature: Notifications And the javascript message "i=3 notification shown" should be logged And "Ignoring notification tag 'counter' due to PyQt bug" should be logged And 3 notifications should be presented - And the notification should have title "i=3" # last one + # last one + And the notification should have title "i=3" @pyqtwebengine>=5.15.0 Scenario: User closes presented notification @@ -123,9 +125,11 @@ Feature: Notifications And I click the notification Then the javascript message "notification clicked" should be logged And the following tabs should be open: + """ - about:blank - data/javascript/notifications.html (active) - about:blank + """ @pyqtwebengine<5.15.0 Scenario: User clicks presented notification (old Qt) diff --git a/tests/end2end/features/open.feature b/tests/end2end/features/open.feature index 62c12aade..da915ca49 100644 --- a/tests/end2end/features/open.feature +++ b/tests/end2end/features/open.feature @@ -6,6 +6,7 @@ Feature: Opening pages And I wait until data/numbers/1.txt is loaded And I run :tab-only Then the session should look like: + """ windows: - tabs: - active: true @@ -13,6 +14,7 @@ Feature: Opening pages - url: about:blank - active: true url: http://localhost:*/data/numbers/1.txt + """ Scenario: :open without URL When I set url.default_page to http://localhost:(port)/data/numbers/11.txt @@ -46,8 +48,10 @@ Feature: Opening pages And I run :open -t http://localhost:(port)/data/numbers/4.txt And I wait until data/numbers/4.txt is loaded Then the following tabs should be open: + """ - about:blank - data/numbers/4.txt (active) + """ Scenario: Opening in a new background tab Given I open about:blank @@ -55,8 +59,10 @@ Feature: Opening pages And I run :open -b http://localhost:(port)/data/numbers/5.txt And I wait until data/numbers/5.txt is loaded Then the following tabs should be open: + """ - about:blank (active) - data/numbers/5.txt + """ Scenario: :open with count Given I open about:blank @@ -65,6 +71,7 @@ Feature: Opening pages And I run :open http://localhost:(port)/data/numbers/6.txt with count 2 And I wait until data/numbers/6.txt is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -74,6 +81,7 @@ Feature: Opening pages - url: about:blank - active: true url: http://localhost:*/data/numbers/6.txt + """ Scenario: Opening in a new tab (unrelated) Given I open about:blank @@ -83,8 +91,10 @@ Feature: Opening pages And I run :open -t http://localhost:(port)/data/numbers/7.txt And I wait until data/numbers/7.txt is loaded Then the following tabs should be open: + """ - about:blank - data/numbers/7.txt (active) + """ Scenario: Opening in a new tab (related) Given I open about:blank @@ -94,8 +104,10 @@ Feature: Opening pages And I run :open -t --related http://localhost:(port)/data/numbers/8.txt And I wait until data/numbers/8.txt is loaded Then the following tabs should be open: + """ - data/numbers/8.txt (active) - about:blank + """ Scenario: Opening in a new window Given I open about:blank @@ -103,6 +115,7 @@ Feature: Opening pages And I run :open -w http://localhost:(port)/data/numbers/9.txt And I wait until data/numbers/9.txt is loaded Then the session should look like: + """ windows: - tabs: - active: true @@ -114,6 +127,7 @@ Feature: Opening pages history: - active: true url: http://localhost:*/data/numbers/9.txt + """ Scenario: Opening a quickmark When I run :quickmark-add http://localhost:(port)/data/numbers/10.txt quickmarktest diff --git a/tests/end2end/features/private.feature b/tests/end2end/features/private.feature index 14e9cbef7..daef2dda4 100644 --- a/tests/end2end/features/private.feature +++ b/tests/end2end/features/private.feature @@ -73,6 +73,7 @@ Feature: Using private browsing And I run :navigate -w increment And I wait until data/numbers/2.txt is loaded Then the session should look like: + """ windows: - private: True tabs: @@ -82,6 +83,7 @@ Feature: Using private browsing tabs: - history: - url: http://localhost:*/data/numbers/2.txt + """ Scenario: Opening private window with :navigate next # Private window handled in navigate.py @@ -90,6 +92,7 @@ Feature: Using private browsing And I run :navigate -w next And I wait until data/navigate/next.html is loaded Then the session should look like: + """ windows: - private: True tabs: @@ -99,6 +102,7 @@ Feature: Using private browsing tabs: - history: - url: http://localhost:*/data/navigate/next.html + """ Scenario: Opening private window with :tab-clone When I open data/hello.txt in a private window @@ -106,6 +110,7 @@ Feature: Using private browsing And I run :tab-clone -w And I wait until data/hello.txt is loaded Then the session should look like: + """ windows: - private: True tabs: @@ -115,6 +120,7 @@ Feature: Using private browsing tabs: - history: - url: http://localhost:*/data/hello.txt + """ Scenario: Opening private window via :click-element When I open data/click_element.html in a private window @@ -122,6 +128,7 @@ Feature: Using private browsing And I run :click-element --target window id link And I wait until data/hello.txt is loaded Then the session should look like: + """ windows: - private: True tabs: @@ -131,6 +138,7 @@ Feature: Using private browsing tabs: - history: - url: http://localhost:*/data/hello.txt + """ Scenario: Skipping private window when saving session When I open data/hello.txt in a private window @@ -145,7 +153,7 @@ Feature: Using private browsing Then the javascript message "console.log works!" should not be logged # Probably needs qutewm to work properly... - @qtwebkit_skip: Only applies to QtWebEngine @xfail_norun + @qtwebkit_skip @xfail_norun # Only applies to QtWebEngine Scenario: Make sure local storage is isolated with private browsing When I open data/hello.txt in a private window And I run :jseval localStorage.qute_private_test = 42 @@ -163,12 +171,14 @@ Feature: Using private browsing And I run :quickmark-load two And I wait until data/numbers/2.txt is loaded Then the session should look like: + """ windows: - private: True tabs: - history: - url: http://localhost:*/data/numbers/1.txt - url: http://localhost:*/data/numbers/2.txt + """ @skip # Too flaky Scenario: Saving a private session with only-active-window @@ -188,6 +198,7 @@ Feature: Using private browsing And I run :session-load -c window_session_name And I wait until data/numbers/5.txt is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -197,6 +208,7 @@ Feature: Using private browsing - history: - active: true url: http://localhost:*/data/numbers/5.txt + """ # https://github.com/qutebrowser/qutebrowser/issues/5810 diff --git a/tests/end2end/features/prompts.feature b/tests/end2end/features/prompts.feature index 32bdd29e7..86c44323f 100644 --- a/tests/end2end/features/prompts.feature +++ b/tests/end2end/features/prompts.feature @@ -62,7 +62,7 @@ Feature: Prompts # Multiple prompts - @qtwebengine_skip: QtWebEngine refuses to load anything with a JS question + @qtwebengine_skip # QtWebEngine refuses to load anything with a JS question Scenario: Blocking question interrupted by blocking one When I set content.javascript.alert to true And I open data/prompt/jsalert.html @@ -78,7 +78,7 @@ Feature: Prompts Then the javascript message "confirm reply: true" should be logged And the javascript message "Alert done" should be logged - @qtwebengine_skip: QtWebEngine refuses to load anything with a JS question + @qtwebengine_skip # QtWebEngine refuses to load anything with a JS question Scenario: Blocking question interrupted by async one Given I have a fresh instance When I set content.javascript.alert to true @@ -159,6 +159,131 @@ Feature: Prompts And I run :click-element id button Then the javascript message "Prompt reply: null" should be logged + # Clipboard permissions - static + + @qtwebkit_skip + Scenario: Clipboard - no permission - copy + When I set content.javascript.clipboard to none + And I open data/prompt/clipboard.html + And I run :click-element id copy + Then the javascript message "Failed to copy text." should be logged + + @qtwebkit_skip + Scenario: Clipboard - no permission - paste + When I set content.javascript.clipboard to none + And I open data/prompt/clipboard.html + And I run :click-element id paste + Then the javascript message "Failed to read from clipboard." should be logged + + # access permission no longer allows copy permission on 6.8 because it + # falls back to a permission prompt that we don't support + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-130599 + @qt<6.8 @qtwebkit_skip + Scenario: Clipboard - access permission - copy + When I set content.javascript.clipboard to access + And I open data/prompt/clipboard.html + And I run :click-element id copy + Then the javascript message "Text copied: default text" should be logged + + @qtwebkit_skip + Scenario: Clipboard - access permission - paste + When I set content.javascript.clipboard to access + And I open data/prompt/clipboard.html + And I run :click-element id paste + Then the javascript message "Failed to read from clipboard." should be logged + + @qtwebkit_skip + Scenario: Clipboard - full permission - copy + When I set content.javascript.clipboard to access-paste + And I open data/prompt/clipboard.html + And I run :click-element id copy + Then the javascript message "Text copied: default text" should be logged + + @qtwebkit_skip + Scenario: Clipboard - full permission - paste + When I set content.javascript.clipboard to access-paste + And I open data/prompt/clipboard.html + And I run :click-element id paste + Then the javascript message "Text pasted: *" should be logged + + # Clipboard permissions - prompt + # A fresh instance is only required for these tests on Qt<6.8 + + @qt>=6.8 + Scenario: Clipboard - ask allow - copy + Given I may need a fresh instance + When I set content.javascript.clipboard to ask + And I open data/prompt/clipboard.html + And I run :click-element id copy + And I wait for a prompt + And I run :prompt-accept yes + Then the javascript message "Text copied: default text" should be logged + + @qt>=6.8 + Scenario: Clipboard - ask allow - paste + Given I may need a fresh instance + When I set content.javascript.clipboard to ask + And I open data/prompt/clipboard.html + And I run :click-element id paste + And I wait for a prompt + And I run :prompt-accept yes + Then the javascript message "Text pasted: *" should be logged + + @qt>=6.8 + Scenario: Clipboard - ask deny - copy + Given I may need a fresh instance + When I set content.javascript.clipboard to ask + And I open data/prompt/clipboard.html + And I run :click-element id copy + And I wait for a prompt + And I run :prompt-accept no + Then the javascript message "Failed to copy text." should be logged + + @qt>=6.8 + Scenario: Clipboard - ask deny - paste + Given I may need a fresh instance + When I set content.javascript.clipboard to ask + And I open data/prompt/clipboard.html + And I run :click-element id paste + And I wait for a prompt + And I run :prompt-accept no + Then the javascript message "Failed to read from clipboard." should be logged + + @qt>=6.8 + Scenario: Clipboard - ask per url - paste + Given I may need a fresh instance + When I set content.javascript.clipboard to none + And I run :set -u localhost:* content.javascript.clipboard ask + And I open data/prompt/clipboard.html + And I run :click-element id paste + And I wait for a prompt + And I run :prompt-accept yes + Then the javascript message "Text pasted: *" should be logged + And I run :config-unset -u localhost:* content.javascript.clipboard + + @qt>=6.8 + Scenario: Clipboard - deny per url - paste + Given I may need a fresh instance + When I set content.javascript.clipboard to access-paste + And I run :set -u localhost:* content.javascript.clipboard none + And I open data/prompt/clipboard.html + And I run :click-element id paste + Then the javascript message "Failed to read from clipboard." should be logged + And I run :config-unset -u localhost:* content.javascript.clipboard + + @qt>=6.8 + Scenario: Clipboard - ask allow persistent - paste + Given I may need a fresh instance + When I set content.javascript.clipboard to ask + And I open data/prompt/clipboard.html + And I run :click-element id paste + And I wait for a prompt + And I run :prompt-accept --save yes + And I wait for "*Text pasted: *" in the log + And I reload data/prompt/clipboard.html + And I run :click-element id paste + Then the javascript message "Text pasted: *" should be logged + # SSL Scenario: SSL error with content.tls.certificate_errors = load-insecurely @@ -252,6 +377,7 @@ Feature: Prompts Then the javascript message "geolocation permission denied" should be logged Scenario: geolocation with ask -> false + Given I may need a fresh instance When I set content.geolocation to ask And I open data/prompt/geolocation.html in a new tab And I run :click-element id button @@ -260,6 +386,7 @@ Feature: Prompts Then the javascript message "geolocation permission denied" should be logged Scenario: geolocation with ask -> false and save + Given I may need a fresh instance When I set content.geolocation to ask And I open data/prompt/geolocation.html in a new tab And I run :click-element id button @@ -269,6 +396,7 @@ Feature: Prompts And the per-domain option content.geolocation should be set to false for http://localhost:(port) Scenario: geolocation with ask -> abort + Given I may need a fresh instance When I set content.geolocation to ask And I open data/prompt/geolocation.html in a new tab And I run :click-element id button @@ -369,10 +497,12 @@ Feature: Prompts And I run :prompt-accept And I wait until basic-auth/user1/password1 is loaded Then the json on the page should be: + """ { "authenticated": true, "user": "user1" } + """ Scenario: Authentication with :prompt-accept value When I open about:blank in a new tab @@ -381,10 +511,12 @@ Feature: Prompts And I run :prompt-accept user2:password2 And I wait until basic-auth/user2/password2 is loaded Then the json on the page should be: + """ { "authenticated": true, "user": "user2" } + """ Scenario: Authentication with invalid :prompt-accept value When I open about:blank in a new tab @@ -407,13 +539,15 @@ Feature: Prompts And I run :prompt-accept And I wait until basic-auth/user4/password4 is loaded Then the json on the page should be: + """ { "authenticated": true, "user": "user4" } + """ @qtwebengine_skip - Scenario: Cancellling webpage authentication with QtWebKit + Scenario: Cancelling webpage authentication with QtWebKit When I open basic-auth/user6/password6 without waiting And I wait for a prompt And I run :mode-leave @@ -494,7 +628,7 @@ Feature: Prompts Then "Added quickmark prompt-in-command-mode for *" should be logged # https://github.com/qutebrowser/qutebrowser/issues/1093 - @qtwebengine_skip: QtWebEngine doesn't open the second page/prompt + @qtwebengine_skip # QtWebEngine doesn't open the second page/prompt Scenario: Keyboard focus with multiple auth prompts When I open basic-auth/user5/password5 without waiting And I open basic-auth/user6/password6 in a new tab without waiting @@ -514,10 +648,12 @@ Feature: Prompts And I wait until basic-auth/user5/password5 is loaded # We're on the second page Then the json on the page should be: + """ { "authenticated": true, "user": "user6" } + """ # https://github.com/qutebrowser/qutebrowser/issues/1249#issuecomment-175205531 # https://github.com/qutebrowser/qutebrowser/pull/2054#issuecomment-258285544 diff --git a/tests/end2end/features/qutescheme.feature b/tests/end2end/features/qutescheme.feature index e99b9af9d..3e84c39fd 100644 --- a/tests/end2end/features/qutescheme.feature +++ b/tests/end2end/features/qutescheme.feature @@ -11,7 +11,9 @@ Feature: Special qute:// pages And I run :help And I wait until qute://help/index.html is loaded Then the following tabs should be open: + """ - qute://help/index.html (active) + """ Scenario: :help with invalid topic When I run :help foo @@ -23,7 +25,9 @@ Feature: Special qute:// pages And I run :help :back And I wait until qute://help/commands.html#back is loaded Then the following tabs should be open: + """ - qute://help/commands.html#back (active) + """ Scenario: :help with invalid command When I run :help :foo @@ -35,7 +39,9 @@ Feature: Special qute:// pages And I run :help editor.command And I wait until qute://help/settings.html#editor.command is loaded Then the following tabs should be open: + """ - qute://help/settings.html#editor.command (active) + """ Scenario: :help with -t When the documentation is up to date @@ -43,8 +49,10 @@ Feature: Special qute:// pages And I run :help -t And I wait until qute://help/index.html is loaded Then the following tabs should be open: + """ - about:blank - qute://help/index.html (active) + """ # https://github.com/qutebrowser/qutebrowser/issues/2513 Scenario: Opening link with qute:help @@ -96,15 +104,19 @@ Feature: Special qute:// pages And I run :history And I wait until qute://history/ is loaded Then the following tabs should be open: + """ - qute://history/ (active) + """ Scenario: :history with -t When I run :tab-only And I run :history -t And I wait until qute://history/ is loaded Then the following tabs should be open: + """ - about:blank - qute://history/ (active) + """ # qute://settings @@ -190,7 +202,7 @@ Feature: Special qute:// pages And I set downloads.location.prompt to true And I open data/misc/test.pdf without waiting And I wait until PDF.js is ready - And I run :jseval document.getElementById("download").click() + And I run :jseval (document.getElementById("downloadButton") || document.getElementById("download")).click() And I wait for "Asking question option=None text=* title='Save file to:'>, *" in the log And I run :mode-leave Then no crash should happen @@ -291,20 +303,22 @@ Feature: Special qute:// pages # :version + @qt69_ci_flaky Scenario: Open qute://version When I open qute://version Then the page should contain the plaintext "Version info" # qute://gpl + @qt69_ci_flaky Scenario: Open qute://gpl When I open qute://gpl Then the page should contain the plaintext "GNU GENERAL PUBLIC LICENSE" # qute://start - # QtWebKit doesn't support formaction - @qtwebkit_skip + # QtWebKit doesn't support formaction; unknown Qt 6.9 renderer process crashes + @qtwebkit_skip @qt69_ci_flaky Scenario: Searching on qute://start When I set url.searchengines to {"DEFAULT": "http://localhost:(port)/data/title.html?q={}"} And I open qute://start diff --git a/tests/end2end/features/scroll.feature b/tests/end2end/features/scroll.feature index d83c47fa4..042f09735 100644 --- a/tests/end2end/features/scroll.feature +++ b/tests/end2end/features/scroll.feature @@ -223,7 +223,7 @@ Feature: Scrolling When I run :scroll-to-perc with count 50 Then the page should be scrolled vertically - @qtwebengine_skip: Causes memory leak... + @qtwebengine_skip # Causes memory leak... Scenario: :scroll-to-perc with a very big value When I run :scroll-to-perc 99999999999 Then no crash should happen @@ -305,13 +305,15 @@ Feature: Scrolling And I wait until the scroll position changed And I run :scroll-page --bottom-navigate next 0 1 Then the following tabs should be open: + """ - data/scroll/simple.html + """ Scenario: :scroll-page with --top-navigate When I run :scroll-page --top-navigate prev 0 -1 Then data/hello3.txt should be loaded - @qtwebengine_skip: Causes memory leak... + @qtwebengine_skip # Causes memory leak... Scenario: :scroll-page with a very big value When I run :scroll-page 99999999999 99999999999 Then the error "Numeric argument is too large for internal int representation." should be shown diff --git a/tests/end2end/features/search.feature b/tests/end2end/features/search.feature index 38c474db2..8b9dbada3 100644 --- a/tests/end2end/features/search.feature +++ b/tests/end2end/features/search.feature @@ -113,7 +113,8 @@ Feature: Searching on a page When I set search.ignore_case to smart And I run :search Foo And I wait for "search found Foo with flags FindCaseSensitively" in the log - Then "Foo" should be found # even though foo was first + # even though foo was first + Then "Foo" should be found ## :search-next @@ -344,8 +345,10 @@ Feature: Searching on a page And I run :selection-follow -t And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/search.html - data/hello.txt (active) + """ Scenario: Don't follow searched text When I run :window-only @@ -353,7 +356,9 @@ Feature: Searching on a page And I wait for "search found foo" in the log And I run :selection-follow Then the following tabs should be open: + """ - data/search.html (active) + """ Scenario: Don't follow searched text in a new tab When I run :window-only @@ -361,7 +366,9 @@ Feature: Searching on a page And I wait for "search found foo" in the log And I run :selection-follow -t Then the following tabs should be open: + """ - data/search.html (active) + """ Scenario: Follow a manually selected link When I run :jseval --file (testdata)/search_select.js @@ -374,10 +381,12 @@ Feature: Searching on a page And I run :selection-follow -t And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/search.html - data/hello.txt (active) + """ - @qtwebkit_skip: Not supported in qtwebkit @skip + @qtwebkit_skip @skip # Not supported in qtwebkit Scenario: Follow a searched link in an iframe When I open data/iframe_search.html And I wait for "* search loaded" in the log @@ -387,7 +396,7 @@ Feature: Searching on a page And I run :selection-follow Then "navigation request: url http://localhost:*/data/hello.txt (current http://localhost:*/data/iframe_search.html), type link_clicked, is_main_frame False" should be logged - @qtwebkit_skip: Not supported in qtwebkit @skip + @qtwebkit_skip @skip # Not supported in qtwebkit Scenario: Follow a tabbed searched link in an iframe When I open data/iframe_search.html And I wait for "* search loaded" in the log @@ -397,8 +406,10 @@ Feature: Searching on a page And I run :selection-follow -t And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/iframe_search.html - data/hello.txt (active) + """ Scenario: Closing a tab during a search When I run :open -b about:blank diff --git a/tests/end2end/features/sessions.feature b/tests/end2end/features/sessions.feature index 2f121132f..9a73bf53e 100644 --- a/tests/end2end/features/sessions.feature +++ b/tests/end2end/features/sessions.feature @@ -7,6 +7,7 @@ Feature: Saving and loading sessions When I open data/hello.txt And I open data/title.html in a new tab Then the session should look like: + """ windows: - active: true tabs: @@ -19,12 +20,14 @@ Feature: Saving and loading sessions - active: true url: http://localhost:*/data/title.html title: Test title + """ @qtwebengine_skip Scenario: Zooming (qtwebkit) When I open data/hello.txt And I run :zoom 50 Then the session should look like: + """ windows: - tabs: - history: @@ -32,6 +35,7 @@ Feature: Saving and loading sessions zoom: 1.0 - url: http://localhost:*/data/hello.txt zoom: 0.5 + """ # The zoom level is only stored for the newest element for QtWebEngine. @qtwebkit_skip @@ -39,18 +43,21 @@ Feature: Saving and loading sessions When I open data/hello.txt And I run :zoom 50 Then the session should look like: + """ windows: - tabs: - history: - url: about:blank - url: http://localhost:*/data/hello.txt zoom: 0.5 + """ @qtwebengine_skip Scenario: Scrolling (qtwebkit) When I open data/scroll/simple.html And I run :scroll-px 10 20 Then the session should look like: + """ windows: - tabs: - history: @@ -62,6 +69,7 @@ Feature: Saving and loading sessions scroll-pos: x: 10 y: 20 + """ # The scroll position is only stored for the newest element for QtWebEngine. @qtwebkit_skip @@ -70,6 +78,7 @@ Feature: Saving and loading sessions And I run :scroll-px 10 20 And I wait until the scroll position changed to 10/20 Then the session should look like: + """ windows: - tabs: - history: @@ -78,10 +87,12 @@ Feature: Saving and loading sessions scroll-pos: x: 10 y: 20 + """ Scenario: Redirect When I open redirect-to?url=data/title.html without waiting And I wait until data/title.html is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -90,16 +101,19 @@ Feature: Saving and loading sessions url: http://localhost:*/data/title.html original-url: http://localhost:*/redirect-to?url=data/title.html title: Test title + """ Scenario: Valid UTF-8 data When I open data/sessions/snowman.html Then the session should look like: + """ windows: - tabs: - history: - url: about:blank - url: http://localhost:*/data/sessions/snowman.html title: snow☃man + """ @qtwebengine_skip Scenario: Long output comparison (qtwebkit) @@ -109,6 +123,7 @@ Feature: Saving and loading sessions And I open data/numbers/3.txt in a new window # Full output apart from "geometry:" and the active window (needs qutewm) Then the session should look like: + """ windows: - tabs: - history: @@ -150,6 +165,7 @@ Feature: Saving and loading sessions title: '' url: http://localhost:*/data/numbers/3.txt zoom: 1.0 + """ # FIXME:qtwebengine what's up with the titles there? @qtwebkit_skip @@ -160,6 +176,7 @@ Feature: Saving and loading sessions And I open data/numbers/3.txt in a new window # Full output apart from "geometry:" and the active window (needs qutewm) Then the session should look like: + """ windows: - tabs: - history: @@ -193,26 +210,31 @@ Feature: Saving and loading sessions title: localhost:*/data/numbers/3.txt url: http://localhost:*/data/numbers/3.txt zoom: 1.0 + """ Scenario: Saving with --no-history When I open data/numbers/1.txt And I open data/numbers/2.txt And I open data/numbers/3.txt Then the session saved with --no-history should look like: + """ windows: - tabs: - history: - url: http://localhost:*/data/numbers/3.txt + """ Scenario: Saving with --no-history and --only-active-window When I open data/numbers/1.txt And I open data/numbers/2.txt And I open data/numbers/3.txt Then the session saved with --no-history --only-active-window should look like: + """ windows: - tabs: - history: - url: http://localhost:*/data/numbers/3.txt + """ # https://github.com/qutebrowser/qutebrowser/issues/879 @@ -220,6 +242,7 @@ Feature: Saving and loading sessions When I open data/sessions/history_replace_state.html without waiting Then the javascript message "Called history.replaceState" should be logged And the session should look like: + """ windows: - tabs: - history: @@ -227,6 +250,7 @@ Feature: Saving and loading sessions - active: true url: http://localhost:*/data/sessions/history_replace_state.html?state=2 title: Test title + """ @qtwebengine_skip Scenario: Saving a session with a page using history.replaceState() and navigating away (qtwebkit) @@ -234,6 +258,7 @@ Feature: Saving and loading sessions And I open data/hello.txt Then the javascript message "Called history.replaceState" should be logged And the session should look like: + """ windows: - tabs: - history: @@ -244,6 +269,7 @@ Feature: Saving and loading sessions title: http://localhost:*/data/sessions/history_replace_state.html?state=2 - active: true url: http://localhost:*/data/hello.txt + """ # Seems like that bug is fixed upstream in QtWebEngine @skip # Too flaky @@ -252,6 +278,7 @@ Feature: Saving and loading sessions And I wait for "* Called history.replaceState" in the log And I open data/hello.txt Then the session should look like: + """ windows: - tabs: - history: @@ -260,6 +287,7 @@ Feature: Saving and loading sessions title: Test title - active: true url: http://localhost:*/data/hello.txt + """ # :session-save @@ -314,6 +342,7 @@ Feature: Saving and loading sessions And I wait until data/numbers/4.txt is loaded And I wait until data/numbers/5.txt is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -327,6 +356,7 @@ Feature: Saving and loading sessions - history: - active: true url: http://localhost:*/data/numbers/5.txt + """ # https://github.com/qutebrowser/qutebrowser/issues/7696 @qtwebkit_skip @@ -339,6 +369,7 @@ Feature: Saving and loading sessions And I run :session-load --clear current And I wait until data/downloads/downloads.html is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -347,6 +378,7 @@ Feature: Saving and loading sessions url: http://localhost:*/data/downloads/downloads.html - active: true history: [] + """ # :session-delete @@ -436,10 +468,12 @@ Feature: Saving and loading sessions And I open data/numbers/4.txt Then the message "Tab is pinned! Opening in new tab." should be shown And the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (active) (pinned) - data/numbers/4.txt - data/numbers/3.txt + """ # Make sure the new_position.related setting doesn't change the tab order # when loading from a session. @@ -452,6 +486,8 @@ Feature: Saving and loading sessions And I run :session-load -c foo And I wait until data/numbers/3.txt is loaded Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) + """ diff --git a/tests/end2end/features/spawn.feature b/tests/end2end/features/spawn.feature index 563259fc8..24ac6d40e 100644 --- a/tests/end2end/features/spawn.feature +++ b/tests/end2end/features/spawn.feature @@ -13,8 +13,8 @@ Feature: :spawn Then the error "Userscript 'this_does_not_exist' not found in userscript directories *" should be shown Scenario: Starting a userscript with absolute path which doesn't exist - When I run :spawn -u /this_does_not_exist - Then the error "Userscript '/this_does_not_exist' not found" should be shown + When I run :spawn -u (rootpath)this_does_not_exist + Then the error "Userscript '*this_does_not_exist' not found" should be shown Scenario: Running :spawn with invalid quoting When I run :spawn ""'"" @@ -54,8 +54,10 @@ Feature: :spawn And I run :spawn -u (testdata)/userscripts/open_current_url And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hello.txt - data/hello.txt (active) + """ @posix Scenario: Running :spawn with userscript and count @@ -75,8 +77,10 @@ Feature: :spawn And I run :spawn -u (testdata)/userscripts/open_current_url.bat And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hello.txt - data/hello.txt (active) + """ @posix Scenario: Running :spawn with userscript that expects the stdin getting closed diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index 942ee2028..bc4297753 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -14,8 +14,10 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-close Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (active) + """ Scenario: :tab-close with count When I open data/numbers/1.txt @@ -23,8 +25,10 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-close with count 1 Then the following tabs should be open: + """ - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: :tab-close with invalid count When I open data/numbers/1.txt @@ -32,9 +36,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-close with count 23 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: :tab-close with tabs.select_on_remove = next When I set tabs.select_on_remove to next @@ -44,8 +50,10 @@ Feature: Tab management And I run :tab-focus 2 And I run :tab-close Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/3.txt (active) + """ Scenario: :tab-close with tabs.select_on_remove = prev When I set tabs.select_on_remove to prev @@ -55,8 +63,10 @@ Feature: Tab management And I run :tab-focus 2 And I run :tab-close Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/3.txt + """ Scenario: :tab-close with tabs.select_on_remove = last-used When I set tabs.select_on_remove to last-used @@ -67,9 +77,11 @@ Feature: Tab management And I run :tab-focus 2 And I run :tab-close Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/3.txt - data/numbers/4.txt (active) + """ Scenario: :tab-close with tabs.select_on_remove = prev and --next When I set tabs.select_on_remove to prev @@ -79,8 +91,10 @@ Feature: Tab management And I run :tab-focus 2 And I run :tab-close --next Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/3.txt (active) + """ Scenario: :tab-close with tabs.select_on_remove = next and --prev When I set tabs.select_on_remove to next @@ -90,8 +104,10 @@ Feature: Tab management And I run :tab-focus 2 And I run :tab-close --prev Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/3.txt + """ Scenario: :tab-close with tabs.select_on_remove = prev and --opposite When I set tabs.select_on_remove to prev @@ -101,8 +117,10 @@ Feature: Tab management And I run :tab-focus 2 And I run :tab-close --opposite Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/3.txt (active) + """ Scenario: :tab-close with tabs.select_on_remove = next and --opposite When I set tabs.select_on_remove to next @@ -112,8 +130,10 @@ Feature: Tab management And I run :tab-focus 2 And I run :tab-close --opposite Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/3.txt + """ Scenario: :tab-close with tabs.select_on_remove = last-used and --opposite When I set tabs.select_on_remove to last-used @@ -131,8 +151,10 @@ Feature: Tab management And I run :tab-focus 2 And I run :tab-close Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/4.txt (active) + """ # :tab-only @@ -142,7 +164,9 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-only Then the following tabs should be open: + """ - data/numbers/3.txt (active) + """ Scenario: :tab-only with --prev When I open data/numbers/1.txt @@ -151,8 +175,10 @@ Feature: Tab management And I run :tab-focus 2 And I run :tab-only --prev Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (active) + """ Scenario: :tab-only with --next When I open data/numbers/1.txt @@ -161,8 +187,10 @@ Feature: Tab management And I run :tab-focus 2 And I run :tab-only --next Then the following tabs should be open: + """ - data/numbers/2.txt (active) - data/numbers/3.txt + """ Scenario: :tab-only with --prev and --next When I run :tab-only --prev --next @@ -180,9 +208,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-focus 2 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (active) - data/numbers/3.txt + """ Scenario: :tab-focus without index/count When I open data/numbers/1.txt @@ -192,9 +222,11 @@ Feature: Tab management And I run :tab-focus Then the warning "Using :tab-focus without count is deprecated, use :tab-next instead." should be shown And the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: :tab-focus with invalid index When I run :tab-focus 23 @@ -210,9 +242,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-focus with count 2 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (active) - data/numbers/3.txt + """ Scenario: :tab-focus with count and index When I open data/numbers/1.txt @@ -220,9 +254,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-focus 4 with count 2 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (active) - data/numbers/3.txt + """ Scenario: :tab-focus last When I open data/numbers/1.txt @@ -232,9 +268,11 @@ Feature: Tab management And I run :tab-focus 3 And I run :tab-focus last Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/2.txt - data/numbers/3.txt + """ Scenario: :tab-focus with current tab number When I open data/numbers/1.txt @@ -244,9 +282,11 @@ Feature: Tab management And I run :tab-focus 3 And I run :tab-focus 3 Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/2.txt - data/numbers/3.txt + """ Scenario: :tab-focus with current tab number and --no-last When I open data/numbers/1.txt @@ -256,9 +296,11 @@ Feature: Tab management And I run :tab-focus 3 And I run :tab-focus --no-last 3 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: :tab-focus with -1 When I open data/numbers/1.txt @@ -267,9 +309,11 @@ Feature: Tab management And I run :tab-focus 1 And I run :tab-focus -1 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: :tab-focus negative index When I open data/numbers/1.txt @@ -277,9 +321,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-focus -2 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (active) - data/numbers/3.txt + """ Scenario: :tab-focus with invalid negative index When I open data/numbers/1.txt @@ -305,11 +351,13 @@ Feature: Tab management And I run :tab-focus 3 And I run :cmd-repeat 2 tab-focus stack-prev Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (active) - data/numbers/3.txt - data/numbers/4.txt - data/numbers/5.txt + """ Scenario: :tab-focus next stacking When I open data/numbers/1.txt @@ -325,11 +373,13 @@ Feature: Tab management And I run :cmd-repeat 3 tab-focus stack-prev And I run :cmd-repeat 2 tab-focus stack-next Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt - data/numbers/4.txt (active) - data/numbers/5.txt + """ Scenario: :tab-focus stacking limit When I set tabs.focus_stack_size to 1 @@ -349,11 +399,13 @@ Feature: Tab management And I run :cmd-repeat 4 tab-focus stack-prev Then the error "Could not find requested tab!" should be shown And the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/2.txt - data/numbers/3.txt - data/numbers/4.txt - data/numbers/5.txt + """ Scenario: :tab-focus stacking and last When I open data/numbers/1.txt @@ -369,11 +421,13 @@ Feature: Tab management And I run :cmd-repeat 2 tab-focus stack-prev And I run :cmd-repeat 3 tab-focus last Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt - data/numbers/4.txt (active) - data/numbers/5.txt + """ Scenario: :tab-focus last after moving current tab @@ -383,9 +437,11 @@ Feature: Tab management And I run :tab-move 2 And I run :tab-focus last Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/3.txt - data/numbers/2.txt (active) + """ Scenario: :tab-focus last after closing a lower number tab When I open data/numbers/1.txt @@ -394,8 +450,10 @@ Feature: Tab management And I run :tab-close with count 1 And I run :tab-focus last Then the following tabs should be open: + """ - data/numbers/2.txt (active) - data/numbers/3.txt + """ # tab-prev/tab-next @@ -404,8 +462,10 @@ Feature: Tab management And I open data/numbers/2.txt in a new tab And I run :tab-prev Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/2.txt + """ Scenario: :tab-next When I open data/numbers/1.txt @@ -413,8 +473,10 @@ Feature: Tab management And I run :tab-focus 1 And I run :tab-next Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (active) + """ Scenario: :tab-prev with count When I open data/numbers/1.txt @@ -422,9 +484,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-prev with count 2 Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/2.txt - data/numbers/3.txt + """ Scenario: :tab-next with count When I open data/numbers/1.txt @@ -433,9 +497,11 @@ Feature: Tab management And I run :tab-focus 1 And I run :tab-next with count 2 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: :tab-prev on first tab without wrap When I set tabs.wrap to false @@ -457,9 +523,11 @@ Feature: Tab management And I run :tab-focus 1 And I run :tab-prev Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: :tab-next with last tab with wrap When I set tabs.wrap to true @@ -468,9 +536,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-next Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/2.txt - data/numbers/3.txt + """ Scenario: :tab-next with last tab, wrap and count When I set tabs.wrap to true @@ -479,9 +549,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-next with count 2 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (active) - data/numbers/3.txt + """ # :tab-move @@ -491,9 +563,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-move Then the following tabs should be open: + """ - data/numbers/3.txt (active) - data/numbers/1.txt - data/numbers/2.txt + """ Scenario: :tab-move with absolute position and count. When I open data/numbers/1.txt @@ -501,9 +575,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-move with count 2 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/3.txt (active) - data/numbers/2.txt + """ Scenario: :tab-move with absolute position and invalid count. When I open data/numbers/1.txt @@ -512,9 +588,11 @@ Feature: Tab management And I run :tab-move with count 23 Then the error "Can't move tab to position 23!" should be shown And the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: :tab-move with index. When I open data/numbers/1.txt @@ -522,9 +600,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-move 2 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/3.txt (active) - data/numbers/2.txt + """ Scenario: :tab-move with negative index. When I open data/numbers/1.txt @@ -532,9 +612,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-move -3 Then the following tabs should be open: + """ - data/numbers/3.txt (active) - data/numbers/1.txt - data/numbers/2.txt + """ Scenario: :tab-move with invalid index. When I open data/numbers/1.txt @@ -543,9 +625,11 @@ Feature: Tab management And I run :tab-move -5 Then the error "Can't move tab to position -1!" should be shown And the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: :tab-move with index and count. When I open data/numbers/1.txt @@ -553,9 +637,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-move 1 with count 2 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/3.txt (active) - data/numbers/2.txt + """ Scenario: :tab-move with index and invalid count. When I open data/numbers/1.txt @@ -564,9 +650,11 @@ Feature: Tab management And I run :tab-move -2 with count 4 Then the error "Can't move tab to position 4!" should be shown And the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: :tab-move with relative position (negative). When I open data/numbers/1.txt @@ -574,9 +662,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-move - Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/3.txt (active) - data/numbers/2.txt + """ Scenario: :tab-move with relative position (positive). When I open data/numbers/1.txt @@ -585,9 +675,11 @@ Feature: Tab management And I run :tab-focus 1 And I run :tab-move + Then the following tabs should be open: + """ - data/numbers/2.txt - data/numbers/1.txt (active) - data/numbers/3.txt + """ Scenario: :tab-move with relative position (negative) and count. When I open data/numbers/1.txt @@ -595,9 +687,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-move - with count 2 Then the following tabs should be open: + """ - data/numbers/3.txt (active) - data/numbers/1.txt - data/numbers/2.txt + """ Scenario: :tab-move with relative position and too big count. When I set tabs.wrap to false @@ -615,9 +709,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-move + Then the following tabs should be open: + """ - data/numbers/3.txt (active) - data/numbers/1.txt - data/numbers/2.txt + """ Scenario: :tab-move with relative position (negative), wrap and count When I set tabs.wrap to true @@ -627,9 +723,11 @@ Feature: Tab management And I run :tab-focus 1 And I run :tab-move - with count 8 Then the following tabs should be open: + """ - data/numbers/2.txt - data/numbers/1.txt (active) - data/numbers/3.txt + """ Scenario: :tab-move with absolute position When I open data/numbers/1.txt @@ -638,9 +736,11 @@ Feature: Tab management And I run :tab-focus 1 And I run :tab-move end Then the following tabs should be open: + """ - data/numbers/2.txt - data/numbers/3.txt - data/numbers/1.txt (active) + """ Scenario: :tab-move with absolute position When I open data/numbers/1.txt @@ -648,9 +748,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-move start Then the following tabs should be open: + """ - data/numbers/3.txt (active) - data/numbers/1.txt - data/numbers/2.txt + """ Scenario: Make sure :tab-move retains metadata When I open data/title.html @@ -658,6 +760,7 @@ Feature: Tab management And I run :tab-focus 1 And I run :tab-move + Then the session should look like: + """ windows: - tabs: - history: @@ -667,6 +770,7 @@ Feature: Tab management - url: about:blank - url: http://localhost:*/data/title.html title: Test title + """ # :tab-clone @@ -679,6 +783,7 @@ Feature: Tab management And I run :tab-clone And I wait until data/title.html is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -690,6 +795,7 @@ Feature: Tab management - url: about:blank - url: http://localhost:*/data/title.html title: Test title + """ Scenario: Cloning zoom value When I open data/hello.txt @@ -697,6 +803,7 @@ Feature: Tab management And I run :tab-clone And I wait until data/hello.txt is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -708,20 +815,24 @@ Feature: Tab management - url: about:blank - url: http://localhost:*/data/hello.txt zoom: 1.2 + """ Scenario: Cloning to background tab When I open data/hello2.txt And I run :tab-clone -b And I wait until data/hello2.txt is loaded Then the following tabs should be open: + """ - data/hello2.txt (active) - data/hello2.txt + """ Scenario: Cloning to new window When I open data/title.html And I run :tab-clone -w And I wait until data/title.html is loaded Then the session should look like: + """ windows: - tabs: - active: true @@ -735,6 +846,7 @@ Feature: Tab management - url: about:blank - url: http://localhost:*/data/title.html title: Test title + """ Scenario: Cloning with tabs_are_windows = true When I open data/title.html @@ -742,6 +854,7 @@ Feature: Tab management And I run :tab-clone And I wait until data/title.html is loaded Then the session should look like: + """ windows: - tabs: - active: true @@ -755,12 +868,14 @@ Feature: Tab management - url: about:blank - url: http://localhost:*/data/title.html title: Test title + """ Scenario: Cloning to private window When I open data/title.html And I run :tab-clone -p And I wait until data/title.html is loaded Then the session should look like: + """ windows: - tabs: - active: true @@ -775,6 +890,7 @@ Feature: Tab management - url: about:blank - url: http://localhost:*/data/title.html title: Test title + """ # https://github.com/qutebrowser/qutebrowser/issues/2289 @@ -801,6 +917,7 @@ Feature: Tab management And I run :undo And I wait until data/numbers/3.txt is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -810,6 +927,7 @@ Feature: Tab management history: - url: http://localhost:*/data/numbers/2.txt - url: http://localhost:*/data/numbers/3.txt + """ @qtwebengine_flaky Scenario: Undo with auto-created last tab @@ -821,7 +939,9 @@ Feature: Tab management And I run :undo And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hello.txt (active) + """ @qtwebengine_flaky Scenario: Undo with auto-created last tab, with history @@ -834,7 +954,9 @@ Feature: Tab management And I run :undo And I wait until data/hello2.txt is loaded Then the following tabs should be open: + """ - data/hello2.txt (active) + """ Scenario: Undo with auto-created last tab (startpage) When I open data/hello.txt @@ -846,7 +968,9 @@ Feature: Tab management And I run :undo And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hello.txt (active) + """ Scenario: Undo with auto-created last tab (default-page) When I open data/hello.txt @@ -858,7 +982,9 @@ Feature: Tab management And I run :undo And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hello.txt (active) + """ @skip # Too flaky Scenario: Double-undo with single tab on tabs.last_close default page @@ -878,9 +1004,11 @@ Feature: Tab management And I run :tab-close with count 1 And I run :undo Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/2.txt - data/numbers/3.txt + """ Scenario: Undo a tab closed after switching tabs When I open data/numbers/1.txt @@ -890,9 +1018,11 @@ Feature: Tab management And I run :tab-focus 2 And I run :undo Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/2.txt - data/numbers/3.txt + """ Scenario: Undo a tab closed after rearranging tabs When I open data/numbers/1.txt @@ -902,9 +1032,11 @@ Feature: Tab management And I run :tab-move with count 1 And I run :undo Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/3.txt - data/numbers/2.txt + """ @flaky Scenario: Undo a tab closed after new tab opened @@ -915,9 +1047,11 @@ Feature: Tab management And I run :undo And I wait until data/numbers/1.txt is loaded Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/2.txt - data/numbers/3.txt + """ Scenario: Undo the closing of tabs using :tab-only When I open data/numbers/1.txt @@ -927,9 +1061,11 @@ Feature: Tab management And I run :tab-only And I run :undo Then the following tabs should be open: + """ - data/numbers/1.txt (active) - data/numbers/2.txt - data/numbers/3.txt + """ # :undo --window @@ -942,6 +1078,7 @@ Feature: Tab management And I run :undo -w And I wait for "Focus object changed: *" in the log Then the session should look like: + """ windows: - tabs: - active: true @@ -953,6 +1090,7 @@ Feature: Tab management - active: true history: - url: http://localhost:*/data/numbers/2.txt + """ Scenario: Undo the closing of a window with multiple tabs Given I clear the log @@ -964,6 +1102,7 @@ Feature: Tab management And I run :undo -w And I wait for "Focus object changed: *" in the log Then the session should look like: + """ windows: - tabs: - active: true @@ -977,6 +1116,7 @@ Feature: Tab management - active: true history: - url: http://localhost:*/data/numbers/3.txt + """ Scenario: Undo the closing of a window with multiple tabs with undo stack Given I clear the log @@ -990,6 +1130,7 @@ Feature: Tab management And I run :undo And I wait for "Focus object changed: *" in the log Then the session should look like: + """ windows: - tabs: - active: true @@ -1003,6 +1144,7 @@ Feature: Tab management - active: true history: - url: http://localhost:*/data/numbers/3.txt + """ Scenario: Undo the closing of a window with tabs are windows Given I clear the log @@ -1015,6 +1157,7 @@ Feature: Tab management And I run :undo -w And I wait for "Focus object changed: *" in the log Then the session should look like: + """ windows: - tabs: - active: true @@ -1025,6 +1168,7 @@ Feature: Tab management - active: true history: - url: http://localhost:*/data/numbers/2.txt + """ # :undo with count @@ -1036,8 +1180,10 @@ Feature: Tab management And I run :tab-close And I run :undo with count 2 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/3.txt (active) + """ Scenario: Undo with a too-high count When I open data/numbers/1.txt @@ -1057,7 +1203,7 @@ Feature: Tab management # tabs.last_close # FIXME:qtwebengine - @qtwebengine_skip: Waits for an earlier about:blank and fails + @qtwebengine_skip # Waits for an earlier about:blank and fails Scenario: tabs.last_close = blank When I open data/hello.txt And I set tabs.last_close to blank @@ -1065,7 +1211,9 @@ Feature: Tab management And I run :tab-close And I wait until about:blank is loaded Then the following tabs should be open: + """ - about:blank (active) + """ Scenario: tabs.last_close = startpage When I set url.start_pages to ["http://localhost:(port)/data/numbers/7.txt", "http://localhost:(port)/data/numbers/8.txt"] @@ -1076,8 +1224,10 @@ Feature: Tab management And I wait until data/numbers/7.txt is loaded And I wait until data/numbers/8.txt is loaded Then the following tabs should be open: + """ - data/numbers/7.txt - data/numbers/8.txt (active) + """ Scenario: tabs.last_close = default-page When I set url.default_page to http://localhost:(port)/data/numbers/9.txt @@ -1087,7 +1237,9 @@ Feature: Tab management And I run :tab-close And I wait until data/numbers/9.txt is loaded Then the following tabs should be open: + """ - data/numbers/9.txt (active) + """ Scenario: tabs.last_close = close When I open data/hello.txt @@ -1104,8 +1256,10 @@ Feature: Tab management And I hint with args "all tab" and follow a And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hints/html/simple.html (active) - data/hello.txt + """ Scenario: opening tab with tabs.new_position.related prev When I set tabs.new_position.related to prev @@ -1115,9 +1269,11 @@ Feature: Tab management And I run :click-element id link --target=tab And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - about:blank - data/hello.txt (active) - data/hints/html/simple.html + """ Scenario: opening tab with tabs.new_position.related next When I set tabs.new_position.related to next @@ -1127,9 +1283,11 @@ Feature: Tab management And I run :click-element id link --target=tab And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - about:blank - data/hints/html/simple.html - data/hello.txt (active) + """ Scenario: opening tab with tabs.new_position.related first When I set tabs.new_position.related to first @@ -1139,9 +1297,11 @@ Feature: Tab management And I run :click-element id link --target=tab And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hello.txt (active) - about:blank - data/hints/html/simple.html + """ Scenario: opening tab with tabs.new_position.related last When I set tabs.new_position.related to last @@ -1152,9 +1312,11 @@ Feature: Tab management And I run :click-element id link --target=tab And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hints/html/simple.html - about:blank - data/hello.txt (active) + """ # stacking tabs Scenario: stacking tabs opening tab with tabs.new_position.related next @@ -1168,10 +1330,12 @@ Feature: Tab management And I wait until data/navigate/prev.html is loaded And I wait until data/navigate/next.html is loaded Then the following tabs should be open: + """ - about:blank - data/navigate/index.html (active) - data/navigate/prev.html - data/navigate/next.html + """ Scenario: stacking tabs opening tab with tabs.new_position.related prev When I set tabs.new_position.related to prev @@ -1184,10 +1348,12 @@ Feature: Tab management And I wait until data/navigate/prev.html is loaded And I wait until data/navigate/next.html is loaded Then the following tabs should be open: + """ - about:blank - data/navigate/next.html - data/navigate/prev.html - data/navigate/index.html (active) + """ Scenario: no stacking tabs opening tab with tabs.new_position.related next When I set tabs.new_position.related to next @@ -1200,10 +1366,12 @@ Feature: Tab management And I wait until data/navigate/prev.html is loaded And I wait until data/navigate/next.html is loaded Then the following tabs should be open: + """ - about:blank - data/navigate/index.html (active) - data/navigate/next.html - data/navigate/prev.html + """ Scenario: no stacking tabs opening tab with tabs.new_position.related prev When I set tabs.new_position.related to prev @@ -1216,10 +1384,12 @@ Feature: Tab management And I wait until data/navigate/prev.html is loaded And I wait until data/navigate/next.html is loaded Then the following tabs should be open: + """ - about:blank - data/navigate/prev.html - data/navigate/next.html - data/navigate/index.html (active) + """ # :tab-select @@ -1234,9 +1404,11 @@ Feature: Tab management And I run :tab-select Searching text And I wait for "Current tab changed, focusing " in the log Then the following tabs should be open: + """ - data/title.html - data/search.html (active) - data/scroll/simple.html + """ Scenario: :tab-select with no matching title When I run :tab-select invalid title @@ -1252,6 +1424,7 @@ Feature: Tab management And I run :tab-select Scrolling And I wait for "Focus object changed: *" in the log Then the session should look like: + """ windows: - active: true tabs: @@ -1269,6 +1442,7 @@ Feature: Tab management - active: true history: - url: http://localhost:*/data/paste_primary.html + """ Scenario: :tab-select with no matching index When I open data/title.html @@ -1292,6 +1466,7 @@ Feature: Tab management And I run :tab-select 0/2 And I wait for "Focus object changed: *" in the log Then the session should look like: + """ windows: - active: true tabs: @@ -1309,6 +1484,7 @@ Feature: Tab management - active: true history: - url: http://localhost:*/data/paste_primary.html + """ Scenario: :tab-select with wrong argument (-1) When I open data/title.html @@ -1319,13 +1495,17 @@ Feature: Tab management When I open data/title.html And I run :tab-select / Then the following tabs should be open: + """ - data/title.html (active) + """ Scenario: :tab-select with wrong argument (//) When I open data/title.html And I run :tab-select // Then the following tabs should be open: + """ - data/title.html (active) + """ Scenario: :tab-select with wrong argument (0/x) When I open data/title.html @@ -1346,6 +1526,7 @@ Feature: Tab management And I open data/numbers/2.txt in a new window And I run :tab-take 0/1 Then the session should look like: + """ windows: - tabs: - history: @@ -1355,6 +1536,7 @@ Feature: Tab management - url: http://localhost:*/data/numbers/2.txt - history: - url: http://localhost:*/data/numbers/1.txt + """ Scenario: Take a tab from the same window Given I have a fresh instance @@ -1369,7 +1551,7 @@ Feature: Tab management And I run :tab-take 0/1 Then the error "Can't take tabs when using windows as tabs" should be shown - @windows_skip + @windows_skip @no_offscreen Scenario: Close the last tab of a window when taken by another window Given I have a fresh instance When I open data/numbers/1.txt @@ -1379,6 +1561,7 @@ Feature: Tab management And I run :tab-take 1/1 And I wait until data/numbers/2.txt is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -1387,6 +1570,7 @@ Feature: Tab management - active: true history: - url: http://localhost:*/data/numbers/2.txt + """ # :tab-give @@ -1397,6 +1581,7 @@ Feature: Tab management And I open data/numbers/2.txt in a new window And I run :tab-give 0 Then the session should look like: + """ windows: - tabs: - history: @@ -1406,6 +1591,7 @@ Feature: Tab management - tabs: - history: - url: about:blank + """ Scenario: Give a tab to the same window Given I have a fresh instance @@ -1419,6 +1605,7 @@ Feature: Tab management And I run :tab-give And I wait until data/numbers/2.txt is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -1427,6 +1614,7 @@ Feature: Tab management - tabs: - history: - url: http://localhost:*/data/numbers/2.txt + """ Scenario: Give a tab from window with only one tab When I open data/hello.txt @@ -1445,7 +1633,7 @@ Feature: Tab management And I run :tab-give 0 Then the error "Can't give tabs when using windows as tabs" should be shown - @windows_skip + @windows_skip @no_offscreen Scenario: Close the last tab of a window when given to another window Given I have a fresh instance When I open data/numbers/1.txt @@ -1455,6 +1643,7 @@ Feature: Tab management And I run :tab-give 1 And I wait until data/numbers/1.txt is loaded Then the session should look like: + """ windows: - tabs: - active: true @@ -1462,6 +1651,7 @@ Feature: Tab management - url: http://localhost:*/data/numbers/2.txt - history: - url: http://localhost:*/data/numbers/1.txt + """ # Other @@ -1485,6 +1675,7 @@ Feature: Tab management And I hint with args "all tab-fg" and follow a And I wait until data/hello.txt is loaded Then the session should look like: + """ windows: - tabs: - history: @@ -1493,6 +1684,7 @@ Feature: Tab management - tabs: - history: - url: http://localhost:*/data/hello.txt + """ Scenario: Closing tab with tabs_are_windows When I set tabs.tabs_are_windows to true @@ -1502,12 +1694,14 @@ Feature: Tab management And I run :tab-close And I wait for "removed: tabbed-browser" in the log Then the session should look like: + """ windows: - tabs: - active: true history: - url: about:blank - url: http://localhost:*/data/numbers/1.txt + """ # :tab-pin @@ -1517,9 +1711,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-pin Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) (pinned) + """ Scenario: :tab-pin unpin When I open data/numbers/1.txt @@ -1529,9 +1725,11 @@ Feature: Tab management And I run :tab-pin And I run :tab-pin Then the following tabs should be open: + """ - data/numbers/1.txt (pinned) - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: :tab-pin to index 2 When I open data/numbers/1.txt @@ -1539,9 +1737,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-pin with count 2 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (pinned) - data/numbers/3.txt (active) + """ Scenario: :tab-pin with an invalid count When I open data/numbers/1.txt @@ -1549,9 +1749,11 @@ Feature: Tab management And I open data/numbers/3.txt in a new tab And I run :tab-pin with count 23 Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt - data/numbers/3.txt (active) + """ Scenario: Pinned :tab-close prompt yes When I open data/numbers/1.txt @@ -1562,7 +1764,9 @@ Feature: Tab management And I wait for "*want to close a pinned tab*" in the log And I run :prompt-accept yes Then the following tabs should be open: + """ - data/numbers/1.txt (active) (pinned) + """ Scenario: Pinned :tab-close prompt no When I open data/numbers/1.txt @@ -1573,8 +1777,10 @@ Feature: Tab management And I wait for "*want to close a pinned tab*" in the log And I run :prompt-accept no Then the following tabs should be open: + """ - data/numbers/1.txt (pinned) - data/numbers/2.txt (active) (pinned) + """ Scenario: Pinned :tab-only prompt yes When I open data/numbers/1.txt @@ -1586,7 +1792,9 @@ Feature: Tab management And I wait for "*want to close pinned tabs*" in the log And I run :prompt-accept yes Then the following tabs should be open: + """ - data/numbers/1.txt (active) (pinned) + """ Scenario: Pinned :tab-only prompt no When I open data/numbers/1.txt @@ -1598,8 +1806,10 @@ Feature: Tab management And I wait for "*want to close pinned tabs*" in the log And I run :prompt-accept no Then the following tabs should be open: + """ - data/numbers/1.txt (active) (pinned) - data/numbers/2.txt (pinned) + """ Scenario: Pinned :tab-only close all but pinned tab When I open data/numbers/1.txt @@ -1607,7 +1817,9 @@ Feature: Tab management And I run :tab-pin And I run :tab-only Then the following tabs should be open: + """ - data/numbers/2.txt (active) (pinned) + """ Scenario: Pinned :tab-only --pinned close When I open data/numbers/1.txt @@ -1617,7 +1829,9 @@ Feature: Tab management And I run :tab-next And I run :tab-only --pinned close Then the following tabs should be open: + """ - data/numbers/1.txt (active) (pinned) + """ Scenario: Pinned :tab-only --pinned keep When I open data/numbers/1.txt @@ -1627,8 +1841,10 @@ Feature: Tab management And I run :tab-next And I run :tab-only --pinned keep Then the following tabs should be open: + """ - data/numbers/1.txt (active) (pinned) - data/numbers/2.txt (pinned) + """ Scenario: Pinned :tab-only --pinned prompt When I open data/numbers/1.txt @@ -1645,8 +1861,10 @@ Feature: Tab management And I open data/numbers/2.txt Then the message "Tab is pinned! Opening in new tab." should be shown And the following tabs should be open: + """ - data/numbers/1.txt (active) (pinned) - data/numbers/2.txt + """ Scenario: :tab-pin open url with tabs.pinned.frozen = false When I set tabs.pinned.frozen to false @@ -1654,7 +1872,9 @@ Feature: Tab management And I run :tab-pin And I open data/numbers/2.txt Then the following tabs should be open: + """ - data/numbers/2.txt (active) (pinned) + """ Scenario: :home on a pinned tab When I open data/numbers/1.txt @@ -1662,7 +1882,9 @@ Feature: Tab management And I run :home Then the message "Tab is pinned!" should be shown And the following tabs should be open: + """ - data/numbers/1.txt (active) (pinned) + """ Scenario: :home on a pinned tab with tabs.pinned.frozen = false When I set url.start_pages to ["http://localhost:(port)/data/numbers/2.txt"] @@ -1672,7 +1894,9 @@ Feature: Tab management And I run :home Then data/numbers/2.txt should be loaded And the following tabs should be open: + """ - data/numbers/2.txt (active) (pinned) + """ Scenario: Cloning a pinned tab When I open data/numbers/1.txt @@ -1680,8 +1904,10 @@ Feature: Tab management And I run :tab-clone And I wait until data/numbers/1.txt is loaded Then the following tabs should be open: + """ - data/numbers/1.txt (pinned) - data/numbers/1.txt (pinned) (active) + """ Scenario: Undo a pinned tab When I open data/numbers/1.txt @@ -1691,8 +1917,10 @@ Feature: Tab management And I run :undo And I wait until data/numbers/2.txt is loaded Then the following tabs should be open: + """ - data/numbers/1.txt - data/numbers/2.txt (pinned) (active) + """ Scenario: Focused webview after clicking link in bg diff --git a/tests/end2end/features/test_history_bdd.py b/tests/end2end/features/test_history_bdd.py index 04f2f7978..7e87c69ed 100644 --- a/tests/end2end/features/test_history_bdd.py +++ b/tests/end2end/features/test_history_bdd.py @@ -34,8 +34,8 @@ def check_query(quteproc, name, value): assert data[name] == value -@bdd.then(bdd.parsers.parse("the history should contain:\n{expected}")) -def check_history(quteproc, server, tmpdir, expected): +@bdd.then(bdd.parsers.parse("the history should contain:")) +def check_history(quteproc, server, tmpdir, docstring): quteproc.wait_for(message='INSERT INTO History *', category='sql') path = tmpdir / 'history' quteproc.send_cmd(':debug-dump-history "{}"'.format(path)) @@ -46,7 +46,7 @@ def check_history(quteproc, server, tmpdir, expected): # ignore access times, they will differ in each run actual = '\n'.join(re.sub('^\\d+-?', '', line).strip() for line in f) - expected = expected.replace('(port)', str(server.port)) + expected = docstring.replace('(port)', str(server.port)) assert actual == expected diff --git a/tests/end2end/features/test_prompts_bdd.py b/tests/end2end/features/test_prompts_bdd.py index c7d676f4d..9297d1d29 100644 --- a/tests/end2end/features/test_prompts_bdd.py +++ b/tests/end2end/features/test_prompts_bdd.py @@ -7,6 +7,12 @@ import logging import pytest_bdd as bdd bdd.scenarios('prompts.feature') +from qutebrowser.utils import qtutils +try: + from qutebrowser.qt.webenginecore import PYQT_WEBENGINE_VERSION +except ImportError: + PYQT_WEBENGINE_VERSION = None + @bdd.when("I load an SSL page") def load_ssl_page(quteproc, ssl_server): @@ -37,6 +43,21 @@ def wait_for_prompt(quteproc): quteproc.wait_for(message='Asking question *') +@bdd.given("I may need a fresh instance") +def fresh_instance(quteproc): + """Restart qutebrowser to bypass webengine's permission persistance.""" + # Qt6.8 by default will remember feature grants or denies. When we are + # on PyQt6.8 we disable that with the new API, otherwise restart the + # browser to make it forget previous prompts. + if ( + qtutils.version_check("6.8", compiled=False) + and PYQT_WEBENGINE_VERSION + and PYQT_WEBENGINE_VERSION < 0x60800 + ): + quteproc.terminate() + quteproc.start() + + @bdd.then("no prompt should be shown") def no_prompt_shown(quteproc): quteproc.ensure_not_logged(message='Entering mode KeyMode.* (reason: ' diff --git a/tests/end2end/features/test_sessions_bdd.py b/tests/end2end/features/test_sessions_bdd.py index f3b31fe04..452a55ce3 100644 --- a/tests/end2end/features/test_sessions_bdd.py +++ b/tests/end2end/features/test_sessions_bdd.py @@ -15,12 +15,12 @@ def turn_on_scroll_logging(quteproc): quteproc.turn_on_scroll_logging() -@bdd.when(bdd.parsers.parse('I have a "{name}" session file:\n{contents}')) -def create_session_file(quteproc, name, contents): +@bdd.when(bdd.parsers.parse('I have a "{name}" session file:')) +def create_session_file(quteproc, name, docstring): filename = os.path.join(quteproc.basedir, 'data', 'sessions', name + '.yml') with open(filename, 'w', encoding='utf-8') as f: - f.write(contents) + f.write(docstring) @bdd.when(bdd.parsers.parse('I replace "{pattern}" by "{replacement}" in the ' diff --git a/tests/end2end/features/urlmarks.feature b/tests/end2end/features/urlmarks.feature index 70962d2d1..5776b47d8 100644 --- a/tests/end2end/features/urlmarks.feature +++ b/tests/end2end/features/urlmarks.feature @@ -34,7 +34,9 @@ Feature: quickmarks and bookmarks And I run :bookmark-load http://localhost:(port)/data/numbers/1.txt Then data/numbers/1.txt should be loaded And the following tabs should be open: + """ - data/numbers/1.txt (active) + """ Scenario: Loading a bookmark in a new tab Given I open about:blank @@ -42,8 +44,10 @@ Feature: quickmarks and bookmarks And I run :bookmark-load -t http://localhost:(port)/data/numbers/2.txt Then data/numbers/2.txt should be loaded And the following tabs should be open: + """ - about:blank - data/numbers/2.txt (active) + """ Scenario: Loading a bookmark in a background tab Given I open about:blank @@ -51,8 +55,10 @@ Feature: quickmarks and bookmarks And I run :bookmark-load -b http://localhost:(port)/data/numbers/3.txt Then data/numbers/3.txt should be loaded And the following tabs should be open: + """ - about:blank (active) - data/numbers/3.txt + """ Scenario: Loading a bookmark in a new window Given I open about:blank @@ -60,6 +66,7 @@ Feature: quickmarks and bookmarks And I run :bookmark-load -w http://localhost:(port)/data/numbers/4.txt And I wait until data/numbers/4.txt is loaded Then the session should look like: + """ windows: - tabs: - active: true @@ -71,6 +78,7 @@ Feature: quickmarks and bookmarks history: - active: true url: http://localhost:*/data/numbers/4.txt + """ Scenario: Loading a bookmark with -t and -b When I run :bookmark-load -t -b about:blank @@ -168,7 +176,9 @@ Feature: quickmarks and bookmarks And I run :quickmark-load thirteen Then data/numbers/13.txt should be loaded And the following tabs should be open: + """ - data/numbers/13.txt (active) + """ Scenario: Loading a quickmark in a new tab Given I open about:blank @@ -177,8 +187,10 @@ Feature: quickmarks and bookmarks And I run :quickmark-load -t fourteen Then data/numbers/14.txt should be loaded And the following tabs should be open: + """ - about:blank - data/numbers/14.txt (active) + """ Scenario: Loading a quickmark in a background tab Given I open about:blank @@ -187,8 +199,10 @@ Feature: quickmarks and bookmarks And I run :quickmark-load -b fifteen Then data/numbers/15.txt should be loaded And the following tabs should be open: + """ - about:blank (active) - data/numbers/15.txt + """ Scenario: Loading a quickmark in a new window Given I open about:blank @@ -197,6 +211,7 @@ Feature: quickmarks and bookmarks And I run :quickmark-load -w sixteen And I wait until data/numbers/16.txt is loaded Then the session should look like: + """ windows: - tabs: - active: true @@ -208,6 +223,7 @@ Feature: quickmarks and bookmarks history: - active: true url: http://localhost:*/data/numbers/16.txt + """ Scenario: Loading a quickmark which does not exist When I run :quickmark-load -b doesnotexist diff --git a/tests/end2end/features/utilcmds.feature b/tests/end2end/features/utilcmds.feature index ebacea890..7a69289fe 100644 --- a/tests/end2end/features/utilcmds.feature +++ b/tests/end2end/features/utilcmds.feature @@ -92,7 +92,6 @@ Feature: Miscellaneous utility commands exposed to the user. ## :debug-cache-stats - @python>=3.9.0 Scenario: :debug-cache-stats When I run :debug-cache-stats Then "is_valid_prefix: CacheInfo(*)" should be logged @@ -144,8 +143,10 @@ Feature: Miscellaneous utility commands exposed to the user. And I run :hint-follow a And I wait until data/hello.txt is loaded Then the following tabs should be open: + """ - data/hints/link_blank.html - data/hello.txt (active) + """ ## :debug-log-capacity diff --git a/tests/end2end/features/yankpaste.feature b/tests/end2end/features/yankpaste.feature index f551d732f..25ce81736 100644 --- a/tests/end2end/features/yankpaste.feature +++ b/tests/end2end/features/yankpaste.feature @@ -41,7 +41,7 @@ Feature: Yanking and pasting. Scenario: Yanking inline to clipboard When I open data/title.html - And I run :yank inline '[[{url}][qutebrowser List[str]: + def GetCapabilities(self, message: QDBusMessage) -> list[str]: assert not message.signature() assert not message.arguments() assert message.type() == QDBusMessage.MessageType.MethodCallMessage diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index e77757880..9d41d9e3e 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -5,6 +5,7 @@ """Fixtures to run qutebrowser in a QProcess and communicate.""" import pathlib +import os import re import sys import time @@ -18,6 +19,7 @@ import json import yaml import pytest +from PIL.ImageGrab import grab from qutebrowser.qt.core import pyqtSignal, QUrl, QPoint from qutebrowser.qt.gui import QImage, QColor @@ -77,6 +79,10 @@ def is_ignored_lowlevel_message(message): 'DRI3 not available', # Webkit on arch with a newer mesa 'MESA: error: ZINK: failed to load libvulkan.so.1', + + # GitHub Actions with Archlinux unstable packages + 'libEGL warning: DRI3: Screen seems not DRI3 capable', + 'libEGL warning: egl: failed to create dri2 screen', ] return any(testutils.pattern_match(pattern=pattern, value=message) for pattern in ignored_messages) @@ -225,6 +231,20 @@ def is_ignored_chromium_message(line): # Some MojoDiscardableSharedMemoryManagerImpls are still alive. They # will be leaked. "Some MojoDiscardableSharedMemoryManagerImpls are still alive. They will be leaked.", + + # Qt 6.7 on GitHub Actions + # [3456:5752:1111/103609.929:ERROR:block_files.cc(443)] Failed to open + # C:\Users\RUNNER~1\AppData\Local\Temp\qutebrowser-basedir-ruvn1lys\data\webengine\DawnCache\data_0 + "Failed to open *webengine*DawnCache*data_*", + + # Qt 6.8 on GitHub Actions + # [7072:3412:1209/220659.527:ERROR:simple_index_file.cc(322)] Failed to + # write the temporary index file + "Failed to write the temporary index file", + + # Qt 6.9 Beta 3 on GitHub Actions + # [978:1041:0311/070551.759339:ERROR:bus.cc(407)] + "Failed to connect to the bus: Failed to connect to socket /run/dbus/system_bus_socket: No such file or directory", ] return any(testutils.pattern_match(pattern=pattern, value=message) for pattern in ignored_messages) @@ -383,24 +403,37 @@ class QuteProc(testprocess.Process): def _executable_args(self): profile = self.request.config.getoption('--qute-profile-subprocs') + strace = self.request.config.getoption('--qute-strace-subprocs') if hasattr(sys, 'frozen'): - if profile: - raise RuntimeError("Can't profile with sys.frozen!") + if profile or strace: + raise RuntimeError("Can't profile/strace with sys.frozen!") executable = str(pathlib.Path(sys.executable).parent / 'qutebrowser') args = [] else: - executable = sys.executable + if strace: + executable = 'strace' + args = [ + "-o", + "qb-strace", + "--output-separately", # create .PID files + "--write=2", # dump full stderr data (qb JSON logs) + sys.executable, + ] + else: + executable = sys.executable + args = [] + if profile: profile_dir = pathlib.Path.cwd() / 'prof' profile_id = '{}_{}'.format(self._instance_id, next(self._run_counter)) profile_file = profile_dir / '{}.pstats'.format(profile_id) profile_dir.mkdir(exist_ok=True) - args = [str(pathlib.Path('scripts') / 'dev' / 'run_profile.py'), + args += [str(pathlib.Path('scripts') / 'dev' / 'run_profile.py'), '--profile-tool', 'none', '--profile-file', str(profile_file)] else: - args = ['-bb', '-m', 'qutebrowser'] + args += ['-bb', '-m', 'qutebrowser'] return executable, args def _default_args(self): @@ -412,10 +445,14 @@ class QuteProc(testprocess.Process): '--debug-flag', 'werror', '--debug-flag', 'test-notification-service', '--debug-flag', 'caret', - '--qt-flag', 'disable-features=PaintHoldingCrossOrigin'] + '--qt-flag', 'disable-features=PaintHoldingCrossOrigin', + '--qt-arg', 'geometry', '800x600+0+0'] - if self.request.config.webengine and testutils.disable_seccomp_bpf_sandbox(): - args += testutils.DISABLE_SECCOMP_BPF_ARGS + if self.request.config.webengine: + if testutils.disable_seccomp_bpf_sandbox(): + args += testutils.DISABLE_SECCOMP_BPF_ARGS + if testutils.use_software_rendering(): + args += testutils.SOFTWARE_RENDERING_ARGS args.append('about:blank') return args @@ -515,6 +552,7 @@ class QuteProc(testprocess.Process): def before_test(self): """Clear settings before every test.""" super().before_test() + self.send_cmd(':clear-messages') self.send_cmd(':config-clear') self._init_settings() self.clear_data() @@ -541,6 +579,8 @@ class QuteProc(testprocess.Process): except AttributeError: pass else: + if call.failed: + self._take_x11_screenshot_of_failed_test() if call.failed or hasattr(call, 'wasxfail') or call.skipped: super().after_test() return @@ -879,6 +919,10 @@ class QuteProc(testprocess.Process): self.send_cmd(cmd.format('no-scroll-filtering')) self.send_cmd(cmd.format('log-scroll-pos')) + def _take_x11_screenshot_of_failed_test(self): + fixture = self.request.getfixturevalue('take_x11_screenshot') + fixture() + class YamlLoader(yaml.SafeLoader): @@ -919,6 +963,39 @@ def _xpath_escape(text): return 'concat({})'.format(', '.join(parts)) +@pytest.fixture +def screenshot_dir(request, tmp_path_factory): + """Return the path of a directory to save e2e screenshots in.""" + path = tmp_path_factory.getbasetemp() + if "PYTEST_XDIST_WORKER" in os.environ: + # If we are running under xdist remove the per-worker directory + # (like "popen-gw0") so the user doesn't have to search through + # multiple folders for the screenshot they are looking for. + path = path.parent + path /= "pytest-screenshots" + path.mkdir(exist_ok=True) + return path + + +@pytest.fixture +def take_x11_screenshot(request, screenshot_dir, record_property, xvfb): + """Take a screenshot of the current pytest-xvfb display. + + Screenshots are saved to the location of the `screenshot_dir` fixture. + """ + def doit(): + if not xvfb: + # Likely we are being run with --no-xvfb + return + + img = grab(xdisplay=f":{xvfb.display}") + fpath = screenshot_dir / f"{request.node.name}.png" + img.save(fpath) + + record_property("screenshot", str(fpath)) + return doit + + @pytest.fixture(scope='module') def quteproc_process(qapp, server, request): """Fixture for qutebrowser process which is started once per file.""" @@ -930,7 +1007,7 @@ def quteproc_process(qapp, server, request): @pytest.fixture -def quteproc(quteproc_process, server, request): +def quteproc(quteproc_process, server, request, take_x11_screenshot): """Per-test qutebrowser fixture which uses the per-file process.""" request.node._quteproc_log = quteproc_process.captured_log quteproc_process.before_test() diff --git a/tests/end2end/fixtures/test_quteprocess.py b/tests/end2end/fixtures/test_quteprocess.py index ec0cd55ac..00266fc32 100644 --- a/tests/end2end/fixtures/test_quteprocess.py +++ b/tests/end2end/fixtures/test_quteprocess.py @@ -98,8 +98,9 @@ def test_quteproc_error_message(qtbot, quteproc, cmd, request_mock): quteproc.after_test() -def test_quteproc_error_message_did_fail(qtbot, quteproc, request_mock): +def test_quteproc_error_message_did_fail(qtbot, quteproc, request_mock, monkeypatch): """Make sure the test does not fail on teardown if the main test failed.""" + monkeypatch.setattr(quteproc, "_take_x11_screenshot_of_failed_test", lambda: None) request_mock.node.rep_call.failed = True with qtbot.wait_signal(quteproc.got_error): quteproc.send_cmd(':message-error test') @@ -108,6 +109,17 @@ def test_quteproc_error_message_did_fail(qtbot, quteproc, request_mock): quteproc.after_test() +def test_quteproc_screenshot_on_fail(qtbot, quteproc, request_mock, monkeypatch, mocker): + """Make sure we call the method to take a screenshot to test failure.""" + take_screenshot_spy = mocker.Mock() + monkeypatch.setattr( + quteproc, "_take_x11_screenshot_of_failed_test", take_screenshot_spy + ) + request_mock.node.rep_call.failed = True + quteproc.after_test() + take_screenshot_spy.assert_called_once() + + def test_quteproc_skip_via_js(qtbot, quteproc): with pytest.raises(pytest.skip.Exception, match='test'): quteproc.send_cmd(':jseval console.log("[SKIP] test");') diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 924cb520b..f8e28cc40 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -115,8 +115,8 @@ class ExpectedRequest: def is_ignored_webserver_message(line: str) -> bool: return testutils.pattern_match( pattern=( - "Client ('127.0.0.1', *) lost — peer dropped the TLS connection suddenly, " - "during handshake: (1, '[SSL: SSLV3_ALERT_CERTIFICATE_UNKNOWN] ssl/tls " + "Client ('127.0.0.1', *) lost * peer dropped the TLS connection suddenly, " + "during handshake: (1, '[SSL: SSLV3_ALERT_CERTIFICATE_UNKNOWN] * " "alert certificate unknown (_ssl.c:*)')" ), value=line, diff --git a/tests/end2end/fixtures/webserver_sub.py b/tests/end2end/fixtures/webserver_sub.py index f716c7443..117232068 100644 --- a/tests/end2end/fixtures/webserver_sub.py +++ b/tests/end2end/fixtures/webserver_sub.py @@ -12,6 +12,7 @@ parameters or headers with the same name properly. """ import sys +import errno import json import time import threading @@ -338,7 +339,33 @@ class WSGIServer(cheroot.wsgi.Server): self._ready = value +def unraisable_hook(unraisable: "sys.UnraisableHookArgs") -> None: + if ( + sys.version_info[:2] == (3, 13) + and isinstance(unraisable.exc_value, OSError) + and ( + unraisable.exc_value.errno == errno.EBADF + or ( + sys.platform == "win32" + # pylint: disable-next=no-member + and unraisable.exc_value.winerror == errno.WSAENOTSOCK + ) + ) + and unraisable.object.__qualname__ == "IOBase.__del__" + ): + # WORKAROUND for bogus exceptions with cheroot: + # https://github.com/cherrypy/cheroot/issues/734 + return + sys.__unraisablehook__(unraisable) + + +def init_unraisable_hook() -> None: + sys.unraisablehook = unraisable_hook + + def main(): + init_unraisable_hook() + app.template_folder = END2END_DIR / 'templates' assert app.template_folder.is_dir(), app.template_folder diff --git a/tests/end2end/fixtures/webserver_sub_ssl.py b/tests/end2end/fixtures/webserver_sub_ssl.py index dad952094..87e8d5708 100644 --- a/tests/end2end/fixtures/webserver_sub_ssl.py +++ b/tests/end2end/fixtures/webserver_sub_ssl.py @@ -47,6 +47,8 @@ def log_request(response): def main(): + webserver_sub.init_unraisable_hook() + port = int(sys.argv[1]) server = webserver_sub.WSGIServer(('127.0.0.1', port), app) diff --git a/tests/end2end/misc/test_runners_e2e.py b/tests/end2end/misc/test_runners_e2e.py index 34d0f1f7a..1dabbeee5 100644 --- a/tests/end2end/misc/test_runners_e2e.py +++ b/tests/end2end/misc/test_runners_e2e.py @@ -39,6 +39,7 @@ def test_command_expansion(quteproc, send_msg, recv_msg): @pytest.mark.parametrize('send_msg, recv_msg, url', [ ('foo{title}', 'fooTest title', 'data/title.html'), ('foo{url:query}', 'fooq=bar', 'data/hello.txt?q=bar'), + ('foo{url:yank}', 'foohttp://localhost:*/hello.txt', 'data/hello.txt?ref=test'), # multiple variable expansion ('{title}bar{url}', 'Test titlebarhttp://localhost:*/title.html', 'data/title.html'), diff --git a/tests/end2end/test_dirbrowser.py b/tests/end2end/test_dirbrowser.py index 58cf66d6a..c1762b183 100644 --- a/tests/end2end/test_dirbrowser.py +++ b/tests/end2end/test_dirbrowser.py @@ -7,7 +7,6 @@ import pathlib import dataclasses -from typing import List import pytest import bs4 @@ -97,8 +96,8 @@ class Parsed: path: str parent: str - folders: List[str] - files: List[str] + folders: list[str] + files: list[str] @dataclasses.dataclass @@ -124,7 +123,7 @@ def parse(quteproc): title_prefix = 'Browse directory: ' # Strip off the title prefix to obtain the path of the folder that # we're browsing - path = pathlib.Path(soup.title.string[len(title_prefix):]) + path = pathlib.Path(soup.title.string.removeprefix(title_prefix)) container = soup('div', id='dirbrowserContainer')[0] diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index a55efb129..3f27ffe0a 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -45,8 +45,11 @@ def _base_args(config): else: args += ['--backend', 'webkit'] - if config.webengine and testutils.disable_seccomp_bpf_sandbox(): - args += testutils.DISABLE_SECCOMP_BPF_ARGS + if config.webengine: + if testutils.disable_seccomp_bpf_sandbox(): + args += testutils.DISABLE_SECCOMP_BPF_ARGS + if testutils.use_software_rendering(): + args += testutils.SOFTWARE_RENDERING_ARGS args.append('about:blank') return args @@ -335,7 +338,7 @@ def test_launching_with_old_python(python): except FileNotFoundError: pytest.skip(f"{python} not found") assert proc.returncode == 1 - error = "At least Python 3.8 is required to run qutebrowser" + error = "At least Python 3.9 is required to run qutebrowser" assert proc.stderr.decode('ascii').startswith(error) @@ -512,6 +515,10 @@ def test_preferred_colorscheme_with_dark_mode( '-s', 'colors.webpage.darkmode.enabled', 'true', '-s', 'colors.webpage.darkmode.algorithm', 'brightness-rgb', ] + if webengine_versions.webengine == utils.VersionNumber(6, 9): + # WORKAROUND: For unknown reasons, dark mode colors are wrong with + # Qt 6.9 + hardware rendering + Xvfb. + args += testutils.SOFTWARE_RENDERING_ARGS quteproc_new.start(args) quteproc_new.open_path('data/darkmode/prefers-color-scheme.html') @@ -636,6 +643,36 @@ def test_cookies_store(quteproc_new, request, short_tmpdir, store): quteproc_new.wait_for_quit() +def test_permission_prompt_across_restart(quteproc_new, request, short_tmpdir): + # Start test process + args = _base_args(request.config) + [ + '--basedir', str(short_tmpdir), + '-s', 'content.notifications.enabled', 'ask', + ] + quteproc_new.start(args) + + def notification_prompt(answer): + quteproc_new.open_path('data/prompt/notifications.html') + quteproc_new.send_cmd(':click-element id button') + quteproc_new.wait_for(message='Asking question *') + quteproc_new.send_cmd(f':prompt-accept {answer}') + + # Make sure we are prompted the first time we are opened in this basedir + notification_prompt('yes') + quteproc_new.wait_for_js('notification permission granted') + + # Restart with same basedir + quteproc_new.send_cmd(':quit') + quteproc_new.wait_for_quit() + quteproc_new.start(args) + + # We should be re-prompted in the new instance + notification_prompt('no') + + quteproc_new.send_cmd(':quit') + quteproc_new.wait_for_quit() + + # The 'colors' dictionaries in the parametrize decorator below have (QtWebEngine # version, CPU architecture) as keys. Either of those (or both) can be None to # say "on all other Qt versions" or "on all other CPU architectures". @@ -708,10 +745,13 @@ def test_dark_mode(webengine_versions, quteproc_new, request, '-s', 'colors.webpage.darkmode.enabled', 'true', '-s', 'colors.webpage.darkmode.algorithm', algorithm, ] - quteproc_new.start(args) + if webengine_versions.webengine == utils.VersionNumber(6, 9): + # WORKAROUND: For unknown reasons, dark mode colors are wrong with + # Qt 6.9 + hardware rendering + Xvfb. + args += testutils.SOFTWARE_RENDERING_ARGS - ver = webengine_versions.webengine - minor_version = str(ver.strip_patch()) + quteproc_new.start(args) + minor_version = str(webengine_versions.webengine.strip_patch()) arch = platform.machine() for key in [ @@ -744,6 +784,11 @@ def test_dark_mode_mathml(webengine_versions, quteproc_new, request, qtbot, suff '-s', 'colors.webpage.darkmode.enabled', 'true', '-s', 'colors.webpage.darkmode.algorithm', 'brightness-rgb', ] + if webengine_versions.webengine == utils.VersionNumber(6, 9): + # WORKAROUND: For unknown reasons, dark mode colors are wrong with + # Qt 6.9 + hardware rendering + Xvfb. + args += testutils.SOFTWARE_RENDERING_ARGS + quteproc_new.start(args) quteproc_new.open_path(f'data/darkmode/mathml-{suffix}.html') @@ -854,11 +899,14 @@ def test_sandboxing( request, quteproc_new, sandboxing, has_namespaces, has_seccomp, has_yama, expected_result, ): + # https://github.com/qutebrowser/qutebrowser/issues/8424 + userns_restricted = testutils.is_userns_restricted() + if not request.config.webengine: pytest.skip("Skipped with QtWebKit") elif sandboxing == "enable-all" and testutils.disable_seccomp_bpf_sandbox(): pytest.skip("Full sandboxing not supported") - elif version.is_flatpak(): + elif version.is_flatpak() or userns_restricted: # https://github.com/flathub/io.qt.qtwebengine.BaseApp/pull/66 has_namespaces = False expected_result = "You are NOT adequately sandboxed." @@ -905,6 +953,7 @@ def test_sandboxing( f"{bpf_text} supports TSYNC": "Yes" if has_seccomp else "No", f"{yama_text} (Broker)": "Yes" if has_yama else "No", + # pylint: disable-next=used-before-assignment f"{yama_text} (Non-broker)": "Yes" if has_yama_non_broker else "No", } @@ -987,7 +1036,7 @@ def test_restart(request, quteproc_new): quteproc_new.wait_for_quit() assert line.message.startswith(prefix) - pid = int(line.message[len(prefix):]) + pid = int(line.message.removeprefix(prefix)) os.kill(pid, signal.SIGTERM) try: diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index a4a54e7bc..b588110c7 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -6,7 +6,8 @@ """Fake objects/stubs.""" -from typing import Any, Callable, Tuple +from typing import Any +from collections.abc import Callable from unittest import mock import contextlib import shutil @@ -328,7 +329,7 @@ class FakeCommand: completion: Any = None maxsplit: int = None takes_count: Callable[[], bool] = lambda: False - modes: Tuple[usertypes.KeyMode] = (usertypes.KeyMode.normal, ) + modes: tuple[usertypes.KeyMode] = (usertypes.KeyMode.normal, ) class FakeTimer(QObject): diff --git a/tests/helpers/testutils.py b/tests/helpers/testutils.py index d74c46fcb..5407823e6 100644 --- a/tests/helpers/testutils.py +++ b/tests/helpers/testutils.py @@ -9,11 +9,14 @@ import re import enum import gzip import pprint +import platform import os.path import contextlib import pathlib +import subprocess import importlib.util import importlib.machinery +from typing import Optional import pytest @@ -261,24 +264,73 @@ def easyprivacy_txt(): return _decompress_gzip_datafile("easyprivacy.txt.gz") +def _has_qtwebengine() -> bool: + """Check whether QtWebEngine is available.""" + try: + from qutebrowser.qt import webenginecore # pylint: disable=unused-import + except ImportError: + return False + return True + + DISABLE_SECCOMP_BPF_FLAG = "--disable-seccomp-filter-sandbox" DISABLE_SECCOMP_BPF_ARGS = ["-s", "qt.chromium.sandboxing", "disable-seccomp-bpf"] -def disable_seccomp_bpf_sandbox(): +def _needs_map_discard_workaround(webengine_version: utils.VersionNumber) -> bool: + """Check if this system needs the glibc 2.41+ MAP_DISCARD workaround. + + WORKAROUND for https://bugreports.qt.io/browse/QTBUG-134631 + See https://bugs.gentoo.org/show_bug.cgi?id=949654 + """ + if not utils.is_posix: + return False + + # Not fixed yet as of Qt 6.9 Beta 3 + utils.unused(webengine_version) + + libc_name, libc_version_str = platform.libc_ver() + if libc_name != "glibc": + return False + + libc_version = utils.VersionNumber.parse(libc_version_str) + kernel_version = utils.VersionNumber.parse(os.uname().release) + + # https://sourceware.org/git/?p=glibc.git;a=commit;h=461cab1 + affected_glibc = utils.VersionNumber(2, 41) + affected_kernel = utils.VersionNumber(6, 11) + + return libc_version >= affected_glibc and kernel_version >= affected_kernel + + +def disable_seccomp_bpf_sandbox() -> bool: """Check whether we need to disable the seccomp BPF sandbox. This is needed for some QtWebEngine setups, with older Qt versions but newer kernels. """ - try: - from qutebrowser.qt import webenginecore # pylint: disable=unused-import - except ImportError: - # no QtWebEngine available + if not _has_qtwebengine(): return False - versions = version.qtwebengine_versions(avoid_init=True) - return versions.webengine == utils.VersionNumber(5, 15, 2) + return ( + versions.webengine == utils.VersionNumber(5, 15, 2) + or _needs_map_discard_workaround(versions.webengine) + ) + + +SOFTWARE_RENDERING_FLAG = "--disable-gpu" +SOFTWARE_RENDERING_ARGS = ["-s", "qt.force_software_rendering", "chromium"] + + +def offscreen_plugin_enabled() -> bool: + """Check whether offscreen rendering is enabled.""" + # FIXME allow configuring via custom CLI flag? + return os.environ.get("QT_QPA_PLATFORM") == "offscreen" + + +def use_software_rendering() -> bool: + """Check whether to enforce software rendering for tests.""" + return _has_qtwebengine() and offscreen_plugin_enabled() def import_userscript(name): @@ -310,3 +362,20 @@ def enum_members(base, enumtype): for name, value in vars(base).items() if isinstance(value, enumtype) } + + +def is_userns_restricted() -> Optional[bool]: + if not utils.is_linux: + return None + + try: + proc = subprocess.run( + ["sysctl", "-n", "kernel.apparmor_restrict_unprivileged_userns"], + capture_output=True, + text=True, + check=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return None + + return proc.stdout.strip() == "1" diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py index 7b35fa149..e46c685f4 100644 --- a/tests/unit/browser/test_history.py +++ b/tests/unit/browser/test_history.py @@ -224,7 +224,7 @@ class TestAdd: assert list(web_history) assert not list(web_history.completion) - def test_no_immedate_duplicates(self, web_history, mock_time): + def test_no_immediate_duplicates(self, web_history, mock_time): url = QUrl("http://example.com") url2 = QUrl("http://example2.com") web_history.add_from_tab(QUrl(url), QUrl(url), 'title') diff --git a/tests/unit/browser/test_notification.py b/tests/unit/browser/test_notification.py index 3d6c16f87..6c888f084 100644 --- a/tests/unit/browser/test_notification.py +++ b/tests/unit/browser/test_notification.py @@ -7,7 +7,7 @@ import logging import itertools import inspect -from typing import List, Dict, Any, Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING import pytest from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QUrl, QObject @@ -36,7 +36,7 @@ class FakeDBusMessage: self._type = typ self._error_name = error_name - def arguments(self) -> List[Any]: + def arguments(self) -> list[Any]: return self._arguments def signature(self) -> str: @@ -107,8 +107,8 @@ class FakeDBusInterface: icon: str, title: str, body: str, - actions: List[str], - hints: Dict[str, Any], + actions: list[str], + hints: dict[str, Any], timeout: int, ) -> FakeDBusMessage: assert self.notify_reply is not None diff --git a/tests/unit/browser/test_pdfjs.py b/tests/unit/browser/test_pdfjs.py index cb5c26229..867e8e1de 100644 --- a/tests/unit/browser/test_pdfjs.py +++ b/tests/unit/browser/test_pdfjs.py @@ -9,7 +9,7 @@ import pytest from qutebrowser.qt.core import QUrl from qutebrowser.browser import pdfjs -from qutebrowser.utils import urlmatch +from qutebrowser.utils import urlmatch, utils pytestmark = [pytest.mark.usefixtures('data_tmpdir')] @@ -154,6 +154,8 @@ def test_read_from_system(names, expected_name, tmpdir): expected = (b'text2', str(file2)) elif expected_name is None: expected = (None, None) + else: + raise utils.Unreachable(expected_name) assert pdfjs._read_from_system(str(tmpdir), names) == expected diff --git a/tests/unit/browser/test_shared.py b/tests/unit/browser/test_shared.py index 839cda290..0d5e17abb 100644 --- a/tests/unit/browser/test_shared.py +++ b/tests/unit/browser/test_shared.py @@ -6,6 +6,7 @@ import logging import pytest +from qutebrowser.qt.core import QUrl from qutebrowser.browser import shared from qutebrowser.utils import usertypes @@ -35,6 +36,19 @@ def test_custom_headers(config_stub, dnt, accept_language, custom_headers, assert shared.custom_headers(url=None) == expected_items +@pytest.mark.parametrize("url, fallback, expected", [ + # url is never None in the wild, mostly sanity check + (None, True, True), + (None, False, True), + (QUrl("http://example.org"), True, True), + (QUrl("http://example.org"), False, False), +]) +def test_accept_language_no_fallback(config_stub, url, fallback, expected): + config_stub.val.content.headers.accept_language = "de, en" + headers = shared.custom_headers(url=url, fallback_accept_language=fallback) + assert (b"Accept-Language" in dict(headers)) == expected + + @pytest.mark.parametrize( ( "levels_setting, excludes_setting, level, source, msg, expected_ret, " diff --git a/tests/unit/browser/webengine/test_darkmode.py b/tests/unit/browser/webengine/test_darkmode.py index bda05feb8..8b8959a15 100644 --- a/tests/unit/browser/webengine/test_darkmode.py +++ b/tests/unit/browser/webengine/test_darkmode.py @@ -4,9 +4,9 @@ import logging -from typing import List, Tuple import pytest +QWebEngineSettings = pytest.importorskip("qutebrowser.qt.webenginecore").QWebEngineSettings from qutebrowser.config import configdata from qutebrowser.utils import usertypes, version, utils @@ -28,6 +28,150 @@ def gentoo_versions(): ) +class TestSetting: + + @pytest.mark.parametrize("value, mapping, expected", [ + ("val", None, ("key", "val")), + (5, None, ("key", "5")), + (True, darkmode._BOOLS, ("key", "true")), + ("excluded", {"excluded": None}, None), + ]) + def test_chromium_tuple(self, value, mapping, expected): + setting = darkmode._Setting(option="opt", chromium_key="key", mapping=mapping) + assert setting.chromium_tuple(value) == expected + + def test_with_prefix(self): + mapping = {"val": "mapped"} + setting = darkmode._Setting(option="opt", chromium_key="key", mapping=mapping) + prefixed = setting.with_prefix("prefix") + assert prefixed == darkmode._Setting( + option="opt", chromium_key="prefixkey", mapping=mapping + ) + + +class TestDefinition: + + @pytest.fixture + def setting1(self) -> darkmode._Setting: + return darkmode._Setting("opt1", "key1") + + @pytest.fixture + def setting2(self) -> darkmode._Setting: + return darkmode._Setting("opt2", "key2") + + @pytest.fixture + def setting3(self) -> darkmode._Setting: + return darkmode._Setting("opt3", "key3") + + @pytest.fixture + def definition( + self, setting1: darkmode._Setting, setting2: darkmode._Setting + ) -> darkmode._Definition: + return darkmode._Definition(setting1, setting2, mandatory=set(), prefix="") + + def _get_settings(self, definition: darkmode._Definition) -> list[darkmode._Setting]: + return [setting for _key, setting in definition.prefixed_settings()] + + @pytest.mark.parametrize("prefix", ["", "prefix"]) + def test_prefixed_settings( + self, + prefix: str, + definition: darkmode._Definition, + setting1: darkmode._Setting, + setting2: darkmode._Setting, + ): + assert definition.prefix == "" # default value + definition.prefix = prefix + prefixed = self._get_settings(definition) + assert prefixed == [setting1.with_prefix(prefix), setting2.with_prefix(prefix)] + + def test_switch_names( + self, + definition: darkmode._Definition, + setting1: darkmode._Setting, + setting2: darkmode._Setting, + setting3: darkmode._Setting, + ): + switch_names = { + setting1.option: "opt1-switch", + None: "default-switch", + } + definition = darkmode._Definition( + setting1, + setting2, + setting3, + mandatory=set(), + prefix="", + switch_names=switch_names, + ) + settings = list(definition.prefixed_settings()) + assert settings == [ + ("opt1-switch", setting1), + ("default-switch", setting2), + ("default-switch", setting3), + ] + + def test_copy_remove_setting( + self, + definition: darkmode._Definition, + setting1: darkmode._Setting, + setting2: darkmode._Setting, + ): + copy = definition.copy_remove_setting(setting2.option) + orig_settings = self._get_settings(definition) + copy_settings = self._get_settings(copy) + assert orig_settings == [setting1, setting2] + assert copy_settings == [setting1] + + def test_copy_remove_setting_not_found(self, definition: darkmode._Definition): + with pytest.raises(ValueError, match="Setting not-found not found in "): + definition.copy_remove_setting("not-found") + + def test_copy_add_setting( + self, + definition: darkmode._Definition, + setting1: darkmode._Setting, + setting2: darkmode._Setting, + setting3: darkmode._Setting, + ): + copy = definition.copy_add_setting(setting3) + orig_settings = self._get_settings(definition) + copy_settings = self._get_settings(copy) + assert orig_settings == [setting1, setting2] + assert copy_settings == [setting1, setting2, setting3] + + def test_copy_add_setting_already_exists( + self, + definition: darkmode._Definition, + setting1: darkmode._Setting, + setting2: darkmode._Setting, + ): + copy = definition.copy_add_setting(setting2) + orig_settings = self._get_settings(definition) + copy_settings = self._get_settings(copy) + assert orig_settings == [setting1, setting2] + assert copy_settings == [setting1, setting2, setting2] + + def test_copy_replace_setting( + self, + definition: darkmode._Definition, + setting1: darkmode._Setting, + setting2: darkmode._Setting, + ): + replaced = darkmode._Setting(setting2.option, setting2.chromium_key + "-replaced") + copy = definition.copy_replace_setting(setting2.option, replaced.chromium_key) + orig_settings = self._get_settings(definition) + copy_settings = self._get_settings(copy) + assert orig_settings == [setting1, setting2] + assert copy_settings == [setting1, replaced] + + def test_copy_replace_setting_not_found( + self, definition: darkmode._Definition, setting3: darkmode._Setting + ): + with pytest.raises(ValueError, match="Setting opt3 not found in "): + definition.copy_replace_setting(setting3.option, setting3.chromium_key) + + @pytest.mark.parametrize('value, webengine_version, expected', [ # Auto ("auto", "5.15.2", [("preferredColorScheme", "2")]), # QTBUG-89753 @@ -122,16 +266,49 @@ QT_64_SETTINGS = { 'dark-mode-settings': [ ('InversionAlgorithm', '1'), ('ImagePolicy', '2'), - ('ForegroundBrightnessThreshold', '100'), + ('ForegroundBrightnessThreshold', '100'), # name changed ], } -@pytest.mark.parametrize('qversion, expected', [ - ('5.15.2', QT_515_2_SETTINGS), - ('5.15.3', QT_515_3_SETTINGS), - ('6.4', QT_64_SETTINGS), -]) +QT_66_SETTINGS = { + 'blink-settings': [('forceDarkModeEnabled', 'true')], + 'dark-mode-settings': [ + ('InversionAlgorithm', '1'), + ('ImagePolicy', '2'), + ('ForegroundBrightnessThreshold', '100'), + ("ImageClassifierPolicy", "0"), # added + ], +} + +QT_67_SETTINGS = { + # blink-settings removed + 'dark-mode-settings': [ + ('InversionAlgorithm', '1'), + ('ImagePolicy', '2'), + ('ForegroundBrightnessThreshold', '100'), + ("ImageClassifierPolicy", "0"), + ], +} + + +@pytest.mark.parametrize( + "qversion, expected", + [ + ("5.15.2", QT_515_2_SETTINGS), + ("5.15.3", QT_515_3_SETTINGS), + ("6.4", QT_64_SETTINGS), + ("6.6", QT_66_SETTINGS), + pytest.param( + "6.7", + QT_67_SETTINGS, + marks=pytest.mark.skipif( + not hasattr(QWebEngineSettings.WebAttribute, "ForceDarkMode"), + reason="needs PyQt 6.7", + ), + ), + ], +) def test_qt_version_differences(config_stub, qversion, expected): settings = { 'enabled': True, @@ -187,7 +364,7 @@ def test_customization(config_stub, setting, value, exp_key, exp_val): ('6.5.3', 'policy.images', 'smart', [('ImagePolicy', '2')]), ('6.5.3', 'policy.images', 'smart-simple', [('ImagePolicy', '2')]), ]) -def test_image_policy(config_stub, qtwe_version: str, setting: str, value: str, expected: List[Tuple[str, str]]): +def test_image_policy(config_stub, qtwe_version: str, setting: str, value: str, expected: list[tuple[str, str]]): config_stub.val.colors.webpage.darkmode.enabled = True config_stub.set_obj('colors.webpage.darkmode.' + setting, value) @@ -213,6 +390,16 @@ def test_variant(webengine_version, expected): assert darkmode._variant(versions) == expected +def test_variant_qt67() -> None: + versions = version.WebEngineVersions.from_pyqt("6.7.0") + # We can't monkeypatch the enum, so compare against the real situation + if hasattr(QWebEngineSettings.WebAttribute, "ForceDarkMode"): + expected = darkmode.Variant.qt_67 + else: + expected = darkmode.Variant.qt_66 + assert darkmode._variant(versions) == expected + + def test_variant_gentoo_workaround(gentoo_versions): assert darkmode._variant(gentoo_versions) == darkmode.Variant.qt_515_3 @@ -257,8 +444,9 @@ def test_options(configdata_init): if not name.startswith('colors.webpage.darkmode.'): continue - assert not opt.supports_pattern, name - assert opt.restart, name + if name != 'colors.webpage.darkmode.enabled': + assert not opt.supports_pattern, name + assert opt.restart, name if opt.backends: # On older Qt versions, this is an empty list. diff --git a/tests/unit/browser/webengine/test_webenginesettings.py b/tests/unit/browser/webengine/test_webenginesettings.py index ecfe65041..bdd81c9ec 100644 --- a/tests/unit/browser/webengine/test_webenginesettings.py +++ b/tests/unit/browser/webengine/test_webenginesettings.py @@ -7,6 +7,7 @@ import logging import pytest QtWebEngineCore = pytest.importorskip('qutebrowser.qt.webenginecore') +QWebEngineProfile = QtWebEngineCore.QWebEngineProfile QWebEngineSettings = QtWebEngineCore.QWebEngineSettings from qutebrowser.browser.webengine import webenginesettings @@ -128,6 +129,7 @@ def test_non_existing_dict(config_stub, monkeypatch, message_mock, caplog, def test_existing_dict(config_stub, monkeypatch, global_settings, default_profile, private_profile): + """With a language set, spell check should get enabled.""" monkeypatch.setattr(webenginesettings.spell, 'local_filename', lambda _code: 'en-US-8-0') config_stub.val.spellcheck.languages = ['en-US'] @@ -139,6 +141,7 @@ def test_existing_dict(config_stub, monkeypatch, global_settings, def test_spell_check_disabled(config_stub, monkeypatch, global_settings, default_profile, private_profile): + """With no language set, spell check should get disabled.""" config_stub.val.spellcheck.languages = [] webenginesettings._update_settings('spellcheck.languages') for profile in [default_profile, private_profile]: @@ -148,8 +151,11 @@ def test_spell_check_disabled(config_stub, monkeypatch, global_settings, def test_parsed_user_agent(qapp): webenginesettings.init_user_agent() parsed = webenginesettings.parsed_user_agent + assert parsed is not None assert parsed.upstream_browser_key == 'Chrome' assert parsed.qt_key == 'QtWebEngine' + assert not parsed.upstream_browser_version.endswith(".0.0.0") + assert parsed.upstream_browser_version_short.endswith(".0.0.0") def test_profile_setter_settings(private_profile, configdata_init): diff --git a/tests/unit/browser/webengine/test_webenginetab.py b/tests/unit/browser/webengine/test_webenginetab.py index d71a4bcd8..173254919 100644 --- a/tests/unit/browser/webengine/test_webenginetab.py +++ b/tests/unit/browser/webengine/test_webenginetab.py @@ -230,3 +230,17 @@ class TestFindFlags: backward=backward, ) assert str(flags) == expected + + +class TestWebEnginePermissions: + + def test_clipboard_value(self): + # Ensure the ClipboardReadWrite permission is in the permission map, + # despite us specifying it by number. + permissions_cls = webenginetab._WebEnginePermissions + try: + clipboard = QWebEnginePage.Feature.ClipboardReadWrite + except AttributeError: + pytest.skip("enum member not available") + assert clipboard in permissions_cls._options + assert clipboard in permissions_cls._messages diff --git a/tests/unit/browser/webengine/test_webview.py b/tests/unit/browser/webengine/test_webview.py index f14a896b6..8ffc81d60 100644 --- a/tests/unit/browser/webengine/test_webview.py +++ b/tests/unit/browser/webengine/test_webview.py @@ -25,10 +25,10 @@ class Naming: def camel_to_snake(naming, name): if naming.prefix: assert name.startswith(naming.prefix) - name = name[len(naming.prefix):] + name = name.removeprefix(naming.prefix) if naming.suffix: assert name.endswith(naming.suffix) - name = name[:-len(naming.suffix)] + name = name.removesuffix(naming.suffix) # https://stackoverflow.com/a/1176023 return re.sub(r'(? None: for (str_url, source_str_url, request_type) in urls: diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 10d9e2292..f9dca3e74 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -840,6 +840,8 @@ class TestBind: func = functools.partial(commands.bind, 0) elif command == 'unbind': func = commands.unbind + else: + raise utils.Unreachable(command) with pytest.raises(cmdutils.CommandError, match=expected): func(*args, **kwargs) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index cde0a180b..25e2d7e50 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -399,6 +399,7 @@ class TestYaml: yaml._save() if not insert and old_config is None: + data = {} # unused lines = [] else: data = autoconfig.read() @@ -787,7 +788,7 @@ class TestYamlMigrations: (None, ('Mozilla/5.0 ({os_info}) ' 'AppleWebKit/{webkit_version} (KHTML, like Gecko) ' '{qt_key}/{qt_version} ' - '{upstream_browser_key}/{upstream_browser_version} ' + '{upstream_browser_key}/{upstream_browser_version_short} ' 'Safari/{webkit_version}')), ('toaster', 'toaster'), ]) diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index bcd257ed7..89dc58912 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -1602,6 +1602,8 @@ class TestDict: valtype=configtypes.String(), required_keys=['one', 'two']) message = 'Required keys .*' + else: + raise utils.Unreachable(kind) if ok: expectation = testutils.nop_contextmanager() @@ -2143,3 +2145,29 @@ def test_regex_eq(first, second, equal): else: assert first != second assert second != first + + +class TestJSClipboardPermission: + + @pytest.fixture + def typ(self): + return configtypes.JSClipboardPermission() + + @pytest.mark.parametrize('value, expected', [ + ("access-paste", True), + ("none", False), + ("asdf", False), + ("access", False), + ("paste", False), + (None, False), + ]) + def test_to_bool(self, typ, value, expected): + assert typ.to_bool(value) == expected + + @pytest.mark.parametrize('value, expected', [ + (True, "access-paste"), + (False, "none"), + (None, "none"), + ]) + def test_from_bool(self, typ, value, expected): + assert typ.from_bool(value) == expected diff --git a/tests/unit/config/test_qtargs.py b/tests/unit/config/test_qtargs.py index d7a95aad1..ab06aeb76 100644 --- a/tests/unit/config/test_qtargs.py +++ b/tests/unit/config/test_qtargs.py @@ -448,6 +448,39 @@ class TestWebEngineArgs: expected = ['--disable-features=InstalledApp'] if has_workaround else [] assert disable_features_args == expected + @pytest.mark.parametrize('qt_version, has_workaround', [ + # Qt 6.6 + ('6.6.3', False), + # Qt 6.7 + ('6.7.0', True), + ('6.7.1', True), + ('6.7.2', True), + ('6.7.3', True), + # Qt 6.8 + ('6.8.0', True), + ('6.8.1', True), + ('6.8.2', True), # tbd + ('6.8.3', True), # tbd + # Qt 6.9 + ('6.9.0', True), # tbd + ('6.9.1', True), # tbd + ]) + def test_document_pip_workaround( + self, parser, version_patcher, qt_version, has_workaround + ): + version_patcher(qt_version) + + parsed = parser.parse_args([]) + args = qtargs.qt_args(parsed) + disable_features_args = [ + arg for arg in args + if arg.startswith(qtargs._DISABLE_FEATURES) + ] + + flag = "--disable-features=DocumentPictureInPictureAPI" + expected = [flag] if has_workaround else [] + assert disable_features_args == expected + @pytest.mark.parametrize('enabled', [True, False]) def test_media_keys(self, config_stub, parser, enabled): config_stub.val.input.media_keys = enabled diff --git a/tests/unit/config/test_websettings.py b/tests/unit/config/test_websettings.py index d2cd25901..8c655dbab 100644 --- a/tests/unit/config/test_websettings.py +++ b/tests/unit/config/test_websettings.py @@ -11,7 +11,8 @@ from qutebrowser.utils import usertypes @pytest.mark.parametrize([ # noqa: PT006 'user_agent', 'os_info', 'webkit_version', - 'upstream_browser_key', 'upstream_browser_version', 'qt_key' + 'upstream_browser_key', 'upstream_browser_version', + 'upstream_browser_version_short', 'qt_key' ], [ ( # QtWebEngine, Linux @@ -21,7 +22,7 @@ from qutebrowser.utils import usertypes "QtWebEngine/5.14.0 Chrome/77.0.3865.98 Safari/537.36"), "X11; Linux x86_64", "537.36", - "Chrome", "77.0.3865.98", + "Chrome", "77.0.3865.98", "77.0.0.0", "QtWebEngine", ), ( # QtWebKit, Linux @@ -31,7 +32,7 @@ from qutebrowser.utils import usertypes "Version/10.0 Safari/602.1"), "X11; Linux x86_64", "602.1", - "Version", "10.0", + "Version", "10.0", "10.0", "Qt", ), ( # QtWebEngine, macOS @@ -40,7 +41,7 @@ from qutebrowser.utils import usertypes "QtWebEngine/5.13.2 Chrome/73.0.3683.105 Safari/537.36"), "Macintosh; Intel Mac OS X 10_12_6", "537.36", - "Chrome", "73.0.3683.105", + "Chrome", "73.0.3683.105", "73.0.0.0", "QtWebEngine", ), ( # QtWebEngine, Windows @@ -49,18 +50,25 @@ from qutebrowser.utils import usertypes "QtWebEngine/5.12.5 Chrome/69.0.3497.128 Safari/537.36"), "Windows NT 10.0; Win64; x64", "537.36", - "Chrome", "69.0.3497.128", + "Chrome", "69.0.3497.128", "69.0.0.0", "QtWebEngine", ) ]) -def test_parse_user_agent(user_agent, os_info, webkit_version, - upstream_browser_key, upstream_browser_version, - qt_key): +def test_parse_user_agent( + user_agent: str, + os_info: str, + webkit_version: str, + upstream_browser_key: str, + upstream_browser_version: str, + upstream_browser_version_short: str, + qt_key: str, +): parsed = websettings.UserAgent.parse(user_agent) assert parsed.os_info == os_info assert parsed.webkit_version == webkit_version assert parsed.upstream_browser_key == upstream_browser_key assert parsed.upstream_browser_version == upstream_browser_version + assert parsed.upstream_browser_version_short == upstream_browser_version_short assert parsed.qt_key == qt_key diff --git a/tests/unit/javascript/test_js_quirks.py b/tests/unit/javascript/test_js_quirks.py index 9218c6d0d..981f9d9e8 100644 --- a/tests/unit/javascript/test_js_quirks.py +++ b/tests/unit/javascript/test_js_quirks.py @@ -55,7 +55,7 @@ def test_js_quirks(config_stub, js_tester_webengine, base_url, source, expected) def test_js_quirks_match_files(webengine_tab): quirks_path = pathlib.Path(qutebrowser.__file__).parent / "javascript" / "quirks" suffix = ".user.js" - quirks_files = {p.name[:-len(suffix)] for p in quirks_path.glob(f"*{suffix}")} + quirks_files = {p.name.removesuffix(suffix) for p in quirks_path.glob(f"*{suffix}")} quirks_code = {q.filename for q in webengine_tab._scripts._get_quirks()} assert quirks_code == quirks_files @@ -66,7 +66,7 @@ def test_js_quirks_match_settings(webengine_tab, configdata_init): valid_values = opt.typ.get_valid_values() assert valid_values is not None quirks_config = { - val[len(prefix):].replace("-", "_") + val.removeprefix(prefix).replace("-", "_") for val in valid_values if val.startswith(prefix) } diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 572456a22..257bd89a2 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -57,7 +57,7 @@ def qtest_key(request): def test_key_data_keys(): """Make sure all possible keys are in key_data.KEYS.""" - key_names = {name[len("Key_"):] + key_names = {name.removeprefix("Key_") for name in testutils.enum_members(Qt, Qt.Key)} key_data_names = {key.attribute for key in sorted(key_data.KEYS)} diff = key_names - key_data_names @@ -66,7 +66,7 @@ def test_key_data_keys(): def test_key_data_modifiers(): """Make sure all possible modifiers are in key_data.MODIFIERS.""" - mod_names = {name[:-len("Modifier")] + mod_names = {name.removesuffix("Modifier") for name, value in testutils.enum_members(Qt, Qt.KeyboardModifier).items() if value not in [Qt.KeyboardModifier.NoModifier, Qt.KeyboardModifier.KeyboardModifierMask]} mod_data_names = {mod.attribute for mod in sorted(key_data.MODIFIERS)} diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py index 4ba11ed58..6a83d614b 100644 --- a/tests/unit/keyinput/test_modeparsers.py +++ b/tests/unit/keyinput/test_modeparsers.py @@ -115,10 +115,12 @@ class TestHintKeyParser: seq = keyutils.KeySequence.parse(keychain) assert len(seq) == 2 + # pylint: disable-next=no-member match = keyparser.handle(seq[0].to_event()) assert match == QKeySequence.SequenceMatch.PartialMatch assert hintmanager.keystr == prefix + # pylint: disable-next=no-member match = keyparser.handle(seq[1].to_event()) assert match == QKeySequence.SequenceMatch.ExactMatch assert hintmanager.keystr == hint @@ -132,10 +134,12 @@ class TestHintKeyParser: seq = keyutils.KeySequence.parse('ασ') assert len(seq) == 2 + # pylint: disable-next=no-member match = keyparser.handle(seq[0].to_event()) assert match == QKeySequence.SequenceMatch.PartialMatch assert hintmanager.keystr == 'a' + # pylint: disable-next=no-member match = keyparser.handle(seq[1].to_event()) assert match == QKeySequence.SequenceMatch.ExactMatch assert hintmanager.keystr == 'as' diff --git a/tests/unit/misc/test_checkpyver.py b/tests/unit/misc/test_checkpyver.py index fddf9e9e8..8bcdf9772 100644 --- a/tests/unit/misc/test_checkpyver.py +++ b/tests/unit/misc/test_checkpyver.py @@ -14,7 +14,7 @@ import pytest from qutebrowser.misc import checkpyver -TEXT = (r"At least Python 3.8 is required to run qutebrowser, but it's " +TEXT = (r"At least Python 3.9 is required to run qutebrowser, but it's " r"running with \d+\.\d+\.\d+.") diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py index 3f7edd143..debfa7b9e 100644 --- a/tests/unit/misc/test_editor.py +++ b/tests/unit/misc/test_editor.py @@ -163,7 +163,7 @@ class TestFileHandling: msg = message_mock.getmsg(usertypes.MessageLevel.info) prefix = 'Editor backup at ' assert msg.text.startswith(prefix) - fname = msg.text[len(prefix):] + fname = msg.text.removeprefix(prefix) with qtbot.wait_signal(editor.editing_finished): editor._proc._proc.finished.emit(0, QProcess.ExitStatus.NormalExit) diff --git a/tests/unit/misc/test_guiprocess.py b/tests/unit/misc/test_guiprocess.py index d1bc6e7c1..7c4ff1a5d 100644 --- a/tests/unit/misc/test_guiprocess.py +++ b/tests/unit/misc/test_guiprocess.py @@ -9,7 +9,7 @@ import logging import signal import pytest -from qutebrowser.qt.core import QProcess, QUrl +from qutebrowser.qt.core import QProcess, QUrl, Qt from qutebrowser.misc import guiprocess from qutebrowser.utils import usertypes, utils, version @@ -534,6 +534,7 @@ def test_str(proc, py_proc): def test_cleanup(proc, py_proc, qtbot): + proc._cleanup_timer.setTimerType(Qt.TimerType.CoarseTimer) proc._cleanup_timer.setInterval(100) with qtbot.wait_signal(proc._cleanup_timer.timeout): diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py index 79c2c7b7d..f611428af 100644 --- a/tests/unit/misc/test_ipc.py +++ b/tests/unit/misc/test_ipc.py @@ -12,7 +12,7 @@ import json import hashlib import dataclasses from unittest import mock -from typing import Optional, List +from typing import Optional import pytest from qutebrowser.qt.core import pyqtSignal, QObject @@ -631,7 +631,7 @@ class TestSendOrListen: no_err_windows: bool basedir: str - command: List[str] + command: list[str] target: Optional[str] @pytest.fixture diff --git a/tests/unit/misc/test_pakjoy.py b/tests/unit/misc/test_pakjoy.py index 2dcfbd5b1..3065243f0 100644 --- a/tests/unit/misc/test_pakjoy.py +++ b/tests/unit/misc/test_pakjoy.py @@ -13,7 +13,7 @@ import shutil import pytest from qutebrowser.misc import pakjoy, binparsing -from qutebrowser.utils import utils, version, standarddir +from qutebrowser.utils import utils, version, standarddir, usertypes pytest.importorskip("qutebrowser.qt.webenginecore") @@ -25,17 +25,6 @@ pytestmark = pytest.mark.usefixtures("cache_tmpdir") versions = version.qtwebengine_versions(avoid_init=True) -# Used to skip happy path tests with the real resources file. -# -# Since we don't know how reliably the Google Meet hangouts extensions is -# reliably in the resource files, and this quirk is only targeting 6.6 -# anyway. -skip_if_unsupported = pytest.mark.skipif( - versions.webengine != utils.VersionNumber(6, 6), - reason="Code under test only runs on 6.6", -) - - @pytest.fixture(autouse=True) def prepare_env(qapp, monkeypatch): monkeypatch.setattr(pakjoy.objects, "qapp", qapp) @@ -78,7 +67,7 @@ def affected_version(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureReq @pytest.mark.parametrize("workdir_exists", [True, False]) -def test_version_gate(cache_tmpdir, unaffected_version, mocker, workdir_exists): +def test_version_gate(cache_tmpdir, unaffected_version, mocker, config_stub, workdir_exists): workdir = cache_tmpdir / pakjoy.CACHE_DIR_NAME if workdir_exists: workdir.mkdir() @@ -92,7 +81,9 @@ def test_version_gate(cache_tmpdir, unaffected_version, mocker, workdir_exists): assert not workdir.exists() -def test_escape_hatch(affected_version, mocker, monkeypatch): +@pytest.mark.parametrize("explicit", [True, False]) +def test_escape_hatch(affected_version, mocker, monkeypatch, config_stub, explicit): + config_stub.val.qt.workarounds.disable_hangouts_extension = explicit fake_open = mocker.patch("qutebrowser.misc.pakjoy.open") monkeypatch.setenv(pakjoy.DISABLE_ENV_VAR, "1") @@ -177,7 +168,7 @@ class TestFindWebengineResources: def test_nowhere(self, fallback_path: pathlib.Path): """Test we raise if we can't find the resources.""" with pytest.raises( - binparsing.ParseError, match="Couldn't find webengine resources dir" + FileNotFoundError, match="Couldn't find webengine resources dir, candidates:\n*" ): pakjoy._find_webengine_resources() @@ -206,7 +197,7 @@ def read_patched_manifest(): class TestWithRealResourcesFile: """Tests that use the real pak file form the Qt installation.""" - @skip_if_unsupported + @pytest.mark.qt6_only def test_happy_path(self): # Go through the full patching processes with the real resources file from # the current installation. Make sure our replacement string is in it @@ -266,6 +257,25 @@ class TestWithRealResourcesFile: "Not applying quirks. Expected location: " ) + @pytest.mark.qt6_only + def test_hardcoded_ids(self): + """Make sure we hardcoded the currently valid ID. + + This avoids users having to iterate through the whole resource file on + every start. It will probably break on every QtWebEngine upgrade and can + be fixed by adding the respective ID to HANGOUTS_IDS. + """ + resources_dir = pakjoy._find_webengine_resources() + file_to_patch = resources_dir / pakjoy.PAK_FILENAME + with open(file_to_patch, "rb") as f: + parser = pakjoy.PakParser(f) + error_msg = ( + "Encountered hangouts extension with resource ID which isn't in pakjoy.HANGOUTS_IDS: " + f"found_resource_id={parser.manifest_entry.resource_id} " + f"webengine_version={versions.webengine}" + ) + assert parser.manifest_entry.resource_id in pakjoy.HANGOUTS_IDS, error_msg + def json_manifest_factory(extension_id=pakjoy.HANGOUTS_MARKER, url=pakjoy.TARGET_URL): assert isinstance(extension_id, bytes) @@ -394,7 +404,9 @@ class TestWithConstructedResourcesFile: ): parser.find_patch_offset() - def test_url_not_found_high_level(self, cache_tmpdir, caplog, affected_version): + @pytest.mark.parametrize("explicit", [True, False]) + def test_url_not_found_high_level(self, cache_tmpdir, caplog, affected_version, config_stub, message_mock, explicit): + config_stub.val.qt.workarounds.disable_hangouts_extension = explicit buffer = pak_factory(entries=[json_manifest_factory(url=b"example.com")]) # Write bytes to file so we can test pakjoy._patch() @@ -402,10 +414,18 @@ class TestWithConstructedResourcesFile: with open(tmpfile, "wb") as fd: fd.write(buffer.read()) - with caplog.at_level(logging.ERROR, "misc"): + logger = "message" if explicit else "misc" + with caplog.at_level(logging.ERROR, logger): pakjoy._patch(tmpfile) - assert caplog.messages == ["Failed to apply quirk to resources pak."] + if explicit: + msg = message_mock.getmsg(usertypes.MessageLevel.error) + assert msg.text == ( + "Failed to disable Hangouts extension:\n" + "Failed to apply quirk to resources pak." + ) + else: + assert caplog.messages[-1] == "Failed to apply quirk to resources pak." @pytest.fixture def resources_path( @@ -437,6 +457,13 @@ class TestWithConstructedResourcesFile: ) assert pakjoy.RESOURCES_ENV_VAR not in os.environ + @pytest.mark.qt6_only + def test_explicitly_enabled(self, monkeypatch: pytest.MonkeyPatch, config_stub): + patch_version(monkeypatch, utils.VersionNumber(6, 7)) # unaffected + config_stub.val.qt.workarounds.disable_hangouts_extension = True + with pakjoy.patch_webengine(): + assert pakjoy.RESOURCES_ENV_VAR in os.environ + def test_preset_env_var( self, resources_path: pathlib.Path, diff --git a/tests/unit/misc/test_split.py b/tests/unit/misc/test_split.py index f8b700982..2e991dc51 100644 --- a/tests/unit/misc/test_split.py +++ b/tests/unit/misc/test_split.py @@ -5,7 +5,6 @@ """Tests for qutebrowser.misc.split.""" import dataclasses -from typing import List import pytest @@ -100,8 +99,8 @@ def _parse_split_test_data_str(): class TestCase: inp: str - keep: List[str] - no_keep: List[str] + keep: list[str] + no_keep: list[str] for line in test_data_str.splitlines(): if not line: diff --git a/tests/unit/test_qt_machinery.py b/tests/unit/test_qt_machinery.py index 25fc83ffd..677494ee5 100644 --- a/tests/unit/test_qt_machinery.py +++ b/tests/unit/test_qt_machinery.py @@ -9,7 +9,7 @@ import sys import html import argparse import typing -from typing import Any, Optional, List, Dict, Union +from typing import Any, Optional, Union import dataclasses import pytest @@ -45,14 +45,14 @@ def undo_init(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.mark.parametrize( - "exception", + "exception, base", [ - machinery.Unavailable(), - machinery.NoWrapperAvailableError(machinery.SelectionInfo()), + (machinery.Unavailable(), ModuleNotFoundError), + (machinery.NoWrapperAvailableError(machinery.SelectionInfo()), ImportError), ], ) -def test_importerror_exceptions(exception: Exception): - with pytest.raises(ImportError): +def test_importerror_exceptions(exception: Exception, base: type[Exception]): + with pytest.raises(base): raise exception @@ -118,7 +118,7 @@ def test_selectioninfo_str(info: machinery.SelectionInfo, expected: str): @pytest.mark.parametrize("order", [["PyQt5", "PyQt6"], ["PyQt6", "PyQt5"]]) -def test_selectioninfo_str_wrapper_precedence(order: List[str]): +def test_selectioninfo_str_wrapper_precedence(order: list[str]): """The order of the wrappers should be the same as in machinery.WRAPPERS.""" info = machinery.SelectionInfo( wrapper="PyQt6", @@ -210,7 +210,7 @@ def modules(): ) def test_autoselect( stubs: Any, - available: Dict[str, Union[bool, Exception]], + available: dict[str, Union[bool, Exception]], expected: machinery.SelectionInfo, monkeypatch: pytest.MonkeyPatch, ): @@ -417,7 +417,7 @@ class TestInit: def test_none_available_implicit( self, stubs: Any, - modules: Dict[str, bool], + modules: dict[str, bool], monkeypatch: pytest.MonkeyPatch, qt_auto_env: None, ): @@ -441,7 +441,7 @@ class TestInit: def test_none_available_explicit( self, stubs: Any, - modules: Dict[str, bool], + modules: dict[str, bool], monkeypatch: pytest.MonkeyPatch, empty_args: argparse.Namespace, qt_auto_env: None, diff --git a/tests/unit/utils/test_log.py b/tests/unit/utils/test_log.py index 03ace4009..8af04486f 100644 --- a/tests/unit/utils/test_log.py +++ b/tests/unit/utils/test_log.py @@ -27,8 +27,7 @@ def restore_loggers(): """ logging.captureWarnings(False) logger_dict = logging.getLogger().manager.loggerDict - logging._acquireLock() - try: + with logging._lock: saved_handlers = logging._handlers.copy() saved_handler_list = logging._handlerList[:] saved_loggers = saved_loggers = logger_dict.copy() @@ -37,8 +36,6 @@ def restore_loggers(): logger_states = {} for name in saved_loggers: logger_states[name] = getattr(saved_loggers[name], 'disabled', None) - finally: - logging._releaseLock() root_logger = logging.getLogger("") root_handlers = root_logger.handlers[:] @@ -56,8 +53,8 @@ def restore_loggers(): if not isinstance(h, _pytest.logging.LogCaptureHandler): # https://github.com/qutebrowser/qutebrowser/issues/856 root_logger.addHandler(h) - logging._acquireLock() - try: + + with logging._lock: logging._levelToName.clear() logging._levelToName.update(saved_level_to_name) logging._nameToLevel.clear() @@ -71,8 +68,6 @@ def restore_loggers(): for name, state in logger_states.items(): if state is not None: saved_loggers[name].disabled = state - finally: - logging._releaseLock() @pytest.fixture(scope='session') diff --git a/tests/unit/utils/test_qtutils.py b/tests/unit/utils/test_qtutils.py index c7af3162c..0a3afa416 100644 --- a/tests/unit/utils/test_qtutils.py +++ b/tests/unit/utils/test_qtutils.py @@ -758,8 +758,10 @@ class TestPyQIODevice: # pylint: enable=no-member,useless-suppression else: pytest.skip("Needs os.SEEK_HOLE or os.SEEK_DATA available.") + pyqiodev.open(QIODevice.OpenModeFlag.ReadOnly) with pytest.raises(io.UnsupportedOperation): + # pylint: disable=possibly-used-before-assignment pyqiodev.seek(0, whence) @pytest.mark.flaky @@ -1116,13 +1118,13 @@ class TestQObjRepr: assert qtutils.qobj_repr(obj) == expected def test_class_name(self): - obj = QTimer() + obj = QTimer() # misc: ignore hidden = sip.cast(obj, QObject) expected = f"<{self._py_repr(hidden)}, className='QTimer'>" assert qtutils.qobj_repr(hidden) == expected def test_both(self): - obj = QTimer() + obj = QTimer() # misc: ignore obj.setObjectName("Pomodoro") hidden = sip.cast(obj, QObject) expected = f"<{self._py_repr(hidden)}, objectName='Pomodoro', className='QTimer'>" diff --git a/tests/unit/utils/test_resources.py b/tests/unit/utils/test_resources.py index 911f072f1..3669e523a 100644 --- a/tests/unit/utils/test_resources.py +++ b/tests/unit/utils/test_resources.py @@ -79,7 +79,7 @@ class TestReadFile: 'html/error.html']) def test_read_cached_file(self, mocker, filename): resources.preload() - m = mocker.patch('qutebrowser.utils.resources.importlib_resources.files') + m = mocker.patch('qutebrowser.utils.resources.importlib.resources.files') resources.read_file(filename) m.assert_not_called() @@ -111,7 +111,7 @@ class TestReadFile: return self if fake_exception is not None: - monkeypatch.setattr(resources.importlib_resources, 'files', + monkeypatch.setattr(resources.importlib.resources, 'files', lambda _pkg: BrokenFileFake(fake_exception)) meth = getattr(resources, name) diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index 85910fa17..b19e508f5 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -14,13 +14,14 @@ Currently not tested: """ import string +import urllib.parse import pytest import hypothesis import hypothesis.strategies as hst from qutebrowser.qt.core import QUrl -from qutebrowser.utils import urlmatch +from qutebrowser.utils import urlmatch, utils # FIXME:v4 (lint): disable=line-too-long @@ -76,7 +77,10 @@ _INVALID_IP_MESSAGE = ( pytest.param( "http://[2607:f8b0:4005:805::200e]]/*", "Invalid IPv6 URL", - marks=pytest.mark.xfail(reason="https://bugs.python.org/issue34360"), + marks=pytest.mark.xfail( + not utils.raises(ValueError, urllib.parse.urlparse, "http://[::1]]"), + reason="https://github.com/python/cpython/issues/105704" + ), id='host-ipv6-two-closing', ), # Two open brackets (`[[`). diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py index d2eab5928..b4002a051 100644 --- a/tests/unit/utils/test_urlutils.py +++ b/tests/unit/utils/test_urlutils.py @@ -749,7 +749,7 @@ class TestProxyFromUrl: class TestParseJavascriptUrl: @pytest.mark.parametrize('url, message', [ - (QUrl(), ""), + (QUrl(), "Invalid URL"), (QUrl('https://example.com'), "Expected a javascript:... URL"), (QUrl('javascript://example.com'), "URL contains unexpected components: example.com"), @@ -787,6 +787,26 @@ class TestParseJavascriptUrl: assert parsed == source +@pytest.mark.parametrize('url, pretty, expected', [ + (QUrl('https://example.com'), False, 'https://example.com'), + (QUrl('https://example.com/page'), False, 'https://example.com/page'), + (QUrl('ftp://example.com'), False, 'ftp://example.com'), + (QUrl('ftp://user:password@example.com'), False, 'ftp://user@example.com'), + (QUrl('https://example.com?ref=test'), False, 'https://example.com'), + (QUrl('https://example.com?ref=test&example=yes'), False, + 'https://example.com?example=yes'), + (QUrl('https://example.com?ref'), False, 'https://example.com'), + (QUrl('https://example.com?example'), False, 'https://example.com?example'), + (QUrl('mailto:email@example.com'), False, 'email@example.com'), + (QUrl('mailto:email@example.com?subject=Hello'), False, + 'email@example.com?subject=Hello'), + (QUrl('https://example.com/?pipe=%7C'), False, 'https://example.com/?pipe=%7C'), + (QUrl('https://example.com/?pipe=%7C'), True, 'https://example.com/?pipe=|'), +]) +def test_get_url_yank_text(url, pretty, expected): + assert urlutils.get_url_yank_text(url, pretty=pretty) == expected + + class TestWiden: @pytest.mark.parametrize('hostname, expected', [ diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index 77805c6dc..fc4a3e652 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -474,7 +474,7 @@ def test_get_repr(constructor, attrs, expected): assert utils.get_repr(Obj(), constructor, **attrs) == expected -class QualnameObj(): +class QualnameObj: """Test object for test_qualname.""" @@ -533,7 +533,7 @@ class TestIsEnum: assert not utils.is_enum(23) -class SentinalException(Exception): +class SentinelException(Exception): pass @@ -543,7 +543,7 @@ class TestRaises: def do_raise(self): """Helper function which raises an exception.""" - raise SentinalException + raise SentinelException def do_nothing(self): """Helper function which does nothing.""" @@ -562,15 +562,15 @@ class TestRaises: def test_no_args_true(self): """Test with no args and an exception which gets raised.""" - assert utils.raises(SentinalException, self.do_raise) + assert utils.raises(SentinelException, self.do_raise) def test_no_args_false(self): """Test with no args and an exception which does not get raised.""" - assert not utils.raises(SentinalException, self.do_nothing) + assert not utils.raises(SentinelException, self.do_nothing) def test_unrelated_exception(self): """Test with an unrelated exception.""" - with pytest.raises(SentinalException): + with pytest.raises(SentinelException): utils.raises(ValueError, self.do_raise) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index f24bf2a7a..91d737dd2 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -623,7 +623,7 @@ def test_path_info(monkeypatch, equal): @pytest.fixture def import_fake(stubs, monkeypatch): """Fixture to patch imports using ImportFake.""" - fake = stubs.ImportFake({mod: True for mod in version.MODULE_INFO}, monkeypatch) + fake = stubs.ImportFake(dict.fromkeys(version.MODULE_INFO, True), monkeypatch) fake.patch() return fake @@ -737,7 +737,6 @@ class TestModuleVersions: ('yaml', True), ('adblock', True), ('dataclasses', False), - ('importlib_resources', False), ('objc', True), ]) def test_existing_attributes(self, name, has_version): @@ -899,21 +898,32 @@ class TestWebEngineVersions: webengine=utils.VersionNumber(5, 15, 2), chromium=None, source='UA'), - "QtWebEngine 5.15.2", + ( + "QtWebEngine 5.15.2\n" + " (source: UA)" + ), ), ( version.WebEngineVersions( webengine=utils.VersionNumber(5, 15, 2), chromium='87.0.4280.144', source='UA'), - "QtWebEngine 5.15.2, based on Chromium 87.0.4280.144", + ( + "QtWebEngine 5.15.2\n" + " based on Chromium 87.0.4280.144\n" + " (source: UA)" + ), ), ( version.WebEngineVersions( webengine=utils.VersionNumber(5, 15, 2), chromium='87.0.4280.144', source='faked'), - "QtWebEngine 5.15.2, based on Chromium 87.0.4280.144 (from faked)", + ( + "QtWebEngine 5.15.2\n" + " based on Chromium 87.0.4280.144\n" + " (source: faked)" + ), ), ( version.WebEngineVersions( @@ -922,8 +932,10 @@ class TestWebEngineVersions: chromium_security='9000.1', source='faked'), ( - "QtWebEngine 5.15.2, based on Chromium 87.0.4280.144, with security " - "patches up to 9000.1 (plus any distribution patches) (from faked)" + "QtWebEngine 5.15.2\n" + " based on Chromium 87.0.4280.144\n" + " with security patches up to 9000.1 (plus any distribution patches)\n" + " (source: faked)" ), ), ]) @@ -961,6 +973,7 @@ class TestWebEngineVersions: expected = version.WebEngineVersions( webengine=utils.VersionNumber(5, 15, 2), chromium='83.0.4103.122', + chromium_security='86.0.4240.183', source='UA', ) assert version.WebEngineVersions.from_ua(ua) == expected @@ -970,21 +983,27 @@ class TestWebEngineVersions: expected = version.WebEngineVersions( webengine=utils.VersionNumber(5, 15, 2), chromium='83.0.4103.122', + chromium_security='86.0.4240.183', source='ELF', ) assert version.WebEngineVersions.from_elf(elf_version) == expected - @pytest.mark.parametrize('pyqt_version, chromium_version', [ - ('5.15.2', '83.0.4103.122'), - ('5.15.3', '87.0.4280.144'), - ('5.15.4', '87.0.4280.144'), - ('5.15.5', '87.0.4280.144'), - ('6.2.0', '90.0.4430.228'), - ('6.3.0', '94.0.4606.126'), + @pytest.mark.parametrize('pyqt_version, chromium_version, security_version', [ + ('5.15.2', '83.0.4103.122', '86.0.4240.183'), + ('5.15.3', '87.0.4280.144', '88.0.4324.150'), + ('5.15.4', '87.0.4280.144', None), + ('5.15.5', '87.0.4280.144', None), + ('5.15.6', '87.0.4280.144', None), + ('5.15.7', '87.0.4280.144', '94.0.4606.61'), + ('6.2.0', '90.0.4430.228', '93.0.4577.63'), + ('6.2.99', '90.0.4430.228', None), + ('6.3.0', '94.0.4606.126', '99.0.4844.84'), + ('6.99.0', None, None), ]) - def test_from_pyqt(self, freezer, pyqt_version, chromium_version): - if freezer and pyqt_version in ['5.15.3', '5.15.4', '5.15.5']: + def test_from_pyqt(self, freezer, pyqt_version, chromium_version, security_version): + if freezer and utils.VersionNumber(5, 15, 3) <= utils.VersionNumber.parse(pyqt_version) < utils.VersionNumber(6): chromium_version = '83.0.4103.122' + security_version = '86.0.4240.183' expected_pyqt_version = '5.15.2' else: expected_pyqt_version = pyqt_version @@ -992,6 +1011,7 @@ class TestWebEngineVersions: expected = version.WebEngineVersions( webengine=utils.VersionNumber.parse(expected_pyqt_version), chromium=chromium_version, + chromium_security=security_version, source='PyQt', ) assert version.WebEngineVersions.from_pyqt(pyqt_version) == expected @@ -1049,6 +1069,26 @@ class TestWebEngineVersions: security = utils.VersionNumber.parse(qWebEngineChromiumSecurityPatchVersion()) assert security >= base + def test_chromium_security_version_dict(self, qapp): + """Check if we infer the QtWebEngine security version properly. + + Note this test mostly tests that our overview in version.py (also + intended for human readers) is accurate. The code we call here is never + going to be called in real-life situations, as the API is available. + """ + try: + from qutebrowser.qt.webenginecore import ( + qWebEngineVersion, + qWebEngineChromiumSecurityPatchVersion, + ) + except ImportError: + pytest.skip("Requires QtWebEngine 6.3+") + + print(version.qtwebengine_versions()) # useful when adding new versions + inferred = version.WebEngineVersions.from_webengine( + qWebEngineVersion(), source="API") + assert inferred.chromium_security == qWebEngineChromiumSecurityPatchVersion() + class FakeQSslSocket: @@ -1319,7 +1359,7 @@ def test_version_info(params, stubs, monkeypatch, config_stub): else: monkeypatch.delattr(version, 'qtutils.qWebKitVersion', raising=False) patches['objects.backend'] = usertypes.Backend.QtWebEngine - substitutions['backend'] = 'QtWebEngine 1.2.3 (from faked)' + substitutions['backend'] = 'QtWebEngine 1.2.3\n (source: faked)' if params.known_distribution: patches['distribution'] = lambda: version.DistributionInfo( diff --git a/tests/unit/utils/usertypes/test_timer.py b/tests/unit/utils/usertypes/test_timer.py index c02f160b6..6aabc8c04 100644 --- a/tests/unit/utils/usertypes/test_timer.py +++ b/tests/unit/utils/usertypes/test_timer.py @@ -4,6 +4,9 @@ """Tests for Timer.""" +import logging +import fnmatch + import pytest from qutebrowser.qt.core import QObject @@ -65,3 +68,63 @@ def test_timeout_set_interval(qtbot): with qtbot.wait_signal(t.timeout, timeout=3000): t.setInterval(200) t.start() + + +@pytest.mark.parametrize( + "elapsed_ms, expected", + [ + (0, False), + (1, False), + (600, True), + (999, True), + (1000, True), + ], +) +def test_early_timeout_check(qtbot, mocker, elapsed_ms, expected): + time_mock = mocker.patch("time.monotonic", autospec=True) + + t = usertypes.Timer() + t.setInterval(1000) # anything long enough to not actually fire + time_mock.return_value = 0 # assigned to _start_time in start() + t.start() + time_mock.return_value = elapsed_ms / 1000 # used for `elapsed` + + assert t.check_timeout_validity() is expected + + t.stop() + + +def test_early_timeout_handler(qtbot, mocker, caplog): + time_mock = mocker.patch("time.monotonic", autospec=True) + + t = usertypes.Timer(name="t") + t.setInterval(3) + t.setSingleShot(True) + time_mock.return_value = 0 + with caplog.at_level(logging.WARNING): + with qtbot.wait_signal(t.timeout, timeout=10): + t.start() + time_mock.return_value = 1 / 1000 + + assert len(caplog.messages) == 1 + assert fnmatch.fnmatch( + caplog.messages[-1], + "Timer t (id *) triggered too early: interval 3 but only 0.001s passed", + ) + + +def test_early_manual_fire(qtbot, mocker, caplog): + """Same as above but start() never gets called.""" + time_mock = mocker.patch("time.monotonic", autospec=True) + + t = usertypes.Timer(name="t") + t.setInterval(3) + t.setSingleShot(True) + time_mock.return_value = 0 + with caplog.at_level(logging.WARNING): + with qtbot.wait_signal(t.timeout, timeout=10): + t.timeout.emit() + time_mock.return_value = 1 / 1000 + + assert len(caplog.messages) == 0 + assert t.check_timeout_validity() diff --git a/tox.ini b/tox.ini index 31e06e396..92f61e55b 100644 --- a/tox.ini +++ b/tox.ini @@ -4,10 +4,11 @@ # and then run "tox" from this directory. [tox] -envlist = py38-pyqt515-cov,mypy-pyqt5,misc,vulture,flake8,pylint,pyroma,check-manifest,eslint,yamllint,actionlint +envlist = py39-pyqt515-cov,mypy-pyqt5,misc,vulture,flake8,pylint,pyroma,check-manifest,eslint,yamllint,actionlint distshare = {toxworkdir} skipsdist = true minversion = 3.20 +toxworkdir={env:TOX_WORK_DIR:{toxinidir}/.tox} [testenv] setenv = @@ -30,14 +31,16 @@ passenv = QT_QUICK_BACKEND FORCE_COLOR DBUS_SESSION_BUS_ADDRESS + RUNNER_TEMP + HYPOTHESIS_EXAMPLES_DIR basepython = py: {env:PYTHON:python3} py3: {env:PYTHON:python3} - py38: {env:PYTHON:python3.8} py39: {env:PYTHON:python3.9} py310: {env:PYTHON:python3.10} py311: {env:PYTHON:python3.11} py312: {env:PYTHON:python3.12} + py313: {env:PYTHON:python3.13} deps = -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-tests.txt @@ -50,8 +53,10 @@ deps = pyqt64: -r{toxinidir}/misc/requirements/requirements-pyqt-6.4.txt pyqt65: -r{toxinidir}/misc/requirements/requirements-pyqt-6.5.txt pyqt66: -r{toxinidir}/misc/requirements/requirements-pyqt-6.6.txt + pyqt67: -r{toxinidir}/misc/requirements/requirements-pyqt-6.7.txt + pyqt68: -r{toxinidir}/misc/requirements/requirements-pyqt-6.8.txt commands = - !pyqt-!pyqt515-!pyqt5152-!pyqt62-!pyqt63-!pyqt64-!pyqt65-!pyqt66: {envpython} scripts/link_pyqt.py --tox {envdir} + !pyqt-!pyqt515-!pyqt5152-!pyqt62-!pyqt63-!pyqt64-!pyqt65-!pyqt66-!pyqt67-!pyqt68: {envpython} scripts/link_pyqt.py --tox {envdir} {envpython} -bb -m pytest {posargs:tests} cov: {envpython} scripts/dev/check_coverage.py {posargs}