diff options
author | toofar <toofar@spalge.com> | 2023-08-12 13:49:01 +1200 |
---|---|---|
committer | toofar <toofar@spalge.com> | 2023-08-12 13:49:01 +1200 |
commit | 0f2d34623cb6194846db70c8549a066e9a5bab17 (patch) | |
tree | d5387b4913c91c05c6200c8b21c9c0702f7b9ea3 | |
parent | b018f3f081db60029be35ee800170e3c3a32d3e7 (diff) | |
parent | fc843f39440e10f918d57b86c7043658a48d7366 (diff) | |
download | qutebrowser-0f2d34623cb6194846db70c8549a066e9a5bab17.tar.gz qutebrowser-0f2d34623cb6194846db70c8549a066e9a5bab17.zip |
Merge remote-tracking branch 'upstream/main' into feat/mac_sandbox_pre_release_pyinstaller
Only conflict was the removal of support for 32bit builds in
build_release.py
34 files changed, 358 insertions, 145 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1612a4eb..14d642491 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,7 @@ jobs: args: "-f gcc" # For problem matchers - testenv: yamllint - testenv: actionlint + - testenv: package steps: - uses: actions/checkout@v3 with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2502d017b..18c12e053 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -19,47 +19,34 @@ jobs: toxenv: build-release-qt5 name: qt5-macos - os: windows-2019 - args: --64bit branch: main toxenv: build-release-qt5 - name: qt5-windows-64bit - - os: windows-2019 - args: --32bit - branch: main - toxenv: build-release-qt5 - name: qt5-windows-32bit - + name: qt5-windows - os: macos-11 args: --debug branch: main toxenv: build-release-qt5 name: qt5-macos-debug - os: windows-2019 - args: --64bit --debug - branch: main - toxenv: build-release-qt5 - name: qt5-windows-64bit-debug - - os: windows-2019 - args: --32bit --debug + args: --debug branch: main toxenv: build-release-qt5 - name: qt5-windows-32bit-debug + name: qt5-windows-debug - os: macos-11 toxenv: build-release name: macos - os: windows-2019 - args: --64bit toxenv: build-release - name: windows-64bit + name: windows - os: macos-11 args: --debug toxenv: build-release name: macos-debug - os: windows-2019 - args: --64bit --debug + args: --debug toxenv: build-release - name: windows-64bit-debug + name: windows-debug runs-on: "${{ matrix.os }}" timeout-minutes: 45 steps: diff --git a/README.asciidoc b/README.asciidoc index 910a6b987..2b6bdfdd6 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -220,6 +220,7 @@ Active * https://nyxt.atlas.engineer/[Nyxt browser] (formerly "Next browser", Lisp, Emacs-like but also offers Vim bindings, QtWebEngine or GTK+/WebKit2 - note there was a https://jgkamat.gitlab.io/blog/next-rce.html[critical remote code execution in 2019] which was handled quite badly) * https://vieb.dev/[Vieb] (JavaScript, Electron) * https://surf.suckless.org/[surf] (C, GTK+ with WebKit1/WebKit2) +* https://github.com/jun7/wyeb[wyeb] (C, GTK+ with WebKit2) * Chrome/Chromium addons: https://vimium.github.io/[Vimium] * Firefox addons (based on WebExtensions): @@ -227,9 +228,8 @@ Active https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/[Vimium-FF] * Addons for Firefox and Chrome: https://github.com/brookhong/Surfingkeys[Surfingkeys], - https://lydell.github.io/LinkHints/[Link Hints] (hinting only) -* Addons for Safari: - https://televator.net/vimari/[Vimari] + https://lydell.github.io/LinkHints/[Link Hints] (hinting only), + https://github.com/ueokande/vimmatic[Vimmatic] Inactive ~~~~~~~~ @@ -246,7 +246,6 @@ main inspiration for qutebrowser) * https://www.uzbl.org/[uzbl] (C, GTK+ with WebKit1/WebKit2) * https://github.com/conformal/xombrero[xombrero] (C, GTK+ with WebKit1) * https://github.com/linkdd/cream-browser[Cream Browser] (C, GTK+ with WebKit1) -* https://github.com/jun7/wyeb[wyeb] (C, GTK+ with WebKit2) * Firefox addons (not based on WebExtensions or no recent activity): http://www.vimperator.org/[Vimperator], http://bug.5digits.org/pentadactyl/index[Pentadactyl], @@ -263,6 +262,8 @@ main inspiration for qutebrowser) https://github.com/1995eaton/chromium-vim[cVim], https://github.com/dcchambers/vb4c[vb4c] (fork of cVim, https://github.com/dcchambers/vb4c/issues/23#issuecomment-810694017[unmaintained]), https://glee.github.io/[GleeBox] +* Addons for Safari: + https://televator.net/vimari/[Vimari] License ------- diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 03dbbeeae..5deb381f7 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -67,6 +67,8 @@ Added - New `colors.webpage.darkmode.increase_text_contrast` setting for Qt 6.3+ - New `fonts.tooltip`, `colors.tooltip.bg` and `colors.tooltip.fg` settings. - New `log-qt-events` debug flag for `-D` +- New `--all` flags for `:bookmark-del` and `:quickmark-del` to delete all + quickmarks/bookmarks. Removed ~~~~~~~ @@ -202,8 +204,16 @@ Fixed - Crash when using QtWebKit with PAC and the file has an invalid encoding. - Crash with the "tiramisu" notification server. - Crash when the "herbe" notification presenter doesn't start correctly. +- Crash when no notification server is installed/available. +- Warning with recent versions of the "deadd" (aka "linux notification center") notification server. - Crash when using `:print --pdf` with a directory where its parent directory did not exist. +- The `PyQt{5,6}.sip` version is now shown correctly in the :version|--version + output. Previously that showed the version from the standalone `sip` module + which was only set for PyQt5. (#7805) +- When a `config.py` calls `.redirect()` via a request interceptor (which is + unsupported) and supplies an invalid redirect target URL, an exception is now + raised for the `.redirect()` call instead of later inside qutebrowser. [[v2.5.4]] v2.5.4 (2023-03-13) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 7f0abc71d..6577a9ddf 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -204,7 +204,7 @@ If no url and title are provided, then save the current page as a bookmark. If a [[bookmark-del]] === bookmark-del -Syntax: +:bookmark-del ['url']+ +Syntax: +:bookmark-del [*--all*] ['url']+ Delete a bookmark. @@ -212,6 +212,9 @@ Delete a bookmark. * +'url'+: The url of the bookmark to delete. If not given, use the current page's url. +==== optional arguments +* +*-a*+, +*--all*+: If given, delete all bookmarks. + ==== note * This command does not split arguments after the last argument and handles quotes literally. @@ -998,7 +1001,7 @@ You can view all saved quickmarks on the link:qute://bookmarks[bookmarks page]. [[quickmark-del]] === quickmark-del -Syntax: +:quickmark-del ['name']+ +Syntax: +:quickmark-del [*--all*] ['name']+ Delete a quickmark. @@ -1007,6 +1010,9 @@ Delete a quickmark. if there are more than one). +==== optional arguments +* +*-a*+, +*--all*+: Delete all quickmarks. + ==== note * This command does not split arguments after the last argument and handles quotes literally. diff --git a/misc/requirements/requirements-dev.txt b/misc/requirements/requirements-dev.txt index 6655cbfde..c9590f1c5 100644 --- a/misc/requirements/requirements-dev.txt +++ b/misc/requirements/requirements-dev.txt @@ -6,7 +6,7 @@ bump2version==1.0.1 certifi==2023.7.22 cffi==1.15.1 charset-normalizer==3.2.0 -cryptography==41.0.2 +cryptography==41.0.3 docutils==0.20.1 github3.py==4.0.1 hunter==3.6.1 @@ -19,12 +19,12 @@ keyring==24.2.0 manhole==1.8.0 markdown-it-py==3.0.0 mdurl==0.1.2 -more-itertools==9.1.0 +more-itertools==10.1.0 packaging==23.1 pkginfo==1.9.6 ply==3.11 pycparser==2.21 -Pygments==2.15.1 +Pygments==2.16.1 PyJWT==2.8.0 Pympler==1.0.1 pyproject_hooks==1.0.0 @@ -34,9 +34,9 @@ readme-renderer==40.0 requests==2.31.0 requests-toolbelt==1.0.0 rfc3986==2.0.0 -rich==13.4.2 +rich==13.5.2 SecretStorage==3.3.3 -sip==6.7.10 +sip==6.7.11 six==1.16.0 tomli==2.0.1 twine==4.0.2 diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt index 685542224..e16d6860f 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py attrs==23.1.0 -flake8==6.0.0 +flake8==6.1.0 flake8-bugbear==23.7.10 flake8-builtins==2.1.0 flake8-comprehensions==3.14.0 @@ -16,8 +16,8 @@ flake8-tidy-imports==4.10.0 flake8-tuple==0.4.1 mccabe==0.7.0 pep8-naming==0.13.3 -pycodestyle==2.10.0 +pycodestyle==2.11.0 pydocstyle==6.3.0 -pyflakes==3.0.1 +pyflakes==3.1.0 six==1.16.0 snowballstemmer==2.2.0 diff --git a/misc/requirements/requirements-mypy.txt b/misc/requirements/requirements-mypy.txt index a47f25d3f..1e18a7ab2 100644 --- a/misc/requirements/requirements-mypy.txt +++ b/misc/requirements/requirements-mypy.txt @@ -1,6 +1,6 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -chardet==5.1.0 +chardet==5.2.0 diff-cover==7.7.0 importlib-resources==6.0.0 Jinja2==3.1.2 @@ -9,12 +9,12 @@ MarkupSafe==2.1.3 mypy==1.4.1 mypy-extensions==1.0.0 pluggy==1.2.0 -Pygments==2.15.1 +Pygments==2.16.1 PyQt5-stubs==5.15.6.0 tomli==2.0.1 types-colorama==0.4.15.12 types-docutils==0.20.0.1 -types-Pygments==2.15.0.2 +types-Pygments==2.16.0.0 types-PyYAML==6.0.12.11 types-setuptools==68.0.0.3 typing_extensions==4.7.1 diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt index 62d7696eb..cd09e4bf2 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -4,7 +4,7 @@ astroid==2.15.6 certifi==2023.7.22 cffi==1.15.1 charset-normalizer==3.2.0 -cryptography==41.0.2 +cryptography==41.0.3 dill==0.3.7 github3.py==4.0.1 idna==3.4 @@ -12,16 +12,16 @@ isort==5.12.0 lazy-object-proxy==1.9.0 mccabe==0.7.0 pefile==2023.2.7 -platformdirs==3.9.1 +platformdirs==3.10.0 pycparser==2.21 PyJWT==2.8.0 -pylint==2.17.4 +pylint==2.17.5 python-dateutil==2.8.2 ./scripts/dev/pylint_checkers requests==2.31.0 six==1.16.0 tomli==2.0.1 -tomlkit==0.11.8 +tomlkit==0.12.1 typing_extensions==4.7.1 uritemplate==4.1.1 # urllib3==2.0.4 diff --git a/misc/requirements/requirements-pyroma.txt b/misc/requirements/requirements-pyroma.txt index 50078baeb..f574b4c26 100644 --- a/misc/requirements/requirements-pyroma.txt +++ b/misc/requirements/requirements-pyroma.txt @@ -6,7 +6,7 @@ charset-normalizer==3.2.0 docutils==0.20.1 idna==3.4 packaging==23.1 -Pygments==2.15.1 +Pygments==2.16.1 pyproject_hooks==1.0.0 pyroma==4.2 requests==2.31.0 diff --git a/misc/requirements/requirements-sphinx.txt b/misc/requirements/requirements-sphinx.txt index 35b0e6257..3557eab6c 100644 --- a/misc/requirements/requirements-sphinx.txt +++ b/misc/requirements/requirements-sphinx.txt @@ -11,11 +11,11 @@ importlib-metadata==6.8.0 Jinja2==3.1.2 MarkupSafe==2.1.3 packaging==23.1 -Pygments==2.15.1 +Pygments==2.16.1 pytz==2023.3 requests==2.31.0 snowballstemmer==2.2.0 -Sphinx==7.0.1 +Sphinx==7.1.2 sphinxcontrib-applehelp==1.0.4 sphinxcontrib-devhelp==1.0.2 sphinxcontrib-htmlhelp==2.0.1 diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 9b99a577a..abd6ea727 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -13,23 +13,23 @@ execnet==2.0.2 filelock==3.12.2 Flask==2.3.2 hunter==3.6.1 -hypothesis==6.82.0 +hypothesis==6.82.2 idna==3.4 importlib-metadata==6.8.0 iniconfig==2.0.0 itsdangerous==2.1.2 -jaraco.functools==3.8.0 +jaraco.functools==3.8.1 # Jinja2==3.1.2 Mako==1.2.4 manhole==1.8.0 # MarkupSafe==2.1.3 -more-itertools==9.1.0 +more-itertools==10.1.0 packaging==23.1 parse==1.19.1 parse-type==0.6.2 pluggy==1.2.0 py-cpuinfo==9.0.0 -Pygments==2.15.1 +Pygments==2.16.1 pytest==7.4.0 pytest-bdd==6.1.1 pytest-benchmark==4.0.0 diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt index a4c7c4948..ae8fce6ff 100644 --- a/misc/requirements/requirements-tox.txt +++ b/misc/requirements/requirements-tox.txt @@ -1,17 +1,17 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py cachetools==5.3.1 -chardet==5.1.0 +chardet==5.2.0 colorama==0.4.6 distlib==0.3.7 filelock==3.12.2 packaging==23.1 pip==23.2.1 -platformdirs==3.9.1 +platformdirs==3.10.0 pluggy==1.2.0 pyproject-api==1.5.3 setuptools==68.0.0 tomli==2.0.1 tox==4.6.4 -virtualenv==20.24.1 -wheel==0.41.0 +virtualenv==20.24.2 +wheel==0.41.1 diff --git a/misc/requirements/requirements-yamllint.txt b/misc/requirements/requirements-yamllint.txt index a35c0ff58..fd9ea256f 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.11.1 +pathspec==0.11.2 PyYAML==6.0.1 yamllint==1.32.0 diff --git a/qutebrowser/api/cmdutils.py b/qutebrowser/api/cmdutils.py index 067ebca92..0c367c6bf 100644 --- a/qutebrowser/api/cmdutils.py +++ b/qutebrowser/api/cmdutils.py @@ -35,7 +35,7 @@ Possible values: import inspect -from typing import Any, Callable, Iterable +from typing import Any, Callable, Iterable, Protocol, Optional, Dict, cast from qutebrowser.utils import qtutils from qutebrowser.commands import command, cmdexc @@ -90,7 +90,21 @@ def check_exclusive(flags: Iterable[bool], names: Iterable[str]) -> None: raise CommandError("Only one of {} can be given!".format(argstr)) -_CmdHandlerType = Callable[..., Any] +_CmdHandlerFunc = Callable[..., Any] + + +class _CmdHandlerType(Protocol): + + """A qutebrowser command function, which had qute_args patched on it. + + Applying @cmdutils.argument to a function will patch it with a qute_args attribute. + Below, we cast the decorated function to _CmdHandlerType to make mypy aware of this. + """ + + qute_args: Optional[Dict[str, command.ArgInfo]] + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + ... class register: # noqa: N801,N806 pylint: disable=invalid-name @@ -118,7 +132,7 @@ class register: # noqa: N801,N806 pylint: disable=invalid-name # The arguments to pass to Command. self._kwargs = kwargs - def __call__(self, func: _CmdHandlerType) -> _CmdHandlerType: + def __call__(self, func: _CmdHandlerFunc) -> _CmdHandlerType: """Register the command before running the function. Gets called when a function should be decorated. @@ -158,7 +172,8 @@ class register: # noqa: N801,N806 pylint: disable=invalid-name # This is checked by future @cmdutils.argument calls so they fail # (as they'd be silently ignored otherwise) - func.qute_args = None # type: ignore[attr-defined] + func = cast(_CmdHandlerType, func) + func.qute_args = None return func @@ -210,19 +225,21 @@ class argument: # noqa: N801,N806 pylint: disable=invalid-name self._argname = argname # The name of the argument to handle. self._kwargs = kwargs # Valid ArgInfo members. - def __call__(self, func: _CmdHandlerType) -> _CmdHandlerType: + def __call__(self, func: _CmdHandlerFunc) -> _CmdHandlerType: funcname = func.__name__ if self._argname not in inspect.signature(func).parameters: raise ValueError("{} has no argument {}!".format(funcname, self._argname)) + + func = cast(_CmdHandlerType, func) if not hasattr(func, 'qute_args'): - func.qute_args = {} # type: ignore[attr-defined] + func.qute_args = {} elif func.qute_args is None: raise ValueError("@cmdutils.argument got called above (after) " "@cmdutils.register for {}!".format(funcname)) arginfo = command.ArgInfo(**self._kwargs) - func.qute_args[self._argname] = arginfo # type: ignore[attr-defined] + func.qute_args[self._argname] = arginfo return func diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index b6cc303cf..442628717 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: from qutebrowser.keyinput import modeman from qutebrowser.config import config, websettings from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils, - urlutils, message, jinja) + urlutils, message, jinja, version) from qutebrowser.misc import miscwidgets, objects, sessions from qutebrowser.browser import eventfilter, inspector from qutebrowser.qt import sip @@ -1169,6 +1169,23 @@ class AbstractTab(QWidget): navigation.url.errorString())) navigation.accepted = False + # WORKAROUND for QtWebEngine >= 6.3 not allowing form requests from + # qute:// to outside domains. + if ( + self.url() == QUrl("qute://start/") and + navigation.navigation_type == navigation.Type.form_submitted and + navigation.url.matches( + QUrl(config.val.url.searchengines['DEFAULT']), + urlutils.FormatOption.REMOVE_QUERY) and + objects.backend == usertypes.Backend.QtWebEngine and + version.qtwebengine_versions().webengine >= utils.VersionNumber(6, 3) + ): + log.webview.debug( + "Working around qute://start loading issue for " + f"{navigation.url.toDisplayString()}") + navigation.accepted = False + self.load_url(navigation.url) + @pyqtSlot(bool) def _on_load_finished(self, ok: bool) -> None: assert self._widget is not None diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 7efb69511..83a846b85 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -1235,21 +1235,31 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) @cmdutils.argument('name', completion=miscmodels.quickmark) - def quickmark_del(self, name=None): + def quickmark_del(self, name=None, all_=False): """Delete a quickmark. Args: name: The name of the quickmark to delete. If not given, delete the quickmark for the current page (choosing one arbitrarily if there are more than one). + all_: Delete all quickmarks. """ quickmark_manager = objreg.get('quickmark-manager') + + if all_: + if name is not None: + raise cmdutils.CommandError("Cannot specify name and --all") + quickmark_manager.clear() + message.info("Quickmarks cleared.") + return + if name is None: url = self._current_url() try: name = quickmark_manager.get_by_qurl(url) except urlmarks.DoesNotExistError as e: raise cmdutils.CommandError(str(e)) + try: quickmark_manager.delete(name) except KeyError: @@ -1320,18 +1330,28 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) @cmdutils.argument('url', completion=miscmodels.bookmark) - def bookmark_del(self, url=None): + def bookmark_del(self, url=None, all_=False): """Delete a bookmark. Args: url: The url of the bookmark to delete. If not given, use the current page's url. + all_: If given, delete all bookmarks. """ + bookmark_manager = objreg.get('bookmark-manager') + if all_: + if url is not None: + raise cmdutils.CommandError("Cannot specify url and --all") + bookmark_manager.clear() + message.info("Bookmarks cleared.") + return + if url is None: url = self._current_url().toString(QUrl.UrlFormattingOption.RemovePassword | QUrl.ComponentFormattingOption.FullyEncoded) + try: - objreg.get('bookmark-manager').delete(url) + bookmark_manager.delete(url) except KeyError: raise cmdutils.CommandError("Bookmark '{}' not found!".format(url)) message.info("Removed bookmark {}".format(url)) diff --git a/qutebrowser/browser/urlmarks.py b/qutebrowser/browser/urlmarks.py index 2a060f5ef..2d2563a1a 100644 --- a/qutebrowser/browser/urlmarks.py +++ b/qutebrowser/browser/urlmarks.py @@ -98,6 +98,11 @@ class UrlMarkManager(QObject): del self.marks[key] self.changed.emit() + def clear(self): + """Delete all marks.""" + self.marks.clear() + self.changed.emit() + class QuickmarkManager(UrlMarkManager): diff --git a/qutebrowser/browser/webengine/interceptor.py b/qutebrowser/browser/webengine/interceptor.py index ac0795803..161f5ffab 100644 --- a/qutebrowser/browser/webengine/interceptor.py +++ b/qutebrowser/browser/webengine/interceptor.py @@ -10,7 +10,7 @@ from qutebrowser.qt.webenginecore import (QWebEngineUrlRequestInterceptor, from qutebrowser.config import websettings, config from qutebrowser.browser import shared -from qutebrowser.utils import debug, log +from qutebrowser.utils import debug, log, qtutils from qutebrowser.extensions import interceptors from qutebrowser.misc import objects @@ -35,6 +35,11 @@ class WebEngineRequest(interceptors.Request): if self._webengine_info is None: raise interceptors.RedirectException("Request improperly initialized.") + try: + qtutils.ensure_valid(url) + except qtutils.QtValueError as e: + raise interceptors.RedirectException(f"Redirect to invalid URL: {e}") + # Redirecting a request that contains payload data is not allowed. # To be safe, abort on any request not in a whitelist. verb = self._webengine_info.requestMethod() diff --git a/qutebrowser/browser/webengine/notification.py b/qutebrowser/browser/webengine/notification.py index 2edb2d538..e8b2e27f1 100644 --- a/qutebrowser/browser/webengine/notification.py +++ b/qutebrowser/browser/webengine/notification.py @@ -113,6 +113,9 @@ class DBusError(Error): # https://crashes.qutebrowser.org/view/de62220a # after "Notification daemon did quit!" "org.freedesktop.DBus.Error.UnknownObject", + + # notmuch-sha1-ef7b6e9e79e5f2f6cba90224122288895c1fe0d8 + "org.freedesktop.DBus.Error.ServiceUnknown", } def __init__(self, msg: QDBusMessage) -> None: @@ -856,12 +859,15 @@ class DBusNotificationAdapter(AbstractNotificationAdapter): log.misc.debug(f"Enabling quirks {quirks}") self._quirks = quirks - expected_spec_version = self._quirks.spec_version or self.SPEC_VERSION - if spec_version != expected_spec_version: + expected_spec_versions = [self.SPEC_VERSION] + if self._quirks.spec_version is not None: + expected_spec_versions.append(self._quirks.spec_version) + + if spec_version not in expected_spec_versions: log.misc.warning( f"Notification server ({name} {ver} by {vendor}) implements " - f"spec {spec_version}, but {expected_spec_version} was expected. " - f"If {name} is up to date, please report a qutebrowser bug.") + f"spec {spec_version}, but {'/'.join(expected_spec_versions)} was " + f"expected. If {name} is up to date, please report a qutebrowser bug.") # https://specifications.freedesktop.org/notification-spec/latest/ar01s08.html icon_key_overrides = { diff --git a/qutebrowser/browser/webengine/webengineelem.py b/qutebrowser/browser/webengine/webengineelem.py index d383c1aa7..20c3a36d4 100644 --- a/qutebrowser/browser/webengine/webengineelem.py +++ b/qutebrowser/browser/webengine/webengineelem.py @@ -11,7 +11,7 @@ from qutebrowser.qt.core import QRect, QEventLoop from qutebrowser.qt.widgets import QApplication from qutebrowser.qt.webenginecore import QWebEngineSettings -from qutebrowser.utils import log, javascript, urlutils, usertypes, utils +from qutebrowser.utils import log, javascript, urlutils, usertypes, utils, version from qutebrowser.browser import webelem if TYPE_CHECKING: @@ -213,6 +213,17 @@ class WebEngineElement(webelem.AbstractWebElement): return True if baseurl.scheme() == url.scheme(): # e.g. a qute:// link return False + + # Qt 6.3+ needs a user interaction to allow navigations from qute:// to + # outside qute:// (like e.g. on qute://bookmarks). + versions = version.qtwebengine_versions() + if ( + baseurl.scheme() == "qute" and + url.scheme() != "qute" and + versions.webengine >= utils.VersionNumber(6, 3) + ): + return True + return url.scheme() not in urlutils.WEBENGINE_SCHEMES def _click_editable(self, click_target: usertypes.ClickTarget) -> None: diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 85f683133..98cb67cb2 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -316,6 +316,8 @@ class TabbedBrowser(QWidget): fields['id'] = self._win_id title = title_format.format(**fields) + # prevent hanging WMs and similar issues with giant URLs + title = utils.elide(title, 1024) self._window().setWindowTitle(title) diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index c347ae53b..0b571946d 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -39,6 +39,7 @@ if machinery.IS_QT6: REMOVE_SCHEME = QUrl.UrlFormattingOption.RemoveScheme REMOVE_PASSWORD = QUrl.UrlFormattingOption.RemovePassword + REMOVE_QUERY = QUrl.UrlFormattingOption.RemoveQuery else: UrlFlagsType = Union[ QUrl.FormattingOptions, @@ -74,6 +75,8 @@ else: _QtFormattingOptions, QUrl.UrlFormattingOption.RemoveScheme) REMOVE_PASSWORD = cast( _QtFormattingOptions, QUrl.UrlFormattingOption.RemovePassword) + REMOVE_QUERY = cast( + _QtFormattingOptions, QUrl.UrlFormattingOption.RemoveQuery) # URL schemes supported by QtWebEngine diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index fdaa12efb..ce816b9fd 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -381,7 +381,6 @@ class ModuleInfo: def _create_module_info() -> Dict[str, ModuleInfo]: packages = [ - ('sip', ['SIP_VERSION_STR']), ('colorama', ['VERSION', '__version__']), ('jinja2', ['__version__']), ('pygments', ['__version__']), @@ -395,9 +394,13 @@ def _create_module_info() -> Dict[str, ModuleInfo]: ('PyQt5.QtWebEngineWidgets', []), ('PyQt5.QtWebEngine', ['PYQT_WEBENGINE_VERSION_STR']), ('PyQt5.QtWebKitWidgets', []), + ('PyQt5.sip', ['SIP_VERSION_STR']), ] elif machinery.IS_QT6: - packages.append(('PyQt6.QtWebEngineCore', ['PYQT_WEBENGINE_VERSION_STR'])) + packages += [ + ('PyQt6.QtWebEngineCore', ['PYQT_WEBENGINE_VERSION_STR']), + ('PyQt6.sip', ['SIP_VERSION_STR']), + ] else: raise utils.Unreachable() diff --git a/requirements.txt b/requirements.txt index b5bab3296..f10ab6f9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ colorama==0.4.6 importlib-resources==6.0.0 ; python_version=="3.8.*" Jinja2==3.1.2 MarkupSafe==2.1.3 -Pygments==2.15.1 +Pygments==2.16.1 PyYAML==6.0.1 zipp==3.16.2 # Unpinned due to recompile_requirements.py limitations diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index f602cae43..4c443136f 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -282,7 +282,7 @@ def build_mac( gh_token=gh_token) utils.print_title("Building .app via pyinstaller") - call_tox(f'pyinstaller-64bit{"-qt5" if qt5 else ""}', '-r', debug=debug) + call_tox(f'pyinstaller{"-qt5" if qt5 else ""}', '-r', debug=debug) utils.print_title("Verifying .app") verify_mac_app() @@ -328,18 +328,14 @@ def build_mac( ] -def _get_windows_python_path(x64: bool) -> pathlib.Path: +def _get_windows_python_path() -> pathlib.Path: """Get the path to Python.exe on Windows.""" parts = str(sys.version_info.major), str(sys.version_info.minor) ver = ''.join(parts) dot_ver = '.'.join(parts) - if x64: - path = rf'SOFTWARE\Python\PythonCore\{dot_ver}\InstallPath' - fallback = pathlib.Path('C:', f'Python{ver}', 'python.exe') - else: - path = rf'SOFTWARE\WOW6432Node\Python\PythonCore\{dot_ver}-32\InstallPath' - fallback = pathlib.Path('C:', f'Python{ver}-32', 'python.exe') + path = rf'SOFTWARE\Python\PythonCore\{dot_ver}\InstallPath' + fallback = pathlib.Path('C:', f'Python{ver}', 'python.exe') try: key = winreg.OpenKeyEx(winreg.HKEY_LOCAL_MACHINE, path) @@ -349,47 +345,39 @@ def _get_windows_python_path(x64: bool) -> pathlib.Path: def _build_windows_single( - *, x64: bool, + *, qt5: bool, skip_packaging: bool, debug: bool, ) -> List[Artifact]: - """Build on Windows for a single architecture.""" - human_arch = '64-bit' if x64 else '32-bit' - utils.print_title(f"Running pyinstaller {human_arch}") + """Build on Windows for a single build type.""" + utils.print_title("Running pyinstaller") dist_path = pathlib.Path("dist") - arch = "x64" if x64 else "x86" - out_path = dist_path / f'qutebrowser-{qutebrowser.__version__}-{arch}' + out_path = dist_path / f'qutebrowser-{qutebrowser.__version__}' _maybe_remove(out_path) - python = _get_windows_python_path(x64=x64) - suffix = "64bit" if x64 else "32bit" - if qt5: - # FIXME:qt6 does this regress 391623d5ec983ecfc4512c7305c4b7a293ac3872? - suffix += "-qt5" - call_tox(f'pyinstaller-{suffix}', '-r', python=python, debug=debug) + 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) out_pyinstaller = dist_path / "qutebrowser" shutil.move(out_pyinstaller, out_path) exe_path = out_path / 'qutebrowser.exe' - utils.print_title(f"Verifying {human_arch} exe") + utils.print_title("Verifying exe") verify_windows_exe(exe_path) - utils.print_title(f"Running {human_arch} smoke test") + utils.print_title("Running smoke test") smoke_test(exe_path, debug=debug, qt5=qt5) if skip_packaging: return [] - utils.print_title(f"Packaging {human_arch}") + utils.print_title("Packaging") return _package_windows_single( - nsis_flags=[] if x64 else ['/DX86'], out_path=out_path, - filename_arch='amd64' if x64 else 'win32', - desc_arch=human_arch, - desc_suffix='' if x64 else ' (only for 32-bit Windows!)', debug=debug, qt5=qt5, ) @@ -398,8 +386,6 @@ def _build_windows_single( def build_windows( *, gh_token: str, skip_packaging: bool, - only_32bit: bool, - only_64bit: bool, qt5: bool, debug: bool, ) -> List[Artifact]: @@ -410,37 +396,23 @@ def build_windows( utils.print_title("Building Windows binaries") - artifacts = [] - from scripts.dev import gen_versioninfo utils.print_title("Updating VersionInfo file") gen_versioninfo.main() - if not only_32bit: - artifacts += _build_windows_single( - x64=True, - skip_packaging=skip_packaging, - debug=debug, - qt5=qt5, - ) - if not only_64bit and qt5: - artifacts += _build_windows_single( - x64=False, + artifacts = [ + _build_windows_single( skip_packaging=skip_packaging, debug=debug, qt5=qt5, - ) - + ), + ] return artifacts def _package_windows_single( *, - nsis_flags: List[str], out_path: pathlib.Path, - desc_arch: str, - desc_suffix: str, - filename_arch: str, debug: bool, qt5: bool, ) -> List[Artifact]: @@ -448,15 +420,14 @@ def _package_windows_single( artifacts = [] dist_path = pathlib.Path("dist") - utils.print_subtitle(f"Building {desc_arch} installer...") + utils.print_subtitle("Building installer...") subprocess.run(['makensis.exe', - f'/DVERSION={qutebrowser.__version__}', *nsis_flags, + f'/DVERSION={qutebrowser.__version__}', 'misc/nsis/qutebrowser.nsi'], check=True) name_parts = [ 'qutebrowser', str(qutebrowser.__version__), - filename_arch, ] if debug: name_parts.append('debug') @@ -467,16 +438,15 @@ def _package_windows_single( artifacts.append(Artifact( path=dist_path / name, mimetype='application/vnd.microsoft.portable-executable', - description=f'Windows {desc_arch} installer{desc_suffix}', + description='Windows installer', )) - utils.print_subtitle(f"Zipping {desc_arch} standalone...") + utils.print_subtitle("Zipping standalone...") zip_name_parts = [ 'qutebrowser', str(qutebrowser.__version__), 'windows', 'standalone', - filename_arch, ] if debug: zip_name_parts.append('debug') @@ -489,7 +459,7 @@ def _package_windows_single( artifacts.append(Artifact( path=zip_path, mimetype='application/zip', - description=f'Windows {desc_arch} standalone{desc_suffix}', + description='Windows standalone', )) return artifacts @@ -660,10 +630,6 @@ def main() -> None: help="Skip confirmation before uploading.") parser.add_argument('--skip-packaging', action='store_true', required=False, help="Skip Windows installer/zip generation or macOS DMG.") - parser.add_argument('--32bit', action='store_true', required=False, - help="Skip Windows 64 bit build.", dest='only_32bit') - parser.add_argument('--64bit', action='store_true', required=False, - help="Skip Windows 32 bit build.", dest='only_64bit') parser.add_argument('--debug', action='store_true', required=False, help="Build a debug build.") parser.add_argument('--qt5', action='store_true', required=False, @@ -694,8 +660,6 @@ def main() -> None: artifacts = build_windows( gh_token=gh_token, skip_packaging=args.skip_packaging, - only_32bit=args.only_32bit, - only_64bit=args.only_64bit, qt5=args.qt5, debug=args.debug, ) diff --git a/tests/end2end/features/qutescheme.feature b/tests/end2end/features/qutescheme.feature index 85f68661a..76686162c 100644 --- a/tests/end2end/features/qutescheme.feature +++ b/tests/end2end/features/qutescheme.feature @@ -300,3 +300,14 @@ Feature: Special qute:// pages Scenario: Open qute://gpl When I open qute://gpl Then the page should contain the plaintext "GNU GENERAL PUBLIC LICENSE" + + # qute://start + + Scenario: Seaching on qute://start + When I set url.searchengines to {"DEFAULT": "http://localhost:(port)/data/title.html?q={}"} + And I open qute://start + And I run :click-element id search-field + And I wait for "Entering mode KeyMode.insert *" in the log + And I press the keys "test" + And I press the key "<Enter>" + Then data/title.html?q=test should be loaded diff --git a/tests/end2end/features/test_urlmarks_bdd.py b/tests/end2end/features/test_urlmarks_bdd.py index 1b21098cd..6d4172085 100644 --- a/tests/end2end/features/test_urlmarks_bdd.py +++ b/tests/end2end/features/test_urlmarks_bdd.py @@ -4,6 +4,7 @@ import os.path +import pytest import pytest_bdd as bdd from helpers import testutils @@ -11,6 +12,14 @@ from helpers import testutils bdd.scenarios('urlmarks.feature') +@pytest.fixture(autouse=True) +def clear_marks(quteproc): + """Clear all existing marks between tests.""" + yield + quteproc.send_cmd(':quickmark-del --all') + quteproc.send_cmd(':bookmark-del --all') + + def _check_marks(quteproc, quickmarks, expected, contains): """Make sure the given line does (not) exist in the bookmarks. diff --git a/tests/end2end/features/urlmarks.feature b/tests/end2end/features/urlmarks.feature index 00fd14fb6..1c97ec322 100644 --- a/tests/end2end/features/urlmarks.feature +++ b/tests/end2end/features/urlmarks.feature @@ -86,6 +86,23 @@ Feature: quickmarks and bookmarks And I run :bookmark-del http://localhost:(port)/data/numbers/5.txt Then the bookmark file should not contain "http://localhost:*/data/numbers/5.txt " + Scenario: Deleting all bookmarks + When I open data/numbers/1.txt + And I run :bookmark-add + And I open data/numbers/2.txt + And I run :bookmark-add + And I run :bookmark-del --all + Then the message "Bookmarks cleared." should be shown + And the bookmark file should not contain "http://localhost:*/data/numbers/1.txt" + And the bookmark file should not contain "http://localhost:*/data/numbers/2.txt" + + Scenario: Deleting all bookmarks with url + When I open data/numbers/1.txt + And I run :bookmark-add + And I run :bookmark-del --all https://example.org + Then the error "Cannot specify url and --all" should be shown + And the bookmark file should contain "http://localhost:*/data/numbers/1.txt" + Scenario: Deleting the current page's bookmark if it doesn't exist When I open data/hello.txt And I run :bookmark-del @@ -210,6 +227,20 @@ Feature: quickmarks and bookmarks And I run :quickmark-del eighteen Then the quickmark file should not contain "eighteen http://localhost:*/data/numbers/18.txt " + Scenario: Deleting all quickmarks + When I run :quickmark-add http://localhost:(port)/data/numbers/1.txt one + When I run :quickmark-add http://localhost:(port)/data/numbers/2.txt two + And I run :quickmark-del --all + Then the message "Quickmarks cleared." should be shown + And the quickmark file should not contain "one http://localhost:*/data/numbers/1.txt" + And the quickmark file should not contain "two http://localhost:*/data/numbers/2.txt" + + Scenario: Deleting all quickmarks with name + When I run :quickmark-add http://localhost:(port)/data/numbers/1.txt one + And I run :quickmark-del --all invalid + Then the error "Cannot specify name and --all" should be shown + And the quickmark file should contain "one http://localhost:*/data/numbers/1.txt" + Scenario: Deleting the current page's quickmark if it has none When I open data/hello.txt And I run :quickmark-del @@ -233,3 +264,10 @@ Feature: quickmarks and bookmarks And I run :bookmark-add And I open qute://bookmarks Then the page should contain the plaintext "Test title" + + Scenario: Following a bookmark + When I open data/numbers/1.txt in a new tab + And I run :bookmark-add + And I open qute://bookmarks + And I hint with args "links current" and follow a + Then data/numbers/1.txt should be loaded diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index e31aa3ecb..af81781f6 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -937,6 +937,7 @@ def test_restart(request, quteproc_new): # If the new process hangs, this will hang too. # Still better than just ignoring it, so we can fix it if something is broken. os.waitpid(pid, 0) # pid, options... positional-only :( - except ChildProcessError: - # Already gone + except (ChildProcessError, PermissionError): + # Already gone. Even if not documented, Windows seems to raise PermissionError + # here... pass diff --git a/tests/helpers/testutils.py b/tests/helpers/testutils.py index f6d7fcc4b..dc6fdac32 100644 --- a/tests/helpers/testutils.py +++ b/tests/helpers/testutils.py @@ -150,7 +150,7 @@ def partial_compare(val1, val2, *, indent=0): if val2 is Ellipsis: print_i("Ignoring ellipsis comparison", indent, error=True) return PartialCompareOutcome() - elif type(val1) != type(val2): # pylint: disable=unidiomatic-typecheck + elif type(val1) is not type(val2): outcome = PartialCompareOutcome( "Different types ({}, {}) -> False".format(type(val1).__name__, type(val2).__name__)) diff --git a/tests/unit/browser/webengine/test_webengineinterceptor.py b/tests/unit/browser/webengine/test_webengineinterceptor.py index d579d60f7..099ba69d7 100644 --- a/tests/unit/browser/webengine/test_webengineinterceptor.py +++ b/tests/unit/browser/webengine/test_webengineinterceptor.py @@ -6,12 +6,15 @@ import pytest +import pytest_mock -pytest.importorskip('qutebrowser.qt.webenginecore') +pytest.importorskip("qutebrowser.qt.webenginecore") +from qutebrowser.qt.core import QUrl, QByteArray from qutebrowser.qt.webenginecore import QWebEngineUrlRequestInfo from qutebrowser.browser.webengine import interceptor +from qutebrowser.extensions import interceptors from qutebrowser.utils import qtutils from helpers import testutils @@ -19,10 +22,12 @@ from helpers import testutils def test_no_missing_resource_types(): request_interceptor = interceptor.RequestInterceptor() qb_keys = set(request_interceptor._resource_types.keys()) - qt_keys = set(testutils.enum_members( - QWebEngineUrlRequestInfo, - QWebEngineUrlRequestInfo.ResourceType, - ).values()) + qt_keys = set( + testutils.enum_members( + QWebEngineUrlRequestInfo, + QWebEngineUrlRequestInfo.ResourceType, + ).values() + ) assert qt_keys == qb_keys @@ -30,3 +35,85 @@ def test_resource_type_values(): request_interceptor = interceptor.RequestInterceptor() for qt_value, qb_item in request_interceptor._resource_types.items(): assert qtutils.extract_enum_val(qt_value) == qb_item.value + + +@pytest.fixture +def we_request( # a shrubbery! + mocker: pytest_mock.MockerFixture, +) -> interceptor.WebEngineRequest: + qt_info = mocker.Mock(spec=QWebEngineUrlRequestInfo) + qt_info.requestMethod.return_value = QByteArray(b"GET") + first_party_url = QUrl("https://firstparty.example.org/") + request_url = QUrl("https://request.example.org/") + return interceptor.WebEngineRequest( + first_party_url=first_party_url, + request_url=request_url, + webengine_info=qt_info, + ) + + +def test_block(we_request: interceptor.WebEngineRequest): + assert not we_request.is_blocked + we_request.block() + assert we_request.is_blocked + + +class TestRedirect: + REDIRECT_URL = QUrl("https://redirect.example.com/") + + def test_redirect(self, we_request: interceptor.WebEngineRequest): + assert not we_request._redirected + we_request.redirect(self.REDIRECT_URL) + assert we_request._redirected + we_request._webengine_info.redirect.assert_called_once_with(self.REDIRECT_URL) + + def test_twice(self, we_request: interceptor.WebEngineRequest): + we_request.redirect(self.REDIRECT_URL) + with pytest.raises( + interceptors.RedirectException, + match=r"Request already redirected.", + ): + we_request.redirect(self.REDIRECT_URL) + we_request._webengine_info.redirect.assert_called_once_with(self.REDIRECT_URL) + + def test_invalid_method(self, we_request: interceptor.WebEngineRequest): + we_request._webengine_info.requestMethod.return_value = QByteArray(b"POST") + with pytest.raises( + interceptors.RedirectException, + match=( + r"Request method b'POST' for https://request.example.org/ does not " + r"support redirection." + ), + ): + we_request.redirect(self.REDIRECT_URL) + assert not we_request._webengine_info.redirect.called + + def test_invalid_method_ignore_unsupported( + self, + we_request: interceptor.WebEngineRequest, + caplog: pytest.LogCaptureFixture, + ): + we_request._webengine_info.requestMethod.return_value = QByteArray(b"POST") + we_request.redirect(self.REDIRECT_URL, ignore_unsupported=True) + assert caplog.messages == [ + "Request method b'POST' for https://request.example.org/ does not support " + "redirection." + ] + assert not we_request._webengine_info.redirect.called + + def test_improperly_initialized(self, we_request: interceptor.WebEngineRequest): + we_request._webengine_info = None + with pytest.raises( + interceptors.RedirectException, + match=r"Request improperly initialized.", + ): + we_request.redirect(self.REDIRECT_URL) + + def test_invalid_url(self, we_request: interceptor.WebEngineRequest): + url = QUrl() + assert not url.isValid() + with pytest.raises( + interceptors.RedirectException, + match=r"Redirect to invalid URL: PyQt\d\.QtCore\.QUrl\(''\) is not valid", + ): + we_request.redirect(url) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index f78a6f12d..486270d70 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -644,8 +644,8 @@ class TestModuleVersions: assert version._module_versions() == expected @pytest.mark.parametrize('module, idx, expected', [ - ('colorama', 1, 'colorama: no'), - ('adblock', 5, 'adblock: no'), + ('colorama', 0, 'colorama: no'), + ('adblock', 4, 'adblock: no'), ]) def test_missing_module(self, module, idx, expected, import_fake): """Test with a module missing. @@ -693,11 +693,11 @@ class TestModuleVersions: assert not mod_info.is_usable() expected = f"adblock: {fake_version} (< {mod_info.min_version}, outdated)" - assert version._module_versions()[5] == expected + assert version._module_versions()[4] == expected @pytest.mark.parametrize('attribute, expected_modules', [ ('VERSION', ['colorama']), - ('SIP_VERSION_STR', ['sip']), + ('SIP_VERSION_STR', ['PyQt5.sip', 'PyQt6.sip']), (None, []), ]) def test_version_attribute(self, attribute, expected_modules, import_fake): @@ -181,7 +181,7 @@ commands = {envpython} scripts/dev/check_doc_changes.py {posargs} {envpython} scripts/asciidoc2html.py {posargs} -[testenv:pyinstaller-{64bit,32bit}{,-qt5}] +[testenv:pyinstaller{,-qt5}] basepython = {env:PYTHON:python3} passenv = APPDATA @@ -282,3 +282,12 @@ deps = commands = !qt5: {envpython} {toxinidir}/scripts/dev/build_release.py {posargs} qt5: {envpython} {toxinidir}/scripts/dev/build_release.py --qt5 {posargs} + +[testenv:package] +basepython = {env:PYTHON:python3} +setenv = + PYTHONWARNINGS=error,default:pkg_resources is deprecated as an API.:DeprecationWarning +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/misc/requirements/requirements-dev.txt +commands = {envpython} -m build |