diff --git a/.travis.yml b/.travis.yml index 50f431626..017228385 100644 --- a/.travis.yml +++ b/.travis.yml @@ -43,8 +43,9 @@ matrix: packages: - libxkbcommon-x11-0 - ### PyQt 5.13 (Python 3.7, with coverage) - - env: TESTENV=py37-pyqt513-cov + ### PyQt 5.13 (Python 3.8, with coverage) + - env: TESTENV=py38-pyqt513-cov + python: 3.8-dev # http://code.qt.io/cgit/qt/qtbase.git/commit/?id=c3a963da1f9e7b1d37e63eedded61da4fbdaaf9a addons: apt: diff --git a/README.asciidoc b/README.asciidoc index 607108e96..dac18adc2 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -143,7 +143,13 @@ If you want to give me a beer or a pizza back, I'm trying to make it as easy as possible for you to do so. If some other way would be easier for you, please get in touch! -* SEPA bank transfer inside Europe (no fee): Contact me at mail@qutebrowser.org for details +* SEPA bank transfer inside Europe (no fee): + - Account holder: Florian Bruhin + - Country: Switzerland + - IBAN (EUR): CH13 0900 0000 9160 4094 6 + - IBAN (other): CH80 0900 0000 8711 8587 3 + - Bank: PostFinance AG, Mingerstrasse 20, 3030 Bern, Switzerland (BIC: POFICHBEXXX) + - If you need any other information: Contact me at mail@qutebrowser.org. * PayPal: https://www.paypal.me/thecompiler[thecompiler] / me@the-compiler.org * Bitcoin: link:bitcoin:1PMzbcetAHfpxoXww8Bj5XqquHtVvMjJtE[1PMzbcetAHfpxoXww8Bj5XqquHtVvMjJtE] diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 607eb4df8..5cff4577e 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -18,10 +18,32 @@ breaking changes (such as renamed commands) can happen in minor releases. v1.9.0 (unreleased) ------------------- +Changed +~~~~~~~ + +- The `qute-pass` userscript now has a new `--extra-url-suffixes` (`-s`) + argument which passes extra URL suffixes to the tldextract library. + Fixed ~~~~~ - dictcli.py now works correctly on Windows again. +- Unbinding keys via `config.bind(key, None)` accidentally worked in + v1.7.0 but raises an exception in v1.8.0. It now works again, but is + deprecated and shows an error. Note that `:config-py-write` did write + such invalid lines before v1.8.0, so existing config files might need + adjustments. +- The `readability-js` userscript now handles encodings correctly (which it + didn't before for some websites). +- New `tabs.tooltips` setting which can be used to disable hover tooltips for + tabs. +- can now be used to paste text starting with a hyphen. +- Following hints via the number keypad now works properly again. +- The logic for `:restart` has been revisited which should fix issues with + relative basedirs. +- Errors while reading the state file are now displayed instead of causing a + crash. +- Crash when using `:debug-log-level` without a console attached. v1.8.1 (2019-09-27) ------------------- diff --git a/doc/contributing.asciidoc b/doc/contributing.asciidoc index d2a4d9328..51e600578 100644 --- a/doc/contributing.asciidoc +++ b/doc/contributing.asciidoc @@ -183,17 +183,21 @@ tox -e py35-cov -- tests/unit/browser/test_webelem.py Profiling ~~~~~~~~~ -In the _scripts/_ subfolder there's a `run_profile.py` which profiles the code -and shows a graphical representation of what takes how much time. +In the _scripts/dev/_ subfolder there's `run_profile.py` which profiles the +code and shows a graphical representation of what takes how much time. It uses the built-in Python -https://docs.python.org/3.6/library/profile.html[cProfile] module and can show -the output in four different ways: +https://docs.python.org/3/library/profile.html[cProfile] module. It launches a +qutebrowser instance, waits for it to exit and then shows the graph. -* Raw profile file (`--profile-tool=none`) +Available methods for visualization are: + +* https://jiffyclub.github.io/snakeviz/[SnakeViz] (`--profile-tool=snakeviz`, the default) * https://pypi.python.org/pypi/pyprof2calltree/[pyprof2calltree] and http://kcachegrind.sourceforge.net/html/Home.html[KCacheGrind] (`--profile-tool=kcachegrind`) -* https://jiffyclub.github.io/snakeviz/[SnakeViz] (`--profile-tool=snakeviz`) -* https://github.com/jrfonseca/gprof2dot[gprof2dot] (needs `dot` from http://graphviz.org/[Graphviz] and http://feh.finalrewind.org/[feh]) +* https://github.com/jrfonseca/gprof2dot[gprof2dot] (`--profile-tool=gprof2dot`, needs `dot` from http://graphviz.org/[Graphviz] and http://feh.finalrewind.org/[feh]) +* https://github.com/nschloe/tuna[tuna] (`--profile-tool=tuna`) + +You can also save the binary profile data to a file (`--profile-tool=none`). Debugging ~~~~~~~~~ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index fe86b577b..7d51c6b48 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -282,6 +282,7 @@ |<>|Alignment of the text inside of tabs. |<>|Format to use for the tab title. |<>|Format to use for the tab title for pinned tabs. The same placeholders like for `tabs.title.format` are defined. +|<>|Show tooltips on tabs. |<>|Number of close tab actions to remember, per window (-1 for no maximum). |<>|Width (in pixels or as percentage of the window) of the tab bar if it's vertical. |<>|Wrap when changing tabs. @@ -487,7 +488,7 @@ Default: * +pass:[<Ctrl-E>]+: +pass:[open-editor]+ * +pass:[<Escape>]+: +pass:[leave-mode]+ -* +pass:[<Shift-Ins>]+: +pass:[insert-text {primary}]+ +* +pass:[<Shift-Ins>]+: +pass:[insert-text -- {primary}]+ - +pass:[normal]+: * +pass:[']+: +pass:[enter-mode jump_mark]+ @@ -3538,6 +3539,15 @@ Type: <> Default: +pass:[{index}]+ +[[tabs.tooltips]] +=== tabs.tooltips +Show tooltips on tabs. +Note this setting only affects windows opened after it has been set. + +Type: <> + +Default: +pass:[true]+ + [[tabs.undo_stack_size]] === tabs.undo_stack_size Number of close tab actions to remember, per window (-1 for no maximum). diff --git a/misc/requirements/requirements-dev.txt b/misc/requirements/requirements-dev.txt index 96c259059..4f431b72b 100644 --- a/misc/requirements/requirements-dev.txt +++ b/misc/requirements/requirements-dev.txt @@ -1,6 +1,6 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -asn1crypto==0.24.0 +asn1crypto==1.1.0 bump2version==0.5.11 certifi==2019.9.11 cffi==1.12.3 @@ -9,7 +9,7 @@ colorama==0.4.1 cryptography==2.7 cssutils==1.0.2 github3.py==1.3.0 -hunter==3.0.1 +hunter==3.0.3 idna==2.8 jwcrypto==0.6.0 lxml==4.4.1 diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt index dd8de2282..9efe25ebf 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -1,13 +1,13 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -attrs==19.1.0 +attrs==19.2.0 entrypoints==0.3 flake8==3.7.8 flake8-bugbear==19.8.0 flake8-builtins==1.4.1 flake8-comprehensions==2.2.0 flake8-copyright==0.2.2 -flake8-debugger==3.1.0 +flake8-debugger==3.1.1 flake8-deprecated==1.3 flake8-docstrings==1.5.0 flake8-future-import==0.4.6 @@ -22,4 +22,4 @@ pycodestyle==2.5.0 pydocstyle==4.0.1 pyflakes==2.1.1 six==1.12.0 -snowballstemmer==1.9.1 +snowballstemmer==2.0.0 diff --git a/misc/requirements/requirements-mypy.txt b/misc/requirements/requirements-mypy.txt index a3c7b9e8e..0b9beab8e 100644 --- a/misc/requirements/requirements-mypy.txt +++ b/misc/requirements/requirements-mypy.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py mypy==0.730 -mypy-extensions==0.4.1 +mypy-extensions==0.4.2 # PyQt5==5.11.3 # PyQt5-sip==4.19.19 -e git+https://github.com/qutebrowser/PyQt5-stubs.git@wip#egg=PyQt5_stubs diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt index 9be637f4f..4f75355ca 100644 --- a/misc/requirements/requirements-pip.txt +++ b/misc/requirements/requirements-pip.txt @@ -3,6 +3,6 @@ appdirs==1.4.3 packaging==19.2 pyparsing==2.4.2 -setuptools==41.2.0 +setuptools==41.4.0 six==1.12.0 wheel==0.33.6 diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt index 47c8fe74c..1f3c995a4 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -1,6 +1,6 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -asn1crypto==0.24.0 +asn1crypto==1.1.0 astroid==2.3.1 certifi==2019.9.11 cffi==1.12.3 diff --git a/misc/requirements/requirements-pyqt-5.10.txt b/misc/requirements/requirements-pyqt-5.10.txt index e098b3ac6..69c3ccbd0 100644 --- a/misc/requirements/requirements-pyqt-5.10.txt +++ b/misc/requirements/requirements-pyqt-5.10.txt @@ -1,4 +1,4 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py PyQt5==5.10.1 # rq.filter: < 5.11 -sip==4.19.8 +sip==4.19.8 # rq.filter: < 5 diff --git a/misc/requirements/requirements-pyqt-5.10.txt-raw b/misc/requirements/requirements-pyqt-5.10.txt-raw index fe16524d8..4fbea8575 100644 --- a/misc/requirements/requirements-pyqt-5.10.txt-raw +++ b/misc/requirements/requirements-pyqt-5.10.txt-raw @@ -1,2 +1,4 @@ #@ filter: PyQt5 < 5.11 PyQt5 >= 5.10, < 5.11 +#@ filter: sip < 5 +sip < 5 diff --git a/misc/requirements/requirements-pyqt-5.11.txt b/misc/requirements/requirements-pyqt-5.11.txt index f4be2fd88..0e3d2a07a 100644 --- a/misc/requirements/requirements-pyqt-5.11.txt +++ b/misc/requirements/requirements-pyqt-5.11.txt @@ -2,3 +2,4 @@ PyQt5==5.11.3 # rq.filter: < 5.12 PyQt5-sip==4.19.19 +sip==4.19.8 # rq.filter: < 5 diff --git a/misc/requirements/requirements-pyqt-5.11.txt-raw b/misc/requirements/requirements-pyqt-5.11.txt-raw index 40a81f952..347f1a472 100644 --- a/misc/requirements/requirements-pyqt-5.11.txt-raw +++ b/misc/requirements/requirements-pyqt-5.11.txt-raw @@ -1,2 +1,4 @@ #@ filter: PyQt5 < 5.12 PyQt5 >= 5.11, < 5.12 +#@ filter: sip < 5 +sip < 5 diff --git a/misc/requirements/requirements-pyqt-5.12.txt b/misc/requirements/requirements-pyqt-5.12.txt index dec26ca96..f09aa599f 100644 --- a/misc/requirements/requirements-pyqt-5.12.txt +++ b/misc/requirements/requirements-pyqt-5.12.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py PyQt5==5.12.3 # rq.filter: < 5.13 -PyQt5-sip==4.19.19 +PyQt5-sip==12.7.0 PyQtWebEngine==5.12.1 # rq.filter: < 5.13 diff --git a/misc/requirements/requirements-pyqt-5.13.txt b/misc/requirements/requirements-pyqt-5.13.txt index 3d2fe90a8..307e15030 100644 --- a/misc/requirements/requirements-pyqt-5.13.txt +++ b/misc/requirements/requirements-pyqt-5.13.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py PyQt5==5.13.0 # rq.filter: < 5.14, != 5.13.1 -PyQt5-sip==4.19.19 +PyQt5-sip==12.7.0 PyQtWebEngine==5.13.1 # rq.filter: < 5.14 diff --git a/misc/requirements/requirements-pyqt-5.7.txt b/misc/requirements/requirements-pyqt-5.7.txt index c3c24c208..703c95a92 100644 --- a/misc/requirements/requirements-pyqt-5.7.txt +++ b/misc/requirements/requirements-pyqt-5.7.txt @@ -1,4 +1,4 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py PyQt5==5.7.1 # rq.filter: < 5.8 -sip==4.19.8 +sip==4.19.8 # rq.filter: < 5 diff --git a/misc/requirements/requirements-pyqt-5.7.txt-raw b/misc/requirements/requirements-pyqt-5.7.txt-raw index 5127ce517..745deb4b9 100644 --- a/misc/requirements/requirements-pyqt-5.7.txt-raw +++ b/misc/requirements/requirements-pyqt-5.7.txt-raw @@ -1,2 +1,4 @@ #@ filter: PyQt5 < 5.8 +#@ filter: sip < 5 PyQt5 >= 5.7, < 5.8 +sip < 5 diff --git a/misc/requirements/requirements-pyqt-5.9.txt b/misc/requirements/requirements-pyqt-5.9.txt index 549df8f39..8f3258721 100644 --- a/misc/requirements/requirements-pyqt-5.9.txt +++ b/misc/requirements/requirements-pyqt-5.9.txt @@ -1,4 +1,4 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py PyQt5==5.9.2 # rq.filter: < 5.10 -sip==4.19.8 +sip==4.19.8 # rq.filter: < 5 diff --git a/misc/requirements/requirements-pyqt-5.9.txt-raw b/misc/requirements/requirements-pyqt-5.9.txt-raw index f30dcda63..45d4e0c10 100644 --- a/misc/requirements/requirements-pyqt-5.9.txt-raw +++ b/misc/requirements/requirements-pyqt-5.9.txt-raw @@ -1,2 +1,4 @@ #@ filter: PyQt5 < 5.10 PyQt5 >= 5.9, < 5.10 +#@ filter: sip < 5 +sip < 5 diff --git a/misc/requirements/requirements-pyqt.txt b/misc/requirements/requirements-pyqt.txt index fb5e06925..6cc0eb5b0 100644 --- a/misc/requirements/requirements-pyqt.txt +++ b/misc/requirements/requirements-pyqt.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py PyQt5==5.13.0 # rq.filter: != 5.13.1 -PyQt5-sip==4.19.19 +PyQt5-sip==12.7.0 PyQtWebEngine==5.13.1 diff --git a/misc/requirements/requirements-sphinx.txt b/misc/requirements/requirements-sphinx.txt index b8715149b..2cc0d5c39 100644 --- a/misc/requirements/requirements-sphinx.txt +++ b/misc/requirements/requirements-sphinx.txt @@ -1,22 +1,21 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py alabaster==0.7.12 -attrs==19.1.0 Babel==2.7.0 certifi==2019.9.11 chardet==3.0.4 docutils==0.15.2 idna==2.8 imagesize==1.1.0 -Jinja2==2.10.1 +Jinja2==2.10.3 MarkupSafe==1.1.1 packaging==19.2 Pygments==2.4.2 pyparsing==2.4.2 -pytz==2019.2 +pytz==2019.3 requests==2.22.0 six==1.12.0 -snowballstemmer==1.9.1 +snowballstemmer==2.0.0 Sphinx==2.2.0 sphinxcontrib-applehelp==1.0.1 sphinxcontrib-devhelp==1.0.1 diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index f42d56dfc..0e85429da 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -1,21 +1,21 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py atomicwrites==1.3.0 -attrs==19.1.0 -backports.functools-lru-cache==1.5 -beautifulsoup4==4.8.0 -cheroot==7.0.0 +attrs==19.2.0 +beautifulsoup4==4.8.1 +cheroot==8.1.0 Click==7.0 # colorama==0.4.1 coverage==4.5.4 EasyProcess==0.2.7 Flask==1.1.1 glob2==0.7 -hunter==3.0.1 -hypothesis==4.37.0 +hunter==3.0.3 +hypothesis==4.40.0 importlib-metadata==0.23 itsdangerous==1.1.0 -# Jinja2==2.10.1 +jaraco.functools==2.0 +# Jinja2==2.10.3 Mako==1.1.0 manhole==1.6.0 # MarkupSafe==1.1.1 @@ -27,12 +27,12 @@ pluggy==0.13.0 py==1.8.0 py-cpuinfo==5.0.0 pyparsing==2.4.2 -pytest==5.2.0 +pytest==5.2.1 pytest-bdd==3.2.1 pytest-benchmark==3.2.2 -pytest-cov==2.7.1 +pytest-cov==2.8.1 pytest-instafail==0.4.1 -pytest-mock==1.11.0 +pytest-mock==1.11.1 pytest-qt==3.2.2 pytest-repeat==0.8.0 pytest-rerunfailures==7.0 diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt index 96cf1d2a3..885f58e5a 100644 --- a/misc/requirements/requirements-tox.txt +++ b/misc/requirements/requirements-tox.txt @@ -1,6 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -attrs==19.1.0 filelock==3.0.12 importlib-metadata==0.23 more-itertools==7.2.0 diff --git a/misc/userscripts/qute-keepass b/misc/userscripts/qute-keepass index 03f9152cc..485d83e05 100755 --- a/misc/userscripts/qute-keepass +++ b/misc/userscripts/qute-keepass @@ -228,7 +228,7 @@ def run(args): args.io_encoding) if selection not in candidates_map: - stderr("'{}' was not a valid entry!").format(selection) + stderr("'{}' was not a valid entry!".format(selection)) return ExitCodes.USER_QUIT selection = candidates_map[selection] diff --git a/misc/userscripts/qute-pass b/misc/userscripts/qute-pass index 4abad6de7..a22cd7187 100755 --- a/misc/userscripts/qute-pass +++ b/misc/userscripts/qute-pass @@ -72,6 +72,8 @@ argument_parser.add_argument('--io-encoding', '-i', default='UTF-8', help='Encoding used to communicate with subprocesses') argument_parser.add_argument('--merge-candidates', '-m', action='store_true', help='Merge pass candidates for fully-qualified and registered domain name') +argument_parser.add_argument('--extra-url-suffixes', '-s', default='', + help='Comma-separated string containing extra suffixes (e.g local)') group = argument_parser.add_mutually_exclusive_group() group.add_argument('--username-only', '-e', action='store_true', help='Only insert username') group.add_argument('--password-only', '-w', action='store_true', help='Only insert password') @@ -145,7 +147,8 @@ def main(arguments): argument_parser.print_help() return ExitCodes.FAILURE - extract_result = tldextract.extract(arguments.url) + extractor = tldextract.TLDExtract(extra_suffixes=arguments.extra_url_suffixes.split(',')) + extract_result = extractor(arguments.url) # Expand potential ~ in paths, since this script won't be called from a shell that does it for us password_store_path = os.path.expanduser(arguments.password_store) diff --git a/misc/userscripts/readability-js b/misc/userscripts/readability-js index 0c33453ad..efd086ce0 100755 --- a/misc/userscripts/readability-js +++ b/misc/userscripts/readability-js @@ -49,12 +49,13 @@ const HEADER = ` `; const scriptsDir = path.join(process.env.QUTE_DATA_DIR, 'userscripts'); const tmpFile = path.join(scriptsDir, '/readability.html'); +const domOpts = {url: process.env.QUTE_URL, contentType: "text/html; charset=utf-8"} if (!fs.existsSync(scriptsDir)){ fs.mkdirSync(scriptsDir); } -JSDOM.fromFile(process.env.QUTE_HTML, { url: process.env.QUTE_URL }).then(dom => { +JSDOM.fromFile(process.env.QUTE_HTML, domOpts).then(dom => { let reader = new Readability(dom.window.document); let article = reader.parse(); let content = util.format(HEADER, article.title) + article.content; diff --git a/mypy.ini b/mypy.ini index 8f5f8e1c6..c47e2f7eb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -58,10 +58,26 @@ disallow_subclassing_any = False disallow_untyped_defs = True disallow_incomplete_defs = True +[mypy-qutebrowser.browser.hints] +disallow_untyped_defs = True +disallow_incomplete_defs = True + [mypy-qutebrowser.misc.objects] disallow_untyped_defs = True disallow_incomplete_defs = True +[mypy-qutebrowser.misc.debugcachestats] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.misc.utilcmds] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.misc.throttle] +disallow_untyped_defs = True +disallow_incomplete_defs = True + [mypy-qutebrowser.commands.cmdutils] disallow_untyped_defs = True disallow_incomplete_defs = True @@ -93,3 +109,19 @@ disallow_incomplete_defs = True [mypy-qutebrowser.browser.webengine.webengineelem] disallow_untyped_defs = True disallow_incomplete_defs = True + +[mypy-qutebrowser.keyinput.*] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.utils.*] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.misc.backendproblem] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.mainwindow.statusbar.command] +disallow_untyped_defs = True +disallow_incomplete_defs = True diff --git a/qutebrowser/app.py b/qutebrowser/app.py index cf35f918f..831a5cca2 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -47,11 +47,13 @@ import tempfile import atexit import datetime import tokenize +import argparse +import typing from PyQt5.QtWidgets import QApplication, QWidget -from PyQt5.QtGui import QDesktopServices, QPixmap, QIcon, QWindow -from PyQt5.QtCore import (pyqtSlot, qInstallMessageHandler, QTimer, QUrl, - QObject, QEvent, pyqtSignal, Qt) +from PyQt5.QtGui import QDesktopServices, QPixmap, QIcon, QWindow, QKeyEvent +from PyQt5.QtCore import (pyqtSlot, QTimer, QUrl, QObject, QEvent, pyqtSignal, + Qt) try: import hunter except ImportError: @@ -83,7 +85,7 @@ from qutebrowser.misc import utilcmds # pylint: enable=unused-import -q_app = None +q_app = typing.cast(QApplication, None) def run(args): @@ -91,8 +93,9 @@ def run(args): if args.temp_basedir: args.basedir = tempfile.mkdtemp(prefix='qutebrowser-basedir-') - quitter = Quitter(args) - objreg.register('quitter', quitter) + quitter = Quitter(args=args) + objreg.register('quitter', quitter, command_only=True) + quitter.shutting_down.connect(log.shutdown_log) log.init.debug("Initializing directories...") standarddir.init(args) @@ -115,13 +118,14 @@ def run(args): crash_handler = crashsignal.CrashHandler( app=q_app, quitter=quitter, args=args, parent=q_app) + objreg.register('crash-handler', crash_handler, command_only=True) crash_handler.activate() - objreg.register('crash-handler', crash_handler) + quitter.shutting_down.connect(crash_handler.shutdown) signal_handler = crashsignal.SignalHandler(app=q_app, quitter=quitter, parent=q_app) signal_handler.activate() - objreg.register('signal-handler', signal_handler) + quitter.shutting_down.connect(signal_handler.deactivate) try: server = ipc.send_or_listen(args) @@ -136,11 +140,12 @@ def run(args): "Backend from the running instance will be used") sys.exit(usertypes.Exit.ok) else: + quitter.shutting_down.connect(server.shutdown) server.got_args.connect(lambda args, target_arg, cwd: process_pos_args(args, cwd=cwd, via_ipc=True, target_arg=target_arg)) - init(args, crash_handler) + init(args=args, crash_handler=crash_handler, quitter=quitter) ret = qt_mainloop() return ret @@ -154,24 +159,23 @@ def qt_mainloop(): return q_app.exec_() -def init(args, crash_handler): - """Initialize everything. - - Args: - args: The argparse namespace. - crash_handler: The CrashHandler instance. - """ +def init(*, args: argparse.Namespace, + crash_handler: crashsignal.CrashHandler, + quitter: 'Quitter'): + """Initialize everything.""" log.init.debug("Starting init...") crash_handler.init_faulthandler() q_app.setQuitOnLastWindowClosed(False) + quitter.shutting_down.connect(QApplication.closeAllWindows) + _init_icon() loader.init() loader.load_components() try: - _init_modules(args, crash_handler) + _init_modules(args=args, crash_handler=crash_handler, quitter=quitter) except (OSError, UnicodeDecodeError, browsertab.WebTabError) as e: error.handle_fatal_exc(e, args, "Error while initializing!", pre_text="Error while initializing") @@ -179,17 +183,17 @@ def init(args, crash_handler): log.init.debug("Initializing eventfilter...") event_filter = EventFilter(q_app) - q_app.installEventFilter(event_filter) - objreg.register('event-filter', event_filter) + event_filter.install() + quitter.shutting_down.connect(event_filter.shutdown) log.init.debug("Connecting signals...") - q_app.focusChanged.connect(on_focus_changed) + q_app.focusChanged.connect(on_focus_changed) # type: ignore _process_args(args) - QDesktopServices.setUrlHandler('http', open_desktopservices_url) - QDesktopServices.setUrlHandler('https', open_desktopservices_url) - QDesktopServices.setUrlHandler('qute', open_desktopservices_url) + for scheme in ['http', 'https', 'qute']: + QDesktopServices.setUrlHandler( + scheme, open_desktopservices_url) # type: ignore log.init.debug("Init done!") crash_handler.raise_crashdlg() @@ -217,8 +221,8 @@ def _process_args(args): """Open startpage etc. and process commandline args.""" if not args.override_restore: _load_session(args.session) - session_manager = objreg.get('session-manager') - if not session_manager.did_load: + + if not sessions.session_manager.did_load: log.init.debug("Initializing main window...") if config.val.content.private_browsing and qtutils.is_single_process(): err = Exception("Private windows are unavailable with " @@ -244,8 +248,7 @@ def _load_session(name): Args: name: The name of the session to load, or None to read state file. """ - session_manager = objreg.get('session-manager') - if name is None and session_manager.exists('_autosave'): + if name is None and sessions.session_manager.exists('_autosave'): name = '_autosave' elif name is None: try: @@ -256,7 +259,7 @@ def _load_session(name): return try: - session_manager.load(name) + sessions.session_manager.load(name) except sessions.SessionNotFoundError: message.error("Session {} not found!".format(name)) except sessions.SessionError as e: @@ -267,7 +270,7 @@ def _load_session(name): pass # If this was a _restart session, delete it. if name == '_restart': - session_manager.delete('_restart') + sessions.session_manager.delete('_restart') def process_pos_args(args, via_ipc=False, cwd=None, target_arg=None): @@ -425,20 +428,22 @@ def open_desktopservices_url(url): tabbed_browser.tabopen(url) -def _init_modules(args, crash_handler): +def _init_modules(*, args, crash_handler, quitter): """Initialize all 'modules' which need to be initialized. Args: args: The argparse namespace. crash_handler: The CrashHandler instance. + quitter: The Quitter instance. """ log.init.debug("Initializing save manager...") save_manager = savemanager.SaveManager(q_app) objreg.register('save-manager', save_manager) + quitter.shutting_down.connect(save_manager.shutdown) configinit.late_init(save_manager) log.init.debug("Checking backend requirements...") - backendproblem.init() + backendproblem.init(quitter=quitter, args=args, save_manager=save_manager) log.init.debug("Initializing prompts...") prompt.init() @@ -448,13 +453,15 @@ def _init_modules(args, crash_handler): log.init.debug("Initializing proxy...") proxy.init() + quitter.shutting_down.connect(proxy.shutdown) log.init.debug("Initializing downloads...") downloads.init() + quitter.shutting_down.connect(downloads.shutdown) log.init.debug("Initializing readline-bridge...") readline_bridge = readline.ReadlineBridge() - objreg.register('readline-bridge', readline_bridge) + objreg.register('readline-bridge', readline_bridge, command_only=True) try: log.init.debug("Initializing SQL...") @@ -471,9 +478,11 @@ def _init_modules(args, crash_handler): cmdhistory.init() log.init.debug("Initializing sessions...") sessions.init(q_app) + quitter.shutting_down.connect(sessions.shutdown) log.init.debug("Initializing websettings...") websettings.init(args) + quitter.shutting_down.connect(websettings.shutdown) if not args.no_err_windows: crash_handler.display_faulthandler() @@ -487,14 +496,10 @@ def _init_modules(args, crash_handler): objreg.register('bookmark-manager', bookmark_manager) log.init.debug("Initializing cookies...") - cookie_jar = cookies.CookieJar(q_app) - ram_cookie_jar = cookies.RAMCookieJar(q_app) - objreg.register('cookie-jar', cookie_jar) - objreg.register('ram-cookie-jar', ram_cookie_jar) + cookies.init(q_app) log.init.debug("Initializing cache...") - diskcache = cache.DiskCache(standarddir.cache(), parent=q_app) - objreg.register('cache', diskcache) + cache.init(q_app) log.init.debug("Initializing downloads...") download_manager = qtnetworkdownloads.DownloadManager(parent=q_app) @@ -509,30 +514,35 @@ def _init_modules(args, crash_handler): browsertab.init() -class Quitter: +class Quitter(QObject): """Utility class to quit/restart the QApplication. Attributes: quit_status: The current quitting status. - _shutting_down: Whether we're currently shutting down. + _is_shutting_down: Whether we're currently shutting down. _args: The argparse namespace. """ - def __init__(self, args): + shutting_down = pyqtSignal() # Emitted immediately before shut down + + def __init__(self, *, + args: argparse.Namespace, + parent: QObject = None) -> None: + super().__init__(parent) self.quit_status = { 'crash': True, 'tabs': False, 'main': False, } - self._shutting_down = False + self._is_shutting_down = False self._args = args - def on_last_window_closed(self): + def on_last_window_closed(self) -> None: """Slot which gets invoked when the last window was closed.""" self.shutdown(last_window=True) - def _compile_modules(self): + def _compile_modules(self) -> None: """Compile all modules to catch SyntaxErrors.""" if os.path.basename(sys.argv[0]) == 'qutebrowser': # Launched via launcher script @@ -551,8 +561,12 @@ class Quitter: with tokenize.open(os.path.join(dirpath, fn)) as f: compile(f.read(), fn, 'exec') - def _get_restart_args(self, pages=(), session=None, override_args=None): - """Get the current working directory and args to relaunch qutebrowser. + def _get_restart_args( + self, pages: typing.Iterable[str] = (), + session: str = None, + override_args: typing.Mapping[str, str] = None + ) -> typing.Sequence[str]: + """Get args to relaunch qutebrowser. Args: pages: The pages to re-open. @@ -560,29 +574,18 @@ class Quitter: override_args: Argument overrides as a dict. Return: - An (args, cwd) tuple. - args: The commandline as a list of strings. - cwd: The current working directory as a string. + The commandline as a list of strings. """ if os.path.basename(sys.argv[0]) == 'qutebrowser': # Launched via launcher script args = [sys.argv[0]] - cwd = None elif hasattr(sys, 'frozen'): args = [sys.executable] - cwd = os.path.abspath(os.path.dirname(sys.executable)) else: args = [sys.executable, '-m', 'qutebrowser'] - cwd = os.path.join( - os.path.abspath(os.path.dirname(qutebrowser.__file__)), '..') - if not os.path.isdir(cwd): - # Probably running from a python egg. Let's fallback to - # cwd=None and see if that works out. - # See https://github.com/qutebrowser/qutebrowser/issues/323 - cwd = None # Add all open pages so they get reopened. - page_args = [] + page_args = [] # type: typing.MutableSequence[str] for win in pages: page_args.extend(win) page_args.append('') @@ -616,12 +619,11 @@ class Quitter: args += ['--json-args', data] log.destroy.debug("args: {}".format(args)) - log.destroy.debug("cwd: {}".format(cwd)) - return args, cwd + return args @cmdutils.register(instance='quitter', name='restart') - def restart_cmd(self): + def restart_cmd(self) -> None: """Restart qutebrowser while keeping existing tabs open.""" try: ok = self.restart(session='_restart') @@ -636,7 +638,9 @@ class Quitter: if ok: self.shutdown(restart=True) - def restart(self, pages=(), session=None, override_args=None): + def restart(self, pages: typing.Sequence[str] = (), + session: str = None, + override_args: typing.Mapping[str, str] = None) -> bool: """Inner logic to restart qutebrowser. The "better" way to restart is to pass a session (_restart usually) as @@ -662,20 +666,17 @@ class Quitter: # Save the session if one is given. if session is not None: - session_manager = objreg.get('session-manager') - session_manager.save(session, with_private=True) + sessions.session_manager.save(session, with_private=True) # Make sure we're not accepting a connection from the new process # before we fully exited. + assert ipc.server is not None ipc.server.shutdown() # Open a new process and immediately shutdown the existing one try: - args, cwd = self._get_restart_args(pages, session, override_args) - if cwd is None: - subprocess.Popen(args) - else: - subprocess.Popen(args, cwd=cwd) + args = self._get_restart_args(pages, session, override_args) + subprocess.Popen(args) except OSError: log.destroy.exception("Failed to restart") return False @@ -684,7 +685,9 @@ class Quitter: @cmdutils.register(instance='quitter', name='quit') @cmdutils.argument('session', completion=miscmodels.session) - def quit(self, save=False, session=None): + def quit(self, + save: bool = False, + session: sessions.ArgType = None) -> None: """Quit qutebrowser. Args: @@ -701,8 +704,10 @@ class Quitter: else: self.shutdown() - def shutdown(self, status=0, session=None, last_window=False, - restart=False): + def shutdown(self, status: int = 0, + session: sessions.ArgType = None, + last_window: bool = False, + restart: bool = False) -> None: """Quit qutebrowser. Args: @@ -712,19 +717,20 @@ class Quitter: closing. restart: If we're planning to restart. """ - if self._shutting_down: + if self._is_shutting_down: return - self._shutting_down = True + self._is_shutting_down = True log.destroy.debug("Shutting down with status {}, session {}...".format( status, session)) - session_manager = objreg.get('session-manager', None) - if session_manager is not None: + if sessions.session_manager is not None: if session is not None: - session_manager.save(session, last_window=last_window, - load_next_time=True) + sessions.session_manager.save(session, + last_window=last_window, + load_next_time=True) elif config.val.auto_save.session: - session_manager.save(sessions.default, last_window=last_window, - load_next_time=True) + sessions.session_manager.save(sessions.default, + last_window=last_window, + load_next_time=True) if prompt.prompt_queue.shutdown(): # If shutdown was called while we were asking a question, we're in @@ -741,64 +747,24 @@ class Quitter: # event loop, so we can shut down immediately. self._shutdown(status, restart=restart) - def _shutdown(self, status, restart): # noqa + def _shutdown(self, status: int, restart: bool) -> None: # noqa """Second stage of shutdown.""" log.destroy.debug("Stage 2 of shutting down...") if q_app is None: # No QApplication exists yet, so quit hard. sys.exit(status) - # Remove eventfilter - try: - log.destroy.debug("Removing eventfilter...") - event_filter = objreg.get('event-filter', None) - if event_filter is not None: - q_app.removeEventFilter(event_filter) - except AttributeError: - pass - # Close all windows - QApplication.closeAllWindows() - # Shut down IPC - try: - ipc.server.shutdown() - except KeyError: - pass - # Save everything - try: - save_manager = objreg.get('save-manager') - except KeyError: - log.destroy.debug("Save manager not initialized yet, so not " - "saving anything.") - else: - for key in save_manager.saveables: - try: - save_manager.save(key, is_exit=True) - except OSError as e: - error.handle_fatal_exc( - e, self._args, "Error while saving!", - pre_text="Error while saving {}".format(key)) - # Disable storage so removing tempdir will work - websettings.shutdown() - # Disable application proxy factory to fix segfaults with Qt 5.10.1 - proxy.shutdown() - # Re-enable faulthandler to stdout, then remove crash log - log.destroy.debug("Deactivating crash log...") - objreg.get('crash-handler').destroy_crashlogfile() + + # Tell everything to shut itself down + self.shutting_down.emit() + # Delete temp basedir if ((self._args.temp_basedir or self._args.temp_basedir_restarted) and not restart): atexit.register(shutil.rmtree, self._args.basedir, ignore_errors=True) - # Delete temp download dir - downloads.temp_download_manager.cleanup() - # If we don't kill our custom handler here we might get segfaults - log.destroy.debug("Deactivating message handler...") - qInstallMessageHandler(None) + # Now we can hopefully quit without segfaults log.destroy.debug("Deferring QApplication::exit...") - objreg.get('signal-handler').deactivate() - session_manager = objreg.get('session-manager', None) - if session_manager is not None: - session_manager.delete_autosave() # We use a singleshot timer to exit here to minimize the likelihood of # segfaults. QTimer.singleShot(0, functools.partial(q_app.exit, status)) @@ -827,10 +793,9 @@ class Application(QApplication): log.init.debug("Qt arguments: {}, based on {}".format(qt_args, args)) super().__init__(qt_args) - log.init.debug("Initializing application...") + objects.args = args - objreg.register('args', args) - objreg.register('app', self) + log.init.debug("Initializing application...") self.launch_time = datetime.datetime.now() self.focusObjectChanged.connect(self.on_focus_object_changed) @@ -883,7 +848,7 @@ class EventFilter(QObject): event. """ - def __init__(self, parent=None): + def __init__(self, parent: QObject = None) -> None: super().__init__(parent) self._activated = True self._handlers = { @@ -892,7 +857,14 @@ class EventFilter(QObject): QEvent.ShortcutOverride: self._handle_key_event, } - def _handle_key_event(self, event): + def install(self): + q_app.installEventFilter(self) + + @pyqtSlot() + def shutdown(self): + q_app.removeEventFilter(self) + + def _handle_key_event(self, event: QKeyEvent) -> bool: """Handle a key press/release event. Args: @@ -912,7 +884,7 @@ class EventFilter(QObject): # No window available yet, or not a MainWindow return False - def eventFilter(self, obj, event): + def eventFilter(self, obj: QObject, event: QEvent) -> bool: """Handle an event. Args: diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 4f8d31c81..b04729a59 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -40,8 +40,8 @@ from qutebrowser.keyinput import modeman from qutebrowser.config import config from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils, urlutils, message) -from qutebrowser.misc import miscwidgets, objects -from qutebrowser.browser import eventfilter, hints +from qutebrowser.misc import miscwidgets, objects, sessions +from qutebrowser.browser import eventfilter from qutebrowser.qt import sip if typing.TYPE_CHECKING: @@ -374,7 +374,7 @@ class AbstractZoom(QObject): levels, mode=usertypes.NeighborList.Modes.edge) self._neighborlist.fuzzyval = config.val.zoom.default - def apply_offset(self, offset: int) -> None: + def apply_offset(self, offset: int) -> int: """Increase/Decrease the zoom level by the given offset. Args: @@ -383,7 +383,7 @@ class AbstractZoom(QObject): Return: The new zoom percentage. """ - level = self._neighborlist.getitem(offset) + level = self._neighborlist.getitem(offset) # type: int self.set_factor(float(level) / 100, fuzzyval=False) return level @@ -888,17 +888,12 @@ class AbstractTab(QWidget): self._tab_event_filter = eventfilter.TabEventFilter( self, parent=self) self.backend = None + # If true, this tab has been requested to be removed (or is removed). self.pending_removal = False self.shutting_down.connect(functools.partial( setattr, self, 'pending_removal', True)) - # FIXME:qtwebengine Should this be public api via self.hints? - # Also, should we get it out of objreg? - hintmanager = hints.HintManager(win_id, self.tab_id, parent=self) - objreg.register('hintmanager', hintmanager, scope='tab', - window=self.win_id, tab=self.tab_id) - self.before_load_started.connect(self._on_before_load_started) def _set_widget(self, widget: QWidget) -> None: @@ -1015,13 +1010,9 @@ class AbstractTab(QWidget): # https://github.com/qutebrowser/qutebrowser/issues/3498 return - try: - sess_manager = objreg.get('session-manager') - except KeyError: - # https://github.com/qutebrowser/qutebrowser/issues/4311 - return + if sessions.session_manager is not None: + sessions.session_manager.save_autosave() - sess_manager.save_autosave() self.load_finished.emit(ok) if not self.title(): diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 636d5e918..856f919c6 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -849,7 +849,7 @@ class CommandDispatcher: idx = int(index_parts[1]) elif len(index_parts) == 1: idx = int(index_parts[0]) - active_win = objreg.get('app').activeWindow() + active_win = QApplication.activeWindow() if active_win is None: # Not sure how you enter a command without an active window... raise cmdutils.CommandError( diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 9dc143140..38fa806a9 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -77,6 +77,11 @@ def init(): config.instance.changed.connect(_clear_last_used) +@pyqtSlot() +def shutdown(): + temp_download_manager.cleanup() + + @config.change_filter('downloads.location.directory', function=True) def _clear_last_used(): global last_used_directory diff --git a/qutebrowser/browser/downloadview.py b/qutebrowser/browser/downloadview.py index fc45fe26c..f65733067 100644 --- a/qutebrowser/browser/downloadview.py +++ b/qutebrowser/browser/downloadview.py @@ -26,7 +26,7 @@ from PyQt5.QtWidgets import QListView, QSizePolicy, QMenu, QStyleFactory from qutebrowser.browser import downloads from qutebrowser.config import config -from qutebrowser.utils import qtutils, utils, objreg +from qutebrowser.utils import qtutils, utils from qutebrowser.qt import sip @@ -59,7 +59,6 @@ class DownloadView(QListView): Attributes: _menu: The QMenu which is currently displayed. - _model: The currently set model. """ STYLESHEET = """ @@ -73,7 +72,7 @@ class DownloadView(QListView): } """ - def __init__(self, win_id, parent=None): + def __init__(self, model, parent=None): super().__init__(parent) if not utils.is_mac: self.setStyle(QStyleFactory.create('Fusion')) @@ -85,7 +84,6 @@ class DownloadView(QListView): self.setFlow(QListView.LeftToRight) self.setSpacing(1) self._menu = None - model = objreg.get('download-model', scope='window', window=win_id) model.rowsInserted.connect(functools.partial(update_geometry, self)) model.rowsRemoved.connect(functools.partial(update_geometry, self)) model.dataChanged.connect(functools.partial(update_geometry, self)) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 7ccfe6b0a..f7d75c09f 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -20,6 +20,7 @@ """A HintManager to draw hints over links.""" import collections +import typing import functools import os import re @@ -37,6 +38,8 @@ from qutebrowser.browser import webelem from qutebrowser.commands import userscripts, runners from qutebrowser.api import cmdutils from qutebrowser.utils import usertypes, log, qtutils, message, objreg, utils +if typing.TYPE_CHECKING: + from qutebrowser.browser import browsertab Target = enum.Enum('Target', ['normal', 'current', 'tab', 'tab_fg', 'tab_bg', @@ -50,7 +53,7 @@ class HintingError(Exception): """Exception raised on errors during hinting.""" -def on_mode_entered(mode, win_id): +def on_mode_entered(mode: usertypes.KeyMode, win_id: int) -> None: """Stop hinting when insert mode was entered.""" if mode == usertypes.KeyMode.insert: modeman.leave(win_id, usertypes.KeyMode.hint, 'insert mode', @@ -66,7 +69,8 @@ class HintLabel(QLabel): _context: The current hinting context. """ - def __init__(self, elem, context): + def __init__(self, elem: webelem.AbstractWebElement, + context: 'HintContext') -> None: super().__init__(parent=context.tab) self._context = context self.elem = elem @@ -81,14 +85,14 @@ class HintLabel(QLabel): self._move_to_elem() self.show() - def __repr__(self): + def __repr__(self) -> str: try: text = self.text() except RuntimeError: text = '' return utils.get_repr(self, elem=self.elem, text=text) - def update_text(self, matched, unmatched): + def update_text(self, matched: str, unmatched: str) -> None: """Set the text for the hint. Args: @@ -112,7 +116,7 @@ class HintLabel(QLabel): self.adjustSize() @pyqtSlot() - def _move_to_elem(self): + def _move_to_elem(self) -> None: """Reposition the label to its element.""" if not self.elem.has_frame(): # This sometimes happens for some reason... @@ -123,7 +127,7 @@ class HintLabel(QLabel): rect = self.elem.rect_on_view(no_js=no_js) self.move(rect.x(), rect.y()) - def cleanup(self): + def cleanup(self) -> None: """Clean up this element and hide it.""" self.hide() self.deleteLater() @@ -159,22 +163,22 @@ class HintContext: group: The group of web elements to hint. """ - all_labels = attr.ib(attr.Factory(list)) - labels = attr.ib(attr.Factory(dict)) - target = attr.ib(None) - baseurl = attr.ib(None) - to_follow = attr.ib(None) - rapid = attr.ib(False) - first_run = attr.ib(True) - add_history = attr.ib(False) - filterstr = attr.ib(None) - args = attr.ib(attr.Factory(list)) - tab = attr.ib(None) - group = attr.ib(None) - hint_mode = attr.ib(None) - first = attr.ib(False) + all_labels = attr.ib(attr.Factory(list)) # type: typing.List[HintLabel] + labels = attr.ib(attr.Factory(dict)) # type: typing.Dict[str, HintLabel] + target = attr.ib(None) # type: Target + baseurl = attr.ib(None) # type: QUrl + to_follow = attr.ib(None) # type: str + rapid = attr.ib(False) # type: bool + first_run = attr.ib(True) # type: bool + add_history = attr.ib(False) # type: bool + filterstr = attr.ib(None) # type: str + args = attr.ib(attr.Factory(list)) # type: typing.List[str] + tab = attr.ib(None) # type: browsertab.AbstractTab + group = attr.ib(None) # type: str + hint_mode = attr.ib(None) # type: str + first = attr.ib(False) # type: bool - def get_args(self, urlstr): + def get_args(self, urlstr: str) -> typing.Sequence[str]: """Get the arguments, with {hint-url} replaced by the given URL.""" args = [] for arg in self.args: @@ -187,16 +191,12 @@ class HintActions: """Actions which can be done after selecting a hint.""" - def __init__(self, win_id): + def __init__(self, win_id: int) -> None: self._win_id = win_id - def click(self, elem, context): - """Click an element. - - Args: - elem: The QWebElement to click. - context: The HintContext to use. - """ + def click(self, elem: webelem.AbstractWebElement, + context: HintContext) -> None: + """Click an element.""" target_mapping = { Target.normal: usertypes.ClickTarget.normal, Target.current: usertypes.ClickTarget.normal, @@ -225,20 +225,15 @@ class HintActions: except webelem.Error as e: raise HintingError(str(e)) - def yank(self, url, context): - """Yank an element to the clipboard or primary selection. - - Args: - url: The URL to open as a QUrl. - context: The HintContext to use. - """ + def yank(self, url: QUrl, context: HintContext) -> None: + """Yank an element to the clipboard or primary selection.""" sel = (context.target == Target.yank_primary and utils.supports_selection()) flags = QUrl.FullyEncoded | QUrl.RemovePassword if url.scheme() == 'mailto': flags |= QUrl.RemoveScheme - urlstr = url.toString(flags) + urlstr = url.toString(flags) # type: ignore new_content = urlstr @@ -257,26 +252,16 @@ class HintActions: urlstr) message.info(msg) - def run_cmd(self, url, context): - """Run the command based on a hint URL. - - Args: - url: The URL to open as a QUrl. - context: The HintContext to use. - """ - urlstr = url.toString(QUrl.FullyEncoded) + def run_cmd(self, url: QUrl, context: HintContext) -> None: + """Run the command based on a hint URL.""" + urlstr = url.toString(QUrl.FullyEncoded) # type: ignore args = context.get_args(urlstr) commandrunner = runners.CommandRunner(self._win_id) commandrunner.run_safely(' '.join(args)) - def preset_cmd_text(self, url, context): - """Preset a commandline text based on a hint URL. - - Args: - url: The URL to open as a QUrl. - context: The HintContext to use. - """ - urlstr = url.toDisplayString(QUrl.FullyEncoded) + def preset_cmd_text(self, url: QUrl, context: HintContext) -> None: + """Preset a commandline text based on a hint URL.""" + urlstr = url.toDisplayString(QUrl.FullyEncoded) # type: ignore args = context.get_args(urlstr) text = ' '.join(args) if text[0] not in modeparsers.STARTCHARS: @@ -285,7 +270,8 @@ class HintActions: cmd = objreg.get('status-command', scope='window', window=self._win_id) cmd.set_cmd_text(text) - def download(self, elem, context): + def download(self, elem: webelem.AbstractWebElement, + context: HintContext) -> None: """Download a hint URL. Args: @@ -305,7 +291,8 @@ class HintActions: download_manager.get(url, qnam=qnam, user_agent=user_agent, prompt_download_directory=prompt) - def call_userscript(self, elem, context): + def call_userscript(self, elem: webelem.AbstractWebElement, + context: HintContext) -> None: """Call a userscript from a hint. Args: @@ -321,7 +308,7 @@ class HintActions: } url = elem.resolve_url(context.baseurl) if url is not None: - env['QUTE_URL'] = url.toString(QUrl.FullyEncoded) + env['QUTE_URL'] = url.toString(QUrl.FullyEncoded) # type: ignore try: userscripts.run_async(context.tab, cmd, *args, win_id=self._win_id, @@ -329,22 +316,28 @@ class HintActions: except userscripts.Error as e: raise HintingError(str(e)) - def delete(self, elem, _context): + def delete(self, elem: webelem.AbstractWebElement, + _context: HintContext) -> None: elem.delete() - def spawn(self, url, context): + def spawn(self, url: QUrl, context: HintContext) -> None: """Spawn a simple command from a hint. Args: url: The URL to open as a QUrl. context: The HintContext to use. """ - urlstr = url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) + urlstr = url.toString( + QUrl.FullyEncoded | QUrl.RemovePassword) # type: ignore args = context.get_args(urlstr) commandrunner = runners.CommandRunner(self._win_id) commandrunner.run_safely('spawn ' + ' '.join(args)) +_ElemsType = typing.Sequence[webelem.AbstractWebElement] +_HintStringsType = typing.MutableSequence[str] + + class HintManager(QObject): """Manage drawing hints over links or other elements. @@ -379,12 +372,11 @@ class HintManager(QObject): Target.delete: "Delete an element", } - def __init__(self, win_id, tab_id, parent=None): + def __init__(self, win_id: int, parent: QObject = None) -> None: """Constructor.""" super().__init__(parent) self._win_id = win_id - self._tab_id = tab_id - self._context = None + self._context = None # type: typing.Optional[HintContext] self._word_hinter = WordHinter() self._actions = HintActions(win_id) @@ -393,16 +385,18 @@ class HintManager(QObject): window=win_id) mode_manager.left.connect(self.on_mode_left) - def _get_text(self): + def _get_text(self) -> str: """Get a hint text based on the current context.""" + assert self._context is not None text = self.HINT_TEXTS[self._context.target] if self._context.rapid: text += ' (rapid mode)' text += '...' return text - def _cleanup(self): + def _cleanup(self) -> None: """Clean up after hinting.""" + assert self._context is not None for label in self._context.all_labels: label.cleanup() @@ -412,7 +406,7 @@ class HintManager(QObject): message_bridge.maybe_reset_text(text) self._context = None - def _hint_strings(self, elems): + def _hint_strings(self, elems: _ElemsType) -> _HintStringsType: """Calculate the hint strings for elems. Inspired by Vimium. @@ -425,6 +419,8 @@ class HintManager(QObject): """ if not elems: return [] + + assert self._context is not None hint_mode = self._context.hint_mode if hint_mode == 'word': try: @@ -442,7 +438,9 @@ class HintManager(QObject): else: return self._hint_linear(min_chars, chars, elems) - def _hint_scattered(self, min_chars, chars, elems): + def _hint_scattered(self, min_chars: int, + chars: str, + elems: _ElemsType) -> _HintStringsType: """Produce scattered hint labels with variable length (like Vimium). Args: @@ -479,7 +477,9 @@ class HintManager(QObject): return self._shuffle_hints(strings, len(chars)) - def _hint_linear(self, min_chars, chars, elems): + def _hint_linear(self, min_chars: int, + chars: str, + elems: _ElemsType) -> _HintStringsType: """Produce linear hint labels with constant length (like dwb). Args: @@ -493,7 +493,8 @@ class HintManager(QObject): strings.append(self._number_to_hint_str(i, chars, needed)) return strings - def _shuffle_hints(self, hints, length): + def _shuffle_hints(self, hints: _HintStringsType, + length: int) -> _HintStringsType: """Shuffle the given set of hints so that they're scattered. Hints starting with the same character will be spread evenly throughout @@ -508,15 +509,19 @@ class HintManager(QObject): Return: A list of shuffled hint strings. """ - buckets = [[] for i in range(length)] + buckets = [ + [] for i in range(length) + ] # type: typing.Sequence[_HintStringsType] for i, hint in enumerate(hints): buckets[i % len(buckets)].append(hint) - result = [] + result = [] # type: _HintStringsType for bucket in buckets: result += bucket return result - def _number_to_hint_str(self, number, chars, digits=0): + def _number_to_hint_str(self, number: int, + chars: str, + digits: int = 0) -> str: """Convert a number like "8" into a hint string like "JK". This is used to sequentially generate all of the hint text. @@ -534,7 +539,7 @@ class HintManager(QObject): A hint string. """ base = len(chars) - hintstr = [] + hintstr = [] # type: typing.MutableSequence[str] remainder = 0 while True: remainder = number % base @@ -548,7 +553,7 @@ class HintManager(QObject): hintstr.insert(0, chars[0]) return ''.join(hintstr) - def _check_args(self, target, *args): + def _check_args(self, target: Target, *args: str) -> None: """Check the arguments passed to start() and raise if they're wrong. Args: @@ -568,7 +573,7 @@ class HintManager(QObject): raise cmdutils.CommandError( "'args' is only allowed with target userscript/spawn.") - def _filter_matches(self, filterstr, elemstr): + def _filter_matches(self, filterstr: str, elemstr: str) -> bool: """Return True if `filterstr` matches `elemstr`.""" # Empty string and None always match if not filterstr: @@ -578,7 +583,7 @@ class HintManager(QObject): # Do multi-word matching return all(word in elemstr for word in filterstr.split()) - def _filter_matches_exactly(self, filterstr, elemstr): + def _filter_matches_exactly(self, filterstr: str, elemstr: str) -> bool: """Return True if `filterstr` exactly matches `elemstr`.""" # Empty string and None never match if not filterstr: @@ -587,7 +592,7 @@ class HintManager(QObject): elemstr = elemstr.casefold() return filterstr == elemstr - def _start_cb(self, elems): + def _start_cb(self, elems: _ElemsType) -> None: """Initialize the elements and labels based on the context set.""" if self._context is None: log.hints.debug("In _start_cb without context!") @@ -603,10 +608,10 @@ class HintManager(QObject): tabbed_browser = objreg.get('tabbed-browser', default=None, scope='window', window=self._win_id) tab = tabbed_browser.widget.currentWidget() - if tab.tab_id != self._tab_id: + if tab.tab_id != self._context.tab.tab_id: log.hints.debug( "Current tab changed ({} -> {}) before _start_cb is run." - .format(self._tab_id, tab.tab_id)) + .format(self._context.tab.tab_id, tab.tab_id)) return strings = self._hint_strings(elems) @@ -635,11 +640,16 @@ class HintManager(QObject): # to make auto_follow == 'always' work self._handle_auto_follow() - @cmdutils.register(instance='hintmanager', scope='tab', name='hint', + @cmdutils.register(instance='hintmanager', scope='window', name='hint', star_args_optional=True, maxsplit=2) def start(self, # pylint: disable=keyword-arg-before-vararg - group='all', target=Target.normal, *args, mode=None, - add_history=False, rapid=False, first=False): + group: str = 'all', + target: Target = Target.normal, + *args: str, + mode: str = None, + add_history: bool = False, + rapid: bool = False, + first: bool = False) -> None: """Start hinting. Args: @@ -755,7 +765,7 @@ class HintManager(QObject): error_cb=lambda err: message.error(str(err)), only_visible=True) - def _get_hint_mode(self, mode): + def _get_hint_mode(self, mode: typing.Optional[str]) -> str: """Get the hinting mode to use based on a mode argument.""" if mode is None: return config.val.hints.mode @@ -767,15 +777,22 @@ class HintManager(QObject): raise cmdutils.CommandError("Invalid mode: {}".format(e)) return mode - def current_mode(self): + def current_mode(self) -> typing.Optional[str]: """Return the currently active hinting mode (or None otherwise).""" if self._context is None: return None return self._context.hint_mode - def _handle_auto_follow(self, keystr="", filterstr="", visible=None): + def _handle_auto_follow( + self, + keystr: str = "", + filterstr: str = "", + visible: typing.Mapping[str, HintLabel] = None + ) -> None: """Handle the auto_follow option.""" + assert self._context is not None + if visible is None: visible = {string: label for string, label in self._context.labels.items() @@ -789,7 +806,7 @@ class HintManager(QObject): if auto_follow == "always": follow = True elif auto_follow == "unique-match": - follow = keystr or filterstr + follow = bool(keystr or filterstr) elif auto_follow == "full-match": elemstr = str(list(visible.values())[0].elem) filter_match = self._filter_matches_exactly(filterstr, elemstr) @@ -810,7 +827,8 @@ class HintManager(QObject): # unpacking gets us the first (and only) key in the dict. self._fire(*visible) - def handle_partial_key(self, keystr): + @pyqtSlot(str) + def handle_partial_key(self, keystr: str) -> None: """Handle a new partial keypress.""" if self._context is None: log.hints.debug("Got key without context!") @@ -834,7 +852,7 @@ class HintManager(QObject): pass self._handle_auto_follow(keystr=keystr) - def filter_hints(self, filterstr): + def filter_hints(self, filterstr: typing.Optional[str]) -> None: """Filter displayed hints according to a text. Args: @@ -844,6 +862,8 @@ class HintManager(QObject): and `self._context.filterstr` are None, all hints are shown. """ + assert self._context is not None + if filterstr is None: filterstr = self._context.filterstr else: @@ -889,12 +909,13 @@ class HintManager(QObject): self._handle_auto_follow(filterstr=filterstr, visible=self._context.labels) - def _fire(self, keystr): + def _fire(self, keystr: str) -> None: """Fire a completed hint. Args: keystr: The keychain string to follow. """ + assert self._context is not None # Handlers which take a QWebElement elem_handlers = { Target.normal: self._actions.click, @@ -956,15 +977,16 @@ class HintManager(QObject): if self._context is not None: self._context.first_run = False - @cmdutils.register(instance='hintmanager', scope='tab', + @cmdutils.register(instance='hintmanager', scope='window', modes=[usertypes.KeyMode.hint]) - def follow_hint(self, select=False, keystring=None): + def follow_hint(self, select: bool = False, keystring: str = None) -> None: """Follow a hint. Args: select: Only select the given hint, don't necessarily follow it. keystring: The hint to follow, or None. """ + assert self._context is not None if keystring is None: if self._context.to_follow is None: raise cmdutils.CommandError("No hint to follow") @@ -980,7 +1002,7 @@ class HintManager(QObject): self._fire(keystring) @pyqtSlot(usertypes.KeyMode) - def on_mode_left(self, mode): + def on_mode_left(self, mode: usertypes.KeyMode) -> None: """Stop hinting when hinting mode was left.""" if mode != usertypes.KeyMode.hint or self._context is None: # We have one HintManager per tab, so when this gets called, @@ -999,12 +1021,12 @@ class WordHinter: derived from the hinted element. """ - def __init__(self): + def __init__(self) -> None: # will be initialized on first use. - self.words = set() + self.words = set() # type: typing.Set[str] self.dictionary = None - def ensure_initialized(self): + def ensure_initialized(self) -> None: """Generate the used words if yet uninitialized.""" dictionary = config.val.hints.dictionary if not self.words or self.dictionary != dictionary: @@ -1031,8 +1053,11 @@ class WordHinter: error = "Word hints requires reading the file at {}: {}" raise HintingError(error.format(dictionary, str(e))) - def extract_tag_words(self, elem): + def extract_tag_words( + self, elem: webelem.AbstractWebElement + ) -> typing.Iterator[str]: """Extract tag words form the given element.""" + _extractor_type = typing.Callable[[webelem.AbstractWebElement], str] attr_extractors = { "alt": lambda elem: elem["alt"], "name": lambda elem: elem["name"], @@ -1041,7 +1066,7 @@ class WordHinter: "src": lambda elem: elem["src"].split('/')[-1], "href": lambda elem: elem["href"].split('/')[-1], "text": str, - } + } # type: typing.Mapping[str, _extractor_type] extractable_attrs = collections.defaultdict(list, { "img": ["alt", "title", "src"], @@ -1055,7 +1080,10 @@ class WordHinter: for attr in extractable_attrs[elem.tag_name()] if attr in elem or attr == "text") - def tag_words_to_hints(self, words): + def tag_words_to_hints( + self, + words: typing.Iterable[str] + ) -> typing.Iterator[str]: """Take words and transform them to proper hints if possible.""" for candidate in words: if not candidate: @@ -1066,13 +1094,20 @@ class WordHinter: if 4 < match.end() - match.start() < 8: yield candidate[match.start():match.end()].lower() - def any_prefix(self, hint, existing): + def any_prefix(self, hint: str, existing: typing.Iterable[str]) -> bool: return any(hint.startswith(e) or e.startswith(hint) for e in existing) - def filter_prefixes(self, hints, existing): + def filter_prefixes( + self, + hints: typing.Iterable[str], + existing: typing.Iterable[str] + ) -> typing.Iterator[str]: + """Filter hints which don't start with the given prefix.""" return (h for h in hints if not self.any_prefix(h, existing)) - def new_hint_for(self, elem, existing, fallback): + def new_hint_for(self, elem: webelem.AbstractWebElement, + existing: typing.Iterable[str], + fallback: typing.Iterable[str]) -> typing.Optional[str]: """Return a hint for elem, not conflicting with the existing.""" new = self.tag_words_to_hints(self.extract_tag_words(elem)) new_no_prefixes = self.filter_prefixes(new, existing) @@ -1081,7 +1116,7 @@ class WordHinter: return (next(new_no_prefixes, None) or next(fallback_no_prefixes, None)) - def hint(self, elems): + def hint(self, elems: _ElemsType) -> _HintStringsType: """Produce hint labels based on the html tags. Produce hint words based on the link text and random words @@ -1096,7 +1131,7 @@ class WordHinter: """ self.ensure_initialized() hints = [] - used_hints = set() + used_hints = set() # type: typing.Set[str] words = iter(self.words) for elem in elems: hint = self.new_hint_for(elem, used_hints, words) diff --git a/qutebrowser/browser/network/proxy.py b/qutebrowser/browser/network/proxy.py index a14448512..2bd1a6325 100644 --- a/qutebrowser/browser/network/proxy.py +++ b/qutebrowser/browser/network/proxy.py @@ -19,21 +19,25 @@ """Handling of proxies.""" +import typing -from PyQt5.QtCore import QUrl +from PyQt5.QtCore import QUrl, pyqtSlot from PyQt5.QtNetwork import QNetworkProxy, QNetworkProxyFactory from qutebrowser.config import config, configtypes -from qutebrowser.utils import objreg, message, usertypes, urlutils +from qutebrowser.utils import message, usertypes, urlutils from qutebrowser.misc import objects from qutebrowser.browser.network import pac +application_factory = None + + def init(): """Set the application wide proxy factory.""" - proxy_factory = ProxyFactory() - objreg.register('proxy-factory', proxy_factory) - QNetworkProxyFactory.setApplicationProxyFactory(proxy_factory) + global application_factory + application_factory = ProxyFactory() + QNetworkProxyFactory.setApplicationProxyFactory(application_factory) config.instance.changed.connect(_warn_for_pac) _warn_for_pac() @@ -48,6 +52,7 @@ def _warn_for_pac(): message.error("PAC support isn't implemented for QtWebEngine yet!") +@pyqtSlot() def shutdown(): QNetworkProxyFactory.setApplicationProxyFactory(None) diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index 747f3162e..b36fb7389 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -27,7 +27,6 @@ from PyQt5.QtGui import QMouseEvent from qutebrowser.config import config from qutebrowser.keyinput import modeman -from qutebrowser.mainwindow import mainwindow from qutebrowser.utils import log, usertypes, utils, qtutils, objreg if typing.TYPE_CHECKING: @@ -89,7 +88,8 @@ class AbstractWebElement(collections.abc.MutableMapping): def __repr__(self) -> str: try: - html = utils.compact_text(self.outer_xml(), 500) + html = utils.compact_text( + self.outer_xml(), 500) # type: typing.Optional[str] except Error: html = None return utils.get_repr(self, html=html) @@ -385,6 +385,7 @@ class AbstractWebElement(collections.abc.MutableMapping): background = click_target == usertypes.ClickTarget.tab_bg tabbed_browser.tabopen(url, background=background) elif click_target == usertypes.ClickTarget.window: + from qutebrowser.mainwindow import mainwindow window = mainwindow.MainWindow(private=tabbed_browser.is_private) window.show() window.tabbed_browser.tabopen(url) diff --git a/qutebrowser/browser/webkit/cache.py b/qutebrowser/browser/webkit/cache.py index 4844ea0d3..c77ae2616 100644 --- a/qutebrowser/browser/webkit/cache.py +++ b/qutebrowser/browser/webkit/cache.py @@ -24,7 +24,10 @@ import os.path from PyQt5.QtNetwork import QNetworkDiskCache from qutebrowser.config import config -from qutebrowser.utils import utils, qtutils +from qutebrowser.utils import utils, qtutils, standarddir + + +diskcache = None class DiskCache(QNetworkDiskCache): @@ -52,3 +55,9 @@ class DiskCache(QNetworkDiskCache): if not qtutils.version_check('5.9', compiled=False): size = 0 # pragma: no cover self.setMaximumCacheSize(size) + + +def init(parent): + """Initialize the global cache.""" + global diskcache + diskcache = DiskCache(standarddir.cache(), parent=parent) diff --git a/qutebrowser/browser/webkit/cookies.py b/qutebrowser/browser/webkit/cookies.py index 9dce9bc11..bd9434018 100644 --- a/qutebrowser/browser/webkit/cookies.py +++ b/qutebrowser/browser/webkit/cookies.py @@ -27,6 +27,10 @@ from qutebrowser.utils import utils, standarddir, objreg from qutebrowser.misc import lineparser +cookie_jar = None +ram_cookie_jar = None + + class RAMCookieJar(QNetworkCookieJar): """An in-RAM cookie jar. @@ -112,3 +116,10 @@ class CookieJar(RAMCookieJar): self._lineparser.data = [] self._lineparser.save() self.changed.emit() + + +def init(qapp): + """Initialize the global cookie jars.""" + global cookie_jar, ram_cookie_jar + cookie_jar = CookieJar(qapp) + ram_cookie_jar = RAMCookieJar(qapp) diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py index bcdc2bfa2..9fd713475 100644 --- a/qutebrowser/browser/webkit/network/networkmanager.py +++ b/qutebrowser/browser/webkit/network/networkmanager.py @@ -32,8 +32,9 @@ from qutebrowser.config import config from qutebrowser.utils import (message, log, usertypes, utils, objreg, urlutils, debug) from qutebrowser.browser import shared +from qutebrowser.browser.network import proxy as proxymod from qutebrowser.extensions import interceptors -from qutebrowser.browser.webkit import certificateerror +from qutebrowser.browser.webkit import certificateerror, cookies, cache from qutebrowser.browser.webkit.network import (webkitqutescheme, networkreply, filescheme) from qutebrowser.misc import objects @@ -174,9 +175,10 @@ class NetworkManager(QNetworkAccessManager): def _set_cookiejar(self): """Set the cookie jar of the NetworkManager correctly.""" if self._private: - cookie_jar = objreg.get('ram-cookie-jar') + cookie_jar = cookies.ram_cookie_jar else: - cookie_jar = objreg.get('cookie-jar') + cookie_jar = cookies.cookie_jar + assert cookie_jar is not None # We have a shared cookie jar - we restore its parent so we don't # take ownership of it. @@ -191,9 +193,8 @@ class NetworkManager(QNetworkAccessManager): # We have a shared cache - we restore its parent so we don't take # ownership of it. app = QCoreApplication.instance() - cache = objreg.get('cache') - self.setCache(cache) - cache.setParent(app) + self.setCache(cache.diskcache) + cache.diskcache.setParent(app) def _get_abort_signals(self, owner=None): """Get a list of signals which should abort a question.""" @@ -375,9 +376,8 @@ class NetworkManager(QNetworkAccessManager): Return: A QNetworkReply. """ - proxy_factory = objreg.get('proxy-factory', None) - if proxy_factory is not None: - proxy_error = proxy_factory.get_error() + if proxymod.application_factory is not None: + proxy_error = proxymod.application_factory.get_error() if proxy_error is not None: return networkreply.ErrorNetworkReply( req, proxy_error, QNetworkReply.UnknownProxyError, diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index 1dc1c7797..952e1c340 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -359,7 +359,8 @@ class Command: tab_id = None else: raise ValueError("Invalid scope {}!".format(scope)) - return objreg.get(name, scope=scope, window=win_id, tab=tab_id) + return objreg.get(name, scope=scope, window=win_id, tab=tab_id, + from_command=True) def _add_special_arg(self, *, value, param, args, kwargs): """Add a special argument value to a function call. diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 7462eff51..994f7e71a 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -32,6 +32,7 @@ from qutebrowser.config import config from qutebrowser.commands import cmdexc from qutebrowser.utils import message, objreg, qtutils, usertypes, utils from qutebrowser.misc import split, objects +from qutebrowser.keyinput import macros if typing.TYPE_CHECKING: from qutebrowser.mainwindow import tabbedbrowser @@ -303,7 +304,21 @@ class CommandParser: return split_args -class CommandRunner(QObject): +class AbstractCommandRunner(QObject): + + """Abstract base class for CommandRunner.""" + + def run(self, text, count=None, *, safely=False): + raise NotImplementedError + + @pyqtSlot(str, int) + @pyqtSlot(str) + def run_safely(self, text, count=None): + """Run a command and display exceptions in the statusbar.""" + self.run(text, count, safely=True) + + +class CommandRunner(AbstractCommandRunner): """Parse and run qutebrowser commandline commands. @@ -369,11 +384,4 @@ class CommandRunner(QObject): last_command[cur_mode] = (text, count) if record_macro and cur_mode == usertypes.KeyMode.normal: - macro_recorder = objreg.get('macro-recorder') - macro_recorder.record_command(text, count) - - @pyqtSlot(str, int) - @pyqtSlot(str) - def run_safely(self, text, count=None): - """Run a command and display exceptions in the statusbar.""" - self.run(text, count, safely=True) + macros.macro_recorder.record_command(text, count) diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index 608d396f2..f5ce706f8 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -25,14 +25,15 @@ subclasses to provide completions. import typing - from PyQt5.QtWidgets import QTreeView, QSizePolicy, QStyleFactory, QWidget from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel, QSize from qutebrowser.config import config from qutebrowser.completion import completiondelegate -from qutebrowser.utils import utils, usertypes, debug, log, objreg +from qutebrowser.utils import utils, usertypes, debug, log from qutebrowser.api import cmdutils +if typing.TYPE_CHECKING: + from qutebrowser.mainwindow.statusbar import command class CompletionView(QTreeView): @@ -50,6 +51,7 @@ class CompletionView(QTreeView): _delegate: The item delegate used. _column_widths: A list of column widths, in percent. _active: Whether a selection is active. + _cmd: The statusbar Command object. Signals: update_geometry: Emitted when the completion should be resized. @@ -108,14 +110,18 @@ class CompletionView(QTreeView): update_geometry = pyqtSignal() selection_changed = pyqtSignal(str) - def __init__(self, win_id: int, parent: QWidget = None) -> None: + def __init__(self, *, + cmd: 'command.Command', + win_id: int, + parent: QWidget = None) -> None: super().__init__(parent) self.pattern = None # type: typing.Optional[str] self._win_id = win_id - config.instance.changed.connect(self._on_config_changed) - + self._cmd = cmd self._active = False + config.instance.changed.connect(self._on_config_changed) + self._delegate = completiondelegate.CompletionItemDelegate(self) self.setItemDelegate(self._delegate) self.setStyle(QStyleFactory.create('Fusion')) @@ -243,15 +249,13 @@ class CompletionView(QTreeView): history: Navigate through command history if no text was typed. """ if history: - status = objreg.get('status-command', scope='window', - window=self._win_id) - if (status.text() == ':' or status.history.is_browsing() or + if (self._cmd.text() == ':' or self._cmd.history.is_browsing() or not self._active): if which == 'next': - status.command_history_next() + self._cmd.command_history_next() return elif which == 'prev': - status.command_history_prev() + self._cmd.command_history_prev() return else: raise cmdutils.CommandError("Can't combine --history with " @@ -412,9 +416,7 @@ class CompletionView(QTreeView): Args: sel: Use the primary selection instead of the clipboard. """ - status = objreg.get('status-command', scope='window', - window=self._win_id) - text = status.selectedText() + text = self._cmd.selectedText() if not text: index = self.currentIndex() if not index.isValid(): diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py index f48d73206..9cfe22656 100644 --- a/qutebrowser/completion/models/miscmodels.py +++ b/qutebrowser/completion/models/miscmodels.py @@ -85,10 +85,11 @@ def bookmark(*, info=None): # pylint: disable=unused-argument def session(*, info=None): # pylint: disable=unused-argument """A CompletionModel filled with session names.""" + from qutebrowser.misc import sessions model = completionmodel.CompletionModel() try: - manager = objreg.get('session-manager') - sessions = ((name,) for name in manager.list_sessions() + sessions = ((name,) for name + in sessions.session_manager.list_sessions() if not name.startswith('_')) model.add_category(listcategory.ListCategory("Sessions", sessions)) except OSError: diff --git a/qutebrowser/completion/models/util.py b/qutebrowser/completion/models/util.py index 281047faf..502da10db 100644 --- a/qutebrowser/completion/models/util.py +++ b/qutebrowser/completion/models/util.py @@ -21,7 +21,7 @@ import typing -from qutebrowser.utils import objreg, usertypes +from qutebrowser.utils import usertypes from qutebrowser.misc import objects @@ -43,7 +43,7 @@ def get_cmd_completions(info, include_hidden, include_aliases, prefix=''): cmdlist = [] cmd_to_keys = info.keyconf.get_reverse_bindings_for('normal') for obj in set(objects.commands.values()): - hide_debug = obj.debug and not objreg.get('args').debug + hide_debug = obj.debug and not objects.args.debug hide_mode = (usertypes.KeyMode.normal not in obj.modes and not include_hidden) if not (hide_debug or hide_mode or obj.deprecated): diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 6cfacbc51..d2e50a645 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -1789,6 +1789,14 @@ tabs.focus_stack_size: zero_ok: false desc: Maximum stack size to remember for tab switches (-1 for no maximum). +tabs.tooltips: + default: true + type: Bool + desc: >- + Show tooltips on tabs. + + Note this setting only affects windows opened after it has been set. + ## url url.auto_search: @@ -2768,7 +2776,7 @@ bindings.default: tIu: config-cycle -p -u {url} content.images ;; reload insert: : open-editor - : insert-text {primary} + : insert-text -- {primary} : leave-mode hint: : follow-hint diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index df171c365..d83ca403b 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -141,11 +141,13 @@ class ConfigFileErrors(Error): def __init__(self, basename: str, - errors: typing.Sequence[ConfigErrorDesc]) -> None: + errors: typing.Sequence[ConfigErrorDesc], *, + fatal: bool = False) -> None: super().__init__("Errors occurred while reading {}:\n{}".format( basename, '\n'.join(' {}'.format(e) for e in errors))) self.basename = basename self.errors = errors + self.fatal = fatal for err in errors: if err.traceback: log.config.debug("Config error stack:") diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 2618d185a..ebfec1354 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -54,7 +54,6 @@ class StateConfig(configparser.ConfigParser): super().__init__() self._filename = os.path.join(standarddir.data(), 'state') self.read(self._filename, encoding='utf-8') - qt_version = qVersion() # We handle this here, so we can avoid setting qt_version_changed if # the config is brand new, but can still set it when qt_version wasn't @@ -159,7 +158,7 @@ class YamlConfig(QObject): # Instead, create a config.py - see :help for details. """.lstrip('\n'))) - utils.yaml_dump(data, f) + utils.yaml_dump(data, f) # type: ignore def _pop_object(self, yaml_data: typing.Any, @@ -441,11 +440,21 @@ class ConfigAPI: urlpattern = urlmatch.UrlPattern(pattern) if pattern else None self._config.set_obj(name, value, pattern=urlpattern) - def bind(self, key: str, command: str, mode: str = 'normal') -> None: + def bind(self, key: str, + command: typing.Optional[str], + mode: str = 'normal') -> None: """Bind a key to a command, with an optional key mode.""" with self._handle_error('binding', key): seq = keyutils.KeySequence.parse(key) - self._keyconfig.bind(seq, command, mode=mode) + if command is None: + text = ("Unbinding commands with config.bind('{key}', None) " + "is deprecated. Use config.unbind('{key}') instead." + .format(key=key)) + self.errors.append(configexc.ConfigErrorDesc( + "While unbinding '{}'".format(key), text)) + self._keyconfig.unbind(seq, mode=mode) + else: + self._keyconfig.bind(seq, command, mode=mode) def unbind(self, key: str, mode: str = 'normal') -> None: """Unbind a key from a command, with an optional key mode.""" @@ -679,7 +688,13 @@ def saved_sys_properties() -> typing.Iterator[None]: def init() -> None: """Initialize config storage not related to the main config.""" global state - state = StateConfig() + + try: + state = StateConfig() + except configparser.Error as e: + msg = "While loading state file from {}".format(standarddir.data()) + desc = configexc.ConfigErrorDesc(msg, e) + raise configexc.ConfigFileErrors('state', [desc], fatal=True) # Set the QSettings path to something like # ~/.config/qutebrowser/qsettings/qutebrowser/qutebrowser.conf so it diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index 74c07e3ab..256c705f0 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -57,9 +57,10 @@ def early_init(args: argparse.Namespace) -> None: config_commands = configcommands.ConfigCommands( config.instance, config.key_instance) - objreg.register('config-commands', config_commands) + objreg.register('config-commands', config_commands, command_only=True) config_file = standarddir.config_py() + global _init_errors try: if os.path.exists(config_file): @@ -68,10 +69,12 @@ def early_init(args: argparse.Namespace) -> None: configfiles.read_autoconfig() except configexc.ConfigFileErrors as e: log.config.exception("Error while loading {}".format(e.basename)) - global _init_errors _init_errors = e - configfiles.init() + try: + configfiles.init() + except configexc.ConfigFileErrors as e: + _init_errors = e for opt, val in args.temp_settings: try: @@ -149,6 +152,10 @@ def late_init(save_manager: savemanager.SaveManager) -> None: icon=QMessageBox.Warning, plain_text=False) errbox.exec_() + + if _init_errors.fatal: + sys.exit(usertypes.Exit.err_init) + _init_errors = None config.instance.init_save_manager(save_manager) diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index f228c2cde..db8f77387 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -22,7 +22,7 @@ import typing import argparse -from PyQt5.QtCore import QUrl +from PyQt5.QtCore import QUrl, pyqtSlot from PyQt5.QtGui import QFont from qutebrowser.config import config, configutils @@ -198,6 +198,7 @@ def init(args: argparse.Namespace) -> None: pattern=urlmatch.UrlPattern(pattern)) +@pyqtSlot() def shutdown() -> None: """Shut down QWeb(Engine)Settings.""" if objects.backend == usertypes.Backend.QtWebEngine: diff --git a/qutebrowser/extensions/loader.py b/qutebrowser/extensions/loader.py index 3becd86bb..3094871c8 100644 --- a/qutebrowser/extensions/loader.py +++ b/qutebrowser/extensions/loader.py @@ -32,7 +32,8 @@ from PyQt5.QtCore import pyqtSlot from qutebrowser import components from qutebrowser.config import config -from qutebrowser.utils import log, standarddir, objreg +from qutebrowser.utils import log, standarddir +from qutebrowser.misc import objects if typing.TYPE_CHECKING: import argparse @@ -137,7 +138,7 @@ def _get_init_context() -> InitContext: """Get an InitContext object.""" return InitContext(data_dir=pathlib.Path(standarddir.data()), config_dir=pathlib.Path(standarddir.config()), - args=objreg.get('args')) + args=objects.args) def _load_component(info: ExtensionInfo, *, diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 6dd003e18..14dc0608b 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -42,7 +42,7 @@ class MatchResult: command = attr.ib() # type: typing.Optional[str] sequence = attr.ib() # type: keyutils.KeySequence - def __attrs_post_init__(self): + def __attrs_post_init__(self) -> None: if self.match_type == QKeySequence.ExactMatch: assert self.command is not None else: @@ -151,13 +151,13 @@ class BaseKeyParser(QObject): do_log: Whether to log keypresses or not. passthrough: Whether unbound keys should be passed through with this handler. + supports_count: Whether count is supported. Attributes: bindings: Bound key bindings _win_id: The window ID this keyparser is associated with. _sequence: The currently entered key sequence _modename: The name of the input mode associated with this keyparser. - _supports_count: Whether count is supported Signals: keystring_updated: Emitted when the keystring is updated. @@ -172,20 +172,19 @@ class BaseKeyParser(QObject): request_leave = pyqtSignal(usertypes.KeyMode, str, bool) do_log = True passthrough = False + supports_count = True - def __init__(self, win_id: int, parent: QWidget = None, - supports_count: bool = True) -> None: + def __init__(self, win_id: int, parent: QWidget = None) -> None: super().__init__(parent) self._win_id = win_id self._modename = None self._sequence = keyutils.KeySequence() self._count = '' - self._supports_count = supports_count self.bindings = BindingTrie() config.instance.changed.connect(self._on_config_changed) def __repr__(self) -> str: - return utils.get_repr(self, supports_count=self._supports_count) + return utils.get_repr(self) def _debug_log(self, message: str) -> None: """Log a message to the debug log if logging is active. @@ -237,7 +236,7 @@ class BaseKeyParser(QObject): dry_run: bool) -> bool: """Try to match a key as count.""" txt = str(sequence[-1]) # To account for sequences changed above. - if (txt in string.digits and self._supports_count and + if (txt in string.digits and self.supports_count and not (not self._count and txt == '0')): self._debug_log("Trying match as count") assert len(txt) == 1, txt @@ -281,6 +280,8 @@ class BaseKeyParser(QObject): return QKeySequence.NoMatch result = self._match_key(sequence) + del sequence # Enforce code below to use the modified result.sequence + if result.match_type == QKeySequence.NoMatch: result = self._match_without_modifiers(result.sequence) if result.match_type == QKeySequence.NoMatch: @@ -305,7 +306,7 @@ class BaseKeyParser(QObject): elif result.match_type == QKeySequence.PartialMatch: self._debug_log("No match for '{}' (added {})".format( result.sequence, txt)) - self.keystring_updated.emit(self._count + str(sequence)) + self.keystring_updated.emit(self._count + str(result.sequence)) elif result.match_type == QKeySequence.NoMatch: self._debug_log("Giving up with '{}', no matches".format( result.sequence)) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index 9e12e1a88..b492e8c0a 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -103,18 +103,18 @@ def _build_special_names() -> typing.Mapping[Qt.Key, str]: 'Dead_Hook': 'Hook', 'Dead_Horn': 'Horn', - 'Dead_Stroke': '̵', - 'Dead_Abovecomma': '̓', - 'Dead_Abovereversedcomma': '̔', - 'Dead_Doublegrave': '̏', - 'Dead_Belowring': '̥', - 'Dead_Belowmacron': '̱', - 'Dead_Belowcircumflex': '̭', - 'Dead_Belowtilde': '̰', - 'Dead_Belowbreve': '̮', - 'Dead_Belowdiaeresis': '̤', - 'Dead_Invertedbreve': '̑', - 'Dead_Belowcomma': '̦', + 'Dead_Stroke': '\u0335', # '̵' + 'Dead_Abovecomma': '\u0313', # '̓' + 'Dead_Abovereversedcomma': '\u0314', # '̔' + 'Dead_Doublegrave': '\u030f', # '̏' + 'Dead_Belowring': '\u0325', # '̥' + 'Dead_Belowmacron': '\u0331', # '̱' + 'Dead_Belowcircumflex': '\u032d', # '̭' + 'Dead_Belowtilde': '\u0330', # '̰' + 'Dead_Belowbreve': '\u032e', # '̮' + 'Dead_Belowdiaeresis': '\u0324', # '̤' + 'Dead_Invertedbreve': '\u0311', # '̑' + 'Dead_Belowcomma': '\u0326', # '̦' 'Dead_Currency': '¤', 'Dead_a': 'a', 'Dead_A': 'A', @@ -129,10 +129,10 @@ def _build_special_names() -> typing.Mapping[Qt.Key, str]: 'Dead_Small_Schwa': 'ə', 'Dead_Capital_Schwa': 'Ə', 'Dead_Greek': 'Greek', - 'Dead_Lowline': '̲', - 'Dead_Aboveverticalline': '̍', + 'Dead_Lowline': '\u0332', # '̲' + 'Dead_Aboveverticalline': '\u030d', # '̍' 'Dead_Belowverticalline': '\u0329', - 'Dead_Longsolidusoverlay': '̸', + 'Dead_Longsolidusoverlay': '\u0338', # '̸' 'Memo': 'Memo', 'ToDoList': 'To Do List', @@ -554,7 +554,9 @@ class KeySequence: def __getitem__(self, item: slice) -> 'KeySequence': ... - def __getitem__(self, item): + def __getitem__( + self, item: typing.Union[int, slice] + ) -> typing.Union[KeyInfo, 'KeySequence']: if isinstance(item, slice): keys = list(self._iter_keys()) return self.__class__(*keys[item]) diff --git a/qutebrowser/keyinput/macros.py b/qutebrowser/keyinput/macros.py index a0116c19f..dd12a4b8f 100644 --- a/qutebrowser/keyinput/macros.py +++ b/qutebrowser/keyinput/macros.py @@ -20,12 +20,19 @@ """Keyboard macro system.""" +import typing + from qutebrowser.commands import runners from qutebrowser.api import cmdutils from qutebrowser.keyinput import modeman from qutebrowser.utils import message, objreg, usertypes +_CommandType = typing.Tuple[str, int] # command, type + +macro_recorder = typing.cast('MacroRecorder', None) + + class MacroRecorder: """An object for recording and running keyboard macros. @@ -39,15 +46,15 @@ class MacroRecorder: _last_register: The macro which did run last. """ - def __init__(self): - self._macros = {} - self._recording_macro = None - self._macro_count = {} - self._last_register = None + def __init__(self) -> None: + self._macros = {} # type: typing.Dict[str, typing.List[_CommandType]] + self._recording_macro = None # type: typing.Optional[str] + self._macro_count = {} # type: typing.Dict[int, int] + self._last_register = None # type: typing.Optional[str] @cmdutils.register(instance='macro-recorder', name='record-macro') @cmdutils.argument('win_id', value=cmdutils.Value.win_id) - def record_macro_command(self, win_id, register=None): + def record_macro_command(self, win_id: int, register: str = None) -> None: """Start or stop recording a macro. Args: @@ -64,7 +71,7 @@ class MacroRecorder: message.info("Macro '{}' recorded.".format(self._recording_macro)) self._recording_macro = None - def record_macro(self, register): + def record_macro(self, register: str) -> None: """Start recording a macro.""" message.info("Recording macro '{}'...".format(register)) self._macros[register] = [] @@ -73,7 +80,9 @@ class MacroRecorder: @cmdutils.register(instance='macro-recorder', name='run-macro') @cmdutils.argument('win_id', value=cmdutils.Value.win_id) @cmdutils.argument('count', value=cmdutils.Value.count) - def run_macro_command(self, win_id, count=1, register=None): + def run_macro_command(self, win_id: int, + count: int = 1, + register: str = None) -> None: """Run a recorded macro. Args: @@ -87,7 +96,7 @@ class MacroRecorder: else: self.run_macro(win_id, register) - def run_macro(self, win_id, register): + def run_macro(self, win_id: int, register: str) -> None: """Run a recorded macro.""" if register == '@': if self._last_register is None: @@ -104,13 +113,14 @@ class MacroRecorder: for cmd in self._macros[register]: commandrunner.run_safely(*cmd) - def record_command(self, text, count): + def record_command(self, text: str, count: int) -> None: """Record a command if a macro is being recorded.""" if self._recording_macro is not None: self._macros[self._recording_macro].append((text, count)) -def init(): +def init() -> None: """Initialize the MacroRecorder.""" + global macro_recorder macro_recorder = MacroRecorder() - objreg.register('macro-recorder', macro_recorder) + objreg.register('macro-recorder', macro_recorder, command_only=True) diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index f4b2bf9ac..9e50481c7 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -20,19 +20,26 @@ """Mode manager singleton which handles the current keyboard mode.""" import functools +import typing import attr from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QObject, QEvent +from PyQt5.QtGui import QKeyEvent from PyQt5.QtWidgets import QApplication -from qutebrowser.keyinput import modeparsers +from qutebrowser.commands import runners +from qutebrowser.keyinput import modeparsers, basekeyparser from qutebrowser.config import config from qutebrowser.api import cmdutils from qutebrowser.utils import usertypes, log, objreg, utils +from qutebrowser.browser import hints INPUT_MODES = [usertypes.KeyMode.insert, usertypes.KeyMode.passthrough] PROMPT_MODES = [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno] +_ParserDictType = typing.MutableMapping[ + usertypes.KeyMode, basekeyparser.BaseKeyParser] + @attr.s(frozen=True) class KeyEvent: @@ -48,13 +55,13 @@ class KeyEvent: text: A string (QKeyEvent::text). """ - key = attr.ib() - text = attr.ib() + key = attr.ib() # type: Qt.Key + text = attr.ib() # type: str @classmethod - def from_event(cls, event): + def from_event(cls, event: QKeyEvent) -> 'KeyEvent': """Initialize a KeyEvent from a QKeyEvent.""" - return cls(event.key(), event.text()) + return cls(Qt.Key(event.key()), event.text()) class NotInModeError(Exception): @@ -62,57 +69,129 @@ class NotInModeError(Exception): """Exception raised when we want to leave a mode we're not in.""" -def init(win_id, parent): +def init(win_id: int, parent: QObject) -> 'ModeManager': """Initialize the mode manager and the keyparsers for the given win_id.""" - KM = usertypes.KeyMode # noqa: N806 modeman = ModeManager(win_id, parent) objreg.register('mode-manager', modeman, scope='window', window=win_id) + + commandrunner = runners.CommandRunner(win_id) + + hintmanager = hints.HintManager(win_id, parent=parent) + objreg.register('hintmanager', hintmanager, scope='window', + window=win_id, command_only=True) + keyparsers = { - KM.normal: - modeparsers.NormalKeyParser(win_id, modeman), - KM.hint: - modeparsers.HintKeyParser(win_id, modeman), - KM.insert: - modeparsers.PassthroughKeyParser(win_id, 'insert', modeman), - KM.passthrough: - modeparsers.PassthroughKeyParser(win_id, 'passthrough', modeman), - KM.command: - modeparsers.PassthroughKeyParser(win_id, 'command', modeman), - KM.prompt: - modeparsers.PassthroughKeyParser(win_id, 'prompt', modeman), - KM.yesno: - modeparsers.PromptKeyParser(win_id, modeman), - KM.caret: - modeparsers.CaretKeyParser(win_id, modeman), - KM.set_mark: - modeparsers.RegisterKeyParser(win_id, KM.set_mark, modeman), - KM.jump_mark: - modeparsers.RegisterKeyParser(win_id, KM.jump_mark, modeman), - KM.record_macro: - modeparsers.RegisterKeyParser(win_id, KM.record_macro, modeman), - KM.run_macro: - modeparsers.RegisterKeyParser(win_id, KM.run_macro, modeman), - } + usertypes.KeyMode.normal: + modeparsers.NormalKeyParser( + win_id=win_id, + commandrunner=commandrunner, + parent=modeman), + + usertypes.KeyMode.hint: + modeparsers.HintKeyParser( + win_id=win_id, + commandrunner=commandrunner, + hintmanager=hintmanager, + parent=modeman), + + usertypes.KeyMode.insert: + modeparsers.PassthroughKeyParser( + win_id=win_id, + mode=usertypes.KeyMode.insert, + commandrunner=commandrunner, + parent=modeman), + + usertypes.KeyMode.passthrough: + modeparsers.PassthroughKeyParser( + win_id=win_id, + mode=usertypes.KeyMode.passthrough, + commandrunner=commandrunner, + parent=modeman), + + usertypes.KeyMode.command: + modeparsers.PassthroughKeyParser( + win_id=win_id, + mode=usertypes.KeyMode.command, + commandrunner=commandrunner, + parent=modeman), + + usertypes.KeyMode.prompt: + modeparsers.PassthroughKeyParser( + win_id=win_id, + mode=usertypes.KeyMode.prompt, + commandrunner=commandrunner, + parent=modeman), + + usertypes.KeyMode.yesno: + modeparsers.PromptKeyParser( + win_id=win_id, + commandrunner=commandrunner, + parent=modeman), + + usertypes.KeyMode.caret: + modeparsers.CaretKeyParser( + win_id=win_id, + commandrunner=commandrunner, + parent=modeman), + + usertypes.KeyMode.set_mark: + modeparsers.RegisterKeyParser( + win_id=win_id, + mode=usertypes.KeyMode.set_mark, + commandrunner=commandrunner, + parent=modeman), + + usertypes.KeyMode.jump_mark: + modeparsers.RegisterKeyParser( + win_id=win_id, + mode=usertypes.KeyMode.jump_mark, + commandrunner=commandrunner, + parent=modeman), + + usertypes.KeyMode.record_macro: + modeparsers.RegisterKeyParser( + win_id=win_id, + mode=usertypes.KeyMode.record_macro, + commandrunner=commandrunner, + parent=modeman), + + usertypes.KeyMode.run_macro: + modeparsers.RegisterKeyParser( + win_id=win_id, + mode=usertypes.KeyMode.run_macro, + commandrunner=commandrunner, + parent=modeman), + } # type: _ParserDictType + objreg.register('keyparsers', keyparsers, scope='window', window=win_id) - modeman.destroyed.connect( + + modeman.destroyed.connect( # type: ignore functools.partial(objreg.delete, 'keyparsers', scope='window', window=win_id)) + for mode, parser in keyparsers.items(): modeman.register(mode, parser) + return modeman -def instance(win_id): +def instance(win_id: int) -> 'ModeManager': """Get a modemanager object.""" return objreg.get('mode-manager', scope='window', window=win_id) -def enter(win_id, mode, reason=None, only_if_normal=False): +def enter(win_id: int, + mode: usertypes.KeyMode, + reason: str = None, + only_if_normal: bool = False) -> None: """Enter the mode 'mode'.""" instance(win_id).enter(mode, reason, only_if_normal) -def leave(win_id, mode, reason=None, *, maybe=False): +def leave(win_id: int, + mode: usertypes.KeyMode, + reason: str = None, *, + maybe: bool = False) -> None: """Leave the mode 'mode'.""" instance(win_id).leave(mode, reason, maybe=maybe) @@ -144,18 +223,19 @@ class ModeManager(QObject): entered = pyqtSignal(usertypes.KeyMode, int) left = pyqtSignal(usertypes.KeyMode, usertypes.KeyMode, int) - def __init__(self, win_id, parent=None): + def __init__(self, win_id: int, parent: QObject = None) -> None: super().__init__(parent) self._win_id = win_id - self._parsers = {} + self._parsers = {} # type: _ParserDictType self._prev_mode = usertypes.KeyMode.normal self.mode = usertypes.KeyMode.normal - self._releaseevents_to_pass = set() + self._releaseevents_to_pass = set() # type: typing.Set[KeyEvent] - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self, mode=self.mode) - def _handle_keypress(self, event, *, dry_run=False): + def _handle_keypress(self, event: QKeyEvent, *, + dry_run: bool = False) -> bool: """Handle filtering of KeyPress events. Args: @@ -199,7 +279,7 @@ class ModeManager(QObject): filter_this, focus_widget)) return filter_this - def _handle_keyrelease(self, event): + def _handle_keyrelease(self, event: QKeyEvent) -> bool: """Handle filtering of KeyRelease events. Args: @@ -219,19 +299,16 @@ class ModeManager(QObject): log.modes.debug("filter: {}".format(filter_this)) return filter_this - def register(self, mode, parser): - """Register a new mode. - - Args: - mode: The name of the mode. - parser: The KeyParser which should be used. - """ - assert isinstance(mode, usertypes.KeyMode) + def register(self, mode: usertypes.KeyMode, + parser: basekeyparser.BaseKeyParser) -> None: + """Register a new mode.""" assert parser is not None self._parsers[mode] = parser parser.request_leave.connect(self.leave) - def enter(self, mode, reason=None, only_if_normal=False): + def enter(self, mode: usertypes.KeyMode, + reason: str = None, + only_if_normal: bool = False) -> None: """Enter a new mode. Args: @@ -239,9 +316,6 @@ class ModeManager(QObject): reason: Why the mode was entered. only_if_normal: Only enter the new mode if we're in normal mode. """ - if not isinstance(mode, usertypes.KeyMode): - raise TypeError("Mode {} is no KeyMode member!".format(mode)) - if mode == usertypes.KeyMode.normal: self.leave(self.mode, reason='enter normal: {}'.format(reason)) return @@ -273,7 +347,7 @@ class ModeManager(QObject): self.entered.emit(mode, self._win_id) @cmdutils.register(instance='mode-manager', scope='window') - def enter_mode(self, mode): + def enter_mode(self, mode: str) -> None: """Enter a key mode. Args: @@ -294,7 +368,9 @@ class ModeManager(QObject): self.enter(m, 'command') @pyqtSlot(usertypes.KeyMode, str, bool) - def leave(self, mode, reason=None, maybe=False): + def leave(self, mode: usertypes.KeyMode, + reason: str = None, + maybe: bool = False) -> None: """Leave a key mode. Args: @@ -324,13 +400,13 @@ class ModeManager(QObject): @cmdutils.register(instance='mode-manager', name='leave-mode', not_modes=[usertypes.KeyMode.normal], scope='window') - def leave_current_mode(self): + def leave_current_mode(self) -> None: """Leave the mode we're currently in.""" if self.mode == usertypes.KeyMode.normal: raise ValueError("Can't leave normal mode!") self.leave(self.mode, 'leave current') - def handle_event(self, event): + def handle_event(self, event: QEvent) -> bool: """Filter all events based on the currently set mode. Also calls the real keypress handler. @@ -341,20 +417,16 @@ class ModeManager(QObject): Return: True if event should be filtered, False otherwise. """ - if self.mode is None: - # We got events before mode is set, so just pass them through. - return False - handlers = { QEvent.KeyPress: self._handle_keypress, QEvent.KeyRelease: self._handle_keyrelease, QEvent.ShortcutOverride: functools.partial(self._handle_keypress, dry_run=True), - } + } # type: typing.Mapping[QEvent.Type, typing.Callable[[QEvent], bool]] handler = handlers[event.type()] return handler(event) @cmdutils.register(instance='mode-manager', scope='window') - def clear_keychain(self): + def clear_keychain(self) -> None: """Clear the currently entered key chain.""" self._parsers[self.mode].clear_keystring() diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 84f7ffe76..742798e04 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -23,16 +23,20 @@ Module attributes: STARTCHARS: Possible chars for starting a commandline input. """ +import typing import traceback import enum -from PyQt5.QtCore import pyqtSlot, Qt -from PyQt5.QtGui import QKeySequence +from PyQt5.QtCore import pyqtSlot, Qt, QObject +from PyQt5.QtGui import QKeySequence, QKeyEvent -from qutebrowser.commands import runners, cmdexc +from qutebrowser.browser import hints +from qutebrowser.commands import cmdexc from qutebrowser.config import config -from qutebrowser.keyinput import basekeyparser, keyutils +from qutebrowser.keyinput import basekeyparser, keyutils, macros from qutebrowser.utils import usertypes, log, message, objreg, utils +if typing.TYPE_CHECKING: + from qutebrowser.commands import runners STARTCHARS = ":/?" @@ -47,11 +51,13 @@ class CommandKeyParser(basekeyparser.BaseKeyParser): _commandrunner: CommandRunner instance. """ - def __init__(self, win_id, parent=None, supports_count=None): - super().__init__(win_id, parent, supports_count) - self._commandrunner = runners.CommandRunner(win_id) + def __init__(self, win_id: int, + commandrunner: 'runners.CommandRunner', + parent: QObject = None) -> None: + super().__init__(win_id, parent) + self._commandrunner = commandrunner - def execute(self, cmdstr, count=None): + def execute(self, cmdstr: str, count: int = None) -> None: try: self._commandrunner.run(cmdstr, count) except cmdexc.Error as e: @@ -66,8 +72,10 @@ class NormalKeyParser(CommandKeyParser): _partial_timer: Timer to clear partial keypresses. """ - def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=True) + def __init__(self, win_id: int, + commandrunner: 'runners.CommandRunner', + parent: QObject = None) -> None: + super().__init__(win_id, commandrunner, parent) self._read_config('normal') self._partial_timer = usertypes.Timer(self, 'partial-match') self._partial_timer.setSingleShot(True) @@ -76,20 +84,12 @@ class NormalKeyParser(CommandKeyParser): self._inhibited_timer = usertypes.Timer(self, 'normal-inhibited') self._inhibited_timer.setSingleShot(True) - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self) - def handle(self, e, *, dry_run=False): - """Override to abort if the key is a startchar. - - Args: - e: the KeyPressEvent from Qt. - dry_run: Don't actually execute anything, only check whether there - would be a match. - - Return: - A self.Match member. - """ + def handle(self, e: QKeyEvent, *, + dry_run: bool = False) -> QKeySequence.SequenceMatch: + """Override to abort if the key is a startchar.""" txt = e.text().strip() if self._inhibited: self._debug_log("Ignoring key '{}', because the normal mode is " @@ -105,7 +105,7 @@ class NormalKeyParser(CommandKeyParser): self._partial_timer.start() return match - def set_inhibited_timeout(self, timeout): + def set_inhibited_timeout(self, timeout: int) -> None: """Ignore keypresses for the given duration.""" if timeout != 0: self._debug_log("Inhibiting the normal mode for {}ms.".format( @@ -116,7 +116,7 @@ class NormalKeyParser(CommandKeyParser): self._inhibited_timer.start() @pyqtSlot() - def _clear_partial_match(self): + def _clear_partial_match(self) -> None: """Clear a partial keystring after a timeout.""" self._debug_log("Clearing partial keystring {}".format( self._sequence)) @@ -124,7 +124,7 @@ class NormalKeyParser(CommandKeyParser): self.keystring_updated.emit(str(self._sequence)) @pyqtSlot() - def _clear_inhibited(self): + def _clear_inhibited(self) -> None: """Reset inhibition state after a timeout.""" self._debug_log("Releasing inhibition state of normal mode.") self._inhibited = False @@ -142,8 +142,12 @@ class PassthroughKeyParser(CommandKeyParser): do_log = False passthrough = True + supports_count = False - def __init__(self, win_id, mode, parent=None): + def __init__(self, win_id: int, + mode: usertypes.KeyMode, + commandrunner: 'runners.CommandRunner', + parent: QObject = None) -> None: """Constructor. Args: @@ -151,11 +155,11 @@ class PassthroughKeyParser(CommandKeyParser): parent: Qt parent. warn: Whether to warn if an ignored key was bound. """ - super().__init__(win_id, parent) - self._read_config(mode) + super().__init__(win_id, commandrunner, parent) + self._read_config(mode.name) self._mode = mode - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self, mode=self._mode) @@ -163,11 +167,15 @@ class PromptKeyParser(CommandKeyParser): """KeyParser for yes/no prompts.""" - def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=False) + supports_count = False + + def __init__(self, win_id: int, + commandrunner: 'runners.CommandRunner', + parent: QObject = None) -> None: + super().__init__(win_id, commandrunner, parent) self._read_config('yesno') - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self) @@ -177,31 +185,27 @@ class HintKeyParser(CommandKeyParser): Attributes: _filtertext: The text to filter with. + _hintmanager: The HintManager to use. _last_press: The nature of the last keypress, a LastPress member. """ - def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=False) + supports_count = False + + def __init__(self, win_id: int, + commandrunner: 'runners.CommandRunner', + hintmanager: hints.HintManager, + parent: QObject = None) -> None: + super().__init__(win_id, commandrunner, parent) + self._hintmanager = hintmanager self._filtertext = '' self._last_press = LastPress.none self._read_config('hint') - self.keystring_updated.connect(self.on_keystring_updated) + self.keystring_updated.connect(self._hintmanager.handle_partial_key) - def _handle_filter_key(self, e): - """Handle keys for string filtering. - - Return True if the keypress has been handled, and False if not. - - Args: - e: the KeyPressEvent from Qt. - - Return: - A QKeySequence match. - """ + def _handle_filter_key(self, e: QKeyEvent) -> QKeySequence.SequenceMatch: + """Handle keys for string filtering.""" log.keyboard.debug("Got filter key 0x{:x} text {}".format( e.key(), e.text())) - hintmanager = objreg.get('hintmanager', scope='tab', - window=self._win_id, tab='current') if e.key() == Qt.Key_Backspace: log.keyboard.debug("Got backspace, mode {}, filtertext '{}', " "sequence '{}'".format(self._last_press, @@ -209,7 +213,7 @@ class HintKeyParser(CommandKeyParser): self._sequence)) if self._last_press != LastPress.keystring and self._filtertext: self._filtertext = self._filtertext[:-1] - hintmanager.filter_hints(self._filtertext) + self._hintmanager.filter_hints(self._filtertext) return QKeySequence.ExactMatch elif self._last_press == LastPress.keystring and self._sequence: self._sequence = self._sequence[:-1] @@ -217,36 +221,28 @@ class HintKeyParser(CommandKeyParser): if not self._sequence and self._filtertext: # Switch back to hint filtering mode (this can happen only # in numeric mode after the number has been deleted). - hintmanager.filter_hints(self._filtertext) + self._hintmanager.filter_hints(self._filtertext) self._last_press = LastPress.filtertext return QKeySequence.ExactMatch else: return QKeySequence.NoMatch - elif hintmanager.current_mode() != 'number': + elif self._hintmanager.current_mode() != 'number': return QKeySequence.NoMatch elif not e.text(): return QKeySequence.NoMatch else: self._filtertext += e.text() - hintmanager.filter_hints(self._filtertext) + self._hintmanager.filter_hints(self._filtertext) self._last_press = LastPress.filtertext return QKeySequence.ExactMatch - def handle(self, e, *, dry_run=False): - """Handle a new keypress and call the respective handlers. - - Args: - e: the KeyPressEvent from Qt - dry_run: Don't actually execute anything, only check whether there - would be a match. - - Returns: - True if the match has been handled, False otherwise. - """ + def handle(self, e: QKeyEvent, *, + dry_run: bool = False) -> QKeySequence.SequenceMatch: + """Handle a new keypress and call the respective handlers.""" if dry_run: return super().handle(e, dry_run=True) - if keyutils.is_special_hint_mode(e.key(), e.modifiers()): + if keyutils.is_special_hint_mode(Qt.Key(e.key()), e.modifiers()): log.keyboard.debug("Got special key, clearing keychain") self.clear_keystring() @@ -265,7 +261,8 @@ class HintKeyParser(CommandKeyParser): return match - def update_bindings(self, strings, preserve_filter=False): + def update_bindings(self, strings: typing.Sequence[str], + preserve_filter: bool = False) -> None: """Update bindings when the hint strings changed. Args: @@ -279,13 +276,6 @@ class HintKeyParser(CommandKeyParser): if not preserve_filter: self._filtertext = '' - @pyqtSlot(str) - def on_keystring_updated(self, keystr): - """Update hintmanager when the keystring was updated.""" - hintmanager = objreg.get('hintmanager', scope='tab', - window=self._win_id, tab='current') - hintmanager.handle_partial_key(keystr) - class CaretKeyParser(CommandKeyParser): @@ -293,8 +283,10 @@ class CaretKeyParser(CommandKeyParser): passthrough = True - def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=True) + def __init__(self, win_id: int, + commandrunner: 'runners.CommandRunner', + parent: QObject = None) -> None: + super().__init__(win_id, commandrunner, parent) self._read_config('caret') @@ -307,27 +299,24 @@ class RegisterKeyParser(CommandKeyParser): and KeyMode.run_macro. """ - def __init__(self, win_id, mode, parent=None): - super().__init__(win_id, parent, supports_count=False) + supports_count = False + + def __init__(self, win_id: int, + mode: usertypes.KeyMode, + commandrunner: 'runners.CommandRunner', + parent: QObject = None) -> None: + super().__init__(win_id, commandrunner, parent) self._mode = mode self._read_config('register') - def handle(self, e, *, dry_run=False): - """Override handle to always match the next key and use the register. - - Args: - e: the KeyPressEvent from Qt. - dry_run: Don't actually execute anything, only check whether there - would be a match. - - Return: - True if event has been handled, False otherwise. - """ + def handle(self, e: QKeyEvent, *, + dry_run: bool = False) -> QKeySequence.SequenceMatch: + """Override to always match the next key and use the register.""" match = super().handle(e, dry_run=dry_run) if match or dry_run: return match - if keyutils.is_special(e.key(), e.modifiers()): + if keyutils.is_special(Qt.Key(e.key()), e.modifiers()): # this is not a proper register key, let it pass and keep going return QKeySequence.NoMatch @@ -335,7 +324,6 @@ class RegisterKeyParser(CommandKeyParser): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) - macro_recorder = objreg.get('macro-recorder') try: if self._mode == usertypes.KeyMode.set_mark: @@ -343,9 +331,9 @@ class RegisterKeyParser(CommandKeyParser): elif self._mode == usertypes.KeyMode.jump_mark: tabbed_browser.jump_mark(key) elif self._mode == usertypes.KeyMode.record_macro: - macro_recorder.record_macro(key) + macros.macro_recorder.record_macro(key) elif self._mode == usertypes.KeyMode.run_macro: - macro_recorder.run_macro(self._win_id, key) + macros.macro_recorder.run_macro(self._win_id, key) else: raise ValueError( "{} is not a valid register mode".format(self._mode)) diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 8d571c7eb..fe0f19ed6 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -37,7 +37,7 @@ from qutebrowser.mainwindow import messageview, prompt from qutebrowser.completion import completionwidget, completer from qutebrowser.keyinput import modeman from qutebrowser.browser import commands, downloadview, hints, downloads -from qutebrowser.misc import crashsignal, keyhintwidget +from qutebrowser.misc import crashsignal, keyhintwidget, sessions win_id_gen = itertools.count(0) @@ -139,6 +139,7 @@ class MainWindow(QWidget): tabbed_browser: The TabbedBrowser widget. state_before_fullscreen: window state before activation of fullscreen. _downloadview: The DownloadView widget. + _download_model: The DownloadModel instance. _vbox: The main QVBoxLayout. _commandrunner: The main CommandRunner instance. _overlays: Widgets shown as overlay for the current webpage. @@ -193,7 +194,8 @@ class MainWindow(QWidget): self._vbox.setSpacing(0) self._init_downloadmanager() - self._downloadview = downloadview.DownloadView(self.win_id) + self._downloadview = downloadview.DownloadView( + model=self._download_model) if config.val.content.private_browsing: # This setting always trumps what's passed in. @@ -220,7 +222,7 @@ class MainWindow(QWidget): self._init_completion() log.init.debug("Initializing modes...") - modeman.init(self.win_id, self) + modeman.init(win_id=self.win_id, parent=self) self._commandrunner = runners.CommandRunner(self.win_id, partial_match=True) @@ -233,7 +235,7 @@ class MainWindow(QWidget): self._prompt_container.update_geometry, centered=True, padding=10) objreg.register('prompt-container', self._prompt_container, - scope='window', window=self.win_id) + scope='window', window=self.win_id, command_only=True) self._prompt_container.hide() self._messageview = messageview.MessageView(parent=self) @@ -248,7 +250,7 @@ class MainWindow(QWidget): QTimer.singleShot(0, self._connect_overlay_signals) config.instance.changed.connect(self._on_config_changed) - objreg.get("app").new_window.emit(self) + QApplication.instance().new_window.emit(self) self._set_decoration(config.val.window.hide_decoration) self.state_before_fullscreen = self.windowState() @@ -329,27 +331,32 @@ class MainWindow(QWidget): except KeyError: webengine_download_manager = None - download_model = downloads.DownloadModel(qtnetwork_download_manager, - webengine_download_manager) - objreg.register('download-model', download_model, scope='window', - window=self.win_id) + self._download_model = downloads.DownloadModel( + qtnetwork_download_manager, webengine_download_manager) + objreg.register('download-model', self._download_model, + scope='window', window=self.win_id, + command_only=True) def _init_completion(self): - self._completion = completionwidget.CompletionView(self.win_id, self) - cmd = objreg.get('status-command', scope='window', window=self.win_id) - completer_obj = completer.Completer(cmd=cmd, win_id=self.win_id, + self._completion = completionwidget.CompletionView(cmd=self.status.cmd, + win_id=self.win_id, + parent=self) + completer_obj = completer.Completer(cmd=self.status.cmd, + win_id=self.win_id, parent=self._completion) self._completion.selection_changed.connect( completer_obj.on_selection_changed) objreg.register('completion', self._completion, scope='window', - window=self.win_id) + window=self.win_id, command_only=True) self._add_overlay(self._completion, self._completion.update_geometry) def _init_command_dispatcher(self): - dispatcher = commands.CommandDispatcher(self.win_id, - self.tabbed_browser) - objreg.register('command-dispatcher', dispatcher, scope='window', - window=self.win_id) + self._command_dispatcher = commands.CommandDispatcher( + self.win_id, self.tabbed_browser) + objreg.register('command-dispatcher', + self._command_dispatcher, + command_only=True, + scope='window', window=self.win_id) self.tabbed_browser.widget.destroyed.connect( functools.partial(objreg.delete, 'command-dispatcher', scope='window', window=self.win_id)) @@ -445,10 +452,7 @@ class MainWindow(QWidget): def _connect_signals(self): """Connect all mainwindow signals.""" - status = self._get_object('statusbar') keyparsers = self._get_object('keyparsers') - completion_obj = self._get_object('completion') - cmd = self._get_object('status-command') message_bridge = self._get_object('message-bridge') mode_manager = self._get_object('mode-manager') @@ -457,17 +461,22 @@ class MainWindow(QWidget): mode_manager.entered.connect(hints.on_mode_entered) # status bar - mode_manager.entered.connect(status.on_mode_entered) - mode_manager.left.connect(status.on_mode_left) - mode_manager.left.connect(cmd.on_mode_left) + mode_manager.entered.connect(self.status.on_mode_entered) + mode_manager.left.connect(self.status.on_mode_left) + mode_manager.left.connect(self.status.cmd.on_mode_left) mode_manager.left.connect(message.global_bridge.mode_left) # commands keyparsers[usertypes.KeyMode.normal].keystring_updated.connect( - status.keystring.setText) - cmd.got_cmd[str].connect(self._commandrunner.run_safely) - cmd.got_cmd[str, int].connect(self._commandrunner.run_safely) - cmd.returnPressed.connect(self.tabbed_browser.on_cmd_return_pressed) + self.status.keystring.setText) + self.status.cmd.got_cmd[str].connect( + self._commandrunner.run_safely) + self.status.cmd.got_cmd[str, int].connect( + self._commandrunner.run_safely) + self.status.cmd.returnPressed.connect( + self.tabbed_browser.on_cmd_return_pressed) + self.status.cmd.got_search.connect( + self._command_dispatcher.search) # key hint popup for mode, parser in keyparsers.items(): @@ -481,42 +490,51 @@ class MainWindow(QWidget): message.global_bridge.clear_messages.connect( self._messageview.clear_messages) - message_bridge.s_set_text.connect(status.set_text) - message_bridge.s_maybe_reset_text.connect(status.txt.maybe_reset_text) + message_bridge.s_set_text.connect(self.status.set_text) + message_bridge.s_maybe_reset_text.connect( + self.status.txt.maybe_reset_text) # statusbar - self.tabbed_browser.current_tab_changed.connect(status.on_tab_changed) + self.tabbed_browser.current_tab_changed.connect( + self.status.on_tab_changed) - self.tabbed_browser.cur_progress.connect(status.prog.on_load_progress) + self.tabbed_browser.cur_progress.connect( + self.status.prog.on_load_progress) self.tabbed_browser.cur_load_started.connect( - status.prog.on_load_started) + self.status.prog.on_load_started) self.tabbed_browser.cur_scroll_perc_changed.connect( - status.percentage.set_perc) + self.status.percentage.set_perc) self.tabbed_browser.widget.tab_index_changed.connect( - status.tabindex.on_tab_index_changed) + self.status.tabindex.on_tab_index_changed) - self.tabbed_browser.cur_url_changed.connect(status.url.set_url) + self.tabbed_browser.cur_url_changed.connect( + self.status.url.set_url) self.tabbed_browser.cur_url_changed.connect(functools.partial( - status.backforward.on_tab_cur_url_changed, + self.status.backforward.on_tab_cur_url_changed, tabs=self.tabbed_browser)) - self.tabbed_browser.cur_link_hovered.connect(status.url.set_hover_url) + self.tabbed_browser.cur_link_hovered.connect( + self.status.url.set_hover_url) self.tabbed_browser.cur_load_status_changed.connect( - status.url.on_load_status_changed) + self.status.url.on_load_status_changed) self.tabbed_browser.cur_caret_selection_toggled.connect( - status.on_caret_selection_toggled) + self.status.on_caret_selection_toggled) self.tabbed_browser.cur_fullscreen_requested.connect( self._on_fullscreen_requested) - self.tabbed_browser.cur_fullscreen_requested.connect(status.maybe_hide) + self.tabbed_browser.cur_fullscreen_requested.connect( + self.status.maybe_hide) # command input / completion - mode_manager.entered.connect(self.tabbed_browser.on_mode_entered) - mode_manager.left.connect(self.tabbed_browser.on_mode_left) - cmd.clear_completion_selection.connect( - completion_obj.on_clear_completion_selection) - cmd.hide_completion.connect(completion_obj.hide) + mode_manager.entered.connect( + self.tabbed_browser.on_mode_entered) + mode_manager.left.connect( + self.tabbed_browser.on_mode_left) + self.status.cmd.clear_completion_selection.connect( + self._completion.on_clear_completion_selection) + self.status.cmd.hide_completion.connect( + self._completion.hide) def _set_decoration(self, hidden): """Set the visibility of the window decoration via Qt.""" @@ -579,7 +597,7 @@ class MainWindow(QWidget): objreg.delete('last-visible-main-window') except KeyError: pass - objreg.get('session-manager').save_last_window_session() + sessions.session_manager.save_last_window_session() self._save_geometry() log.destroy.debug("Closing window {}".format(self.win_id)) self.tabbed_browser.shutdown() @@ -590,9 +608,7 @@ class MainWindow(QWidget): e.accept() return tab_count = self.tabbed_browser.widget.count() - download_model = objreg.get('download-model', scope='window', - window=self.win_id) - download_count = download_model.running_downloads() + download_count = self._download_model.running_downloads() quit_texts = [] # Ask if multiple-tabs are open if 'multiple-tabs' in config.val.confirm_quit and tab_count > 1: diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py index 623e3ecad..81538a68a 100644 --- a/qutebrowser/mainwindow/prompt.py +++ b/qutebrowser/mainwindow/prompt.py @@ -23,6 +23,7 @@ import os.path import html import collections import functools +import typing import attr from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QTimer, QDir, QModelIndex, @@ -39,7 +40,7 @@ from qutebrowser.api import cmdutils from qutebrowser.utils import urlmatch -prompt_queue = None +prompt_queue = typing.cast('PromptQueue', None) @attr.s @@ -949,6 +950,5 @@ def init(): """Initialize global prompt objects.""" global prompt_queue prompt_queue = PromptQueue() - objreg.register('prompt-queue', prompt_queue) # for commands message.global_bridge.ask_question.connect( prompt_queue.ask_question, Qt.DirectConnection) diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py index 42b3f4652..52e09cd70 100644 --- a/qutebrowser/mainwindow/statusbar/bar.py +++ b/qutebrowser/mainwindow/statusbar/bar.py @@ -157,7 +157,6 @@ class StatusBar(QWidget): def __init__(self, *, win_id, private, parent=None): super().__init__(parent) - objreg.register('statusbar', self, scope='window', window=win_id) self.setObjectName(self.__class__.__name__) self.setAttribute(Qt.WA_StyledBackground) config.set_register_stylesheet(self) diff --git a/qutebrowser/mainwindow/statusbar/command.py b/qutebrowser/mainwindow/statusbar/command.py index bdac24d61..5d0d73a61 100644 --- a/qutebrowser/mainwindow/statusbar/command.py +++ b/qutebrowser/mainwindow/statusbar/command.py @@ -19,10 +19,10 @@ """The commandline in the statusbar.""" -import functools from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QSize -from PyQt5.QtWidgets import QSizePolicy +from PyQt5.QtGui import QKeyEvent +from PyQt5.QtWidgets import QSizePolicy, QWidget from qutebrowser.keyinput import modeman, modeparsers from qutebrowser.api import cmdutils @@ -42,6 +42,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): Signals: got_cmd: Emitted when a command is triggered by the user. arg: The command string and also potentially the count. + got_search: Emitted when a search should happen. clear_completion_selection: Emitted before the completion widget is hidden. hide_completion: Emitted when the completion widget should be hidden. @@ -51,13 +52,16 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): """ got_cmd = pyqtSignal([str], [str, int]) + got_search = pyqtSignal(str, bool) # text, reverse clear_completion_selection = pyqtSignal() hide_completion = pyqtSignal() update_completion = pyqtSignal() show_cmd = pyqtSignal() hide_cmd = pyqtSignal() - def __init__(self, *, win_id, private, parent=None): + def __init__(self, *, win_id: int, + private: bool, + parent: QWidget = None) -> None: misc.CommandLineEdit.__init__(self, parent=parent) misc.MinimalLineEditMixin.__init__(self) self._win_id = win_id @@ -66,32 +70,29 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): self.history.history = command_history.data self.history.changed.connect(command_history.changed) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Ignored) - self.cursorPositionChanged.connect(self.update_completion) - self.textChanged.connect(self.update_completion) + + self.cursorPositionChanged.connect( + self.update_completion) # type: ignore + self.textChanged.connect(self.update_completion) # type: ignore self.textChanged.connect(self.updateGeometry) self.textChanged.connect(self._incremental_search) - self._command_dispatcher = objreg.get( - 'command-dispatcher', scope='window', window=self._win_id) - - def _handle_search(self): + def _handle_search(self) -> bool: """Check if the currently entered text is a search, and if so, run it. Return: True if a search was executed, False otherwise. """ - search_prefixes = { - '/': self._command_dispatcher.search, - '?': functools.partial( - self._command_dispatcher.search, reverse=True) - } - if self.prefix() in search_prefixes: - search_fn = search_prefixes[self.prefix()] - search_fn(self.text()[1:]) + if self.prefix() == '/': + self.got_search.emit(self.text()[1:], False) return True - return False + elif self.prefix() == '?': + self.got_search.emit(self.text()[1:], True) + return True + else: + return False - def prefix(self): + def prefix(self) -> str: """Get the currently entered command prefix.""" text = self.text() if not text: @@ -101,7 +102,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): else: return '' - def set_cmd_text(self, text): + def set_cmd_text(self, text: str) -> None: """Preset the statusbar to some text. Args: @@ -116,8 +117,11 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): @cmdutils.register(instance='status-command', name='set-cmd-text', scope='window', maxsplit=0) @cmdutils.argument('count', value=cmdutils.Value.count) - def set_cmd_text_command(self, text, count=None, space=False, append=False, - run_on_count=False): + def set_cmd_text_command(self, text: str, + count: int = None, + space: bool = False, + append: bool = False, + run_on_count: bool = False) -> None: """Preset the statusbar to some text. // @@ -144,13 +148,13 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): raise cmdutils.CommandError( "Invalid command text '{}'.".format(text)) if run_on_count and count is not None: - self.got_cmd[str, int].emit(text, count) + self.got_cmd[str, int].emit(text, count) # type: ignore else: self.set_cmd_text(text) @cmdutils.register(instance='status-command', modes=[usertypes.KeyMode.command], scope='window') - def command_history_prev(self): + def command_history_prev(self) -> None: """Go back in the commandline history.""" try: if not self.history.is_browsing(): @@ -165,7 +169,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): @cmdutils.register(instance='status-command', modes=[usertypes.KeyMode.command], scope='window') - def command_history_next(self): + def command_history_next(self) -> None: """Go forward in the commandline history.""" if not self.history.is_browsing(): return @@ -178,7 +182,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): @cmdutils.register(instance='status-command', modes=[usertypes.KeyMode.command], scope='window') - def command_accept(self, rapid=False): + def command_accept(self, rapid: bool = False) -> None: """Execute the command currently in the commandline. Args: @@ -194,10 +198,10 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): 'cmd accept') if not was_search: - self.got_cmd[str].emit(text[1:]) + self.got_cmd[str].emit(text[1:]) # type: ignore @cmdutils.register(instance='status-command', scope='window') - def edit_command(self, run=False): + def edit_command(self, run: bool = False) -> None: """Open an editor to modify the current command. Args: @@ -205,7 +209,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): """ ed = editor.ExternalEditor(parent=self) - def callback(text): + def callback(text: str) -> None: """Set the commandline to the edited text.""" if not text or text[0] not in modeparsers.STARTCHARS: message.error('command must start with one of {}' @@ -219,7 +223,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): ed.edit(self.text()) @pyqtSlot(usertypes.KeyMode) - def on_mode_left(self, mode): + def on_mode_left(self, mode: usertypes.KeyMode) -> None: """Clear up when command mode was left. - Clear the statusbar text if it's explicitly unfocused. @@ -236,7 +240,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): self.clear_completion_selection.emit() self.hide_completion.emit() - def setText(self, text): + def setText(self, text: str) -> None: """Extend setText to set prefix and make sure the prompt is ok.""" if not text: pass @@ -247,7 +251,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): "'{}'!".format(text)) super().setText(text) - def keyPressEvent(self, e): + def keyPressEvent(self, e: QKeyEvent) -> None: """Override keyPressEvent to ignore Return key presses. If this widget is focused, we are in passthrough key mode, and @@ -266,7 +270,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): else: super().keyPressEvent(e) - def sizeHint(self): + def sizeHint(self) -> QSize: """Dynamically calculate the needed size.""" height = super().sizeHint().height() text = self.text() @@ -276,7 +280,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): return QSize(width, height) @pyqtSlot() - def _incremental_search(self): + def _incremental_search(self) -> None: if not config.val.search.incremental: return diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 1953c2012..43b194159 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -153,8 +153,9 @@ class TabWidget(QTabWidget): if tabbar.tabText(idx) != title: tabbar.setTabText(idx, title) - # always show only plain title in tooltips - tabbar.setTabToolTip(idx, fields['current_title']) + if config.cache['tabs.tooltips']: + # always show only plain title in tooltips + tabbar.setTabToolTip(idx, fields['current_title']) def get_tab_fields(self, idx): """Get the tab field data.""" diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index 812c058ff..a8d950767 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -27,17 +27,21 @@ import ctypes import ctypes.util import enum import shutil +import typing +import argparse import attr from PyQt5.QtCore import Qt from PyQt5.QtWidgets import (QApplication, QDialog, QPushButton, QHBoxLayout, - QVBoxLayout, QLabel, QMessageBox) + QVBoxLayout, QLabel, QMessageBox, QWidget) from PyQt5.QtNetwork import QSslSocket from qutebrowser.config import config, configfiles -from qutebrowser.utils import (usertypes, objreg, version, qtutils, log, utils, +from qutebrowser.utils import (usertypes, version, qtutils, log, utils, standarddir) -from qutebrowser.misc import objects, msgbox +from qutebrowser.misc import objects, msgbox, savemanager +if typing.TYPE_CHECKING: + from qutebrowser import app class _Result(enum.IntEnum): @@ -55,13 +59,15 @@ class _Button: """A button passed to BackendProblemDialog.""" - text = attr.ib() - setting = attr.ib() - value = attr.ib() - default = attr.ib(default=False) + text = attr.ib() # type: str + setting = attr.ib() # type: str + value = attr.ib() # type: str + default = attr.ib(default=False) # type: bool -def _other_backend(backend): +def _other_backend( + backend: usertypes.Backend +) -> typing.Tuple[usertypes.Backend, str]: """Get the other backend enum/setting for a given backend.""" other_backend = { usertypes.Backend.QtWebKit: usertypes.Backend.QtWebEngine, @@ -71,7 +77,7 @@ def _other_backend(backend): return (other_backend, other_setting) -def _error_text(because, text, backend): +def _error_text(because: str, text: str, backend: usertypes.Backend) -> str: """Get an error text for the given information.""" other_backend, other_setting = _other_backend(backend) if other_backend == usertypes.Backend.QtWebKit: @@ -96,14 +102,19 @@ class _Dialog(QDialog): """A dialog which gets shown if there are issues with the backend.""" - def __init__(self, because, text, backend, buttons=None, parent=None): + def __init__(self, *, because: str, + text: str, + backend: usertypes.Backend, + buttons: typing.Sequence[_Button] = None, + parent: QWidget = None) -> None: super().__init__(parent) vbox = QVBoxLayout(self) other_backend, other_setting = _other_backend(backend) text = _error_text(because, text, backend) - label = QLabel(text, wordWrap=True) + label = QLabel(text) + label.setWordWrap(True) label.setTextFormat(Qt.RichText) vbox.addWidget(label) @@ -123,18 +134,17 @@ class _Dialog(QDialog): hbox.addWidget(backend_button) for button in buttons: - btn = QPushButton(button.text, default=button.default) + btn = QPushButton(button.text) + btn.setDefault(button.default) btn.clicked.connect(functools.partial( self._change_setting, button.setting, button.value)) hbox.addWidget(btn) vbox.addLayout(hbox) - def _change_setting(self, setting, value): + def _change_setting(self, setting: str, value: str) -> None: """Change the given setting and restart.""" config.instance.set_obj(setting, value, save_yaml=True) - save_manager = objreg.get('save-manager') - save_manager.save_all(is_exit=True) if setting == 'backend' and value == 'webkit': self.done(_Result.restart_webkit) @@ -144,296 +154,318 @@ class _Dialog(QDialog): self.done(_Result.restart) -def _show_dialog(*args, **kwargs): - """Show a dialog for a backend problem.""" - cmd_args = objreg.get('args') - if cmd_args.no_err_windows: - text = _error_text(*args, **kwargs) - print(text, file=sys.stderr) - sys.exit(usertypes.Exit.err_init) - - dialog = _Dialog(*args, **kwargs) - - status = dialog.exec_() - quitter = objreg.get('quitter') - - if status in [_Result.quit, QDialog.Rejected]: - pass - elif status == _Result.restart_webkit: - quitter.restart(override_args={'backend': 'webkit'}) - elif status == _Result.restart_webengine: - quitter.restart(override_args={'backend': 'webengine'}) - elif status == _Result.restart: - quitter.restart() - else: - raise utils.Unreachable(status) - - sys.exit(usertypes.Exit.err_init) - - -def _nvidia_shader_workaround(): - """Work around QOpenGLShaderProgram issues. - - NOTE: This needs to be called before _handle_nouveau_graphics, or some - setups will segfault in version.opengl_vendor(). - - See https://bugs.launchpad.net/ubuntu/+source/python-qt4/+bug/941826 - """ - assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend - - if os.environ.get('QUTE_SKIP_LIBGL_WORKAROUND'): - return - - libgl = ctypes.util.find_library("GL") - if libgl is not None: - ctypes.CDLL(libgl, mode=ctypes.RTLD_GLOBAL) - - -def _handle_nouveau_graphics(): - """Force software rendering when using the Nouveau driver. - - WORKAROUND for https://bugreports.qt.io/browse/QTBUG-41242 - Should be fixed in Qt 5.10 via https://codereview.qt-project.org/#/c/208664/ - """ - assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend - - if os.environ.get('QUTE_SKIP_NOUVEAU_CHECK'): - return - - if qtutils.version_check('5.10', compiled=False): - return - - if version.opengl_vendor() != 'nouveau': - return - - if (os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1' or - # qt.force_software_rendering = 'software-opengl' - 'QT_XCB_FORCE_SOFTWARE_OPENGL' in os.environ or - # qt.force_software_rendering = 'chromium', also see: - # https://build.opensuse.org/package/view_file/openSUSE:Factory/libqt5-qtwebengine/disable-gpu-when-using-nouveau-boo-1005323.diff?expand=1 - 'QT_WEBENGINE_DISABLE_NOUVEAU_WORKAROUND' in os.environ): - return - - button = _Button("Force software rendering", 'qt.force_software_rendering', - 'chromium') - _show_dialog( - backend=usertypes.Backend.QtWebEngine, - because="you're using Nouveau graphics", - text="

There are two ways to fix this:

" - "

Forcing software rendering

" - "

This allows you to use the newer QtWebEngine backend (based " - "on Chromium) but could have noticeable performance impact " - "(depending on your hardware). " - "This sets the qt.force_software_rendering = 'chromium' " - "option (if you have a config.py file, you'll need to set " - "this manually).

", - buttons=[button], - ) - - raise utils.Unreachable - - -def _handle_wayland(): - assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend - - if os.environ.get('QUTE_SKIP_WAYLAND_CHECK'): - return - - platform = QApplication.instance().platformName() - if platform not in ['wayland', 'wayland-egl']: - return - - has_qt511 = qtutils.version_check('5.11', compiled=False) - if has_qt511 and config.val.qt.force_software_rendering == 'chromium': - return - - if qtutils.version_check('5.11.2', compiled=False): - return - - buttons = [] - text = "

You can work around this in one of the following ways:

" - - if 'DISPLAY' in os.environ: - # XWayland is available, but QT_QPA_PLATFORM=wayland is set - buttons.append(_Button("Force XWayland", 'qt.force_platform', 'xcb')) - text += ("

Force Qt to use XWayland

" - "

This allows you to use the newer QtWebEngine backend " - "(based on Chromium). " - "This sets the qt.force_platform = 'xcb' option " - "(if you have a config.py file, you'll need to set " - "this manually).

") - else: - text += ("

Set up XWayland

" - "

This allows you to use the newer QtWebEngine backend " - "(based on Chromium). ") - - if has_qt511: - buttons.append(_Button("Force software rendering", - 'qt.force_software_rendering', - 'chromium')) - text += ("

Forcing software rendering

" - "

This allows you to use the newer QtWebEngine backend " - "(based on Chromium) but could have noticeable performance " - "impact (depending on your hardware). This sets the " - "qt.force_software_rendering = 'chromium' option " - "(if you have a config.py file, you'll need to set " - "this manually).

") - - _show_dialog(backend=usertypes.Backend.QtWebEngine, - because="you're using Wayland", text=text, buttons=buttons) - - @attr.s -class BackendImports: +class _BackendImports: """Whether backend modules could be imported.""" - webkit_available = attr.ib(default=None) - webengine_available = attr.ib(default=None) - webkit_error = attr.ib(default=None) - webengine_error = attr.ib(default=None) + webkit_available = attr.ib(default=None) # type: bool + webengine_available = attr.ib(default=None) # type: bool + webkit_error = attr.ib(default=None) # type: str + webengine_error = attr.ib(default=None) # type: str -def _try_import_backends(): - """Check whether backends can be imported and return BackendImports.""" - # pylint: disable=unused-import - results = BackendImports() +class _BackendProblemChecker: - try: - from PyQt5 import QtWebKit - from PyQt5 import QtWebKitWidgets - except ImportError as e: - results.webkit_available = False - results.webkit_error = str(e) - else: - if qtutils.is_new_qtwebkit(): - results.webkit_available = True + """Check for various backend-specific issues.""" + + def __init__(self, *, + quitter: 'app.Quitter', + no_err_windows: bool, + save_manager: savemanager.SaveManager) -> None: + self._quitter = quitter + self._save_manager = save_manager + self._no_err_windows = no_err_windows + + def _show_dialog(self, *args: typing.Any, **kwargs: typing.Any) -> None: + """Show a dialog for a backend problem.""" + if self._no_err_windows: + text = _error_text(*args, **kwargs) + print(text, file=sys.stderr) + sys.exit(usertypes.Exit.err_init) + + dialog = _Dialog(*args, **kwargs) + + status = dialog.exec_() + self._save_manager.save_all(is_exit=True) + + if status in [_Result.quit, QDialog.Rejected]: + pass + elif status == _Result.restart_webkit: + self._quitter.restart(override_args={'backend': 'webkit'}) + elif status == _Result.restart_webengine: + self._quitter.restart(override_args={'backend': 'webengine'}) + elif status == _Result.restart: + self._quitter.restart() else: - results.webkit_available = False - results.webkit_error = "Unsupported legacy QtWebKit found" + raise utils.Unreachable(status) - try: - from PyQt5 import QtWebEngineWidgets - except ImportError as e: - results.webengine_available = False - results.webengine_error = str(e) - else: - results.webengine_available = True - - assert results.webkit_available is not None - assert results.webengine_available is not None - if not results.webkit_available: - assert results.webkit_error is not None - if not results.webengine_available: - assert results.webengine_error is not None - - return results - - -def _handle_ssl_support(fatal=False): - """Check for full SSL availability. - - If "fatal" is given, show an error and exit. - """ - text = ("Could not initialize QtNetwork SSL support. If you use " - "OpenSSL 1.1 with a PyQt package from PyPI (e.g. on Archlinux " - "or Debian Stretch), you need to set LD_LIBRARY_PATH to the path " - "of OpenSSL 1.0. This only affects downloads.") - - if QSslSocket.supportsSsl(): - return - - if fatal: - errbox = msgbox.msgbox(parent=None, - title="SSL error", - text="Could not initialize SSL support.", - icon=QMessageBox.Critical, - plain_text=False) - errbox.exec_() sys.exit(usertypes.Exit.err_init) - assert not fatal - log.init.warning(text) + def _nvidia_shader_workaround(self) -> None: + """Work around QOpenGLShaderProgram issues. + NOTE: This needs to be called before _handle_nouveau_graphics, or some + setups will segfault in version.opengl_vendor(). -def _check_backend_modules(): - """Check for the modules needed for QtWebKit/QtWebEngine.""" - imports = _try_import_backends() + See https://bugs.launchpad.net/ubuntu/+source/python-qt4/+bug/941826 + """ + self._assert_backend(usertypes.Backend.QtWebEngine) - if imports.webkit_available and imports.webengine_available: - return - elif not imports.webkit_available and not imports.webengine_available: - text = ("

qutebrowser needs QtWebKit or QtWebEngine, but neither " - "could be imported!

" - "

The errors encountered were:

    " - "
  • QtWebKit: {webkit_error}" - "
  • QtWebEngine: {webengine_error}" - "

".format( - webkit_error=html.escape(imports.webkit_error), - webengine_error=html.escape(imports.webengine_error))) - errbox = msgbox.msgbox(parent=None, - title="No backend library found!", - text=text, - icon=QMessageBox.Critical, - plain_text=False) - errbox.exec_() - sys.exit(usertypes.Exit.err_init) - elif objects.backend == usertypes.Backend.QtWebKit: - if imports.webkit_available: + if os.environ.get('QUTE_SKIP_LIBGL_WORKAROUND'): return - assert imports.webengine_available - _show_dialog( - backend=usertypes.Backend.QtWebKit, - because="QtWebKit could not be imported", - text="

The error encountered was:
{}

".format( - html.escape(imports.webkit_error)) - ) - elif objects.backend == usertypes.Backend.QtWebEngine: - if imports.webengine_available: + + libgl = ctypes.util.find_library("GL") + if libgl is not None: + ctypes.CDLL(libgl, mode=ctypes.RTLD_GLOBAL) + + def _handle_nouveau_graphics(self) -> None: + """Force software rendering when using the Nouveau driver. + + WORKAROUND for + https://bugreports.qt.io/browse/QTBUG-41242 + Should be fixed in Qt 5.10 via + https://codereview.qt-project.org/#/c/208664/ + """ + self._assert_backend(usertypes.Backend.QtWebEngine) + + if os.environ.get('QUTE_SKIP_NOUVEAU_CHECK'): return - assert imports.webkit_available - _show_dialog( + + if qtutils.version_check('5.10', compiled=False): + return + + if version.opengl_vendor() != 'nouveau': + return + + if (os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1' or + # qt.force_software_rendering = 'software-opengl' + 'QT_XCB_FORCE_SOFTWARE_OPENGL' in os.environ or + # qt.force_software_rendering = 'chromium', also see: + # https://build.opensuse.org/package/view_file/openSUSE:Factory/libqt5-qtwebengine/disable-gpu-when-using-nouveau-boo-1005323.diff?expand=1 + 'QT_WEBENGINE_DISABLE_NOUVEAU_WORKAROUND' in os.environ): + return + + button = _Button("Force software rendering", + 'qt.force_software_rendering', + 'chromium') + self._show_dialog( backend=usertypes.Backend.QtWebEngine, - because="QtWebEngine could not be imported", - text="

The error encountered was:
{}

".format( - html.escape(imports.webengine_error)) + because="you're using Nouveau graphics", + text=("

There are two ways to fix this:

" + "

Forcing software rendering

" + "

This allows you to use the newer QtWebEngine backend " + "(based on Chromium) but could have noticeable performance " + "impact (depending on your hardware). This sets the " + "qt.force_software_rendering = 'chromium' option " + "(if you have a config.py file, you'll need to set " + "this manually).

"), + buttons=[button], ) - raise utils.Unreachable + raise utils.Unreachable + + def _handle_wayland(self) -> None: + self._assert_backend(usertypes.Backend.QtWebEngine) + + if os.environ.get('QUTE_SKIP_WAYLAND_CHECK'): + return + + platform = QApplication.instance().platformName() + if platform not in ['wayland', 'wayland-egl']: + return + + has_qt511 = qtutils.version_check('5.11', compiled=False) + if has_qt511 and config.val.qt.force_software_rendering == 'chromium': + return + + if qtutils.version_check('5.11.2', compiled=False): + return + + buttons = [] + text = "

You can work around this in one of the following ways:

" + + if 'DISPLAY' in os.environ: + # XWayland is available, but QT_QPA_PLATFORM=wayland is set + buttons.append( + _Button("Force XWayland", 'qt.force_platform', 'xcb')) + text += ("

Force Qt to use XWayland

" + "

This allows you to use the newer QtWebEngine backend " + "(based on Chromium). " + "This sets the qt.force_platform = 'xcb' option " + "(if you have a config.py file, you'll need to " + "set this manually).

") + else: + text += ("

Set up XWayland

" + "

This allows you to use the newer QtWebEngine backend " + "(based on Chromium). ") + + if has_qt511: + buttons.append(_Button("Force software rendering", + 'qt.force_software_rendering', + 'chromium')) + text += ("

Forcing software rendering

" + "

This allows you to use the newer QtWebEngine backend " + "(based on Chromium) but could have noticeable " + "performance impact (depending on your hardware). This " + "sets the qt.force_software_rendering = " + "'chromium' option (if you have a config.py " + "file, you'll need to set this manually).

") + + self._show_dialog(backend=usertypes.Backend.QtWebEngine, + because="you're using Wayland", + text=text, + buttons=buttons) + + def _try_import_backends(self) -> _BackendImports: + """Check whether backends can be imported and return BackendImports.""" + # pylint: disable=unused-import + results = _BackendImports() + + try: + from PyQt5 import QtWebKit + from PyQt5 import QtWebKitWidgets + except ImportError as e: + results.webkit_available = False + results.webkit_error = str(e) + else: + if qtutils.is_new_qtwebkit(): + results.webkit_available = True + else: + results.webkit_available = False + results.webkit_error = "Unsupported legacy QtWebKit found" + + try: + from PyQt5 import QtWebEngineWidgets + except ImportError as e: + results.webengine_available = False + results.webengine_error = str(e) + else: + results.webengine_available = True + + assert results.webkit_available is not None + assert results.webengine_available is not None + if not results.webkit_available: + assert results.webkit_error is not None + if not results.webengine_available: + assert results.webengine_error is not None + + return results + + def _handle_ssl_support(self, fatal: bool = False) -> None: + """Check for full SSL availability. + + If "fatal" is given, show an error and exit. + """ + text = ("Could not initialize QtNetwork SSL support. If you use " + "OpenSSL 1.1 with a PyQt package from PyPI (e.g. on Archlinux " + "or Debian Stretch), you need to set LD_LIBRARY_PATH to the " + "path of OpenSSL 1.0. This only affects downloads.") + + if QSslSocket.supportsSsl(): + return + + if fatal: + errbox = msgbox.msgbox(parent=None, + title="SSL error", + text="Could not initialize SSL support.", + icon=QMessageBox.Critical, + plain_text=False) + errbox.exec_() + sys.exit(usertypes.Exit.err_init) + + assert not fatal + log.init.warning(text) + + def _check_backend_modules(self) -> None: + """Check for the modules needed for QtWebKit/QtWebEngine.""" + imports = self._try_import_backends() + + if imports.webkit_available and imports.webengine_available: + return + elif not imports.webkit_available and not imports.webengine_available: + text = ("

qutebrowser needs QtWebKit or QtWebEngine, but " + "neither could be imported!

" + "

The errors encountered were:

    " + "
  • QtWebKit: {webkit_error}" + "
  • QtWebEngine: {webengine_error}" + "

".format( + webkit_error=html.escape(imports.webkit_error), + webengine_error=html.escape(imports.webengine_error))) + errbox = msgbox.msgbox(parent=None, + title="No backend library found!", + text=text, + icon=QMessageBox.Critical, + plain_text=False) + errbox.exec_() + sys.exit(usertypes.Exit.err_init) + elif objects.backend == usertypes.Backend.QtWebKit: + if imports.webkit_available: + return + assert imports.webengine_available + self._show_dialog( + backend=usertypes.Backend.QtWebKit, + because="QtWebKit could not be imported", + text="

The error encountered was:
{}

".format( + html.escape(imports.webkit_error)) + ) + elif objects.backend == usertypes.Backend.QtWebEngine: + if imports.webengine_available: + return + assert imports.webkit_available + self._show_dialog( + backend=usertypes.Backend.QtWebEngine, + because="QtWebEngine could not be imported", + text="

The error encountered was:
{}

".format( + html.escape(imports.webengine_error)) + ) + + raise utils.Unreachable + + def _handle_cache_nuking(self) -> None: + """Nuke the QtWebEngine cache if the Qt version changed. + + WORKAROUND for https://bugreports.qt.io/browse/QTBUG-72532 + """ + if not configfiles.state.qt_version_changed: + return + + # Only nuke the cache in cases where we know there are problems. + # It seems these issues started with Qt 5.12. + # They should be fixed with Qt 5.12.5: + # https://codereview.qt-project.org/c/qt/qtwebengine-chromium/+/265408 + affected = (qtutils.version_check('5.12', compiled=False) and not + qtutils.version_check('5.12.5', compiled=False)) + if not affected: + return + + log.init.info("Qt version changed, nuking QtWebEngine cache") + cache_dir = os.path.join(standarddir.cache(), 'webengine') + if os.path.exists(cache_dir): + shutil.rmtree(cache_dir) + + def _assert_backend(self, backend: usertypes.Backend) -> None: + assert objects.backend == backend, objects.backend + + def check(self) -> None: + """Run all checks.""" + self._check_backend_modules() + if objects.backend == usertypes.Backend.QtWebEngine: + self._handle_ssl_support() + self._handle_wayland() + self._nvidia_shader_workaround() + self._handle_nouveau_graphics() + self._handle_cache_nuking() + else: + self._assert_backend(usertypes.Backend.QtWebKit) + self._handle_ssl_support(fatal=True) -def _handle_cache_nuking(): - """Nuke the QtWebEngine cache if the Qt version changed. - - WORKAROUND for https://bugreports.qt.io/browse/QTBUG-72532 - """ - if not configfiles.state.qt_version_changed: - return - - # Only nuke the cache in cases where we know there are problems. - # It seems these issues started with Qt 5.12. - # They should be fixed with Qt 5.12.5: - # https://codereview.qt-project.org/c/qt/qtwebengine-chromium/+/265408 - affected = (qtutils.version_check('5.12', compiled=False) and not - qtutils.version_check('5.12.5', compiled=False)) - if not affected: - return - - log.init.info("Qt version changed, nuking QtWebEngine cache") - cache_dir = os.path.join(standarddir.cache(), 'webengine') - if os.path.exists(cache_dir): - shutil.rmtree(cache_dir) - - -def init(): - """Check for various issues related to QtWebKit/QtWebEngine.""" - _check_backend_modules() - if objects.backend == usertypes.Backend.QtWebEngine: - _handle_ssl_support() - _handle_wayland() - _nvidia_shader_workaround() - _handle_nouveau_graphics() - _handle_cache_nuking() - else: - assert objects.backend == usertypes.Backend.QtWebKit, objects.backend - _handle_ssl_support(fatal=True) +def init(*, quitter: 'app.Quitter', + args: argparse.Namespace, + save_manager: savemanager.SaveManager) -> None: + """Run all checks.""" + checker = _BackendProblemChecker(quitter=quitter, + no_err_windows=args.no_err_windows, + save_manager=save_manager) + checker.check() diff --git a/qutebrowser/misc/consolewidget.py b/qutebrowser/misc/consolewidget.py index 138ce76c8..82d6caf98 100644 --- a/qutebrowser/misc/consolewidget.py +++ b/qutebrowser/misc/consolewidget.py @@ -21,6 +21,7 @@ import sys import code +import typing from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt from PyQt5.QtWidgets import QTextEdit, QWidget, QVBoxLayout, QApplication @@ -31,6 +32,9 @@ from qutebrowser.misc import cmdhistory, miscwidgets from qutebrowser.utils import utils, objreg +console_widget = None + + class ConsoleLineEdit(miscwidgets.CommandLineEdit): """A QLineEdit which executes entered code and provides a history. @@ -211,3 +215,9 @@ class ConsoleWidget(QWidget): def _curprompt(self): """Get the prompt which is visible currently.""" return sys.ps2 if self._more else sys.ps1 + + +def init(): + """Initialize a global console.""" + global console_widget + console_widget = ConsoleWidget() diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py index 286a4c339..53e203866 100644 --- a/qutebrowser/misc/crashsignal.py +++ b/qutebrowser/misc/crashsignal.py @@ -162,6 +162,10 @@ class CrashHandler(QObject): all_objects) self._crash_dialog.show() + @pyqtSlot() + def shutdown(self): + self.destroy_crashlogfile() + def destroy_crashlogfile(self): """Clean up the crash log file and delete it.""" if self._crash_log_file is None: diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py index f7bf6e826..e16effb62 100644 --- a/qutebrowser/misc/ipc.py +++ b/qutebrowser/misc/ipc.py @@ -395,6 +395,7 @@ class IPCServer(QObject): log.ipc.debug("Touching {}".format(path)) os.utime(path) + @pyqtSlot() def shutdown(self): """Shut down the IPC server cleanly.""" log.ipc.debug("Shutting down IPC (socket 0x{:x})".format( diff --git a/qutebrowser/misc/msgbox.py b/qutebrowser/misc/msgbox.py index 241951b84..5b31d64c6 100644 --- a/qutebrowser/misc/msgbox.py +++ b/qutebrowser/misc/msgbox.py @@ -24,7 +24,7 @@ import sys from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMessageBox -from qutebrowser.utils import objreg +from qutebrowser.misc import objects class DummyBox: @@ -51,8 +51,7 @@ def msgbox(parent, title, text, *, icon, buttons=QMessageBox.Ok, Return: A new QMessageBox. """ - args = objreg.get('args') - if args.no_err_windows: + if objects.args.no_err_windows: print('Message box: {}; {}'.format(title, text), file=sys.stderr) return DummyBox() diff --git a/qutebrowser/misc/objects.py b/qutebrowser/misc/objects.py index e2885ca22..5415ab453 100644 --- a/qutebrowser/misc/objects.py +++ b/qutebrowser/misc/objects.py @@ -23,6 +23,7 @@ # earlyinit. import typing +import argparse if typing.TYPE_CHECKING: from qutebrowser.utils import usertypes @@ -40,3 +41,4 @@ class NoBackend: backend = NoBackend() # type: typing.Union[usertypes.Backend, NoBackend] commands = {} # type: typing.Dict[str, command.Command] debug_flags = set() # type: typing.Set[str] +args = typing.cast(argparse.Namespace, None) diff --git a/qutebrowser/misc/savemanager.py b/qutebrowser/misc/savemanager.py index 384522bf0..f2a551c7d 100644 --- a/qutebrowser/misc/savemanager.py +++ b/qutebrowser/misc/savemanager.py @@ -26,7 +26,7 @@ from PyQt5.QtCore import pyqtSlot, QObject, QTimer from qutebrowser.config import config from qutebrowser.api import cmdutils -from qutebrowser.utils import utils, log, message, usertypes +from qutebrowser.utils import utils, log, message, usertypes, error class Saveable: @@ -201,3 +201,14 @@ class SaveManager(QObject): except OSError as e: message.error("Could not save {}: {}".format(key, e)) log.save.debug(":save saved {}".format(', '.join(what))) + + @pyqtSlot() + def shutdown(self): + """Save all saveables when shutting down.""" + for key in self.saveables: + try: + self.save(key, is_exit=True) + except OSError as e: + error.handle_fatal_exc( + e, self._args, "Error while saving!", + pre_text="Error while saving {}".format(key)) diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index d061ed6e4..62fce370f 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -25,7 +25,7 @@ import itertools import urllib import typing -from PyQt5.QtCore import QUrl, QObject, QPoint, QTimer +from PyQt5.QtCore import QUrl, QObject, QPoint, QTimer, pyqtSlot from PyQt5.QtWidgets import QApplication import yaml @@ -44,6 +44,9 @@ class Sentinel: default = Sentinel() +session_manager = typing.cast('SessionManager', None) + +ArgType = typing.Union[str, Sentinel] def init(parent=None): @@ -58,8 +61,14 @@ def init(parent=None): except FileExistsError: pass + global session_manager session_manager = SessionManager(base_path, parent) - objreg.register('session-manager', session_manager) + objreg.register('session-manager', session_manager, command_only=True) + + +@pyqtSlot() +def shutdown(): + session_manager.delete_autosave() class SessionError(Exception): @@ -518,7 +527,7 @@ class SessionManager(QObject): @cmdutils.argument('name', completion=miscmodels.session) @cmdutils.argument('win_id', value=cmdutils.Value.win_id) @cmdutils.argument('with_private', flag='p') - def session_save(self, name: typing.Union[str, Sentinel] = default, + def session_save(self, name: ArgType = default, current: bool = False, quiet: bool = False, force: bool = False, diff --git a/qutebrowser/misc/throttle.py b/qutebrowser/misc/throttle.py index 9bc8c620c..35ac54c85 100644 --- a/qutebrowser/misc/throttle.py +++ b/qutebrowser/misc/throttle.py @@ -64,8 +64,9 @@ class Throttle(QObject): self._timer = usertypes.Timer(self, 'throttle-timer') self._timer.setSingleShot(True) - def _call_pending(self): + def _call_pending(self) -> None: """Start a pending call.""" + assert self._pending_call is not None self._func(*self._pending_call.args, **self._pending_call.kwargs) self._pending_call = None self._last_call_ms = int(time.monotonic() * 1000) diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index 93cf20be8..1811142d7 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -26,7 +26,7 @@ import os import traceback from PyQt5.QtCore import QUrl -from PyQt5.QtWidgets import QApplication # pylint: disable=unused-import +from PyQt5.QtWidgets import QApplication from qutebrowser.browser import qutescheme from qutebrowser.utils import log, objreg, usertypes, message, debug, utils @@ -50,8 +50,7 @@ def later(ms: int, command: str, win_id: int) -> None: if ms < 0: raise cmdutils.CommandError("I can't run something in the past!") commandrunner = runners.CommandRunner(win_id) - app = objreg.get('app') - timer = usertypes.Timer(name='later', parent=app) + timer = usertypes.Timer(name='later', parent=QApplication.instance()) try: timer.setSingleShot(True) try: @@ -107,44 +106,43 @@ def run_with_count(count_arg: int, command: str, win_id: int, @cmdutils.register() -def clear_messages(): +def clear_messages() -> None: """Clear all message notifications.""" message.global_bridge.clear_messages.emit() @cmdutils.register(debug=True) -def debug_all_objects(): +def debug_all_objects() -> None: """Print a list of all objects to the debug log.""" s = debug.get_all_objects() log.misc.debug(s) @cmdutils.register(debug=True) -def debug_cache_stats(): +def debug_cache_stats() -> None: """Print LRU cache stats.""" debugcachestats.debug_cache_stats() @cmdutils.register(debug=True) -def debug_console(): +def debug_console() -> None: """Show the debugging console.""" - try: - con_widget = objreg.get('debug-console') - except KeyError: + if consolewidget.console_widget is None: log.misc.debug('initializing debug console') - con_widget = consolewidget.ConsoleWidget() - objreg.register('debug-console', con_widget) + consolewidget.init() - if con_widget.isVisible(): + assert consolewidget.console_widget is not None + + if consolewidget.console_widget.isVisible(): log.misc.debug('hiding debug console') - con_widget.hide() + consolewidget.console_widget.hide() else: log.misc.debug('showing debug console') - con_widget.show() + consolewidget.console_widget.show() @cmdutils.register(maxsplit=0, debug=True, no_cmd_split=True) -def debug_pyeval(s, file=False, quiet=False): +def debug_pyeval(s: str, file: bool = False, quiet: bool = False) -> None: """Evaluate a python string and display the results as a web page. Args: @@ -182,7 +180,7 @@ def debug_pyeval(s, file=False, quiet=False): @cmdutils.register(debug=True) -def debug_set_fake_clipboard(s=None): +def debug_set_fake_clipboard(s: str = None) -> None: """Put data into the fake clipboard and enable logging, used for tests. Args: @@ -197,7 +195,7 @@ def debug_set_fake_clipboard(s=None): @cmdutils.register() @cmdutils.argument('win_id', value=cmdutils.Value.win_id) @cmdutils.argument('count', value=cmdutils.Value.count) -def repeat_command(win_id, count=None): +def repeat_command(win_id: int, count: int = None) -> None: """Repeat the last executed command. Args: @@ -234,8 +232,11 @@ def debug_log_level(level: str) -> None: Args: level: The log level to set. """ + if log.console_handler is None: + raise cmdutils.CommandError("No log.console_handler. Not attached " + "to a console?") + log.change_console_formatter(log.LOG_LEVELS[level.upper()]) - assert log.console_handler is not None log.console_handler.setLevel(log.LOG_LEVELS[level.upper()]) @@ -265,7 +266,7 @@ def debug_log_filter(filters: str) -> None: @cmdutils.register() @cmdutils.argument('current_win_id', value=cmdutils.Value.win_id) -def window_only(current_win_id): +def window_only(current_win_id: int) -> None: """Close all windows except for the current one.""" for win_id, window in objreg.window_registry.items(): @@ -279,7 +280,7 @@ def window_only(current_win_id): @cmdutils.register() @cmdutils.argument('win_id', value=cmdutils.Value.win_id) -def version(win_id, paste=False): +def version(win_id: int, paste: bool = False) -> None: """Show version information. Args: diff --git a/qutebrowser/utils/debug.py b/qutebrowser/utils/debug.py index 7bb2d6992..383a19df6 100644 --- a/qutebrowser/utils/debug.py +++ b/qutebrowser/utils/debug.py @@ -24,34 +24,36 @@ import inspect import logging import functools import datetime +import typing +import types -from PyQt5.QtCore import Qt, QEvent, QMetaMethod, QObject +from PyQt5.QtCore import Qt, QEvent, QMetaMethod, QObject, pyqtSignal from PyQt5.QtWidgets import QApplication from qutebrowser.utils import log, utils, qtutils, objreg -def log_events(klass): +def log_events(klass: typing.Type) -> typing.Type: """Class decorator to log Qt events.""" old_event = klass.event @functools.wraps(old_event) - def new_event(self, e, *args, **kwargs): + def new_event(self: typing.Any, e: QEvent) -> bool: """Wrapper for event() which logs events.""" log.misc.debug("Event in {}: {}".format(utils.qualname(klass), qenum_key(QEvent, e.type()))) - return old_event(self, e, *args, **kwargs) + return old_event(self, e) klass.event = new_event return klass -def log_signals(obj): +def log_signals(obj: QObject) -> QObject: """Log all signals of an object or class. Can be used as class decorator. """ - def log_slot(obj, signal, *args): + def log_slot(obj: QObject, signal: pyqtSignal, *args: typing.Any) -> None: """Slot connected to a signal to log it.""" dbg = dbg_signal(signal, args) try: @@ -60,7 +62,7 @@ def log_signals(obj): r = '' log.signals.debug("Signal in {}: {}".format(r, dbg)) - def connect_log_slot(obj): + def connect_log_slot(obj: QObject) -> None: """Helper function to connect all signals to a logging slot.""" metaobj = obj.metaObject() for i in range(metaobj.methodCount()): @@ -77,23 +79,27 @@ def log_signals(obj): pass if inspect.isclass(obj): - old_init = obj.__init__ + old_init = obj.__init__ # type: ignore @functools.wraps(old_init) - def new_init(self, *args, **kwargs): + def new_init(self: typing.Any, + *args: typing.Any, + **kwargs: typing.Any) -> None: """Wrapper for __init__() which logs signals.""" - ret = old_init(self, *args, **kwargs) + old_init(self, *args, **kwargs) connect_log_slot(self) - return ret - obj.__init__ = new_init + obj.__init__ = new_init # type: ignore else: connect_log_slot(obj) return obj -def qenum_key(base, value, add_base=False, klass=None): +def qenum_key(base: typing.Type, + value: int, + add_base: bool = False, + klass: typing.Type = None) -> str: """Convert a Qt Enum value to its key as a string. Args: @@ -132,7 +138,10 @@ def qenum_key(base, value, add_base=False, klass=None): return ret -def qflags_key(base, value, add_base=False, klass=None): +def qflags_key(base: typing.Type, + value: int, + add_base: bool = False, + klass: typing.Type = None) -> str: """Convert a Qt QFlags value to its keys as string. Note: Passing a combined value (such as Qt.AlignCenter) will get the names @@ -176,7 +185,7 @@ def qflags_key(base, value, add_base=False, klass=None): return '|'.join(names) -def signal_name(sig): +def signal_name(sig: pyqtSignal) -> str: """Get a cleaned up name of a signal. Args: @@ -185,11 +194,13 @@ def signal_name(sig): Return: The cleaned up signal name. """ - m = re.fullmatch(r'[0-9]+(.*)\(.*\)', sig.signal) + m = re.fullmatch(r'[0-9]+(.*)\(.*\)', sig.signal) # type: ignore + assert m is not None return m.group(1) -def format_args(args=None, kwargs=None): +def format_args(args: typing.Sequence = None, + kwargs: typing.Mapping = None) -> str: """Format a list of arguments/kwargs to a function-call like string.""" if args is not None: arglist = [utils.compact_text(repr(arg), 200) for arg in args] @@ -201,7 +212,7 @@ def format_args(args=None, kwargs=None): return ', '.join(arglist) -def dbg_signal(sig, args): +def dbg_signal(sig: pyqtSignal, args: typing.Any) -> str: """Get a string representation of a signal for debugging. Args: @@ -214,7 +225,10 @@ def dbg_signal(sig, args): return '{}({})'.format(signal_name(sig), format_args(args)) -def format_call(func, args=None, kwargs=None, full=True): +def format_call(func: typing.Callable, + args: typing.Sequence = None, + kwargs: typing.Mapping = None, + full: bool = True) -> str: """Get a string representation of a function calls with the given args. Args: @@ -240,7 +254,8 @@ class log_time: # noqa: N801,N806 pylint: disable=invalid-name Usable as context manager or as decorator. """ - def __init__(self, logger, action='operation'): + def __init__(self, logger: typing.Union[logging.Logger, str], + action: str = 'operation') -> None: """Constructor. Args: @@ -251,44 +266,49 @@ class log_time: # noqa: N801,N806 pylint: disable=invalid-name self._logger = logging.getLogger(logger) else: self._logger = logger - self._started = None + self._started = None # type: typing.Optional[datetime.datetime] self._action = action - def __enter__(self): + def __enter__(self) -> None: self._started = datetime.datetime.now() - def __exit__(self, _exc_type, _exc_val, _exc_tb): + def __exit__(self, _exc_type: typing.Optional[typing.Type[BaseException]], + _exc_val: typing.Optional[BaseException], + _exc_tb: typing.Optional[types.TracebackType]) -> bool: assert self._started is not None finished = datetime.datetime.now() delta = (finished - self._started).total_seconds() self._logger.debug("{} took {} seconds.".format( self._action.capitalize(), delta)) + return False - def __call__(self, func): + def __call__(self, func: typing.Callable) -> typing.Callable: @functools.wraps(func) - def wrapped(*args, **kwargs): + def wrapped(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: """Call the original function.""" with self: - func(*args, **kwargs) + return func(*args, **kwargs) return wrapped -def _get_widgets(): +def _get_widgets() -> typing.Sequence[str]: """Get a string list of all widgets.""" widgets = QApplication.instance().allWidgets() widgets.sort(key=repr) return [repr(w) for w in widgets] -def _get_pyqt_objects(lines, obj, depth=0): +def _get_pyqt_objects(lines: typing.MutableSequence[str], + obj: QObject, + depth: int = 0) -> None: """Recursive method for get_all_objects to get Qt objects.""" for kid in obj.findChildren(QObject, '', Qt.FindDirectChildrenOnly): lines.append(' ' * depth + repr(kid)) _get_pyqt_objects(lines, kid, depth + 1) -def get_all_objects(start_obj=None): +def get_all_objects(start_obj: QObject = None) -> str: """Get all children of an object recursively as a string.""" output = [''] widget_lines = _get_widgets() @@ -300,7 +320,7 @@ def get_all_objects(start_obj=None): if start_obj is None: start_obj = QApplication.instance() - pyqt_lines = [] + pyqt_lines = [] # type: typing.List[str] _get_pyqt_objects(pyqt_lines, start_obj) pyqt_lines = [' ' + e for e in pyqt_lines] pyqt_lines.insert(0, 'Qt objects - {} objects:'.format(len(pyqt_lines))) diff --git a/qutebrowser/utils/docutils.py b/qutebrowser/utils/docutils.py index cf346fb81..94c348292 100644 --- a/qutebrowser/utils/docutils.py +++ b/qutebrowser/utils/docutils.py @@ -25,18 +25,19 @@ import inspect import os.path import collections import enum +import typing import qutebrowser from qutebrowser.utils import log, utils -def is_git_repo(): +def is_git_repo() -> bool: """Check if we're running from a git repository.""" gitfolder = os.path.join(qutebrowser.basedir, os.path.pardir, '.git') return os.path.isdir(gitfolder) -def docs_up_to_date(path): +def docs_up_to_date(path: str) -> bool: """Check if the generated html documentation is up to date. Args: @@ -79,17 +80,18 @@ class DocstringParser: State = enum.Enum('State', ['short', 'desc', 'desc_hidden', 'arg_start', 'arg_inside', 'misc']) - def __init__(self, func): + def __init__(self, func: typing.Callable) -> None: """Constructor. Args: func: The function to parse the docstring for. """ self._state = self.State.short - self._cur_arg_name = None - self._short_desc_parts = [] - self._long_desc_parts = [] - self.arg_descs = collections.OrderedDict() + self._cur_arg_name = None # type: typing.Optional[str] + self._short_desc_parts = [] # type: typing.List[str] + self._long_desc_parts = [] # type: typing.List[str] + self.arg_descs = collections.OrderedDict( + ) # type: typing.Dict[str, typing.Union[str, typing.List[str]]] doc = inspect.getdoc(func) handlers = { self.State.short: self._parse_short, @@ -104,7 +106,8 @@ class DocstringParser: log.commands.warning( "Function {}() from {} has no docstring".format( utils.qualname(func), - inspect.getsourcefile(func))) + # https://github.com/python/typeshed/pull/3295 + inspect.getsourcefile(func))) # type: ignore self.long_desc = "" self.short_desc = "" return @@ -121,25 +124,25 @@ class DocstringParser: self.long_desc = ' '.join(self._long_desc_parts) self.short_desc = ' '.join(self._short_desc_parts) - def _process_arg(self, line): + def _process_arg(self, line: str) -> None: """Helper method to process a line like 'fooarg: Blah blub'.""" self._cur_arg_name, argdesc = line.split(':', maxsplit=1) self._cur_arg_name = self._cur_arg_name.strip().lstrip('*') self.arg_descs[self._cur_arg_name] = [argdesc.strip()] - def _skip(self, line): + def _skip(self, line: str) -> None: """Handler to ignore everything until we get 'Args:'.""" if line.startswith('Args:'): self._state = self.State.arg_start - def _parse_short(self, line): + def _parse_short(self, line: str) -> None: """Parse the short description (first block) in the docstring.""" if not line: self._state = self.State.desc else: self._short_desc_parts.append(line.strip()) - def _parse_desc(self, line): + def _parse_desc(self, line: str) -> None: """Parse the long description in the docstring.""" if line.startswith('Args:'): self._state = self.State.arg_start @@ -148,22 +151,27 @@ class DocstringParser: elif line.strip(): self._long_desc_parts.append(line.strip()) - def _parse_arg_start(self, line): + def _parse_arg_start(self, line: str) -> None: """Parse first argument line.""" self._process_arg(line) self._state = self.State.arg_inside - def _parse_arg_inside(self, line): + def _parse_arg_inside(self, line: str) -> bool: """Parse subsequent argument lines.""" argname = self._cur_arg_name + assert argname is not None + + descs = self.arg_descs[argname] + assert isinstance(descs, list) + if re.fullmatch(r'[A-Z][a-z]+:', line): - if not self.arg_descs[argname][-1].strip(): - self.arg_descs[argname] = self.arg_descs[argname][:-1] + if not descs[-1].strip(): + del descs[-1] return True elif not line.strip(): - self.arg_descs[argname].append('\n\n') + descs.append('\n\n') elif line[4:].startswith(' '): - self.arg_descs[argname].append(line.strip() + '\n') + descs.append(line.strip() + '\n') else: self._process_arg(line) return False diff --git a/qutebrowser/utils/error.py b/qutebrowser/utils/error.py index fe8077526..c5c1974be 100644 --- a/qutebrowser/utils/error.py +++ b/qutebrowser/utils/error.py @@ -19,12 +19,14 @@ """Tools related to error printing/displaying.""" +import argparse + from PyQt5.QtWidgets import QMessageBox from qutebrowser.utils import log, utils -def _get_name(exc): +def _get_name(exc: BaseException) -> str: """Get a suitable exception name as a string.""" prefixes = ['qutebrowser', 'builtins'] name = utils.qualname(exc.__class__) @@ -35,7 +37,11 @@ def _get_name(exc): return name -def handle_fatal_exc(exc, args, title, *, pre_text='', post_text=''): +def handle_fatal_exc(exc: BaseException, + args: argparse.Namespace, + title: str, *, + pre_text: str = '', + post_text: str = '') -> None: """Handle a fatal "expected" exception by displaying an error box. If --no-err-windows is given as argument, the text is logged to the error diff --git a/qutebrowser/utils/javascript.py b/qutebrowser/utils/javascript.py index 42da9759d..f456d93c9 100644 --- a/qutebrowser/utils/javascript.py +++ b/qutebrowser/utils/javascript.py @@ -19,11 +19,15 @@ """Utilities related to javascript interaction.""" +import typing from qutebrowser.utils import jinja +_InnerJsArgType = typing.Union[None, str, bool, int, float] +_JsArgType = typing.Union[_InnerJsArgType, typing.Sequence[_InnerJsArgType]] -def string_escape(text): + +def string_escape(text: str) -> str: """Escape values special to javascript in strings. With this we should be able to use something like: @@ -49,7 +53,7 @@ def string_escape(text): return text -def to_js(arg): +def to_js(arg: _JsArgType) -> str: """Convert the given argument so it's the equivalent in JS.""" if arg is None: return 'undefined' @@ -66,7 +70,7 @@ def to_js(arg): arg, type(arg).__name__)) -def assemble(module, function, *args): +def assemble(module: str, function: str, *args: _JsArgType) -> str: """Assemble a javascript file and a function call.""" js_args = ', '.join(to_js(arg) for arg in args) if module == 'window': @@ -77,7 +81,7 @@ def assemble(module, function, *args): return code -def wrap_global(name, *sources): +def wrap_global(name: str, *sources: str) -> str: """Wrap a script using window._qutebrowser.""" template = jinja.js_environment.get_template('global_wrapper.js') return template.render(code='\n'.join(sources), name=name) diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py index bbe3c23f6..76987ddaa 100644 --- a/qutebrowser/utils/jinja.py +++ b/qutebrowser/utils/jinja.py @@ -61,10 +61,14 @@ class Loader(jinja2.BaseLoader): _subdir: The subdirectory to find templates in. """ - def __init__(self, subdir): + def __init__(self, subdir: str) -> None: self._subdir = subdir - def get_source(self, _env, template): + def get_source( + self, + _env: jinja2.Environment, + template: str + ) -> typing.Tuple[str, str, typing.Callable[[], bool]]: path = os.path.join(self._subdir, template) try: source = utils.read_file(path) @@ -82,7 +86,7 @@ class Environment(jinja2.Environment): """Our own jinja environment which is more strict.""" - def __init__(self): + def __init__(self) -> None: super().__init__(loader=Loader('html'), autoescape=lambda _name: self._autoescape, undefined=jinja2.StrictUndefined) @@ -94,29 +98,31 @@ class Environment(jinja2.Environment): self._autoescape = True @contextlib.contextmanager - def no_autoescape(self): + def no_autoescape(self) -> typing.Iterator[None]: """Context manager to temporarily turn off autoescaping.""" self._autoescape = False yield self._autoescape = True - def _resource_url(self, path): + def _resource_url(self, path: str) -> str: """Load images from a relative path (to qutebrowser). Arguments: path: The relative path to the image """ image = utils.resource_filename(path) - return QUrl.fromLocalFile(image).toString(QUrl.FullyEncoded) + url = QUrl.fromLocalFile(image) + urlstr = url.toString(QUrl.FullyEncoded) # type: ignore + return urlstr - def _data_url(self, path): + def _data_url(self, path: str) -> str: """Get a data: url for the broken qutebrowser logo.""" data = utils.read_file(path, binary=True) filename = utils.resource_filename(path) mimetype = utils.guess_mimetype(filename) return urlutils.data_url(mimetype, data).toString() - def getattr(self, obj, attribute): + def getattr(self, obj: typing.Any, attribute: str) -> typing.Any: """Override jinja's getattr() to be less clever. This means it doesn't fall back to __getitem__, and it doesn't hide @@ -125,7 +131,7 @@ class Environment(jinja2.Environment): return getattr(obj, attribute) -def render(template, **kwargs): +def render(template: str, **kwargs: typing.Any) -> str: """Render the given template and pass the given arguments to it.""" return environment.get_template(template).render(**kwargs) diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index 168d5f52d..148495bfb 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -31,6 +31,8 @@ import traceback import warnings import json import inspect +import typing +import argparse from PyQt5 import QtCore # Optional imports @@ -91,7 +93,10 @@ LOG_LEVELS = { } -def vdebug(self, msg, *args, **kwargs): +def vdebug(self: logging.Logger, + msg: str, + *args: typing.Any, + **kwargs: typing.Any) -> None: """Log with a VDEBUG level. VDEBUG is used when a debug message is rather verbose, and probably of @@ -100,7 +105,7 @@ def vdebug(self, msg, *args, **kwargs): """ if self.isEnabledFor(VDEBUG_LEVEL): # pylint: disable=protected-access - self._log(VDEBUG_LEVEL, msg, args, **kwargs) + self._log(VDEBUG_LEVEL, msg, args, **kwargs) # type: ignore # pylint: enable=protected-access @@ -151,12 +156,12 @@ LOGGER_NAMES = [ ] -ram_handler = None -console_handler = None +ram_handler = None # type: typing.Optional[RAMHandler] +console_handler = None # type: typing.Optional[logging.Handler] console_filter = None -def stub(suffix=''): +def stub(suffix: str = '') -> None: """Show a STUB: message for the calling function.""" try: function = inspect.stack()[1][3] @@ -169,7 +174,7 @@ def stub(suffix=''): misc.warning(text) -def init_log(args): +def init_log(args: argparse.Namespace) -> None: """Init loggers based on the argparse namespace passed.""" level = args.loglevel.upper() try: @@ -208,13 +213,18 @@ def init_log(args): root.setLevel(logging.NOTSET) logging.captureWarnings(True) _init_py_warnings() - QtCore.qInstallMessageHandler(qt_message_handler) + QtCore.qInstallMessageHandler(qt_message_handler) # type: ignore global _log_inited, _args _log_inited = True _args = args -def _init_py_warnings(): +@QtCore.pyqtSlot() +def shutdown_log() -> None: + QtCore.qInstallMessageHandler(None) + + +def _init_py_warnings() -> None: """Initialize Python warning handling.""" warnings.simplefilter('default') warnings.filterwarnings('ignore', module='pdb', category=ResourceWarning) @@ -226,7 +236,7 @@ def _init_py_warnings(): @contextlib.contextmanager -def disable_qt_msghandler(): +def disable_qt_msghandler() -> typing.Iterator[None]: """Contextmanager which temporarily disables the Qt message handler.""" old_handler = QtCore.qInstallMessageHandler(None) try: @@ -236,7 +246,7 @@ def disable_qt_msghandler(): @contextlib.contextmanager -def ignore_py_warnings(**kwargs): +def ignore_py_warnings(**kwargs: typing.Any) -> typing.Iterator[None]: """Contextmanager to temporarily disable certain Python warnings.""" warnings.filterwarnings('ignore', **kwargs) yield @@ -244,7 +254,13 @@ def ignore_py_warnings(**kwargs): _init_py_warnings() -def _init_handlers(level, color, force_color, json_logging, ram_capacity): +def _init_handlers( + level: int, + color: bool, + force_color: bool, + json_logging: bool, + ram_capacity: int +) -> typing.Tuple[logging.StreamHandler, typing.Optional['RAMHandler']]: """Init log handlers. Args: @@ -281,7 +297,7 @@ def _init_handlers(level, color, force_color, json_logging, ram_capacity): return console_handler, ram_handler -def get_console_format(level): +def get_console_format(level: int) -> str: """Get the log format the console logger should use. Args: @@ -293,7 +309,13 @@ def get_console_format(level): return EXTENDED_FMT if level <= logging.DEBUG else SIMPLE_FMT -def _init_formatters(level, color, force_color, json_logging): +def _init_formatters( + level: int, + color: bool, + force_color: bool, + json_logging: bool +) -> typing.Tuple[typing.Union['JSONFormatter', 'ColoredFormatter'], + 'ColoredFormatter', 'HTMLFormatter', bool]: """Init log formatters. Args: @@ -316,8 +338,8 @@ def _init_formatters(level, color, force_color, json_logging): return None, ram_formatter, html_formatter, False # type: ignore if json_logging: - console_formatter = JSONFormatter() - return console_formatter, ram_formatter, html_formatter, False + json_formatter = JSONFormatter() + return json_formatter, ram_formatter, html_formatter, False use_colorama = False color_supported = os.name == 'posix' or colorama @@ -334,24 +356,24 @@ def _init_formatters(level, color, force_color, json_logging): return console_formatter, ram_formatter, html_formatter, use_colorama -def change_console_formatter(level): +def change_console_formatter(level: int) -> None: """Change console formatter based on level. Args: level: The numeric logging level """ - if not isinstance(console_handler.formatter, ColoredFormatter): - # JSON Formatter being used for end2end tests - pass + assert console_handler is not None - use_colors = console_handler.formatter.use_colors + old_formatter = typing.cast(ColoredFormatter, console_handler.formatter) console_fmt = get_console_format(level) console_formatter = ColoredFormatter(console_fmt, DATEFMT, '{', - use_colors=use_colors) + use_colors=old_formatter.use_colors) console_handler.setFormatter(console_formatter) -def qt_message_handler(msg_type, context, msg): +def qt_message_handler(msg_type: QtCore.QtMsgType, + context: QtCore.QMessageLogContext, + msg: str) -> None: """Qt message handler to redirect qWarning etc. to the logging system. Args: @@ -456,13 +478,14 @@ def qt_message_handler(msg_type, context, msg): level = qt_to_logging[msg_type] if context.function is None: - func = 'none' + func = 'none' # type: ignore elif ':' in context.function: func = '"{}"'.format(context.function) else: func = context.function - if context.category is None or context.category == 'default': + if (context.category is None or # type: ignore + context.category == 'default'): name = 'qt' else: name = 'qt-' + context.category @@ -474,17 +497,19 @@ def qt_message_handler(msg_type, context, msg): " pacman -S libxkbcommon-x11") faulthandler.disable() + assert _args is not None if _args.debug: - stack = ''.join(traceback.format_stack()) + stack = ''.join(traceback.format_stack()) # type: typing.Optional[str] else: stack = None + record = qt.makeRecord(name, level, context.file, context.line, msg, (), None, func, sinfo=stack) qt.handle(record) @contextlib.contextmanager -def hide_qt_warning(pattern, logger='qt'): +def hide_qt_warning(pattern: str, logger: str = 'qt') -> typing.Iterator[None]: """Hide Qt warnings matching the given regex.""" log_filter = QtWarningFilter(pattern) logger_obj = logging.getLogger(logger) @@ -503,11 +528,11 @@ class QtWarningFilter(logging.Filter): _pattern: The start of the message. """ - def __init__(self, pattern): + def __init__(self, pattern: str) -> None: super().__init__() self._pattern = pattern - def filter(self, record): + def filter(self, record: logging.LogRecord) -> bool: """Determine if the specified record is to be logged.""" do_log = not record.msg.strip().startswith(self._pattern) return do_log @@ -525,12 +550,13 @@ class LogFilter(logging.Filter): negated: Whether names is a list of records to log or to suppress. """ - def __init__(self, names, negate=False): + def __init__(self, names: typing.Optional[typing.Iterable[str]], + negate: bool = False) -> None: super().__init__() self.names = names self.negated = negate - def filter(self, record): + def filter(self, record: logging.LogRecord) -> bool: """Determine if the specified record is to be logged.""" if self.names is None: return True @@ -558,20 +584,22 @@ class RAMHandler(logging.Handler): _data: A deque containing the logging records. """ - def __init__(self, capacity): + def __init__(self, capacity: int) -> None: super().__init__() - self.html_formatter = None + self.html_formatter = None # type: typing.Optional[HTMLFormatter] if capacity != -1: - self._data = collections.deque(maxlen=capacity) + self._data = collections.deque( + maxlen=capacity + ) # type: typing.MutableSequence[logging.LogRecord] else: self._data = collections.deque() - def emit(self, record): + def emit(self, record: logging.LogRecord) -> None: if record.levelno >= logging.DEBUG: # We don't log VDEBUG to RAM. self._data.append(record) - def dump_log(self, html=False, level='vdebug'): + def dump_log(self, html: bool = False, level: str = 'vdebug') -> str: """Dump the complete formatted log data as string. FIXME: We should do all the HTML formatter via jinja2. @@ -579,7 +607,13 @@ class RAMHandler(logging.Handler): https://github.com/qutebrowser/qutebrowser/issues/34 """ minlevel = LOG_LEVELS.get(level.upper(), VDEBUG_LEVEL) - fmt = self.html_formatter.format if html else self.format + + if html: + assert self.html_formatter is not None + fmt = self.html_formatter.format + else: + fmt = self.format + self.acquire() try: lines = [fmt(record) @@ -589,7 +623,7 @@ class RAMHandler(logging.Handler): self.release() return '\n'.join(lines) - def change_log_capacity(self, capacity): + def change_log_capacity(self, capacity: int) -> None: self._data = collections.deque(self._data, maxlen=capacity) @@ -601,11 +635,14 @@ class ColoredFormatter(logging.Formatter): use_colors: Whether to do colored logging or not. """ - def __init__(self, fmt, datefmt, style, *, use_colors): + def __init__(self, fmt: str, + datefmt: str, + style: str, *, + use_colors: bool) -> None: super().__init__(fmt, datefmt, style) self.use_colors = use_colors - def format(self, record): + def format(self, record: logging.LogRecord) -> str: if self.use_colors: color_dict = dict(COLOR_ESCAPES) color_dict['reset'] = RESET_ESCAPE @@ -628,7 +665,9 @@ class HTMLFormatter(logging.Formatter): _colordict: The colordict passed to the logger. """ - def __init__(self, fmt, datefmt, log_colors): + def __init__(self, fmt: str, + datefmt: str, + log_colors: typing.Mapping[str, str]) -> None: """Constructor. Args: @@ -637,22 +676,22 @@ class HTMLFormatter(logging.Formatter): log_colors: The colors to use for logging levels. """ super().__init__(fmt, datefmt) - self._log_colors = log_colors - self._colordict = {} + self._log_colors = log_colors # type: typing.Mapping[str, str] + self._colordict = {} # type: typing.Mapping[str, str] # We could solve this nicer by using CSS, but for this simple case this # works. for color in COLORS: self._colordict[color] = ''.format(color) self._colordict['reset'] = '' - def format(self, record): + def format(self, record: logging.LogRecord) -> str: record_clone = copy.copy(record) record_clone.__dict__.update(self._colordict) if record_clone.levelname in self._log_colors: color = self._log_colors[record_clone.levelname] - record_clone.log_color = self._colordict[color] + record_clone.log_color = self._colordict[color] # type: ignore else: - record_clone.log_color = '' + record_clone.log_color = '' # type: ignore for field in ['msg', 'filename', 'funcName', 'levelname', 'module', 'name', 'pathname', 'processName', 'threadName']: data = str(getattr(record_clone, field)) @@ -662,8 +701,10 @@ class HTMLFormatter(logging.Formatter): msg += self._colordict['reset'] return msg - def formatTime(self, record, datefmt=None): - out = super().formatTime(record, datefmt) + def formatTime(self, record: logging.LogRecord, + datefmt: str = None) -> str: + # https://github.com/python/typeshed/pull/3343 + out = super().formatTime(record, datefmt) # type: ignore return pyhtml.escape(out) @@ -671,7 +712,7 @@ class JSONFormatter(logging.Formatter): """Formatter for JSON-encoded log messages.""" - def format(self, record): + def format(self, record: logging.LogRecord) -> str: obj = {} for field in ['created', 'msecs', 'levelname', 'name', 'module', 'funcName', 'lineno', 'levelno']: diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py index 155547abd..2bd30c136 100644 --- a/qutebrowser/utils/message.py +++ b/qutebrowser/utils/message.py @@ -23,8 +23,9 @@ """Message singleton so we don't have to define unneeded signals.""" import traceback +import typing -from PyQt5.QtCore import pyqtSignal, QObject +from PyQt5.QtCore import pyqtSignal, QObject, QUrl from qutebrowser.utils import usertypes, log, utils @@ -82,11 +83,14 @@ def info(message: str, *, replace: bool = False) -> None: global_bridge.show(usertypes.MessageLevel.info, message, replace) -def _build_question(title, text=None, *, mode, default=None, abort_on=(), - url=None, option=None): +def _build_question(title: str, + text: str = None, *, + mode: usertypes.PromptMode, + default: typing.Union[None, bool, str] = None, + abort_on: typing.Iterable[pyqtSignal] = (), + url: QUrl = None, + option: bool = None) -> usertypes.Question: """Common function for ask/ask_async.""" - if not isinstance(mode, usertypes.PromptMode): - raise TypeError("Mode {} is no PromptMode member!".format(mode)) question = usertypes.Question() question.title = title question.text = text @@ -106,7 +110,7 @@ def _build_question(title, text=None, *, mode, default=None, abort_on=(), return question -def ask(*args, **kwargs): +def ask(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: """Ask a modular question in the statusbar (blocking). Args: @@ -128,7 +132,10 @@ def ask(*args, **kwargs): return answer -def ask_async(title, mode, handler, **kwargs): +def ask_async(title: str, + mode: usertypes.PromptMode, + handler: typing.Callable[[typing.Any], None], + **kwargs: typing.Any) -> None: """Ask an async question in the statusbar. Args: @@ -144,8 +151,10 @@ def ask_async(title, mode, handler, **kwargs): global_bridge.ask(question, blocking=False) -def confirm_async(*, yes_action, no_action=None, cancel_action=None, - **kwargs): +def confirm_async(*, yes_action: typing.Callable[[], None], + no_action: typing.Callable[[], None] = None, + cancel_action: typing.Callable[[], None] = None, + **kwargs: typing.Any) -> usertypes.Question: """Ask a yes/no question to the user and execute the given actions. Args: @@ -204,12 +213,15 @@ class GlobalMessageBridge(QObject): mode_left = pyqtSignal(usertypes.KeyMode) clear_messages = pyqtSignal() - def __init__(self, parent=None): + def __init__(self, parent: QObject = None) -> None: super().__init__(parent) self._connected = False - self._cache = [] + self._cache = [ + ] # type: typing.List[typing.Tuple[usertypes.MessageLevel, str, bool]] - def ask(self, question, blocking, *, log_stack=False): + def ask(self, question: usertypes.Question, + blocking: bool, *, + log_stack: bool = False) -> None: """Ask a question to the user. Note this method doesn't return the answer, it only blocks. The caller @@ -223,14 +235,16 @@ class GlobalMessageBridge(QObject): """ self.ask_question.emit(question, blocking) - def show(self, level, text, replace=False): + def show(self, level: usertypes.MessageLevel, + text: str, + replace: bool = False) -> None: """Show the given message.""" if self._connected: self.show_message.emit(level, text, replace) else: self._cache.append((level, text, replace)) - def flush(self): + def flush(self) -> None: """Flush messages which accumulated while no handler was connected. This is so we don't miss messages shown during some early init phase. @@ -256,10 +270,10 @@ class MessageBridge(QObject): s_set_text = pyqtSignal(str) s_maybe_reset_text = pyqtSignal(str) - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self) - def set_text(self, text, *, log_stack=False): + def set_text(self, text: str, *, log_stack: bool = False) -> None: """Set the normal text of the statusbar. Args: @@ -270,7 +284,7 @@ class MessageBridge(QObject): log.message.debug(text) self.s_set_text.emit(text) - def maybe_reset_text(self, text, *, log_stack=False): + def maybe_reset_text(self, text: str, *, log_stack: bool = False) -> None: """Reset the text in the statusbar if it matches an expected text. Args: diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py index a00460a34..9661c8d2b 100644 --- a/qutebrowser/utils/objreg.py +++ b/qutebrowser/utils/objreg.py @@ -22,20 +22,18 @@ import collections import functools +import typing from PyQt5.QtCore import QObject, QTimer +from PyQt5.QtWidgets import QApplication +from PyQt5.QtWidgets import QWidget # pylint: disable=unused-import -from qutebrowser.utils import log +from qutebrowser.utils import log, usertypes +if typing.TYPE_CHECKING: + from qutebrowser.mainwindow import mainwindow -class UnsetObject: - - """Class for an unset object. - - Only used (rather than object) so we can tell pylint to shut up about it. - """ - - __slots__ = () +_WindowTab = typing.Union[str, int, None] class RegistryUnavailableError(Exception): @@ -48,7 +46,9 @@ class NoWindow(Exception): """Exception raised by last_window if no window is available.""" -_UNSET = UnsetObject() +class CommandOnlyError(Exception): + + """Raised when an object is requested which is used for commands only.""" class ObjectRegistry(collections.UserDict): @@ -59,13 +59,16 @@ class ObjectRegistry(collections.UserDict): Attributes: _partial_objs: A dictionary of the connected partial objects. + command_only: Objects which are only registered for commands. """ - def __init__(self): + def __init__(self) -> None: super().__init__() - self._partial_objs = {} + self._partial_objs = { + } # type: typing.MutableMapping[str, typing.Callable[[], None]] + self.command_only = [] # type: typing.MutableSequence[str] - def __setitem__(self, name, obj): + def __setitem__(self, name: str, obj: typing.Any) -> None: """Register an object in the object registry. Sets a slot to remove QObjects when they are destroyed. @@ -80,17 +83,17 @@ class ObjectRegistry(collections.UserDict): if isinstance(obj, QObject): func = functools.partial(self.on_destroyed, name) - obj.destroyed.connect(func) + obj.destroyed.connect(func) # type: ignore self._partial_objs[name] = func super().__setitem__(name, obj) - def __delitem__(self, name): + def __delitem__(self, name: str) -> None: """Extend __delitem__ to disconnect the destroyed signal.""" self._disconnect_destroyed(name) super().__delitem__(name) - def _disconnect_destroyed(self, name): + def _disconnect_destroyed(self, name: str) -> None: """Disconnect the destroyed slot if it was connected.""" try: partial_objs = self._partial_objs @@ -109,7 +112,7 @@ class ObjectRegistry(collections.UserDict): pass del partial_objs[name] - def on_destroyed(self, name): + def on_destroyed(self, name: str) -> None: """Schedule removing of a destroyed QObject. We don't remove the destroyed object immediately because it might still @@ -119,7 +122,7 @@ class ObjectRegistry(collections.UserDict): log.destroy.debug("schedule removal: {}".format(name)) QTimer.singleShot(0, functools.partial(self._on_destroyed, name)) - def _on_destroyed(self, name): + def _on_destroyed(self, name: str) -> None: """Remove a destroyed QObject.""" log.destroy.debug("removed: {}".format(name)) if not hasattr(self, 'data'): @@ -133,7 +136,7 @@ class ObjectRegistry(collections.UserDict): except KeyError: pass - def dump_objects(self): + def dump_objects(self) -> typing.Sequence[str]: """Dump all objects as a list of strings.""" lines = [] for name, obj in self.data.items(): @@ -142,7 +145,9 @@ class ObjectRegistry(collections.UserDict): except RuntimeError: # Underlying object deleted probably obj_repr = '' - lines.append("{}: {}".format(name, obj_repr)) + suffix = (" (for commands only)" if name in self.command_only + else "") + lines.append("{}: {}{}".format(name, obj_repr, suffix)) return lines @@ -152,13 +157,13 @@ global_registry = ObjectRegistry() window_registry = ObjectRegistry() -def _get_tab_registry(win_id, tab_id): +def _get_tab_registry(win_id: _WindowTab, + tab_id: _WindowTab) -> ObjectRegistry: """Get the registry of a tab.""" if tab_id is None: raise ValueError("Got tab_id None (win_id {})".format(win_id)) if tab_id == 'current' and win_id is None: - app = get('app') - window = app.activeWindow() + window = QApplication.activeWindow() # type: typing.Optional[QWidget] if window is None or not hasattr(window, 'win_id'): raise RegistryUnavailableError('tab') win_id = window.win_id @@ -180,27 +185,32 @@ def _get_tab_registry(win_id, tab_id): raise RegistryUnavailableError('tab') -def _get_window_registry(window): +def _get_window_registry(window: _WindowTab) -> ObjectRegistry: """Get the registry of a window.""" if window is None: raise TypeError("window is None with scope window!") try: if window == 'current': - app = get('app') - win = app.activeWindow() + win = QApplication.activeWindow() # type: typing.Optional[QWidget] elif window == 'last-focused': win = last_focused_window() else: win = window_registry[window] except (KeyError, NoWindow): win = None + + if win is None: + raise RegistryUnavailableError('window') + try: return win.registry except AttributeError: raise RegistryUnavailableError('window') -def _get_registry(scope, window=None, tab=None): +def _get_registry(scope: str, + window: _WindowTab = None, + tab: _WindowTab = None) -> ObjectRegistry: """Get the correct registry for a given scope.""" if window is not None and scope not in ['window', 'tab']: raise TypeError("window is set with scope {}".format(scope)) @@ -216,24 +226,39 @@ def _get_registry(scope, window=None, tab=None): raise ValueError("Invalid scope '{}'!".format(scope)) -def get(name, default=_UNSET, scope='global', window=None, tab=None): +def get(name: str, + default: typing.Any = usertypes.UNSET, + scope: str = 'global', + window: _WindowTab = None, + tab: _WindowTab = None, + from_command: bool = False) -> typing.Any: """Helper function to get an object. Args: default: A default to return if the object does not exist. """ reg = _get_registry(scope, window, tab) + if name in reg.command_only and not from_command: + raise CommandOnlyError("{} is only registered for commands" + .format(name)) + try: return reg[name] except KeyError: - if default is not _UNSET: + if default is not usertypes.UNSET: return default else: raise -def register(name, obj, update=False, scope=None, registry=None, window=None, - tab=None): +def register(name: str, + obj: typing.Any, + update: bool = False, + scope: str = None, + registry: ObjectRegistry = None, + window: _WindowTab = None, + tab: _WindowTab = None, + command_only: bool = False) -> None: """Helper function to register an object. Args: @@ -244,25 +269,33 @@ def register(name, obj, update=False, scope=None, registry=None, window=None, if scope is not None and registry is not None: raise ValueError("scope ({}) and registry ({}) can't be given at the " "same time!".format(scope, registry)) + if registry is not None: reg = registry else: if scope is None: scope = 'global' reg = _get_registry(scope, window, tab) + if not update and name in reg: raise KeyError("Object '{}' is already registered ({})!".format( name, repr(reg[name]))) reg[name] = obj + if command_only: + reg.command_only.append(name) -def delete(name, scope='global', window=None, tab=None): + +def delete(name: str, + scope: str = 'global', + window: _WindowTab = None, + tab: _WindowTab = None) -> None: """Helper function to unregister an object.""" reg = _get_registry(scope, window, tab) del reg[name] -def dump_objects(): +def dump_objects() -> typing.Sequence[str]: """Get all registered objects in all registries as a string.""" blocks = [] lines = [] @@ -275,16 +308,16 @@ def dump_objects(): dump = tab.registry.dump_objects() data = [' ' + line for line in dump] blocks.append((' tab-{}'.format(tab_id), data)) - for name, data in blocks: + for name, block_data in blocks: lines.append("") lines.append("{} object registry - {} objects:".format( - name, len(data))) - for line in data: + name, len(block_data))) + for line in block_data: lines.append(" {}".format(line)) return lines -def last_visible_window(): +def last_visible_window() -> 'mainwindow.MainWindow': """Get the last visible window, or the last focused window if none.""" try: return get('last-visible-main-window') @@ -292,7 +325,7 @@ def last_visible_window(): return last_focused_window() -def last_focused_window(): +def last_focused_window() -> 'mainwindow.MainWindow': """Get the last focused window, or the last window if none.""" try: return get('last-focused-main-window') @@ -300,7 +333,7 @@ def last_focused_window(): return window_by_index(-1) -def window_by_index(idx): +def window_by_index(idx: int) -> 'mainwindow.MainWindow': """Get the Nth opened window object.""" if not window_registry: raise NoWindow() diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py index 1dc2193da..3e81d4a23 100644 --- a/qutebrowser/utils/qtutils.py +++ b/qutebrowser/utils/qtutils.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +# FIXME:typing Can we have less "# type: ignore" in here? + """Misc. utilities related to Qt. Module attributes: @@ -116,7 +118,7 @@ def is_new_qtwebkit() -> bool: pkg_resources.parse_version('538.1')) -def is_single_process(): +def is_single_process() -> bool: """Check whether QtWebEngine is running in single-process mode.""" if objects.backend == usertypes.Backend.QtWebKit: return False @@ -199,7 +201,11 @@ def deserialize_stream(stream: QDataStream, obj: QObject) -> None: @contextlib.contextmanager -def savefile_open(filename, binary=False, encoding='utf-8'): +def savefile_open( + filename: str, + binary: bool = False, + encoding: str = 'utf-8' +) -> typing.Iterator[typing.Union['PyQIODevice', io.TextIOWrapper]]: """Context manager to easily use a QSaveFile.""" f = QSaveFile(filename) cancelled = False @@ -209,9 +215,11 @@ def savefile_open(filename, binary=False, encoding='utf-8'): raise QtOSError(f) if binary: - new_f = PyQIODevice(f) + new_f = PyQIODevice( + f) # type: typing.Union[PyQIODevice, io.TextIOWrapper] else: - new_f = io.TextIOWrapper(PyQIODevice(f), encoding=encoding) + new_f = io.TextIOWrapper(PyQIODevice(f), # type: ignore + encoding=encoding) yield new_f @@ -268,7 +276,7 @@ class PyQIODevice(io.BufferedIOBase): if not self.writable(): raise OSError("Trying to write to unwritable file!") - def open(self, mode): + def open(self, mode: QIODevice.OpenMode) -> contextlib.closing: """Open the underlying device and ensure opening succeeded. Raises OSError if opening failed. @@ -289,10 +297,10 @@ class PyQIODevice(io.BufferedIOBase): """Close the underlying device.""" self.dev.close() - def fileno(self): + def fileno(self) -> int: raise io.UnsupportedOperation - def seek(self, offset, whence=io.SEEK_SET): + def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: self._check_open() self._check_random() if whence == io.SEEK_SET: @@ -307,7 +315,9 @@ class PyQIODevice(io.BufferedIOBase): if not ok: raise QtOSError(self.dev, msg="seek failed!") - def truncate(self, size=None): + return self.dev.pos() + + def truncate(self, size: int = None) -> int: raise io.UnsupportedOperation @property @@ -325,7 +335,7 @@ class PyQIODevice(io.BufferedIOBase): def readable(self) -> bool: return self.dev.isReadable() - def readline(self, size=-1): + def readline(self, size: int = -1) -> QByteArray: self._check_open() self._check_readable() @@ -346,7 +356,7 @@ class PyQIODevice(io.BufferedIOBase): if buf is None: raise QtOSError(self.dev) - return buf + return buf # type: ignore def seekable(self) -> bool: return not self.dev.isSequential() @@ -359,23 +369,26 @@ class PyQIODevice(io.BufferedIOBase): def writable(self) -> bool: return self.dev.isWritable() - def write(self, b: bytes) -> int: + def write(self, data: str) -> int: # type: ignore self._check_open() self._check_writable() - num = self.dev.write(b) - if num == -1 or num < len(b): + num = self.dev.write(data) # type: ignore + if num == -1 or num < len(data): raise QtOSError(self.dev) return num - def read(self, size=-1): + def read(self, size: typing.Optional[int] = None) -> QByteArray: self._check_open() self._check_readable() - if size < 0: + + if size in [None, -1]: buf = self.dev.readAll() else: - buf = self.dev.read(size) + buf = self.dev.read(size) # type: ignore + if buf is None: raise QtOSError(self.dev) + return buf @@ -405,11 +418,14 @@ class EventLoop(QEventLoop): super().__init__(parent) self._executing = False - def exec_(self, flags=QEventLoop.AllEvents): + def exec_( + self, + flags: QEventLoop.ProcessEventsFlag = QEventLoop.AllEvents + ) -> int: """Override exec_ to raise an exception when re-running.""" if self._executing: raise AssertionError("Eventloop is already running!") self._executing = True - status = super().exec_(flags) + status = super().exec_(flags) # type: ignore self._executing = False return status diff --git a/qutebrowser/utils/standarddir.py b/qutebrowser/utils/standarddir.py index 186a42ae3..e07ffe4c3 100644 --- a/qutebrowser/utils/standarddir.py +++ b/qutebrowser/utils/standarddir.py @@ -25,6 +25,8 @@ import sys import shutil import contextlib import enum +import argparse +import typing from PyQt5.QtCore import QStandardPaths from PyQt5.QtWidgets import QApplication @@ -58,7 +60,7 @@ class EmptyValueError(Exception): @contextlib.contextmanager -def _unset_organization(): +def _unset_organization() -> typing.Iterator[None]: """Temporarily unset QApplication.organizationName(). This is primarily needed in config.py. @@ -66,7 +68,7 @@ def _unset_organization(): qapp = QApplication.instance() if qapp is not None: orgname = qapp.organizationName() - qapp.setOrganizationName(None) + qapp.setOrganizationName(None) # type: ignore try: yield finally: @@ -74,36 +76,38 @@ def _unset_organization(): qapp.setOrganizationName(orgname) -def _init_config(args): +def _init_config(args: typing.Optional[argparse.Namespace]) -> None: """Initialize the location for configs.""" typ = QStandardPaths.ConfigLocation - overridden, path = _from_args(typ, args) - if not overridden: + path = _from_args(typ, args) + if path is None: if utils.is_windows: app_data_path = _writable_location( QStandardPaths.AppDataLocation) path = os.path.join(app_data_path, 'config') else: path = _writable_location(typ) + _create(path) _locations[_Location.config] = path _locations[_Location.auto_config] = path # Override the normal (non-auto) config on macOS if utils.is_mac: - overridden, path = _from_args(typ, args) - if not overridden: # pragma: no branch + path = _from_args(typ, args) + if path is None: # pragma: no branch path = os.path.expanduser('~/.' + APPNAME) _create(path) _locations[_Location.config] = path config_py_file = os.path.join(_locations[_Location.config], 'config.py') if getattr(args, 'config_py', None) is not None: + assert args is not None config_py_file = os.path.abspath(args.config_py) _locations[_Location.config_py] = config_py_file -def config(auto=False): +def config(auto: bool = False) -> str: """Get the location for the config directory. If auto=True is given, get the location for the autoconfig.yml directory, @@ -123,11 +127,11 @@ def config_py() -> str: return _locations[_Location.config_py] -def _init_data(args): +def _init_data(args: typing.Optional[argparse.Namespace]) -> None: """Initialize the location for data.""" typ = QStandardPaths.DataLocation - overridden, path = _from_args(typ, args) - if not overridden: + path = _from_args(typ, args) + if path is None: if utils.is_windows: app_data_path = _writable_location(QStandardPaths.AppDataLocation) path = os.path.join(app_data_path, 'data') @@ -137,6 +141,7 @@ def _init_data(args): path = os.path.join(config_path, 'data') else: path = _writable_location(typ) + _create(path) _locations[_Location.data] = path @@ -148,7 +153,7 @@ def _init_data(args): _locations[_Location.system_data] = path -def data(system=False): +def data(system: bool = False) -> str: """Get the data directory. If system=True is given, gets the system-wide (probably non-writable) data @@ -162,43 +167,44 @@ def data(system=False): return _locations[_Location.data] -def _init_cache(args): +def _init_cache(args: typing.Optional[argparse.Namespace]) -> None: """Initialize the location for the cache.""" typ = QStandardPaths.CacheLocation - overridden, path = _from_args(typ, args) - if not overridden: + path = _from_args(typ, args) + if path is None: if utils.is_windows: # Local, not Roaming! data_path = _writable_location(QStandardPaths.DataLocation) path = os.path.join(data_path, 'cache') else: path = _writable_location(typ) + _create(path) _locations[_Location.cache] = path -def cache(): +def cache() -> str: return _locations[_Location.cache] -def _init_download(args): +def _init_download(args: typing.Optional[argparse.Namespace]) -> None: """Initialize the location for downloads. Note this is only the default directory as found by Qt. Therefore, we also don't create it. """ typ = QStandardPaths.DownloadLocation - overridden, path = _from_args(typ, args) - if not overridden: + path = _from_args(typ, args) + if path is None: path = _writable_location(typ) _locations[_Location.download] = path -def download(): +def download() -> str: return _locations[_Location.download] -def _init_runtime(args): +def _init_runtime(args: typing.Optional[argparse.Namespace]) -> None: """Initialize location for runtime data.""" if utils.is_mac or utils.is_windows: # RuntimeLocation is a weird path on macOS and Windows. @@ -206,9 +212,8 @@ def _init_runtime(args): else: typ = QStandardPaths.RuntimeLocation - overridden, path = _from_args(typ, args) - - if not overridden: + path = _from_args(typ, args) + if path is None: try: path = _writable_location(typ) except EmptyValueError: @@ -231,11 +236,11 @@ def _init_runtime(args): _locations[_Location.runtime] = path -def runtime(): +def runtime() -> str: return _locations[_Location.runtime] -def _writable_location(typ): +def _writable_location(typ: QStandardPaths.StandardLocation) -> str: """Wrapper around QStandardPaths.writableLocation. Arguments: @@ -271,17 +276,14 @@ def _writable_location(typ): return path -def _from_args(typ, args): +def _from_args( + typ: QStandardPaths.StandardLocation, + args: typing.Optional[argparse.Namespace] +) -> typing.Optional[str]: """Get the standard directory from an argparse namespace. - Args: - typ: A member of the QStandardPaths::StandardLocation enum - args: An argparse namespace or None. - Return: - A (override, path) tuple. - override: boolean, if the user did override the path - path: The overridden path, or None to turn off storage. + The overridden path, or None if there is no override. """ basedir_suffix = { QStandardPaths.ConfigLocation: 'config', @@ -291,19 +293,18 @@ def _from_args(typ, args): QStandardPaths.RuntimeLocation: 'runtime', } - if getattr(args, 'basedir', None) is not None: - basedir = args.basedir + if getattr(args, 'basedir', None) is None: + return None + assert args is not None - try: - suffix = basedir_suffix[typ] - except KeyError: # pragma: no cover - return (False, None) - return (True, os.path.abspath(os.path.join(basedir, suffix))) - else: - return (False, None) + try: + suffix = basedir_suffix[typ] + except KeyError: # pragma: no cover + return None + return os.path.abspath(os.path.join(args.basedir, suffix)) -def _create(path): +def _create(path: str) -> None: """Create the `path` directory. From the XDG basedir spec: @@ -315,7 +316,7 @@ def _create(path): os.makedirs(path, 0o700, exist_ok=True) -def _init_dirs(args=None): +def _init_dirs(args: argparse.Namespace = None) -> None: """Create and cache standard directory locations. Mainly in a separate function because we need to call it in tests. @@ -327,7 +328,7 @@ def _init_dirs(args=None): _init_runtime(args) -def init(args): +def init(args: typing.Optional[argparse.Namespace]) -> None: """Initialize all standard dirs.""" if args is not None: # args can be None during tests @@ -342,7 +343,7 @@ def init(args): _move_windows() -def _move_macos(): +def _move_macos() -> None: """Move most config files to new location on macOS.""" old_config = config(auto=True) # ~/Library/Preferences/qutebrowser new_config = config() # ~/.qutebrowser @@ -352,7 +353,7 @@ def _move_macos(): os.path.join(new_config, f)) -def _move_windows(): +def _move_windows() -> None: """Move the whole qutebrowser directory from Local to Roaming AppData.""" # %APPDATA%\Local\qutebrowser old_appdata_dir = _writable_location(QStandardPaths.DataLocation) @@ -375,7 +376,7 @@ def _move_windows(): os.path.join(new_config_dir, f)) -def _init_cachedir_tag(): +def _init_cachedir_tag() -> None: """Create CACHEDIR.TAG if it doesn't exist. See http://www.brynosaurus.com/cachedir/spec.html @@ -394,7 +395,7 @@ def _init_cachedir_tag(): log.init.exception("Failed to create CACHEDIR.TAG") -def _move_data(old, new): +def _move_data(old: str, new: str) -> bool: """Migrate data from an old to a new directory. If the old directory does not exist, the migration is skipped. diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py index db7adf139..4a4e68189 100644 --- a/qutebrowser/utils/urlmatch.py +++ b/qutebrowser/utils/urlmatch.py @@ -27,6 +27,7 @@ https://cs.chromium.org/chromium/src/extensions/common/url_pattern.h import ipaddress import fnmatch +import typing import urllib.parse from PyQt5.QtCore import QUrl @@ -64,15 +65,15 @@ class UrlPattern: _DEFAULT_PORTS = {'https': 443, 'http': 80, 'ftp': 21} _SCHEMES_WITHOUT_HOST = ['about', 'file', 'data', 'javascript'] - def __init__(self, pattern): + def __init__(self, pattern: str) -> None: # Make sure all attributes are initialized if we exit early. self._pattern = pattern self._match_all = False self._match_subdomains = False - self._scheme = None - self._host = None - self._path = None - self._port = None + self._scheme = None # type: typing.Optional[str] + self._host = None # type: typing.Optional[str] + self._path = None # type: typing.Optional[str] + self._port = None # type: typing.Optional[int] # > The special pattern matches any URL that starts with a # > permitted scheme. @@ -99,26 +100,26 @@ class UrlPattern: self._init_path(parsed) self._init_port(parsed) - def _to_tuple(self): + def _to_tuple(self) -> typing.Tuple: """Get a pattern with information used for __eq__/__hash__.""" return (self._match_all, self._match_subdomains, self._scheme, self._host, self._path, self._port) - def __hash__(self): + def __hash__(self) -> int: return hash(self._to_tuple()) - def __eq__(self, other): + def __eq__(self, other: typing.Any) -> bool: if not isinstance(other, UrlPattern): return NotImplemented return self._to_tuple() == other._to_tuple() - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self, pattern=self._pattern, constructor=True) - def __str__(self): + def __str__(self) -> str: return self._pattern - def _fixup_pattern(self, pattern): + def _fixup_pattern(self, pattern: str) -> str: """Make sure the given pattern is parseable by urllib.parse.""" if pattern.startswith('*:'): # Any scheme, but *:// is unparseable pattern = 'any:' + pattern[2:] @@ -135,7 +136,7 @@ class UrlPattern: return pattern - def _init_scheme(self, parsed): + def _init_scheme(self, parsed: urllib.parse.ParseResult) -> None: """Parse the scheme from the given URL. Deviation from Chromium: @@ -150,7 +151,7 @@ class UrlPattern: self._scheme = parsed.scheme - def _init_path(self, parsed): + def _init_path(self, parsed: urllib.parse.ParseResult) -> None: """Parse the path from the given URL. Deviation from Chromium: @@ -168,13 +169,15 @@ class UrlPattern: else: self._path = parsed.path - def _init_host(self, parsed): + def _init_host(self, parsed: urllib.parse.ParseResult) -> None: """Parse the host from the given URL. Deviation from Chromium: - http://:1234/ is not a valid URL because it has no host. """ - if parsed.hostname is None or not parsed.hostname.strip(): + # https://github.com/python/typeshed/commit/f0ccb325aa787ca0a539ef9914276b2c3148327a + if (parsed.hostname is None or # type: ignore + not parsed.hostname.strip()): if self._scheme not in self._SCHEMES_WITHOUT_HOST: raise ParseError("Pattern without host") assert self._host is None @@ -208,7 +211,7 @@ class UrlPattern: # Only * or *.foo is allowed as host. raise ParseError("Invalid host wildcard") - def _init_port(self, parsed): + def _init_port(self, parsed: urllib.parse.ParseResult) -> None: """Parse the port from the given URL. Deviation from Chromium: @@ -225,15 +228,16 @@ class UrlPattern: except ValueError as e: raise ParseError("Invalid port: {}".format(e)) - if (self._scheme not in list(self._DEFAULT_PORTS) + [None] and - self._port is not None): + scheme_has_port = (self._scheme in list(self._DEFAULT_PORTS) or + self._scheme is None) + if self._port is not None and not scheme_has_port: raise ParseError("Ports are unsupported with {} scheme".format( self._scheme)) - def _matches_scheme(self, scheme): + def _matches_scheme(self, scheme: str) -> bool: return self._scheme is None or self._scheme == scheme - def _matches_host(self, host): + def _matches_host(self, host: str) -> bool: # FIXME what about multiple dots? host = host.rstrip('.') @@ -268,12 +272,12 @@ class UrlPattern: return host[len(host) - len(self._host) - 1] == '.' - def _matches_port(self, scheme, port): + def _matches_port(self, scheme: str, port: int) -> bool: if port == -1 and scheme in self._DEFAULT_PORTS: port = self._DEFAULT_PORTS[scheme] return self._port is None or self._port == port - def _matches_path(self, path): + def _matches_path(self, path: str) -> bool: if self._path is None: return True @@ -285,7 +289,7 @@ class UrlPattern: # doesn't rely on regexes. Do we need that too? return fnmatch.fnmatchcase(path, self._path) - def matches(self, qurl): + def matches(self, qurl: QUrl) -> bool: """Check if the pattern matches the given QUrl.""" qtutils.ensure_valid(qurl) diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index c6f4cb858..74273bd85 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -25,6 +25,7 @@ import os.path import ipaddress import posixpath import urllib.parse +import typing from PyQt5.QtCore import QUrl, QUrlQuery from PyQt5.QtNetwork import QHostInfo, QHostAddress, QNetworkProxy @@ -58,7 +59,7 @@ class InvalidUrlError(Exception): """Error raised if a function got an invalid URL.""" - def __init__(self, url): + def __init__(self, url: QUrl) -> None: if url.isValid(): raise ValueError("Got valid URL {}!".format(url.toDisplayString())) self.url = url @@ -66,7 +67,7 @@ class InvalidUrlError(Exception): super().__init__(self.msg) -def _parse_search_term(s): +def _parse_search_term(s: str) -> typing.Tuple[typing.Optional[str], str]: """Get a search engine name and search term from a string. Args: @@ -79,7 +80,7 @@ def _parse_search_term(s): split = s.split(maxsplit=1) if len(split) == 2: - engine = split[0] + engine = split[0] # type: typing.Optional[str] try: config.val.url.searchengines[engine] except KeyError: @@ -97,7 +98,7 @@ def _parse_search_term(s): return (engine, term) -def _get_search_url(txt): +def _get_search_url(txt: str) -> QUrl: """Get a search engine URL for a text. Args: @@ -117,14 +118,14 @@ def _get_search_url(txt): if config.val.url.open_base_url and term in config.val.url.searchengines: url = qurl_from_user_input(config.val.url.searchengines[term]) - url.setPath(None) - url.setFragment(None) - url.setQuery(None) + url.setPath(None) # type: ignore + url.setFragment(None) # type: ignore + url.setQuery(None) # type: ignore qtutils.ensure_valid(url) return url -def _is_url_naive(urlstr): +def _is_url_naive(urlstr: str) -> bool: """Naive check if given URL is really a URL. Args: @@ -150,7 +151,7 @@ def _is_url_naive(urlstr): return '.' in host and not host.endswith('.') -def _is_url_dns(urlstr): +def _is_url_dns(urlstr: str) -> bool: """Check if a URL is really a URL via DNS. Args: @@ -178,8 +179,11 @@ def _is_url_dns(urlstr): return not info.error() -def fuzzy_url(urlstr, cwd=None, relative=False, do_search=True, - force_search=False): +def fuzzy_url(urlstr: str, + cwd: str = None, + relative: bool = False, + do_search: bool = True, + force_search: bool = False) -> QUrl: """Get a QUrl based on a user input which is URL or search term. Args: @@ -218,7 +222,7 @@ def fuzzy_url(urlstr, cwd=None, relative=False, do_search=True, return url -def _has_explicit_scheme(url): +def _has_explicit_scheme(url: QUrl) -> bool: """Check if a url has an explicit scheme given. Args: @@ -228,13 +232,13 @@ def _has_explicit_scheme(url): # after the scheme delimiter. Since we don't know of any URIs # using this and want to support e.g. searching for scoped C++ # symbols, we treat this as not a URI anyways. - return (url.isValid() and url.scheme() and - (url.host() or url.path()) and - ' ' not in url.path() and - not url.path().startswith(':')) + return bool(url.isValid() and url.scheme() and + (url.host() or url.path()) and + ' ' not in url.path() and + not url.path().startswith(':')) -def is_special_url(url): +def is_special_url(url: QUrl) -> bool: """Return True if url is an about:... or other special URL. Args: @@ -246,7 +250,7 @@ def is_special_url(url): return url.scheme() in special_schemes -def is_url(urlstr): +def is_url(urlstr: str) -> bool: """Check if url seems to be a valid URL. Args: @@ -303,7 +307,7 @@ def is_url(urlstr): return url -def qurl_from_user_input(urlstr): +def qurl_from_user_input(urlstr: str) -> QUrl: """Get a QUrl based on a user input. Additionally handles IPv6 addresses. QUrl.fromUserInput handles something like '::1' as a file URL instead of an @@ -339,12 +343,12 @@ def qurl_from_user_input(urlstr): return QUrl('http://[{}]{}'.format(ipstr, rest)) -def ensure_valid(url): +def ensure_valid(url: QUrl) -> None: if not url.isValid(): raise InvalidUrlError(url) -def invalid_url_error(url, action): +def invalid_url_error(url: QUrl, action: str) -> None: """Display an error message for a URL. Args: @@ -358,7 +362,7 @@ def invalid_url_error(url, action): message.error(errstring) -def raise_cmdexc_if_invalid(url): +def raise_cmdexc_if_invalid(url: QUrl) -> None: """Check if the given QUrl is invalid, and if so, raise a CommandError.""" try: ensure_valid(url) @@ -366,7 +370,10 @@ def raise_cmdexc_if_invalid(url): raise cmdutils.CommandError(str(e)) -def get_path_if_valid(pathstr, cwd=None, relative=False, check_exists=False): +def get_path_if_valid(pathstr: str, + cwd: str = None, + relative: bool = False, + check_exists: bool = False) -> typing.Optional[str]: """Check if path is a valid path. Args: @@ -384,7 +391,7 @@ def get_path_if_valid(pathstr, cwd=None, relative=False, check_exists=False): expanded = os.path.expanduser(pathstr) if os.path.isabs(expanded): - path = expanded + path = expanded # type: typing.Optional[str] elif relative and cwd: path = os.path.join(cwd, expanded) elif relative: @@ -411,7 +418,7 @@ def get_path_if_valid(pathstr, cwd=None, relative=False, check_exists=False): return path -def filename_from_url(url): +def filename_from_url(url: QUrl) -> typing.Optional[str]: """Get a suitable filename from a URL. Args: @@ -431,7 +438,7 @@ def filename_from_url(url): return None -def host_tuple(url): +def host_tuple(url: QUrl) -> typing.Tuple[str, str, int]: """Get a (scheme, host, port) tuple from a QUrl. This is suitable to identify a connection, e.g. for SSL errors. @@ -456,7 +463,7 @@ def host_tuple(url): return scheme, host, port -def get_errstring(url, base="Invalid URL"): +def get_errstring(url: QUrl, base: str = "Invalid URL") -> str: """Get an error string for a URL. Args: @@ -473,7 +480,7 @@ def get_errstring(url, base="Invalid URL"): return base -def same_domain(url1, url2): +def same_domain(url1: QUrl, url2: QUrl) -> bool: """Check if url1 and url2 belong to the same website. This will use a "public suffix list" to determine what a "top level domain" @@ -501,7 +508,7 @@ def same_domain(url1, url2): return domain1 == domain2 -def encoded_url(url): +def encoded_url(url: QUrl) -> str: """Return the fully encoded url as string. Args: @@ -510,16 +517,17 @@ def encoded_url(url): return bytes(url.toEncoded()).decode('ascii') -def file_url(path): +def file_url(path: str) -> QUrl: """Return a file:// url (as string) to the given local path. Arguments: path: The absolute path to the local file """ - return QUrl.fromLocalFile(path).toString(QUrl.FullyEncoded) + url = QUrl.fromLocalFile(path) + return url.toString(QUrl.FullyEncoded) # type: ignore -def data_url(mimetype, data): +def data_url(mimetype: str, data: bytes) -> QUrl: """Get a data: QUrl for the given data.""" b64 = base64.b64encode(data).decode('ascii') url = QUrl('data:{};base64,{}'.format(mimetype, b64)) @@ -527,7 +535,7 @@ def data_url(mimetype, data): return url -def safe_display_string(qurl): +def safe_display_string(qurl: QUrl) -> str: """Get a IDN-homograph phishing safe form of the given QUrl. If we're dealing with a Punycode-encoded URL, this prepends the hostname in @@ -538,19 +546,20 @@ def safe_display_string(qurl): """ ensure_valid(qurl) - host = qurl.host(QUrl.FullyEncoded) + host = qurl.host(QUrl.FullyEncoded) # type: ignore if '..' in host: # pragma: no cover # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-60364 return '(unparseable URL!) {}'.format(qurl.toDisplayString()) for part in host.split('.'): - if part.startswith('xn--') and host != qurl.host(QUrl.FullyDecoded): + url_host = qurl.host(QUrl.FullyDecoded) # type: ignore + if part.startswith('xn--') and host != url_host: return '({}) {}'.format(host, qurl.toDisplayString()) return qurl.toDisplayString() -def query_string(qurl): +def query_string(qurl: QUrl) -> str: """Get a query string for the given URL. This is a WORKAROUND for: @@ -566,11 +575,11 @@ class InvalidProxyTypeError(Exception): """Error raised when proxy_from_url gets an unknown proxy type.""" - def __init__(self, typ): + def __init__(self, typ: str) -> None: super().__init__("Invalid proxy type {}!".format(typ)) -def proxy_from_url(url): +def proxy_from_url(url: QUrl) -> QNetworkProxy: """Create a QNetworkProxy from QUrl and a proxy type. Args: diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 0bb7badc8..102cc01e9 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -17,23 +17,31 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Custom useful data types. - -Module attributes: - _UNSET: Used as default argument in the constructor so default can be None. -""" +"""Custom useful data types.""" import operator import collections.abc import enum +import typing import attr from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QTimer +from PyQt5.QtCore import QUrl # pylint: disable=unused-import from qutebrowser.utils import log, qtutils, utils -_UNSET = object() +_T = typing.TypeVar('_T') + + +class UnsetObject: + + """Class for an unset object.""" + + __slots__ = () + + +UNSET = UnsetObject() class NeighborList(collections.abc.Sequence): @@ -52,7 +60,9 @@ class NeighborList(collections.abc.Sequence): Modes = enum.Enum('Modes', ['edge', 'exception']) - def __init__(self, items=None, default=_UNSET, mode=Modes.exception): + def __init__(self, items: typing.Sequence[_T] = None, + default: typing.Union[_T, UnsetObject] = UNSET, + mode: Modes = Modes.exception) -> None: """Constructor. Args: @@ -65,28 +75,31 @@ class NeighborList(collections.abc.Sequence): if not isinstance(mode, self.Modes): raise TypeError("Mode {} is not a Modes member!".format(mode)) if items is None: - self._items = [] + self._items = [] # type: typing.Sequence[_T] else: self._items = list(items) self._default = default - if default is not _UNSET: - self._idx = self._items.index(default) + + if not isinstance(default, UnsetObject): + idx = self._items.index(default) + self._idx = idx # type: typing.Optional[int] else: self._idx = None - self._mode = mode - self.fuzzyval = None - def __getitem__(self, key): + self._mode = mode + self.fuzzyval = None # type: typing.Optional[int] + + def __getitem__(self, key: int) -> _T: # type: ignore return self._items[key] - def __len__(self): + def __len__(self) -> int: return len(self._items) - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self, items=self._items, mode=self._mode, idx=self._idx, fuzzyval=self.fuzzyval) - def _snap_in(self, offset): + def _snap_in(self, offset: int) -> bool: """Set the current item to the closest item to self.fuzzyval. Args: @@ -97,11 +110,15 @@ class NeighborList(collections.abc.Sequence): True if the value snapped in (changed), False when the value already was in the list. """ + assert isinstance(self.fuzzyval, (int, float)), self.fuzzyval + op = operator.le if offset < 0 else operator.ge items = [(idx, e) for (idx, e) in enumerate(self._items) if op(e, self.fuzzyval)] if items: - item = min(items, key=lambda tpl: abs(self.fuzzyval - tpl[1])) + item = min( + items, + key=lambda tpl: abs(self.fuzzyval - tpl[1])) # type: ignore else: sorted_items = sorted(((idx, e) for (idx, e) in enumerate(self.items)), key=lambda e: e[1]) @@ -110,7 +127,7 @@ class NeighborList(collections.abc.Sequence): self._idx = item[0] return self.fuzzyval not in self._items - def _get_new_item(self, offset): + def _get_new_item(self, offset: int) -> _T: """Logic for getitem to get the item at offset. Args: @@ -119,6 +136,7 @@ class NeighborList(collections.abc.Sequence): Return: The new item. """ + assert self._idx is not None try: if self._idx + offset >= 0: new = self._items[self._idx + offset] @@ -138,11 +156,11 @@ class NeighborList(collections.abc.Sequence): return new @property - def items(self): + def items(self) -> typing.Sequence[_T]: """Getter for items, which should not be set.""" return self._items - def getitem(self, offset): + def getitem(self, offset: int) -> _T: """Get the item with a relative position. Args: @@ -167,38 +185,38 @@ class NeighborList(collections.abc.Sequence): self.fuzzyval = None return self._get_new_item(offset) - def curitem(self): + def curitem(self) -> _T: """Get the current item in the list.""" if self._idx is not None: return self._items[self._idx] else: raise IndexError("No current item!") - def nextitem(self): + def nextitem(self) -> _T: """Get the next item in the list.""" return self.getitem(1) - def previtem(self): + def previtem(self) -> _T: """Get the previous item in the list.""" return self.getitem(-1) - def firstitem(self): + def firstitem(self) -> _T: """Get the first item in the list.""" if not self._items: raise IndexError("No items found!") self._idx = 0 return self.curitem() - def lastitem(self): + def lastitem(self) -> _T: """Get the last item in the list.""" if not self._items: raise IndexError("No items found!") self._idx = len(self._items) - 1 return self.curitem() - def reset(self): + def reset(self) -> _T: """Reset the position to the default.""" - if self._default is _UNSET: + if self._default is UNSET: raise ValueError("No default set!") self._idx = self._items.index(self._default) return self.curitem() @@ -334,37 +352,25 @@ class Question(QObject): answered_no = pyqtSignal() completed = pyqtSignal() - def __init__(self, parent=None): + def __init__(self, parent: QObject = None) -> None: super().__init__(parent) - self._mode = None - self.default = None - self.title = None - self.text = None - self.url = None - self.option = None - self.answer = None + self.mode = None # type: typing.Optional[PromptMode] + self.default = None # type: typing.Union[bool, str, None] + self.title = None # type: typing.Optional[str] + self.text = None # type: typing.Optional[str] + self.url = None # type: typing.Optional[QUrl] + self.option = None # type: typing.Optional[bool] + self.answer = None # type: typing.Union[str, bool, None] self.is_aborted = False self.interrupted = False - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self, title=self.title, text=self.text, - mode=self._mode, default=self.default, + mode=self.mode, default=self.default, option=self.option) - @property - def mode(self): - """Getter for mode so we can define a setter.""" - return self._mode - - @mode.setter - def mode(self, val): - """Setter for mode to do basic type checking.""" - if not isinstance(val, PromptMode): - raise TypeError("Mode {} is no PromptMode member!".format(val)) - self._mode = val - @pyqtSlot() - def done(self): + def done(self) -> None: """Must be called when the question was answered completely.""" self.answered.emit(self.answer) if self.mode == PromptMode.yesno: @@ -375,13 +381,13 @@ class Question(QObject): self.completed.emit() @pyqtSlot() - def cancel(self): + def cancel(self) -> None: """Cancel the question (resulting from user-input).""" self.cancelled.emit() self.completed.emit() @pyqtSlot() - def abort(self): + def abort(self) -> None: """Abort the question.""" if self.is_aborted: log.misc.debug("Question was already aborted") @@ -399,7 +405,7 @@ class Timer(QTimer): _name: The name of the timer. """ - def __init__(self, parent=None, name=None): + def __init__(self, parent: QObject = None, name: str = None) -> None: super().__init__(parent) if name is None: self._name = "unnamed" @@ -407,15 +413,15 @@ class Timer(QTimer): self.setObjectName(name) self._name = name - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self, name=self._name) - def setInterval(self, msec): + def setInterval(self, msec: int) -> None: """Extend setInterval to check for overflows.""" qtutils.check_overflow(msec, 'int') super().setInterval(msec) - def start(self, msec=None): + def start(self, msec: int = None) -> None: """Extend start to check for overflows.""" if msec is not None: qtutils.check_overflow(msec, 'int') @@ -428,16 +434,16 @@ class AbstractCertificateErrorWrapper: """A wrapper over an SSL/certificate error.""" - def __init__(self, error): + def __init__(self, error: typing.Any) -> None: self._error = error - def __str__(self): + def __str__(self) -> str: raise NotImplementedError - def __repr__(self): + def __repr__(self) -> str: raise NotImplementedError - def is_overridable(self): + def is_overridable(self) -> bool: raise NotImplementedError @@ -457,7 +463,7 @@ class NavigationRequest: 'other' ]) - url = attr.ib() - navigation_type = attr.ib() - is_main_frame = attr.ib() - accepted = attr.ib(default=True) + url = attr.ib() # type: QUrl + navigation_type = attr.ib() # type: Type + is_main_frame = attr.ib() # type: bool + accepted = attr.ib(default=True) # type: bool diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 30fdaffb9..966515df0 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -35,6 +35,7 @@ import socket import shlex import glob import mimetypes +import typing from PyQt5.QtCore import QUrl from PyQt5.QtGui import QColor, QClipboard, QDesktopServices @@ -78,7 +79,7 @@ class SelectionUnsupportedError(ClipboardError): """Raised if [gs]et_clipboard is used and selection=True is unsupported.""" - def __init__(self): + def __init__(self) -> None: super().__init__("Primary selection is not supported on this " "platform!") @@ -88,7 +89,7 @@ class ClipboardEmptyError(ClipboardError): """Raised if get_clipboard is used and the clipboard is empty.""" -def elide(text, length): +def elide(text: str, length: int) -> str: """Elide text so it uses a maximum of length chars.""" if length < 1: raise ValueError("length must be >= 1!") @@ -98,7 +99,7 @@ def elide(text, length): return text[:length - 1] + '\u2026' -def elide_filename(filename, length): +def elide_filename(filename: str, length: int) -> str: """Elide a filename to the given length. The difference to the elide() is that the text is removed from @@ -130,7 +131,7 @@ def elide_filename(filename, length): return filename[:left] + elidestr + filename[-right:] -def compact_text(text, elidelength=None): +def compact_text(text: str, elidelength: int = None) -> str: """Remove leading whitespace and newlines from a text and maybe elide it. Args: @@ -146,7 +147,7 @@ def compact_text(text, elidelength=None): return out -def preload_resources(): +def preload_resources() -> None: """Load resource files into the cache.""" for subdir, pattern in [('html', '*.html'), ('javascript', '*.js')]: path = resource_filename(subdir) @@ -155,7 +156,8 @@ def preload_resources(): _resource_cache[sub_path] = read_file(sub_path) -def read_file(filename, binary=False): +# FIXME:typing Return value should be bytes/str +def read_file(filename: str, binary: bool = False) -> typing.Any: """Get the contents of a file contained with qutebrowser. Args: @@ -177,19 +179,22 @@ def read_file(filename, binary=False): # https://github.com/pyinstaller/pyinstaller/wiki/FAQ#misc fn = os.path.join(os.path.dirname(sys.executable), filename) if binary: - with open(fn, 'rb') as f: + with open(fn, 'rb') as f: # type: typing.IO return f.read() else: with open(fn, 'r', encoding='utf-8') as f: return f.read() else: - data = pkg_resources.resource_string(qutebrowser.__name__, filename) - if not binary: - data = data.decode('UTF-8') - return data + data = pkg_resources.resource_string( + qutebrowser.__name__, filename) + + if binary: + return data + + return data.decode('UTF-8') -def resource_filename(filename): +def resource_filename(filename: str) -> str: """Get the absolute filename of a file contained with qutebrowser. Args: @@ -203,7 +208,9 @@ def resource_filename(filename): return pkg_resources.resource_filename(qutebrowser.__name__, filename) -def _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3, percent): +def _get_color_percentage(a_c1: int, a_c2: int, a_c3: + int, b_c1: int, b_c2: int, b_c3: int, + percent: int) -> typing.Tuple[int, int, int]: """Get a color which is percent% interpolated between start and end. Args: @@ -224,7 +231,12 @@ def _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3, percent): return (out_c1, out_c2, out_c3) -def interpolate_color(start, end, percent, colorspace=QColor.Rgb): +def interpolate_color( + start: QColor, + end: QColor, + percent: int, + colorspace: typing.Optional[QColor.Spec] = QColor.Rgb +) -> QColor: """Get an interpolated color value. Args: @@ -273,7 +285,7 @@ def interpolate_color(start, end, percent, colorspace=QColor.Rgb): return out -def format_seconds(total_seconds): +def format_seconds(total_seconds: int) -> str: """Format a count of seconds to get a [H:]M:SS string.""" prefix = '-' if total_seconds < 0 else '' hours, rem = divmod(abs(round(total_seconds)), 3600) @@ -289,7 +301,9 @@ def format_seconds(total_seconds): return prefix + ':'.join(chunks) -def format_size(size, base=1024, suffix=''): +def format_size(size: typing.Optional[float], + base: int = 1024, + suffix: str = '') -> str: """Format a byte size so it's human readable. Inspired by http://stackoverflow.com/q/1094841 @@ -308,13 +322,13 @@ class FakeIOStream(io.TextIOBase): """A fake file-like stream which calls a function for write-calls.""" - def __init__(self, write_func): + def __init__(self, write_func: typing.Callable[[str], int]) -> None: super().__init__() - self.write = write_func + self.write = write_func # type: ignore @contextlib.contextmanager -def fake_io(write_func): +def fake_io(write_func: typing.Callable[[str], int]) -> typing.Iterator[None]: """Run code with stdout and stderr replaced by FakeIOStreams. Args: @@ -324,21 +338,21 @@ def fake_io(write_func): old_stderr = sys.stderr fake_stderr = FakeIOStream(write_func) fake_stdout = FakeIOStream(write_func) - sys.stderr = fake_stderr - sys.stdout = fake_stdout + sys.stderr = fake_stderr # type: ignore + sys.stdout = fake_stdout # type: ignore try: yield finally: # If the code we did run did change sys.stdout/sys.stderr, we leave it # unchanged. Otherwise, we reset it. - if sys.stdout is fake_stdout: + if sys.stdout is fake_stdout: # type: ignore sys.stdout = old_stdout - if sys.stderr is fake_stderr: + if sys.stderr is fake_stderr: # type: ignore sys.stderr = old_stderr @contextlib.contextmanager -def disabled_excepthook(): +def disabled_excepthook() -> typing.Iterator[None]: """Run code with the exception hook temporarily disabled.""" old_excepthook = sys.excepthook sys.excepthook = sys.__excepthook__ @@ -371,7 +385,7 @@ class prevent_exceptions: # noqa: N801,N806 pylint: disable=invalid-name _predicate: The condition which needs to be True to prevent exceptions """ - def __init__(self, retval, predicate=True): + def __init__(self, retval: typing.Any, predicate: bool = True) -> None: """Save decorator arguments. Gets called on parse-time with the decorator arguments. @@ -382,7 +396,7 @@ class prevent_exceptions: # noqa: N801,N806 pylint: disable=invalid-name self._retval = retval self._predicate = predicate - def __call__(self, func): + def __call__(self, func: typing.Callable) -> typing.Callable: """Called when a function should be decorated. Args: @@ -397,7 +411,7 @@ class prevent_exceptions: # noqa: N801,N806 pylint: disable=invalid-name retval = self._retval @functools.wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: """Call the original function.""" try: return func(*args, **kwargs) @@ -408,7 +422,7 @@ class prevent_exceptions: # noqa: N801,N806 pylint: disable=invalid-name return wrapper -def is_enum(obj): +def is_enum(obj: typing.Any) -> bool: """Check if a given object is an enum.""" try: return issubclass(obj, enum.Enum) @@ -416,7 +430,9 @@ def is_enum(obj): return False -def get_repr(obj, constructor=False, **attrs): +def get_repr(obj: typing.Any, + constructor: bool = False, + **attrs: typing.Any) -> str: """Get a suitable __repr__ string for an object. Args: @@ -439,7 +455,7 @@ def get_repr(obj, constructor=False, **attrs): return '<{}>'.format(cls) -def qualname(obj): +def qualname(obj: typing.Any) -> str: """Get the fully qualified name of an object. Based on twisted.python.reflect.fullyQualifiedName. @@ -467,7 +483,10 @@ def qualname(obj): return repr(obj) -def raises(exc, func, *args): +def raises(exc: typing.Union[typing.Type[BaseException], + typing.Tuple[typing.Type[BaseException]]], + func: typing.Callable, + *args: typing.Any) -> bool: """Check if a function raises a given exception. Args: @@ -486,7 +505,7 @@ def raises(exc, func, *args): return False -def force_encoding(text, encoding): +def force_encoding(text: str, encoding: str) -> str: """Make sure a given text is encodable with the given encoding. This replaces all chars not encodable with question marks. @@ -494,7 +513,8 @@ def force_encoding(text, encoding): return text.encode(encoding, errors='replace').decode(encoding) -def sanitize_filename(name, replacement='_'): +def sanitize_filename(name: str, + replacement: typing.Optional[str] = '_') -> str: """Replace invalid filename characters. Note: This should be used for the basename, as it also removes the path @@ -527,7 +547,7 @@ def sanitize_filename(name, replacement='_'): return name -def set_clipboard(data, selection=False): +def set_clipboard(data: str, selection: bool = False) -> None: """Set the clipboard to some given data.""" global fake_clipboard if selection and not supports_selection(): @@ -541,7 +561,7 @@ def set_clipboard(data, selection=False): QApplication.clipboard().setText(data, mode=mode) -def get_clipboard(selection=False, fallback=False): +def get_clipboard(selection: bool = False, fallback: bool = False) -> str: """Get data from the clipboard. Args: @@ -574,12 +594,12 @@ def get_clipboard(selection=False, fallback=False): return data -def supports_selection(): +def supports_selection() -> bool: """Check if the OS supports primary selection.""" return QApplication.clipboard().supportsSelection() -def random_port(): +def random_port() -> int: """Get a random free port.""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(('localhost', 0)) @@ -588,7 +608,7 @@ def random_port(): return port -def open_file(filename, cmdline=None): +def open_file(filename: str, cmdline: str = None) -> None: """Open the given file. If cmdline is not given, downloads.open_dispatcher is used. @@ -624,6 +644,8 @@ def open_file(filename, cmdline=None): if cmdline is None and override: cmdline = override + assert cmdline is not None + cmd, *args = shlex.split(cmdline) args = [arg.replace('{}', filename) for arg in args] if '{}' not in cmdline: @@ -634,11 +656,11 @@ def open_file(filename, cmdline=None): proc.start_detached(cmd, args) -def unused(_arg): +def unused(_arg: typing.Any) -> None: """Function which does nothing to avoid pylint complaining.""" -def expand_windows_drive(path): +def expand_windows_drive(path: str) -> str: r"""Expand a drive-path like E: into E:\. Does nothing for other paths. @@ -656,7 +678,7 @@ def expand_windows_drive(path): return path -def yaml_load(f): +def yaml_load(f: typing.Union[str, typing.IO[str]]) -> typing.Any: """Wrapper over yaml.load using the C loader if possible.""" start = datetime.datetime.now() @@ -686,7 +708,8 @@ def yaml_load(f): return data -def yaml_dump(data, f=None): +def yaml_dump(data: typing.Any, + f: typing.IO[str] = None) -> typing.Optional[str]: """Wrapper over yaml.dump using the C dumper if possible. Also returns a str instead of bytes. @@ -699,7 +722,7 @@ def yaml_dump(data, f=None): return yaml_data.decode('utf-8') -def chunk(elems, n): +def chunk(elems: typing.Sequence, n: int) -> typing.Iterator[typing.Sequence]: """Yield successive n-sized chunks from elems. If elems % n != 0, the last chunk will be smaller. @@ -710,7 +733,7 @@ def chunk(elems, n): yield elems[i:i + n] -def guess_mimetype(filename, fallback=False): +def guess_mimetype(filename: str, fallback: bool = False) -> str: """Guess a mimetype based on a filename. Args: @@ -726,7 +749,7 @@ def guess_mimetype(filename, fallback=False): return mimetype -def ceil_log(number, base): +def ceil_log(number: int, base: int) -> int: """Compute max(1, ceil(log(number, base))). Use only integer arithmetic in order to avoid numerical error. diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 3d784dd91..b339994a8 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -30,6 +30,7 @@ import collections import enum import datetime import getpass +import typing import attr import pkg_resources @@ -66,10 +67,10 @@ class DistributionInfo: """Information about the running distribution.""" - id = attr.ib() - parsed = attr.ib() - version = attr.ib() - pretty = attr.ib() + id = attr.ib() # type: typing.Optional[str] + parsed = attr.ib() # type: Distribution + version = attr.ib() # type: typing.Optional[typing.Tuple[str, ...]] + pretty = attr.ib() # type: str pastebin_url = None @@ -79,7 +80,7 @@ Distribution = enum.Enum( 'kde']) -def distribution(): +def distribution() -> typing.Optional[DistributionInfo]: """Get some information about the running Linux distribution. Returns: @@ -104,9 +105,12 @@ def distribution(): pretty = info.get('PRETTY_NAME', None) if pretty in ['Linux', None]: # Funtoo has PRETTY_NAME=Linux pretty = info.get('NAME', 'Unknown') + assert pretty is not None if 'VERSION_ID' in info: - dist_version = pkg_resources.parse_version(info['VERSION_ID']) + dist_version = pkg_resources.parse_version( + info['VERSION_ID'] + ) # type: typing.Optional[typing.Tuple[str, ...]] else: dist_version = None @@ -115,16 +119,19 @@ def distribution(): 'funtoo': 'gentoo', # does not have ID_LIKE=gentoo 'org.kde.Platform': 'kde', } - try: - parsed = Distribution[id_mappings.get(dist_id, dist_id)] - except KeyError: - parsed = Distribution.unknown + + parsed = Distribution.unknown + if dist_id is not None: + try: + parsed = Distribution[id_mappings.get(dist_id, dist_id)] + except KeyError: + pass return DistributionInfo(parsed=parsed, version=dist_version, pretty=pretty, id=dist_id) -def _git_str(): +def _git_str() -> typing.Optional[str]: """Try to find out git version. Return: @@ -150,7 +157,7 @@ def _git_str(): return None -def _git_str_subprocess(gitpath): +def _git_str_subprocess(gitpath: str) -> typing.Optional[str]: """Try to get the git commit ID and timestamp by calling git. Args: @@ -176,7 +183,7 @@ def _git_str_subprocess(gitpath): return None -def _release_info(): +def _release_info() -> typing.Sequence[typing.Tuple[str, str]]: """Try to gather distribution release information. Return: @@ -200,7 +207,7 @@ def _release_info(): return data -def _module_versions(): +def _module_versions() -> typing.Sequence[str]: """Get versions of optional modules. Return: @@ -219,7 +226,7 @@ def _module_versions(): ('PyQt5.QtWebEngineWidgets', []), ('PyQt5.QtWebEngine', ['PYQT_WEBENGINE_VERSION_STR']), ('PyQt5.QtWebKitWidgets', []), - ]) + ]) # type: typing.Mapping[str, typing.Sequence[str]] for modname, attributes in modules.items(): try: module = importlib.import_module(modname) @@ -239,7 +246,7 @@ def _module_versions(): return lines -def _path_info(): +def _path_info() -> typing.Mapping[str, str]: """Get info about important path names. Return: @@ -258,7 +265,7 @@ def _path_info(): return info -def _os_info(): +def _os_info() -> typing.Sequence[str]: """Get operating system info. Return: @@ -272,11 +279,11 @@ def _os_info(): elif utils.is_windows: osver = ', '.join(platform.win32_ver()) elif utils.is_mac: - release, versioninfo, machine = platform.mac_ver() - if all(not e for e in versioninfo): + release, info_tpl, machine = platform.mac_ver() + if all(not e for e in info_tpl): versioninfo = '' else: - versioninfo = '.'.join(versioninfo) + versioninfo = '.'.join(info_tpl) osver = ', '.join([e for e in [release, versioninfo, machine] if e]) elif utils.is_posix: osver = ' '.join(platform.uname()) @@ -289,7 +296,7 @@ def _os_info(): return lines -def _pdfjs_version(): +def _pdfjs_version() -> str: """Get the pdf.js version. Return: @@ -315,7 +322,7 @@ def _pdfjs_version(): return '{} ({})'.format(pdfjs_version, file_path) -def _chromium_version(): +def _chromium_version() -> str: """Get the Chromium version for QtWebEngine. This can also be checked by looking at this file with the right Qt tag: @@ -368,7 +375,7 @@ def _chromium_version(): return match.group(1) -def _backend(): +def _backend() -> str: """Get the backend line with relevant information.""" if objects.backend == usertypes.Backend.QtWebKit: return 'new QtWebKit (WebKit {})'.format(qWebKitVersion()) @@ -397,7 +404,7 @@ def _config_py_loaded() -> str: return "no config.py was loaded" -def version(): +def version() -> str: """Return a string with various version information.""" lines = ["qutebrowser v{}".format(qutebrowser.__version__)] gitver = _git_str() @@ -471,7 +478,7 @@ def version(): return '\n'.join(lines) -def opengl_vendor(): # pragma: no cover +def opengl_vendor() -> typing.Optional[str]: # pragma: no cover """Get the OpenGL vendor used. This returns a string such as 'nouveau' or @@ -485,7 +492,8 @@ def opengl_vendor(): # pragma: no cover log.init.debug("Using override {}".format(override)) return override - old_context = QOpenGLContext.currentContext() + old_context = typing.cast(typing.Optional[QOpenGLContext], + QOpenGLContext.currentContext()) old_surface = None if old_context is None else old_context.surface() surface = QOffscreenSurface() @@ -527,20 +535,22 @@ def opengl_vendor(): # pragma: no cover old_context.makeCurrent(old_surface) -def pastebin_version(pbclient=None): +def pastebin_version(pbclient: pastebin.PastebinClient = None) -> None: """Pastebin the version and log the url to messages.""" - def _yank_url(url): + def _yank_url(url: str) -> None: utils.set_clipboard(url) message.info("Version url {} yanked to clipboard.".format(url)) - def _on_paste_version_success(url): + def _on_paste_version_success(url: str) -> None: + assert pbclient is not None global pastebin_url url = url.strip() _yank_url(url) pbclient.deleteLater() pastebin_url = url - def _on_paste_version_err(text): + def _on_paste_version_err(text: str) -> None: + assert pbclient is not None message.error("Failed to pastebin version" " info: {}".format(text)) pbclient.deleteLater() diff --git a/requirements.txt b/requirements.txt index 4d1279e87..5c8f4f385 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -attrs==19.1.0 +attrs==19.2.0 colorama==0.4.1 cssutils==1.0.2 -Jinja2==2.10.1 +Jinja2==2.10.3 MarkupSafe==1.1.1 Pygments==2.4.2 pyPEG2==2.15.2 diff --git a/scripts/dev/run_profile.py b/scripts/dev/run_profile.py index e7ff564e2..e71c6aff0 100755 --- a/scripts/dev/run_profile.py +++ b/scripts/dev/run_profile.py @@ -49,6 +49,7 @@ def parse_args(): default='snakeviz', help="The tool to use to view the profiling data") parser.add_argument('--profile-file', metavar='FILE', action='store', + default="profile_data", help="The filename to use with --profile-tool=none") parser.add_argument('--profile-test', action='store_true', help="Run pytest instead of qutebrowser") @@ -80,7 +81,7 @@ def main(): profiler.dump_stats(profilefile) if args.profile_tool == 'none': - pass + print("Profile data written to {}".format(profilefile)) elif args.profile_tool == 'gprof2dot': # yep, shell=True. I know what I'm doing. subprocess.run( diff --git a/scripts/utils.py b/scripts/utils.py index d4e606ebc..6467c915c 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -52,14 +52,6 @@ fg_colors = { bg_colors = {name: col + 10 for name, col in fg_colors.items()} -term_attributes = { - 'bright': 1, - 'dim': 2, - 'normal': 22, - 'reset': 0, -} - - def _esc(code): """Get an ANSI color code based on a color number.""" return '\033[{}m'.format(code) diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 12bb789bd..01129c8ee 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -100,7 +100,7 @@ class Request(testprocess.Line): return NotImplemented -@attr.s(frozen=True, cmp=False, hash=True) +@attr.s(frozen=True, eq=False, hash=True) class ExpectedRequest: """Class to compare expected requests easily.""" diff --git a/tests/end2end/test_dirbrowser.py b/tests/end2end/test_dirbrowser.py index 1d68e26dc..df7cf18c0 100644 --- a/tests/end2end/test_dirbrowser.py +++ b/tests/end2end/test_dirbrowser.py @@ -27,7 +27,9 @@ import pytest import bs4 from PyQt5.QtCore import QUrl + from qutebrowser.utils import urlutils +from helpers import utils as testutils pytestmark = pytest.mark.qtwebengine_skip("Title is empty when parsing for " @@ -127,7 +129,10 @@ def parse(quteproc): """ html = quteproc.get_content(plain=False) soup = bs4.BeautifulSoup(html, 'html.parser') - print(soup.prettify()) + + with testutils.ignore_bs4_warning(): + print(soup.prettify()) + title_prefix = 'Browse directory: ' # Strip off the title prefix to obtain the path of the folder that # we're browsing diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index d752456e6..596b5241b 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -47,8 +47,8 @@ from qutebrowser.config import (config, configdata, configtypes, configexc, from qutebrowser.api import config as configapi from qutebrowser.utils import objreg, standarddir, utils, usertypes from qutebrowser.browser import greasemonkey, history, qutescheme -from qutebrowser.browser.webkit import cookies -from qutebrowser.misc import savemanager, sql, objects +from qutebrowser.browser.webkit import cookies, cache +from qutebrowser.misc import savemanager, sql, objects, sessions from qutebrowser.keyinput import modeman @@ -350,12 +350,11 @@ def bookmark_manager_stub(stubs): @pytest.fixture -def session_manager_stub(stubs): +def session_manager_stub(stubs, monkeypatch): """Fixture which provides a fake session-manager object.""" stub = stubs.SessionManagerStub() - objreg.register('session-manager', stub) - yield stub - objreg.delete('session-manager') + monkeypatch.setattr(sessions, 'session_manager', stub) + return stub @pytest.fixture @@ -370,15 +369,6 @@ def tabbed_browser_stubs(qapp, stubs, win_registry): objreg.delete('tabbed-browser', scope='window', window=1) -@pytest.fixture -def app_stub(stubs): - """Fixture which provides a fake app object.""" - stub = stubs.ApplicationStub() - objreg.register('app', stub) - yield stub - objreg.delete('app') - - @pytest.fixture def status_command_stub(stubs, qtbot, win_registry): """Fixture which provides a fake status-command object.""" @@ -465,18 +455,11 @@ def webframe(webpage): @pytest.fixture -def cookiejar_and_cache(stubs): +def cookiejar_and_cache(stubs, monkeypatch): """Fixture providing a fake cookie jar and cache.""" - jar = QNetworkCookieJar() - ram_jar = cookies.RAMCookieJar() - cache = stubs.FakeNetworkCache() - objreg.register('cookie-jar', jar) - objreg.register('ram-cookie-jar', ram_jar) - objreg.register('cache', cache) - yield - objreg.delete('cookie-jar') - objreg.delete('ram-cookie-jar') - objreg.delete('cache') + monkeypatch.setattr(cookies, 'cookie_jar', QNetworkCookieJar()) + monkeypatch.setattr(cookies, 'ram_cookie_jar', cookies.RAMCookieJar()) + monkeypatch.setattr(cache, 'diskcache', stubs.FakeNetworkCache()) @pytest.fixture @@ -501,18 +484,18 @@ def fake_save_manager(): @pytest.fixture -def fake_args(request): +def fake_args(request, monkeypatch): ns = types.SimpleNamespace() ns.backend = 'webengine' if request.config.webengine else 'webkit' ns.debug_flags = [] - objreg.register('args', ns) - yield ns - objreg.delete('args') + + monkeypatch.setattr(objects, 'args', ns) + return ns @pytest.fixture def mode_manager(win_registry, config_stub, key_config_stub, qapp): - mm = modeman.init(0, parent=qapp) + mm = modeman.init(win_id=0, parent=qapp) yield mm objreg.delete('mode-manager', scope='window', window=0) diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index cabd606b6..bb72dbccb 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -34,7 +34,7 @@ from PyQt5.QtWidgets import QCommonStyle, QLineEdit, QWidget, QTabBar from qutebrowser.browser import browsertab, downloads from qutebrowser.utils import usertypes -from qutebrowser.mainwindow import mainwindow +from qutebrowser.commands import runners class FakeNetworkCache(QAbstractNetworkCache): @@ -547,13 +547,6 @@ class TabWidgetStub(QObject): return self.tabs[idx - 1] -class ApplicationStub(QObject): - - """Stub to insert as the app object in objreg.""" - - new_window = pyqtSignal(mainwindow.MainWindow) - - class HTTPPostStub(QObject): """A stub class for HTTPClient. @@ -639,3 +632,22 @@ class FakeHistoryProgress: def finish(self): self._finished = True + + +class FakeCommandRunner(runners.AbstractCommandRunner): + + def __init__(self, parent=None): + super().__init__(parent) + self.commands = [] + + def run(self, text, count=None, *, safely=False): + self.commands.append((text, count)) + + +class FakeHintManager: + + def __init__(self): + self.keystr = None + + def handle_partial_key(self, keystr): + self.keystr = keystr diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py index 2157eaeb1..5be464a2c 100644 --- a/tests/helpers/utils.py +++ b/tests/helpers/utils.py @@ -27,7 +27,7 @@ import contextlib import pytest -from qutebrowser.utils import qtutils +from qutebrowser.utils import qtutils, log qt58 = pytest.mark.skipif( @@ -180,3 +180,13 @@ def abs_datapath(): @contextlib.contextmanager def nop_contextmanager(): yield + + +@contextlib.contextmanager +def ignore_bs4_warning(): + """WORKAROUND for https://bugs.launchpad.net/beautifulsoup/+bug/1847592.""" + with log.ignore_py_warnings( + category=DeprecationWarning, + message="Using or importing the ABCs from 'collections' instead " + "of from 'collections.abc' is deprecated", module='bs4.element'): + yield diff --git a/tests/unit/browser/test_hints.py b/tests/unit/browser/test_hints.py index 8ce677a25..3398db76a 100644 --- a/tests/unit/browser/test_hints.py +++ b/tests/unit/browser/test_hints.py @@ -54,8 +54,7 @@ def test_show_benchmark(benchmark, tabbed_browser, qtbot, message_bridge, with qtbot.wait_signal(tab.load_finished): tab.load_url(QUrl('qute://testdata/data/hints/benchmark.html')) - manager = qutebrowser.browser.hints.HintManager( - win_id=0, tab_id=tab.tab_id) + manager = qutebrowser.browser.hints.HintManager(win_id=0) def bench(): with qtbot.wait_signal(mode_manager.entered): @@ -76,8 +75,7 @@ def test_match_benchmark(benchmark, tabbed_browser, qtbot, message_bridge, tab.load_url(QUrl('qute://testdata/data/hints/benchmark.html')) config_stub.val.hints.scatter = False - manager = qutebrowser.browser.hints.HintManager( - win_id=0, tab_id=tab.tab_id) + manager = qutebrowser.browser.hints.HintManager(win_id=0) with qtbot.wait_signal(mode_manager.entered): manager.start() @@ -106,7 +104,7 @@ def test_scattered_hints_count(min_len, num_chars, num_elements): 2. There can only be two hint lengths, only 1 apart 3. There are no unique prefixes for long hints, such as 'la' with no 'l' """ - manager = qutebrowser.browser.hints.HintManager(0, 0) + manager = qutebrowser.browser.hints.HintManager(win_id=0) chars = string.ascii_lowercase[:num_chars] hints = manager._hint_scattered(min_len, chars, diff --git a/tests/unit/browser/webkit/network/test_filescheme.py b/tests/unit/browser/webkit/network/test_filescheme.py index c477ead23..1372aa06c 100644 --- a/tests/unit/browser/webkit/network/test_filescheme.py +++ b/tests/unit/browser/webkit/network/test_filescheme.py @@ -28,6 +28,7 @@ from PyQt5.QtNetwork import QNetworkRequest from qutebrowser.browser.webkit.network import filescheme from qutebrowser.utils import urlutils, utils +from helpers import utils as testutils @pytest.mark.parametrize('create_file, create_dir, filterfunc, expected', [ @@ -129,7 +130,10 @@ class TestDirbrowserHtml: def parse(path): html = filescheme.dirbrowser_html(path).decode('utf-8') soup = bs4.BeautifulSoup(html, 'html.parser') - print(soup.prettify()) + + with testutils.ignore_bs4_warning(): + print(soup.prettify()) + container = soup('div', id='dirbrowserContainer')[0] parent_elem = container('ul', class_='parent') @@ -156,7 +160,10 @@ class TestDirbrowserHtml: def test_basic(self): html = filescheme.dirbrowser_html(os.getcwd()).decode('utf-8') soup = bs4.BeautifulSoup(html, 'html.parser') - print(soup.prettify()) + + with testutils.ignore_bs4_warning(): + print(soup.prettify()) + container = soup.div assert container['id'] == 'dirbrowserContainer' title_elem = container('div', id='dirbrowserTitle')[0] @@ -170,7 +177,9 @@ class TestDirbrowserHtml: html = filescheme.dirbrowser_html(os.getcwd()).decode('utf-8') soup = bs4.BeautifulSoup(html, 'html.parser') - print(soup.prettify()) + + with testutils.ignore_bs4_warning(): + print(soup.prettify()) css = soup.html.head.style.string assert "background-image: url('file:///test%20path/foo.svg');" in css @@ -239,7 +248,10 @@ class TestDirbrowserHtml: m.side_effect = OSError('Error message') html = filescheme.dirbrowser_html('').decode('utf-8') soup = bs4.BeautifulSoup(html, 'html.parser') - print(soup.prettify()) + + with testutils.ignore_bs4_warning(): + print(soup.prettify()) + error_msg = soup('p', id='error-message-text')[0].string assert error_msg == 'Error message' diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index 0ac91b8a8..1153b6976 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -37,7 +37,7 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry, mocker.patch( 'qutebrowser.completion.completiondelegate.CompletionItemDelegate', new=lambda *_: None) - view = completionwidget.CompletionView(win_id=0) + view = completionwidget.CompletionView(cmd=status_command_stub, win_id=0) qtbot.addWidget(view) return view diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 3d3ca0bf3..cdba937b2 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -677,7 +677,7 @@ def test_session_completion(qtmodeltester, session_manager_stub): }) -def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, +def test_tab_completion(qtmodeltester, fake_web_tab, win_registry, tabbed_browser_stubs): tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), @@ -703,8 +703,8 @@ def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, }) -def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub, - win_registry, tabbed_browser_stubs): +def test_tab_completion_delete(qtmodeltester, fake_web_tab, win_registry, + tabbed_browser_stubs): """Verify closing a tab by deleting it from the completion widget.""" tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), @@ -731,8 +731,8 @@ def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub, QUrl('https://duckduckgo.com')] -def test_tab_completion_not_sorted(qtmodeltester, fake_web_tab, app_stub, - win_registry, tabbed_browser_stubs): +def test_tab_completion_not_sorted(qtmodeltester, fake_web_tab, win_registry, + tabbed_browser_stubs): """Ensure that the completion row order is the same as tab index order. Would be violated for more than 9 tabs if the completion was being @@ -758,8 +758,8 @@ def test_tab_completion_not_sorted(qtmodeltester, fake_web_tab, app_stub, }) -def test_other_buffer_completion(qtmodeltester, fake_web_tab, app_stub, - win_registry, tabbed_browser_stubs, info): +def test_other_buffer_completion(qtmodeltester, fake_web_tab, win_registry, + tabbed_browser_stubs, info): tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), @@ -782,7 +782,7 @@ def test_other_buffer_completion(qtmodeltester, fake_web_tab, app_stub, }) -def test_other_buffer_completion_id0(qtmodeltester, fake_web_tab, app_stub, +def test_other_buffer_completion_id0(qtmodeltester, fake_web_tab, win_registry, tabbed_browser_stubs, info): tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), diff --git a/tests/unit/config/test_configexc.py b/tests/unit/config/test_configexc.py index f3f86a6dd..0335d0cee 100644 --- a/tests/unit/config/test_configexc.py +++ b/tests/unit/config/test_configexc.py @@ -126,3 +126,9 @@ Fake traceback """) # Make sure the traceback is not indented assert '
\nFake traceback\n' in html
+
+
+def test_config_file_errors_fatal():
+    err = configexc.ConfigErrorDesc("Text", Exception("Text"))
+    errors = configexc.ConfigFileErrors("state", [err], fatal=True)
+    assert errors.fatal
diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py
index a21d6787a..833e1e4fd 100644
--- a/tests/unit/config/test_configfiles.py
+++ b/tests/unit/config/test_configfiles.py
@@ -737,13 +737,27 @@ class TestConfigPy:
         expected = {'normal': {'H': 'message-info back'}}
         assert config.instance.get_obj('bindings.commands') == expected
 
-    def test_bind_none(self, confpy):
+    def test_bind_nop(self, confpy):
         confpy.write("c.bindings.commands = None",
                      "config.bind(',x', 'nop')")
         confpy.read()
         expected = {'normal': {',x': 'nop'}}
         assert config.instance.get_obj('bindings.commands') == expected
 
+    def test_bind_none(self, confpy):
+        confpy.write("config.bind('', None)")
+        with pytest.raises(configexc.ConfigFileErrors) as excinfo:
+            confpy.read()
+
+        expected = {'normal': {'': None}}
+        assert config.instance.get_obj('bindings.commands') == expected
+
+        msg = ("While unbinding '': Unbinding commands with "
+               "config.bind('', None) is deprecated. Use "
+               "config.unbind('') instead.")
+        assert len(excinfo.value.errors) == 1
+        assert str(excinfo.value.errors[0]) == msg
+
     @pytest.mark.parametrize('line, key, mode', [
         ('config.unbind("o")', 'o', 'normal'),
         ('config.unbind("y", mode="yesno")', 'y', 'yesno'),
diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py
index 93ca334ef..91264b758 100644
--- a/tests/unit/config/test_configinit.py
+++ b/tests/unit/config/test_configinit.py
@@ -101,7 +101,6 @@ class TestEarlyInit:
         assert actual_errors == expected_errors
 
         # Make sure things have been init'ed
-        objreg.get('config-commands')
         assert isinstance(config.instance, config.Config)
         assert isinstance(config.key_instance, config.KeyConfig)
 
@@ -205,6 +204,12 @@ class TestEarlyInit:
 
         assert dump == '\n'.join(expected)
 
+    def test_state_init_errors(self, init_patch, args, data_tmpdir):
+        state_file = data_tmpdir / 'state'
+        state_file.write_binary(b'\x00')
+        configinit.early_init(args)
+        assert configinit._init_errors.errors
+
     def test_invalid_change_filter(self, init_patch, args):
         config.change_filter('foobar')
         with pytest.raises(configexc.NoOptionError):
@@ -325,16 +330,23 @@ class TestEarlyInit:
         configinit._init_envvars()
 
 
-@pytest.mark.parametrize('errors', [True, False])
+@pytest.mark.parametrize('errors', [True, 'fatal', False])
 def test_late_init(init_patch, monkeypatch, fake_save_manager, args,
                    mocker, errors):
     configinit.early_init(args)
+
     if errors:
         err = configexc.ConfigErrorDesc("Error text", Exception("Exception"))
         errs = configexc.ConfigFileErrors("config.py", [err])
+        if errors == 'fatal':
+            errs.fatal = True
+
         monkeypatch.setattr(configinit, '_init_errors', errs)
+
     msgbox_mock = mocker.patch('qutebrowser.config.configinit.msgbox.msgbox',
                                autospec=True)
+    exit_mock = mocker.patch('qutebrowser.config.configinit.sys.exit',
+                             autospec=True)
 
     configinit.late_init(fake_save_manager)
 
@@ -342,12 +354,15 @@ def test_late_init(init_patch, monkeypatch, fake_save_manager, args,
         'state-config', unittest.mock.ANY)
     fake_save_manager.add_saveable.assert_any_call(
         'yaml-config', unittest.mock.ANY, unittest.mock.ANY)
+
     if errors:
         assert len(msgbox_mock.call_args_list) == 1
         _call_posargs, call_kwargs = msgbox_mock.call_args_list[0]
         text = call_kwargs['text'].strip()
         assert text.startswith('Errors occurred while reading config.py:')
         assert 'Error text: Exception' in text
+
+        assert exit_mock.called == (errors == 'fatal')
     else:
         assert not msgbox_mock.called
 
diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py
index 133da3090..23e730349 100644
--- a/tests/unit/keyinput/test_basekeyparser.py
+++ b/tests/unit/keyinput/test_basekeyparser.py
@@ -36,7 +36,7 @@ def keyseq(s):
 @pytest.fixture
 def keyparser(key_config_stub):
     """Fixture providing a BaseKeyParser supporting count/chains."""
-    kp = basekeyparser.BaseKeyParser(0, supports_count=True)
+    kp = basekeyparser.BaseKeyParser(win_id=0)
     kp.execute = mock.Mock()
     yield kp
 
@@ -79,7 +79,8 @@ class TestDebugLog:
 ])
 def test_split_count(config_stub, key_config_stub,
                      input_key, supports_count, count, command):
-    kp = basekeyparser.BaseKeyParser(0, supports_count=supports_count)
+    kp = basekeyparser.BaseKeyParser(win_id=0)
+    kp.supports_count = supports_count
     kp._read_config('normal')
 
     for info in keyseq(input_key):
diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py
index 436843960..1b5533822 100644
--- a/tests/unit/keyinput/test_modeparsers.py
+++ b/tests/unit/keyinput/test_modeparsers.py
@@ -19,8 +19,6 @@
 
 """Tests for mode parsers."""
 
-from unittest import mock
-
 from PyQt5.QtCore import Qt
 from PyQt5.QtGui import QKeySequence
 
@@ -29,6 +27,11 @@ import pytest
 from qutebrowser.keyinput import modeparsers, keyutils
 
 
+@pytest.fixture
+def commandrunner(stubs):
+    return stubs.FakeCommandRunner()
+
+
 class TestsNormalKeyParser:
 
     @pytest.fixture(autouse=True)
@@ -39,27 +42,27 @@ class TestsNormalKeyParser:
             stubs.FakeTimer)
 
     @pytest.fixture
-    def keyparser(self):
-        kp = modeparsers.NormalKeyParser(0)
-        kp.execute = mock.Mock()
+    def keyparser(self, commandrunner):
+        kp = modeparsers.NormalKeyParser(win_id=0, commandrunner=commandrunner)
         return kp
 
-    def test_keychain(self, keyparser, fake_keyevent):
+    def test_keychain(self, keyparser, fake_keyevent, commandrunner):
         """Test valid keychain."""
-        # Press 'x' which is ignored because of no match
-        keyparser.handle(fake_keyevent(Qt.Key_X))
+        # Press 'z' which is ignored because of no match
+        keyparser.handle(fake_keyevent(Qt.Key_Z))
         # Then start the real chain
         keyparser.handle(fake_keyevent(Qt.Key_B))
         keyparser.handle(fake_keyevent(Qt.Key_A))
-        keyparser.execute.assert_called_with('message-info ba', None)
+        assert commandrunner.commands == [('message-info ba', None)]
         assert not keyparser._sequence
 
     def test_partial_keychain_timeout(self, keyparser, config_stub,
-                                      fake_keyevent):
+                                      fake_keyevent, qtbot, commandrunner):
         """Test partial keychain timeout."""
         config_stub.val.input.partial_timeout = 100
         timer = keyparser._partial_timer
         assert not timer.isActive()
+
         # Press 'b' for a partial match.
         # Then we check if the timer has been set up correctly
         keyparser.handle(fake_keyevent(Qt.Key_B))
@@ -67,40 +70,57 @@ class TestsNormalKeyParser:
         assert timer.interval() == 100
         assert timer.isActive()
 
-        assert not keyparser.execute.called
+        assert not commandrunner.commands
         assert keyparser._sequence == keyutils.KeySequence.parse('b')
+
         # Now simulate a timeout and check the keystring has been cleared.
-        keystring_updated_mock = mock.Mock()
-        keyparser.keystring_updated.connect(keystring_updated_mock)
-        timer.timeout.emit()
-        assert not keyparser.execute.called
+        with qtbot.wait_signal(keyparser.keystring_updated) as blocker:
+            timer.timeout.emit()
+
+        assert not commandrunner.commands
         assert not keyparser._sequence
-        keystring_updated_mock.assert_called_once_with('')
+        assert blocker.args == ['']
 
 
 class TestHintKeyParser:
 
     @pytest.fixture
-    def keyparser(self, config_stub, key_config_stub):
-        kp = modeparsers.HintKeyParser(0)
-        kp.execute = mock.Mock()
-        kp.keystring_updated.disconnect()  # Don't try to update HintManager
-        return kp
+    def hintmanager(self, stubs):
+        return stubs.FakeHintManager()
 
-    def test_simple_hint_match(self, keyparser, fake_keyevent):
-        keyparser.update_bindings(['aa', 'as'])
+    @pytest.fixture
+    def keyparser(self, config_stub, key_config_stub, commandrunner,
+                  hintmanager):
+        return modeparsers.HintKeyParser(win_id=0,
+                                         hintmanager=hintmanager,
+                                         commandrunner=commandrunner)
 
-        match = keyparser.handle(fake_keyevent(Qt.Key_A))
+    @pytest.mark.parametrize('bindings, event1, event2, prefix, command', [
+        (
+            ['aa', 'as'],
+            [Qt.Key_A],
+            [Qt.Key_S],
+            'a',
+            'follow-hint -s as'
+        ),
+        (
+            ['21', '22'],
+            [Qt.Key_2, Qt.KeypadModifier],
+            [Qt.Key_2, Qt.KeypadModifier],
+            '2',
+            'follow-hint -s 22'
+        ),
+    ])
+    def test_match(self, keyparser, fake_keyevent, commandrunner, hintmanager,
+                   bindings, event1, event2, prefix, command):
+        keyparser.update_bindings(bindings)
+
+        match = keyparser.handle(fake_keyevent(*event1))
         assert match == QKeySequence.PartialMatch
-        match = keyparser.handle(fake_keyevent(Qt.Key_S))
+        assert hintmanager.keystr == prefix
+
+        match = keyparser.handle(fake_keyevent(*event2))
         assert match == QKeySequence.ExactMatch
+        assert not hintmanager.keystr
 
-        keyparser.execute.assert_called_with('follow-hint -s as', None)
-
-    def test_numberkey_hint_match(self, keyparser, fake_keyevent):
-        keyparser.update_bindings(['21', '22'])
-
-        match = keyparser.handle(fake_keyevent(Qt.Key_2, Qt.KeypadModifier))
-        assert match == QKeySequence.PartialMatch
-        match = keyparser.handle(fake_keyevent(Qt.Key_2, Qt.KeypadModifier))
-        assert match == QKeySequence.ExactMatch
+        assert commandrunner.commands == [(command, None)]
diff --git a/tests/unit/misc/test_sessions.py b/tests/unit/misc/test_sessions.py
index 8bb8155c6..611cfecbf 100644
--- a/tests/unit/misc/test_sessions.py
+++ b/tests/unit/misc/test_sessions.py
@@ -48,9 +48,13 @@ def sess_man(tmpdir):
 class TestInit:
 
     @pytest.fixture(autouse=True)
-    def cleanup(self):
+    def cleanup(self, monkeypatch):
+        monkeypatch.setattr(sessions, 'session_manager', None)
         yield
-        objreg.delete('session-manager')
+        try:
+            objreg.delete('session-manager')
+        except KeyError:
+            pass
 
     @pytest.mark.parametrize('create_dir', [True, False])
     def test_with_standarddir(self, tmpdir, monkeypatch, create_dir):
@@ -60,10 +64,9 @@ class TestInit:
             session_dir.ensure(dir=True)
 
         sessions.init()
-        manager = objreg.get('session-manager')
 
         assert session_dir.exists()
-        assert manager._base_path == str(session_dir)
+        assert sessions.session_manager._base_path == str(session_dir)
 
 
 def test_did_not_load(sess_man):
diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py
index 89dcc4bf8..ad5a8f9ad 100644
--- a/tests/unit/utils/test_version.py
+++ b/tests/unit/utils/test_version.py
@@ -25,7 +25,7 @@ import collections
 import os.path
 import subprocess
 import contextlib
-import builtins
+import builtins  # noqa https://github.com/JBKahn/flake8-debugger/issues/20
 import types
 import importlib
 import logging
diff --git a/tests/unit/utils/usertypes/test_question.py b/tests/unit/utils/usertypes/test_question.py
index 13eb13e60..3a2000cba 100644
--- a/tests/unit/utils/usertypes/test_question.py
+++ b/tests/unit/utils/usertypes/test_question.py
@@ -41,12 +41,6 @@ def test_mode(question):
     assert question.mode == usertypes.PromptMode.yesno
 
 
-def test_mode_invalid(question):
-    """Test setting mode to something which is not a PromptMode member."""
-    with pytest.raises(TypeError):
-        question.mode = 42
-
-
 @pytest.mark.parametrize('mode, answer, signal_names', [
     (usertypes.PromptMode.text, 'foo', ['answered', 'completed']),
     (usertypes.PromptMode.yesno, True, ['answered', 'answered_yes',