diff options
author | Lembrun <amadeusk7@free.fr> | 2021-03-19 13:56:17 +0100 |
---|---|---|
committer | Lembrun <amadeusk7@free.fr> | 2021-03-19 13:56:17 +0100 |
commit | 6d3edf743e99d3daf5ea9e2280c680053f7af94d (patch) | |
tree | d61b66f60dd37d9b05d2b4153c0ee6b09a08a634 | |
parent | f0c13a7abef0f712dd135c9217df39d09f2803d8 (diff) | |
parent | 7207c88a56db4240bb2408dcb46a9f641f08408e (diff) | |
download | qutebrowser-6d3edf743e99d3daf5ea9e2280c680053f7af94d.tar.gz qutebrowser-6d3edf743e99d3daf5ea9e2280c680053f7af94d.zip |
Merge branch 'master' into pathlib-/tests/conftest.py
38 files changed, 642 insertions, 417 deletions
diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 2d399ad6d..9ada74f7e 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -15,10 +15,40 @@ breaking changes (such as renamed commands) can happen in minor releases. // `Fixed` for any bug fixes. // `Security` to invite users to upgrade in case of vulnerabilities. +[[v2.2.0]] +v2.2.0 (unreleased) +------------------- + +Deprecated +~~~~~~~~~~ + +- Running qutebrowser with Qt 5.12.0 is now unsupported and logs a warning. It + should still work, however, a workaround for issues with the Nvidia graphic + driver was dropped. Newer Qt 5.12.x versions are still fully supported. + +Added +~~~~~ + +- New `input.media_keys` setting which can be used to disable Chromium's + handling of media keys. + +Changed +~~~~~~~ + +- The completion now also shows bindings starting with `set-cmd-text` in its + third column, such as `o` for `:open`. + [[v2.1.1]] v2.1.1 (unreleased) ------------------- +Added +~~~~~ + +- Site-specific quirk for krunker.io, which shows a "Socket Error" with + qutebrowser's default Accept-Language header. The workaround is equivalent to + doing `:set -u matchmaker.krunker.io content.headers.accept_language ""`. + Changed ~~~~~~~ @@ -30,6 +60,14 @@ Fixed - The workaround for black on (almost) black formula images in dark mode now also works with Qt 5.12 and 5.13. +- When running in Flatpak, the QtWebEngine version is now detected properly. + Before, a wrong version was assumed, breaking dark mode and certain workarounds + (resulting in crashes on websites like LinkedIn or TradingView). +- When running in Flatpak, communicating with an existing instance now works + properly. Before, a new instance was always opened. +- When the metainfo in the completion database doesn't have the expected + structure, qutebrowser now tries to gracefully recover from the situation + instead of crashing. [[v2.1.0]] v2.1.0 (2021-03-12) diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 0ecd7d753..8b2964f4f 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -261,6 +261,7 @@ |<<input.insert_mode.leave_on_load,input.insert_mode.leave_on_load>>|Leave insert mode when starting a new page load. |<<input.insert_mode.plugins,input.insert_mode.plugins>>|Switch to insert mode when clicking flash and other plugins. |<<input.links_included_in_focus_chain,input.links_included_in_focus_chain>>|Include hyperlinks in the keyboard focus chain when tabbing. +|<<input.media_keys,input.media_keys>>|Whether the underlying Chromium should handle media keys. |<<input.mouse.back_forward_buttons,input.mouse.back_forward_buttons>>|Enable back and forward buttons on the mouse. |<<input.mouse.rocker_gestures,input.mouse.rocker_gestures>>|Enable Opera-like mouse rocker gestures. |<<input.partial_timeout,input.partial_timeout>>|Timeout (in milliseconds) for partially typed key bindings. @@ -3392,6 +3393,21 @@ Type: <<types,Bool>> Default: +pass:[true]+ +[[input.media_keys]] +=== input.media_keys +Whether the underlying Chromium should handle media keys. +On Linux, disabling this also disables Chromium's MPRIS integration. + +This setting requires a restart. + +On QtWebEngine, this setting requires Qt 5.14 or newer. + +On QtWebKit, this setting is unavailable. + +Type: <<types,Bool>> + +Default: +pass:[true]+ + [[input.mouse.back_forward_buttons]] === input.mouse.back_forward_buttons Enable back and forward buttons on the mouse. diff --git a/misc/requirements/requirements-tests-bleeding.txt b/misc/requirements/requirements-tests-bleeding.txt new file mode 100644 index 000000000..cbefb99f3 --- /dev/null +++ b/misc/requirements/requirements-tests-bleeding.txt @@ -0,0 +1,37 @@ +# Problematic: needs bzr +# bzr+lp:beautifulsoup +beautifulsoup4 +git+https://github.com/cherrypy/cheroot.git +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 +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 +git+https://github.com/pytest-dev/pytest-qt.git +git+https://github.com/pytest-dev/pytest-rerunfailures.git + +git+https://github.com/ionelmc/python-hunter.git +git+https://github.com/jendrikseipp/vulture.git +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/pytest-dev/pytest-xdist.git +git+https://github.com/hjwp/pytest-icdiff.git +git+https://github.com/john-kurkowski/tldextract +# Problematic: needs rust (and some time to build) +# git+https://github.com/ArniDagur/python-adblock.git +adblock ; python_version!="3.10" + +## qutebrowser dependencies + +git+https://github.com/pallets/jinja.git +git+https://github.com/yaml/pyyaml.git +git+https://github.com/tartley/colorama.git + +# https://github.com/pyparsing/pyparsing/issues/271 +pyparsing!=3.0.0b2,!=3.0.0b1 diff --git a/misc/requirements/requirements-tests-git.txt b/misc/requirements/requirements-tests-git.txt deleted file mode 100644 index 6fc4bb460..000000000 --- a/misc/requirements/requirements-tests-git.txt +++ /dev/null @@ -1,34 +0,0 @@ -bzr+lp:beautifulsoup -git+https://github.com/cherrypy/cheroot.git -hg+https://bitbucket.org/ned/coveragepy -git+https://github.com/micheles/decorator.git -git+https://github.com/pallets/flask.git -git+https://github.com/miracle2k/python-glob2.git -git+https://github.com/HypothesisWorks/hypothesis-python.git -git+https://github.com/pallets/itsdangerous.git -git+https://bitbucket.org/zzzeek/mako.git -git+https://github.com/r1chardj0n3s/parse.git -git+https://github.com/jenisys/parse_type.git -hg+https://bitbucket.org/pytest-dev/py -git+https://github.com/pytest-dev/pytest.git@features -git+https://github.com/pytest-dev/pytest-bdd.git -git+https://github.com/pytest-dev/pytest-cov.git -git+https://github.com/pytest-dev/pytest-instafail.git -git+https://github.com/pytest-dev/pytest-mock.git -git+https://github.com/pytest-dev/pytest-qt.git -git+https://github.com/pytest-dev/pytest-repeat.git -git+https://github.com/pytest-dev/pytest-rerunfailures.git -git+https://github.com/The-Compiler/pytest-xvfb.git -hg+https://bitbucket.org/gutworth/six -hg+https://bitbucket.org/jendrikseipp/vulture -git+https://github.com/pallets/werkzeug.git - - -## qutebrowser dependencies - -git+https://github.com/tartley/colorama.git -git+https://github.com/pallets/jinja.git -git+https://github.com/pallets/markupsafe.git -git+https://github.com/pygments/pygments.git -git+https://github.com/python-attrs/attrs.git -git+https://github.com/yaml/pyyaml.git diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index ef4650a35..773c6cc51 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -92,8 +92,11 @@ class CompletionMetaInfo(sql.SqlTable): } def __init__(self, parent=None): - super().__init__("CompletionMetaInfo", ['key', 'value'], - constraints={'key': 'PRIMARY KEY'}) + self._fields = ['key', 'value'] + self._constraints = {'key': 'PRIMARY KEY'} + super().__init__( + "CompletionMetaInfo", self._fields, constraints=self._constraints) + if sql.user_version_changed(): self._init_default_values() @@ -101,6 +104,15 @@ class CompletionMetaInfo(sql.SqlTable): if key not in self.KEYS: raise KeyError(key) + def try_recover(self): + """Try recovering the table structure. + + This should be called if getting a value via __getattr__ failed. In theory, this + should never happen, in practice, it does. + """ + self._create_table(self._fields, constraints=self._constraints, force=True) + self._init_default_values() + def _init_default_values(self): for key, default in self.KEYS.items(): if key not in self: @@ -164,7 +176,13 @@ class WebHistory(sql.SqlTable): self.completion = CompletionHistory(parent=self) self.metainfo = CompletionMetaInfo(parent=self) - rebuild_completion = self.metainfo['force_rebuild'] + try: + rebuild_completion = self.metainfo['force_rebuild'] + except sql.BugError: # pragma: no cover + log.sql.warning("Failed to access meta info, trying to recover...", + exc_info=True) + self.metainfo.try_recover() + rebuild_completion = self.metainfo['force_rebuild'] if sql.user_version_changed(): # If the DB user version changed, run a full cleanup and rebuild the diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 830c818fc..23d8812c3 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -38,7 +38,7 @@ from qutebrowser.browser.webengine import (spell, webenginequtescheme, cookies, from qutebrowser.config import config, websettings from qutebrowser.config.websettings import AttributeInfo as Attr from qutebrowser.utils import (standarddir, qtutils, message, log, - urlmatch, usertypes, objreg) + urlmatch, usertypes, objreg, version) if TYPE_CHECKING: from qutebrowser.browser.webengine import interceptor @@ -374,7 +374,17 @@ def _init_default_profile(): default_profile = QWebEngineProfile.defaultProfile() + assert parsed_user_agent is None # avoid earlier profile initialization + non_ua_version = version.qtwebengine_versions(avoid_init=True) + init_user_agent() + ua_version = version.qtwebengine_versions() + if ua_version.webengine != non_ua_version.webengine: + log.init.warning( + "QtWebEngine version mismatch - unexpected behavior might occur, " + "please open a bug about this.\n" + f" Early version: {non_ua_version}\n" + f" Real version: {ua_version}") default_profile.setCachePath( os.path.join(standarddir.cache(), 'webengine')) @@ -448,6 +458,13 @@ def _init_site_specific_quirks(): pattern=urlmatch.UrlPattern(pattern), hide_userconfig=True) + config.instance.set_obj( + 'content.headers.accept_language', + '', + pattern=urlmatch.UrlPattern('https://matchmaker.krunker.io/*'), + hide_userconfig=True, + ) + def _init_devtools_settings(): """Make sure the devtools always get images/JS permissions.""" diff --git a/qutebrowser/commands/parser.py b/qutebrowser/commands/parser.py new file mode 100644 index 000000000..06a20cdf6 --- /dev/null +++ b/qutebrowser/commands/parser.py @@ -0,0 +1,209 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2021 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <https://www.gnu.org/licenses/>. + +"""Module for parsing commands entered into the browser.""" + +import dataclasses +from typing import List, Iterator + +from qutebrowser.commands import cmdexc, command +from qutebrowser.misc import split, objects +from qutebrowser.config import config + + +@dataclasses.dataclass +class ParseResult: + + """The result of parsing a commandline.""" + + cmd: command.Command + args: List[str] + cmdline: List[str] + + +class CommandParser: + + """Parse qutebrowser commandline commands. + + Attributes: + _partial_match: Whether to allow partial command matches. + """ + + def __init__(self, partial_match: bool = False) -> None: + self._partial_match = partial_match + + def _get_alias(self, text: str, *, default: str) -> str: + """Get an alias from the config. + + Args: + text: The text to parse. + aliases: A map of aliases to commands. + default : Default value to return when alias was not found. + + Return: + The new command string if an alias was found. Default value + otherwise. + """ + parts = text.strip().split(maxsplit=1) + aliases = config.cache['aliases'] + if parts[0] not in aliases: + return default + alias = aliases[parts[0]] + + try: + new_cmd = '{} {}'.format(alias, parts[1]) + except IndexError: + new_cmd = alias + if text.endswith(' '): + new_cmd += ' ' + return new_cmd + + def _parse_all_gen( + self, + text: str, + aliases: bool = True, + **kwargs: bool, + ) -> Iterator[ParseResult]: + """Split a command on ;; and parse all parts. + + If the first command in the commandline is a non-split one, it only + returns that. + + Args: + text: Text to parse. + aliases: Whether to handle aliases. + **kwargs: Passed to parse(). + + Yields: + ParseResult tuples. + """ + text = text.strip().lstrip(':').strip() + if not text: + raise cmdexc.NoSuchCommandError("No command given") + + if aliases: + text = self._get_alias(text, default=text) + + if ';;' in text: + # Get the first command and check if it doesn't want to have ;; + # split. + first = text.split(';;')[0] + result = self.parse(first, **kwargs) + if result.cmd.no_cmd_split: + sub_texts = [text] + else: + sub_texts = [e.strip() for e in text.split(';;')] + else: + sub_texts = [text] + for sub in sub_texts: + yield self.parse(sub, **kwargs) + + def parse_all(self, text: str, **kwargs: bool) -> List[ParseResult]: + """Wrapper over _parse_all_gen.""" + return list(self._parse_all_gen(text, **kwargs)) + + def parse(self, text: str, *, keep: bool = False) -> ParseResult: + """Split the commandline text into command and arguments. + + Args: + text: Text to parse. + keep: Whether to keep special chars and whitespace. + """ + cmdstr, sep, argstr = text.partition(' ') + + if not cmdstr: + raise cmdexc.NoSuchCommandError("No command given") + + if self._partial_match: + cmdstr = self._completion_match(cmdstr) + + try: + cmd = objects.commands[cmdstr] + except KeyError: + raise cmdexc.NoSuchCommandError(f'{cmdstr}: no such command') + + args = self._split_args(cmd, argstr, keep) + if keep and args: + cmdline = [cmdstr, sep + args[0]] + args[1:] + elif keep: + cmdline = [cmdstr, sep] + else: + cmdline = [cmdstr] + args[:] + + return ParseResult(cmd=cmd, args=args, cmdline=cmdline) + + def _completion_match(self, cmdstr: str) -> str: + """Replace cmdstr with a matching completion if there's only one match. + + Args: + cmdstr: The string representing the entered command so far. + + Return: + cmdstr modified to the matching completion or unmodified + """ + matches = [cmd for cmd in sorted(objects.commands, key=len) + if cmdstr in cmd] + if len(matches) == 1: + cmdstr = matches[0] + elif len(matches) > 1 and config.val.completion.use_best_match: + cmdstr = matches[0] + return cmdstr + + def _split_args(self, cmd: command.Command, argstr: str, keep: bool) -> List[str]: + """Split the arguments from an arg string. + + Args: + cmd: The command we're currently handling. + argstr: An argument string. + keep: Whether to keep special chars and whitespace + + Return: + A list containing the split strings. + """ + if not argstr: + return [] + elif cmd.maxsplit is None: + return split.split(argstr, keep=keep) + else: + # If split=False, we still want to split the flags, but not + # everything after that. + # We first split the arg string and check the index of the first + # non-flag args, then we re-split again properly. + # example: + # + # input: "--foo -v bar baz" + # first split: ['--foo', '-v', 'bar', 'baz'] + # 0 1 2 3 + # second split: ['--foo', '-v', 'bar baz'] + # (maxsplit=2) + split_args = split.simple_split(argstr, keep=keep) + flag_arg_count = 0 + for i, arg in enumerate(split_args): + arg = arg.strip() + if arg.startswith('-'): + if arg in cmd.flags_with_args: + flag_arg_count += 1 + else: + maxsplit = i + cmd.maxsplit + flag_arg_count + return split.simple_split(argstr, keep=keep, + maxsplit=maxsplit) + + # If there are only flags, we got it right on the first try + # already. + return split_args diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 4d53295dd..5fb054455 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -22,17 +22,13 @@ import traceback import re import contextlib -import dataclasses -from typing import (TYPE_CHECKING, Callable, Dict, Iterator, Mapping, MutableMapping, - List, Optional) +from typing import TYPE_CHECKING, Callable, Dict, Iterator, Mapping, MutableMapping from PyQt5.QtCore import pyqtSlot, QUrl, QObject from qutebrowser.api import cmdutils -from qutebrowser.config import config -from qutebrowser.commands import cmdexc, command +from qutebrowser.commands import cmdexc, parser from qutebrowser.utils import message, objreg, qtutils, usertypes, utils -from qutebrowser.misc import split, objects from qutebrowser.keyinput import macros, modeman if TYPE_CHECKING: @@ -43,16 +39,6 @@ _ReplacementFunction = Callable[['tabbedbrowser.TabbedBrowser'], str] last_command = {} -@dataclasses.dataclass -class ParseResult: - - """The result of parsing a commandline.""" - - cmd: Optional[command.Command] - args: Optional[List[str]] - cmdline: List[str] - - def _url(tabbed_browser): """Convenience method to get the current url.""" try: @@ -130,181 +116,6 @@ def replace_variables(win_id, arglist): return args -class CommandParser: - - """Parse qutebrowser commandline commands. - - Attributes: - _partial_match: Whether to allow partial command matches. - """ - - def __init__(self, partial_match=False): - self._partial_match = partial_match - - def _get_alias(self, text, default=None): - """Get an alias from the config. - - Args: - text: The text to parse. - default : Default value to return when alias was not found. - - Return: - The new command string if an alias was found. Default value - otherwise. - """ - parts = text.strip().split(maxsplit=1) - aliases = config.cache['aliases'] - if parts[0] not in aliases: - return default - alias = aliases[parts[0]] - - try: - new_cmd = '{} {}'.format(alias, parts[1]) - except IndexError: - new_cmd = alias - if text.endswith(' '): - new_cmd += ' ' - return new_cmd - - def _parse_all_gen(self, text, *args, aliases=True, **kwargs): - """Split a command on ;; and parse all parts. - - If the first command in the commandline is a non-split one, it only - returns that. - - Args: - text: Text to parse. - aliases: Whether to handle aliases. - *args/**kwargs: Passed to parse(). - - Yields: - ParseResult tuples. - """ - text = text.strip().lstrip(':').strip() - if not text: - raise cmdexc.NoSuchCommandError("No command given") - - if aliases: - text = self._get_alias(text, text) - - if ';;' in text: - # Get the first command and check if it doesn't want to have ;; - # split. - first = text.split(';;')[0] - result = self.parse(first, *args, **kwargs) - if result.cmd.no_cmd_split: - sub_texts = [text] - else: - sub_texts = [e.strip() for e in text.split(';;')] - else: - sub_texts = [text] - for sub in sub_texts: - yield self.parse(sub, *args, **kwargs) - - def parse_all(self, *args, **kwargs): - """Wrapper over _parse_all_gen.""" - return list(self._parse_all_gen(*args, **kwargs)) - - def parse(self, text, *, fallback=False, keep=False): - """Split the commandline text into command and arguments. - - Args: - text: Text to parse. - fallback: Whether to do a fallback splitting when the command was - unknown. - keep: Whether to keep special chars and whitespace - - Return: - A ParseResult tuple. - """ - cmdstr, sep, argstr = text.partition(' ') - - if not cmdstr and not fallback: - raise cmdexc.NoSuchCommandError("No command given") - - if self._partial_match: - cmdstr = self._completion_match(cmdstr) - - try: - cmd = objects.commands[cmdstr] - except KeyError: - if not fallback: - raise cmdexc.NoSuchCommandError( - '{}: no such command'.format(cmdstr)) - cmdline = split.split(text, keep=keep) - return ParseResult(cmd=None, args=None, cmdline=cmdline) - - args = self._split_args(cmd, argstr, keep) - if keep and args: - cmdline = [cmdstr, sep + args[0]] + args[1:] - elif keep: - cmdline = [cmdstr, sep] - else: - cmdline = [cmdstr] + args[:] - - return ParseResult(cmd=cmd, args=args, cmdline=cmdline) - - def _completion_match(self, cmdstr): - """Replace cmdstr with a matching completion if there's only one match. - - Args: - cmdstr: The string representing the entered command so far - - Return: - cmdstr modified to the matching completion or unmodified - """ - matches = [cmd for cmd in sorted(objects.commands, key=len) - if cmdstr in cmd] - if len(matches) == 1: - cmdstr = matches[0] - elif len(matches) > 1 and config.val.completion.use_best_match: - cmdstr = matches[0] - return cmdstr - - def _split_args(self, cmd, argstr, keep): - """Split the arguments from an arg string. - - Args: - cmd: The command we're currently handling. - argstr: An argument string. - keep: Whether to keep special chars and whitespace - - Return: - A list containing the split strings. - """ - if not argstr: - return [] - elif cmd.maxsplit is None: - return split.split(argstr, keep=keep) - else: - # If split=False, we still want to split the flags, but not - # everything after that. - # We first split the arg string and check the index of the first - # non-flag args, then we re-split again properly. - # example: - # - # input: "--foo -v bar baz" - # first split: ['--foo', '-v', 'bar', 'baz'] - # 0 1 2 3 - # second split: ['--foo', '-v', 'bar baz'] - # (maxsplit=2) - split_args = split.simple_split(argstr, keep=keep) - flag_arg_count = 0 - for i, arg in enumerate(split_args): - arg = arg.strip() - if arg.startswith('-'): - if arg in cmd.flags_with_args: - flag_arg_count += 1 - else: - maxsplit = i + cmd.maxsplit + flag_arg_count - return split.simple_split(argstr, keep=keep, - maxsplit=maxsplit) - - # If there are only flags, we got it right on the first try - # already. - return split_args - - class AbstractCommandRunner(QObject): """Abstract base class for CommandRunner.""" @@ -329,7 +140,7 @@ class CommandRunner(AbstractCommandRunner): def __init__(self, win_id, partial_match=False, parent=None): super().__init__(parent) - self._parser = CommandParser(partial_match=partial_match) + self._parser = parser.CommandParser(partial_match=partial_match) self._win_id = win_id @contextlib.contextmanager @@ -362,7 +173,7 @@ class CommandRunner(AbstractCommandRunner): parsed = self._parser.parse_all(text) if parsed is None: - return + return # type: ignore[unreachable] for result in parsed: with self._handle_error(safely): diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 52d4dc5f2..778333854 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -25,8 +25,8 @@ from typing import TYPE_CHECKING from PyQt5.QtCore import pyqtSlot, QObject, QTimer from qutebrowser.config import config -from qutebrowser.commands import runners -from qutebrowser.misc import objects +from qutebrowser.commands import parser, cmdexc +from qutebrowser.misc import objects, split from qutebrowser.utils import log, utils, debug, objreg from qutebrowser.completion.models import miscmodels if TYPE_CHECKING: @@ -139,13 +139,18 @@ class Completer(QObject): if not text or not text.strip(): # Only ":", empty part under the cursor with nothing before/after return [], '', [] - parser = runners.CommandParser() - result = parser.parse(text, fallback=True, keep=True) - parts = [x for x in result.cmdline if x] + + try: + parse_result = parser.CommandParser().parse(text, keep=True) + except cmdexc.NoSuchCommandError: + cmdline = split.split(text, keep=True) + else: + cmdline = parse_result.cmdline + + parts = [x for x in cmdline if x] pos = self._cmd.cursorPosition() - len(self._cmd.prefix()) pos = min(pos, len(text)) # Qt treats 2-byte UTF-16 chars as 2 chars - log.completion.debug('partitioning {} around position {}'.format(parts, - pos)) + log.completion.debug(f'partitioning {parts} around position {pos}') for i, part in enumerate(parts): pos -= len(part) if pos <= 0: @@ -156,11 +161,10 @@ class Completer(QObject): center = parts[i].strip() # strip trailing whitespace included as a separate token postfix = [x.strip() for x in parts[i+1:] if not x.isspace()] - log.completion.debug( - "partitioned: {} '{}' {}".format(prefix, center, postfix)) + log.completion.debug(f"partitioned: {prefix} '{center}' {postfix}") return prefix, center, postfix - raise utils.Unreachable("Not all parts consumed: {}".format(parts)) + raise utils.Unreachable(f"Not all parts consumed: {parts}") @pyqtSlot(str) def on_selection_changed(self, text): diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index a942b868a..736d09644 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -21,7 +21,7 @@ from qutebrowser.config import configdata, configexc from qutebrowser.completion.models import completionmodel, listcategory, util -from qutebrowser.commands import runners, cmdexc +from qutebrowser.commands import parser, cmdexc from qutebrowser.keyinput import keyutils @@ -117,9 +117,8 @@ def _bind_current_default(key, info): cmd_text = info.keyconf.get_command(seq, 'normal') if cmd_text: - parser = runners.CommandParser() try: - cmd = parser.parse(cmd_text).cmd + cmd = parser.CommandParser().parse(cmd_text).cmd except cmdexc.NoSuchCommandError: data.append((cmd_text, '(Current) Invalid command!', key)) else: @@ -127,8 +126,7 @@ def _bind_current_default(key, info): cmd_text = info.keyconf.get_command(seq, 'normal', default=True) if cmd_text: - parser = runners.CommandParser() - cmd = parser.parse(cmd_text).cmd + cmd = parser.CommandParser().parse(cmd_text).cmd data.append((cmd_text, '(Default) {}'.format(cmd.desc), key)) return data diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index c644725b5..07d16ea92 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -27,6 +27,7 @@ from typing import (TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Mapping, from PyQt5.QtCore import pyqtSignal, QObject, QUrl +from qutebrowser.commands import cmdexc, parser from qutebrowser.config import configdata, configexc, configutils from qutebrowser.utils import utils, log, urlmatch from qutebrowser.misc import objects @@ -162,13 +163,38 @@ class KeyConfig: bindings[key] = binding return bindings + def _implied_cmd(self, cmdline: str) -> Optional[str]: + """Return cmdline, or the implied cmd if cmdline is a set-cmd-text.""" + try: + results = parser.CommandParser().parse_all(cmdline) + except cmdexc.NoSuchCommandError: + return None + + result = results[0] + if result.cmd.name != "set-cmd-text": + return cmdline + *flags, cmd = result.args + if "-a" in flags or "--append" in flags or not cmd.startswith(":"): + return None # doesn't look like this sets a command + return cmd.lstrip(":") + def get_reverse_bindings_for(self, mode: str) -> '_ReverseBindings': - """Get a dict of commands to a list of bindings for the mode.""" + """Get a dict of commands to a list of bindings for the mode. + + This is intented for user-facing display of keybindings. + As such, bindings for 'set-cmd-text [flags] :<cmd> ...' are translated + to '<cmd> ...', as from the user's perspective these keys behave like + bindings for '<cmd>' (that allow for further input before running). + + See #5942. + """ cmd_to_keys: KeyConfig._ReverseBindings = {} bindings = self.get_bindings_for(mode) for seq, full_cmd in sorted(bindings.items()): - for cmd in full_cmd.split(';;'): - cmd = cmd.strip() + for cmdtext in full_cmd.split(';;'): + cmd = self._implied_cmd(cmdtext.strip()) + if not cmd: + continue cmd_to_keys.setdefault(cmd, []) # Put bindings involving modifiers last if any(info.modifiers for info in seq): diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 22a4b2151..45d8d1a7c 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -1613,6 +1613,18 @@ input.spatial_navigation: Right key, heuristics determine whether there is an element he might be trying to reach towards the right and which element he probably wants. +input.media_keys: + default: true + type: Bool + backend: + QtWebEngine: Qt 5.14 + QtWebKit: false + restart: true + desc: >- + Whether the underlying Chromium should handle media keys. + + On Linux, disabling this also disables Chromium's MPRIS integration. + ## keyhint keyhint.blacklist: diff --git a/qutebrowser/config/qtargs.py b/qutebrowser/config/qtargs.py index 407ccb37e..d9564556a 100644 --- a/qutebrowser/config/qtargs.py +++ b/qutebrowser/config/qtargs.py @@ -157,6 +157,9 @@ def _qtwebengine_features( # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-89740 disabled_features.append('InstalledApp') + if not config.val.input.media_keys: + disabled_features.append('HardwareMediaKeyHandling') + return (enabled_features, disabled_features) diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 1a8b171c2..bd5d4e801 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -86,6 +86,8 @@ class NormalKeyParser(CommandKeyParser): _partial_timer: Timer to clear partial keypresses. """ + _sequence: keyutils.KeySequence + def __init__(self, *, win_id: int, commandrunner: 'runners.CommandRunner', parent: QObject = None) -> None: @@ -154,6 +156,8 @@ class HintKeyParser(basekeyparser.BaseKeyParser): _last_press: The nature of the last keypress, a LastPress member. """ + _sequence: keyutils.KeySequence + def __init__(self, *, win_id: int, commandrunner: 'runners.CommandRunner', hintmanager: hints.HintManager, diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index 4751e1cea..001aa3047 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -194,14 +194,6 @@ class _BackendProblemChecker: sys.exit(usertypes.Exit.err_init) - def _nvidia_shader_workaround(self) -> None: - """Work around QOpenGLShaderProgram issues. - - See https://bugs.launchpad.net/ubuntu/+source/python-qt4/+bug/941826 - """ - self._assert_backend(usertypes.Backend.QtWebEngine) - utils.libgl_workaround() - def _xwayland_options(self) -> Tuple[str, List[_Button]]: """Get buttons/text for a possible XWayland solution.""" buttons = [] @@ -435,7 +427,6 @@ class _BackendProblemChecker: self._check_backend_modules() if objects.backend == usertypes.Backend.QtWebEngine: self._handle_ssl_support() - self._nvidia_shader_workaround() self._handle_wayland_webgl() self._handle_cache_nuking() self._handle_serviceworker_nuking() diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index 420f90f9a..ca8f9e8fe 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -185,6 +185,11 @@ def check_qt_version(): PYQT_VERSION_STR)) _die(text) + if qt_ver == QVersionNumber(5, 12, 0): + from qutebrowser.utils import log + log.init.warning("Running on Qt 5.12.0. Doing so is unsupported " + "(newer 5.12.x versions are fine).") + def check_ssl_support(): """Check if SSL support is available.""" @@ -274,6 +279,21 @@ def check_optimize_flag(): "unexpected behavior may occur.") +def webengine_early_import(): + """If QtWebEngine is available, import it early. + + We need to ensure that QtWebEngine is imported before a QApplication is created for + everything to work properly. + + This needs to be done even when using the QtWebKit backend, to ensure that e.g. + error messages in backendproblem.py are accurate. + """ + try: + from PyQt5 import QtWebEngineWidgets # pylint: disable=unused-import + except ImportError: + pass + + def early_init(args): """Do all needed early initialization. @@ -298,3 +318,4 @@ def early_init(args): configure_pyqt() check_ssl_support() check_optimize_flag() + webengine_early_import() diff --git a/qutebrowser/misc/elf.py b/qutebrowser/misc/elf.py index 1da4709af..18cb9634c 100644 --- a/qutebrowser/misc/elf.py +++ b/qutebrowser/misc/elf.py @@ -69,7 +69,7 @@ from typing import IO, ClassVar, Dict, Optional, Tuple, cast from PyQt5.QtCore import QLibraryInfo -from qutebrowser.utils import log +from qutebrowser.utils import log, version class ParseError(Exception): @@ -141,7 +141,7 @@ class Ident: @classmethod def parse(cls, fobj: IO[bytes]) -> 'Ident': """Parse an ELF ident header from a file.""" - magic, klass, data, version, osabi, abiversion = _unpack(cls._FORMAT, fobj) + magic, klass, data, elfversion, osabi, abiversion = _unpack(cls._FORMAT, fobj) try: bitness = Bitness(klass) @@ -153,7 +153,7 @@ class Ident: except ValueError: raise ParseError(f"Invalid endianness {data}") - return cls(magic, bitness, endianness, version, osabi, abiversion) + return cls(magic, bitness, endianness, elfversion, osabi, abiversion) @dataclasses.dataclass @@ -310,7 +310,11 @@ def _parse_from_file(f: IO[bytes]) -> Versions: def parse_webenginecore() -> Optional[Versions]: """Parse the QtWebEngineCore library file.""" - library_path = pathlib.Path(QLibraryInfo.location(QLibraryInfo.LibrariesPath)) + if version.is_flatpak(): + # Flatpak has Qt in /usr/lib/x86_64-linux-gnu, but QtWebEngine in /app/lib. + library_path = pathlib.Path("/app/lib") + else: + library_path = pathlib.Path(QLibraryInfo.location(QLibraryInfo.LibrariesPath)) # PyQt bundles those files with a .5 suffix lib_file = library_path / 'libQt5WebEngineCore.so.5' diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py index 7a3626f6e..68c0fd538 100644 --- a/qutebrowser/misc/sql.py +++ b/qutebrowser/misc/sql.py @@ -351,13 +351,13 @@ class SqlTable(QObject): self._name = name self._create_table(fields, constraints) - def _create_table(self, fields, constraints): + def _create_table(self, fields, constraints, *, force=False): """Create the table if the database is uninitialized. - If the table already exists, this does nothing, so it can e.g. be called on - every user_version change. + If the table already exists, this does nothing (except with force=True), so it + can e.g. be called on every user_version change. """ - if not user_version_changed(): + if not user_version_changed() and not force: return constraints = constraints or {} diff --git a/qutebrowser/utils/standarddir.py b/qutebrowser/utils/standarddir.py index f94d46061..7bb632b57 100644 --- a/qutebrowser/utils/standarddir.py +++ b/qutebrowser/utils/standarddir.py @@ -30,7 +30,7 @@ from typing import Iterator, Optional from PyQt5.QtCore import QStandardPaths from PyQt5.QtWidgets import QApplication -from qutebrowser.utils import log, debug, utils +from qutebrowser.utils import log, debug, utils, version # The cached locations _locations = {} @@ -232,7 +232,16 @@ def _init_runtime(args: Optional[argparse.Namespace]) -> None: # Unfortunately this path could get too long for sockets (which have a # maximum length of 104 chars), so we don't add the username here... - _create(path) + if version.is_flatpak(): + # We need a path like /run/user/1000/app/org.qutebrowser.qutebrowser rather than + # /run/user/1000/qutebrowser on Flatpak, since that's bind-mounted in a way that + # it is accessible by any other qutebrowser instances. + *parts, app_name = os.path.split(path) + assert app_name == APPNAME, app_name + path = os.path.join(*parts, 'app', os.environ['FLATPAK_ID']) + else: + _create(path) + _locations[_Location.runtime] = path diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 03a3c7842..2a47d60aa 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -32,8 +32,6 @@ import functools import contextlib import shlex import mimetypes -import ctypes -import ctypes.util from typing import (Any, Callable, IO, Iterator, Optional, Sequence, Tuple, Type, Union, TypeVar, TYPE_CHECKING) @@ -607,7 +605,7 @@ def open_file(filename: str, cmdline: str = None) -> None: # if we want to use the default override = config.val.downloads.open_dispatcher - if version.is_sandboxed(): + if version.is_flatpak(): if cmdline: message.error("Cannot spawn download dispatcher from sandbox") return @@ -753,19 +751,6 @@ def ceil_log(number: int, base: int) -> int: return result -def libgl_workaround() -> None: - """Work around QOpenGLShaderProgram issues, especially for Nvidia. - - See https://bugs.launchpad.net/ubuntu/+source/python-qt4/+bug/941826 - """ - if os.environ.get('QUTE_SKIP_LIBGL_WORKAROUND'): - return - - libgl = ctypes.util.find_library("GL") - if libgl is not None: # pragma: no branch - ctypes.CDLL(libgl, mode=ctypes.RTLD_GLOBAL) - - def parse_duration(duration: str) -> int: """Parse duration in format XhYmZs into milliseconds duration.""" if duration.isdigit(): diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index a1b8e6c72..89da353fc 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -58,12 +58,6 @@ from qutebrowser.misc import objects, earlyinit, sql, httpclient, pastebin, elf from qutebrowser.browser import pdfjs from qutebrowser.config import config, websettings -try: - from qutebrowser.browser.webengine import webenginesettings -except ImportError: # pragma: no cover - webenginesettings = None # type: ignore[assignment] - - _LOGO = r''' ______ ,, ,.-"` | ,-` | @@ -189,8 +183,12 @@ def distribution() -> Optional[DistributionInfo]: parsed=parsed, version=dist_version, pretty=pretty, id=dist_id) -def is_sandboxed() -> bool: - """Whether the environment has restricted access to the host system.""" +def is_flatpak() -> bool: + """Whether qutebrowser is running via Flatpak. + + If packaged via Flatpak, the environment is has restricted access to the host + system. + """ current_distro = distribution() if current_distro is None: return False @@ -684,7 +682,7 @@ def qtwebengine_versions(avoid_init: bool = False) -> WebEngineVersions: - https://www.chromium.org/developers/calendar - https://chromereleases.googleblog.com/ """ - assert webenginesettings is not None + from qutebrowser.browser.webengine import webenginesettings if webenginesettings.parsed_user_agent is None and not avoid_init: webenginesettings.init_user_agent() @@ -882,9 +880,6 @@ def opengl_info() -> Optional[OpenGLInfo]: # pragma: no cover """ assert QApplication.instance() - # Some setups can segfault in here if we don't do this. - utils.libgl_workaround() - override = os.environ.get('QUTE_FAKE_OPENGL') if override is not None: log.init.debug("Using override {}".format(override)) diff --git a/scripts/dev/misc_checks.py b/scripts/dev/misc_checks.py index 4c913cd3d..bae51e372 100644 --- a/scripts/dev/misc_checks.py +++ b/scripts/dev/misc_checks.py @@ -155,7 +155,7 @@ def check_spelling(args: argparse.Namespace) -> Optional[bool]: """Check commonly misspelled words.""" # Words which I often misspell words = {'behaviour', 'quitted', 'likelyhood', 'sucessfully', - 'occur[^rs .!]', 'seperator', 'explicitely', 'auxillary', + 'occur[^rs .!,]', 'seperator', 'explicitely', 'auxillary', 'accidentaly', 'ambigious', 'loosly', 'initialis', 'convienence', 'similiar', 'uncommited', 'reproducable', 'an user', 'convienience', 'wether', 'programatically', 'splitted', diff --git a/scripts/dev/run_pylint_on_tests.py b/scripts/dev/run_pylint_on_tests.py index 16a281d57..d0385bd17 100644 --- a/scripts/dev/run_pylint_on_tests.py +++ b/scripts/dev/run_pylint_on_tests.py @@ -58,6 +58,7 @@ def main(): 'protected-access', 'len-as-condition', 'compare-to-empty-string', + 'pointless-statement', # directories without __init__.py... 'import-error', ] diff --git a/tests/conftest.py b/tests/conftest.py index 084acddc5..8e35e1c24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -240,12 +240,6 @@ def set_backend(monkeypatch, request): monkeypatch.setattr(objects, 'backend', backend) -@pytest.fixture(autouse=True, scope='session') -def apply_libgl_workaround(): - """Make sure we load libGL early so QtWebEngine tests run properly.""" - utils.libgl_workaround() - - @pytest.fixture(autouse=True) def apply_fake_os(monkeypatch, request): fake_os = request.node.get_closest_marker('fake_os') diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 81a864c8e..658ff0e56 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -62,7 +62,11 @@ class Request(testprocess.Line): def _check_status(self): """Check if the http status is what we expected.""" path_to_statuses = { - '/favicon.ico': [HTTPStatus.OK, HTTPStatus.PARTIAL_CONTENT], + '/favicon.ico': [ + HTTPStatus.OK, + HTTPStatus.PARTIAL_CONTENT, + HTTPStatus.NOT_MODIFIED, + ], '/does-not-exist': [HTTPStatus.NOT_FOUND], '/does-not-exist-2': [HTTPStatus.NOT_FOUND], diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py index 9b08de30d..1a46c5be0 100644 --- a/tests/unit/browser/test_history.py +++ b/tests/unit/browser/test_history.py @@ -488,12 +488,11 @@ class TestCompletionMetaInfo: def test_contains_keyerror(self, metainfo): with pytest.raises(KeyError): - # pylint: disable=pointless-statement 'does_not_exist' in metainfo # noqa: B015 def test_getitem_keyerror(self, metainfo): with pytest.raises(KeyError): - metainfo['does_not_exist'] # pylint: disable=pointless-statement + metainfo['does_not_exist'] def test_setitem_keyerror(self, metainfo): with pytest.raises(KeyError): @@ -508,6 +507,28 @@ class TestCompletionMetaInfo: metainfo['excluded_patterns'] = value assert metainfo['excluded_patterns'] == value + # FIXME: It'd be good to test those two things via WebHistory (and not just + # CompletionMetaInfo in isolation), but we can't do that right now - see the + # docstring of TestRebuild for details. + + def test_recovery_no_key(self, metainfo): + metainfo.delete('key', 'force_rebuild') + + with pytest.raises(sql.BugError, match='No result for single-result query'): + metainfo['force_rebuild'] + + metainfo.try_recover() + assert not metainfo['force_rebuild'] + + def test_recovery_no_table(self, metainfo): + sql.Query("DROP TABLE CompletionMetaInfo").run() + + with pytest.raises(sql.BugError, match='no such table: CompletionMetaInfo'): + metainfo['force_rebuild'] + + metainfo.try_recover() + assert not metainfo['force_rebuild'] + class TestHistoryProgress: diff --git a/tests/unit/browser/webkit/test_webkitelem.py b/tests/unit/browser/webkit/test_webkitelem.py index 33af45b6c..593896e96 100644 --- a/tests/unit/browser/webkit/test_webkitelem.py +++ b/tests/unit/browser/webkit/test_webkitelem.py @@ -303,7 +303,7 @@ class TestWebKitElement: def test_getitem_keyerror(self, elem): with pytest.raises(KeyError): - elem['foo'] # pylint: disable=pointless-statement + elem['foo'] def test_setitem(self, elem): elem['foo'] = 'bar' diff --git a/tests/unit/commands/test_runners.py b/tests/unit/commands/test_parser.py index ac9fee485..b851ad3b0 100644 --- a/tests/unit/commands/test_runners.py +++ b/tests/unit/commands/test_parser.py @@ -17,12 +17,12 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see <https://www.gnu.org/licenses/>. -"""Tests for qutebrowser.commands.runners.""" +"""Tests for qutebrowser.commands.parser.""" import pytest from qutebrowser.misc import objects -from qutebrowser.commands import runners, cmdexc +from qutebrowser.commands import parser, cmdexc class TestCommandParser: @@ -35,12 +35,12 @@ class TestCommandParser: Args: cmdline_test: A pytest fixture which provides testcases. """ - parser = runners.CommandParser() + p = parser.CommandParser() if cmdline_test.valid: - parser.parse_all(cmdline_test.cmd, aliases=False) + p.parse_all(cmdline_test.cmd, aliases=False) else: with pytest.raises(cmdexc.NoSuchCommandError): - parser.parse_all(cmdline_test.cmd, aliases=False) + p.parse_all(cmdline_test.cmd, aliases=False) def test_parse_all_with_alias(self, cmdline_test, monkeypatch, config_stub): @@ -49,12 +49,12 @@ class TestCommandParser: config_stub.val.aliases = {'alias_name': cmdline_test.cmd} - parser = runners.CommandParser() + p = parser.CommandParser() if cmdline_test.valid: - assert len(parser.parse_all("alias_name")) > 0 + assert len(p.parse_all("alias_name")) > 0 else: with pytest.raises(cmdexc.NoSuchCommandError): - parser.parse_all("alias_name") + p.parse_all("alias_name") @pytest.mark.parametrize('command', ['', ' ']) def test_parse_empty_with_alias(self, command): @@ -63,9 +63,33 @@ class TestCommandParser: See https://github.com/qutebrowser/qutebrowser/issues/1690 and https://github.com/qutebrowser/qutebrowser/issues/1773 """ - parser = runners.CommandParser() + p = parser.CommandParser() with pytest.raises(cmdexc.NoSuchCommandError): - parser.parse_all(command) + p.parse_all(command) + + @pytest.mark.parametrize('command, name, args', [ + ("set-cmd-text -s :open", "set-cmd-text", ["-s", ":open"]), + ("set-cmd-text :open {url:pretty}", "set-cmd-text", + [":open {url:pretty}"]), + ("set-cmd-text -s :open -t", "set-cmd-text", ["-s", ":open -t"]), + ("set-cmd-text :open -t -r {url:pretty}", "set-cmd-text", + [":open -t -r {url:pretty}"]), + ("set-cmd-text -s :open -b", "set-cmd-text", ["-s", ":open -b"]), + ("set-cmd-text :open -b -r {url:pretty}", "set-cmd-text", + [":open -b -r {url:pretty}"]), + ("set-cmd-text -s :open -w", "set-cmd-text", + ["-s", ":open -w"]), + ("set-cmd-text :open -w {url:pretty}", "set-cmd-text", + [":open -w {url:pretty}"]), + ("set-cmd-text /", "set-cmd-text", ["/"]), + ("set-cmd-text ?", "set-cmd-text", ["?"]), + ("set-cmd-text :", "set-cmd-text", [":"]), + ]) + def test_parse_result(self, config_stub, command, name, args): + p = parser.CommandParser() + result = p.parse_all(command)[0] + assert result.cmd.name == name + assert result.args == args class TestCompletions: @@ -86,8 +110,8 @@ class TestCompletions: The same with it being disabled is tested by test_parse_all. """ - parser = runners.CommandParser(partial_match=True) - result = parser.parse('on') + p = parser.CommandParser(partial_match=True) + result = p.parse('on') assert result.cmd.name == 'one' def test_dont_use_best_match(self, config_stub): @@ -96,10 +120,10 @@ class TestCompletions: Should raise NoSuchCommandError """ config_stub.val.completion.use_best_match = False - parser = runners.CommandParser(partial_match=True) + p = parser.CommandParser(partial_match=True) with pytest.raises(cmdexc.NoSuchCommandError): - parser.parse('tw') + p.parse('tw') def test_use_best_match(self, config_stub): """Test multiple completion options with use_best_match set to true. @@ -107,7 +131,7 @@ class TestCompletions: The resulting command should be the best match """ config_stub.val.completion.use_best_match = True - parser = runners.CommandParser(partial_match=True) + p = parser.CommandParser(partial_match=True) - result = parser.parse('tw') + result = p.parse('tw') assert result.cmd.name == 'two' diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 8a9d8154d..dd6ef54fa 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -187,17 +187,39 @@ class TestKeyConfig: @pytest.mark.parametrize('bindings, expected', [ # Simple - ({'a': 'message-info foo', 'b': 'message-info bar'}, - {'message-info foo': ['a'], 'message-info bar': ['b']}), + ({'a': 'open foo', 'b': 'open bar'}, + {'open foo': ['a'], 'open bar': ['b']}), # Multiple bindings - ({'a': 'message-info foo', 'b': 'message-info foo'}, - {'message-info foo': ['b', 'a']}), + ({'a': 'open foo', 'b': 'open foo'}, + {'open foo': ['b', 'a']}), # With modifier keys (should be listed last and normalized) - ({'a': 'message-info foo', '<ctrl-a>': 'message-info foo'}, - {'message-info foo': ['a', '<Ctrl+a>']}), + ({'a': 'open foo', '<ctrl-a>': 'open foo'}, + {'open foo': ['a', '<Ctrl+a>']}), # Chained command - ({'a': 'message-info foo ;; message-info bar'}, - {'message-info foo': ['a'], 'message-info bar': ['a']}), + ({'a': 'open foo ;; open bar'}, + {'open foo': ['a'], 'open bar': ['a']}), + # Command using set-cmd-text (#5942) + ( + { + "o": "set-cmd-text -s :open", + "O": "set-cmd-text -s :open -t", + "go": "set-cmd-text :open {url:pretty}", + # all of these should be ignored + "/": "set-cmd-text /", + "?": "set-cmd-text ?", + ":": "set-cmd-text :", + "a": "set-cmd-text no_leading_colon", + "b": "set-cmd-text -s -a :skip_cuz_append", + "c": "set-cmd-text --append :skip_cuz_append", + }, + { + "open": ["o"], + "open -t": ["O"], + "open {url:pretty}": ["go"], + } + ), + # Empty/unknown commands + ({"a": "", "b": "notreal"}, {}), ]) def test_get_reverse_bindings_for(self, key_config_stub, config_stub, no_bindings, bindings, expected): @@ -725,7 +747,7 @@ class TestContainer: def test_getattr_invalid_private(self, container): """Make sure an invalid _attribute doesn't try getting a container.""" with pytest.raises(AttributeError): - container._foo # pylint: disable=pointless-statement + container._foo def test_getattr_prefix(self, container): new_container = container.tabs @@ -744,7 +766,7 @@ class TestContainer: def test_getattr_invalid(self, container): with pytest.raises(configexc.NoOptionError) as excinfo: - container.tabs.foobar # pylint: disable=pointless-statement + container.tabs.foobar assert excinfo.value.option == 'tabs.foobar' def test_setattr_option(self, config_stub, container): @@ -754,7 +776,7 @@ class TestContainer: def test_confapi_errors(self, container): configapi = types.SimpleNamespace(errors=[]) container._configapi = configapi - container.tabs.foobar # pylint: disable=pointless-statement + container.tabs.foobar assert len(configapi.errors) == 1 error = configapi.errors[0] diff --git a/tests/unit/config/test_configcache.py b/tests/unit/config/test_configcache.py index 6bd841a65..87514bada 100644 --- a/tests/unit/config/test_configcache.py +++ b/tests/unit/config/test_configcache.py @@ -55,12 +55,10 @@ def test_configcache_get_after_set(config_stub): def test_configcache_naive_benchmark(config_stub, benchmark): def _run_bench(): for _i in range(10000): - # pylint: disable=pointless-statement config.cache['tabs.padding'] config.cache['tabs.indicator.width'] config.cache['tabs.indicator.padding'] config.cache['tabs.min_width'] config.cache['tabs.max_width'] config.cache['tabs.pinned.shrink'] - # pylint: enable=pointless-statement benchmark(_run_bench) diff --git a/tests/unit/config/test_qtargs.py b/tests/unit/config/test_qtargs.py index 695649213..4c31c5b07 100644 --- a/tests/unit/config/test_qtargs.py +++ b/tests/unit/config/test_qtargs.py @@ -52,10 +52,14 @@ def version_patcher(monkeypatch): @pytest.fixture -def reduce_args(config_stub, version_patcher): +def reduce_args(config_stub, version_patcher, monkeypatch): """Make sure no --disable-shared-workers/referer argument get added.""" - version_patcher('5.15.0') + version_patcher('5.15.3') config_stub.val.content.headers.referer = 'always' + config_stub.val.scrolling.bar = 'never' + monkeypatch.setattr(qtargs.utils, 'is_mac', False) + # Avoid WebRTC pipewire feature + monkeypatch.setattr(qtargs.utils, 'is_linux', False) @pytest.mark.usefixtures('reduce_args') @@ -78,11 +82,6 @@ class TestQtArgs: ]) def test_qt_args(self, monkeypatch, config_stub, args, expected, parser): """Test commandline with no Qt arguments given.""" - # Avoid scrollbar overlay argument - config_stub.val.scrolling.bar = 'never' - # Avoid WebRTC pipewire feature - monkeypatch.setattr(qtargs.utils, 'is_linux', False) - parsed = parser.parse_args(args) assert qtargs.qt_args(parsed) == expected @@ -112,7 +111,6 @@ def test_no_webengine_available(monkeypatch, config_stub, parser, stubs): here. """ monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) - monkeypatch.setattr(qtargs.version, 'webenginesettings', None) fake = stubs.ImportFake({'qutebrowser.browser.webengine': False}, monkeypatch) fake.patch() @@ -126,9 +124,10 @@ def test_no_webengine_available(monkeypatch, config_stub, parser, stubs): class TestWebEngineArgs: @pytest.fixture(autouse=True) - def ensure_webengine(self): + def ensure_webengine(self, monkeypatch): """Skip all tests if QtWebEngine is unavailable.""" pytest.importorskip("PyQt5.QtWebEngine") + monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) @pytest.mark.parametrize('backend, qt_version, expected', [ (usertypes.Backend.QtWebEngine, '5.13.0', False), @@ -184,7 +183,6 @@ class TestWebEngineArgs: (['--debug-flag', 'wait-renderer-process'], ['--renderer-startup-dialog']), ]) def test_chromium_flags(self, monkeypatch, parser, flags, args): - monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) parsed = parser.parse_args(flags) args = qtargs.qt_args(parsed) @@ -203,7 +201,6 @@ class TestWebEngineArgs: ('chromium', True), ]) def test_disable_gpu(self, config, added, config_stub, monkeypatch, parser): - monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) config_stub.val.qt.force_software_rendering = config parsed = parser.parse_args([]) args = qtargs.qt_args(parsed) @@ -225,7 +222,6 @@ class TestWebEngineArgs: 'disable_non_proxied_udp'), ]) def test_webrtc(self, config_stub, monkeypatch, parser, policy, arg): - monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) config_stub.val.content.webrtc_ip_handling_policy = policy parsed = parser.parse_args([]) @@ -241,10 +237,7 @@ class TestWebEngineArgs: (True, False), # canvas reading enabled (False, True), ]) - def test_canvas_reading(self, config_stub, monkeypatch, parser, - canvas_reading, added): - monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) - + def test_canvas_reading(self, config_stub, parser, canvas_reading, added): config_stub.val.content.canvas_reading = canvas_reading parsed = parser.parse_args([]) args = qtargs.qt_args(parsed) @@ -255,10 +248,7 @@ class TestWebEngineArgs: ('process-per-site', True), ('single-process', True), ]) - def test_process_model(self, config_stub, monkeypatch, parser, - process_model, added): - monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) - + def test_process_model(self, config_stub, parser, process_model, added): config_stub.val.qt.process_model = process_model parsed = parser.parse_args([]) args = qtargs.qt_args(parsed) @@ -276,10 +266,7 @@ class TestWebEngineArgs: ('always', '--enable-low-end-device-mode'), ('never', '--disable-low-end-device-mode'), ]) - def test_low_end_device_mode(self, config_stub, monkeypatch, parser, - low_end_device_mode, arg): - monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) - + def test_low_end_device_mode(self, config_stub, parser, low_end_device_mode, arg): config_stub.val.qt.low_end_device_mode = low_end_device_mode parsed = parser.parse_args([]) args = qtargs.qt_args(parsed) @@ -307,16 +294,10 @@ class TestWebEngineArgs: ('5.14.0', 'same-domain', '--enable-features=ReducedReferrerGranularity'), ('5.15.0', 'same-domain', '--enable-features=ReducedReferrerGranularity'), ]) - def test_referer(self, config_stub, monkeypatch, version_patcher, parser, + def test_referer(self, config_stub, version_patcher, parser, qt_version, referer, arg): - monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) version_patcher(qt_version) - # Avoid WebRTC pipewire feature - monkeypatch.setattr(qtargs.utils, 'is_linux', False) - # Avoid overlay scrollbar feature - config_stub.val.scrolling.bar = 'never' - config_stub.val.content.headers.referer = referer parsed = parser.parse_args([]) args = qtargs.qt_args(parsed) @@ -380,10 +361,7 @@ class TestWebEngineArgs: ]) def test_overlay_scrollbar(self, config_stub, monkeypatch, parser, bar, is_mac, added): - monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) monkeypatch.setattr(qtargs.utils, 'is_mac', is_mac) - # Avoid WebRTC pipewire feature - monkeypatch.setattr(qtargs.utils, 'is_linux', False) config_stub.val.scrolling.bar = bar @@ -392,15 +370,6 @@ class TestWebEngineArgs: assert ('--enable-features=OverlayScrollbar' in args) == added - @pytest.fixture - def feature_flag_patch(self, monkeypatch, config_stub, version_patcher): - """Patch away things affecting feature flags.""" - config_stub.val.scrolling.bar = 'never' - version_patcher('5.15.3') - monkeypatch.setattr(qtargs.utils, 'is_mac', False) - # Avoid WebRTC pipewire feature - monkeypatch.setattr(qtargs.utils, 'is_linux', False) - @pytest.mark.parametrize('via_commandline', [True, False]) @pytest.mark.parametrize('overlay, passed_features, expected_features', [ (True, @@ -413,7 +382,7 @@ class TestWebEngineArgs: 'CustomFeature', 'CustomFeature'), ]) - def test_overlay_features_flag(self, config_stub, parser, feature_flag_patch, + def test_overlay_features_flag(self, config_stub, parser, via_commandline, overlay, passed_features, expected_features): """If enable-features is already specified, we should combine both.""" @@ -442,7 +411,7 @@ class TestWebEngineArgs: ['CustomFeature'], ['CustomFeature1', 'CustomFeature2'], ]) - def test_disable_features_passthrough(self, config_stub, parser, feature_flag_patch, + def test_disable_features_passthrough(self, config_stub, parser, via_commandline, passed_features): flag = qtargs._DISABLE_FEATURES + ','.join(passed_features) @@ -458,7 +427,7 @@ class TestWebEngineArgs: ] assert disable_features_args == [flag] - def test_blink_settings_passthrough(self, parser, config_stub, feature_flag_patch): + def test_blink_settings_passthrough(self, parser, config_stub): config_stub.val.colors.webpage.darkmode.enabled = True flag = qtargs._BLINK_SETTINGS + 'foo=bar' @@ -492,6 +461,16 @@ class TestWebEngineArgs: expected = ['--disable-features=InstalledApp'] if has_workaround else [] assert disable_features_args == expected + @pytest.mark.parametrize('enabled', [True, False]) + @testutils.qt514 + def test_media_keys(self, config_stub, parser, enabled): + config_stub.val.input.media_keys = enabled + + parsed = parser.parse_args([]) + args = qtargs.qt_args(parsed) + + assert ('--disable-features=HardwareMediaKeyHandling' in args) != enabled + @pytest.mark.parametrize('variant, expected', [ ( 'qt_515_1', @@ -518,7 +497,6 @@ class TestWebEngineArgs: def test_dark_mode_settings(self, config_stub, monkeypatch, parser, variant, expected): from qutebrowser.browser.webengine import darkmode - monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) monkeypatch.setattr( darkmode, '_variant', lambda _versions: darkmode.Variant[variant]) @@ -531,16 +509,16 @@ class TestWebEngineArgs: assert arg in args @pytest.mark.linux - def test_locale_workaround(self, config_stub, monkeypatch, version_patcher, - parser): + def test_locale_workaround(self, config_stub, monkeypatch, version_patcher, parser): class FakeLocale: def bcp47Name(self): return 'de-CH' - monkeypatch.setattr(qtargs.objects, 'backend', usertypes.Backend.QtWebEngine) + monkeypatch.setattr(qtargs.utils, 'is_linux', True) # patched in reduce_args monkeypatch.setattr(qtargs, 'QLocale', FakeLocale) version_patcher('5.15.3') + config_stub.val.qt.workarounds.locale = True parsed = parser.parse_args([]) args = qtargs.qt_args(parsed) diff --git a/tests/unit/javascript/conftest.py b/tests/unit/javascript/conftest.py index 85d5ebe0a..4a7f09204 100644 --- a/tests/unit/javascript/conftest.py +++ b/tests/unit/javascript/conftest.py @@ -19,9 +19,7 @@ """pytest conftest file for javascript tests.""" -import os -import os.path - +import pathlib import pytest import jinja2 @@ -30,6 +28,8 @@ from PyQt5.QtCore import QUrl import qutebrowser from qutebrowser.utils import usertypes +JS_DIR = pathlib.Path(__file__).parent + class JSTester: @@ -44,7 +44,7 @@ class JSTester: def __init__(self, tab, qtbot, config_stub): self.tab = tab self.qtbot = qtbot - loader = jinja2.FileSystemLoader(os.path.dirname(__file__)) + loader = jinja2.FileSystemLoader(JS_DIR) self._jinja_env = jinja2.Environment(loader=loader, autoescape=True) # Make sure error logging via JS fails tests config_stub.val.content.javascript.log = { @@ -87,7 +87,7 @@ class JSTester: force: Whether to force loading even if the file is invalid. """ self.load_url(QUrl.fromLocalFile( - os.path.join(os.path.dirname(__file__), path)), force) + str(JS_DIR / path)), force) def load_url(self, url: QUrl, force: bool = False): """Load a given QUrl. @@ -109,9 +109,8 @@ class JSTester: path: The path to the JS file, relative to the qutebrowser package. expected: The value expected return from the javascript execution """ - base_path = os.path.dirname(os.path.abspath(qutebrowser.__file__)) - with open(os.path.join(base_path, path), 'r', encoding='utf-8') as f: - source = f.read() + base_path = pathlib.Path(qutebrowser.__file__).resolve().parent + source = (base_path / path).read_text(encoding='utf-8') self.run(source, expected) def run(self, source: str, expected=usertypes.UNSET, world=None) -> None: diff --git a/tests/unit/javascript/stylesheet/test_stylesheet_js.py b/tests/unit/javascript/stylesheet/test_stylesheet_js.py index 13ec85cd5..1eebe3b7f 100644 --- a/tests/unit/javascript/stylesheet/test_stylesheet_js.py +++ b/tests/unit/javascript/stylesheet/test_stylesheet_js.py @@ -19,7 +19,7 @@ """Tests for stylesheet.js.""" -import os +import pathlib import pytest QtWebEngineWidgets = pytest.importorskip("PyQt5.QtWebEngineWidgets") @@ -49,8 +49,8 @@ class StylesheetTester: def init_stylesheet(self, css_file="green.css"): """Initialize the stylesheet with a provided css file.""" - css_path = os.path.join(os.path.dirname(__file__), css_file) - self.config_stub.val.content.user_stylesheets = css_path + css_path = pathlib.Path(__file__).parent / css_file + self.config_stub.val.content.user_stylesheets = str(css_path) def set_css(self, css): """Set document style to `css` via stylesheet.js.""" diff --git a/tests/unit/javascript/test_greasemonkey.py b/tests/unit/javascript/test_greasemonkey.py index 3a3ea0294..2bfb9ca83 100644 --- a/tests/unit/javascript/test_greasemonkey.py +++ b/tests/unit/javascript/test_greasemonkey.py @@ -198,7 +198,7 @@ class TestForceDocumentEnd: assert script.needs_document_end_workaround() == force -def test_required_scripts_are_included(download_stub, tmpdir): +def test_required_scripts_are_included(download_stub, tmp_path): test_require_script = textwrap.dedent(""" // ==UserScript== // @name qutebrowser test userscript @@ -212,7 +212,7 @@ def test_required_scripts_are_included(download_stub, tmpdir): console.log("Script is running."); """) _save_script(test_require_script, 'requiring.user.js') - (tmpdir / 'test.js').write_text('REQUIRED SCRIPT', encoding='UTF-8') + (tmp_path / 'test.js').write_text('REQUIRED SCRIPT', encoding='UTF-8') gm_manager = greasemonkey.GreasemonkeyManager() assert len(gm_manager._in_progress_dls) == 1 diff --git a/tests/unit/utils/test_standarddir.py b/tests/unit/utils/test_standarddir.py index 5b24ed962..1a9107995 100644 --- a/tests/unit/utils/test_standarddir.py +++ b/tests/unit/utils/test_standarddir.py @@ -202,6 +202,22 @@ class TestStandardDir: standarddir._init_runtime(args=None) assert standarddir.runtime() == str(tmpdir_env / APPNAME) + @pytest.mark.linux + def test_flatpak_runtimedir(self, monkeypatch, tmp_path): + runtime_path = tmp_path / 'runtime' + runtime_path.mkdir() + runtime_path.chmod(0o0700) + + app_id = 'org.qutebrowser.qutebrowser' + expected = runtime_path / 'app' / app_id + + monkeypatch.setattr(standarddir.version, 'is_flatpak', lambda: True) + monkeypatch.setenv('XDG_RUNTIME_DIR', str(runtime_path)) + monkeypatch.setenv('FLATPAK_ID', app_id) + + standarddir._init_runtime(args=None) + assert standarddir.runtime() == str(expected) + @pytest.mark.fake_os('windows') def test_runtimedir_empty_tempdir(self, monkeypatch, tmpdir): """With an empty tempdir on non-Linux, we should raise.""" diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index b43638cb3..2c726ddb6 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -892,13 +892,6 @@ def test_ceil_log_invalid(number, base): utils.ceil_log(number, base) -@pytest.mark.parametrize('skip', [True, False]) -def test_libgl_workaround(monkeypatch, skip): - if skip: - monkeypatch.setenv('QUTE_SKIP_LIBGL_WORKAROUND', '1') - utils.libgl_workaround() # Just make sure it doesn't crash. - - @pytest.mark.parametrize('duration, out', [ ("0", 0), ("0s", 0), diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index c91017e84..a53b4bdce 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -40,6 +40,11 @@ from qutebrowser.utils import version, usertypes, utils, standarddir from qutebrowser.misc import pastebin, objects, elf from qutebrowser.browser import pdfjs +try: + from qutebrowser.browser.webengine import webenginesettings +except ImportError: + webenginesettings = None + @pytest.mark.parametrize('os_release, expected', [ # No file @@ -314,9 +319,9 @@ def test_distribution(tmpdir, monkeypatch, os_release, expected): id='arch', parsed=version.Distribution.arch, version=None, pretty='Arch Linux'), False) ]) -def test_is_sandboxed(monkeypatch, distribution, expected): +def test_is_flatpak(monkeypatch, distribution, expected): monkeypatch.setattr(version, "distribution", lambda: distribution) - assert version.is_sandboxed() == expected + assert version.is_flatpak() == expected class GitStrSubprocessFake: @@ -1004,7 +1009,6 @@ class TestWebEngineVersions: versions = version.WebEngineVersions.from_pyqt(pyqt_webengine_version) - from qutebrowser.browser.webengine import webenginesettings webenginesettings.init_user_agent() expected = webenginesettings.parsed_user_agent.upstream_browser_version @@ -1045,26 +1049,24 @@ class TestChromiumVersion: @pytest.fixture(autouse=True) def clear_parsed_ua(self, monkeypatch): pytest.importorskip('PyQt5.QtWebEngineWidgets') - if version.webenginesettings is not None: + if webenginesettings is not None: # Not available with QtWebKit - monkeypatch.setattr(version.webenginesettings, 'parsed_user_agent', None) + monkeypatch.setattr(webenginesettings, 'parsed_user_agent', None) def test_fake_ua(self, monkeypatch, caplog): ver = '77.0.3865.98' - version.webenginesettings._init_user_agent_str( - _QTWE_USER_AGENT.format(ver)) + webenginesettings._init_user_agent_str(_QTWE_USER_AGENT.format(ver)) assert version.qtwebengine_versions().chromium == ver def test_prefers_saved_user_agent(self, monkeypatch): - version.webenginesettings._init_user_agent_str(_QTWE_USER_AGENT.format('87')) + webenginesettings._init_user_agent_str(_QTWE_USER_AGENT.format('87')) class FakeProfile: def defaultProfile(self): raise AssertionError("Should not be called") - monkeypatch.setattr(version.webenginesettings, 'QWebEngineProfile', - FakeProfile()) + monkeypatch.setattr(webenginesettings, 'QWebEngineProfile', FakeProfile()) version.qtwebengine_versions() @@ -1250,7 +1252,6 @@ def test_version_info(params, stubs, monkeypatch, config_stub): if params.with_webkit: patches['qWebKitVersion'] = lambda: 'WEBKIT VERSION' patches['objects.backend'] = usertypes.Backend.QtWebKit - patches['webenginesettings'] = None substitutions['backend'] = 'new QtWebKit (WebKit WEBKIT VERSION)' else: monkeypatch.delattr(version, 'qtutils.qWebKitVersion', raising=False) @@ -38,6 +38,16 @@ commands = {envpython} -bb -m pytest {posargs:tests} cov: {envpython} scripts/dev/check_coverage.py {posargs} +[testenv:bleeding] +basepython = {env:PYTHON:python3} +setenv = + PYTEST_QT_API=pyqt5 + QUTE_BDD_WEBENGINE=true +pip_pre = true +deps = -r{toxinidir}/misc/requirements/requirements-tests-bleeding.txt +commands_pre = pip install --index-url https://www.riverbankcomputing.com/pypi/simple/ --pre --upgrade PyQt5 PyQtWebEngine +commands = {envpython} -bb -m pytest {posargs:tests} + # other envs [testenv:misc] |