diff --git a/.coveragerc b/.coveragerc index 9ba8e8a5e..69b126e12 100644 --- a/.coveragerc +++ b/.coveragerc @@ -14,6 +14,7 @@ exclude_lines = raise NotImplementedError raise utils\.Unreachable if __name__ == ["']__main__["']: + if MYPY: [xml] output=coverage.xml diff --git a/.flake8 b/.flake8 index 8c03ef729..7a783a4b0 100644 --- a/.flake8 +++ b/.flake8 @@ -46,6 +46,7 @@ ignore = min-version = 3.4.0 max-complexity = 12 per-file-ignores = + /qutebrowser/api/hook.py : N801 /tests/**/*.py : D100,D101,D401 /tests/unit/browser/test_history.py : N806 /tests/helpers/fixtures.py : N806 diff --git a/.github/img/hsr.png b/.github/img/hsr.png new file mode 100644 index 000000000..9d1312f6d Binary files /dev/null and b/.github/img/hsr.png differ diff --git a/.gitignore b/.gitignore index 9efceef63..ceafd9946 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ TODO /scripts/testbrowser/cpp/webengine/.qmake.stash /scripts/dev/pylint_checkers/qute_pylint.egg-info /misc/file_version_info.txt +/doc/extapi/_build diff --git a/.travis.yml b/.travis.yml index bb11e0c3c..663c4c6f9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,37 +1,26 @@ -sudo: false -dist: trusty +dist: xenial language: python group: edge python: 3.6 +os: linux matrix: include: - - os: linux - env: DOCKER=archlinux + - env: DOCKER=archlinux services: docker - - os: linux - env: DOCKER=archlinux-webengine QUTE_BDD_WEBENGINE=true + - env: DOCKER=archlinux-webengine QUTE_BDD_WEBENGINE=true services: docker - - os: linux - env: TESTENV=py36-pyqt571 - - os: linux - python: 3.5 + - env: TESTENV=py36-pyqt571 + - python: 3.5 env: TESTENV=py35-pyqt571 - - os: linux - env: TESTENV=py36-pyqt59 - - os: linux - env: TESTENV=py36-pyqt510 + - env: TESTENV=py36-pyqt59 + - env: TESTENV=py36-pyqt510 addons: apt: packages: - xfonts-base - - os: linux - env: TESTENV=py36-pyqt511-cov - # https://github.com/travis-ci/travis-ci/issues/9069 - - os: linux - python: 3.7 - sudo: required - dist: xenial + - env: TESTENV=py36-pyqt511-cov + - python: 3.7 env: TESTENV=py37-pyqt511 - os: osx env: TESTENV=py37 OSX=sierra @@ -41,38 +30,26 @@ matrix: # - os: osx # env: TESTENV=py35 OSX=yosemite # osx_image: xcode6.4 - - os: linux - env: TESTENV=pylint PYTHON=python3.6 - - os: linux - env: TESTENV=flake8 - - os: linux - env: TESTENV=docs + - env: TESTENV=pylint + - env: TESTENV=flake8 + - env: TESTENV=mypy + - env: TESTENV=docs addons: apt: packages: - asciidoc - - os: linux - env: TESTENV=vulture - - os: linux - env: TESTENV=misc - - os: linux - env: TESTENV=pyroma - - os: linux - env: TESTENV=check-manifest - - os: linux - env: TESTENV=eslint + - env: TESTENV=vulture + - env: TESTENV=misc + - env: TESTENV=pyroma + - env: TESTENV=check-manifest + - env: TESTENV=eslint language: node_js python: null node_js: "lts/*" - - os: linux - language: generic + - language: generic env: TESTENV=shellcheck services: docker fast_finish: true - allow_failures: - # https://github.com/qutebrowser/qutebrowser/issues/4055 - - os: linux - env: TESTENV=py36-pyqt510 cache: directories: diff --git a/MANIFEST.in b/MANIFEST.in index ff96264aa..cd9e50cf9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -32,6 +32,7 @@ include doc/changelog.asciidoc prune tests prune qutebrowser/3rdparty exclude pytest.ini +exclude mypy.ini exclude qutebrowser/javascript/.eslintrc.yaml exclude qutebrowser/javascript/.eslintignore exclude doc/help @@ -39,5 +40,6 @@ exclude .* exclude misc/qutebrowser.spec exclude misc/qutebrowser.nsi exclude misc/qutebrowser.rcc +prune doc/extapi global-exclude __pycache__ *.pyc *.pyo diff --git a/README.asciidoc b/README.asciidoc index d6ed98a56..b2d882eae 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -154,7 +154,11 @@ https://www.macstadium.com/opensource[Open Source Project]. (They don't require including this here - I've just been very happy with their offer, and without them, no macOS releases or tests would exist) +Thanks to the https://www.hsr.ch/[HSR Hochschule für Technik Rapperswil], which +made it possible to work on qutebrowser extensions as a student research project. + image:.github/img/macstadium.png["powered by MacStadium",width=200,link="https://www.macstadium.com/"] +image:.github/img/hsr.png["HSR Hochschule für Technik Rapperswil",link="https://www.hsr.ch/"] Authors ------- diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 22c5fd333..514261953 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -51,6 +51,8 @@ Changed adblocker can be disabled on a given page. - Elements with a `tabindex` attribute now also get hints by default. - Various small performance improvements for hints and the completion. +- The Wayland check for QtWebEngine is now disabled on Qt >= 5.11.2, as those + versions should work without any issues. Fixed ~~~~~ @@ -66,6 +68,8 @@ Fixed like GMail. However, the default for `content.cookies.accept` is still `all` to be in line with what other browsers do. - `:navigate` not incrementing in anchors or queries or anchors. +- Crash when trying to use a proxy requiring authentication with QtWebKit. +- Slashes in search terms are now percent-escaped. v1.5.2 ------ @@ -1244,7 +1248,7 @@ Added - New `:debug-log-filter` command to change console log filtering on-the-fly. - New `:debug-log-level` command to change the console loglevel on-the-fly. - New `general -> yank-ignored-url-parameters` option to configure which URL - parameters (like `utm_source` etc.) to strip off when yanking an URL. + parameters (like `utm_source` etc.) to strip off when yanking a URL. - Support for the https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API[HTML5 page visibility API] - New `readability` userscript which shows a readable version of a page (using @@ -1355,7 +1359,7 @@ Changed - `:hint` has a new `--add-history` argument to add the URL to the history for yank/spawn targets. - `:set` now cycles through values if more than one argument is given. -- `:open` now opens `default-page` without an URL even without `-t`/`-b`/`-w` given. +- `:open` now opens `default-page` without a URL even without `-t`/`-b`/`-w` given. Deprecated ~~~~~~~~~~ diff --git a/doc/contributing.asciidoc b/doc/contributing.asciidoc index 1a2369516..dc52dd9a0 100644 --- a/doc/contributing.asciidoc +++ b/doc/contributing.asciidoc @@ -407,7 +407,7 @@ Creating a new command is straightforward: [source,python] ---- -import qutebrowser.commands.cmdutils +from qutebrowser.api import cmdutils ... @@ -429,7 +429,7 @@ selects which object registry (global, per-tab, etc.) to use. See the There are also other arguments to customize the way the command is registered; see the class documentation for `register` in -`qutebrowser.commands.cmdutils` for details. +`qutebrowser.api.cmdutils` for details. The types of the function arguments are inferred based on their default values, e.g., an argument `foo=True` will be converted to a flag `-f`/`--foo` in @@ -480,8 +480,10 @@ For `typing.Union` types, the given `choices` are only checked if other types The following arguments are supported for `@cmdutils.argument`: - `flag`: Customize the short flag (`-x`) the argument will get. -- `win_id=True`: Mark the argument as special window ID argument. -- `count=True`: Mark the argument as special count argument. +- `value`: Tell qutebrowser to fill the argument with special values: + - `value=cmdutils.Value.count`: The `count` given by the user to the command. + - `value=cmdutils.Value.win_id`: The window ID of the current window. + - `value=cmdutils.Value.cur_tab`: The tab object which is currently focused. - `completion`: A completion function (see `qutebrowser.completions.models.*`) to use when completing arguments for the given command. - `choices`: The allowed string choices for the argument. diff --git a/doc/extapi/_static/.gitkeep b/doc/extapi/_static/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/doc/extapi/_templates/.gitkeep b/doc/extapi/_templates/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/doc/extapi/api.rst b/doc/extapi/api.rst new file mode 100644 index 000000000..b63db57c3 --- /dev/null +++ b/doc/extapi/api.rst @@ -0,0 +1,48 @@ +API modules +=========== + +cmdutils module +--------------- + +.. automodule:: qutebrowser.api.cmdutils + :members: + :imported-members: + +apitypes module +--------------- + +.. automodule:: qutebrowser.api.apitypes + :members: + :imported-members: + +config module +------------- + +.. automodule:: qutebrowser.api.config + :members: + +downloads module +---------------- + +.. automodule:: qutebrowser.api.downloads + :members: + +hook module +----------- + +.. automodule:: qutebrowser.api.hook + :members: + +interceptor module +------------------ + +.. automodule:: qutebrowser.api.interceptor + :members: + :imported-members: + +message module +-------------- + +.. automodule:: qutebrowser.api.message + :members: + :imported-members: diff --git a/doc/extapi/conf.py b/doc/extapi/conf.py new file mode 100644 index 000000000..4cc5c6803 --- /dev/null +++ b/doc/extapi/conf.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'qutebrowser extensions' +copyright = '2018, Florian Bruhin' +author = 'Florian Bruhin' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = '' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.napoleon', +] +autodoc_member_order = 'bysource' + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'qutebrowserextensionsdoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'qutebrowserextensions.tex', 'qutebrowser extensions Documentation', + 'Florian Bruhin', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'qutebrowserextensions', 'qutebrowser extensions Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'qutebrowserextensions', 'qutebrowser extensions Documentation', + author, 'qutebrowserextensions', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + + +# -- Extension configuration ------------------------------------------------- diff --git a/doc/extapi/index.rst b/doc/extapi/index.rst new file mode 100644 index 000000000..d181c2ccd --- /dev/null +++ b/doc/extapi/index.rst @@ -0,0 +1,22 @@ +.. qutebrowser extensions documentation master file, created by + sphinx-quickstart on Tue Dec 11 18:59:44 2018. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to qutebrowser extensions's documentation! +================================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + api + tab + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/doc/extapi/tab.rst b/doc/extapi/tab.rst new file mode 100644 index 000000000..57a14ac6e --- /dev/null +++ b/doc/extapi/tab.rst @@ -0,0 +1,44 @@ +Tab API +======= + +.. autoclass:: qutebrowser.browser.browsertab.AbstractTab() + :members: + +.. autoclass:: qutebrowser.browser.browsertab.AbstractAction() + :members: + +.. autoclass:: qutebrowser.browser.browsertab.AbstractPrinting() + :members: + +.. autoclass:: qutebrowser.browser.browsertab.AbstractSearch() + :members: + +.. autoclass:: qutebrowser.browser.browsertab.AbstractZoom() + :members: + +.. autoclass:: qutebrowser.browser.browsertab.AbstractCaret() + :members: + +.. autoclass:: qutebrowser.browser.browsertab.AbstractScroller() + :members: + +.. autoclass:: qutebrowser.browser.browsertab.AbstractHistory() + :members: + +.. autoclass:: qutebrowser.browser.browsertab.AbstractElements() + :members: + +.. autoclass:: qutebrowser.browser.browsertab.AbstractAudio() + :members: + +Web element API +=============== + +.. autoclass:: qutebrowser.browser.webelem.AbstractWebElement + :members: + +.. autoclass:: qutebrowser.browser.webelem.Error + :members: + +.. autoclass:: qutebrowser.browser.webelem.OrphanedError + :members: diff --git a/doc/faq.asciidoc b/doc/faq.asciidoc index c4176a438..113a11f09 100644 --- a/doc/faq.asciidoc +++ b/doc/faq.asciidoc @@ -215,11 +215,11 @@ What's the difference between insert and passthrough mode?:: be useful to rebind escape to something else in passthrough mode only, to be able to send an escape keypress to the website. -Why takes it longer to open an URL in qutebrowser than in chromium?:: - When opening an URL in an existing instance the normal qutebrowser +Why does it take longer to open a URL in qutebrowser than in chromium?:: + When opening a URL in an existing instance, the normal qutebrowser Python script is started and a few PyQt libraries need to be loaded until it is detected that there is an instance running - where the URL is then passed to. This takes some time. + to which the URL is then passed. This takes some time. One workaround is to use this https://github.com/qutebrowser/qutebrowser/blob/master/scripts/open_url_in_instance.sh[script] and place it in your $PATH with the name "qutebrowser". This @@ -260,6 +260,12 @@ Note that there are some missing features which you may run into: . Any greasemonkey API function to do with adding UI elements is not currently supported. That means context menu extentensions and background pages. +How do I change the `WM_CLASS` used by qutebrowser windows?:: + Qt only supports setting `WM_CLASS` globally, which you can do by starting + with `--qt-arg name foo`. Note that all windows are part of the same + qutebrowser instance (unless you use `--temp-basedir` or `--basedir`), so + they all will share the same `WM_CLASS`. + == Troubleshooting Unable to view flash content.:: diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 13cf0a790..2d71a28c1 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -1484,14 +1484,14 @@ Yank something to the clipboard or primary selection. [[zoom]] === zoom -Syntax: +:zoom [*--quiet*] ['zoom']+ +Syntax: +:zoom [*--quiet*] ['level']+ Set the zoom level for the current tab. The zoom can be given as argument or as [count]. If neither is given, the zoom is set to the default zoom. If both are given, use [count]. ==== positional arguments -* +'zoom'+: The zoom percentage to set. +* +'level'+: The zoom percentage to set. ==== optional arguments * +*-q*+, +*--quiet*+: Don't show a zoom level message. diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 57e5528c0..33711b755 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -19,10 +19,10 @@ hand, you can simply use those - see <> for details. For more advanced configuration, you can write a `config.py` file - see -<>. As soon as a `config.py` +<>. When a `config.py` exists, the `autoconfig.yml` file **is not read anymore** by default. You need -to <> if you want settings done via -`:set`/`:bind` to still persist. +to <> if you want settings changed via +`:set`/`:bind` to persist between restarts. [[autoconfig]] Configuring qutebrowser via the user interface @@ -229,18 +229,18 @@ Loading `autoconfig.yml` ~~~~~~~~~~~~~~~~~~~~~~~~ All customization done via the UI (`:set`, `:bind` and `:unbind`) is -stored in the `autoconfig.yml` file, which is not loaded automatically as soon -as a `config.py` exists. If you want those settings to be loaded, you'll need to -explicitly load the `autoconfig.yml` file in your `config.py` by doing: +stored in the `autoconfig.yml` file. When a `config.py` file exists, `autoconfig.yml` +is not loaded automatically. To load `autoconfig.yml` automatically, add the +following snippet to `config.py`: -.config.py: [source,python] ---- config.load_autoconfig() ---- -If you do so at the top of your file, your `config.py` settings will take -precedence as they overwrite the settings done in `autoconfig.yml`. +You can configure which file overrides the other by the location of the above code snippet. +Place the snippet at the top to allow `config.py` to override `autoconfig.yml`. +Place the snippet at the bottom for the opposite effect. Importing other modules ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index b7cb0349b..7da6e543e 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -2960,7 +2960,7 @@ Default: +pass:[false]+ === search.ignore_case When to find text on a page case-insensitively. -Type: <> +Type: <> Valid values: @@ -3624,6 +3624,7 @@ Lists with duplicate flags are invalid. Each item is checked against the valid v |FontFamily|A Qt font family. |FormatString|A string with placeholders. |FuzzyUrl|A URL which gets interpreted as search if needed. +|IgnoreCase|Whether to search case insensitively. |Int|Base class for an integer setting. |Key|A name of a key. |List|A list of values. diff --git a/misc/qutebrowser.desktop b/misc/qutebrowser.desktop index 5243b0c17..976803915 100644 --- a/misc/qutebrowser.desktop +++ b/misc/qutebrowser.desktop @@ -1,7 +1,45 @@ [Desktop Entry] Name=qutebrowser GenericName=Web Browser +GenericName[ar]=ﻢﺘﺼﻔﺣ ﺎﻠﺸﺒﻛﺓ +GenericName[bg]=Уеб браузър +GenericName[ca]=Navegador web +GenericName[cs]=WWW prohlížeč +GenericName[da]=Browser +GenericName[de]=Web-Browser +GenericName[el]=Περιηγητής ιστού +GenericName[en_GB]=Web Browser +GenericName[es]=Navegador web +GenericName[et]=Veebibrauser +GenericName[fi]=WWW-selain +GenericName[fr]=Navigateur Web +GenericName[gu]=વેબ બ્રાઉઝર +GenericName[he]=דפדפן אינטרנט +GenericName[hi]=वेब ब्राउज़र +GenericName[hu]=Webböngésző +GenericName[it]=Browser Web +GenericName[ja]=ウェブブラウザ +GenericName[kn]=ಜಾಲ ವೀಕ್ಷಕ +GenericName[ko]=웹 브라우저 +GenericName[lt]=Žiniatinklio naršyklė +GenericName[lv]=Tīmekļa pārlūks +GenericName[ml]=വെബ് ബ്രൌസര്<200d> +GenericName[mr]=वेब ब्राऊजर +GenericName[nb]=Nettleser +GenericName[nl]=Webbrowser +GenericName[pl]=Przeglądarka WWW +GenericName[pt]=Navegador Web +GenericName[pt_BR]=Navegador da Internet +GenericName[ro]=Navigator de Internet +GenericName[ru]=Веб-браузер +GenericName[sl]=Spletni brskalnik +GenericName[sv]=Webbläsare +GenericName[ta]=இணைய உலாவி +GenericName[th]=เว็บเบราว์เซอร์ +GenericName[tr]=Web Tarayıcı +GenericName[uk]=Навігатор Тенет瀏覽器 Comment=A keyboard-driven, vim-like browser based on PyQt5 +Comment[it]= Un browser web vim-like utilizzabile da tastiera basato su PyQt5 Icon=qutebrowser Type=Application Categories=Network;WebBrowser; @@ -10,3 +48,128 @@ Terminal=false StartupNotify=false MimeType=text/html;text/xml;application/xhtml+xml;application/xml;application/rdf+xml;image/gif;image/jpeg;image/png;x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/qute; Keywords=Browser + +[Desktop Action new-window] +Name=New Window +Name[am]=አዲስ መስኮት +Name[ar]=ﻥﺎﻓﺫﺓ ﺝﺪﻳﺩﺓ +Name[bg]=Нов прозорец +Name[bn]=নতুন উইন্ডো +Name[ca]=Finestra nova +Name[cs]=Nové okno +Name[da]=Nyt vindue +Name[de]=Neues Fenster +Name[el]=Νέο Παράθυρο +Name[en_GB]=New Window +Name[es]=Nueva ventana +Name[et]=Uus aken +Name[fa]=پﻦﺟﺮﻫ ﺝﺩیﺩ +Name[fi]=Uusi ikkuna +Name[fil]=New Window +Name[fr]=Nouvelle fenêtre +Name[gu]=નવી વિંડો +Name[hi]=नई विंडो +Name[hr]=Novi prozor +Name[hu]=Új ablak +Name[id]=Jendela Baru +Name[it]=Nuova finestra +Name[iw]=חלון חדש +Name[ja]=新規ウインドウ +Name[kn]=ಹೊಸ ವಿಂಡೊ +Name[ko]=새 창 +Name[lt]=Naujas langas +Name[lv]=Jauns logs +Name[ml]=പുതിയ വിന്<200d>ഡോ +Name[mr]=नवीन विंडो +Name[nl]=Nieuw venster +Name[no]=Nytt vindu +Name[pl]=Nowe okno +Name[pt]=Nova janela +Name[pt_BR]=Nova janela +Name[ro]=Fereastră nouă +Name[ru]=Новое окно +Name[sk]=Nové okno +Name[sl]=Novo okno +Name[sr]=Нови прозор +Name[sv]=Nytt fönster +Name[sw]=Dirisha Jipya +Name[ta]=புதிய சாளரம் +Name[te]=క్రొత్త విండో +Name[th]=หน้าต่างใหม่ +Name[tr]=Yeni Pencere +Name[uk]=Нове вікно +Name[vi]=Cửa sổ Mới +Exec=qutebrowser + +[Desktop Action preferences] +Name=Preferences +Name[an]=Preferencias +Name[ar]=ﺎﻠﺘﻔﻀﻳﻼﺗ +Name[as]=পছন্দসমূহ +Name[be]=Настройкі +Name[bg]=Настройки +Name[bn_IN]=পছন্দ +Name[bs]=Postavke +Name[ca]=Preferències +Name[ca@valencia]=Preferències +Name[cs]=Předvolby +Name[da]=Indstillinger +Name[de]=Einstellungen +Name[el]=Προτιμήσεις +Name[en_GB]=Preferences +Name[eo]=Agordoj +Name[es]=Preferencias +Name[et]=Eelistused +Name[eu]=Hobespenak +Name[fa]=ﺕﺮﺟیﺡﺎﺗ +Name[fi]=Asetukset +Name[fr]=Préférences +Name[fur]=Preferencis +Name[ga]=Sainroghanna +Name[gd]=Roghainnean +Name[gl]=Preferencias +Name[gu]=પસંદગીઓ +Name[he]=העדפות +Name[hi]=वरीयताएँ +Name[hr]=Osobitosti +Name[hu]=Beállítások +Name[id]=Preferensi +Name[is]=Kjörstillingar +Name[it]=Preferenze +Name[ja]=設定 +Name[kk]=Баптаулар +Name[km]=ចំណូលចិត្ត +Name[kn]=ಆದ್ಯತೆಗಳು +Name[ko]=기본 설정 +Name[lt]=Nuostatos +Name[lv]=Iestatījumi +Name[ml]=മുന്<200d>ഗണനകള്<200d> +Name[mr]=पसंती +Name[nb]=Brukervalg +Name[ne]=प्राथमिकताहरू +Name[nl]=Voorkeuren +Name[oc]=Preferéncias +Name[or]=ପସନ୍ଦ +Name[pa]=ਮੇਰੀ ਪਸੰਦ +Name[pl]=Preferencje +Name[pt]=Preferências +Name[pt_BR]=Preferências +Name[ro]=Preferințe +Name[ru]=Параметры +Name[sk]=Nastavenia +Name[sl]=Možnosti +Name[sr]=Поставке +Name[sr@latin]=Postavke +Name[sv]=Inställningar +Name[ta]=விருப்பங்கள் +Name[te]=అభీష్టాలు +Name[tg]=Хусусиятҳо +Name[th]=ปรับแต่ง +Name[tr]=Tercihler +Name[ug]=ﻡﺎﻳﻰﻠﻟﻰﻗ +Name[uk]=Параметри +Name[vi]=Tùy thích +Name[zh_CN]=首选项 +Name[zh_HK]=偏好設定 +Name[zh_TW]=偏好設定 +Exec=qutebrowser "qute://settings" diff --git a/misc/qutebrowser.spec b/misc/qutebrowser.spec index ff1b10577..269668751 100644 --- a/misc/qutebrowser.spec +++ b/misc/qutebrowser.spec @@ -6,6 +6,8 @@ import os sys.path.insert(0, os.getcwd()) from scripts import setupcommon +from qutebrowser.extensions import loader + block_cipher = None @@ -27,6 +29,13 @@ def get_data_files(): return data_files +def get_hidden_imports(): + imports = ['PyQt5.QtOpenGL', 'PyQt5._QOpenGLFunctions_2_0'] + for info in loader.walk_components(): + imports.append(info.name) + return imports + + setupcommon.write_git_file() @@ -42,7 +51,7 @@ a = Analysis(['../qutebrowser/__main__.py'], pathex=['misc'], binaries=None, datas=get_data_files(), - hiddenimports=['PyQt5.QtOpenGL', 'PyQt5._QOpenGLFunctions_2_0'], + hiddenimports=get_hidden_imports(), hookspath=[], runtime_hooks=[], excludes=['tkinter'], diff --git a/misc/requirements/requirements-codecov.txt b/misc/requirements/requirements-codecov.txt index 2ab6e78f0..19ae758af 100644 --- a/misc/requirements/requirements-codecov.txt +++ b/misc/requirements/requirements-codecov.txt @@ -1,9 +1,9 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -certifi==2018.10.15 +certifi==2018.11.29 chardet==3.0.4 codecov==2.0.15 coverage==4.5.2 -idna==2.7 -requests==2.20.1 +idna==2.8 +requests==2.21.0 urllib3==1.24.1 diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt index cd1fcba4d..20a66cd5f 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -22,6 +22,6 @@ pep8-naming==0.7.0 pycodestyle==2.4.0 pydocstyle==3.0.0 pyflakes==2.0.0 -six==1.11.0 +six==1.12.0 snowballstemmer==1.2.1 typing==3.6.6 diff --git a/misc/requirements/requirements-mypy.txt b/misc/requirements/requirements-mypy.txt new file mode 100644 index 000000000..6b8c63e97 --- /dev/null +++ b/misc/requirements/requirements-mypy.txt @@ -0,0 +1,8 @@ +# This file is automatically generated by scripts/dev/recompile_requirements.py + +mypy==0.650 +mypy-extensions==0.4.1 +PyQt5==5.11.3 +PyQt5-sip==4.19.13 +-e git+https://github.com/qutebrowser/PyQt5-stubs.git@wip#egg=PyQt5_stubs +typed-ast==1.1.1 diff --git a/misc/requirements/requirements-mypy.txt-raw b/misc/requirements/requirements-mypy.txt-raw new file mode 100644 index 000000000..636ad43a4 --- /dev/null +++ b/misc/requirements/requirements-mypy.txt-raw @@ -0,0 +1,5 @@ +mypy +-e git+https://github.com/qutebrowser/PyQt5-stubs.git@wip#egg=PyQt5-stubs + +# remove @commit-id for scm installs +#@ replace: @.*# @wip# diff --git a/misc/requirements/requirements-optional.txt b/misc/requirements/requirements-optional.txt new file mode 100644 index 000000000..aafa38e46 --- /dev/null +++ b/misc/requirements/requirements-optional.txt @@ -0,0 +1,7 @@ +# This file is automatically generated by scripts/dev/recompile_requirements.py + +colorama==0.4.1 +cssutils==1.0.2 +hunter==2.1.0 +Pympler==0.6 +six==1.12.0 diff --git a/misc/requirements/requirements-optional.txt-raw b/misc/requirements/requirements-optional.txt-raw new file mode 100644 index 000000000..a0be23733 --- /dev/null +++ b/misc/requirements/requirements-optional.txt-raw @@ -0,0 +1,3 @@ +hunter +cssutils +pympler diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt index a3068c5f9..f15a3a3e1 100644 --- a/misc/requirements/requirements-pip.txt +++ b/misc/requirements/requirements-pip.txt @@ -3,6 +3,6 @@ appdirs==1.4.3 packaging==18.0 pyparsing==2.3.0 -setuptools==40.5.0 -six==1.11.0 -wheel==0.32.2 +setuptools==40.6.3 +six==1.12.0 +wheel==0.32.3 diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt index 30b038ef8..b3ecdaf70 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -1,23 +1,23 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py asn1crypto==0.24.0 -astroid==2.0.4 -certifi==2018.10.15 +astroid==2.1.0 +certifi==2018.11.29 cffi==1.11.5 chardet==3.0.4 -cryptography==2.4.1 +cryptography==2.4.2 github3.py==1.2.0 -idna==2.7 +idna==2.8 isort==4.3.4 jwcrypto==0.6.0 lazy-object-proxy==1.3.1 mccabe==0.6.1 pycparser==2.19 -pylint==2.1.1 +pylint==2.2.2 python-dateutil==2.7.5 ./scripts/dev/pylint_checkers -requests==2.20.1 -six==1.11.0 +requests==2.21.0 +six==1.12.0 uritemplate==3.0.0 urllib3==1.24.1 wrapt==1.10.11 diff --git a/misc/requirements/requirements-sphinx.txt b/misc/requirements/requirements-sphinx.txt new file mode 100644 index 000000000..6f106798b --- /dev/null +++ b/misc/requirements/requirements-sphinx.txt @@ -0,0 +1,21 @@ +# This file is automatically generated by scripts/dev/recompile_requirements.py + +alabaster==0.7.12 +Babel==2.6.0 +certifi==2018.11.29 +chardet==3.0.4 +docutils==0.14 +idna==2.8 +imagesize==1.1.0 +Jinja2==2.10 +MarkupSafe==1.1.0 +packaging==18.0 +Pygments==2.3.1 +pyparsing==2.3.0 +pytz==2018.7 +requests==2.21.0 +six==1.12.0 +snowballstemmer==1.2.1 +Sphinx==1.8.2 +sphinxcontrib-websupport==1.1.0 +urllib3==1.24.1 diff --git a/misc/requirements/requirements-sphinx.txt-raw b/misc/requirements/requirements-sphinx.txt-raw new file mode 100644 index 000000000..6966869c7 --- /dev/null +++ b/misc/requirements/requirements-sphinx.txt-raw @@ -0,0 +1 @@ +sphinx diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index c17f0d6b1..28c26d891 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -5,38 +5,37 @@ attrs==18.2.0 backports.functools-lru-cache==1.5 beautifulsoup4==4.6.3 cheroot==6.5.2 -click==7.0 -# colorama==0.3.9 +Click==7.0 +# colorama==0.4.1 coverage==4.5.2 -EasyProcess==0.2.3 -fields==5.0.0 +EasyProcess==0.2.5 Flask==1.0.2 glob2==0.6 -hunter==2.0.2 -hypothesis==3.82.1 +hunter==2.1.0 +hypothesis==3.84.5 itsdangerous==1.1.0 # Jinja2==2.10 Mako==1.0.7 -# MarkupSafe==1.0 +# MarkupSafe==1.1.0 more-itertools==4.3.0 parse==1.9.0 parse-type==0.4.2 pluggy==0.8.0 py==1.7.0 py-cpuinfo==4.0.0 -pytest==3.10.1 -pytest-bdd==3.0.0 +pytest==4.0.2 +pytest-bdd==3.0.1 pytest-benchmark==3.1.1 pytest-cov==2.6.0 pytest-faulthandler==1.5.0 pytest-instafail==0.4.0 pytest-mock==1.10.0 -pytest-qt==3.2.1 +pytest-qt==3.2.2 pytest-repeat==0.7.0 pytest-rerunfailures==5.0 pytest-travis-fold==1.3.0 pytest-xvfb==1.1.0 PyVirtualDisplay==0.2.1 -six==1.11.0 +six==1.12.0 vulture==1.0 Werkzeug==0.14.1 diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt index 9cda12622..ed0db2870 100644 --- a/misc/requirements/requirements-tox.txt +++ b/misc/requirements/requirements-tox.txt @@ -1,8 +1,9 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py +filelock==3.0.10 pluggy==0.8.0 py==1.7.0 -six==1.11.0 +six==1.12.0 toml==0.10.0 -tox==3.5.3 +tox==3.6.1 virtualenv==16.1.0 diff --git a/misc/userscripts/README.md b/misc/userscripts/README.md index 5680267b7..ceddb6c81 100644 --- a/misc/userscripts/README.md +++ b/misc/userscripts/README.md @@ -53,9 +53,10 @@ The following userscripts can be found on their own repositories. - [qtb.us](https://github.com/Chinggis6/qtb.us): small pack of userscripts. - [pinboard.zsh](https://github.com/dmix/pinboard.zsh): Add URL to your [Pinboard][] bookmark manager. +- [qute-capture](https://github.com/alcah/qute-capture): Capture links with + Emacs's org-mode to a read-later file. [Zotero]: https://www.zotero.org/ [Pocket]: https://getpocket.com/ [Instapaper]: https://www.instapaper.com/ [Pinboard]: https://pinboard.in/ - diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 000000000..d8c7221ad --- /dev/null +++ b/mypy.ini @@ -0,0 +1,87 @@ +[mypy] +# We also need to support 3.5, but if we'd chose that here, we'd need to deal +# with conditional imports (like secrets.py). +python_version = 3.6 + +# --strict +warn_redundant_casts = True +warn_unused_ignores = True +disallow_subclassing_any = True +disallow_untyped_decorators = True +## https://github.com/python/mypy/issues/5957 +# warn_unused_configs = True +# disallow_untyped_calls = True +# disallow_untyped_defs = True +## https://github.com/python/mypy/issues/5954 +# disallow_incomplete_defs = True +# check_untyped_defs = True +# no_implicit_optional = True +# warn_return_any = True + +[mypy-colorama] +# https://github.com/tartley/colorama/issues/206 +ignore_missing_imports = True + +[mypy-hunter] +# https://github.com/ionelmc/python-hunter/issues/43 +ignore_missing_imports = True + +[mypy-pygments.*] +# https://bitbucket.org/birkenfeld/pygments-main/issues/1485/type-hints +ignore_missing_imports = True + +[mypy-cssutils] +# Pretty much inactive currently +ignore_missing_imports = True + +[mypy-pypeg2] +# Pretty much inactive currently +ignore_missing_imports = True + +[mypy-bdb] +# stdlib, missing in typeshed +ignore_missing_imports = True + +[mypy-qutebrowser.browser.webkit.rfc6266] +# subclasses dynamic PyPEG2 classes +disallow_subclassing_any = False + +[mypy-qutebrowser.browser.browsertab] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.misc.objects] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.commands.cmdutils] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.config.*] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.api.*] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.components.*] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.extensions.*] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.browser.webelem] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.browser.webkit.webkitelem] +disallow_untyped_defs = True +disallow_incomplete_defs = True + +[mypy-qutebrowser.browser.webengine.webengineelem] +disallow_untyped_defs = True +disallow_incomplete_defs = True diff --git a/qutebrowser/api/__init__.py b/qutebrowser/api/__init__.py new file mode 100644 index 000000000..648887005 --- /dev/null +++ b/qutebrowser/api/__init__.py @@ -0,0 +1,26 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""API for extensions. + +This API currently isn't exposed to third-party extensions yet, but will be in +the future. Thus, care must be taken when adding new APIs here. + +Code in qutebrowser.components only uses this API. +""" diff --git a/qutebrowser/api/apitypes.py b/qutebrowser/api/apitypes.py new file mode 100644 index 000000000..8fbc1a9a7 --- /dev/null +++ b/qutebrowser/api/apitypes.py @@ -0,0 +1,27 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""A single tab.""" + +# pylint: disable=unused-import +from qutebrowser.browser.browsertab import WebTabError, AbstractTab as Tab +from qutebrowser.browser.webelem import (Error as WebElemError, + AbstractWebElement as WebElement) +from qutebrowser.utils.usertypes import ClickTarget, JsWorld +from qutebrowser.extensions.loader import InitContext diff --git a/qutebrowser/api/cmdutils.py b/qutebrowser/api/cmdutils.py new file mode 100644 index 000000000..cd43079ad --- /dev/null +++ b/qutebrowser/api/cmdutils.py @@ -0,0 +1,219 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""qutebrowser has the concept of functions, exposed to the user as commands. + +Creating a new command is straightforward:: + + from qutebrowser.api import cmdutils + + @cmdutils.register(...) + def foo(): + ... + +The commands arguments are automatically deduced by inspecting your function. + +The types of the function arguments are inferred based on their default values, +e.g., an argument `foo=True` will be converted to a flag `-f`/`--foo` in +qutebrowser's commandline. + +The type can be overridden using Python's function annotations:: + + @cmdutils.register(...) + def foo(bar: int, baz=True): + ... + +Possible values: + +- A callable (``int``, ``float``, etc.): Gets called to validate/convert the + value. +- A python enum type: All members of the enum are possible values. +- A ``typing.Union`` of multiple types above: Any of these types are valid + values, e.g., ``typing.Union[str, int]``. +""" + + +import inspect +import typing + +from qutebrowser.utils import qtutils +from qutebrowser.commands import command, cmdexc +# pylint: disable=unused-import +from qutebrowser.utils.usertypes import KeyMode, CommandValue as Value + + +class CommandError(cmdexc.Error): + + """Raised when a command encounters an error while running. + + If your command handler encounters an error and cannot continue, raise this + exception with an appropriate error message:: + + raise cmdexc.CommandError("Message") + + The message will then be shown in the qutebrowser status bar. + + .. note:: + + You should only raise this exception while a command handler is run. + Raising it at another point causes qutebrowser to crash due to an + unhandled exception. + """ + + +def check_overflow(arg: int, ctype: str) -> None: + """Check if the given argument is in bounds for the given type. + + Args: + arg: The argument to check. + ctype: The C++/Qt type to check as a string ('int'/'int64'). + """ + try: + qtutils.check_overflow(arg, ctype) + except OverflowError: + raise CommandError("Numeric argument is too large for internal {} " + "representation.".format(ctype)) + + +def check_exclusive(flags: typing.Iterable[bool], + names: typing.Iterable[str]) -> None: + """Check if only one flag is set with exclusive flags. + + Raise a CommandError if not. + + Args: + flags: The flag values to check. + names: A list of names (corresponding to the flags argument). + """ + if sum(1 for e in flags if e) > 1: + argstr = '/'.join('-' + e for e in names) + raise CommandError("Only one of {} can be given!".format(argstr)) + + +class register: # noqa: N801,N806 pylint: disable=invalid-name + + """Decorator to register a new command handler.""" + + def __init__(self, *, + instance: str = None, + name: str = None, + **kwargs: typing.Any) -> None: + """Save decorator arguments. + + Gets called on parse-time with the decorator arguments. + + Args: + See class attributes. + """ + # The object from the object registry to be used as "self". + self._instance = instance + # The name (as string) or names (as list) of the command. + self._name = name + # The arguments to pass to Command. + self._kwargs = kwargs + + def __call__(self, func: typing.Callable) -> typing.Callable: + """Register the command before running the function. + + Gets called when a function should be decorated. + + Doesn't actually decorate anything, but creates a Command object and + registers it in the global commands dict. + + Args: + func: The function to be decorated. + + Return: + The original function (unmodified). + """ + if self._name is None: + name = func.__name__.lower().replace('_', '-') + else: + assert isinstance(self._name, str), self._name + name = self._name + + cmd = command.Command(name=name, instance=self._instance, + handler=func, **self._kwargs) + cmd.register() + return func + + +class argument: # noqa: N801,N806 pylint: disable=invalid-name + + """Decorator to customize an argument. + + You can customize how an argument is handled using the + ``@cmdutils.argument`` decorator *after* ``@cmdutils.register``. This can, + for example, be used to customize the flag an argument should get:: + + @cmdutils.register(...) + @cmdutils.argument('bar', flag='c') + def foo(bar): + ... + + For a ``str`` argument, you can restrict the allowed strings using + ``choices``:: + + @cmdutils.register(...) + @cmdutils.argument('bar', choices=['val1', 'val2']) + def foo(bar: str): + ... + + For ``typing.Union`` types, the given ``choices`` are only checked if other + types (like ``int``) don't match. + + The following arguments are supported for ``@cmdutils.argument``: + + - ``flag``: Customize the short flag (``-x``) the argument will get. + - ``value``: Tell qutebrowser to fill the argument with special values: + + * ``value=cmdutils.Value.count``: The ``count`` given by the user to the + command. + * ``value=cmdutils.Value.win_id``: The window ID of the current window. + * ``value=cmdutils.Value.cur_tab``: The tab object which is currently + focused. + + - ``completion``: A completion function to use when completing arguments + for the given command. + - ``choices``: The allowed string choices for the argument. + + The name of an argument will always be the parameter name, with any + trailing underscores stripped and underscores replaced by dashes. + """ + + def __init__(self, argname: str, **kwargs: typing.Any) -> None: + self._argname = argname # The name of the argument to handle. + self._kwargs = kwargs # Valid ArgInfo members. + + def __call__(self, func: typing.Callable) -> typing.Callable: + funcname = func.__name__ + + if self._argname not in inspect.signature(func).parameters: + raise ValueError("{} has no argument {}!".format(funcname, + self._argname)) + if not hasattr(func, 'qute_args'): + func.qute_args = {} # type: ignore + elif func.qute_args is None: # type: ignore + raise ValueError("@cmdutils.argument got called above (after) " + "@cmdutils.register for {}!".format(funcname)) + + arginfo = command.ArgInfo(**self._kwargs) + func.qute_args[self._argname] = arginfo # type: ignore + + return func diff --git a/qutebrowser/api/config.py b/qutebrowser/api/config.py new file mode 100644 index 000000000..0c633e54d --- /dev/null +++ b/qutebrowser/api/config.py @@ -0,0 +1,43 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Access to the qutebrowser configuration.""" + +import typing + +from PyQt5.QtCore import QUrl + +from qutebrowser.config import config + +#: Simplified access to config values using attribute acccess. +#: For example, to access the ``content.javascript.enabled`` setting, +#: you can do:: +#: +#: if config.val.content.javascript.enabled: +#: ... +#: +#: This also supports setting configuration values:: +#: +#: config.val.content.javascript.enabled = False +val = typing.cast('config.ConfigContainer', None) + + +def get(name: str, url: QUrl = None) -> typing.Any: + """Get a value from the config based on a string name.""" + return config.instance.get(name, url) diff --git a/qutebrowser/api/downloads.py b/qutebrowser/api/downloads.py new file mode 100644 index 000000000..a2a37d931 --- /dev/null +++ b/qutebrowser/api/downloads.py @@ -0,0 +1,75 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + + +"""APIs related to downloading files.""" + + +import io + +from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, QUrl + +from qutebrowser.browser import downloads, qtnetworkdownloads +from qutebrowser.utils import objreg + + +class TempDownload(QObject): + + """A download of some data into a file object.""" + + finished = pyqtSignal() + + def __init__(self, item: qtnetworkdownloads.DownloadItem) -> None: + super().__init__() + self._item = item + self._item.finished.connect(self._on_download_finished) + self.successful = False + self.fileobj = item.fileobj + + @pyqtSlot() + def _on_download_finished(self) -> None: + self.successful = self._item.successful + self.finished.emit() + + +def download_temp(url: QUrl) -> TempDownload: + """Download the given URL into a file object. + + The download is not saved to disk. + + Returns a ``TempDownload`` object, which triggers a ``finished`` signal + when the download has finished:: + + dl = downloads.download_temp(QUrl("https://www.example.com/")) + dl.finished.connect(functools.partial(on_download_finished, dl)) + + After the download has finished, its ``successful`` attribute can be + checked to make sure it finished successfully. If so, its contents can be + read by accessing the ``fileobj`` attribute:: + + def on_download_finished(download: downloads.TempDownload) -> None: + if download.successful: + print(download.fileobj.read()) + download.fileobj.close() + """ + fobj = io.BytesIO() + fobj.name = 'temporary: ' + url.host() + target = downloads.FileObjDownloadTarget(fobj) + download_manager = objreg.get('qtnetwork-download-manager') + return download_manager.get(url, target=target, auto_remove=True) diff --git a/qutebrowser/api/hook.py b/qutebrowser/api/hook.py new file mode 100644 index 000000000..84e103cbd --- /dev/null +++ b/qutebrowser/api/hook.py @@ -0,0 +1,92 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +# pylint: disable=invalid-name + +"""Hooks for extensions.""" + +import importlib +import typing + + +from qutebrowser.extensions import loader + + +def _add_module_info(func: typing.Callable) -> loader.ModuleInfo: + """Add module info to the given function.""" + module = importlib.import_module(func.__module__) + return loader.add_module_info(module) + + +class init: + + """Decorator to mark a function to run when initializing. + + The decorated function gets called with a + :class:`qutebrowser.api.apitypes.InitContext` as argument. + + Example:: + + @hook.init() + def init(_context): + message.info("Extension initialized.") + """ + + def __call__(self, func: typing.Callable) -> typing.Callable: + info = _add_module_info(func) + if info.init_hook is not None: + raise ValueError("init hook is already registered!") + info.init_hook = func + return func + + +class config_changed: + + """Decorator to get notified about changed configs. + + By default, the decorated function is called when any change in the config + occurs:: + + @hook.config_changed() + def on_config_changed(): + ... + + When an option name is passed, it's only called when the given option was + changed:: + + @hook.config_changed('content.javascript.enabled') + def on_config_changed(): + ... + + Alternatively, a part of an option name can be specified. In the following + snippet, ``on_config_changed`` gets called when either + ``bindings.commands`` or ``bindings.key_mappings`` have changed:: + + @hook.config_changed('bindings') + def on_config_changed(): + ... + """ + + def __init__(self, option_filter: str = None) -> None: + self._filter = option_filter + + def __call__(self, func: typing.Callable) -> typing.Callable: + info = _add_module_info(func) + info.config_changed_hooks.append((self._filter, func)) + return func diff --git a/qutebrowser/api/interceptor.py b/qutebrowser/api/interceptor.py new file mode 100644 index 000000000..78819dc46 --- /dev/null +++ b/qutebrowser/api/interceptor.py @@ -0,0 +1,43 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""APIs related to intercepting/blocking requests.""" + +from qutebrowser.extensions import interceptors +# pylint: disable=unused-import +from qutebrowser.extensions.interceptors import Request + + +#: Type annotation for an interceptor function. +InterceptorType = interceptors.InterceptorType + + +def register(interceptor: InterceptorType) -> None: + """Register a request interceptor. + + Whenever a request happens, the interceptor gets called with a + :class:`Request` object. + + Example:: + + def intercept(request: interceptor.Request) -> None: + if request.request_url.host() == 'badhost.example.com': + request.block() + """ + interceptors.register(interceptor) diff --git a/qutebrowser/api/message.py b/qutebrowser/api/message.py new file mode 100644 index 000000000..fdb06354f --- /dev/null +++ b/qutebrowser/api/message.py @@ -0,0 +1,23 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Utilities to display messages above the status bar.""" + +# pylint: disable=unused-import +from qutebrowser.utils.message import error, warning, info diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 1cfcc8496..2b6896b76 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -60,13 +60,15 @@ except ImportError: import qutebrowser import qutebrowser.resources from qutebrowser.completion.models import miscmodels -from qutebrowser.commands import cmdutils, runners, cmdexc +from qutebrowser.commands import runners +from qutebrowser.api import cmdutils from qutebrowser.config import config, websettings, configfiles, configinit -from qutebrowser.browser import (urlmarks, adblock, history, browsertab, +from qutebrowser.browser import (urlmarks, history, browsertab, qtnetworkdownloads, downloads, greasemonkey) from qutebrowser.browser.network import proxy from qutebrowser.browser.webkit import cookies, cache from qutebrowser.browser.webkit.network import networkmanager +from qutebrowser.extensions import loader from qutebrowser.keyinput import macros from qutebrowser.mainwindow import mainwindow, prompt from qutebrowser.misc import (readline, ipc, savemanager, sessions, @@ -163,6 +165,8 @@ def init(args, crash_handler): qApp.setQuitOnLastWindowClosed(False) _init_icon() + loader.init() + loader.load_components() try: _init_modules(args, crash_handler) except (OSError, UnicodeDecodeError, browsertab.WebTabError) as e: @@ -193,7 +197,7 @@ def _init_icon(): icon = QIcon() fallback_icon = QIcon() for size in [16, 24, 32, 48, 64, 96, 128, 256, 512]: - filename = ':/icons/qutebrowser-{}x{}.png'.format(size, size) + filename = ':/icons/qutebrowser-{size}x{size}.png'.format(size=size) pixmap = QPixmap(filename) if pixmap.isNull(): log.init.warning("Failed to load {}".format(filename)) @@ -303,10 +307,10 @@ def process_pos_args(args, via_ipc=False, cwd=None, target_arg=None): def open_url(url, target=None, no_raise=False, via_ipc=True): - """Open an URL in new window/tab. + """Open a URL in new window/tab. Args: - url: An URL to open. + url: A URL to open. target: same as new_instance_open_target (used as a default). no_raise: suppress target window raising. via_ipc: Whether the arguments were transmitted over IPC. @@ -465,11 +469,6 @@ def _init_modules(args, crash_handler): log.init.debug("Initializing websettings...") websettings.init(args) - log.init.debug("Initializing adblock...") - host_blocker = adblock.HostBlocker() - host_blocker.read_hosts() - objreg.register('host-blocker', host_blocker) - log.init.debug("Initializing quickmarks...") quickmark_manager = urlmarks.QuickmarkManager(qApp) objreg.register('quickmark-manager', quickmark_manager) @@ -619,10 +618,11 @@ class Quitter: ok = self.restart(session='_restart') except sessions.SessionError as e: log.destroy.exception("Failed to save session!") - raise cmdexc.CommandError("Failed to save session: {}!".format(e)) + raise cmdutils.CommandError("Failed to save session: {}!" + .format(e)) except SyntaxError as e: log.destroy.exception("Got SyntaxError") - raise cmdexc.CommandError("SyntaxError in {}:{}: {}".format( + raise cmdutils.CommandError("SyntaxError in {}:{}: {}".format( e.filename, e.lineno, e)) if ok: self.shutdown(restart=True) @@ -684,7 +684,7 @@ class Quitter: session: The name of the session to save. """ if session is not None and not save: - raise cmdexc.CommandError("Session name given without --save!") + raise cmdutils.CommandError("Session name given without --save!") if save: if session is None: session = sessions.default diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index bb0b31626..029394657 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -21,12 +21,15 @@ import enum import itertools +import typing import attr -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, QObject, QSizeF, Qt -from PyQt5.QtGui import QIcon +from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QUrl, QObject, QSizeF, Qt, + QEvent, QPoint) +from PyQt5.QtGui import QKeyEvent, QIcon from PyQt5.QtWidgets import QWidget, QApplication, QDialog -from PyQt5.QtPrintSupport import QPrintDialog +from PyQt5.QtPrintSupport import QPrintDialog, QPrinter +from PyQt5.QtNetwork import QNetworkAccessManager import pygments import pygments.lexers @@ -39,12 +42,20 @@ from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils, from qutebrowser.misc import miscwidgets, objects from qutebrowser.browser import mouse, hints from qutebrowser.qt import sip +MYPY = False +if MYPY: + # pylint can't interpret type comments with Python 3.7 + # pylint: disable=unused-import,useless-suppression + from qutebrowser.browser import webelem + from qutebrowser.browser.inspector import AbstractWebInspector tab_id_gen = itertools.count(0) -def create(win_id, private, parent=None): +def create(win_id: int, + private: bool, + parent: QWidget = None) -> 'AbstractTab': """Get a QtWebKit/QtWebEngine tab object. Args: @@ -65,7 +76,7 @@ def create(win_id, private, parent=None): parent=parent) -def init(): +def init() -> None: """Initialize backend-specific modules.""" if objects.backend == usertypes.Backend.QtWebEngine: from qutebrowser.browser.webengine import webenginetab @@ -112,60 +123,60 @@ class TabData: input_mode: current input mode for the tab. """ - keep_icon = attr.ib(False) - viewing_source = attr.ib(False) - inspector = attr.ib(None) - open_target = attr.ib(usertypes.ClickTarget.normal) - override_target = attr.ib(None) - pinned = attr.ib(False) - fullscreen = attr.ib(False) - netrc_used = attr.ib(False) - input_mode = attr.ib(usertypes.KeyMode.normal) + keep_icon = attr.ib(False) # type: bool + viewing_source = attr.ib(False) # type: bool + inspector = attr.ib(None) # type: typing.Optional[AbstractWebInspector] + open_target = attr.ib( + usertypes.ClickTarget.normal) # type: usertypes.ClickTarget + override_target = attr.ib(None) # type: usertypes.ClickTarget + pinned = attr.ib(False) # type: bool + fullscreen = attr.ib(False) # type: bool + netrc_used = attr.ib(False) # type: bool + input_mode = attr.ib(usertypes.KeyMode.normal) # type: usertypes.KeyMode - def should_show_icon(self): + def should_show_icon(self) -> bool: return (config.val.tabs.favicons.show == 'always' or config.val.tabs.favicons.show == 'pinned' and self.pinned) class AbstractAction: - """Attribute of AbstractTab for Qt WebActions. + """Attribute ``action`` of AbstractTab for Qt WebActions.""" - Class attributes (overridden by subclasses): - action_class: The class actions are defined on (QWeb{Engine,}Page) - action_base: The type of the actions (QWeb{Engine,}Page.WebAction) - """ + # The class actions are defined on (QWeb{Engine,}Page) + action_class = None # type: type + # The type of the actions (QWeb{Engine,}Page.WebAction) + action_base = None # type: type - action_class = None - action_base = None - - def __init__(self, tab): - self._widget = None + def __init__(self, tab: 'AbstractTab') -> None: + self._widget = typing.cast(QWidget, None) self._tab = tab - def exit_fullscreen(self): + def exit_fullscreen(self) -> None: """Exit the fullscreen mode.""" raise NotImplementedError - def save_page(self): + def save_page(self) -> None: """Save the current page.""" raise NotImplementedError - def run_string(self, name): + def run_string(self, name: str) -> None: """Run a webaction based on its name.""" member = getattr(self.action_class, name, None) if not isinstance(member, self.action_base): raise WebTabError("{} is not a valid web action!".format(name)) self._widget.triggerPageAction(member) - def show_source(self, - pygments=False): # pylint: disable=redefined-outer-name + def show_source( + self, + pygments: bool = False # pylint: disable=redefined-outer-name + ) -> None: """Show the source of the current page in a new tab.""" raise NotImplementedError - def _show_source_pygments(self): + def _show_source_pygments(self) -> None: - def show_source_cb(source): + def show_source_cb(source: str) -> None: """Show source as soon as it's ready.""" # WORKAROUND for https://github.com/PyCQA/pylint/issues/491 # pylint: disable=no-member @@ -186,25 +197,42 @@ class AbstractAction: class AbstractPrinting: - """Attribute of AbstractTab for printing the page.""" + """Attribute ``printing`` of AbstractTab for printing the page.""" - def __init__(self, tab): + def __init__(self, tab: 'AbstractTab') -> None: self._widget = None self._tab = tab - def check_pdf_support(self): + def check_pdf_support(self) -> None: + """Check whether writing to PDFs is supported. + + If it's not supported (by the current Qt version), a WebTabError is + raised. + """ raise NotImplementedError - def check_printer_support(self): + def check_printer_support(self) -> None: + """Check whether writing to a printer is supported. + + If it's not supported (by the current Qt version), a WebTabError is + raised. + """ raise NotImplementedError - def check_preview_support(self): + def check_preview_support(self) -> None: + """Check whether showing a print preview is supported. + + If it's not supported (by the current Qt version), a WebTabError is + raised. + """ raise NotImplementedError - def to_pdf(self, filename): + def to_pdf(self, filename: str) -> bool: + """Print the tab to a PDF with the given filename.""" raise NotImplementedError - def to_printer(self, printer, callback=None): + def to_printer(self, printer: QPrinter, + callback: typing.Callable[[bool], None] = None) -> None: """Print the tab. Args: @@ -214,17 +242,17 @@ class AbstractPrinting: """ raise NotImplementedError - def show_dialog(self): + def show_dialog(self) -> None: """Print with a QPrintDialog.""" self.check_printer_support() - def print_callback(ok): + def print_callback(ok: bool) -> None: """Called when printing finished.""" if not ok: message.error("Printing failed!") diag.deleteLater() - def do_print(): + def do_print() -> None: """Called when the dialog was closed.""" self.to_printer(diag.printer(), print_callback) @@ -240,7 +268,7 @@ class AbstractPrinting: class AbstractSearch(QObject): - """Attribute of AbstractTab for doing searches. + """Attribute ``search`` of AbstractTab for doing searches. Attributes: text: The last thing this view was searched for. @@ -248,24 +276,24 @@ class AbstractSearch(QObject): this view. _flags: The flags of the last search (needs to be set by subclasses). _widget: The underlying WebView widget. - - Signals: - finished: Emitted when a search was finished. - arg: True if the text was found, False otherwise. - cleared: Emitted when an existing search was cleared. """ + #: Signal emitted when a search was finished + #: (True if the text was found, False otherwise) finished = pyqtSignal(bool) + #: Signal emitted when an existing search was cleared. cleared = pyqtSignal() - def __init__(self, tab, parent=None): + _Callback = typing.Callable[[bool], None] + + def __init__(self, tab: 'AbstractTab', parent: QWidget = None): super().__init__(parent) self._tab = tab self._widget = None - self.text = None + self.text = None # type: typing.Optional[str] self.search_displayed = False - def _is_case_sensitive(self, ignore_case): + def _is_case_sensitive(self, ignore_case: usertypes.IgnoreCase) -> bool: """Check if case-sensitivity should be used. This assumes self.text is already set properly. @@ -273,30 +301,33 @@ class AbstractSearch(QObject): Arguments: ignore_case: The ignore_case value from the config. """ + assert self.text is not None mapping = { - 'smart': not self.text.islower(), - 'never': True, - 'always': False, + usertypes.IgnoreCase.smart: not self.text.islower(), + usertypes.IgnoreCase.never: True, + usertypes.IgnoreCase.always: False, } return mapping[ignore_case] - def search(self, text, *, ignore_case='never', reverse=False, - result_cb=None): + def search(self, text: str, *, + ignore_case: usertypes.IgnoreCase = usertypes.IgnoreCase.never, + reverse: bool = False, + result_cb: _Callback = None) -> None: """Find the given text on the page. Args: text: The text to search for. - ignore_case: Search case-insensitively. ('always'/'never/'smart') + ignore_case: Search case-insensitively. reverse: Reverse search direction. result_cb: Called with a bool indicating whether a match was found. """ raise NotImplementedError - def clear(self): + def clear(self) -> None: """Clear the current search.""" raise NotImplementedError - def prev_result(self, *, result_cb=None): + def prev_result(self, *, result_cb: _Callback = None) -> None: """Go to the previous result of the current search. Args: @@ -304,7 +335,7 @@ class AbstractSearch(QObject): """ raise NotImplementedError - def next_result(self, *, result_cb=None): + def next_result(self, *, result_cb: _Callback = None) -> None: """Go to the next result of the current search. Args: @@ -315,45 +346,36 @@ class AbstractSearch(QObject): class AbstractZoom(QObject): - """Attribute of AbstractTab for controlling zoom. + """Attribute ``zoom`` of AbstractTab for controlling zoom.""" - Attributes: - _neighborlist: A NeighborList with the zoom levels. - _default_zoom_changed: Whether the zoom was changed from the default. - """ - - def __init__(self, tab, parent=None): + def __init__(self, tab: 'AbstractTab', parent: QWidget = None) -> None: super().__init__(parent) self._tab = tab self._widget = None + # Whether zoom was changed from the default. self._default_zoom_changed = False self._init_neighborlist() config.instance.changed.connect(self._on_config_changed) self._zoom_factor = float(config.val.zoom.default) / 100 - # # FIXME:qtwebengine is this needed? - # # For some reason, this signal doesn't get disconnected automatically - # # when the WebView is destroyed on older PyQt versions. - # # See https://github.com/qutebrowser/qutebrowser/issues/390 - # self.destroyed.connect(functools.partial( - # cfg.changed.disconnect, self.init_neighborlist)) - @pyqtSlot(str) - def _on_config_changed(self, option): + def _on_config_changed(self, option: str) -> None: if option in ['zoom.levels', 'zoom.default']: if not self._default_zoom_changed: factor = float(config.val.zoom.default) / 100 self.set_factor(factor) self._init_neighborlist() - def _init_neighborlist(self): - """Initialize self._neighborlist.""" + def _init_neighborlist(self) -> None: + """Initialize self._neighborlist. + + It is a NeighborList with the zoom levels.""" levels = config.val.zoom.levels self._neighborlist = usertypes.NeighborList( levels, mode=usertypes.NeighborList.Modes.edge) self._neighborlist.fuzzyval = config.val.zoom.default - def offset(self, offset): + def apply_offset(self, offset: int) -> None: """Increase/Decrease the zoom level by the given offset. Args: @@ -366,10 +388,10 @@ class AbstractZoom(QObject): self.set_factor(float(level) / 100, fuzzyval=False) return level - def _set_factor_internal(self, factor): + def _set_factor_internal(self, factor: float) -> None: raise NotImplementedError - def set_factor(self, factor, *, fuzzyval=True): + def set_factor(self, factor: float, *, fuzzyval: bool = True) -> None: """Zoom to a given zoom factor. Args: @@ -387,30 +409,30 @@ class AbstractZoom(QObject): self._zoom_factor = factor self._set_factor_internal(factor) - def factor(self): + def factor(self) -> float: return self._zoom_factor - def set_default(self): + def apply_default(self) -> None: self._set_factor_internal(float(config.val.zoom.default) / 100) - def set_current(self): + def reapply(self) -> None: self._set_factor_internal(self._zoom_factor) class AbstractCaret(QObject): - """Attribute of AbstractTab for caret browsing. - - Signals: - selection_toggled: Emitted when the selection was toggled. - arg: Whether the selection is now active. - follow_selected_done: Emitted when a follow_selection action is done. - """ + """Attribute ``caret`` of AbstractTab for caret browsing.""" + #: Signal emitted when the selection was toggled. + #: (argument - whether the selection is now active) selection_toggled = pyqtSignal(bool) + #: Emitted when a ``follow_selection`` action is done. follow_selected_done = pyqtSignal() - def __init__(self, tab, mode_manager, parent=None): + def __init__(self, + tab: 'AbstractTab', + mode_manager: modeman.ModeManager, + parent: QWidget = None) -> None: super().__init__(parent) self._tab = tab self._widget = None @@ -418,146 +440,171 @@ class AbstractCaret(QObject): mode_manager.entered.connect(self._on_mode_entered) mode_manager.left.connect(self._on_mode_left) - def _on_mode_entered(self, mode): + def _on_mode_entered(self, mode: usertypes.KeyMode) -> None: raise NotImplementedError - def _on_mode_left(self, mode): + def _on_mode_left(self, mode: usertypes.KeyMode) -> None: raise NotImplementedError - def move_to_next_line(self, count=1): + def move_to_next_line(self, count: int = 1) -> None: raise NotImplementedError - def move_to_prev_line(self, count=1): + def move_to_prev_line(self, count: int = 1) -> None: raise NotImplementedError - def move_to_next_char(self, count=1): + def move_to_next_char(self, count: int = 1) -> None: raise NotImplementedError - def move_to_prev_char(self, count=1): + def move_to_prev_char(self, count: int = 1) -> None: raise NotImplementedError - def move_to_end_of_word(self, count=1): + def move_to_end_of_word(self, count: int = 1) -> None: raise NotImplementedError - def move_to_next_word(self, count=1): + def move_to_next_word(self, count: int = 1) -> None: raise NotImplementedError - def move_to_prev_word(self, count=1): + def move_to_prev_word(self, count: int = 1) -> None: raise NotImplementedError - def move_to_start_of_line(self): + def move_to_start_of_line(self) -> None: raise NotImplementedError - def move_to_end_of_line(self): + def move_to_end_of_line(self) -> None: raise NotImplementedError - def move_to_start_of_next_block(self, count=1): + def move_to_start_of_next_block(self, count: int = 1) -> None: raise NotImplementedError - def move_to_start_of_prev_block(self, count=1): + def move_to_start_of_prev_block(self, count: int = 1) -> None: raise NotImplementedError - def move_to_end_of_next_block(self, count=1): + def move_to_end_of_next_block(self, count: int = 1) -> None: raise NotImplementedError - def move_to_end_of_prev_block(self, count=1): + def move_to_end_of_prev_block(self, count: int = 1) -> None: raise NotImplementedError - def move_to_start_of_document(self): + def move_to_start_of_document(self) -> None: raise NotImplementedError - def move_to_end_of_document(self): + def move_to_end_of_document(self) -> None: raise NotImplementedError - def toggle_selection(self): + def toggle_selection(self) -> None: raise NotImplementedError - def drop_selection(self): + def drop_selection(self) -> None: raise NotImplementedError - def selection(self, callback): + def selection(self, callback: typing.Callable[[str], None]) -> None: raise NotImplementedError - def _follow_enter(self, tab): + def _follow_enter(self, tab: bool) -> None: """Follow a link by faking an enter press.""" if tab: - self._tab.key_press(Qt.Key_Enter, modifier=Qt.ControlModifier) + self._tab.fake_key_press(Qt.Key_Enter, modifier=Qt.ControlModifier) else: - self._tab.key_press(Qt.Key_Enter) + self._tab.fake_key_press(Qt.Key_Enter) - def follow_selected(self, *, tab=False): + def follow_selected(self, *, tab: bool = False) -> None: raise NotImplementedError class AbstractScroller(QObject): - """Attribute of AbstractTab to manage scroll position.""" + """Attribute ``scroller`` of AbstractTab to manage scroll position.""" + #: Signal emitted when the scroll position changed (int, int) perc_changed = pyqtSignal(int, int) + #: Signal emitted before the user requested a jump. + #: Used to set the special ' mark so the user can return. + before_jump_requested = pyqtSignal() - def __init__(self, tab, parent=None): + def __init__(self, tab: 'AbstractTab', parent: QWidget = None): super().__init__(parent) self._tab = tab - self._widget = None + self._widget = None # type: typing.Optional[QWidget] self.perc_changed.connect(self._log_scroll_pos_change) @pyqtSlot() - def _log_scroll_pos_change(self): - log.webview.vdebug("Scroll position changed to {}".format( - self.pos_px())) + def _log_scroll_pos_change(self) -> None: + log.webview.vdebug( # type: ignore + "Scroll position changed to {}".format(self.pos_px())) - def _init_widget(self, widget): + def _init_widget(self, widget: QWidget) -> None: self._widget = widget - def pos_px(self): + def pos_px(self) -> int: raise NotImplementedError - def pos_perc(self): + def pos_perc(self) -> int: raise NotImplementedError - def to_perc(self, x=None, y=None): + def to_perc(self, x: int = None, y: int = None) -> None: raise NotImplementedError - def to_point(self, point): + def to_point(self, point: QPoint) -> None: raise NotImplementedError - def to_anchor(self, name): + def to_anchor(self, name: str) -> None: raise NotImplementedError - def delta(self, x=0, y=0): + def delta(self, x: int = 0, y: int = 0) -> None: raise NotImplementedError - def delta_page(self, x=0, y=0): + def delta_page(self, x: float = 0, y: float = 0) -> None: raise NotImplementedError - def up(self, count=1): + def up(self, count: int = 1) -> None: raise NotImplementedError - def down(self, count=1): + def down(self, count: int = 1) -> None: raise NotImplementedError - def left(self, count=1): + def left(self, count: int = 1) -> None: raise NotImplementedError - def right(self, count=1): + def right(self, count: int = 1) -> None: raise NotImplementedError - def top(self): + def top(self) -> None: raise NotImplementedError - def bottom(self): + def bottom(self) -> None: raise NotImplementedError - def page_up(self, count=1): + def page_up(self, count: int = 1) -> None: raise NotImplementedError - def page_down(self, count=1): + def page_down(self, count: int = 1) -> None: raise NotImplementedError - def at_top(self): + def at_top(self) -> bool: raise NotImplementedError - def at_bottom(self): + def at_bottom(self) -> bool: + raise NotImplementedError + + +class AbstractHistoryPrivate: + + """Private API related to the history.""" + + def __init__(self, tab: 'AbstractTab'): + self._tab = tab + self._history = None + + def serialize(self) -> bytes: + """Serialize into an opaque format understood by self.deserialize.""" + raise NotImplementedError + + def deserialize(self, data: bytes) -> None: + """Deserialize from a format produced by self.serialize.""" + raise NotImplementedError + + def load_items(self, items: typing.Sequence) -> None: + """Deserialize from a list of WebHistoryItems.""" raise NotImplementedError @@ -565,21 +612,28 @@ class AbstractHistory: """The history attribute of a AbstractTab.""" - def __init__(self, tab): + def __init__(self, tab: 'AbstractTab') -> None: self._tab = tab self._history = None + self.private_api = AbstractHistoryPrivate(tab) - def __len__(self): - return len(self._history) - - def __iter__(self): - return iter(self._history.items()) - - def current_idx(self): + def __len__(self) -> int: raise NotImplementedError - def back(self, count=1): + def __iter__(self) -> typing.Iterable: + raise NotImplementedError + + def _check_count(self, count: int) -> None: + """Check whether the count is positive.""" + if count < 0: + raise WebTabError("count needs to be positive!") + + def current_idx(self) -> int: + raise NotImplementedError + + def back(self, count: int = 1) -> None: """Go back in the tab's history.""" + self._check_count(count) idx = self.current_idx() - count if idx >= 0: self._go_to_item(self._item_at(idx)) @@ -587,8 +641,9 @@ class AbstractHistory: self._go_to_item(self._item_at(0)) raise WebTabError("At beginning of history.") - def forward(self, count=1): + def forward(self, count: int = 1) -> None: """Go forward in the tab's history.""" + self._check_count(count) idx = self.current_idx() + count if idx < len(self): self._go_to_item(self._item_at(idx)) @@ -596,28 +651,16 @@ class AbstractHistory: self._go_to_item(self._item_at(len(self) - 1)) raise WebTabError("At end of history.") - def can_go_back(self): + def can_go_back(self) -> bool: raise NotImplementedError - def can_go_forward(self): + def can_go_forward(self) -> bool: raise NotImplementedError - def _item_at(self, i): + def _item_at(self, i: int) -> typing.Any: raise NotImplementedError - def _go_to_item(self, item): - raise NotImplementedError - - def serialize(self): - """Serialize into an opaque format understood by self.deserialize.""" - raise NotImplementedError - - def deserialize(self, data): - """Serialize from a format produced by self.serialize.""" - raise NotImplementedError - - def load_items(self, items): - """Deserialize from a list of WebHistoryItems.""" + def _go_to_item(self, item: typing.Any) -> None: raise NotImplementedError @@ -625,11 +668,20 @@ class AbstractElements: """Finding and handling of elements on the page.""" - def __init__(self, tab): + _MultiCallback = typing.Callable[ + [typing.Sequence['webelem.AbstractWebElement']], None] + _SingleCallback = typing.Callable[ + [typing.Optional['webelem.AbstractWebElement']], None] + _ErrorCallback = typing.Callable[[Exception], None] + + def __init__(self, tab: 'AbstractTab') -> None: self._widget = None self._tab = tab - def find_css(self, selector, callback, *, only_visible=False): + def find_css(self, selector: str, + callback: _MultiCallback, + error_cb: _ErrorCallback, *, + only_visible: bool = False) -> None: """Find all HTML elements matching a given selector async. If there's an error, the callback is called with a webelem.Error @@ -637,21 +689,23 @@ class AbstractElements: Args: callback: The callback to be called when the search finished. + error_cb: The callback to be called when an error occurred. selector: The CSS selector to search for. only_visible: Only show elements which are visible on screen. """ raise NotImplementedError - def find_id(self, elem_id, callback): + def find_id(self, elem_id: str, callback: _SingleCallback) -> None: """Find the HTML element with the given ID async. Args: callback: The callback to be called when the search finished. + Called with a WebEngineElement or None. elem_id: The ID to search for. """ raise NotImplementedError - def find_focused(self, callback): + def find_focused(self, callback: _SingleCallback) -> None: """Find the focused element on the page async. Args: @@ -660,7 +714,7 @@ class AbstractElements: """ raise NotImplementedError - def find_at_pos(self, pos, callback): + def find_at_pos(self, pos: QPoint, callback: _SingleCallback) -> None: """Find the element at the given position async. This is also called "hit test" elsewhere. @@ -680,12 +734,12 @@ class AbstractAudio(QObject): muted_changed = pyqtSignal(bool) recently_audible_changed = pyqtSignal(bool) - def __init__(self, tab, parent=None): + def __init__(self, tab: 'AbstractTab', parent: QWidget = None) -> None: super().__init__(parent) - self._widget = None + self._widget = None # type: typing.Optional[QWidget] self._tab = tab - def set_muted(self, muted: bool, override: bool = False): + def set_muted(self, muted: bool, override: bool = False) -> None: """Set this tab as muted or not. Arguments: @@ -695,71 +749,120 @@ class AbstractAudio(QObject): """ raise NotImplementedError - def is_muted(self): - """Whether this tab is muted.""" + def is_muted(self) -> bool: raise NotImplementedError - def toggle_muted(self, *, override: bool = False): - self.set_muted(not self.is_muted(), override=override) - - def is_recently_audible(self): + def is_recently_audible(self) -> bool: """Whether this tab has had audio playing recently.""" raise NotImplementedError +class AbstractTabPrivate: + + """Tab-related methods which are only needed in the core. + + Those methods are not part of the API which is exposed to extensions, and + should ideally be removed at some point in the future. + """ + + def __init__(self, mode_manager: modeman.ModeManager, + tab: 'AbstractTab') -> None: + self._widget = None # type: typing.Optional[QWidget] + self._tab = tab + self._mode_manager = mode_manager + + def event_target(self) -> QWidget: + """Return the widget events should be sent to.""" + raise NotImplementedError + + def handle_auto_insert_mode(self, ok: bool) -> None: + """Handle `input.insert_mode.auto_load` after loading finished.""" + if not config.val.input.insert_mode.auto_load or not ok: + return + + cur_mode = self._mode_manager.mode + if cur_mode == usertypes.KeyMode.insert: + return + + def _auto_insert_mode_cb(elem: 'webelem.AbstractWebElement') -> None: + """Called from JS after finding the focused element.""" + if elem is None: + log.webview.debug("No focused element!") + return + if elem.is_editable(): + modeman.enter(self._tab.win_id, usertypes.KeyMode.insert, + 'load finished', only_if_normal=True) + + self._tab.elements.find_focused(_auto_insert_mode_cb) + + def clear_ssl_errors(self) -> None: + raise NotImplementedError + + def networkaccessmanager(self) -> typing.Optional[QNetworkAccessManager]: + """Get the QNetworkAccessManager for this tab. + + This is only implemented for QtWebKit. + For QtWebEngine, always returns None. + """ + raise NotImplementedError + + def user_agent(self) -> typing.Optional[str]: + """Get the user agent for this tab. + + This is only implemented for QtWebKit. + For QtWebEngine, always returns None. + """ + raise NotImplementedError + + def shutdown(self) -> None: + raise NotImplementedError + + class AbstractTab(QWidget): - """A wrapper over the given widget to hide its API and expose another one. - - We use this to unify QWebView and QWebEngineView. - - Attributes: - history: The AbstractHistory for the current tab. - registry: The ObjectRegistry associated with this tab. - private: Whether private browsing is turned on for this tab. - - _load_status: loading status of this page - Accessible via load_status() method. - _has_ssl_errors: Whether SSL errors happened. - Needs to be set by subclasses. - - for properties, see WebView/WebEngineView docs. - - Signals: - See related Qt signals. - - new_tab_requested: Emitted when a new tab should be opened with the - given URL. - load_status_changed: The loading status changed - fullscreen_requested: Fullscreen display was requested by the page. - arg: True if fullscreen should be turned on, - False if it should be turned off. - renderer_process_terminated: Emitted when the underlying renderer - process terminated. - arg 0: A TerminationStatus member. - arg 1: The exit code. - predicted_navigation: Emitted before we tell Qt to open a URL. - """ + """An adapter for QWebView/QWebEngineView representing a single tab.""" + #: Signal emitted when a website requests to close this tab. window_close_requested = pyqtSignal() + #: Signal emitted when a link is hovered (the hover text) link_hovered = pyqtSignal(str) + #: Signal emitted when a page started loading load_started = pyqtSignal() + #: Signal emitted when a page is loading (progress percentage) load_progress = pyqtSignal(int) + #: Signal emitted when a page finished loading (success as bool) load_finished = pyqtSignal(bool) + #: Signal emitted when a page's favicon changed (icon as QIcon) icon_changed = pyqtSignal(QIcon) + #: Signal emitted when a page's title changed (new title as str) title_changed = pyqtSignal(str) - load_status_changed = pyqtSignal(str) + #: Signal emitted when a new tab should be opened (url as QUrl) new_tab_requested = pyqtSignal(QUrl) + #: Signal emitted when a page's URL changed (url as QUrl) url_changed = pyqtSignal(QUrl) - shutting_down = pyqtSignal() + #: Signal emitted when a tab's content size changed + #: (new size as QSizeF) contents_size_changed = pyqtSignal(QSizeF) - add_history_item = pyqtSignal(QUrl, QUrl, str) # url, requested url, title + #: Signal emitted when a page requested full-screen (bool) fullscreen_requested = pyqtSignal(bool) - renderer_process_terminated = pyqtSignal(TerminationStatus, int) - predicted_navigation = pyqtSignal(QUrl) + #: Signal emitted before load starts (URL as QUrl) + before_load_started = pyqtSignal(QUrl) - def __init__(self, *, win_id, mode_manager, private, parent=None): - self.private = private + # Signal emitted when a page's load status changed + # (argument: usertypes.LoadStatus) + load_status_changed = pyqtSignal(usertypes.LoadStatus) + # Signal emitted before shutting down + shutting_down = pyqtSignal() + # Signal emitted when a history item should be added + history_item_triggered = pyqtSignal(QUrl, QUrl, str) + # Signal emitted when the underlying renderer process terminated. + # arg 0: A TerminationStatus member. + # arg 1: The exit code. + renderer_process_terminated = pyqtSignal(TerminationStatus, int) + + def __init__(self, *, win_id: int, private: bool, + parent: QWidget = None) -> None: + self.is_private = private self.win_id = win_id self.tab_id = next(tab_id_gen) super().__init__(parent) @@ -772,10 +875,9 @@ class AbstractTab(QWidget): self.data = TabData() self._layout = miscwidgets.WrapperLayout(self) - self._widget = None + self._widget = None # type: typing.Optional[QWidget] self._progress = 0 self._has_ssl_errors = False - self._mode_manager = mode_manager self._load_status = usertypes.LoadStatus.none self._mouse_event_filter = mouse.MouseEventFilter( self, parent=self) @@ -787,13 +889,14 @@ class AbstractTab(QWidget): objreg.register('hintmanager', hintmanager, scope='tab', window=self.win_id, tab=self.tab_id) - self.predicted_navigation.connect(self._on_predicted_navigation) + self.before_load_started.connect(self._on_before_load_started) - def _set_widget(self, widget): + def _set_widget(self, widget: QWidget) -> None: # pylint: disable=protected-access self._widget = widget self._layout.wrap(self, widget) self.history._history = widget.history() + self.history.private_api._history = widget.history() self.scroller._init_widget(widget) self.caret._widget = widget self.zoom._widget = widget @@ -802,31 +905,28 @@ class AbstractTab(QWidget): self.action._widget = widget self.elements._widget = widget self.audio._widget = widget + self.private_api._widget = widget self.settings._settings = widget.settings() self._install_event_filter() - self.zoom.set_default() + self.zoom.apply_default() - def _install_event_filter(self): + def _install_event_filter(self) -> None: raise NotImplementedError - def _set_load_status(self, val): + def _set_load_status(self, val: usertypes.LoadStatus) -> None: """Setter for load_status.""" if not isinstance(val, usertypes.LoadStatus): raise TypeError("Type {} is no LoadStatus member!".format(val)) log.webview.debug("load status for {}: {}".format(repr(self), val)) self._load_status = val - self.load_status_changed.emit(val.name) + self.load_status_changed.emit(val) - def event_target(self): - """Return the widget events should be sent to.""" - raise NotImplementedError - - def send_event(self, evt): + def send_event(self, evt: QEvent) -> None: """Send the given event to the underlying widget. The event will be sent via QApplication.postEvent. - Note that a posted event may not be re-used in any way! + Note that a posted event must not be re-used in any way! """ # This only gives us some mild protection against re-using events, but # it's certainly better than a segfault. @@ -834,7 +934,7 @@ class AbstractTab(QWidget): raise utils.Unreachable("Can't re-use an event which was already " "posted!") - recipient = self.event_target() + recipient = self.private_api.event_target() if recipient is None: # https://github.com/qutebrowser/qutebrowser/issues/3888 log.webview.warning("Unable to find event target!") @@ -844,22 +944,22 @@ class AbstractTab(QWidget): QApplication.postEvent(recipient, evt) @pyqtSlot(QUrl) - def _on_predicted_navigation(self, url): - """Adjust the title if we are going to visit an URL soon.""" + def _on_before_load_started(self, url: QUrl) -> None: + """Adjust the title if we are going to visit a URL soon.""" qtutils.ensure_valid(url) url_string = url.toDisplayString() - log.webview.debug("Predicted navigation: {}".format(url_string)) + log.webview.debug("Going to start loading: {}".format(url_string)) self.title_changed.emit(url_string) @pyqtSlot(QUrl) - def _on_url_changed(self, url): + def _on_url_changed(self, url: QUrl) -> None: """Update title when URL has changed and no title is available.""" if url.isValid() and not self.title(): self.title_changed.emit(url.toDisplayString()) self.url_changed.emit(url) @pyqtSlot() - def _on_load_started(self): + def _on_load_started(self) -> None: self._progress = 0 self._has_ssl_errors = False self.data.viewing_source = False @@ -867,7 +967,10 @@ class AbstractTab(QWidget): self.load_started.emit() @pyqtSlot(usertypes.NavigationRequest) - def _on_navigation_request(self, navigation): + def _on_navigation_request( + self, + navigation: usertypes.NavigationRequest + ) -> None: """Handle common acceptNavigationRequest code.""" url = utils.elide(navigation.url.toDisplayString(), 100) log.webview.debug("navigation request: url {}, type {}, is_main_frame " @@ -891,28 +994,9 @@ class AbstractTab(QWidget): navigation.url.errorString())) navigation.accepted = False - def handle_auto_insert_mode(self, ok): - """Handle `input.insert_mode.auto_load` after loading finished.""" - if not config.val.input.insert_mode.auto_load or not ok: - return - - cur_mode = self._mode_manager.mode - if cur_mode == usertypes.KeyMode.insert: - return - - def _auto_insert_mode_cb(elem): - """Called from JS after finding the focused element.""" - if elem is None: - log.webview.debug("No focused element!") - return - if elem.is_editable(): - modeman.enter(self.win_id, usertypes.KeyMode.insert, - 'load finished', only_if_normal=True) - - self.elements.find_focused(_auto_insert_mode_cb) - @pyqtSlot(bool) - def _on_load_finished(self, ok): + def _on_load_finished(self, ok: bool) -> None: + assert self._widget is not None if sip.isdeleted(self._widget): # https://github.com/qutebrowser/qutebrowser/issues/3498 return @@ -940,49 +1024,56 @@ class AbstractTab(QWidget): if not self.title(): self.title_changed.emit(self.url().toDisplayString()) - self.zoom.set_current() + self.zoom.reapply() @pyqtSlot() - def _on_history_trigger(self): - """Emit add_history_item when triggered by backend-specific signal.""" + def _on_history_trigger(self) -> None: + """Emit history_item_triggered based on backend-specific signal.""" raise NotImplementedError @pyqtSlot(int) - def _on_load_progress(self, perc): + def _on_load_progress(self, perc: int) -> None: self._progress = perc self.load_progress.emit(perc) - def url(self, requested=False): + def url(self, *, requested: bool = False) -> QUrl: raise NotImplementedError - def progress(self): + def progress(self) -> int: return self._progress - def load_status(self): + def load_status(self) -> usertypes.LoadStatus: return self._load_status - def _openurl_prepare(self, url, *, predict=True): + def _load_url_prepare(self, url: QUrl, *, + emit_before_load_started: bool = True) -> None: qtutils.ensure_valid(url) - if predict: - self.predicted_navigation.emit(url) + if emit_before_load_started: + self.before_load_started.emit(url) - def openurl(self, url, *, predict=True): + def load_url(self, url: QUrl, *, + emit_before_load_started: bool = True) -> None: raise NotImplementedError - def reload(self, *, force=False): + def reload(self, *, force: bool = False) -> None: raise NotImplementedError - def stop(self): + def stop(self) -> None: raise NotImplementedError - def clear_ssl_errors(self): - raise NotImplementedError - - def key_press(self, key, modifier=Qt.NoModifier): + def fake_key_press(self, + key: Qt.Key, + modifier: Qt.KeyboardModifier = Qt.NoModifier) -> None: """Send a fake key event to this tab.""" - raise NotImplementedError + press_evt = QKeyEvent(QEvent.KeyPress, key, modifier, 0, 0, 0) + release_evt = QKeyEvent(QEvent.KeyRelease, key, modifier, + 0, 0, 0) + self.send_event(press_evt) + self.send_event(release_evt) - def dump_async(self, callback, *, plain=False): + def dump_async(self, + callback: typing.Callable[[str], None], *, + plain: bool = False) -> None: """Dump the current page's html asynchronously. The given callback will be called with the result when dumping is @@ -990,7 +1081,12 @@ class AbstractTab(QWidget): """ raise NotImplementedError - def run_js_async(self, code, callback=None, *, world=None): + def run_js_async( + self, + code: str, + callback: typing.Callable[[typing.Any], None] = None, *, + world: typing.Union[usertypes.JsWorld, int] = None + ) -> None: """Run javascript async. The given callback will be called with the result when running JS is @@ -1004,41 +1100,25 @@ class AbstractTab(QWidget): """ raise NotImplementedError - def shutdown(self): + def title(self) -> str: raise NotImplementedError - def title(self): + def icon(self) -> None: raise NotImplementedError - def icon(self): + def set_html(self, html: str, base_url: QUrl = QUrl()) -> None: raise NotImplementedError - def set_html(self, html, base_url=QUrl()): - raise NotImplementedError - - def networkaccessmanager(self): - """Get the QNetworkAccessManager for this tab. - - This is only implemented for QtWebKit. - For QtWebEngine, always returns None. - """ - raise NotImplementedError - - def user_agent(self): - """Get the user agent for this tab. - - This is only implemented for QtWebKit. - For QtWebEngine, always returns None. - """ - raise NotImplementedError - - def __repr__(self): + def __repr__(self) -> str: try: - url = utils.elide(self.url().toDisplayString(QUrl.EncodeUnicode), - 100) + qurl = self.url() + url = qurl.toDisplayString(QUrl.EncodeUnicode) # type: ignore except (AttributeError, RuntimeError) as exc: url = '<{}>'.format(exc.__class__.__name__) + else: + url = utils.elide(url, 100) return utils.get_repr(self, tab_id=self.tab_id, url=url) - def is_deleted(self): + def is_deleted(self) -> bool: + assert self._widget is not None return sip.isdeleted(self._widget) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index ceafbc011..571e06d8b 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -19,7 +19,6 @@ """Command dispatcher for TabbedBrowser.""" -import os import os.path import shlex import functools @@ -27,17 +26,17 @@ import typing from PyQt5.QtWidgets import QApplication, QTabBar from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QEvent, QUrlQuery -from PyQt5.QtPrintSupport import QPrintPreviewDialog -from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners +from qutebrowser.commands import userscripts, runners +from qutebrowser.api import cmdutils from qutebrowser.config import config, configdata from qutebrowser.browser import (urlmarks, browsertab, inspector, navigate, webelem, downloads) from qutebrowser.keyinput import modeman, keyutils from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, - objreg, utils, standarddir) + objreg, utils, standarddir, debug) from qutebrowser.utils.usertypes import KeyMode -from qutebrowser.misc import editor, guiprocess +from qutebrowser.misc import editor, guiprocess, objects from qutebrowser.completion.models import urlmodel, miscmodels from qutebrowser.mainwindow import mainwindow @@ -68,14 +67,14 @@ class CommandDispatcher: """Get a tabbed-browser from a new window.""" args = QApplication.instance().arguments() if private and '--single-process' in args: - raise cmdexc.CommandError("Private windows are unavailable with " - "the single-process process model.") + raise cmdutils.CommandError("Private windows are unavailable with " + "the single-process process model.") new_window = mainwindow.MainWindow(private=private) new_window.show() return new_window.tabbed_browser - def _count(self): + def _count(self) -> int: """Convenience method to get the widget count.""" return self._tabbed_browser.widget.count() @@ -97,7 +96,7 @@ class CommandDispatcher: if e.reason: msg += " ({})".format(e.reason) msg += "!" - raise cmdexc.CommandError(msg) + raise cmdutils.CommandError(msg) def _current_title(self): """Convenience method to get the current title.""" @@ -107,7 +106,7 @@ class CommandDispatcher: """Get the currently active widget from a command.""" widget = self._tabbed_browser.widget.currentWidget() if widget is None: - raise cmdexc.CommandError("No WebView available yet!") + raise cmdutils.CommandError("No WebView available yet!") return widget def _open(self, url, tab=False, background=False, window=False, @@ -126,7 +125,7 @@ class CommandDispatcher: tabbed_browser = self._tabbed_browser cmdutils.check_exclusive((tab, background, window, private), 'tbwp') if window and private is None: - private = self._tabbed_browser.private + private = self._tabbed_browser.is_private if window or private: tabbed_browser = self._new_tabbed_browser(private) @@ -137,7 +136,7 @@ class CommandDispatcher: tabbed_browser.tabopen(url, background=True, related=related) else: widget = self._current_widget() - widget.openurl(url) + widget.load_url(url) def _cntwidget(self, count=None): """Return a widget based on a count/idx. @@ -166,10 +165,10 @@ class CommandDispatcher: except KeyError: if not show_error: return - raise cmdexc.CommandError("No last focused tab!") + raise cmdutils.CommandError("No last focused tab!") idx = self._tabbed_browser.widget.indexOf(tab) if idx == -1: - raise cmdexc.CommandError("Last focused tab vanished!") + raise cmdutils.CommandError("Last focused tab vanished!") self._set_current_index(idx) def _get_selection_override(self, prev, next_, opposite): @@ -197,7 +196,7 @@ class CommandDispatcher: elif conf_selection == QTabBar.SelectRightTab: return QTabBar.SelectLeftTab elif conf_selection == QTabBar.SelectPreviousTab: - raise cmdexc.CommandError( + raise cmdutils.CommandError( "-o is not supported with 'tabs.select_on_remove' set to " "'last-used'!") else: # pragma: no cover @@ -229,7 +228,7 @@ class CommandDispatcher: tabbar.setSelectionBehaviorOnRemove(old_selection_behavior) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def tab_close(self, prev=False, next_=False, opposite=False, force=False, count=None): """Close the current/[count]th tab. @@ -252,7 +251,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', name='tab-pin') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def tab_pin(self, count=None): """Pin/Unpin the current/[count]th tab. @@ -273,7 +272,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', name='open', maxsplit=0, scope='window') @cmdutils.argument('url', completion=urlmodel.url) - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def openurl(self, url=None, related=False, bg=False, tab=False, window=False, count=None, secure=False, private=False): @@ -320,7 +319,7 @@ class CommandDispatcher: elif curtab.data.pinned: message.info("Tab is pinned!") else: - curtab.openurl(cur_url) + curtab.load_url(cur_url) def _parse_url(self, url, *, force_search=False): """Parse a URL or quickmark or search query. @@ -339,7 +338,7 @@ class CommandDispatcher: try: return urlutils.fuzzy_url(url, force_search=force_search) except urlutils.InvalidUrlError as e: - # We don't use cmdexc.CommandError here as this can be + # We don't use cmdutils.CommandError here as this can be # called async from edit_url message.error(str(e)) return None @@ -369,83 +368,6 @@ class CommandDispatcher: if parsed is not None: yield parsed - @cmdutils.register(instance='command-dispatcher', name='reload', - scope='window') - @cmdutils.argument('count', count=True) - def reloadpage(self, force=False, count=None): - """Reload the current/[count]th tab. - - Args: - count: The tab index to reload, or None. - force: Bypass the page cache. - """ - tab = self._cntwidget(count) - if tab is not None: - tab.reload(force=force) - - @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) - def stop(self, count=None): - """Stop loading in the current/[count]th tab. - - Args: - count: The tab index to stop, or None. - """ - tab = self._cntwidget(count) - if tab is not None: - tab.stop() - - def _print_preview(self, tab): - """Show a print preview.""" - def print_callback(ok): - if not ok: - message.error("Printing failed!") - - tab.printing.check_preview_support() - diag = QPrintPreviewDialog(tab) - diag.setAttribute(Qt.WA_DeleteOnClose) - diag.setWindowFlags(diag.windowFlags() | Qt.WindowMaximizeButtonHint | - Qt.WindowMinimizeButtonHint) - diag.paintRequested.connect(functools.partial( - tab.printing.to_printer, callback=print_callback)) - diag.exec_() - - def _print_pdf(self, tab, filename): - """Print to the given PDF file.""" - tab.printing.check_pdf_support() - filename = os.path.expanduser(filename) - directory = os.path.dirname(filename) - if directory and not os.path.exists(directory): - os.mkdir(directory) - tab.printing.to_pdf(filename) - log.misc.debug("Print to file: {}".format(filename)) - - @cmdutils.register(instance='command-dispatcher', name='print', - scope='window') - @cmdutils.argument('count', count=True) - @cmdutils.argument('pdf', flag='f', metavar='file') - def printpage(self, preview=False, count=None, *, pdf=None): - """Print the current/[count]th tab. - - Args: - preview: Show preview instead of printing. - count: The tab index to print, or None. - pdf: The file path to write the PDF to. - """ - tab = self._cntwidget(count) - if tab is None: - return - - try: - if preview: - self._print_preview(tab) - elif pdf: - self._print_pdf(tab, pdf) - else: - tab.printing.show_dialog() - except browsertab.WebTabError as e: - raise cmdexc.CommandError(e) - @cmdutils.register(instance='command-dispatcher', scope='window') def tab_clone(self, bg=False, window=False): """Duplicate the current tab. @@ -462,15 +384,15 @@ class CommandDispatcher: cur_title = self._tabbed_browser.widget.page_title( self._current_index()) try: - history = curtab.history.serialize() + history = curtab.history.private_api.serialize() except browsertab.WebTabError as e: - raise cmdexc.CommandError(e) + raise cmdutils.CommandError(e) # The new tab could be in a new tabbed_browser (e.g. because of # tabs.tabs_are_windows being set) if window: new_tabbed_browser = self._new_tabbed_browser( - private=self._tabbed_browser.private) + private=self._tabbed_browser.is_private) else: new_tabbed_browser = self._tabbed_browser newtab = new_tabbed_browser.tabopen(background=bg) @@ -485,7 +407,7 @@ class CommandDispatcher: new_tabbed_browser.widget.window().setWindowIcon(curtab.icon()) newtab.data.keep_icon = True - newtab.history.deserialize(history) + newtab.history.private_api.deserialize(history) newtab.zoom.set_factor(curtab.zoom.factor()) new_tabbed_browser.widget.set_tab_pinned(newtab, curtab.data.pinned) return newtab @@ -504,7 +426,8 @@ class CommandDispatcher: tabbed_browser, tab = self._resolve_buffer_index(index) if tabbed_browser is self._tabbed_browser: - raise cmdexc.CommandError("Can't take a tab from the same window") + raise cmdutils.CommandError("Can't take a tab from the same " + "window") self._open(tab.url(), tab=True) if not keep: @@ -512,8 +435,9 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('win_id', completion=miscmodels.window) - @cmdutils.argument('count', count=True) - def tab_give(self, win_id: int = None, keep=False, count=None): + @cmdutils.argument('count', value=cmdutils.Value.count) + def tab_give(self, win_id: int = None, keep: bool = False, + count: int = None) -> None: """Give the current tab to a new or existing window if win_id given. If no win_id is given, the tab will get detached into a new window. @@ -527,18 +451,18 @@ class CommandDispatcher: win_id = count - 1 if win_id == self._win_id: - raise cmdexc.CommandError("Can't give a tab to the same window") + raise cmdutils.CommandError("Can't give a tab to the same window") if win_id is None: if self._count() < 2 and not keep: - raise cmdexc.CommandError("Cannot detach from a window with " - "only one tab") + raise cmdutils.CommandError("Cannot detach from a window with " + "only one tab") tabbed_browser = self._new_tabbed_browser( - private=self._tabbed_browser.private) + private=self._tabbed_browser.is_private) else: if win_id not in objreg.window_registry: - raise cmdexc.CommandError( + raise cmdutils.CommandError( "There's no window with id {}!".format(win_id)) tabbed_browser = objreg.get('tabbed-browser', scope='window', @@ -554,9 +478,9 @@ class CommandDispatcher: history = self._current_widget().history # Catch common cases before e.g. cloning tab if not forward and not history.can_go_back(): - raise cmdexc.CommandError("At beginning of history.") + raise cmdutils.CommandError("At beginning of history.") elif forward and not history.can_go_forward(): - raise cmdexc.CommandError("At end of history.") + raise cmdutils.CommandError("At end of history.") if tab or bg or window: widget = self.tab_clone(bg, window) @@ -569,10 +493,10 @@ class CommandDispatcher: else: widget.history.back(count) except browsertab.WebTabError as e: - raise cmdexc.CommandError(e) + raise cmdutils.CommandError(e) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def back(self, tab=False, bg=False, window=False, count=1): """Go back in the history of the current tab. @@ -585,7 +509,7 @@ class CommandDispatcher: self._back_forward(tab, bg, window, count, forward=False) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def forward(self, tab=False, bg=False, window=False, count=1): """Go forward in the history of the current tab. @@ -600,8 +524,9 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('where', choices=['prev', 'next', 'up', 'increment', 'decrement']) - @cmdutils.argument('count', count=True) - def navigate(self, where: str, tab=False, bg=False, window=False, count=1): + @cmdutils.argument('count', value=cmdutils.Value.count) + def navigate(self, where: str, tab: bool = False, bg: bool = False, + window: bool = False, count: int = 1) -> None: """Open typical prev/next links or navigate using the URL path. This tries to automatically click on typical _Previous Page_ or @@ -630,9 +555,6 @@ class CommandDispatcher: count: For `increment` and `decrement`, the number to change the URL by. For `up`, the number of levels to go up in the URL. """ - # save the pre-jump position in the special ' mark - self.set_mark("'") - cmdutils.check_exclusive((tab, bg, window), 'tbw') widget = self._current_widget() url = self._current_url() @@ -645,7 +567,7 @@ class CommandDispatcher: inc_or_dec='decrement'), 'increment': functools.partial(navigate.incdec, inc_or_dec='increment'), - } + } # type: typing.Dict[str, typing.Callable] try: if where in ['prev', 'next']: @@ -661,110 +583,17 @@ class CommandDispatcher: raise ValueError("Got called with invalid value {} for " "`where'.".format(where)) except navigate.Error as e: - raise cmdexc.CommandError(e) + raise cmdutils.CommandError(e) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) - def scroll_px(self, dx: int, dy: int, count=1): - """Scroll the current tab by 'count * dx/dy' pixels. - - Args: - dx: How much to scroll in x-direction. - dy: How much to scroll in y-direction. - count: multiplier - """ - dx *= count - dy *= count - cmdutils.check_overflow(dx, 'int') - cmdutils.check_overflow(dy, 'int') - self._current_widget().scroller.delta(dx, dy) - - @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) - def scroll(self, direction: typing.Union[str, int], count=1): - """Scroll the current tab in the given direction. - - Note you can use `:run-with-count` to have a keybinding with a bigger - scroll increment. - - Args: - direction: In which direction to scroll - (up/down/left/right/top/bottom). - count: multiplier - """ - tab = self._current_widget() - funcs = { - 'up': tab.scroller.up, - 'down': tab.scroller.down, - 'left': tab.scroller.left, - 'right': tab.scroller.right, - 'top': tab.scroller.top, - 'bottom': tab.scroller.bottom, - 'page-up': tab.scroller.page_up, - 'page-down': tab.scroller.page_down, - } - try: - func = funcs[direction] - except KeyError: - expected_values = ', '.join(sorted(funcs)) - raise cmdexc.CommandError("Invalid value {!r} for direction - " - "expected one of: {}".format( - direction, expected_values)) - - if direction in ['top', 'bottom']: - func() - else: - func(count=count) - - @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) - @cmdutils.argument('horizontal', flag='x') - def scroll_to_perc(self, perc: float = None, horizontal=False, count=None): - """Scroll to a specific percentage of the page. - - The percentage can be given either as argument or as count. - If no percentage is given, the page is scrolled to the end. - - Args: - perc: Percentage to scroll. - horizontal: Scroll horizontally instead of vertically. - count: Percentage to scroll. - """ - # save the pre-jump position in the special ' mark - self.set_mark("'") - - if perc is None and count is None: - perc = 100 - elif count is not None: - perc = count - - if horizontal: - x = perc - y = None - else: - x = None - y = perc - - self._current_widget().scroller.to_perc(x, y) - - @cmdutils.register(instance='command-dispatcher', scope='window') - def scroll_to_anchor(self, name): - """Scroll to the given anchor in the document. - - Args: - name: The anchor to scroll to. - """ - self._current_widget().scroller.to_anchor(name) - - @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) @cmdutils.argument('top_navigate', metavar='ACTION', choices=('prev', 'decrement')) @cmdutils.argument('bottom_navigate', metavar='ACTION', choices=('next', 'increment')) def scroll_page(self, x: float, y: float, *, top_navigate: str = None, bottom_navigate: str = None, - count=1): + count: int = 1) -> None: """Scroll the frame page-wise. Args: @@ -791,7 +620,7 @@ class CommandDispatcher: try: tab.scroller.delta_page(count * x, count * y) except OverflowError: - raise cmdexc.CommandError( + raise cmdutils.CommandError( "Numeric argument is too large for internal int " "representation.") @@ -885,73 +714,6 @@ class CommandDispatcher: modeman.leave(self._win_id, KeyMode.caret, "yank selected", maybe=True) - @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) - def zoom_in(self, count=1, quiet=False): - """Increase the zoom level for the current tab. - - Args: - count: How many steps to zoom in. - quiet: Don't show a zoom level message. - """ - tab = self._current_widget() - try: - perc = tab.zoom.offset(count) - except ValueError as e: - raise cmdexc.CommandError(e) - if not quiet: - message.info("Zoom level: {}%".format(int(perc)), replace=True) - - @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) - def zoom_out(self, count=1, quiet=False): - """Decrease the zoom level for the current tab. - - Args: - count: How many steps to zoom out. - quiet: Don't show a zoom level message. - """ - tab = self._current_widget() - try: - perc = tab.zoom.offset(-count) - except ValueError as e: - raise cmdexc.CommandError(e) - if not quiet: - message.info("Zoom level: {}%".format(int(perc)), replace=True) - - @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) - def zoom(self, zoom=None, count=None, quiet=False): - """Set the zoom level for the current tab. - - The zoom can be given as argument or as [count]. If neither is - given, the zoom is set to the default zoom. If both are given, - use [count]. - - Args: - zoom: The zoom percentage to set. - count: The zoom percentage to set. - quiet: Don't show a zoom level message. - """ - if zoom is not None: - try: - zoom = int(zoom.rstrip('%')) - except ValueError: - raise cmdexc.CommandError("zoom: Invalid int value {}" - .format(zoom)) - - level = count if count is not None else zoom - if level is None: - level = config.val.zoom.default - tab = self._current_widget() - - try: - tab.zoom.set_factor(float(level) / 100) - except ValueError: - raise cmdexc.CommandError("Can't zoom {}%!".format(level)) - if not quiet: - message.info("Zoom level: {}%".format(int(level)), replace=True) - @cmdutils.register(instance='command-dispatcher', scope='window') def tab_only(self, prev=False, next_=False, force=False): """Close all tabs except for the current one. @@ -997,10 +759,10 @@ class CommandDispatcher: try: self._tabbed_browser.undo() except IndexError: - raise cmdexc.CommandError("Nothing to undo!") + raise cmdutils.CommandError("Nothing to undo!") @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def tab_prev(self, count=1): """Switch to the previous tab, or switch [count] tabs back. @@ -1020,7 +782,7 @@ class CommandDispatcher: log.webview.debug("First tab") @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def tab_next(self, count=1): """Switch to the next tab, or switch [count] tabs forward. @@ -1058,7 +820,7 @@ class CommandDispatcher: index = model.data(model.first_item()) index_parts = index.split('/', 1) else: - raise cmdexc.CommandError( + raise cmdutils.CommandError( "No matching tab for: {}".format(index)) if len(index_parts) == 2: @@ -1069,18 +831,18 @@ class CommandDispatcher: active_win = objreg.get('app').activeWindow() if active_win is None: # Not sure how you enter a command without an active window... - raise cmdexc.CommandError( + raise cmdutils.CommandError( "No window specified and couldn't find active window!") win_id = active_win.win_id if win_id not in objreg.window_registry: - raise cmdexc.CommandError( + raise cmdutils.CommandError( "There's no window with id {}!".format(win_id)) tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) if not 0 < idx <= tabbed_browser.widget.count(): - raise cmdexc.CommandError( + raise cmdutils.CommandError( "There's no tab with index {}!".format(idx)) return (tabbed_browser, tabbed_browser.widget.widget(idx-1)) @@ -1088,7 +850,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) @cmdutils.argument('index', completion=miscmodels.buffer) - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def buffer(self, index=None, count=None): """Select tab by index or url/title best match. @@ -1118,9 +880,9 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('index', choices=['last']) - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def tab_focus(self, index: typing.Union[str, int] = None, - count=None, no_last=False): + count: int = None, no_last: bool = False) -> None: """Select the tab given as argument/[count]. If neither count nor index are given, it behaves like tab-next. @@ -1143,6 +905,8 @@ class CommandDispatcher: self.tab_next() return + assert isinstance(index, int) + if index < 0: index = self._count() + index + 1 @@ -1153,13 +917,14 @@ class CommandDispatcher: if 1 <= index <= self._count(): self._set_current_index(index - 1) else: - raise cmdexc.CommandError("There's no tab with index {}!".format( + raise cmdutils.CommandError("There's no tab with index {}!".format( index)) @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('index', choices=['+', '-']) - @cmdutils.argument('count', count=True) - def tab_move(self, index: typing.Union[str, int] = None, count=None): + @cmdutils.argument('count', value=cmdutils.Value.count) + def tab_move(self, index: typing.Union[str, int] = None, + count: int = None) -> None: """Move the current tab according to the argument and [count]. If neither is given, move it to the first position. @@ -1188,13 +953,14 @@ class CommandDispatcher: if count is not None: new_idx = count - 1 elif index is not None: + assert isinstance(index, int) new_idx = index - 1 if index >= 0 else index + self._count() else: new_idx = 0 if not 0 <= new_idx < self._count(): - raise cmdexc.CommandError("Can't move tab to position {}!".format( - new_idx + 1)) + raise cmdutils.CommandError("Can't move tab to position {}!" + .format(new_idx + 1)) cur_idx = self._current_index() cmdutils.check_overflow(cur_idx, 'int') @@ -1203,7 +969,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0, no_replace_variables=True) - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def spawn(self, cmdline, userscript=False, verbose=False, output=False, detach=False, count=None): """Spawn a command in a shell. @@ -1225,8 +991,8 @@ class CommandDispatcher: try: cmd, *args = shlex.split(cmdline) except ValueError as e: - raise cmdexc.CommandError("Error while splitting command: " - "{}".format(e)) + raise cmdutils.CommandError("Error while splitting command: " + "{}".format(e)) args = runners.replace_variables(self._win_id, args) @@ -1238,14 +1004,14 @@ class CommandDispatcher: if output: tb = objreg.get('tabbed-browser', scope='window', window='last-focused') - tb.openurl(QUrl('qute://spawn-output'), newtab=True) + tb.load_url(QUrl('qute://spawn-output'), newtab=True) if userscript: def _selection_callback(s): try: runner = self._run_userscript(s, cmd, args, verbose, count) runner.finished.connect(_on_proc_finished) - except cmdexc.CommandError as e: + except cmdutils.CommandError as e: message.error(str(e)) # ~ expansion is handled by the userscript module. @@ -1265,11 +1031,6 @@ class CommandDispatcher: proc.start(cmd, args) proc.finished.connect(_on_proc_finished) - @cmdutils.register(instance='command-dispatcher', scope='window') - def home(self): - """Open main startpage in current tab.""" - self.openurl(config.val.url.start_pages[0]) - def _run_userscript(self, selection, cmd, args, verbose, count): """Run a userscript given as argument. @@ -1305,7 +1066,7 @@ class CommandDispatcher: runner = userscripts.run_async( tab, cmd, *args, win_id=self._win_id, env=env, verbose=verbose) except userscripts.Error as e: - raise cmdexc.CommandError(e) + raise cmdutils.CommandError(e) return runner @cmdutils.register(instance='command-dispatcher', scope='window') @@ -1329,7 +1090,7 @@ class CommandDispatcher: try: url = objreg.get('quickmark-manager').get(name) except urlmarks.Error as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) self._open(url, tab, bg, window) @cmdutils.register(instance='command-dispatcher', scope='window', @@ -1349,11 +1110,12 @@ class CommandDispatcher: try: name = quickmark_manager.get_by_qurl(url) except urlmarks.DoesNotExistError as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) try: quickmark_manager.delete(name) except KeyError: - raise cmdexc.CommandError("Quickmark '{}' not found!".format(name)) + raise cmdutils.CommandError("Quickmark '{}' not found!" + .format(name)) @cmdutils.register(instance='command-dispatcher', scope='window') def bookmark_add(self, url=None, title=None, toggle=False): @@ -1375,8 +1137,8 @@ class CommandDispatcher: already exists. """ if url and not title: - raise cmdexc.CommandError('Title must be provided if url has ' - 'been provided') + raise cmdutils.CommandError('Title must be provided if url has ' + 'been provided') bookmark_manager = objreg.get('bookmark-manager') if not url: url = self._current_url() @@ -1384,13 +1146,13 @@ class CommandDispatcher: try: url = urlutils.fuzzy_url(url) except urlutils.InvalidUrlError as e: - raise cmdexc.CommandError(e) + raise cmdutils.CommandError(e) if not title: title = self._current_title() try: was_added = bookmark_manager.add(url, title, toggle=toggle) except urlmarks.Error as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) else: msg = "Bookmarked {}" if was_added else "Removed bookmark {}" message.info(msg.format(url.toDisplayString())) @@ -1412,7 +1174,7 @@ class CommandDispatcher: try: qurl = urlutils.fuzzy_url(url) except urlutils.InvalidUrlError as e: - raise cmdexc.CommandError(e) + raise cmdutils.CommandError(e) self._open(qurl, tab, bg, window) if delete: self.bookmark_del(url) @@ -1433,19 +1195,7 @@ class CommandDispatcher: try: objreg.get('bookmark-manager').delete(url) except KeyError: - raise cmdexc.CommandError("Bookmark '{}' not found!".format(url)) - - @cmdutils.register(instance='command-dispatcher', scope='window') - def follow_selected(self, *, tab=False): - """Follow the selected text. - - Args: - tab: Load the selected link in a new tab. - """ - try: - self._current_widget().caret.follow_selected(tab=tab) - except browsertab.WebTabError as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError("Bookmark '{}' not found!".format(url)) @cmdutils.register(instance='command-dispatcher', name='inspector', scope='window') @@ -1467,7 +1217,7 @@ class CommandDispatcher: else: tab.data.inspector.toggle(page) except inspector.WebInspectorError as e: - raise cmdexc.CommandError(e) + raise cmdutils.CommandError(e) @cmdutils.register(instance='command-dispatcher', scope='window') def download(self, url=None, *, mhtml_=False, dest=None): @@ -1484,16 +1234,16 @@ class CommandDispatcher: if dest is not None: dest = downloads.transform_path(dest) if dest is None: - raise cmdexc.CommandError("Invalid target filename") + raise cmdutils.CommandError("Invalid target filename") target = downloads.FileDownloadTarget(dest) tab = self._current_widget() - user_agent = tab.user_agent() + user_agent = tab.private_api.user_agent() if url: if mhtml_: - raise cmdexc.CommandError("Can only download the current page" - " as mhtml.") + raise cmdutils.CommandError("Can only download the current " + "page as mhtml.") url = urlutils.qurl_from_user_input(url) urlutils.raise_cmdexc_if_invalid(url) download_manager.get(url, user_agent=user_agent, target=target) @@ -1505,11 +1255,11 @@ class CommandDispatcher: try: webengine_download_manager.get_mhtml(tab, target) except browsertab.UnsupportedOperationError as e: - raise cmdexc.CommandError(e) + raise cmdutils.CommandError(e) else: download_manager.get_mhtml(tab, target) else: - qnam = tab.networkaccessmanager() + qnam = tab.private_api.networkaccessmanager() suggested_fn = downloads.suggested_fn_from_title( self._current_url().path(), tab.title() @@ -1537,12 +1287,12 @@ class CommandDispatcher: tab = self._current_widget() try: current_url = self._current_url() - except cmdexc.CommandError as e: + except cmdutils.CommandError as e: message.error(str(e)) return if current_url.scheme() == 'view-source' or tab.data.viewing_source: - raise cmdexc.CommandError("Already viewing source!") + raise cmdutils.CommandError("Already viewing source!") if edit: ed = editor.ExternalEditor(self._tabbed_browser) @@ -1550,30 +1300,6 @@ class CommandDispatcher: else: tab.action.show_source(pygments) - @cmdutils.register(instance='command-dispatcher', scope='window', - debug=True) - def debug_dump_page(self, dest, plain=False): - """Dump the current page's content to a file. - - Args: - dest: Where to write the file to. - plain: Write plain text instead of HTML. - """ - tab = self._current_widget() - dest = os.path.expanduser(dest) - - def callback(data): - """Write the data to disk.""" - try: - with open(dest, 'w', encoding='utf-8') as f: - f.write(data) - except OSError as e: - message.error('Could not write page: {}'.format(e)) - else: - message.info("Dumped page to {}.".format(dest)) - - tab.dump_async(callback, plain=plain) - @cmdutils.register(instance='command-dispatcher', scope='window') def history(self, tab=True, bg=False, window=False): """Show browsing history. @@ -1605,14 +1331,14 @@ class CommandDispatcher: path = 'index.html' elif topic.startswith(':'): command = topic[1:] - if command not in cmdutils.cmd_dict: - raise cmdexc.CommandError("Invalid command {}!".format( + if command not in objects.commands: + raise cmdutils.CommandError("Invalid command {}!".format( command)) path = 'commands.html#{}'.format(command) elif topic in configdata.DATA: path = 'settings.html#{}'.format(topic) else: - raise cmdexc.CommandError("Invalid help topic {}!".format(topic)) + raise cmdutils.CommandError("Invalid help topic {}!".format(topic)) url = QUrl('qute://help/{}'.format(path)) self._open(url, tab, bg, window) @@ -1630,7 +1356,7 @@ class CommandDispatcher: window: Open in a new window. """ if level.upper() not in log.LOG_LEVELS: - raise cmdexc.CommandError("Invalid log level {}!".format(level)) + raise cmdutils.CommandError("Invalid log level {}!".format(level)) if plain: url = QUrl('qute://plainlog?level={}'.format(level)) else: @@ -1691,75 +1417,6 @@ class CommandDispatcher: message.error(str(e)) ed.backup() - @cmdutils.register(instance='command-dispatcher', maxsplit=0, - scope='window') - def insert_text(self, text): - """Insert text at cursor position. - - Args: - text: The text to insert. - """ - tab = self._current_widget() - - def _insert_text_cb(elem): - if elem is None: - message.error("No element focused!") - return - try: - elem.insert_text(text) - except webelem.Error as e: - message.error(str(e)) - return - - tab.elements.find_focused(_insert_text_cb) - - @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('filter_', choices=['id']) - def click_element(self, filter_: str, value, *, - target: usertypes.ClickTarget = - usertypes.ClickTarget.normal, - force_event=False): - """Click the element matching the given filter. - - The given filter needs to result in exactly one element, otherwise, an - error is shown. - - Args: - filter_: How to filter the elements. - id: Get an element based on its ID. - value: The value to filter for. - target: How to open the clicked element (normal/tab/tab-bg/window). - force_event: Force generating a fake click event. - """ - tab = self._current_widget() - - def single_cb(elem): - """Click a single element.""" - if elem is None: - message.error("No element found with id {}!".format(value)) - return - try: - elem.click(target, force_event=force_event) - except webelem.Error as e: - message.error(str(e)) - return - - # def multiple_cb(elems): - # """Click multiple elements (with only one expected).""" - # if not elems: - # message.error("No element found!") - # return - # elif len(elems) != 1: - # message.error("{} elements found!".format(len(elems))) - # return - # elems[0].click(target) - - handlers = { - 'id': (tab.elements.find_id, single_cb), - } - handler, callback = handlers[filter_] - handler(value, callback) - def _search_cb(self, found, *, tab, old_scroll_pos, options, text, prev): """Callback called from search/search_next/search_prev. @@ -1796,7 +1453,6 @@ class CommandDispatcher: text: The text to search for. reverse: Reverse search direction. """ - self.set_mark("'") tab = self._current_widget() if not text: @@ -1817,10 +1473,11 @@ class CommandDispatcher: options=options, text=text, prev=False) options['result_cb'] = cb + tab.scroller.before_jump_requested.emit() tab.search.search(text, **options) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def search_next(self, count=1): """Continue the search to the ([count]th) next term. @@ -1832,9 +1489,9 @@ class CommandDispatcher: window_options = self._tabbed_browser.search_options if window_text is None: - raise cmdexc.CommandError("No search done yet.") + raise cmdutils.CommandError("No search done yet.") - self.set_mark("'") + tab.scroller.before_jump_requested.emit() if window_text is not None and window_text != tab.search.text: tab.search.clear() @@ -1854,7 +1511,7 @@ class CommandDispatcher: tab.search.next_result(result_cb=cb) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def search_prev(self, count=1): """Continue the search to the ([count]th) previous term. @@ -1866,9 +1523,9 @@ class CommandDispatcher: window_options = self._tabbed_browser.search_options if window_text is None: - raise cmdexc.CommandError("No search done yet.") + raise cmdutils.CommandError("No search done yet.") - self.set_mark("'") + tab.scroller.before_jump_requested.emit() if window_text is not None and window_text != tab.search.text: tab.search.clear() @@ -1887,188 +1544,10 @@ class CommandDispatcher: tab.search.prev_result() tab.search.prev_result(result_cb=cb) - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - @cmdutils.argument('count', count=True) - def move_to_next_line(self, count=1): - """Move the cursor or selection to the next line. - - Args: - count: How many lines to move. - """ - self._current_widget().caret.move_to_next_line(count) - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - @cmdutils.argument('count', count=True) - def move_to_prev_line(self, count=1): - """Move the cursor or selection to the prev line. - - Args: - count: How many lines to move. - """ - self._current_widget().caret.move_to_prev_line(count) - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - @cmdutils.argument('count', count=True) - def move_to_next_char(self, count=1): - """Move the cursor or selection to the next char. - - Args: - count: How many lines to move. - """ - self._current_widget().caret.move_to_next_char(count) - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - @cmdutils.argument('count', count=True) - def move_to_prev_char(self, count=1): - """Move the cursor or selection to the previous char. - - Args: - count: How many chars to move. - """ - self._current_widget().caret.move_to_prev_char(count) - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - @cmdutils.argument('count', count=True) - def move_to_end_of_word(self, count=1): - """Move the cursor or selection to the end of the word. - - Args: - count: How many words to move. - """ - self._current_widget().caret.move_to_end_of_word(count) - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - @cmdutils.argument('count', count=True) - def move_to_next_word(self, count=1): - """Move the cursor or selection to the next word. - - Args: - count: How many words to move. - """ - self._current_widget().caret.move_to_next_word(count) - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - @cmdutils.argument('count', count=True) - def move_to_prev_word(self, count=1): - """Move the cursor or selection to the previous word. - - Args: - count: How many words to move. - """ - self._current_widget().caret.move_to_prev_word(count) - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - def move_to_start_of_line(self): - """Move the cursor or selection to the start of the line.""" - self._current_widget().caret.move_to_start_of_line() - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - def move_to_end_of_line(self): - """Move the cursor or selection to the end of line.""" - self._current_widget().caret.move_to_end_of_line() - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - @cmdutils.argument('count', count=True) - def move_to_start_of_next_block(self, count=1): - """Move the cursor or selection to the start of next block. - - Args: - count: How many blocks to move. - """ - self._current_widget().caret.move_to_start_of_next_block(count) - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - @cmdutils.argument('count', count=True) - def move_to_start_of_prev_block(self, count=1): - """Move the cursor or selection to the start of previous block. - - Args: - count: How many blocks to move. - """ - self._current_widget().caret.move_to_start_of_prev_block(count) - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - @cmdutils.argument('count', count=True) - def move_to_end_of_next_block(self, count=1): - """Move the cursor or selection to the end of next block. - - Args: - count: How many blocks to move. - """ - self._current_widget().caret.move_to_end_of_next_block(count) - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - @cmdutils.argument('count', count=True) - def move_to_end_of_prev_block(self, count=1): - """Move the cursor or selection to the end of previous block. - - Args: - count: How many blocks to move. - """ - self._current_widget().caret.move_to_end_of_prev_block(count) - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - def move_to_start_of_document(self): - """Move the cursor or selection to the start of the document.""" - self._current_widget().caret.move_to_start_of_document() - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - def move_to_end_of_document(self): - """Move the cursor or selection to the end of the document.""" - self._current_widget().caret.move_to_end_of_document() - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - def toggle_selection(self): - """Toggle caret selection mode.""" - self._current_widget().caret.toggle_selection() - - @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.caret], - scope='window') - def drop_selection(self): - """Drop selection and keep selection mode enabled.""" - self._current_widget().caret.drop_selection() - - @cmdutils.register(instance='command-dispatcher', scope='window', - debug=True) - @cmdutils.argument('count', count=True) - def debug_webaction(self, action, count=1): - """Execute a webaction. - - Available actions: - http://doc.qt.io/archives/qt-5.5/qwebpage.html#WebAction-enum (WebKit) - http://doc.qt.io/qt-5/qwebenginepage.html#WebAction-enum (WebEngine) - - Args: - action: The action to execute, e.g. MoveToNextChar. - count: How many times to repeat the action. - """ - tab = self._current_widget() - for _ in range(count): - try: - tab.action.run_string(action) - except browsertab.WebTabError as e: - raise cmdexc.CommandError(str(e)) - @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0, no_cmd_split=True) - def jseval(self, js_code, file=False, quiet=False, *, - world: typing.Union[usertypes.JsWorld, int] = None): + def jseval(self, js_code: str, file: bool = False, quiet: bool = False, *, + world: typing.Union[usertypes.JsWorld, int] = None) -> None: """Evaluate a JavaScript string. Args: @@ -2114,13 +1593,13 @@ class CommandDispatcher: with open(path, 'r', encoding='utf-8') as f: js_code = f.read() except OSError as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) widget = self._current_widget() try: widget.run_js_async(js_code, callback=jseval_cb, world=world) except browsertab.WebTabError as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) @cmdutils.register(instance='command-dispatcher', scope='window') def fake_key(self, keystring, global_=False): @@ -2137,7 +1616,7 @@ class CommandDispatcher: try: sequence = keyutils.KeySequence.parse(keystring) except keyutils.KeyParseError as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) for keyinfo in sequence: press_event = keyinfo.to_event(QEvent.KeyPress) @@ -2146,7 +1625,7 @@ class CommandDispatcher: if global_: window = QApplication.focusWindow() if window is None: - raise cmdexc.CommandError("No focused window!") + raise cmdutils.CommandError("No focused window!") QApplication.postEvent(window, press_event) QApplication.postEvent(window, release_event) else: @@ -2158,7 +1637,7 @@ class CommandDispatcher: debug=True, backend=usertypes.Backend.QtWebKit) def debug_clear_ssl_errors(self): """Clear remembered SSL error answers.""" - self._current_widget().clear_ssl_errors() + self._current_widget().private_api.clear_ssl_errors() @cmdutils.register(instance='command-dispatcher', scope='window') def edit_url(self, url=None, bg=False, tab=False, window=False, @@ -2242,21 +1721,10 @@ class CommandDispatcher: return window = self._tabbed_browser.widget.window() + + if not window.isFullScreen(): + window.state_before_fullscreen = window.windowState() window.setWindowState(window.windowState() ^ Qt.WindowFullScreen) - @cmdutils.register(instance='command-dispatcher', scope='window', - name='tab-mute') - @cmdutils.argument('count', count=True) - def tab_mute(self, count=None): - """Mute/Unmute the current/[count]th tab. - - Args: - count: The tab index to mute or unmute, or None - """ - tab = self._cntwidget(count) - if tab is None: - return - try: - tab.audio.toggle_muted(override=True) - except browsertab.WebTabError as e: - raise cmdexc.CommandError(e) + log.misc.debug('state before fullscreen: {}'.format( + debug.qflags_key(Qt, window.state_before_fullscreen))) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 92d846bd8..b18e426d7 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -33,14 +33,18 @@ from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QObject, QModelIndex, QTimer, QAbstractListModel, QUrl) from qutebrowser.browser import pdfjs -from qutebrowser.commands import cmdexc, cmdutils +from qutebrowser.api import cmdutils from qutebrowser.config import config from qutebrowser.utils import (usertypes, standarddir, utils, message, log, qtutils, objreg) from qutebrowser.qt import sip -ModelRole = enum.IntEnum('ModelRole', ['item'], start=Qt.UserRole) +class ModelRole(enum.IntEnum): + + """Custom download model roles.""" + + item = Qt.UserRole # Remember the last used directory @@ -60,8 +64,6 @@ class UnsupportedAttribute: supported with QtWebengine. """ - pass - class UnsupportedOperationError(Exception): @@ -1007,11 +1009,11 @@ class DownloadModel(QAbstractListModel): count: The index of the download """ if not count: - raise cmdexc.CommandError("There's no download!") - raise cmdexc.CommandError("There's no download {}!".format(count)) + raise cmdutils.CommandError("There's no download!") + raise cmdutils.CommandError("There's no download {}!".format(count)) @cmdutils.register(instance='download-model', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def download_cancel(self, all_=False, count=0): """Cancel the last/[count]th download. @@ -1032,12 +1034,12 @@ class DownloadModel(QAbstractListModel): if download.done: if not count: count = len(self) - raise cmdexc.CommandError("Download {} is already done!" - .format(count)) + raise cmdutils.CommandError("Download {} is already done!" + .format(count)) download.cancel() @cmdutils.register(instance='download-model', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def download_delete(self, count=0): """Delete the last/[count]th download from disk. @@ -1051,14 +1053,15 @@ class DownloadModel(QAbstractListModel): if not download.successful: if not count: count = len(self) - raise cmdexc.CommandError("Download {} is not done!".format(count)) + raise cmdutils.CommandError("Download {} is not done!" + .format(count)) download.delete() download.remove() log.downloads.debug("deleted download {}".format(download)) @cmdutils.register(instance='download-model', scope='window', maxsplit=0) - @cmdutils.argument('count', count=True) - def download_open(self, cmdline: str = None, count=0): + @cmdutils.argument('count', value=cmdutils.Value.count) + def download_open(self, cmdline: str = None, count: int = 0) -> None: """Open the last/[count]th download. If no specific command is given, this will use the system's default @@ -1078,11 +1081,12 @@ class DownloadModel(QAbstractListModel): if not download.successful: if not count: count = len(self) - raise cmdexc.CommandError("Download {} is not done!".format(count)) + raise cmdutils.CommandError("Download {} is not done!" + .format(count)) download.open_file(cmdline) @cmdutils.register(instance='download-model', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def download_retry(self, count=0): """Retry the first failed/[count]th download. @@ -1095,12 +1099,12 @@ class DownloadModel(QAbstractListModel): except IndexError: self._raise_no_download(count) if download.successful or not download.done: - raise cmdexc.CommandError("Download {} did not fail!".format( - count)) + raise cmdutils.CommandError("Download {} did not fail!" + .format(count)) else: to_retry = [d for d in self if d.done and not d.successful] if not to_retry: - raise cmdexc.CommandError("No failed downloads!") + raise cmdutils.CommandError("No failed downloads!") else: download = to_retry[0] download.try_retry() @@ -1117,7 +1121,7 @@ class DownloadModel(QAbstractListModel): download.remove() @cmdutils.register(instance='download-model', scope='window') - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def download_remove(self, all_=False, count=0): """Remove the last/[count]th download from the list. @@ -1135,8 +1139,8 @@ class DownloadModel(QAbstractListModel): if not download.done: if not count: count = len(self) - raise cmdexc.CommandError("Download {} is not done!" - .format(count)) + raise cmdutils.CommandError("Download {} is not done!" + .format(count)) download.remove() def running_downloads(self): diff --git a/qutebrowser/browser/downloadview.py b/qutebrowser/browser/downloadview.py index e90e37509..1ea2b6744 100644 --- a/qutebrowser/browser/downloadview.py +++ b/qutebrowser/browser/downloadview.py @@ -75,7 +75,8 @@ class DownloadView(QListView): def __init__(self, win_id, parent=None): super().__init__(parent) - self.setStyle(QStyleFactory.create('Fusion')) + if not utils.is_mac: + self.setStyle(QStyleFactory.create('Fusion')) config.set_register_stylesheet(self) self.setResizeMode(QListView.Adjust) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index a2ae73aab..9314f81c6 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -32,7 +32,7 @@ from PyQt5.QtCore import pyqtSignal, QObject, QUrl from qutebrowser.utils import (log, standarddir, jinja, objreg, utils, javascript, urlmatch, version, usertypes) -from qutebrowser.commands import cmdutils +from qutebrowser.api import cmdutils from qutebrowser.browser import downloads from qutebrowser.misc import objects diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index d8d5a0624..43257d0a8 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -34,7 +34,8 @@ from PyQt5.QtWidgets import QLabel from qutebrowser.config import config, configexc from qutebrowser.keyinput import modeman, modeparsers from qutebrowser.browser import webelem -from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners +from qutebrowser.commands import userscripts, runners +from qutebrowser.api import cmdutils from qutebrowser.utils import usertypes, log, qtutils, message, objreg, utils @@ -217,9 +218,7 @@ class HintActions: if context.target in [Target.normal, Target.current]: # Set the pre-jump mark ', so we can jump back here after following - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=self._win_id) - tabbed_browser.set_mark("'") + context.tab.scroller.before_jump_requested.emit() try: if context.target == Target.hover: @@ -304,8 +303,8 @@ class HintActions: raise HintingError("No suitable link found for this element.") prompt = False if context.rapid else None - qnam = context.tab.networkaccessmanager() - user_agent = context.tab.user_agent() + qnam = context.tab.private_api.networkaccessmanager() + user_agent = context.tab.private_api.user_agent() # FIXME:qtwebengine do this with QtWebEngine downloads? download_manager = objreg.get('qtnetwork-download-manager') @@ -563,12 +562,12 @@ class HintManager(QObject): if target in [Target.userscript, Target.spawn, Target.run, Target.fill]: if not args: - raise cmdexc.CommandError( + raise cmdutils.CommandError( "'args' is required with target userscript/spawn/run/" "fill.") else: if args: - raise cmdexc.CommandError( + raise cmdutils.CommandError( "'args' is only allowed with target userscript/spawn.") def _filter_matches(self, filterstr, elemstr): @@ -596,13 +595,6 @@ class HintManager(QObject): log.hints.debug("In _start_cb without context!") return - if elems is None: - message.error("Unknown error while getting hint elements.") - return - elif isinstance(elems, webelem.Error): - message.error(str(elems)) - return - if not elems: message.error("No elements found.") return @@ -705,7 +697,7 @@ class HintManager(QObject): window=self._win_id) tab = tabbed_browser.widget.currentWidget() if tab is None: - raise cmdexc.CommandError("No WebView available yet!") + raise cmdutils.CommandError("No WebView available yet!") mode_manager = objreg.get('mode-manager', scope='window', window=self._win_id) @@ -722,8 +714,8 @@ class HintManager(QObject): pass else: name = target.name.replace('_', '-') - raise cmdexc.CommandError("Rapid hinting makes no sense with " - "target {}!".format(name)) + raise cmdutils.CommandError("Rapid hinting makes no sense " + "with target {}!".format(name)) self._check_args(target, *args) self._context = HintContext() @@ -736,18 +728,21 @@ class HintManager(QObject): try: self._context.baseurl = tabbed_browser.current_url() except qtutils.QtValueError: - raise cmdexc.CommandError("No URL set for this page yet!") - self._context.args = args + raise cmdutils.CommandError("No URL set for this page yet!") + self._context.args = list(args) self._context.group = group try: selector = webelem.css_selector(self._context.group, self._context.baseurl) except webelem.Error as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) - self._context.tab.elements.find_css(selector, self._start_cb, - only_visible=True) + self._context.tab.elements.find_css( + selector, + callback=self._start_cb, + error_cb=lambda err: message.error(str(err)), + only_visible=True) def _get_hint_mode(self, mode): """Get the hinting mode to use based on a mode argument.""" @@ -758,7 +753,7 @@ class HintManager(QObject): try: opt.typ.to_py(mode) except configexc.ValidationError as e: - raise cmdexc.CommandError("Invalid mode: {}".format(e)) + raise cmdutils.CommandError("Invalid mode: {}".format(e)) return mode def current_mode(self): @@ -960,13 +955,13 @@ class HintManager(QObject): """ if keystring is None: if self._context.to_follow is None: - raise cmdexc.CommandError("No hint to follow") + raise cmdutils.CommandError("No hint to follow") elif select: - raise cmdexc.CommandError("Can't use --select without hint.") + raise cmdutils.CommandError("Can't use --select without hint.") else: keystring = self._context.to_follow elif keystring not in self._context.labels: - raise cmdexc.CommandError("No hint {}!".format(keystring)) + raise cmdutils.CommandError("No hint {}!".format(keystring)) if select: self.handle_partial_key(keystring) diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index f98ec51a5..757a72e41 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -27,7 +27,7 @@ from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal from PyQt5.QtWidgets import QProgressDialog, QApplication from qutebrowser.config import config -from qutebrowser.commands import cmdutils, cmdexc +from qutebrowser.api import cmdutils from qutebrowser.utils import utils, objreg, log, usertypes, message, qtutils from qutebrowser.misc import objects, sql @@ -365,7 +365,8 @@ class WebHistory(sql.SqlTable): f.write('\n'.join(lines)) message.info("Dumped history to {}".format(dest)) except OSError as e: - raise cmdexc.CommandError('Could not write history: {}'.format(e)) + raise cmdutils.CommandError('Could not write history: {}' + .format(e)) def init(parent=None): diff --git a/qutebrowser/browser/inspector.py b/qutebrowser/browser/inspector.py index 9b3fced2b..3334cea4e 100644 --- a/qutebrowser/browser/inspector.py +++ b/qutebrowser/browser/inspector.py @@ -49,8 +49,6 @@ class WebInspectorError(Exception): """Raised when the inspector could not be initialized.""" - pass - class AbstractWebInspector(QWidget): diff --git a/qutebrowser/browser/mouse.py b/qutebrowser/browser/mouse.py index 7c405a57e..a73f28203 100644 --- a/qutebrowser/browser/mouse.py +++ b/qutebrowser/browser/mouse.py @@ -240,7 +240,7 @@ class MouseEventFilter(QObject): evtype = event.type() if evtype not in self._handlers: return False - if obj is not self._tab.event_target(): + if obj is not self._tab.private_api.event_target(): log.mouse.debug("Ignoring {} to {}".format( event.__class__.__name__, obj)) return False diff --git a/qutebrowser/browser/navigate.py b/qutebrowser/browser/navigate.py index baeb7bf5b..240878ed6 100644 --- a/qutebrowser/browser/navigate.py +++ b/qutebrowser/browser/navigate.py @@ -116,13 +116,6 @@ def prevnext(*, browsertab, win_id, baseurl, prev=False, window: True to open in a new window, False for the current one. """ def _prevnext_cb(elems): - if elems is None: - message.error("Unknown error while getting hint elements") - return - elif isinstance(elems, webelem.Error): - message.error(str(elems)) - return - elem = _find_prevnext(prev, elems) word = 'prev' if prev else 'forward' @@ -140,7 +133,7 @@ def prevnext(*, browsertab, win_id, baseurl, prev=False, if window: new_window = mainwindow.MainWindow( - private=cur_tabbed_browser.private) + private=cur_tabbed_browser.is_private) new_window.show() tabbed_browser = objreg.get('tabbed-browser', scope='window', window=new_window.win_id) @@ -148,11 +141,12 @@ def prevnext(*, browsertab, win_id, baseurl, prev=False, elif tab: cur_tabbed_browser.tabopen(url, background=background) else: - browsertab.openurl(url) + browsertab.load_url(url) try: link_selector = webelem.css_selector('links', baseurl) except webelem.Error as e: raise Error(str(e)) - browsertab.elements.find_css(link_selector, _prevnext_cb) + browsertab.elements.find_css(link_selector, callback=_prevnext_cb, + error_cb=lambda err: message.error(str(err))) diff --git a/qutebrowser/browser/network/pac.py b/qutebrowser/browser/network/pac.py index 95ff99390..1c6075945 100644 --- a/qutebrowser/browser/network/pac.py +++ b/qutebrowser/browser/network/pac.py @@ -35,15 +35,11 @@ class ParseProxyError(Exception): """Error while parsing PAC result string.""" - pass - class EvalProxyError(Exception): """Error while evaluating PAC script.""" - pass - def _js_slot(*args): """Wrap a methods as a JavaScript function. diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 0fa9366a6..14c43ad1e 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -37,7 +37,7 @@ try: import secrets except ImportError: # New in Python 3.6 - secrets = None + secrets = None # type: ignore from PyQt5.QtCore import QUrlQuery, QUrl, qVersion @@ -61,36 +61,26 @@ class Error(Exception): """Exception for generic errors on a qute:// page.""" - pass - class NotFoundError(Error): """Raised when the given URL was not found.""" - pass - class SchemeOSError(Error): """Raised when there was an OSError inside a handler.""" - pass - class UrlInvalidError(Error): """Raised when an invalid URL was opened.""" - pass - class RequestDeniedError(Error): """Raised when the request is forbidden.""" - pass - class Redirect(Exception): diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py index eb222cbe8..0bf3301f9 100644 --- a/qutebrowser/browser/shared.py +++ b/qutebrowser/browser/shared.py @@ -262,7 +262,7 @@ def get_tab(win_id, target): elif target == usertypes.ClickTarget.window: tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) - window = mainwindow.MainWindow(private=tabbed_browser.private) + window = mainwindow.MainWindow(private=tabbed_browser.is_private) window.show() win_id = window.win_id bg_tab = False diff --git a/qutebrowser/browser/urlmarks.py b/qutebrowser/browser/urlmarks.py index bb20e2166..4d25dde45 100644 --- a/qutebrowser/browser/urlmarks.py +++ b/qutebrowser/browser/urlmarks.py @@ -35,7 +35,7 @@ from PyQt5.QtCore import pyqtSignal, QUrl, QObject from qutebrowser.utils import (message, usertypes, qtutils, urlutils, standarddir, objreg, log) -from qutebrowser.commands import cmdutils +from qutebrowser.api import cmdutils from qutebrowser.misc import lineparser @@ -43,29 +43,21 @@ class Error(Exception): """Base class for all errors in this module.""" - pass - class InvalidUrlError(Error): """Exception emitted when a URL is invalid.""" - pass - class DoesNotExistError(Error): """Exception emitted when a given URL does not exist.""" - pass - class AlreadyExistsError(Error): """Exception emitted when a given URL does already exist.""" - pass - class UrlMarkManager(QObject): @@ -174,7 +166,7 @@ class QuickmarkManager(UrlMarkManager): url: The url to add as quickmark. name: The name for the new quickmark. """ - # We don't raise cmdexc.CommandError here as this can be called async + # We don't raise cmdutils.CommandError here as this can be called async # via prompt_save. if not name: message.error("Can't set mark with empty name!") diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index e39eb5850..ac46fdcb9 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -19,32 +19,36 @@ """Generic web element related code.""" +import typing import collections.abc -from PyQt5.QtCore import QUrl, Qt, QEvent, QTimer +from PyQt5.QtCore import QUrl, Qt, QEvent, QTimer, QRect, QPoint 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 +MYPY = False +if MYPY: + # pylint: disable=unused-import,useless-suppression + from qutebrowser.browser import browsertab + + +JsValueType = typing.Union[int, float, str, None] class Error(Exception): """Base class for WebElement errors.""" - pass - class OrphanedError(Error): """Raised when a webelement's parent has vanished.""" - pass - -def css_selector(group, url): +def css_selector(group: str, url: QUrl) -> str: """Get a CSS selector for the given group/URL.""" selectors = config.instance.get('hints.selectors', url) if group not in selectors: @@ -58,76 +62,74 @@ def css_selector(group, url): class AbstractWebElement(collections.abc.MutableMapping): - """A wrapper around QtWebKit/QtWebEngine web element. + """A wrapper around QtWebKit/QtWebEngine web element.""" - Attributes: - tab: The tab associated with this element. - """ - - def __init__(self, tab): + def __init__(self, tab: 'browsertab.AbstractTab') -> None: self._tab = tab - def __eq__(self, other): + def __eq__(self, other: object) -> bool: raise NotImplementedError - def __str__(self): + def __str__(self) -> str: raise NotImplementedError - def __getitem__(self, key): + def __getitem__(self, key: str) -> str: raise NotImplementedError - def __setitem__(self, key, val): + def __setitem__(self, key: str, val: str) -> None: raise NotImplementedError - def __delitem__(self, key): + def __delitem__(self, key: str) -> None: raise NotImplementedError - def __iter__(self): + def __iter__(self) -> typing.Iterator[str]: raise NotImplementedError - def __len__(self): + def __len__(self) -> int: raise NotImplementedError - def __repr__(self): + def __repr__(self) -> str: try: html = utils.compact_text(self.outer_xml(), 500) except Error: html = None return utils.get_repr(self, html=html) - def has_frame(self): + def has_frame(self) -> bool: """Check if this element has a valid frame attached.""" raise NotImplementedError - def geometry(self): + def geometry(self) -> QRect: """Get the geometry for this element.""" raise NotImplementedError - def classes(self): + def classes(self) -> typing.List[str]: """Get a list of classes assigned to this element.""" raise NotImplementedError - def tag_name(self): + def tag_name(self) -> str: """Get the tag name of this element. The returned name will always be lower-case. """ raise NotImplementedError - def outer_xml(self): + def outer_xml(self) -> str: """Get the full HTML representation of this element.""" raise NotImplementedError - def value(self): + def value(self) -> JsValueType: """Get the value attribute for this element, or None.""" raise NotImplementedError - def set_value(self, value): + def set_value(self, value: JsValueType) -> None: """Set the element value.""" raise NotImplementedError - def dispatch_event(self, event, bubbles=False, - cancelable=False, composed=False): + def dispatch_event(self, event: str, + bubbles: bool = False, + cancelable: bool = False, + composed: bool = False) -> None: """Dispatch an event to the element. Args: @@ -138,35 +140,25 @@ class AbstractWebElement(collections.abc.MutableMapping): """ raise NotImplementedError - def insert_text(self, text): + def insert_text(self, text: str) -> None: """Insert the given text into the element.""" raise NotImplementedError - def rect_on_view(self, *, elem_geometry=None, no_js=False): + def rect_on_view(self, *, elem_geometry: QRect = None, + no_js: bool = False) -> QRect: """Get the geometry of the element relative to the webview. - Uses the getClientRects() JavaScript method to obtain the collection of - rectangles containing the element and returns the first rectangle which - is large enough (larger than 1px times 1px). If all rectangles returned - by getClientRects() are too small, falls back to elem.rect_on_view(). - - Skipping of small rectangles is due to elements containing other - elements with "display:block" style, see - https://github.com/qutebrowser/qutebrowser/issues/1298 - Args: elem_geometry: The geometry of the element, or None. - Calling QWebElement::geometry is rather expensive so - we want to avoid doing it twice. - no_js: Fall back to the Python implementation + no_js: Fall back to the Python implementation. """ raise NotImplementedError - def is_writable(self): + def is_writable(self) -> bool: """Check whether an element is writable.""" return not ('disabled' in self or 'readonly' in self) - def is_content_editable(self): + def is_content_editable(self) -> bool: """Check if an element has a contenteditable attribute. Args: @@ -181,7 +173,7 @@ class AbstractWebElement(collections.abc.MutableMapping): except KeyError: return False - def _is_editable_object(self): + def _is_editable_object(self) -> bool: """Check if an object-element is editable.""" if 'type' not in self: log.webelem.debug(" without type clicked...") @@ -197,7 +189,7 @@ class AbstractWebElement(collections.abc.MutableMapping): # Image/Audio/... return False - def _is_editable_input(self): + def _is_editable_input(self) -> bool: """Check if an input-element is editable. Return: @@ -214,7 +206,7 @@ class AbstractWebElement(collections.abc.MutableMapping): else: return False - def _is_editable_classes(self): + def _is_editable_classes(self) -> bool: """Check if an element is editable based on its classes. Return: @@ -233,7 +225,7 @@ class AbstractWebElement(collections.abc.MutableMapping): return True return False - def is_editable(self, strict=False): + def is_editable(self, strict: bool = False) -> bool: """Check whether we should switch to insert mode for this element. Args: @@ -264,17 +256,17 @@ class AbstractWebElement(collections.abc.MutableMapping): return self._is_editable_classes() and not strict return False - def is_text_input(self): + def is_text_input(self) -> bool: """Check if this element is some kind of text box.""" roles = ('combobox', 'textbox') tag = self.tag_name() return self.get('role', None) in roles or tag in ['input', 'textarea'] - def remove_blank_target(self): + def remove_blank_target(self) -> None: """Remove target from link.""" raise NotImplementedError - def resolve_url(self, baseurl): + def resolve_url(self, baseurl: QUrl) -> typing.Optional[QUrl]: """Resolve the URL in the element's src/href attribute. Args: @@ -301,16 +293,16 @@ class AbstractWebElement(collections.abc.MutableMapping): qtutils.ensure_valid(url) return url - def is_link(self): + def is_link(self) -> bool: """Return True if this AbstractWebElement is a link.""" href_tags = ['a', 'area', 'link'] return self.tag_name() in href_tags and 'href' in self - def _requires_user_interaction(self): + def _requires_user_interaction(self) -> bool: """Return True if clicking this element needs user interaction.""" raise NotImplementedError - def _mouse_pos(self): + def _mouse_pos(self) -> QPoint: """Get the position to click/hover.""" # Click the center of the largest square fitting into the top/left # corner of the rectangle, this will help if part of the element @@ -326,35 +318,38 @@ class AbstractWebElement(collections.abc.MutableMapping): raise Error("Element position is out of view!") return pos - def _move_text_cursor(self): + def _move_text_cursor(self) -> None: """Move cursor to end after clicking.""" raise NotImplementedError - def _click_fake_event(self, click_target): + def _click_fake_event(self, click_target: usertypes.ClickTarget) -> None: """Send a fake click event to the element.""" pos = self._mouse_pos() log.webelem.debug("Sending fake click to {!r} at position {} with " "target {}".format(self, pos, click_target)) - modifiers = { + target_modifiers = { usertypes.ClickTarget.normal: Qt.NoModifier, usertypes.ClickTarget.window: Qt.AltModifier | Qt.ShiftModifier, usertypes.ClickTarget.tab: Qt.ControlModifier, usertypes.ClickTarget.tab_bg: Qt.ControlModifier, } if config.val.tabs.background: - modifiers[usertypes.ClickTarget.tab] |= Qt.ShiftModifier + target_modifiers[usertypes.ClickTarget.tab] |= Qt.ShiftModifier else: - modifiers[usertypes.ClickTarget.tab_bg] |= Qt.ShiftModifier + target_modifiers[usertypes.ClickTarget.tab_bg] |= Qt.ShiftModifier + + modifiers = typing.cast(Qt.KeyboardModifiers, + target_modifiers[click_target]) events = [ QMouseEvent(QEvent.MouseMove, pos, Qt.NoButton, Qt.NoButton, Qt.NoModifier), QMouseEvent(QEvent.MouseButtonPress, pos, Qt.LeftButton, - Qt.LeftButton, modifiers[click_target]), + Qt.LeftButton, modifiers), QMouseEvent(QEvent.MouseButtonRelease, pos, Qt.LeftButton, - Qt.NoButton, modifiers[click_target]), + Qt.NoButton, modifiers), ] for evt in events: @@ -362,15 +357,15 @@ class AbstractWebElement(collections.abc.MutableMapping): QTimer.singleShot(0, self._move_text_cursor) - def _click_editable(self, click_target): + def _click_editable(self, click_target: usertypes.ClickTarget) -> None: """Fake a click on an editable input field.""" raise NotImplementedError - def _click_js(self, click_target): + def _click_js(self, click_target: usertypes.ClickTarget) -> None: """Fake a click by using the JS .click() method.""" raise NotImplementedError - def _click_href(self, click_target): + def _click_href(self, click_target: usertypes.ClickTarget) -> None: """Fake a click on an element with a href by opening the link.""" baseurl = self._tab.url() url = self.resolve_url(baseurl) @@ -386,13 +381,14 @@ class AbstractWebElement(collections.abc.MutableMapping): background = click_target == usertypes.ClickTarget.tab_bg tabbed_browser.tabopen(url, background=background) elif click_target == usertypes.ClickTarget.window: - window = mainwindow.MainWindow(private=tabbed_browser.private) + window = mainwindow.MainWindow(private=tabbed_browser.is_private) window.show() window.tabbed_browser.tabopen(url) else: raise ValueError("Unknown ClickTarget {}".format(click_target)) - def click(self, click_target, *, force_event=False): + def click(self, click_target: usertypes.ClickTarget, *, + force_event: bool = False) -> None: """Simulate a click on the element. Args: @@ -429,7 +425,7 @@ class AbstractWebElement(collections.abc.MutableMapping): else: raise ValueError("Unknown ClickTarget {}".format(click_target)) - def hover(self): + def hover(self) -> None: """Simulate a mouse hover over the element.""" pos = self._mouse_pos() event = QMouseEvent(QEvent.MouseMove, pos, Qt.NoButton, Qt.NoButton, diff --git a/qutebrowser/browser/webengine/interceptor.py b/qutebrowser/browser/webengine/interceptor.py index 516dd0899..a5b7721b8 100644 --- a/qutebrowser/browser/webengine/interceptor.py +++ b/qutebrowser/browser/webengine/interceptor.py @@ -26,15 +26,15 @@ from PyQt5.QtWebEngineCore import (QWebEngineUrlRequestInterceptor, from qutebrowser.config import config from qutebrowser.browser import shared from qutebrowser.utils import utils, log, debug +from qutebrowser.extensions import interceptors class RequestInterceptor(QWebEngineUrlRequestInterceptor): """Handle ad blocking and custom headers.""" - def __init__(self, host_blocker, args, parent=None): + def __init__(self, args, parent=None): super().__init__(parent) - self._host_blocker = host_blocker self._args = args def install(self, profile): @@ -84,9 +84,10 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor): return # FIXME:qtwebengine only block ads for NavigationTypeOther? - if self._host_blocker.is_blocked(url, first_party): - log.webview.info("Request to {} blocked by host blocker.".format( - url.host())) + request = interceptors.Request(first_party_url=first_party, + request_url=url) + interceptors.run(request) + if request.is_blocked: info.block(True) for header, value in shared.custom_headers(url=url): diff --git a/qutebrowser/browser/webengine/webenginedownloads.py b/qutebrowser/browser/webengine/webenginedownloads.py index 94caa887e..6dde42070 100644 --- a/qutebrowser/browser/webengine/webenginedownloads.py +++ b/qutebrowser/browser/webengine/webenginedownloads.py @@ -117,8 +117,7 @@ class DownloadItem(downloads.AbstractDownloadItem): def _get_open_filename(self): return self._filename - def _set_fileobj(self, fileobj, *, - autoclose=True): # pylint: disable=unused-argument + def _set_fileobj(self, fileobj, *, autoclose=True): raise downloads.UnsupportedOperationError def _set_tempfile(self, fileobj): diff --git a/qutebrowser/browser/webengine/webengineelem.py b/qutebrowser/browser/webengine/webengineelem.py index 4ef20da18..13292b45b 100644 --- a/qutebrowser/browser/webengine/webengineelem.py +++ b/qutebrowser/browser/webengine/webengineelem.py @@ -22,20 +22,27 @@ """QtWebEngine specific part of the web element API.""" +import typing + from PyQt5.QtCore import QRect, Qt, QPoint, QEventLoop from PyQt5.QtGui import QMouseEvent from PyQt5.QtWidgets import QApplication from PyQt5.QtWebEngineWidgets import QWebEngineSettings -from qutebrowser.utils import log, javascript, urlutils +from qutebrowser.utils import log, javascript, urlutils, usertypes from qutebrowser.browser import webelem +MYPY = False +if MYPY: + # pylint: disable=unused-import,useless-suppression + from qutebrowser.browser.webengine import webenginetab class WebEngineElement(webelem.AbstractWebElement): """A web element for QtWebEngine, using JS under the hood.""" - def __init__(self, js_dict, tab): + def __init__(self, js_dict: typing.Dict[str, typing.Any], + tab: 'webenginetab.WebEngineTab') -> None: super().__init__(tab) # Do some sanity checks on the data we get from JS js_dict_types = { @@ -48,7 +55,7 @@ class WebEngineElement(webelem.AbstractWebElement): 'rects': list, 'attributes': dict, 'caret_position': (int, type(None)), - } + } # type: typing.Dict[str, typing.Union[type, typing.Tuple[type,...]]] assert set(js_dict.keys()).issubset(js_dict_types.keys()) for name, typ in js_dict_types.items(): if name in js_dict and not isinstance(js_dict[name], typ): @@ -73,50 +80,51 @@ class WebEngineElement(webelem.AbstractWebElement): self._id = js_dict['id'] self._js_dict = js_dict - def __str__(self): + def __str__(self) -> str: return self._js_dict.get('text', '') - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, WebEngineElement): return NotImplemented return self._id == other._id # pylint: disable=protected-access - def __getitem__(self, key): + def __getitem__(self, key: str) -> str: attrs = self._js_dict['attributes'] return attrs[key] - def __setitem__(self, key, val): + def __setitem__(self, key: str, val: str) -> None: self._js_dict['attributes'][key] = val self._js_call('set_attribute', key, val) - def __delitem__(self, key): + def __delitem__(self, key: str) -> None: log.stub() - def __iter__(self): + def __iter__(self) -> typing.Iterator[str]: return iter(self._js_dict['attributes']) - def __len__(self): + def __len__(self) -> int: return len(self._js_dict['attributes']) - def _js_call(self, name, *args, callback=None): + def _js_call(self, name: str, *args: webelem.JsValueType, + callback: typing.Callable[[typing.Any], None] = None) -> None: """Wrapper to run stuff from webelem.js.""" if self._tab.is_deleted(): raise webelem.OrphanedError("Tab containing element vanished") js_code = javascript.assemble('webelem', name, self._id, *args) self._tab.run_js_async(js_code, callback=callback) - def has_frame(self): + def has_frame(self) -> bool: return True - def geometry(self): + def geometry(self) -> QRect: log.stub() return QRect() - def classes(self): + def classes(self) -> typing.List[str]: """Get a list of classes assigned to this element.""" return self._js_dict['class_name'].split() - def tag_name(self): + def tag_name(self) -> str: """Get the tag name of this element. The returned name will always be lower-case. @@ -125,34 +133,37 @@ class WebEngineElement(webelem.AbstractWebElement): assert isinstance(tag, str), tag return tag.lower() - def outer_xml(self): + def outer_xml(self) -> str: """Get the full HTML representation of this element.""" return self._js_dict['outer_xml'] - def value(self): + def value(self) -> webelem.JsValueType: return self._js_dict.get('value', None) - def set_value(self, value): + def set_value(self, value: webelem.JsValueType) -> None: self._js_call('set_value', value) - def dispatch_event(self, event, bubbles=False, - cancelable=False, composed=False): + def dispatch_event(self, event: str, + bubbles: bool = False, + cancelable: bool = False, + composed: bool = False) -> None: self._js_call('dispatch_event', event, bubbles, cancelable, composed) - def caret_position(self): + def caret_position(self) -> typing.Optional[int]: """Get the text caret position for the current element. If the element is not a text element, None is returned. """ return self._js_dict.get('caret_position', None) - def insert_text(self, text): + def insert_text(self, text: str) -> None: if not self.is_editable(strict=True): raise webelem.Error("Element is not editable!") log.webelem.debug("Inserting text into element {!r}".format(self)) self._js_call('insert_text', text) - def rect_on_view(self, *, elem_geometry=None, no_js=False): + def rect_on_view(self, *, elem_geometry: QRect = None, + no_js: bool = False) -> QRect: """Get the geometry of the element relative to the webview. Skipping of small rectangles is due to elements containing other @@ -193,16 +204,16 @@ class WebEngineElement(webelem.AbstractWebElement): self, rects)) return QRect() - def remove_blank_target(self): + def remove_blank_target(self) -> None: if self._js_dict['attributes'].get('target') == '_blank': self._js_dict['attributes']['target'] = '_top' self._js_call('remove_blank_target') - def _move_text_cursor(self): + def _move_text_cursor(self) -> None: if self.is_text_input() and self.is_editable(): self._js_call('move_cursor_to_end') - def _requires_user_interaction(self): + def _requires_user_interaction(self) -> bool: baseurl = self._tab.url() url = self.resolve_url(baseurl) if url is None: @@ -211,7 +222,7 @@ class WebEngineElement(webelem.AbstractWebElement): return False return url.scheme() not in urlutils.WEBENGINE_SCHEMES - def _click_editable(self, click_target): + def _click_editable(self, click_target: usertypes.ClickTarget) -> None: # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-58515 ev = QMouseEvent(QMouseEvent.MouseButtonPress, QPoint(0, 0), QPoint(0, 0), QPoint(0, 0), Qt.NoButton, Qt.NoButton, @@ -221,10 +232,11 @@ class WebEngineElement(webelem.AbstractWebElement): self._js_call('focus') self._move_text_cursor() - def _click_js(self, _click_target): + def _click_js(self, _click_target: usertypes.ClickTarget) -> None: # FIXME:qtwebengine Have a proper API for this # pylint: disable=protected-access view = self._tab._widget + assert view is not None # pylint: enable=protected-access attribute = QWebEngineSettings.JavascriptCanOpenWindows could_open_windows = view.settings().testAttribute(attribute) @@ -238,8 +250,9 @@ class WebEngineElement(webelem.AbstractWebElement): qapp.processEvents(QEventLoop.ExcludeSocketNotifiers | QEventLoop.ExcludeUserInputEvents) - def reset_setting(_arg): + def reset_setting(_arg: typing.Any) -> None: """Set the JavascriptCanOpenWindows setting to its old value.""" + assert view is not None try: view.settings().setAttribute(attribute, could_open_windows) except RuntimeError: diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index eba9174b1..22380cb1f 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -24,9 +24,9 @@ import functools import re import html as html_utils -from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QEvent, QPoint, QPointF, - QUrl, QTimer, QObject) -from PyQt5.QtGui import QKeyEvent, QIcon +from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QUrl, + QTimer, QObject) +from PyQt5.QtGui import QIcon from PyQt5.QtNetwork import QAuthenticator from PyQt5.QtWidgets import QApplication from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript @@ -60,10 +60,8 @@ def init(): _qute_scheme_handler.install(webenginesettings.private_profile) log.init.debug("Initializing request interceptor...") - host_blocker = objreg.get('host-blocker') args = objreg.get('args') - req_interceptor = interceptor.RequestInterceptor( - host_blocker, args=args, parent=app) + req_interceptor = interceptor.RequestInterceptor(args=args, parent=app) req_interceptor.install(webenginesettings.default_profile) req_interceptor.install(webenginesettings.private_profile) @@ -132,7 +130,7 @@ class WebEnginePrinting(browsertab.AbstractPrinting): """QtWebEngine implementations related to printing.""" def check_pdf_support(self): - return True + pass def check_printer_support(self): if not hasattr(self._widget.page(), 'print'): @@ -205,8 +203,8 @@ class WebEngineSearch(browsertab.AbstractSearch): self._widget.findText(text, flags, wrapped_callback) - def search(self, text, *, ignore_case='never', reverse=False, - result_cb=None): + def search(self, text, *, ignore_case=usertypes.IgnoreCase.never, + reverse=False, result_cb=None): # Don't go to next entry on duplicate search if self.text == text and self.search_displayed: log.webview.debug("Ignoring duplicate search request" @@ -423,7 +421,7 @@ class WebEngineScroller(browsertab.AbstractScroller): def _repeated_key_press(self, key, count=1, modifier=Qt.NoModifier): """Send count fake key presses to this scroller's WebEngineTab.""" for _ in range(min(count, 1000)): - self._tab.key_press(key, modifier) + self._tab.fake_key_press(key, modifier) @pyqtSlot(QPointF) def _update_pos(self, pos): @@ -478,7 +476,7 @@ class WebEngineScroller(browsertab.AbstractScroller): def to_anchor(self, name): url = self._tab.url() url.setFragment(name) - self._tab.openurl(url) + self._tab.load_url(url) def delta(self, x=0, y=0): self._tab.run_js_async(javascript.assemble('window', 'scrollBy', x, y)) @@ -500,10 +498,10 @@ class WebEngineScroller(browsertab.AbstractScroller): self._repeated_key_press(Qt.Key_Right, count) def top(self): - self._tab.key_press(Qt.Key_Home) + self._tab.fake_key_press(Qt.Key_Home) def bottom(self): - self._tab.key_press(Qt.Key_End) + self._tab.fake_key_press(Qt.Key_End) def page_up(self, count=1): self._repeated_key_press(Qt.Key_PageUp, count) @@ -518,25 +516,9 @@ class WebEngineScroller(browsertab.AbstractScroller): return self._at_bottom -class WebEngineHistory(browsertab.AbstractHistory): +class WebEngineHistoryPrivate(browsertab.AbstractHistoryPrivate): - """QtWebEngine implementations related to page history.""" - - def current_idx(self): - return self._history.currentItemIndex() - - def can_go_back(self): - return self._history.canGoBack() - - def can_go_forward(self): - return self._history.canGoForward() - - def _item_at(self, i): - return self._history.itemAt(i) - - def _go_to_item(self, item): - self._tab.predicted_navigation.emit(item.url()) - self._history.goToItem(item) + """History-related methods which are not part of the extension API.""" def serialize(self): if not qtutils.version_check('5.9', compiled=False): @@ -551,11 +533,11 @@ class WebEngineHistory(browsertab.AbstractHistory): return qtutils.serialize(self._history) def deserialize(self, data): - return qtutils.deserialize(data, self._history) + qtutils.deserialize(data, self._history) def load_items(self, items): if items: - self._tab.predicted_navigation.emit(items[-1].url) + self._tab.before_load_started.emit(items[-1].url) stream, _data, cur_data = tabhistory.serialize(items) qtutils.deserialize_stream(stream, self._history) @@ -573,6 +555,37 @@ class WebEngineHistory(browsertab.AbstractHistory): self._tab.load_finished.connect(_on_load_finished) +class WebEngineHistory(browsertab.AbstractHistory): + + """QtWebEngine implementations related to page history.""" + + def __init__(self, tab): + super().__init__(tab) + self.private_api = WebEngineHistoryPrivate(tab) + + def __len__(self): + return len(self._history) + + def __iter__(self): + return iter(self._history.items()) + + def current_idx(self): + return self._history.currentItemIndex() + + def can_go_back(self): + return self._history.canGoBack() + + def can_go_forward(self): + return self._history.canGoForward() + + def _item_at(self, i): + return self._history.itemAt(i) + + def _go_to_item(self, item): + self._tab.before_load_started.emit(item.url()) + self._history.goToItem(item) + + class WebEngineZoom(browsertab.AbstractZoom): """QtWebEngine implementations related to zooming.""" @@ -585,19 +598,20 @@ class WebEngineElements(browsertab.AbstractElements): """QtWebEngine implemementations related to elements on the page.""" - def _js_cb_multiple(self, callback, js_elems): + def _js_cb_multiple(self, callback, error_cb, js_elems): """Handle found elements coming from JS and call the real callback. Args: callback: The callback to call with the found elements. - Called with None if there was an error. + error_cb: The callback to call in case of an error. js_elems: The elements serialized from javascript. """ if js_elems is None: - callback(None) + error_cb(webelem.Error("Unknown error while getting " + "elements")) return elif not js_elems['success']: - callback(webelem.Error(js_elems['error'])) + error_cb(webelem.Error(js_elems['error'])) return elems = [] @@ -624,10 +638,11 @@ class WebEngineElements(browsertab.AbstractElements): elem = webengineelem.WebEngineElement(js_elem, tab=self._tab) callback(elem) - def find_css(self, selector, callback, *, only_visible=False): + def find_css(self, selector, callback, error_cb, *, + only_visible=False): js_code = javascript.assemble('webelem', 'find_css', selector, only_visible) - js_cb = functools.partial(self._js_cb_multiple, callback) + js_cb = functools.partial(self._js_cb_multiple, callback, error_cb) self._tab.run_js_async(js_code, js_cb) def find_id(self, elem_id, callback): @@ -670,8 +685,9 @@ class WebEngineAudio(browsertab.AbstractAudio): self._tab.url_changed.connect(self._on_url_changed) config.instance.changed.connect(self._on_config_changed) - def set_muted(self, muted: bool, override: bool = False): + def set_muted(self, muted: bool, override: bool = False) -> None: self._overridden = override + assert self._widget is not None page = self._widget.page() page.setAudioMuted(muted) @@ -1031,6 +1047,28 @@ class _WebEngineScripts(QObject): page_scripts.insert(new_script) +class WebEngineTabPrivate(browsertab.AbstractTabPrivate): + + """QtWebEngine-related methods which aren't part of the public API.""" + + def networkaccessmanager(self): + return None + + def user_agent(self): + return None + + def clear_ssl_errors(self): + raise browsertab.UnsupportedOperationError + + def event_target(self): + return self._widget.render_widget() + + def shutdown(self): + self._tab.shutting_down.emit() + self._tab.action.exit_fullscreen() + self._widget.shutdown() + + class WebEngineTab(browsertab.AbstractTab): """A QtWebEngine tab in the browser. @@ -1044,8 +1082,7 @@ class WebEngineTab(browsertab.AbstractTab): _load_finished_fake = pyqtSignal(bool) def __init__(self, *, win_id, mode_manager, private, parent=None): - super().__init__(win_id=win_id, mode_manager=mode_manager, - private=private, parent=parent) + super().__init__(win_id=win_id, private=private, parent=parent) widget = webview.WebEngineView(tabdata=self.data, win_id=win_id, private=private) self.history = WebEngineHistory(tab=self) @@ -1058,6 +1095,8 @@ class WebEngineTab(browsertab.AbstractTab): self.elements = WebEngineElements(tab=self) self.action = WebEngineAction(tab=self) self.audio = WebEngineAudio(tab=self, parent=self) + self.private_api = WebEngineTabPrivate(mode_manager=mode_manager, + tab=self) self._permissions = _WebEnginePermissions(tab=self, parent=self) self._scripts = _WebEngineScripts(tab=self, parent=self) # We're assigning settings in _set_widget @@ -1095,21 +1134,23 @@ class WebEngineTab(browsertab.AbstractTab): self.zoom.set_factor(self._saved_zoom) self._saved_zoom = None - def openurl(self, url, *, predict=True): - """Open the given URL in this tab. + def load_url(self, url, *, emit_before_load_started=True): + """Load the given URL in this tab. Arguments: - url: The QUrl to open. - predict: If set to False, predicted_navigation is not emitted. + url: The QUrl to load. + emit_before_load_started: If set to False, before_load_started is + not emitted. """ if sip.isdeleted(self._widget): # https://github.com/qutebrowser/qutebrowser/issues/3896 return self._saved_zoom = self.zoom.factor() - self._openurl_prepare(url, predict=predict) + self._load_url_prepare( + url, emit_before_load_started=emit_before_load_started) self._widget.load(url) - def url(self, requested=False): + def url(self, *, requested=False): page = self._widget.page() if requested: return page.requestedUrl() @@ -1139,11 +1180,6 @@ class WebEngineTab(browsertab.AbstractTab): else: self._widget.page().runJavaScript(code, world_id, callback) - def shutdown(self): - self.shutting_down.emit() - self.action.exit_fullscreen() - self._widget.shutdown() - def reload(self, *, force=False): if force: action = QWebEnginePage.ReloadAndBypassCache @@ -1168,22 +1204,6 @@ class WebEngineTab(browsertab.AbstractTab): # percent encoded content is 2 megabytes minus 30 bytes. self._widget.setHtml(html, base_url) - def networkaccessmanager(self): - return None - - def user_agent(self): - return None - - def clear_ssl_errors(self): - raise browsertab.UnsupportedOperationError - - def key_press(self, key, modifier=Qt.NoModifier): - press_evt = QKeyEvent(QEvent.KeyPress, key, modifier, 0, 0, 0) - release_evt = QKeyEvent(QEvent.KeyRelease, key, modifier, - 0, 0, 0) - self.send_event(press_evt) - self.send_event(release_evt) - def _show_error_page(self, url, error): """Show an error page in the tab.""" log.misc.debug("Showing error page for {}".format(error)) @@ -1220,7 +1240,7 @@ class WebEngineTab(browsertab.AbstractTab): log.misc.debug("Ignoring invalid URL being added to history") return - self.add_history_item.emit(url, requested_url, title) + self.history_item_triggered.emit(url, requested_url, title) @pyqtSlot(QUrl, 'QAuthenticator*', 'QString') def _on_proxy_authentication_required(self, url, authenticator, @@ -1348,9 +1368,9 @@ class WebEngineTab(browsertab.AbstractTab): log.config.debug( "Loading {} again because of config change".format( self._reload_url.toDisplayString())) - QTimer.singleShot(100, functools.partial(self.openurl, - self._reload_url, - predict=False)) + QTimer.singleShot(100, functools.partial( + self.load_url, self._reload_url, + emit_before_load_started=False)) self._reload_url = None if not qtutils.version_check('5.10', compiled=False): @@ -1389,12 +1409,12 @@ class WebEngineTab(browsertab.AbstractTab): self._show_error_page(url, str(error)) @pyqtSlot(QUrl) - def _on_predicted_navigation(self, url): - """If we know we're going to visit an URL soon, change the settings. + def _on_before_load_started(self, url): + """If we know we're going to visit a URL soon, change the settings. This is a WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656 """ - super()._on_predicted_navigation(url) + super()._on_before_load_started(url) if not qtutils.version_check('5.11.1', compiled=False): self.settings.update_for_url(url) @@ -1472,12 +1492,9 @@ class WebEngineTab(browsertab.AbstractTab): page.loadFinished.connect(self._restore_zoom) page.loadFinished.connect(self._on_load_finished) - self.predicted_navigation.connect(self._on_predicted_navigation) + self.before_load_started.connect(self._on_before_load_started) # pylint: disable=protected-access self.audio._connect_signals() self._permissions.connect_signals() self._scripts.connect_signals() - - def event_target(self): - return self._widget.render_widget() diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index b10cc5f9a..e70226f30 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -240,7 +240,7 @@ class WebEnginePage(QWebEnginePage): def acceptNavigationRequest(self, url: QUrl, typ: QWebEnginePage.NavigationType, - is_main_frame: bool): + is_main_frame: bool) -> bool: """Override acceptNavigationRequest to forward it to the tab API.""" type_map = { QWebEnginePage.NavigationTypeLinkClicked: diff --git a/qutebrowser/browser/webkit/mhtml.py b/qutebrowser/browser/webkit/mhtml.py index 9339df9ce..70a22351f 100644 --- a/qutebrowser/browser/webkit/mhtml.py +++ b/qutebrowser/browser/webkit/mhtml.py @@ -39,6 +39,7 @@ from PyQt5.QtCore import QUrl from qutebrowser.browser import downloads from qutebrowser.browser.webkit import webkitelem from qutebrowser.utils import log, objreg, message, usertypes, utils, urlutils +from qutebrowser.extensions import interceptors @attr.s @@ -354,8 +355,9 @@ class _Downloader: # qute, see the comments/discussion on # https://github.com/qutebrowser/qutebrowser/pull/962#discussion_r40256987 # and https://github.com/qutebrowser/qutebrowser/issues/1053 - host_blocker = objreg.get('host-blocker') - if host_blocker.is_blocked(url): + request = interceptors.Request(first_party_url=None, request_url=url) + interceptors.run(request) + if request.is_blocked: log.downloads.debug("Skipping {}, host-blocked".format(url)) # We still need an empty file in the output, QWebView can be pretty # picky about displaying a file correctly when not all assets are @@ -516,7 +518,6 @@ class _NoCloseBytesIO(io.BytesIO): def close(self): """Do nothing.""" - pass def actual_close(self): """Close the stream.""" diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py index 8d2523456..dd3643c87 100644 --- a/qutebrowser/browser/webkit/network/networkmanager.py +++ b/qutebrowser/browser/webkit/network/networkmanager.py @@ -21,6 +21,7 @@ import collections import html +import typing # pylint: disable=unused-import import attr from PyQt5.QtCore import (pyqtSlot, pyqtSignal, QCoreApplication, QUrl, @@ -28,16 +29,23 @@ from PyQt5.QtCore import (pyqtSlot, pyqtSignal, QCoreApplication, QUrl, from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QSslSocket from qutebrowser.config import config + +MYPY = False +if MYPY: + # pylint can't interpret type comments with Python 3.7 + # pylint: disable=unused-import,useless-suppression + from qutebrowser.mainwindow import prompt from qutebrowser.utils import (message, log, usertypes, utils, objreg, urlutils, debug) from qutebrowser.browser import shared +from qutebrowser.extensions import interceptors from qutebrowser.browser.webkit import certificateerror from qutebrowser.browser.webkit.network import (webkitqutescheme, networkreply, filescheme) HOSTBLOCK_ERROR_STRING = '%HOSTBLOCK%' -_proxy_auth_cache = {} +_proxy_auth_cache = {} # type: typing.Dict[ProxyId, prompt.AuthInfo] @attr.s(frozen=True) @@ -295,9 +303,9 @@ class NetworkManager(QNetworkAccessManager): """Called when a proxy needs authentication.""" proxy_id = ProxyId(proxy.type(), proxy.hostName(), proxy.port()) if proxy_id in _proxy_auth_cache: - user, password = _proxy_auth_cache[proxy_id] - authenticator.setUser(user) - authenticator.setPassword(password) + authinfo = _proxy_auth_cache[proxy_id] + authenticator.setUser(authinfo.user) + authenticator.setPassword(authinfo.password) else: msg = '{} says:
{}'.format( html.escape(proxy.hostName()), @@ -398,10 +406,10 @@ class NetworkManager(QNetworkAccessManager): # the webpage shutdown here. current_url = QUrl() - host_blocker = objreg.get('host-blocker') - if host_blocker.is_blocked(req.url(), current_url): - log.webview.info("Request to {} blocked by host blocker.".format( - req.url().host())) + request = interceptors.Request(first_party_url=current_url, + request_url=req.url()) + interceptors.run(request) + if request.is_blocked: return networkreply.ErrorNetworkReply( req, HOSTBLOCK_ERROR_STRING, QNetworkReply.ContentAccessDenied, self) diff --git a/qutebrowser/browser/webkit/network/networkreply.py b/qutebrowser/browser/webkit/network/networkreply.py index dc6bed5ed..c56fe2a9b 100644 --- a/qutebrowser/browser/webkit/network/networkreply.py +++ b/qutebrowser/browser/webkit/network/networkreply.py @@ -67,7 +67,6 @@ class FixedDataNetworkReply(QNetworkReply): @pyqtSlot() def abort(self): """Abort the operation.""" - pass def bytesAvailable(self): """Determine the bytes available for being read. @@ -123,7 +122,6 @@ class ErrorNetworkReply(QNetworkReply): def abort(self): """Do nothing since it's a fake reply.""" - pass def bytesAvailable(self): """We always have 0 bytes available.""" @@ -151,7 +149,6 @@ class RedirectNetworkReply(QNetworkReply): def abort(self): """Called when there's e.g. a redirection limit.""" - pass def readData(self, _maxlen): return bytes() diff --git a/qutebrowser/browser/webkit/webkitelem.py b/qutebrowser/browser/webkit/webkitelem.py index 01a2736dc..af0db295d 100644 --- a/qutebrowser/browser/webkit/webkitelem.py +++ b/qutebrowser/browser/webkit/webkitelem.py @@ -19,26 +19,31 @@ """QtWebKit specific part of the web element API.""" +import typing + from PyQt5.QtCore import QRect from PyQt5.QtWebKit import QWebElement, QWebSettings +from PyQt5.QtWebKitWidgets import QWebFrame from qutebrowser.config import config -from qutebrowser.utils import log, utils, javascript +from qutebrowser.utils import log, utils, javascript, usertypes from qutebrowser.browser import webelem +MYPY = False +if MYPY: + # pylint: disable=unused-import,useless-suppression + from qutebrowser.browser.webkit import webkittab class IsNullError(webelem.Error): """Gets raised by WebKitElement if an element is null.""" - pass - class WebKitElement(webelem.AbstractWebElement): """A wrapper around a QWebElement.""" - def __init__(self, elem, tab): + def __init__(self, elem: QWebElement, tab: 'webkittab.WebKitTab') -> None: super().__init__(tab) if isinstance(elem, self.__class__): raise TypeError("Trying to wrap a wrapper!") @@ -46,90 +51,94 @@ class WebKitElement(webelem.AbstractWebElement): raise IsNullError('{} is a null element!'.format(elem)) self._elem = elem - def __str__(self): + def __str__(self) -> str: self._check_vanished() return self._elem.toPlainText() - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, WebKitElement): return NotImplemented return self._elem == other._elem # pylint: disable=protected-access - def __getitem__(self, key): + def __getitem__(self, key: str) -> str: self._check_vanished() if key not in self: raise KeyError(key) return self._elem.attribute(key) - def __setitem__(self, key, val): + def __setitem__(self, key: str, val: str) -> None: self._check_vanished() self._elem.setAttribute(key, val) - def __delitem__(self, key): + def __delitem__(self, key: str) -> None: self._check_vanished() if key not in self: raise KeyError(key) self._elem.removeAttribute(key) - def __contains__(self, key): + def __contains__(self, key: object) -> bool: + assert isinstance(key, str) self._check_vanished() return self._elem.hasAttribute(key) - def __iter__(self): + def __iter__(self) -> typing.Iterator[str]: self._check_vanished() yield from self._elem.attributeNames() - def __len__(self): + def __len__(self) -> int: self._check_vanished() return len(self._elem.attributeNames()) - def _check_vanished(self): + def _check_vanished(self) -> None: """Raise an exception if the element vanished (is null).""" if self._elem.isNull(): raise IsNullError('Element {} vanished!'.format(self._elem)) - def has_frame(self): + def has_frame(self) -> bool: self._check_vanished() return self._elem.webFrame() is not None - def geometry(self): + def geometry(self) -> QRect: self._check_vanished() return self._elem.geometry() - def classes(self): + def classes(self) -> typing.List[str]: self._check_vanished() return self._elem.classes() - def tag_name(self): + def tag_name(self) -> str: """Get the tag name for the current element.""" self._check_vanished() return self._elem.tagName().lower() - def outer_xml(self): + def outer_xml(self) -> str: """Get the full HTML representation of this element.""" self._check_vanished() return self._elem.toOuterXml() - def value(self): + def value(self) -> webelem.JsValueType: self._check_vanished() val = self._elem.evaluateJavaScript('this.value') assert isinstance(val, (int, float, str, type(None))), val return val - def set_value(self, value): + def set_value(self, value: webelem.JsValueType) -> None: self._check_vanished() if self._tab.is_deleted(): raise webelem.OrphanedError("Tab containing element vanished") if self.is_content_editable(): log.webelem.debug("Filling {!r} via set_text.".format(self)) + assert isinstance(value, str) self._elem.setPlainText(value) else: log.webelem.debug("Filling {!r} via javascript.".format(self)) value = javascript.to_js(value) self._elem.evaluateJavaScript("this.value={}".format(value)) - def dispatch_event(self, event, bubbles=False, - cancelable=False, composed=False): + def dispatch_event(self, event: str, + bubbles: bool = False, + cancelable: bool = False, + composed: bool = False) -> None: self._check_vanished() log.webelem.debug("Firing event on {!r} via javascript.".format(self)) self._elem.evaluateJavaScript( @@ -140,7 +149,7 @@ class WebKitElement(webelem.AbstractWebElement): javascript.to_js(cancelable), javascript.to_js(composed))) - def caret_position(self): + def caret_position(self) -> int: """Get the text caret position for the current element.""" self._check_vanished() pos = self._elem.evaluateJavaScript('this.selectionStart') @@ -148,7 +157,7 @@ class WebKitElement(webelem.AbstractWebElement): return 0 return int(pos) - def insert_text(self, text): + def insert_text(self, text: str) -> None: self._check_vanished() if not self.is_editable(strict=True): raise webelem.Error("Element is not editable!") @@ -160,7 +169,7 @@ class WebKitElement(webelem.AbstractWebElement): this.dispatchEvent(event); """.format(javascript.to_js(text))) - def _parent(self): + def _parent(self) -> typing.Optional['WebKitElement']: """Get the parent element of this element.""" self._check_vanished() elem = self._elem.parent() @@ -168,7 +177,7 @@ class WebKitElement(webelem.AbstractWebElement): return None return WebKitElement(elem, tab=self._tab) - def _rect_on_view_js(self): + def _rect_on_view_js(self) -> typing.Optional[QRect]: """Javascript implementation for rect_on_view.""" # FIXME:qtwebengine maybe we can reuse this? rects = self._elem.evaluateJavaScript("this.getClientRects()") @@ -180,8 +189,8 @@ class WebKitElement(webelem.AbstractWebElement): return None text = utils.compact_text(self._elem.toOuterXml(), 500) - log.webelem.vdebug("Client rectangles of element '{}': {}".format( - text, rects)) + log.webelem.vdebug( # type: ignore + "Client rectangles of element '{}': {}".format(text, rects)) for i in range(int(rects.get("length", 0))): rect = rects[str(i)] @@ -206,7 +215,8 @@ class WebKitElement(webelem.AbstractWebElement): return None - def _rect_on_view_python(self, elem_geometry): + def _rect_on_view_python(self, + elem_geometry: typing.Optional[QRect]) -> QRect: """Python implementation for rect_on_view.""" if elem_geometry is None: geometry = self._elem.geometry() @@ -220,7 +230,8 @@ class WebKitElement(webelem.AbstractWebElement): frame = frame.parentFrame() return rect - def rect_on_view(self, *, elem_geometry=None, no_js=False): + def rect_on_view(self, *, elem_geometry: QRect = None, + no_js: bool = False) -> QRect: """Get the geometry of the element relative to the webview. Uses the getClientRects() JavaScript method to obtain the collection of @@ -250,7 +261,7 @@ class WebKitElement(webelem.AbstractWebElement): # No suitable rects found via JS, try via the QWebElement API return self._rect_on_view_python(elem_geometry) - def _is_visible(self, mainframe): + def _is_visible(self, mainframe: QWebFrame) -> bool: """Check if the given element is visible in the given frame. This is not public API because it can't be implemented easily here with @@ -302,8 +313,8 @@ class WebKitElement(webelem.AbstractWebElement): visible_in_frame = visible_on_screen return all([visible_on_screen, visible_in_frame]) - def remove_blank_target(self): - elem = self + def remove_blank_target(self) -> None: + elem = self # type: typing.Optional[WebKitElement] for _ in range(5): if elem is None: break @@ -313,14 +324,14 @@ class WebKitElement(webelem.AbstractWebElement): break elem = elem._parent() # pylint: disable=protected-access - def _move_text_cursor(self): + def _move_text_cursor(self) -> None: if self.is_text_input() and self.is_editable(): self._tab.caret.move_to_end_of_document() - def _requires_user_interaction(self): + def _requires_user_interaction(self) -> bool: return False - def _click_editable(self, click_target): + def _click_editable(self, click_target: usertypes.ClickTarget) -> None: ok = self._elem.evaluateJavaScript('this.focus(); true;') if ok: self._move_text_cursor() @@ -328,7 +339,7 @@ class WebKitElement(webelem.AbstractWebElement): log.webelem.debug("Failed to focus via JS, falling back to event") self._click_fake_event(click_target) - def _click_js(self, click_target): + def _click_js(self, click_target: usertypes.ClickTarget) -> None: settings = QWebSettings.globalSettings() attribute = QWebSettings.JavascriptCanOpenWindows could_open_windows = settings.testAttribute(attribute) @@ -339,12 +350,12 @@ class WebKitElement(webelem.AbstractWebElement): log.webelem.debug("Failed to click via JS, falling back to event") self._click_fake_event(click_target) - def _click_fake_event(self, click_target): + def _click_fake_event(self, click_target: usertypes.ClickTarget) -> None: self._tab.data.override_target = click_target super()._click_fake_event(click_target) -def get_child_frames(startframe): +def get_child_frames(startframe: QWebFrame) -> typing.List[QWebFrame]: """Get all children recursively of a given QWebFrame. Loosely based on http://blog.nextgenetics.net/?e=64 @@ -358,7 +369,7 @@ def get_child_frames(startframe): results = [] frames = [startframe] while frames: - new_frames = [] + new_frames = [] # type: typing.List[QWebFrame] for frame in frames: results.append(frame) new_frames += frame.childFrames() diff --git a/qutebrowser/browser/webkit/webkithistory.py b/qutebrowser/browser/webkit/webkithistory.py index 2c719acd6..65d9bbb01 100644 --- a/qutebrowser/browser/webkit/webkithistory.py +++ b/qutebrowser/browser/webkit/webkithistory.py @@ -41,7 +41,6 @@ class WebHistoryInterface(QWebHistoryInterface): def addHistoryEntry(self, url_string): """Required for a QWebHistoryInterface impl, obsoleted by add_url.""" - pass @functools.lru_cache(maxsize=32768) def historyContains(self, url_string): diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 2edea1777..c10c2aeec 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -23,9 +23,8 @@ import re import functools import xml.etree.ElementTree -from PyQt5.QtCore import (pyqtSlot, Qt, QEvent, QUrl, QPoint, QTimer, QSizeF, - QSize) -from PyQt5.QtGui import QKeyEvent, QIcon +from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QPoint, QTimer, QSizeF, QSize +from PyQt5.QtGui import QIcon from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame from PyQt5.QtWebKit import QWebSettings from PyQt5.QtPrintSupport import QPrinter @@ -125,8 +124,8 @@ class WebKitSearch(browsertab.AbstractSearch): self._widget.findText('') self._widget.findText('', QWebPage.HighlightAllOccurrences) - def search(self, text, *, ignore_case='never', reverse=False, - result_cb=None): + def search(self, text, *, ignore_case=usertypes.IgnoreCase.never, + reverse=False, result_cb=None): # Don't go to next entry on duplicate search if self.text == text and self.search_displayed: log.webview.debug("Ignoring duplicate search request" @@ -391,7 +390,7 @@ class WebKitCaret(browsertab.AbstractCaret): if tab: self._tab.new_tab_requested.emit(url) else: - self._tab.openurl(url) + self._tab.load_url(url) def follow_selected(self, *, tab=False): try: @@ -474,7 +473,7 @@ class WebKitScroller(browsertab.AbstractScroller): if (getter is not None and frame.scrollBarValue(direction) == getter(direction)): return - self._tab.key_press(key) + self._tab.fake_key_press(key) def up(self, count=1): self._key_press(Qt.Key_Up, count, 'scrollBarMinimum', Qt.Vertical) @@ -509,35 +508,19 @@ class WebKitScroller(browsertab.AbstractScroller): return self.pos_px().y() >= frame.scrollBarMaximum(Qt.Vertical) -class WebKitHistory(browsertab.AbstractHistory): +class WebKitHistoryPrivate(browsertab.AbstractHistoryPrivate): - """QtWebKit implementations related to page history.""" - - def current_idx(self): - return self._history.currentItemIndex() - - def can_go_back(self): - return self._history.canGoBack() - - def can_go_forward(self): - return self._history.canGoForward() - - def _item_at(self, i): - return self._history.itemAt(i) - - def _go_to_item(self, item): - self._tab.predicted_navigation.emit(item.url()) - self._history.goToItem(item) + """History-related methods which are not part of the extension API.""" def serialize(self): return qtutils.serialize(self._history) def deserialize(self, data): - return qtutils.deserialize(data, self._history) + qtutils.deserialize(data, self._history) def load_items(self, items): if items: - self._tab.predicted_navigation.emit(items[-1].url) + self._tab.before_load_started.emit(items[-1].url) stream, _data, user_data = tabhistory.serialize(items) qtutils.deserialize_stream(stream, self._history) @@ -553,11 +536,43 @@ class WebKitHistory(browsertab.AbstractHistory): self._tab.scroller.to_point, cur_data['scroll-pos'])) +class WebKitHistory(browsertab.AbstractHistory): + + """QtWebKit implementations related to page history.""" + + def __init__(self, tab): + super().__init__(tab) + self.private_api = WebKitHistoryPrivate(tab) + + def __len__(self): + return len(self._history) + + def __iter__(self): + return iter(self._history.items()) + + def current_idx(self): + return self._history.currentItemIndex() + + def can_go_back(self): + return self._history.canGoBack() + + def can_go_forward(self): + return self._history.canGoForward() + + def _item_at(self, i): + return self._history.itemAt(i) + + def _go_to_item(self, item): + self._tab.before_load_started.emit(item.url()) + self._history.goToItem(item) + + class WebKitElements(browsertab.AbstractElements): """QtWebKit implemementations related to elements on the page.""" - def find_css(self, selector, callback, *, only_visible=False): + def find_css(self, selector, callback, error_cb, *, only_visible=False): + utils.unused(error_cb) mainframe = self._widget.page().mainFrame() if mainframe is None: raise browsertab.WebTabError("No frame focused!") @@ -586,7 +601,7 @@ class WebKitElements(browsertab.AbstractElements): # Escape non-alphanumeric characters in the selector # https://www.w3.org/TR/CSS2/syndata.html#value-def-identifier elem_id = re.sub(r'[^a-zA-Z0-9_-]', r'\\\g<0>', elem_id) - self.find_css('#' + elem_id, find_id_cb) + self.find_css('#' + elem_id, find_id_cb, error_cb=lambda exc: None) def find_focused(self, callback): frame = self._widget.page().currentFrame() @@ -641,7 +656,7 @@ class WebKitAudio(browsertab.AbstractAudio): """Dummy handling of audio status for QtWebKit.""" - def set_muted(self, muted: bool, override: bool = False): + def set_muted(self, muted: bool, override: bool = False) -> None: raise browsertab.WebTabError('Muting is not supported on QtWebKit!') def is_muted(self): @@ -651,13 +666,33 @@ class WebKitAudio(browsertab.AbstractAudio): return False +class WebKitTabPrivate(browsertab.AbstractTabPrivate): + + """QtWebKit-related methods which aren't part of the public API.""" + + def networkaccessmanager(self): + return self._widget.page().networkAccessManager() + + def user_agent(self): + page = self._widget.page() + return page.userAgentForUrl(self._tab.url()) + + def clear_ssl_errors(self): + self.networkaccessmanager().clear_all_ssl_errors() + + def event_target(self): + return self._widget + + def shutdown(self): + self._widget.shutdown() + + class WebKitTab(browsertab.AbstractTab): """A QtWebKit tab in the browser.""" def __init__(self, *, win_id, mode_manager, private, parent=None): - super().__init__(win_id=win_id, mode_manager=mode_manager, - private=private, parent=parent) + super().__init__(win_id=win_id, private=private, parent=parent) widget = webview.WebView(win_id=win_id, tab_id=self.tab_id, private=private, tab=self) if private: @@ -672,6 +707,8 @@ class WebKitTab(browsertab.AbstractTab): self.elements = WebKitElements(tab=self) self.action = WebKitAction(tab=self) self.audio = WebKitAudio(tab=self, parent=self) + self.private_api = WebKitTabPrivate(mode_manager=mode_manager, + tab=self) # We're assigning settings in _set_widget self.settings = webkitsettings.WebKitSettings(settings=None) self._set_widget(widget) @@ -685,11 +722,12 @@ class WebKitTab(browsertab.AbstractTab): settings = widget.settings() settings.setAttribute(QWebSettings.PrivateBrowsingEnabled, True) - def openurl(self, url, *, predict=True): - self._openurl_prepare(url, predict=predict) - self._widget.openurl(url) + def load_url(self, url, *, emit_before_load_started=True): + self._load_url_prepare( + url, emit_before_load_started=emit_before_load_started) + self._widget.load(url) - def url(self, requested=False): + def url(self, *, requested=False): frame = self._widget.page().mainFrame() if requested: return frame.requestedUrl() @@ -714,9 +752,6 @@ class WebKitTab(browsertab.AbstractTab): def icon(self): return self._widget.icon() - def shutdown(self): - self._widget.shutdown() - def reload(self, *, force=False): if force: action = QWebPage.ReloadAndBypassCache @@ -730,36 +765,20 @@ class WebKitTab(browsertab.AbstractTab): def title(self): return self._widget.title() - def clear_ssl_errors(self): - self.networkaccessmanager().clear_all_ssl_errors() - - def key_press(self, key, modifier=Qt.NoModifier): - press_evt = QKeyEvent(QEvent.KeyPress, key, modifier, 0, 0, 0) - release_evt = QKeyEvent(QEvent.KeyRelease, key, modifier, - 0, 0, 0) - self.send_event(press_evt) - self.send_event(release_evt) - @pyqtSlot() def _on_history_trigger(self): url = self.url() requested_url = self.url(requested=True) - self.add_history_item.emit(url, requested_url, self.title()) + self.history_item_triggered.emit(url, requested_url, self.title()) def set_html(self, html, base_url=QUrl()): self._widget.setHtml(html, base_url) - def networkaccessmanager(self): - return self._widget.page().networkAccessManager() - - def user_agent(self): - page = self._widget.page() - return page.userAgentForUrl(self.url()) - @pyqtSlot() def _on_load_started(self): super()._on_load_started() - self.networkaccessmanager().netrc_used = False + nam = self._widget.page().networkAccessManager() + nam.netrc_used = False # Make sure the icon is cleared when navigating to a page without one. self.icon_changed.emit(QIcon()) @@ -811,7 +830,7 @@ class WebKitTab(browsertab.AbstractTab): if (navigation.navigation_type == navigation.Type.link_clicked and target != usertypes.ClickTarget.normal): tab = shared.get_tab(self.win_id, target) - tab.openurl(navigation.url) + tab.load_url(navigation.url) self.data.open_target = usertypes.ClickTarget.normal navigation.accepted = False @@ -841,6 +860,3 @@ class WebKitTab(browsertab.AbstractTab): frame.contentsSizeChanged.connect(self._on_contents_size_changed) frame.initialLayoutCompleted.connect(self._on_history_trigger) page.navigation_request.connect(self._on_navigation_request) - - def event_target(self): - return self._widget diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index ce985b466..0195ec17f 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -469,7 +469,7 @@ class BrowserPage(QWebPage): def acceptNavigationRequest(self, frame: QWebFrame, request: QNetworkRequest, - typ: QWebPage.NavigationType): + typ: QWebPage.NavigationType) -> bool: """Override acceptNavigationRequest to handle clicked links. Setting linkDelegationPolicy to DelegateAllLinks and using a slot bound diff --git a/qutebrowser/browser/webkit/webview.py b/qutebrowser/browser/webkit/webview.py index 3d56366c3..8921e211c 100644 --- a/qutebrowser/browser/webkit/webview.py +++ b/qutebrowser/browser/webkit/webview.py @@ -118,14 +118,6 @@ class WebView(QWebView): self.stop() self.page().shutdown() - def openurl(self, url): - """Open a URL in the browser. - - Args: - url: The URL to load as QUrl - """ - self.load(url) - def createWindow(self, wintype): """Called by Qt when a page wants to create a new window. diff --git a/qutebrowser/commands/cmdexc.py b/qutebrowser/commands/cmdexc.py index 5d3ac2a89..f342f2436 100644 --- a/qutebrowser/commands/cmdexc.py +++ b/qutebrowser/commands/cmdexc.py @@ -28,26 +28,15 @@ class Error(Exception): """Base class for all cmdexc errors.""" -class CommandError(Error): - - """Raised when a command encounters an error while running.""" - - pass - - class NoSuchCommandError(Error): """Raised when a command wasn't found.""" - pass - class ArgumentTypeError(Error): """Raised when an argument had an invalid type.""" - pass - class PrerequisitesError(Error): @@ -56,5 +45,3 @@ class PrerequisitesError(Error): This is raised for example when we're in the wrong mode while executing the command, or we need javascript enabled but don't have done so. """ - - pass diff --git a/qutebrowser/commands/cmdutils.py b/qutebrowser/commands/cmdutils.py deleted file mode 100644 index f9ce91b8f..000000000 --- a/qutebrowser/commands/cmdutils.py +++ /dev/null @@ -1,147 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2014-2018 Florian Bruhin (The Compiler) -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see . - -"""Contains various command utils and a global command dict. - -Module attributes: - cmd_dict: A mapping from command-strings to command objects. -""" - -import inspect - -from qutebrowser.utils import qtutils, log -from qutebrowser.commands import command, cmdexc - -cmd_dict = {} - - -def check_overflow(arg, ctype): - """Check if the given argument is in bounds for the given type. - - Args: - arg: The argument to check - ctype: The C/Qt type to check as a string. - """ - try: - qtutils.check_overflow(arg, ctype) - except OverflowError: - raise cmdexc.CommandError( - "Numeric argument is too large for internal {} " - "representation.".format(ctype)) - - -def check_exclusive(flags, names): - """Check if only one flag is set with exclusive flags. - - Raise a CommandError if not. - - Args: - flags: An iterable of booleans to check. - names: An iterable of flag names for the error message. - """ - if sum(1 for e in flags if e) > 1: - argstr = '/'.join('-' + e for e in names) - raise cmdexc.CommandError("Only one of {} can be given!".format( - argstr)) - - -class register: # noqa: N801,N806 pylint: disable=invalid-name - - """Decorator to register a new command handler. - - This could also be a function, but as a class (with a "wrong" name) it's - much cleaner to implement. - - Attributes: - _instance: The object from the object registry to be used as "self". - _name: The name (as string) or names (as list) of the command. - _kwargs: The arguments to pass to Command. - """ - - def __init__(self, *, instance=None, name=None, **kwargs): - """Save decorator arguments. - - Gets called on parse-time with the decorator arguments. - - Args: - See class attributes. - """ - self._instance = instance - self._name = name - self._kwargs = kwargs - - def __call__(self, func): - """Register the command before running the function. - - Gets called when a function should be decorated. - - Doesn't actually decorate anything, but creates a Command object and - registers it in the cmd_dict. - - Args: - func: The function to be decorated. - - Return: - The original function (unmodified). - """ - if self._name is None: - name = func.__name__.lower().replace('_', '-') - else: - assert isinstance(self._name, str), self._name - name = self._name - log.commands.vdebug("Registering command {} (from {}:{})".format( - name, func.__module__, func.__qualname__)) - if name in cmd_dict: - raise ValueError("{} is already registered!".format(name)) - cmd = command.Command(name=name, instance=self._instance, - handler=func, **self._kwargs) - cmd_dict[name] = cmd - return func - - -class argument: # noqa: N801,N806 pylint: disable=invalid-name - - """Decorator to customize an argument for @cmdutils.register. - - This could also be a function, but as a class (with a "wrong" name) it's - much cleaner to implement. - - Attributes: - _argname: The name of the argument to handle. - _kwargs: Keyword arguments, valid ArgInfo members - """ - - def __init__(self, argname, **kwargs): - self._argname = argname - self._kwargs = kwargs - - def __call__(self, func): - funcname = func.__name__ - - if self._argname not in inspect.signature(func).parameters: - raise ValueError("{} has no argument {}!".format(funcname, - self._argname)) - if not hasattr(func, 'qute_args'): - func.qute_args = {} - elif func.qute_args is None: - raise ValueError("@cmdutils.argument got called above (after) " - "@cmdutils.register for {}!".format(funcname)) - - func.qute_args[self._argname] = command.ArgInfo(**self._kwargs) - return func diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index 5c5ab1311..46f92772f 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -26,8 +26,9 @@ import typing import attr +from qutebrowser.api import cmdutils from qutebrowser.commands import cmdexc, argparser -from qutebrowser.utils import log, message, docutils, objreg, usertypes +from qutebrowser.utils import log, message, docutils, objreg, usertypes, utils from qutebrowser.utils import debug as debug_utils from qutebrowser.misc import objects @@ -37,18 +38,13 @@ class ArgInfo: """Information about an argument.""" - win_id = attr.ib(False) - count = attr.ib(False) + value = attr.ib(None) hide = attr.ib(False) metavar = attr.ib(None) flag = attr.ib(None) completion = attr.ib(None) choices = attr.ib(None) - def __attrs_post_init__(self): - if self.win_id and self.count: - raise TypeError("Argument marked as both count/win_id!") - class Command: @@ -75,6 +71,10 @@ class Command: _scope: The scope to get _instance for in the object registry. """ + # CommandValue values which need a count + COUNT_COMMAND_VALUES = [usertypes.CommandValue.count, + usertypes.CommandValue.count_tab] + def __init__(self, *, handler, name, instance=None, maxsplit=None, modes=None, not_modes=None, debug=False, deprecated=False, no_cmd_split=False, star_args_optional=False, scope='global', @@ -116,7 +116,6 @@ class Command: self.parser.add_argument('-h', '--help', action=argparser.HelpAction, default=argparser.SUPPRESS, nargs=0, help=argparser.SUPPRESS) - self._check_func() self.opt_args = collections.OrderedDict() self.namespace = None self._count = None @@ -130,6 +129,7 @@ class Command: self._qute_args = getattr(self.handler, 'qute_args', {}) self.handler.qute_args = None + self._check_func() self._inspect_func() def _check_prerequisites(self, win_id): @@ -154,16 +154,21 @@ class Command: def _check_func(self): """Make sure the function parameters don't violate any rules.""" signature = inspect.signature(self.handler) - if 'self' in signature.parameters and self._instance is None: - raise TypeError("{} is a class method, but instance was not " - "given!".format(self.name[0])) + if 'self' in signature.parameters: + if self._instance is None: + raise TypeError("{} is a class method, but instance was not " + "given!".format(self.name)) + arg_info = self.get_arg_info(signature.parameters['self']) + if arg_info.value is not None: + raise TypeError("{}: Can't fill 'self' with value!" + .format(self.name)) elif 'self' not in signature.parameters and self._instance is not None: raise TypeError("{} is not a class method, but instance was " - "given!".format(self.name[0])) + "given!".format(self.name)) elif any(param.kind == inspect.Parameter.VAR_KEYWORD for param in signature.parameters.values()): raise TypeError("{}: functions with varkw arguments are not " - "supported!".format(self.name[0])) + "supported!".format(self.name)) def get_arg_info(self, param): """Get an ArgInfo tuple for the given inspect.Parameter.""" @@ -186,13 +191,18 @@ class Command: True if the parameter is special, False otherwise. """ arg_info = self.get_arg_info(param) - if arg_info.count: + if arg_info.value is None: + return False + elif arg_info.value == usertypes.CommandValue.count: if param.default is inspect.Parameter.empty: raise TypeError("{}: handler has count parameter " "without default!".format(self.name)) return True - elif arg_info.win_id: + elif isinstance(arg_info.value, usertypes.CommandValue): return True + else: + raise TypeError("{}: Invalid value={!r} for argument '{}'!" + .format(self.name, arg_info.value, param.name)) return False def _inspect_func(self): @@ -292,6 +302,8 @@ class Command: name = argparser.arg_name(param.name) arg_info = self.get_arg_info(param) + assert not arg_info.value, name + if arg_info.flag is not None: shortname = arg_info.flag else: @@ -321,75 +333,66 @@ class Command: param: The inspect.Parameter to look at. """ arginfo = self.get_arg_info(param) - if param.annotation is not inspect.Parameter.empty: + if arginfo.value: + # Filled values are passed 1:1 + return None + elif param.kind in [inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD]: + # For *args/**kwargs we only support strings + assert param.annotation in [inspect.Parameter.empty, str], param + return None + elif param.annotation is not inspect.Parameter.empty: return param.annotation elif param.default not in [None, inspect.Parameter.empty]: return type(param.default) - elif arginfo.count or arginfo.win_id or param.kind in [ - inspect.Parameter.VAR_POSITIONAL, - inspect.Parameter.VAR_KEYWORD]: - return None else: return str - def _get_self_arg(self, win_id, param, args): - """Get the self argument for a function call. - - Arguments: - win_id: The window id this command should be executed in. - param: The count parameter. - args: The positional argument list. Gets modified directly. - """ - assert param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - if self._scope == 'global': + def _get_objreg(self, *, win_id, name, scope): + """Get an object from the objreg.""" + if scope == 'global': tab_id = None win_id = None - elif self._scope == 'tab': + elif scope == 'tab': tab_id = 'current' - elif self._scope == 'window': + elif scope == 'window': tab_id = None else: - raise ValueError("Invalid scope {}!".format(self._scope)) - obj = objreg.get(self._instance, scope=self._scope, window=win_id, - tab=tab_id) - args.append(obj) + raise ValueError("Invalid scope {}!".format(scope)) + return objreg.get(name, scope=scope, window=win_id, tab=tab_id) - def _get_count_arg(self, param, args, kwargs): - """Add the count argument to a function call. + def _add_special_arg(self, *, value, param, args, kwargs): + """Add a special argument value to a function call. Arguments: - param: The count parameter. + value: The value to add. + param: The parameter being filled. args: The positional argument list. Gets modified directly. kwargs: The keyword argument dict. Gets modified directly. """ if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: - if self._count is not None: - args.append(self._count) - else: - args.append(param.default) + args.append(value) elif param.kind == inspect.Parameter.KEYWORD_ONLY: - if self._count is not None: - kwargs[param.name] = self._count + kwargs[param.name] = value else: raise TypeError("{}: invalid parameter type {} for argument " "{!r}!".format(self.name, param.kind, param.name)) - def _get_win_id_arg(self, win_id, param, args, kwargs): - """Add the win_id argument to a function call. + def _add_count_tab(self, *, win_id, param, args, kwargs): + """Add the count_tab widget argument.""" + tabbed_browser = self._get_objreg( + win_id=win_id, name='tabbed-browser', scope='window') - Arguments: - win_id: The window ID to add. - param: The count parameter. - args: The positional argument list. Gets modified directly. - kwargs: The keyword argument dict. Gets modified directly. - """ - if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: - args.append(win_id) - elif param.kind == inspect.Parameter.KEYWORD_ONLY: - kwargs[param.name] = win_id + if self._count is None: + tab = tabbed_browser.widget.currentWidget() + elif 1 <= self._count <= tabbed_browser.widget.count(): + cmdutils.check_overflow(self._count + 1, 'int') + tab = tabbed_browser.widget.widget(self._count - 1) else: - raise TypeError("{}: invalid parameter type {} for argument " - "{!r}!".format(self.name, param.kind, param.name)) + tab = None + + self._add_special_arg(value=tab, param=param, args=args, + kwargs=kwargs) def _get_param_value(self, param): """Get the converted value for an inspect.Parameter.""" @@ -428,6 +431,55 @@ class Command: return value + def _handle_special_call_arg(self, *, pos, param, win_id, args, kwargs): + """Check whether the argument is special, and if so, fill it in. + + Args: + pos: The position of the argument. + param: The argparse.Parameter. + win_id: The window ID the command is run in. + args/kwargs: The args/kwargs to fill. + + Return: + True if it was a special arg, False otherwise. + """ + arg_info = self.get_arg_info(param) + if pos == 0 and self._instance is not None: + assert param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + self_value = self._get_objreg(win_id=win_id, name=self._instance, + scope=self._scope) + self._add_special_arg(value=self_value, param=param, + args=args, kwargs=kwargs) + return True + elif arg_info.value == usertypes.CommandValue.count: + if self._count is None: + assert param.default is not inspect.Parameter.empty + value = param.default + else: + value = self._count + self._add_special_arg(value=value, param=param, + args=args, kwargs=kwargs) + return True + elif arg_info.value == usertypes.CommandValue.win_id: + self._add_special_arg(value=win_id, param=param, + args=args, kwargs=kwargs) + return True + elif arg_info.value == usertypes.CommandValue.cur_tab: + tab = self._get_objreg(win_id=win_id, name='tab', scope='tab') + self._add_special_arg(value=tab, param=param, + args=args, kwargs=kwargs) + return True + elif arg_info.value == usertypes.CommandValue.count_tab: + self._add_count_tab(win_id=win_id, param=param, args=args, + kwargs=kwargs) + return True + elif arg_info.value is None: + pass + else: + raise utils.Unreachable(arg_info) + + return False + def _get_call_args(self, win_id): """Get arguments for a function call. @@ -442,20 +494,11 @@ class Command: signature = inspect.signature(self.handler) for i, param in enumerate(signature.parameters.values()): - arg_info = self.get_arg_info(param) - if i == 0 and self._instance is not None: - # Special case for 'self'. - self._get_self_arg(win_id, param, args) - continue - elif arg_info.count: - # Special case for count parameter. - self._get_count_arg(param, args, kwargs) - continue - # elif arg_info.win_id: - elif arg_info.win_id: - # Special case for win_id parameter. - self._get_win_id_arg(win_id, param, args, kwargs) + if self._handle_special_call_arg(pos=i, param=param, + win_id=win_id, args=args, + kwargs=kwargs): continue + value = self._get_param_value(param) if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: args.append(value) @@ -520,4 +563,14 @@ class Command: def takes_count(self): """Return true iff this command can take a count argument.""" - return any(arg.count for arg in self._qute_args) + return any(info.value in self.COUNT_COMMAND_VALUES + for info in self._qute_args.values()) + + def register(self): + """Register this command in objects.commands.""" + log.commands.vdebug( + "Registering command {} (from {}:{})".format( + self.name, self.handler.__module__, self.handler.__qualname__)) + if self.name in objects.commands: + raise ValueError("{} is already registered!".format(self.name)) + objects.commands[self.name] = self diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index c3f5d87a1..000689a75 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -25,10 +25,11 @@ import re import attr from PyQt5.QtCore import pyqtSlot, QUrl, QObject +from qutebrowser.api import cmdutils from qutebrowser.config import config -from qutebrowser.commands import cmdexc, cmdutils +from qutebrowser.commands import cmdexc from qutebrowser.utils import message, objreg, qtutils, usertypes, utils -from qutebrowser.misc import split +from qutebrowser.misc import split, objects last_command = {} @@ -53,11 +54,14 @@ def _current_url(tabbed_browser): if e.reason: msg += " ({})".format(e.reason) msg += "!" - raise cmdexc.CommandError(msg) + raise cmdutils.CommandError(msg) def replace_variables(win_id, arglist): """Utility function to replace variables like {url} in a list of args.""" + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + variables = { 'url': lambda: _current_url(tabbed_browser).toString( QUrl.FullyEncoded | QUrl.RemovePassword), @@ -67,13 +71,13 @@ def replace_variables(win_id, arglist): 'clipboard': utils.get_clipboard, 'primary': lambda: utils.get_clipboard(selection=True), } + for key in list(variables): modified_key = '{' + key + '}' variables[modified_key] = lambda x=modified_key: x + values = {} args = [] - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=win_id) def repl_cb(matchobj): """Return replacement for given match.""" @@ -90,7 +94,7 @@ def replace_variables(win_id, arglist): # "{url}" from clipboard is not expanded) args.append(repl_pattern.sub(repl_cb, arg)) except utils.ClipboardError as e: - raise cmdexc.CommandError(e) + raise cmdutils.CommandError(e) return args @@ -190,7 +194,7 @@ class CommandParser: cmdstr = self._completion_match(cmdstr) try: - cmd = cmdutils.cmd_dict[cmdstr] + cmd = objects.commands[cmdstr] except KeyError: if not fallback: raise cmdexc.NoSuchCommandError( @@ -217,7 +221,7 @@ class CommandParser: Return: cmdstr modified to the matching completion or unmodified """ - matches = [cmd for cmd in sorted(cmdutils.cmd_dict, key=len) + matches = [cmd for cmd in sorted(objects.commands, key=len) if cmdstr in cmd] if len(matches) == 1: cmdstr = matches[0] diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 4cbdc4724..ea64225d5 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -23,7 +23,8 @@ import attr from PyQt5.QtCore import pyqtSlot, QObject, QTimer from qutebrowser.config import config -from qutebrowser.commands import cmdutils, runners +from qutebrowser.commands import runners +from qutebrowser.misc import objects from qutebrowser.utils import log, utils, debug from qutebrowser.completion.models import miscmodels @@ -92,7 +93,7 @@ class Completer(QObject): log.completion.debug('Starting command completion') return miscmodels.command try: - cmd = cmdutils.cmd_dict[before_cursor[0]] + cmd = objects.commands[before_cursor[0]] except KeyError: log.completion.debug("No completion for unknown command: {}" .format(before_cursor[0])) @@ -170,7 +171,7 @@ class Completer(QObject): before, center, after = self._partition() log.completion.debug("Changing {} to '{}'".format(center, text)) try: - maxsplit = cmdutils.cmd_dict[before[0]].maxsplit + maxsplit = objects.commands[before[0]].maxsplit except (KeyError, IndexError): maxsplit = None if maxsplit is None: diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index 740be75d9..0af4ecbe1 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -29,7 +29,7 @@ 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.commands import cmdexc, cmdutils +from qutebrowser.api import cmdutils class CompletionView(QTreeView): @@ -251,8 +251,8 @@ class CompletionView(QTreeView): status.command_history_prev() return else: - raise cmdexc.CommandError("Can't combine --history with " - "{}!".format(which)) + raise cmdutils.CommandError("Can't combine --history with " + "{}!".format(which)) if not self._active: return @@ -394,7 +394,7 @@ class CompletionView(QTreeView): """Delete the current completion item.""" index = self.currentIndex() if not index.isValid(): - raise cmdexc.CommandError("No item selected!") + raise cmdutils.CommandError("No item selected!") self.model().delete_cur_item(index) @cmdutils.register(instance='completion', @@ -411,6 +411,6 @@ class CompletionView(QTreeView): if not text: index = self.currentIndex() if not index.isValid(): - raise cmdexc.CommandError("No item selected!") + raise cmdutils.CommandError("No item selected!") text = self.model().data(index) utils.set_clipboard(text, selection=sel) diff --git a/qutebrowser/completion/models/completionmodel.py b/qutebrowser/completion/models/completionmodel.py index 1c77e1d31..36a465fb7 100644 --- a/qutebrowser/completion/models/completionmodel.py +++ b/qutebrowser/completion/models/completionmodel.py @@ -22,7 +22,7 @@ from PyQt5.QtCore import Qt, QModelIndex, QAbstractItemModel from qutebrowser.utils import log, qtutils -from qutebrowser.commands import cmdexc +from qutebrowser.api import cmdutils class CompletionModel(QAbstractItemModel): @@ -224,7 +224,7 @@ class CompletionModel(QAbstractItemModel): cat = self._cat_from_idx(parent) assert cat, "CompletionView sent invalid index for deletion" if not cat.delete_func: - raise cmdexc.CommandError("Cannot delete this item.") + raise cmdutils.CommandError("Cannot delete this item.") data = [cat.data(cat.index(index.row(), i)) for i in range(cat.columnCount())] diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py index 7c9fc920d..83eafef50 100644 --- a/qutebrowser/completion/models/histcategory.py +++ b/qutebrowser/completion/models/histcategory.py @@ -74,9 +74,10 @@ class HistoryCategory(QSqlQueryModel): # build a where clause to match all of the words in any order # given the search term "a b", the WHERE clause would be: - # ((url || title) LIKE '%a%') AND ((url || title) LIKE '%b%') + # ((url || ' ' || title) LIKE '%a%') AND + # ((url || ' ' || title) LIKE '%b%') where_clause = ' AND '.join( - "(url || title) LIKE :{} escape '\\'".format(i) + "(url || ' ' || title) LIKE :{} escape '\\'".format(i) for i in range(len(words))) # replace ' in timestamp-format to avoid breaking the query diff --git a/qutebrowser/completion/models/util.py b/qutebrowser/completion/models/util.py index c1b8b56f9..08f99eb6c 100644 --- a/qutebrowser/completion/models/util.py +++ b/qutebrowser/completion/models/util.py @@ -20,7 +20,7 @@ """Utility functions for completion models.""" from qutebrowser.utils import objreg, usertypes -from qutebrowser.commands import cmdutils +from qutebrowser.misc import objects def get_cmd_completions(info, include_hidden, include_aliases, prefix=''): @@ -34,10 +34,10 @@ def get_cmd_completions(info, include_hidden, include_aliases, prefix=''): Return: A list of tuples of form (name, description, bindings). """ - assert cmdutils.cmd_dict + assert objects.commands cmdlist = [] cmd_to_keys = info.keyconf.get_reverse_bindings_for('normal') - for obj in set(cmdutils.cmd_dict.values()): + for obj in set(objects.commands.values()): hide_debug = obj.debug and not objreg.get('args').debug hide_mode = (usertypes.KeyMode.normal not in obj.modes and not include_hidden) diff --git a/qutebrowser/components/__init__.py b/qutebrowser/components/__init__.py new file mode 100644 index 000000000..1a13763bf --- /dev/null +++ b/qutebrowser/components/__init__.py @@ -0,0 +1,20 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""qutebrowser "extensions" which only use the qutebrowser.api API.""" diff --git a/qutebrowser/browser/adblock.py b/qutebrowser/components/adblock.py similarity index 65% rename from qutebrowser/browser/adblock.py rename to qutebrowser/components/adblock.py index d2a21639c..9baa12d7c 100644 --- a/qutebrowser/browser/adblock.py +++ b/qutebrowser/components/adblock.py @@ -24,19 +24,22 @@ import os.path import functools import posixpath import zipfile +import logging +import typing +import pathlib -from qutebrowser.browser import downloads -from qutebrowser.config import config -from qutebrowser.utils import objreg, standarddir, log, message -from qutebrowser.commands import cmdutils +from PyQt5.QtCore import QUrl + +from qutebrowser.api import (cmdutils, hook, config, message, downloads, + interceptor, apitypes) -def _guess_zip_filename(zf): - """Guess which file to use inside a zip file. +logger = logging.getLogger('misc') +_host_blocker = typing.cast('HostBlocker', None) - Args: - zf: A ZipFile instance. - """ + +def _guess_zip_filename(zf: zipfile.ZipFile) -> str: + """Guess which file to use inside a zip file.""" files = zf.namelist() if len(files) == 1: return files[0] @@ -47,7 +50,7 @@ def _guess_zip_filename(zf): raise FileNotFoundError("No hosts file found in zip") -def get_fileobj(byte_io): +def get_fileobj(byte_io: typing.IO[bytes]) -> typing.IO[bytes]: """Get a usable file object to read the hosts file from.""" byte_io.seek(0) # rewind downloaded file if zipfile.is_zipfile(byte_io): @@ -60,24 +63,20 @@ def get_fileobj(byte_io): return byte_io -def _is_whitelisted_url(url): - """Check if the given URL is on the adblock whitelist. - - Args: - url: The URL to check as QUrl. - """ +def _is_whitelisted_url(url: QUrl) -> bool: + """Check if the given URL is on the adblock whitelist.""" for pattern in config.val.content.host_blocking.whitelist: if pattern.matches(url): return True return False -class _FakeDownload: +class _FakeDownload(downloads.TempDownload): """A download stub to use on_download_finished with local files.""" - def __init__(self, fileobj): - self.basename = os.path.basename(fileobj.name) + def __init__(self, # pylint: disable=super-init-not-called + fileobj: typing.IO[bytes]) -> None: self.fileobj = fileobj self.successful = True @@ -93,37 +92,46 @@ class HostBlocker: _done_count: How many files have been read successfully. _local_hosts_file: The path to the blocked-hosts file. _config_hosts_file: The path to a blocked-hosts in ~/.config + _has_basedir: Whether a custom --basedir is set. """ - def __init__(self): - self._blocked_hosts = set() - self._config_blocked_hosts = set() - self._in_progress = [] + def __init__(self, *, data_dir: pathlib.Path, config_dir: pathlib.Path, + has_basedir: bool = False) -> None: + self._has_basedir = has_basedir + self._blocked_hosts = set() # type: typing.Set[str] + self._config_blocked_hosts = set() # type: typing.Set[str] + self._in_progress = [] # type: typing.List[downloads.TempDownload] self._done_count = 0 - data_dir = standarddir.data() - self._local_hosts_file = os.path.join(data_dir, 'blocked-hosts') - self._update_files() + self._local_hosts_file = str(data_dir / 'blocked-hosts') + self.update_files() - config_dir = standarddir.config() - self._config_hosts_file = os.path.join(config_dir, 'blocked-hosts') + self._config_hosts_file = str(config_dir / 'blocked-hosts') - config.instance.changed.connect(self._update_files) - - def is_blocked(self, url, first_party_url=None): - """Check if the given URL (as QUrl) is blocked.""" + def _is_blocked(self, request_url: QUrl, + first_party_url: QUrl = None) -> bool: + """Check whether the given request is blocked.""" if first_party_url is not None and not first_party_url.isValid(): first_party_url = None - if not config.instance.get('content.host_blocking.enabled', - url=first_party_url): + + if not config.get('content.host_blocking.enabled', + url=first_party_url): return False - host = url.host() + host = request_url.host() return ((host in self._blocked_hosts or host in self._config_blocked_hosts) and - not _is_whitelisted_url(url)) + not _is_whitelisted_url(request_url)) - def _read_hosts_file(self, filename, target): + def filter_request(self, info: interceptor.Request) -> None: + """Block the given request if necessary.""" + if self._is_blocked(request_url=info.request_url, + first_party_url=info.first_party_url): + logger.info("Request to {} blocked by host blocker." + .format(info.request_url.host())) + info.block() + + def _read_hosts_file(self, filename: str, target: typing.Set[str]) -> bool: """Read hosts from the given filename. Args: @@ -141,11 +149,11 @@ class HostBlocker: for line in f: target.add(line.strip()) except (OSError, UnicodeDecodeError): - log.misc.exception("Failed to read host blocklist!") + logger.exception("Failed to read host blocklist!") return True - def read_hosts(self): + def read_hosts(self) -> None: """Read hosts from the existing blocked-hosts file.""" self._blocked_hosts = set() @@ -156,24 +164,17 @@ class HostBlocker: self._blocked_hosts) if not found: - args = objreg.get('args') if (config.val.content.host_blocking.lists and - args.basedir is None and + not self._has_basedir and config.val.content.host_blocking.enabled): message.info("Run :adblock-update to get adblock lists.") - @cmdutils.register(instance='host-blocker') - def adblock_update(self): - """Update the adblock block lists. - - This updates `~/.local/share/qutebrowser/blocked-hosts` with downloaded - host lists and re-reads `~/.config/qutebrowser/blocked-hosts`. - """ + def adblock_update(self) -> None: + """Update the adblock block lists.""" self._read_hosts_file(self._config_hosts_file, self._config_blocked_hosts) self._blocked_hosts = set() self._done_count = 0 - download_manager = objreg.get('qtnetwork-download-manager') for url in config.val.content.host_blocking.lists: if url.scheme() == 'file': filename = url.toLocalFile() @@ -184,16 +185,12 @@ class HostBlocker: else: self._import_local(filename) else: - fobj = io.BytesIO() - fobj.name = 'adblock: ' + url.host() - target = downloads.FileObjDownloadTarget(fobj) - download = download_manager.get(url, target=target, - auto_remove=True) + download = downloads.download_temp(url) self._in_progress.append(download) download.finished.connect( functools.partial(self._on_download_finished, download)) - def _import_local(self, filename): + def _import_local(self, filename: str) -> None: """Adds the contents of a file to the blocklist. Args: @@ -209,24 +206,24 @@ class HostBlocker: self._in_progress.append(download) self._on_download_finished(download) - def _parse_line(self, line): + def _parse_line(self, raw_line: bytes) -> bool: """Parse a line from a host file. Args: - line: The bytes object to parse. + raw_line: The bytes object to parse. Returns: True if parsing succeeded, False otherwise. """ - if line.startswith(b'#'): + if raw_line.startswith(b'#'): # Ignoring comments early so we don't have to care about # encoding errors in them. return True try: - line = line.decode('utf-8') + line = raw_line.decode('utf-8') except UnicodeDecodeError: - log.misc.error("Failed to decode: {!r}".format(line)) + logger.error("Failed to decode: {!r}".format(raw_line)) return False # Remove comments @@ -257,14 +254,11 @@ class HostBlocker: return True - def _merge_file(self, byte_io): + def _merge_file(self, byte_io: io.BytesIO) -> None: """Read and merge host files. Args: byte_io: The BytesIO object of the completed download. - - Return: - A set of the merged hosts. """ error_count = 0 line_count = 0 @@ -282,12 +276,12 @@ class HostBlocker: if not ok: error_count += 1 - log.misc.debug("{}: read {} lines".format(byte_io.name, line_count)) + logger.debug("{}: read {} lines".format(byte_io.name, line_count)) if error_count > 0: message.error("adblock: {} read errors for {}".format( error_count, byte_io.name)) - def _on_lists_downloaded(self): + def _on_lists_downloaded(self) -> None: """Install block lists after files have been downloaded.""" with open(self._local_hosts_file, 'w', encoding='utf-8') as f: for host in sorted(self._blocked_hosts): @@ -295,8 +289,7 @@ class HostBlocker: message.info("adblock: Read {} hosts from {} sources.".format( len(self._blocked_hosts), self._done_count)) - @config.change_filter('content.host_blocking.lists') - def _update_files(self): + def update_files(self) -> None: """Update files when the config changed.""" if not config.val.content.host_blocking.lists: try: @@ -304,13 +297,13 @@ class HostBlocker: except FileNotFoundError: pass except OSError as e: - log.misc.exception("Failed to delete hosts file: {}".format(e)) + logger.exception("Failed to delete hosts file: {}".format(e)) - def _on_download_finished(self, download): + def _on_download_finished(self, download: downloads.TempDownload) -> None: """Check if all downloads are finished and if so, trigger reading. Arguments: - download: The finished DownloadItem. + download: The finished download. """ self._in_progress.remove(download) if download.successful: @@ -323,4 +316,32 @@ class HostBlocker: try: self._on_lists_downloaded() except OSError: - log.misc.exception("Failed to write host block list!") + logger.exception("Failed to write host block list!") + + +@cmdutils.register() +def adblock_update() -> None: + """Update the adblock block lists. + + This updates `~/.local/share/qutebrowser/blocked-hosts` with downloaded + host lists and re-reads `~/.config/qutebrowser/blocked-hosts`. + """ + # FIXME: As soon as we can register instances again, we should move this + # back to the class. + _host_blocker.adblock_update() + + +@hook.config_changed('content.host_blocking.lists') +def on_config_changed() -> None: + _host_blocker.update_files() + + +@hook.init() +def init(context: apitypes.InitContext) -> None: + """Initialize the host blocker.""" + global _host_blocker + _host_blocker = HostBlocker(data_dir=context.data_dir, + config_dir=context.config_dir, + has_basedir=context.args.basedir is not None) + _host_blocker.read_hosts() + interceptor.register(_host_blocker.filter_request) diff --git a/qutebrowser/components/caretcommands.py b/qutebrowser/components/caretcommands.py new file mode 100644 index 000000000..4bab6b6c6 --- /dev/null +++ b/qutebrowser/components/caretcommands.py @@ -0,0 +1,211 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Commands related to caret browsing.""" + + +from qutebrowser.api import cmdutils, apitypes + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def move_to_next_line(tab: apitypes.Tab, count: int = 1) -> None: + """Move the cursor or selection to the next line. + + Args: + count: How many lines to move. + """ + tab.caret.move_to_next_line(count) + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def move_to_prev_line(tab: apitypes.Tab, count: int = 1) -> None: + """Move the cursor or selection to the prev line. + + Args: + count: How many lines to move. + """ + tab.caret.move_to_prev_line(count) + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def move_to_next_char(tab: apitypes.Tab, count: int = 1) -> None: + """Move the cursor or selection to the next char. + + Args: + count: How many lines to move. + """ + tab.caret.move_to_next_char(count) + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def move_to_prev_char(tab: apitypes.Tab, count: int = 1) -> None: + """Move the cursor or selection to the previous char. + + Args: + count: How many chars to move. + """ + tab.caret.move_to_prev_char(count) + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def move_to_end_of_word(tab: apitypes.Tab, count: int = 1) -> None: + """Move the cursor or selection to the end of the word. + + Args: + count: How many words to move. + """ + tab.caret.move_to_end_of_word(count) + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def move_to_next_word(tab: apitypes.Tab, count: int = 1) -> None: + """Move the cursor or selection to the next word. + + Args: + count: How many words to move. + """ + tab.caret.move_to_next_word(count) + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def move_to_prev_word(tab: apitypes.Tab, count: int = 1) -> None: + """Move the cursor or selection to the previous word. + + Args: + count: How many words to move. + """ + tab.caret.move_to_prev_word(count) + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +def move_to_start_of_line(tab: apitypes.Tab) -> None: + """Move the cursor or selection to the start of the line.""" + tab.caret.move_to_start_of_line() + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +def move_to_end_of_line(tab: apitypes.Tab) -> None: + """Move the cursor or selection to the end of line.""" + tab.caret.move_to_end_of_line() + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def move_to_start_of_next_block(tab: apitypes.Tab, count: int = 1) -> None: + """Move the cursor or selection to the start of next block. + + Args: + count: How many blocks to move. + """ + tab.caret.move_to_start_of_next_block(count) + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def move_to_start_of_prev_block(tab: apitypes.Tab, count: int = 1) -> None: + """Move the cursor or selection to the start of previous block. + + Args: + count: How many blocks to move. + """ + tab.caret.move_to_start_of_prev_block(count) + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def move_to_end_of_next_block(tab: apitypes.Tab, count: int = 1) -> None: + """Move the cursor or selection to the end of next block. + + Args: + count: How many blocks to move. + """ + tab.caret.move_to_end_of_next_block(count) + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def move_to_end_of_prev_block(tab: apitypes.Tab, count: int = 1) -> None: + """Move the cursor or selection to the end of previous block. + + Args: + count: How many blocks to move. + """ + tab.caret.move_to_end_of_prev_block(count) + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +def move_to_start_of_document(tab: apitypes.Tab) -> None: + """Move the cursor or selection to the start of the document.""" + tab.caret.move_to_start_of_document() + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +def move_to_end_of_document(tab: apitypes.Tab) -> None: + """Move the cursor or selection to the end of the document.""" + tab.caret.move_to_end_of_document() + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +def toggle_selection(tab: apitypes.Tab) -> None: + """Toggle caret selection mode.""" + tab.caret.toggle_selection() + + +@cmdutils.register(modes=[cmdutils.KeyMode.caret]) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +def drop_selection(tab: apitypes.Tab) -> None: + """Drop selection and keep selection mode enabled.""" + tab.caret.drop_selection() + + +@cmdutils.register() +@cmdutils.argument('tab_obj', value=cmdutils.Value.cur_tab) +def follow_selected(tab_obj: apitypes.Tab, *, tab: bool = False) -> None: + """Follow the selected text. + + Args: + tab: Load the selected link in a new tab. + """ + try: + tab_obj.caret.follow_selected(tab=tab) + except apitypes.WebTabError as e: + raise cmdutils.CommandError(str(e)) diff --git a/qutebrowser/components/misccommands.py b/qutebrowser/components/misccommands.py new file mode 100644 index 000000000..eaf45f40d --- /dev/null +++ b/qutebrowser/components/misccommands.py @@ -0,0 +1,312 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Various commands.""" + +import os +import signal +import functools +import logging + +try: + import hunter +except ImportError: + hunter = None + +from PyQt5.QtCore import Qt +from PyQt5.QtPrintSupport import QPrintPreviewDialog + +from qutebrowser.api import cmdutils, apitypes, message, config + + +@cmdutils.register(name='reload') +@cmdutils.argument('tab', value=cmdutils.Value.count_tab) +def reloadpage(tab: apitypes.Tab, force: bool = False) -> None: + """Reload the current/[count]th tab. + + Args: + count: The tab index to reload, or None. + force: Bypass the page cache. + """ + if tab is not None: + tab.reload(force=force) + + +@cmdutils.register() +@cmdutils.argument('tab', value=cmdutils.Value.count_tab) +def stop(tab: apitypes.Tab) -> None: + """Stop loading in the current/[count]th tab. + + Args: + count: The tab index to stop, or None. + """ + if tab is not None: + tab.stop() + + +def _print_preview(tab: apitypes.Tab) -> None: + """Show a print preview.""" + def print_callback(ok: bool) -> None: + if not ok: + message.error("Printing failed!") + + tab.printing.check_preview_support() + diag = QPrintPreviewDialog(tab) + diag.setAttribute(Qt.WA_DeleteOnClose) + diag.setWindowFlags(diag.windowFlags() | Qt.WindowMaximizeButtonHint | + Qt.WindowMinimizeButtonHint) + diag.paintRequested.connect(functools.partial( + tab.printing.to_printer, callback=print_callback)) + diag.exec_() + + +def _print_pdf(tab: apitypes.Tab, filename: str) -> None: + """Print to the given PDF file.""" + tab.printing.check_pdf_support() + filename = os.path.expanduser(filename) + directory = os.path.dirname(filename) + if directory and not os.path.exists(directory): + os.mkdir(directory) + tab.printing.to_pdf(filename) + logging.getLogger('misc').debug("Print to file: {}".format(filename)) + + +@cmdutils.register(name='print') +@cmdutils.argument('tab', value=cmdutils.Value.count_tab) +@cmdutils.argument('pdf', flag='f', metavar='file') +def printpage(tab: apitypes.Tab, + preview: bool = False, *, + pdf: str = None) -> None: + """Print the current/[count]th tab. + + Args: + preview: Show preview instead of printing. + count: The tab index to print, or None. + pdf: The file path to write the PDF to. + """ + if tab is None: + return + + try: + if preview: + _print_preview(tab) + elif pdf: + _print_pdf(tab, pdf) + else: + tab.printing.show_dialog() + except apitypes.WebTabError as e: + raise cmdutils.CommandError(e) + + +@cmdutils.register() +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +def home(tab: apitypes.Tab) -> None: + """Open main startpage in current tab.""" + if tab.data.pinned: + message.info("Tab is pinned!") + else: + tab.load_url(config.val.url.start_pages[0]) + + +@cmdutils.register(debug=True) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +def debug_dump_page(tab: apitypes.Tab, dest: str, plain: bool = False) -> None: + """Dump the current page's content to a file. + + Args: + dest: Where to write the file to. + plain: Write plain text instead of HTML. + """ + dest = os.path.expanduser(dest) + + def callback(data: str) -> None: + """Write the data to disk.""" + try: + with open(dest, 'w', encoding='utf-8') as f: + f.write(data) + except OSError as e: + message.error('Could not write page: {}'.format(e)) + else: + message.info("Dumped page to {}.".format(dest)) + + tab.dump_async(callback, plain=plain) + + +@cmdutils.register(maxsplit=0) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +def insert_text(tab: apitypes.Tab, text: str) -> None: + """Insert text at cursor position. + + Args: + text: The text to insert. + """ + def _insert_text_cb(elem: apitypes.WebElement) -> None: + if elem is None: + message.error("No element focused!") + return + try: + elem.insert_text(text) + except apitypes.WebElemError as e: + message.error(str(e)) + return + + tab.elements.find_focused(_insert_text_cb) + + +@cmdutils.register() +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('filter_', choices=['id']) +def click_element(tab: apitypes.Tab, filter_: str, value: str, *, + target: apitypes.ClickTarget = + apitypes.ClickTarget.normal, + force_event: bool = False) -> None: + """Click the element matching the given filter. + + The given filter needs to result in exactly one element, otherwise, an + error is shown. + + Args: + filter_: How to filter the elements. + id: Get an element based on its ID. + value: The value to filter for. + target: How to open the clicked element (normal/tab/tab-bg/window). + force_event: Force generating a fake click event. + """ + def single_cb(elem: apitypes.WebElement) -> None: + """Click a single element.""" + if elem is None: + message.error("No element found with id {}!".format(value)) + return + try: + elem.click(target, force_event=force_event) + except apitypes.WebElemError as e: + message.error(str(e)) + return + + handlers = { + 'id': (tab.elements.find_id, single_cb), + } + handler, callback = handlers[filter_] + handler(value, callback) + + +@cmdutils.register(debug=True) +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def debug_webaction(tab: apitypes.Tab, action: str, count: int = 1) -> None: + """Execute a webaction. + + Available actions: + http://doc.qt.io/archives/qt-5.5/qwebpage.html#WebAction-enum (WebKit) + http://doc.qt.io/qt-5/qwebenginepage.html#WebAction-enum (WebEngine) + + Args: + action: The action to execute, e.g. MoveToNextChar. + count: How many times to repeat the action. + """ + for _ in range(count): + try: + tab.action.run_string(action) + except apitypes.WebTabError as e: + raise cmdutils.CommandError(str(e)) + + +@cmdutils.register() +@cmdutils.argument('tab', value=cmdutils.Value.count_tab) +def tab_mute(tab: apitypes.Tab) -> None: + """Mute/Unmute the current/[count]th tab. + + Args: + count: The tab index to mute or unmute, or None + """ + if tab is None: + return + try: + tab.audio.set_muted(not tab.audio.is_muted(), override=True) + except apitypes.WebTabError as e: + raise cmdutils.CommandError(e) + + +@cmdutils.register() +def nop() -> None: + """Do nothing.""" + + +@cmdutils.register() +def message_error(text: str) -> None: + """Show an error message in the statusbar. + + Args: + text: The text to show. + """ + message.error(text) + + +@cmdutils.register() +@cmdutils.argument('count', value=cmdutils.Value.count) +def message_info(text: str, count: int = 1) -> None: + """Show an info message in the statusbar. + + Args: + text: The text to show. + count: How many times to show the message + """ + for _ in range(count): + message.info(text) + + +@cmdutils.register() +def message_warning(text: str) -> None: + """Show a warning message in the statusbar. + + Args: + text: The text to show. + """ + message.warning(text) + + +@cmdutils.register(debug=True) +@cmdutils.argument('typ', choices=['exception', 'segfault']) +def debug_crash(typ: str = 'exception') -> None: + """Crash for debugging purposes. + + Args: + typ: either 'exception' or 'segfault'. + """ + if typ == 'segfault': + os.kill(os.getpid(), signal.SIGSEGV) + raise Exception("Segfault failed (wat.)") + else: + raise Exception("Forced crash") + + +@cmdutils.register(debug=True, maxsplit=0, no_cmd_split=True) +def debug_trace(expr: str = "") -> None: + """Trace executed code via hunter. + + Args: + expr: What to trace, passed to hunter. + """ + if hunter is None: + raise cmdutils.CommandError("You need to install 'hunter' to use this " + "command!") + try: + eval('hunter.trace({})'.format(expr)) + except Exception as e: + raise cmdutils.CommandError("{}: {}".format(e.__class__.__name__, e)) diff --git a/qutebrowser/components/scrollcommands.py b/qutebrowser/components/scrollcommands.py new file mode 100644 index 000000000..0b8943f2d --- /dev/null +++ b/qutebrowser/components/scrollcommands.py @@ -0,0 +1,122 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Scrolling-related commands.""" + +from qutebrowser.api import cmdutils, apitypes + + +@cmdutils.register() +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def scroll_px(tab: apitypes.Tab, dx: int, dy: int, count: int = 1) -> None: + """Scroll the current tab by 'count * dx/dy' pixels. + + Args: + dx: How much to scroll in x-direction. + dy: How much to scroll in y-direction. + count: multiplier + """ + dx *= count + dy *= count + cmdutils.check_overflow(dx, 'int') + cmdutils.check_overflow(dy, 'int') + tab.scroller.delta(dx, dy) + + +@cmdutils.register() +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def scroll(tab: apitypes.Tab, direction: str, count: int = 1) -> None: + """Scroll the current tab in the given direction. + + Note you can use `:run-with-count` to have a keybinding with a bigger + scroll increment. + + Args: + direction: In which direction to scroll + (up/down/left/right/top/bottom). + count: multiplier + """ + funcs = { + 'up': tab.scroller.up, + 'down': tab.scroller.down, + 'left': tab.scroller.left, + 'right': tab.scroller.right, + 'top': tab.scroller.top, + 'bottom': tab.scroller.bottom, + 'page-up': tab.scroller.page_up, + 'page-down': tab.scroller.page_down, + } + try: + func = funcs[direction] + except KeyError: + expected_values = ', '.join(sorted(funcs)) + raise cmdutils.CommandError("Invalid value {!r} for direction - " + "expected one of: {}".format( + direction, expected_values)) + + if direction in ['top', 'bottom']: + func() + else: + func(count=count) + + +@cmdutils.register() +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +@cmdutils.argument('horizontal', flag='x') +def scroll_to_perc(tab: apitypes.Tab, count: int = None, + perc: float = None, horizontal: bool = False) -> None: + """Scroll to a specific percentage of the page. + + The percentage can be given either as argument or as count. + If no percentage is given, the page is scrolled to the end. + + Args: + perc: Percentage to scroll. + horizontal: Scroll horizontally instead of vertically. + count: Percentage to scroll. + """ + if perc is None and count is None: + perc = 100 + elif count is not None: + perc = count + + if horizontal: + x = perc + y = None + else: + x = None + y = perc + + tab.scroller.before_jump_requested.emit() + tab.scroller.to_perc(x, y) + + +@cmdutils.register() +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +def scroll_to_anchor(tab: apitypes.Tab, name: str) -> None: + """Scroll to the given anchor in the document. + + Args: + name: The anchor to scroll to. + """ + tab.scroller.before_jump_requested.emit() + tab.scroller.to_anchor(name) diff --git a/qutebrowser/components/zoomcommands.py b/qutebrowser/components/zoomcommands.py new file mode 100644 index 000000000..51d01cfea --- /dev/null +++ b/qutebrowser/components/zoomcommands.py @@ -0,0 +1,95 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Zooming-related commands.""" + +from qutebrowser.api import cmdutils, apitypes, message, config + + +@cmdutils.register() +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def zoom_in(tab: apitypes.Tab, count: int = 1, quiet: bool = False) -> None: + """Increase the zoom level for the current tab. + + Args: + count: How many steps to zoom in. + quiet: Don't show a zoom level message. + """ + try: + perc = tab.zoom.apply_offset(count) + except ValueError as e: + raise cmdutils.CommandError(e) + if not quiet: + message.info("Zoom level: {}%".format(int(perc)), replace=True) + + +@cmdutils.register() +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def zoom_out(tab: apitypes.Tab, count: int = 1, quiet: bool = False) -> None: + """Decrease the zoom level for the current tab. + + Args: + count: How many steps to zoom out. + quiet: Don't show a zoom level message. + """ + try: + perc = tab.zoom.apply_offset(-count) + except ValueError as e: + raise cmdutils.CommandError(e) + if not quiet: + message.info("Zoom level: {}%".format(int(perc)), replace=True) + + +@cmdutils.register() +@cmdutils.argument('tab', value=cmdutils.Value.cur_tab) +@cmdutils.argument('count', value=cmdutils.Value.count) +def zoom(tab: apitypes.Tab, + level: str = None, + count: int = None, + quiet: bool = False) -> None: + """Set the zoom level for the current tab. + + The zoom can be given as argument or as [count]. If neither is + given, the zoom is set to the default zoom. If both are given, + use [count]. + + Args: + level: The zoom percentage to set. + count: The zoom percentage to set. + quiet: Don't show a zoom level message. + """ + if count is not None: + int_level = count + elif level is not None: + try: + int_level = int(level.rstrip('%')) + except ValueError: + raise cmdutils.CommandError("zoom: Invalid int value {}" + .format(level)) + else: + int_level = int(config.val.zoom.default) + + try: + tab.zoom.set_factor(int_level / 100) + except ValueError: + raise cmdutils.CommandError("Can't zoom {}%!".format(int_level)) + if not quiet: + message.info("Zoom level: {}%".format(int_level), replace=True) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index facfcc553..201b87fde 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -22,19 +22,28 @@ import copy import contextlib import functools +import typing +from typing import Any -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl from qutebrowser.config import configdata, configexc, configutils -from qutebrowser.utils import utils, log, jinja +from qutebrowser.utils import utils, log, jinja, urlmatch from qutebrowser.misc import objects from qutebrowser.keyinput import keyutils +MYPY = False +if MYPY: + # pylint: disable=unused-import,useless-suppression + from typing import Tuple, MutableMapping + from qutebrowser.config import configcache, configfiles + from qutebrowser.misc import savemanager + # An easy way to access the config from other code via config.val.foo -val = None -instance = None -key_instance = None -cache = None +val = typing.cast('ConfigContainer', None) +instance = typing.cast('Config', None) +key_instance = typing.cast('KeyConfig', None) +cache = typing.cast('configcache.ConfigCache', None) # Keeping track of all change filters to validate them later. change_filters = [] @@ -55,7 +64,7 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name _function: Whether a function rather than a method is decorated. """ - def __init__(self, option, function=False): + def __init__(self, option: str, function: bool = False) -> None: """Save decorator arguments. Gets called on parse-time with the decorator arguments. @@ -68,7 +77,7 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name self._function = function change_filters.append(self) - def validate(self): + def validate(self) -> None: """Make sure the configured option or prefix exists. We can't do this in __init__ as configdata isn't ready yet. @@ -77,7 +86,7 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name not configdata.is_valid_prefix(self._option)): raise configexc.NoOptionError(self._option) - def _check_match(self, option): + def check_match(self, option: typing.Optional[str]) -> bool: """Check if the given option matches the filter.""" if option is None: # Called directly, not from a config change event. @@ -90,7 +99,7 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name else: return False - def __call__(self, func): + def __call__(self, func: typing.Callable) -> typing.Callable: """Filter calls to the decorated function. Gets called when a function should be decorated. @@ -108,20 +117,21 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name """ if self._function: @functools.wraps(func) - def wrapper(option=None): + def func_wrapper(option: str = None) -> typing.Any: """Call the underlying function.""" - if self._check_match(option): + if self.check_match(option): return func() return None + return func_wrapper else: @functools.wraps(func) - def wrapper(wrapper_self, option=None): + def meth_wrapper(wrapper_self: typing.Any, + option: str = None) -> typing.Any: """Call the underlying function.""" - if self._check_match(option): + if self.check_match(option): return func(wrapper_self) return None - - return wrapper + return meth_wrapper class KeyConfig: @@ -134,17 +144,22 @@ class KeyConfig: _config: The Config object to be used. """ - def __init__(self, config): + _ReverseBindings = typing.Dict[str, typing.MutableSequence[str]] + + def __init__(self, config: 'Config') -> None: self._config = config - def _validate(self, key, mode): + def _validate(self, key: keyutils.KeySequence, mode: str) -> None: """Validate the given key and mode.""" # Catch old usage of this code assert isinstance(key, keyutils.KeySequence), key if mode not in configdata.DATA['bindings.default'].default: raise configexc.KeybindingError("Invalid mode {}!".format(mode)) - def get_bindings_for(self, mode): + def get_bindings_for( + self, + mode: str + ) -> typing.Dict[keyutils.KeySequence, str]: """Get the combined bindings for the given mode.""" bindings = dict(val.bindings.default[mode]) for key, binding in val.bindings.commands[mode].items(): @@ -154,9 +169,9 @@ class KeyConfig: bindings[key] = binding return bindings - def get_reverse_bindings_for(self, mode): + def get_reverse_bindings_for(self, mode: str) -> '_ReverseBindings': """Get a dict of commands to a list of bindings for the mode.""" - cmd_to_keys = {} + cmd_to_keys = {} # type: KeyConfig._ReverseBindings bindings = self.get_bindings_for(mode) for seq, full_cmd in sorted(bindings.items()): for cmd in full_cmd.split(';;'): @@ -169,7 +184,10 @@ class KeyConfig: cmd_to_keys[cmd].insert(0, str(seq)) return cmd_to_keys - def get_command(self, key, mode, default=False): + def get_command(self, + key: keyutils.KeySequence, + mode: str, + default: bool = False) -> str: """Get the command for a given key (or None).""" self._validate(key, mode) if default: @@ -178,7 +196,11 @@ class KeyConfig: bindings = self.get_bindings_for(mode) return bindings.get(key, None) - def bind(self, key, command, *, mode, save_yaml=False): + def bind(self, + key: keyutils.KeySequence, + command: str, *, + mode: str, + save_yaml: bool = False) -> None: """Add a new binding from key to command.""" if command is not None and not command.strip(): raise configexc.KeybindingError( @@ -186,8 +208,8 @@ class KeyConfig: 'mode'.format(key, mode)) self._validate(key, mode) - log.keyboard.vdebug("Adding binding {} -> {} in mode {}.".format( - key, command, mode)) + log.keyboard.vdebug( # type: ignore + "Adding binding {} -> {} in mode {}.".format(key, command, mode)) bindings = self._config.get_mutable_obj('bindings.commands') if mode not in bindings: @@ -195,7 +217,10 @@ class KeyConfig: bindings[mode][str(key)] = command self._config.update_mutables(save_yaml=save_yaml) - def bind_default(self, key, *, mode='normal', save_yaml=False): + def bind_default(self, + key: keyutils.KeySequence, *, + mode: str = 'normal', + save_yaml: bool = False) -> None: """Restore a default keybinding.""" self._validate(key, mode) @@ -207,7 +232,10 @@ class KeyConfig: "Can't find binding '{}' in {} mode".format(key, mode)) self._config.update_mutables(save_yaml=save_yaml) - def unbind(self, key, *, mode='normal', save_yaml=False): + def unbind(self, + key: keyutils.KeySequence, *, + mode: str = 'normal', + save_yaml: bool = False) -> None: """Unbind the given key in the given mode.""" self._validate(key, mode) @@ -248,24 +276,27 @@ class Config(QObject): MUTABLE_TYPES = (dict, list) changed = pyqtSignal(str) - def __init__(self, yaml_config, parent=None): + def __init__(self, + yaml_config: 'configfiles.YamlConfig', + parent: QObject = None) -> None: super().__init__(parent) self.changed.connect(_render_stylesheet.cache_clear) - self._mutables = {} + self._mutables = {} # type: MutableMapping[str, Tuple[Any, Any]] self._yaml = yaml_config self._init_values() - def _init_values(self): + def _init_values(self) -> None: """Populate the self._values dict.""" - self._values = {} + self._values = {} # type: typing.Mapping for name, opt in configdata.DATA.items(): self._values[name] = configutils.Values(opt) - def __iter__(self): + def __iter__(self) -> typing.Iterator[configutils.Values]: """Iterate over configutils.Values items.""" yield from self._values.values() - def init_save_manager(self, save_manager): + def init_save_manager(self, + save_manager: 'savemanager.SaveManager') -> None: """Make sure the config gets saved properly. We do this outside of __init__ because the config gets created before @@ -273,7 +304,10 @@ class Config(QObject): """ self._yaml.init_save_manager(save_manager) - def _set_value(self, opt, value, pattern=None): + def _set_value(self, + opt: 'configdata.Option', + value: Any, + pattern: urlmatch.UrlPattern = None) -> None: """Set the given option to the given value.""" if not isinstance(objects.backend, objects.NoBackend): if objects.backend not in opt.backends: @@ -288,12 +322,12 @@ class Config(QObject): log.config.debug("Config option changed: {} = {}".format( opt.name, value)) - def _check_yaml(self, opt, save_yaml): + def _check_yaml(self, opt: 'configdata.Option', save_yaml: bool) -> None: """Make sure the given option may be set in autoconfig.yml.""" if save_yaml and opt.no_autoconfig: raise configexc.NoAutoconfigError(opt.name) - def read_yaml(self): + def read_yaml(self) -> None: """Read the YAML settings from self._yaml.""" self._yaml.load() for values in self._yaml: @@ -301,7 +335,7 @@ class Config(QObject): self._set_value(values.opt, scoped.value, pattern=scoped.pattern) - def get_opt(self, name): + def get_opt(self, name: str) -> 'configdata.Option': """Get a configdata.Option object for the given setting.""" try: return configdata.DATA[name] @@ -312,7 +346,10 @@ class Config(QObject): name, deleted=deleted, renamed=renamed) raise exception from None - def get(self, name, url=None, *, fallback=True): + def get(self, + name: str, + url: QUrl = None, *, + fallback: bool = True) -> Any: """Get the given setting converted for Python code. Args: @@ -322,7 +359,7 @@ class Config(QObject): obj = self.get_obj(name, url=url, fallback=fallback) return opt.typ.to_py(obj) - def _maybe_copy(self, value): + def _maybe_copy(self, value: Any) -> Any: """Copy the value if it could potentially be mutated.""" if isinstance(value, self.MUTABLE_TYPES): # For mutable objects, create a copy so we don't accidentally @@ -333,7 +370,10 @@ class Config(QObject): assert value.__hash__ is not None, value return value - def get_obj(self, name, *, url=None, fallback=True): + def get_obj(self, + name: str, *, + url: QUrl = None, + fallback: bool = True) -> Any: """Get the given setting as object (for YAML/config.py). Note that the returned values are not watched for mutation. @@ -343,7 +383,10 @@ class Config(QObject): value = self._values[name].get_for_url(url, fallback=fallback) return self._maybe_copy(value) - def get_obj_for_pattern(self, name, *, pattern): + def get_obj_for_pattern( + self, name: str, *, + pattern: typing.Optional[urlmatch.UrlPattern] + ) -> Any: """Get the given setting as object (for YAML/config.py). This gets the overridden value for a given pattern, or @@ -353,11 +396,12 @@ class Config(QObject): value = self._values[name].get_for_pattern(pattern, fallback=False) return self._maybe_copy(value) - def get_mutable_obj(self, name, *, pattern=None): + def get_mutable_obj(self, name: str, *, + pattern: urlmatch.UrlPattern = None) -> Any: """Get an object which can be mutated, e.g. in a config.py. If a pattern is given, return the value for that pattern. - Note that it's impossible to get a mutable object for an URL as we + Note that it's impossible to get a mutable object for a URL as we wouldn't know what pattern to apply. """ self.get_opt(name) # To make sure it exists @@ -378,7 +422,8 @@ class Config(QObject): return copy_value - def get_str(self, name, *, pattern=None): + def get_str(self, name: str, *, + pattern: urlmatch.UrlPattern = None) -> str: """Get the given setting as string. If a pattern is given, get the setting for the given pattern or @@ -389,7 +434,10 @@ class Config(QObject): value = values.get_for_pattern(pattern) return opt.typ.to_str(value) - def set_obj(self, name, value, *, pattern=None, save_yaml=False): + def set_obj(self, name: str, + value: Any, *, + pattern: urlmatch.UrlPattern = None, + save_yaml: bool = False) -> None: """Set the given setting from a YAML/config.py object. If save_yaml=True is given, store the new value to YAML. @@ -400,7 +448,10 @@ class Config(QObject): if save_yaml: self._yaml.set_obj(name, value, pattern=pattern) - def set_str(self, name, value, *, pattern=None, save_yaml=False): + def set_str(self, name: str, + value: str, *, + pattern: urlmatch.UrlPattern = None, + save_yaml: bool = False) -> None: """Set the given setting from a string. If save_yaml=True is given, store the new value to YAML. @@ -415,7 +466,9 @@ class Config(QObject): if save_yaml: self._yaml.set_obj(name, converted, pattern=pattern) - def unset(self, name, *, save_yaml=False, pattern=None): + def unset(self, name: str, *, + save_yaml: bool = False, + pattern: urlmatch.UrlPattern = None) -> None: """Set the given setting back to its default.""" opt = self.get_opt(name) self._check_yaml(opt, save_yaml) @@ -426,7 +479,7 @@ class Config(QObject): if save_yaml: self._yaml.unset(name, pattern=pattern) - def clear(self, *, save_yaml=False): + def clear(self, *, save_yaml: bool = False) -> None: """Clear all settings in the config. If save_yaml=True is given, also remove all customization from the YAML @@ -440,7 +493,7 @@ class Config(QObject): if save_yaml: self._yaml.clear() - def update_mutables(self, *, save_yaml=False): + def update_mutables(self, *, save_yaml: bool = False) -> None: """Update mutable settings if they changed. Every time someone calls get_obj() on a mutable object, we save a @@ -455,7 +508,7 @@ class Config(QObject): self.set_obj(name, new_value, save_yaml=save_yaml) self._mutables = {} - def dump_userconfig(self): + def dump_userconfig(self) -> str: """Get the part of the config which was changed by the user. Return: @@ -484,7 +537,10 @@ class ConfigContainer: _pattern: The URL pattern to be used. """ - def __init__(self, config, configapi=None, prefix='', pattern=None): + def __init__(self, config: Config, + configapi: 'configfiles.ConfigAPI' = None, + prefix: str = '', + pattern: urlmatch.UrlPattern = None) -> None: self._config = config self._prefix = prefix self._configapi = configapi @@ -492,13 +548,13 @@ class ConfigContainer: if configapi is None and pattern is not None: raise TypeError("Can't use pattern without configapi!") - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self, constructor=True, config=self._config, configapi=self._configapi, prefix=self._prefix, pattern=self._pattern) @contextlib.contextmanager - def _handle_error(self, action, name): + def _handle_error(self, action: str, name: str) -> typing.Iterator[None]: try: yield except configexc.Error as e: @@ -507,7 +563,7 @@ class ConfigContainer: text = "While {} '{}'".format(action, name) self._configapi.errors.append(configexc.ConfigErrorDesc(text, e)) - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> Any: """Get an option or a new ConfigContainer with the added prefix. If we get an option which exists, we return the value for it. @@ -534,7 +590,7 @@ class ConfigContainer: return self._config.get_mutable_obj( name, pattern=self._pattern) - def __setattr__(self, attr, value): + def __setattr__(self, attr: str, value: Any) -> None: """Set the given option in the config.""" if attr.startswith('_'): super().__setattr__(attr, value) @@ -544,7 +600,7 @@ class ConfigContainer: with self._handle_error('setting', name): self._config.set_obj(name, value, pattern=self._pattern) - def _join(self, attr): + def _join(self, attr: str) -> str: """Get the prefix joined with the given attribute.""" if self._prefix: return '{}.{}'.format(self._prefix, attr) @@ -552,8 +608,10 @@ class ConfigContainer: return attr -def set_register_stylesheet(obj, *, stylesheet=None, update=True): - """Set the stylesheet for an object based on it's STYLESHEET attribute. +def set_register_stylesheet(obj: QObject, *, + stylesheet: str = None, + update: bool = True) -> None: + """Set the stylesheet for an object. Also, register an update when the config is changed. @@ -568,7 +626,7 @@ def set_register_stylesheet(obj, *, stylesheet=None, update=True): @functools.lru_cache() -def _render_stylesheet(stylesheet): +def _render_stylesheet(stylesheet: str) -> str: """Render the given stylesheet jinja template.""" with jinja.environment.no_autoescape(): template = jinja.environment.from_string(stylesheet) @@ -584,7 +642,9 @@ class StyleSheetObserver(QObject): _stylesheet: The stylesheet template to use. """ - def __init__(self, obj, stylesheet, update): + def __init__(self, obj: QObject, + stylesheet: typing.Optional[str], + update: bool) -> None: super().__init__() self._obj = obj self._update = update @@ -593,11 +653,11 @@ class StyleSheetObserver(QObject): if self._update: self.setParent(self._obj) if stylesheet is None: - self._stylesheet = obj.STYLESHEET + self._stylesheet = obj.STYLESHEET # type: str else: self._stylesheet = stylesheet - def _get_stylesheet(self): + def _get_stylesheet(self) -> str: """Format a stylesheet based on a template. Return: @@ -606,19 +666,15 @@ class StyleSheetObserver(QObject): return _render_stylesheet(self._stylesheet) @pyqtSlot() - def _update_stylesheet(self): + def _update_stylesheet(self) -> None: """Update the stylesheet for obj.""" self._obj.setStyleSheet(self._get_stylesheet()) - def register(self): - """Do a first update and listen for more. - - Args: - update: if False, don't listen for future updates. - """ + def register(self) -> None: + """Do a first update and listen for more.""" qss = self._get_stylesheet() - log.config.vdebug("stylesheet for {}: {}".format( - self._obj.__class__.__name__, qss)) + log.config.vdebug( # type: ignore + "stylesheet for {}: {}".format(self._obj.__class__.__name__, qss)) self._obj.setStyleSheet(qss) if self._update: instance.changed.connect(self._update_stylesheet) diff --git a/qutebrowser/config/configcache.py b/qutebrowser/config/configcache.py index dfead6664..a421ba85c 100644 --- a/qutebrowser/config/configcache.py +++ b/qutebrowser/config/configcache.py @@ -20,6 +20,8 @@ """Implementation of a basic config cache.""" +import typing + from qutebrowser.config import config @@ -36,14 +38,14 @@ class ConfigCache: """ def __init__(self) -> None: - self._cache = {} + self._cache = {} # type: typing.Dict[str, typing.Any] config.instance.changed.connect(self._on_config_changed) def _on_config_changed(self, attr: str) -> None: if attr in self._cache: self._cache[attr] = config.instance.get(attr) - def __getitem__(self, attr: str): + def __getitem__(self, attr: str) -> typing.Any: if attr not in self._cache: assert not config.instance.get_opt(attr).supports_pattern self._cache[attr] = config.instance.get(attr) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index 8853f9ca3..5d5b2db7e 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -19,36 +19,47 @@ """Commands related to the configuration.""" +import typing import os.path import contextlib from PyQt5.QtCore import QUrl -from qutebrowser.commands import cmdexc, cmdutils +from qutebrowser.api import cmdutils from qutebrowser.completion.models import configmodel from qutebrowser.utils import objreg, message, standarddir, urlmatch from qutebrowser.config import configtypes, configexc, configfiles, configdata from qutebrowser.misc import editor from qutebrowser.keyinput import keyutils +MYPY = False +if MYPY: + # pylint: disable=unused-import,useless-suppression + from qutebrowser.config.config import Config, KeyConfig + class ConfigCommands: """qutebrowser commands related to the configuration.""" - def __init__(self, config, keyconfig): + def __init__(self, + config: 'Config', + keyconfig: 'KeyConfig') -> None: self._config = config self._keyconfig = keyconfig @contextlib.contextmanager - def _handle_config_error(self): + def _handle_config_error(self) -> typing.Iterator[None]: """Catch errors in set_command and raise CommandError.""" try: yield except configexc.Error as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) - def _parse_pattern(self, pattern): + def _parse_pattern( + self, + pattern: typing.Optional[str] + ) -> typing.Optional[urlmatch.UrlPattern]: """Parse a pattern string argument to a pattern.""" if pattern is None: return None @@ -56,17 +67,18 @@ class ConfigCommands: try: return urlmatch.UrlPattern(pattern) except urlmatch.ParseError as e: - raise cmdexc.CommandError("Error while parsing {}: {}" - .format(pattern, str(e))) + raise cmdutils.CommandError("Error while parsing {}: {}" + .format(pattern, str(e))) - def _parse_key(self, key): + def _parse_key(self, key: str) -> keyutils.KeySequence: """Parse a key argument.""" try: return keyutils.KeySequence.parse(key) except keyutils.KeyParseError as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) - def _print_value(self, option, pattern): + def _print_value(self, option: str, + pattern: typing.Optional[urlmatch.UrlPattern]) -> None: """Print the value of the given option.""" with self._handle_config_error(): value = self._config.get_str(option, pattern=pattern) @@ -79,10 +91,11 @@ class ConfigCommands: @cmdutils.register(instance='config-commands') @cmdutils.argument('option', completion=configmodel.option) @cmdutils.argument('value', completion=configmodel.value) - @cmdutils.argument('win_id', win_id=True) + @cmdutils.argument('win_id', value=cmdutils.Value.win_id) @cmdutils.argument('pattern', flag='u') - def set(self, win_id, option=None, value=None, temp=False, print_=False, - *, pattern=None): + def set(self, win_id: int, option: str = None, value: str = None, + temp: bool = False, print_: bool = False, + *, pattern: str = None) -> None: """Set an option. If the option name ends with '?' or no value is provided, the @@ -101,35 +114,35 @@ class ConfigCommands: if option is None: tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) - tabbed_browser.openurl(QUrl('qute://settings'), newtab=False) + tabbed_browser.load_url(QUrl('qute://settings'), newtab=False) return if option.endswith('!'): - raise cmdexc.CommandError("Toggling values was moved to the " - ":config-cycle command") + raise cmdutils.CommandError("Toggling values was moved to the " + ":config-cycle command") - pattern = self._parse_pattern(pattern) + parsed_pattern = self._parse_pattern(pattern) if option.endswith('?') and option != '?': - self._print_value(option[:-1], pattern=pattern) + self._print_value(option[:-1], pattern=parsed_pattern) return with self._handle_config_error(): if value is None: - self._print_value(option, pattern=pattern) + self._print_value(option, pattern=parsed_pattern) else: - self._config.set_str(option, value, pattern=pattern, + self._config.set_str(option, value, pattern=parsed_pattern, save_yaml=not temp) if print_: - self._print_value(option, pattern=pattern) + self._print_value(option, pattern=parsed_pattern) @cmdutils.register(instance='config-commands', maxsplit=1, no_cmd_split=True, no_replace_variables=True) @cmdutils.argument('command', completion=configmodel.bind) - @cmdutils.argument('win_id', win_id=True) - def bind(self, win_id, key=None, command=None, *, mode='normal', - default=False): + @cmdutils.argument('win_id', value=cmdutils.Value.win_id) + def bind(self, win_id: str, key: str = None, command: str = None, *, + mode: str = 'normal', default: bool = False) -> None: """Bind a key to a command. If no command is given, show the current binding for the given key. @@ -147,7 +160,7 @@ class ConfigCommands: if key is None: tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) - tabbed_browser.openurl(QUrl('qute://bindings'), newtab=True) + tabbed_browser.load_url(QUrl('qute://bindings'), newtab=True) return seq = self._parse_key(key) @@ -174,7 +187,7 @@ class ConfigCommands: self._keyconfig.bind(seq, command, mode=mode, save_yaml=True) @cmdutils.register(instance='config-commands') - def unbind(self, key, *, mode='normal'): + def unbind(self, key: str, *, mode: str = 'normal') -> None: """Unbind a keychain. Args: @@ -191,8 +204,9 @@ class ConfigCommands: @cmdutils.argument('option', completion=configmodel.option) @cmdutils.argument('values', completion=configmodel.value) @cmdutils.argument('pattern', flag='u') - def config_cycle(self, option, *values, pattern=None, temp=False, - print_=False): + def config_cycle(self, option: str, *values: str, + pattern: str = None, + temp: bool = False, print_: bool = False) -> None: """Cycle an option between multiple values. Args: @@ -202,42 +216,42 @@ class ConfigCommands: temp: Set value temporarily until qutebrowser is closed. print_: Print the value after setting. """ - pattern = self._parse_pattern(pattern) + parsed_pattern = self._parse_pattern(pattern) with self._handle_config_error(): opt = self._config.get_opt(option) - old_value = self._config.get_obj_for_pattern(option, - pattern=pattern) + old_value = self._config.get_obj_for_pattern( + option, pattern=parsed_pattern) if not values and isinstance(opt.typ, configtypes.Bool): - values = ['true', 'false'] + values = ('true', 'false') if len(values) < 2: - raise cmdexc.CommandError("Need at least two values for " - "non-boolean settings.") + raise cmdutils.CommandError("Need at least two values for " + "non-boolean settings.") # Use the next valid value from values, or the first if the current # value does not appear in the list with self._handle_config_error(): - values = [opt.typ.from_str(val) for val in values] + cycle_values = [opt.typ.from_str(val) for val in values] try: - idx = values.index(old_value) - idx = (idx + 1) % len(values) - value = values[idx] + idx = cycle_values.index(old_value) + idx = (idx + 1) % len(cycle_values) + value = cycle_values[idx] except ValueError: - value = values[0] + value = cycle_values[0] with self._handle_config_error(): - self._config.set_obj(option, value, pattern=pattern, + self._config.set_obj(option, value, pattern=parsed_pattern, save_yaml=not temp) if print_: - self._print_value(option, pattern=pattern) + self._print_value(option, pattern=parsed_pattern) @cmdutils.register(instance='config-commands') @cmdutils.argument('option', completion=configmodel.customized_option) - def config_unset(self, option, temp=False): + def config_unset(self, option: str, temp: bool = False) -> None: """Unset an option. This sets an option back to its default and removes it from @@ -252,7 +266,8 @@ class ConfigCommands: @cmdutils.register(instance='config-commands') @cmdutils.argument('option', completion=configmodel.list_option) - def config_list_add(self, option, value, temp=False): + def config_list_add(self, option: str, value: str, + temp: bool = False) -> None: """Append a value to a config option that is a list. Args: @@ -263,8 +278,8 @@ class ConfigCommands: opt = self._config.get_opt(option) valid_list_types = (configtypes.List, configtypes.ListOrValue) if not isinstance(opt.typ, valid_list_types): - raise cmdexc.CommandError(":config-list-add can only be used for " - "lists") + raise cmdutils.CommandError(":config-list-add can only be used " + "for lists") with self._handle_config_error(): option_value = self._config.get_mutable_obj(option) @@ -273,7 +288,8 @@ class ConfigCommands: @cmdutils.register(instance='config-commands') @cmdutils.argument('option', completion=configmodel.dict_option) - def config_dict_add(self, option, key, value, temp=False, replace=False): + def config_dict_add(self, option: str, key: str, value: str, + temp: bool = False, replace: bool = False) -> None: """Add a key/value pair to a dictionary option. Args: @@ -286,23 +302,24 @@ class ConfigCommands: """ opt = self._config.get_opt(option) if not isinstance(opt.typ, configtypes.Dict): - raise cmdexc.CommandError(":config-dict-add can only be used for " - "dicts") + raise cmdutils.CommandError(":config-dict-add can only be used " + "for dicts") with self._handle_config_error(): option_value = self._config.get_mutable_obj(option) if key in option_value and not replace: - raise cmdexc.CommandError("{} already exists in {} - use " - "--replace to overwrite!" - .format(key, option)) + raise cmdutils.CommandError("{} already exists in {} - use " + "--replace to overwrite!" + .format(key, option)) option_value[key] = value self._config.update_mutables(save_yaml=not temp) @cmdutils.register(instance='config-commands') @cmdutils.argument('option', completion=configmodel.list_option) - def config_list_remove(self, option, value, temp=False): + def config_list_remove(self, option: str, value: str, + temp: bool = False) -> None: """Remove a value from a list. Args: @@ -313,15 +330,15 @@ class ConfigCommands: opt = self._config.get_opt(option) valid_list_types = (configtypes.List, configtypes.ListOrValue) if not isinstance(opt.typ, valid_list_types): - raise cmdexc.CommandError(":config-list-remove can only be used " - "for lists") + raise cmdutils.CommandError(":config-list-remove can only be used " + "for lists") with self._handle_config_error(): option_value = self._config.get_mutable_obj(option) if value not in option_value: - raise cmdexc.CommandError("{} is not in {}!".format(value, - option)) + raise cmdutils.CommandError("{} is not in {}!".format( + value, option)) option_value.remove(value) @@ -329,7 +346,8 @@ class ConfigCommands: @cmdutils.register(instance='config-commands') @cmdutils.argument('option', completion=configmodel.dict_option) - def config_dict_remove(self, option, key, temp=False): + def config_dict_remove(self, option: str, key: str, + temp: bool = False) -> None: """Remove a key from a dict. Args: @@ -339,22 +357,22 @@ class ConfigCommands: """ opt = self._config.get_opt(option) if not isinstance(opt.typ, configtypes.Dict): - raise cmdexc.CommandError(":config-dict-remove can only be used " - "for dicts") + raise cmdutils.CommandError(":config-dict-remove can only be used " + "for dicts") with self._handle_config_error(): option_value = self._config.get_mutable_obj(option) if key not in option_value: - raise cmdexc.CommandError("{} is not in {}!".format(key, - option)) + raise cmdutils.CommandError("{} is not in {}!".format( + key, option)) del option_value[key] self._config.update_mutables(save_yaml=not temp) @cmdutils.register(instance='config-commands') - def config_clear(self, save=False): + def config_clear(self, save: bool = False) -> None: """Set all settings back to their default. Args: @@ -364,7 +382,7 @@ class ConfigCommands: self._config.clear(save_yaml=save) @cmdutils.register(instance='config-commands') - def config_source(self, filename=None, clear=False): + def config_source(self, filename: str = None, clear: bool = False) -> None: """Read a config.py file. Args: @@ -383,19 +401,19 @@ class ConfigCommands: try: configfiles.read_config_py(filename) except configexc.ConfigFileErrors as e: - raise cmdexc.CommandError(e) + raise cmdutils.CommandError(e) @cmdutils.register(instance='config-commands') - def config_edit(self, no_source=False): + def config_edit(self, no_source: bool = False) -> None: """Open the config.py file in the editor. Args: no_source: Don't re-source the config file after editing. """ - def on_file_updated(): + def on_file_updated() -> None: """Source the new config when editing finished. - This can't use cmdexc.CommandError as it's run async. + This can't use cmdutils.CommandError as it's run async. """ try: configfiles.read_config_py(filename) @@ -410,7 +428,8 @@ class ConfigCommands: ed.edit_file(filename) @cmdutils.register(instance='config-commands') - def config_write_py(self, filename=None, force=False, defaults=False): + def config_write_py(self, filename: str = None, + force: bool = False, defaults: bool = False) -> None: """Write the current configuration to a config.py file. Args: @@ -426,16 +445,16 @@ class ConfigCommands: filename = os.path.expanduser(filename) if os.path.exists(filename) and not force: - raise cmdexc.CommandError("{} already exists - use --force to " - "overwrite!".format(filename)) + raise cmdutils.CommandError("{} already exists - use --force to " + "overwrite!".format(filename)) + options = [] # type: typing.List if defaults: options = [(None, opt, opt.default) for _name, opt in sorted(configdata.DATA.items())] bindings = dict(configdata.DATA['bindings.default'].default) commented = True else: - options = [] for values in self._config: for scoped in values: options.append((scoped.pattern, values.opt, scoped.value)) @@ -447,4 +466,4 @@ class ConfigCommands: try: writer.write(filename) except OSError as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index dace0772a..61e35fd53 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -24,14 +24,18 @@ Module attributes: DATA: A dict of Option objects after init() has been called. """ +import typing +from typing import Optional # pylint: disable=unused-import,useless-suppression import functools import attr from qutebrowser.config import configtypes from qutebrowser.utils import usertypes, qtutils, utils -DATA = None -MIGRATIONS = None +DATA = typing.cast(typing.Mapping[str, 'Option'], None) +MIGRATIONS = typing.cast('Migrations', None) + +_BackendDict = typing.Mapping[str, typing.Union[str, bool]] @attr.s @@ -42,15 +46,15 @@ class Option: Note that this is just an option which exists, with no value associated. """ - name = attr.ib() - typ = attr.ib() - default = attr.ib() - backends = attr.ib() - raw_backends = attr.ib() - description = attr.ib() - supports_pattern = attr.ib(default=False) - restart = attr.ib(default=False) - no_autoconfig = attr.ib(default=False) + name = attr.ib() # type: str + typ = attr.ib() # type: configtypes.BaseType + default = attr.ib() # type: typing.Any + backends = attr.ib() # type: typing.Iterable[usertypes.Backend] + raw_backends = attr.ib() # type: Optional[typing.Mapping[str, bool]] + description = attr.ib() # type: str + supports_pattern = attr.ib(default=False) # type: bool + restart = attr.ib(default=False) # type: bool + no_autoconfig = attr.ib(default=False) # type: bool @attr.s @@ -63,11 +67,13 @@ class Migrations: deleted: A list of option names which have been removed. """ - renamed = attr.ib(default=attr.Factory(dict)) - deleted = attr.ib(default=attr.Factory(list)) + renamed = attr.ib( + default=attr.Factory(dict)) # type: typing.Dict[str, str] + deleted = attr.ib( + default=attr.Factory(list)) # type: typing.List[str] -def _raise_invalid_node(name, what, node): +def _raise_invalid_node(name: str, what: str, node: typing.Any) -> None: """Raise an exception for an invalid configdata YAML node. Args: @@ -79,13 +85,16 @@ def _raise_invalid_node(name, what, node): name, what, node)) -def _parse_yaml_type(name, node): +def _parse_yaml_type( + name: str, + node: typing.Union[str, typing.Mapping[str, typing.Any]], +) -> configtypes.BaseType: if isinstance(node, str): # e.g: # type: Bool # -> create the type object without any arguments type_name = node - kwargs = {} + kwargs = {} # type: typing.MutableMapping[str, typing.Any] elif isinstance(node, dict): # e.g: # type: @@ -123,7 +132,10 @@ def _parse_yaml_type(name, node): type_name, node, e)) -def _parse_yaml_backends_dict(name, node): +def _parse_yaml_backends_dict( + name: str, + node: _BackendDict, +) -> typing.Sequence[usertypes.Backend]: """Parse a dict definition for backends. Example: @@ -160,7 +172,10 @@ def _parse_yaml_backends_dict(name, node): return backends -def _parse_yaml_backends(name, node): +def _parse_yaml_backends( + name: str, + node: typing.Union[None, str, _BackendDict], +) -> typing.Sequence[usertypes.Backend]: """Parse a backend node in the yaml. It can have one of those four forms: @@ -187,7 +202,9 @@ def _parse_yaml_backends(name, node): raise utils.Unreachable -def _read_yaml(yaml_data): +def _read_yaml( + yaml_data: str, +) -> typing.Tuple[typing.Mapping[str, Option], Migrations]: """Read config data from a YAML file. Args: @@ -249,12 +266,12 @@ def _read_yaml(yaml_data): @functools.lru_cache(maxsize=256) -def is_valid_prefix(prefix): +def is_valid_prefix(prefix: str) -> bool: """Check whether the given prefix is a valid prefix for some option.""" return any(key.startswith(prefix + '.') for key in DATA) -def init(): +def init() -> None: """Initialize configdata from the YAML file.""" global DATA, MIGRATIONS DATA, MIGRATIONS = _read_yaml(utils.read_file('config/configdata.yml')) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index ff43cc156..bed4d9659 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -39,12 +39,7 @@ ignore_case: renamed: search.ignore_case search.ignore_case: - type: - name: String - valid_values: - - always: Search case-insensitively. - - never: Search case-sensitively. - - smart: Search case-sensitively if there are capital characters. + type: IgnoreCase default: smart desc: When to find text on a page case-insensitively. diff --git a/qutebrowser/config/configdiff.py b/qutebrowser/config/configdiff.py index 9f8b70a26..ba78f64b4 100644 --- a/qutebrowser/config/configdiff.py +++ b/qutebrowser/config/configdiff.py @@ -19,6 +19,7 @@ """Code to show a diff of the legacy config format.""" +import typing # pylint: disable=unused-import,useless-suppression import difflib import os.path @@ -727,10 +728,10 @@ scroll right """ -def get_diff(): +def get_diff() -> str: """Get a HTML diff for the old config files.""" - old_conf_lines = [] - old_key_lines = [] + old_conf_lines = [] # type: typing.MutableSequence[str] + old_key_lines = [] # type: typing.MutableSequence[str] for filename, dest in [('qutebrowser.conf', old_conf_lines), ('keys.conf', old_key_lines)]: diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index 2a99dfa5c..80a2cedb2 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -19,23 +19,22 @@ """Exceptions related to config parsing.""" +import typing import attr -from qutebrowser.utils import jinja +from qutebrowser.utils import jinja, usertypes class Error(Exception): """Base exception for config-related errors.""" - pass - class NoAutoconfigError(Error): """Raised when this option can't be set in autoconfig.yml.""" - def __init__(self, name): + def __init__(self, name: str) -> None: super().__init__("The {} setting can only be set in config.py!" .format(name)) @@ -44,7 +43,11 @@ class BackendError(Error): """Raised when this setting is unavailable with the current backend.""" - def __init__(self, name, backend, raw_backends): + def __init__( + self, name: str, + backend: usertypes.Backend, + raw_backends: typing.Optional[typing.Mapping[str, bool]] + ) -> None: if raw_backends is None or not raw_backends[backend.name]: msg = ("The {} setting is not available with the {} backend!" .format(name, backend.name)) @@ -59,7 +62,7 @@ class NoPatternError(Error): """Raised when the given setting does not support URL patterns.""" - def __init__(self, name): + def __init__(self, name: str) -> None: super().__init__("The {} setting does not support URL patterns!" .format(name)) @@ -73,7 +76,8 @@ class ValidationError(Error): msg: Additional error message. """ - def __init__(self, value, msg): + def __init__(self, value: typing.Any, + msg: typing.Union[str, Exception]) -> None: super().__init__("Invalid value '{}' - {}".format(value, msg)) self.option = None @@ -87,7 +91,9 @@ class NoOptionError(Error): """Raised when an option was not found.""" - def __init__(self, option, *, deleted=False, renamed=None): + def __init__(self, option: str, *, + deleted: bool = False, + renamed: str = None) -> None: if deleted: assert renamed is None suffix = ' (this option was removed from qutebrowser)' @@ -111,18 +117,18 @@ class ConfigErrorDesc: traceback: The formatted traceback of the exception. """ - text = attr.ib() - exception = attr.ib() - traceback = attr.ib(None) + text = attr.ib() # type: str + exception = attr.ib() # type: typing.Union[str, Exception] + traceback = attr.ib(None) # type: str - def __str__(self): + def __str__(self) -> str: if self.traceback: return '{} - {}: {}'.format(self.text, self.exception.__class__.__name__, self.exception) return '{}: {}'.format(self.text, self.exception) - def with_text(self, text): + def with_text(self, text: str) -> 'ConfigErrorDesc': """Get a new ConfigErrorDesc with the given text appended.""" return self.__class__(text='{} ({})'.format(self.text, text), exception=self.exception, @@ -133,13 +139,15 @@ class ConfigFileErrors(Error): """Raised when multiple errors occurred inside the config.""" - def __init__(self, basename, errors): + def __init__(self, + basename: str, + errors: typing.Sequence[ConfigErrorDesc]) -> None: super().__init__("Errors occurred while reading {}:\n{}".format( basename, '\n'.join(' {}'.format(e) for e in errors))) self.basename = basename self.errors = errors - def to_html(self): + def to_html(self) -> str: """Get the error texts as a HTML snippet.""" template = jinja.environment.from_string(""" Errors occurred while reading {{ basename }}: diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index b4c8ea4ec..54ca91488 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -27,6 +27,7 @@ import textwrap import traceback import configparser import contextlib +import typing import yaml from PyQt5.QtCore import pyqtSignal, QObject, QSettings @@ -36,16 +37,21 @@ from qutebrowser.config import configexc, config, configdata, configutils from qutebrowser.keyinput import keyutils from qutebrowser.utils import standarddir, utils, qtutils, log, urlmatch +MYPY = False +if MYPY: + # pylint: disable=unused-import, useless-suppression + from qutebrowser.misc import savemanager + # The StateConfig instance -state = None +state = typing.cast('StateConfig', None) class StateConfig(configparser.ConfigParser): """The "state" file saving various application state.""" - def __init__(self): + def __init__(self) -> None: super().__init__() self._filename = os.path.join(standarddir.data(), 'state') self.read(self._filename, encoding='utf-8') @@ -59,7 +65,8 @@ class StateConfig(configparser.ConfigParser): for key in deleted_keys: self['general'].pop(key, None) - def init_save_manager(self, save_manager): + def init_save_manager(self, + save_manager: 'savemanager.SaveManager') -> None: """Make sure the config gets saved properly. We do this outside of __init__ because the config gets created before @@ -67,7 +74,7 @@ class StateConfig(configparser.ConfigParser): """ save_manager.add_saveable('state-config', self._save) - def _save(self): + def _save(self) -> None: """Save the state file to the configured location.""" with open(self._filename, 'w', encoding='utf-8') as f: self.write(f) @@ -84,17 +91,20 @@ class YamlConfig(QObject): VERSION = 2 changed = pyqtSignal() - def __init__(self, parent=None): + _SettingsType = typing.Dict[str, typing.Dict[str, typing.Any]] + + def __init__(self, parent: QObject = None) -> None: super().__init__(parent) self._filename = os.path.join(standarddir.config(auto=True), 'autoconfig.yml') - self._dirty = None + self._dirty = False - self._values = {} + self._values = {} # type: typing.Dict[str, configutils.Values] for name, opt in configdata.DATA.items(): self._values[name] = configutils.Values(opt) - def init_save_manager(self, save_manager): + def init_save_manager(self, + save_manager: 'savemanager.SaveManager') -> None: """Make sure the config gets saved properly. We do this outside of __init__ because the config gets created before @@ -102,21 +112,21 @@ class YamlConfig(QObject): """ save_manager.add_saveable('yaml-config', self._save, self.changed) - def __iter__(self): + def __iter__(self) -> typing.Iterator[configutils.Values]: """Iterate over configutils.Values items.""" yield from self._values.values() - def _mark_changed(self): + def _mark_changed(self) -> None: """Mark the YAML config as changed.""" self._dirty = True self.changed.emit() - def _save(self): + def _save(self) -> None: """Save the settings to the YAML file if they've changed.""" if not self._dirty: return - settings = {} + settings = {} # type: YamlConfig._SettingsType for name, values in sorted(self._values.items()): if not values: continue @@ -135,7 +145,10 @@ class YamlConfig(QObject): """.lstrip('\n'))) utils.yaml_dump(data, f) - def _pop_object(self, yaml_data, key, typ): + def _pop_object(self, + yaml_data: typing.Any, + key: str, + typ: type) -> typing.Any: """Get a global object from the given data.""" if not isinstance(yaml_data, dict): desc = configexc.ConfigErrorDesc("While loading data", @@ -158,7 +171,7 @@ class YamlConfig(QObject): return data - def load(self): + def load(self) -> None: """Load configuration from the configured YAML file.""" try: with open(self._filename, 'r', encoding='utf-8') as f: @@ -189,18 +202,19 @@ class YamlConfig(QObject): self._validate(settings) self._build_values(settings) - def _load_settings_object(self, yaml_data): + def _load_settings_object(self, yaml_data: typing.Any) -> '_SettingsType': """Load the settings from the settings: key.""" return self._pop_object(yaml_data, 'settings', dict) - def _load_legacy_settings_object(self, yaml_data): + def _load_legacy_settings_object(self, + yaml_data: typing.Any) -> '_SettingsType': data = self._pop_object(yaml_data, 'global', dict) settings = {} for name, value in data.items(): settings[name] = {'global': value} return settings - def _build_values(self, settings): + def _build_values(self, settings: typing.Mapping) -> None: """Build up self._values from the values in the given dict.""" errors = [] for name, yaml_values in settings.items(): @@ -233,7 +247,8 @@ class YamlConfig(QObject): if errors: raise configexc.ConfigFileErrors('autoconfig.yml', errors) - def _migrate_bool(self, settings, name, true_value, false_value): + def _migrate_bool(self, settings: _SettingsType, name: str, + true_value: str, false_value: str) -> None: """Migrate a boolean in the settings.""" if name in settings: for scope, val in settings[name].items(): @@ -241,7 +256,7 @@ class YamlConfig(QObject): settings[name][scope] = true_value if val else false_value self._mark_changed() - def _handle_migrations(self, settings): + def _handle_migrations(self, settings: _SettingsType) -> '_SettingsType': """Migrate older configs to the newest format.""" # Simple renamed/deleted options for name in list(settings): @@ -299,7 +314,7 @@ class YamlConfig(QObject): return settings - def _validate(self, settings): + def _validate(self, settings: _SettingsType) -> None: """Make sure all settings exist.""" unknown = [] for name in settings: @@ -312,18 +327,19 @@ class YamlConfig(QObject): for e in sorted(unknown)] raise configexc.ConfigFileErrors('autoconfig.yml', errors) - def set_obj(self, name, value, *, pattern=None): + def set_obj(self, name: str, value: typing.Any, *, + pattern: urlmatch.UrlPattern = None) -> None: """Set the given setting to the given value.""" self._values[name].add(value, pattern) self._mark_changed() - def unset(self, name, *, pattern=None): + def unset(self, name: str, *, pattern: urlmatch.UrlPattern = None) -> None: """Remove the given option name if it's configured.""" changed = self._values[name].remove(pattern) if changed: self._mark_changed() - def clear(self): + def clear(self) -> None: """Clear all values from the YAML file.""" for values in self._values.values(): values.clear() @@ -346,15 +362,15 @@ class ConfigAPI: datadir: The qutebrowser data directory, as pathlib.Path. """ - def __init__(self, conf, keyconfig): + def __init__(self, conf: config.Config, keyconfig: config.KeyConfig): self._config = conf self._keyconfig = keyconfig - self.errors = [] + self.errors = [] # type: typing.List[configexc.ConfigErrorDesc] self.configdir = pathlib.Path(standarddir.config()) self.datadir = pathlib.Path(standarddir.data()) @contextlib.contextmanager - def _handle_error(self, action, name): + def _handle_error(self, action: str, name: str) -> typing.Iterator[None]: """Catch config-related exceptions and save them in self.errors.""" try: yield @@ -372,40 +388,40 @@ class ConfigAPI: text = "While {} '{}' and parsing key".format(action, name) self.errors.append(configexc.ConfigErrorDesc(text, e)) - def finalize(self): + def finalize(self) -> None: """Do work which needs to be done after reading config.py.""" self._config.update_mutables() - def load_autoconfig(self): + def load_autoconfig(self) -> None: """Load the autoconfig.yml file which is used for :set/:bind/etc.""" with self._handle_error('reading', 'autoconfig.yml'): read_autoconfig() - def get(self, name, pattern=None): + def get(self, name: str, pattern: str = None) -> typing.Any: """Get a setting value from the config, optionally with a pattern.""" with self._handle_error('getting', name): urlpattern = urlmatch.UrlPattern(pattern) if pattern else None return self._config.get_mutable_obj(name, pattern=urlpattern) - def set(self, name, value, pattern=None): + def set(self, name: str, value: typing.Any, pattern: str = None) -> None: """Set a setting value in the config, optionally with a pattern.""" with self._handle_error('setting', name): urlpattern = urlmatch.UrlPattern(pattern) if pattern else None self._config.set_obj(name, value, pattern=urlpattern) - def bind(self, key, command, mode='normal'): + def bind(self, key: str, command: 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) - def unbind(self, key, mode='normal'): + def unbind(self, key: str, mode: str = 'normal') -> None: """Unbind a key from a command, with an optional key mode.""" with self._handle_error('unbinding', key): seq = keyutils.KeySequence.parse(key) self._keyconfig.unbind(seq, mode=mode) - def source(self, filename): + def source(self, filename: str) -> None: """Read the given config file from disk.""" if not os.path.isabs(filename): filename = str(self.configdir / filename) @@ -416,7 +432,7 @@ class ConfigAPI: self.errors += e.errors @contextlib.contextmanager - def pattern(self, pattern): + def pattern(self, pattern: str) -> typing.Iterator[config.ConfigContainer]: """Get a ConfigContainer for the given pattern.""" # We need to propagate the exception so we don't need to return # something. @@ -430,17 +446,21 @@ class ConfigPyWriter: """Writer for config.py files from given settings.""" - def __init__(self, options, bindings, *, commented): + def __init__( + self, + options: typing.List, + bindings: typing.MutableMapping[str, typing.Mapping[str, str]], *, + commented: bool) -> None: self._options = options self._bindings = bindings self._commented = commented - def write(self, filename): + def write(self, filename: str) -> None: """Write the config to the given file.""" with open(filename, 'w', encoding='utf-8') as f: f.write('\n'.join(self._gen_lines())) - def _line(self, line): + def _line(self, line: str) -> str: """Get an (optionally commented) line.""" if self._commented: if line.startswith('#'): @@ -450,7 +470,7 @@ class ConfigPyWriter: else: return line - def _gen_lines(self): + def _gen_lines(self) -> typing.Iterator[str]: """Generate a config.py with the given settings/bindings. Yields individual lines. @@ -459,7 +479,7 @@ class ConfigPyWriter: yield from self._gen_options() yield from self._gen_bindings() - def _gen_header(self): + def _gen_header(self) -> typing.Iterator[str]: """Generate the initial header of the config.""" yield self._line("# Autogenerated config.py") yield self._line("# Documentation:") @@ -481,7 +501,7 @@ class ConfigPyWriter: yield self._line("# config.load_autoconfig()") yield '' - def _gen_options(self): + def _gen_options(self) -> typing.Iterator[str]: """Generate the options part of the config.""" for pattern, opt, value in self._options: if opt.name in ['bindings.commands', 'bindings.default']: @@ -509,7 +529,7 @@ class ConfigPyWriter: opt.name, value, str(pattern))) yield '' - def _gen_bindings(self): + def _gen_bindings(self) -> typing.Iterator[str]: """Generate the bindings part of the config.""" normal_bindings = self._bindings.pop('normal', {}) if normal_bindings: @@ -527,7 +547,7 @@ class ConfigPyWriter: yield '' -def read_config_py(filename, raising=False): +def read_config_py(filename: str, raising: bool = False) -> None: """Read a config.py file. Arguments; @@ -543,8 +563,8 @@ def read_config_py(filename, raising=False): basename = os.path.basename(filename) module = types.ModuleType('config') - module.config = api - module.c = container + module.config = api # type: ignore + module.c = container # type: ignore module.__file__ = filename try: @@ -589,7 +609,7 @@ def read_config_py(filename, raising=False): raise configexc.ConfigFileErrors('config.py', api.errors) -def read_autoconfig(): +def read_autoconfig() -> None: """Read the autoconfig.yml file.""" try: config.instance.read_yaml() @@ -601,7 +621,7 @@ def read_autoconfig(): @contextlib.contextmanager -def saved_sys_properties(): +def saved_sys_properties() -> typing.Iterator[None]: """Save various sys properties such as sys.path and sys.modules.""" old_path = sys.path.copy() old_modules = sys.modules.copy() @@ -614,7 +634,7 @@ def saved_sys_properties(): del sys.modules[module] -def init(): +def init() -> None: """Initialize config storage not related to the main config.""" global state state = StateConfig() diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index 8480889af..ff0fd0e41 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -19,24 +19,27 @@ """Initialization of the configuration.""" +import argparse import os.path import sys +import typing from PyQt5.QtWidgets import QMessageBox +from qutebrowser.api import config as configapi from qutebrowser.config import (config, configdata, configfiles, configtypes, configexc, configcommands) from qutebrowser.utils import (objreg, usertypes, log, standarddir, message, qtutils) from qutebrowser.config import configcache -from qutebrowser.misc import msgbox, objects +from qutebrowser.misc import msgbox, objects, savemanager # Error which happened during init, so we can show a message box. _init_errors = None -def early_init(args): +def early_init(args: argparse.Namespace) -> None: """Initialize the part of the config which works without a QApplication.""" configdata.init() @@ -44,6 +47,7 @@ def early_init(args): config.instance = config.Config(yaml_config=yaml_config) config.val = config.ConfigContainer(config.instance) + configapi.val = config.ConfigContainer(config.instance) config.key_instance = config.KeyConfig(config.instance) config.cache = configcache.ConfigCache() yaml_config.setParent(config.instance) @@ -83,7 +87,7 @@ def early_init(args): _init_envvars() -def _init_envvars(): +def _init_envvars() -> None: """Initialize environment variables which need to be set early.""" if objects.backend == usertypes.Backend.QtWebEngine: software_rendering = config.val.qt.force_software_rendering @@ -105,7 +109,7 @@ def _init_envvars(): @config.change_filter('fonts.monospace', function=True) -def _update_monospace_fonts(): +def _update_monospace_fonts() -> None: """Update all fonts if fonts.monospace was set.""" configtypes.Font.monospace_fonts = config.val.fonts.monospace for name, opt in configdata.DATA.items(): @@ -121,7 +125,7 @@ def _update_monospace_fonts(): config.instance.changed.emit(name) -def get_backend(args): +def get_backend(args: argparse.Namespace) -> usertypes.Backend: """Find out what backend to use based on available libraries.""" str_to_backend = { 'webkit': usertypes.Backend.QtWebKit, @@ -134,7 +138,7 @@ def get_backend(args): return str_to_backend[config.val.backend] -def late_init(save_manager): +def late_init(save_manager: savemanager.SaveManager) -> None: """Initialize the rest of the config after the QApplication is created.""" global _init_errors if _init_errors is not None: @@ -150,7 +154,7 @@ def late_init(save_manager): configfiles.state.init_save_manager(save_manager) -def qt_args(namespace): +def qt_args(namespace: argparse.Namespace) -> typing.List[str]: """Get the Qt QApplication arguments based on an argparse namespace. Args: @@ -176,7 +180,7 @@ def qt_args(namespace): return argv -def _qtwebengine_args(): +def _qtwebengine_args() -> typing.Iterator[str]: """Get the QtWebEngine arguments to use based on the config.""" if not qtutils.version_check('5.11', compiled=False): # WORKAROUND equivalent to @@ -222,7 +226,7 @@ def _qtwebengine_args(): 'never': '--no-referrers', 'same-domain': '--reduced-referrer-granularity', } - } + } # type: typing.Dict[str, typing.Dict[typing.Any, typing.Optional[str]]] if not qtutils.version_check('5.11'): # On Qt 5.11, we can control this via QWebEngineSettings diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 5503ea4f3..3f134f770 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -52,26 +52,39 @@ import datetime import functools import operator import json +import typing import attr import yaml from PyQt5.QtCore import QUrl, Qt from PyQt5.QtGui import QColor, QFont from PyQt5.QtWidgets import QTabWidget, QTabBar +from PyQt5.QtNetwork import QNetworkProxy -from qutebrowser.commands import cmdutils +from qutebrowser.misc import objects from qutebrowser.config import configexc, configutils -from qutebrowser.utils import standarddir, utils, qtutils, urlutils, urlmatch +from qutebrowser.utils import (standarddir, utils, qtutils, urlutils, urlmatch, + usertypes) from qutebrowser.keyinput import keyutils -SYSTEM_PROXY = object() # Return value for Proxy type +class _SystemProxy: + + pass + + +SYSTEM_PROXY = _SystemProxy() # Return value for Proxy type # Taken from configparser BOOLEAN_STATES = {'1': True, 'yes': True, 'true': True, 'on': True, '0': False, 'no': False, 'false': False, 'off': False} +_Completions = typing.Optional[typing.Iterable[typing.Tuple[str, str]]] +_StrUnset = typing.Union[str, configutils.Unset] +_StrUnsetNone = typing.Union[None, str, configutils.Unset] + + class ValidValues: """Container for valid values for a given type. @@ -82,11 +95,15 @@ class ValidValues: generate_docs: Whether to show the values in the docs. """ - def __init__(self, *values, generate_docs=True): + def __init__(self, + *values: typing.Union[str, + typing.Dict[str, str], + typing.Tuple[str, str]], + generate_docs: bool = True) -> None: if not values: raise ValueError("ValidValues with no values makes no sense!") - self.descriptions = {} - self.values = [] + self.descriptions = {} # type: typing.Dict[str, str] + self.values = [] # type: typing.List[str] self.generate_docs = generate_docs for value in values: if isinstance(value, str): @@ -103,17 +120,18 @@ class ValidValues: self.values.append(value[0]) self.descriptions[value[0]] = value[1] - def __contains__(self, val): + def __contains__(self, val: str) -> bool: return val in self.values - def __iter__(self): + def __iter__(self) -> typing.Iterator[str]: return self.values.__iter__() - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self, values=self.values, descriptions=self.descriptions) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + assert isinstance(other, ValidValues) return (self.values == other.values and self.descriptions == other.descriptions) @@ -130,26 +148,28 @@ class BaseType: string. ValidValues instance. """ - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: self.none_ok = none_ok - self.valid_values = None + self.valid_values = None # type: typing.Optional[ValidValues] - def get_name(self): + def get_name(self) -> str: """Get a name for the type for documentation.""" return self.__class__.__name__ - def get_valid_values(self): + def get_valid_values(self) -> typing.Optional[ValidValues]: """Get the type's valid values for documentation.""" return self.valid_values - def _basic_py_validation(self, value, pytype): + def _basic_py_validation( + self, value: typing.Any, + pytype: typing.Union[type, typing.Tuple[type, ...]]) -> None: """Do some basic validation for Python values (emptyness, type). Arguments: value: The value to check. pytype: A Python type to check the value against. """ - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return if (value is None or (pytype == list and value == []) or @@ -172,7 +192,7 @@ class BaseType: if isinstance(value, str): self._basic_str_validation(value) - def _basic_str_validation(self, value): + def _basic_str_validation(self, value: str) -> None: """Do some basic validation for string values. This checks that the value isn't empty and doesn't contain any @@ -188,7 +208,8 @@ class BaseType: raise configexc.ValidationError( value, "may not contain unprintable chars!") - def _validate_surrogate_escapes(self, full_value, value): + def _validate_surrogate_escapes(self, full_value: typing.Any, + value: typing.Any) -> None: """Make sure the given value doesn't contain surrogate escapes. This is used for values passed to json.dump, as it can't handle those. @@ -199,7 +220,7 @@ class BaseType: raise configexc.ValidationError( full_value, "may not contain surrogate escapes!") - def _validate_valid_values(self, value): + def _validate_valid_values(self, value: str) -> None: """Validate value against possible values. The default implementation checks the value against self.valid_values @@ -214,7 +235,7 @@ class BaseType: value, "valid values: {}".format(', '.join(self.valid_values))) - def from_str(self, value): + def from_str(self, value: str) -> typing.Any: """Get the setting value from a string. By default this invokes to_py() for validation and returns the @@ -233,11 +254,11 @@ class BaseType: return None return value - def from_obj(self, value): + def from_obj(self, value: typing.Any) -> typing.Any: """Get the setting value from a config.py/YAML object.""" return value - def to_py(self, value): + def to_py(self, value: typing.Any) -> typing.Any: """Get the setting value from a Python value. Args: @@ -251,7 +272,7 @@ class BaseType: """ raise NotImplementedError - def to_str(self, value): + def to_str(self, value: typing.Any) -> str: """Get a string from the setting value. The resulting string should be parseable again by from_str. @@ -261,7 +282,7 @@ class BaseType: assert isinstance(value, str), value return value - def to_doc(self, value, indent=0): + def to_doc(self, value: typing.Any, indent: int = 0) -> str: """Get a string with the given value for the documentation. This currently uses asciidoc syntax. @@ -272,7 +293,7 @@ class BaseType: return 'empty' return '+pass:[{}]+'.format(html.escape(str_value)) - def complete(self): + def complete(self) -> _Completions: """Return a list of possible values for completion. The default implementation just returns valid_values, but it might be @@ -304,15 +325,16 @@ class MappingType(BaseType): MAPPING: The mapping to use. """ - MAPPING = {} + MAPPING = {} # type: typing.Dict[str, typing.Any] - def __init__(self, none_ok=False, valid_values=None): + def __init__(self, none_ok: bool = False, + valid_values: ValidValues = None) -> None: super().__init__(none_ok) self.valid_values = valid_values - def to_py(self, value): + def to_py(self, value: typing.Any) -> typing.Any: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -333,9 +355,11 @@ class String(BaseType): completions: completions to be used, or None """ - def __init__(self, *, minlen=None, maxlen=None, forbidden=None, - encoding=None, none_ok=False, completions=None, - valid_values=None): + def __init__(self, *, minlen: int = None, maxlen: int = None, + forbidden: str = None, encoding: str = None, + none_ok: bool = False, + completions: _Completions = None, + valid_values: ValidValues = None) -> None: super().__init__(none_ok) self.valid_values = valid_values @@ -352,7 +376,7 @@ class String(BaseType): self._completions = completions self.encoding = encoding - def _validate_encoding(self, value): + def _validate_encoding(self, value: str) -> None: """Check if the given value fits into the configured encoding. Raises ValidationError if not. @@ -370,9 +394,9 @@ class String(BaseType): value, self.encoding, e) raise configexc.ValidationError(value, msg) - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -393,7 +417,7 @@ class String(BaseType): return value - def complete(self): + def complete(self) -> _Completions: if self._completions is not None: return self._completions else: @@ -404,19 +428,19 @@ class UniqueCharString(String): """A string which may not contain duplicate chars.""" - def to_py(self, value): - value = super().to_py(value) - if value is configutils.UNSET: - return value - elif not value: + def to_py(self, value: _StrUnset) -> _StrUnsetNone: + py_value = super().to_py(value) + if isinstance(py_value, configutils.Unset): + return py_value + elif not py_value: return None # Check for duplicate values - if len(set(value)) != len(value): + if len(set(py_value)) != len(py_value): raise configexc.ValidationError( - value, "String contains duplicate values!") + py_value, "String contains duplicate values!") - return value + return py_value class List(BaseType): @@ -428,21 +452,23 @@ class List(BaseType): _show_valtype = True - def __init__(self, valtype, none_ok=False, length=None): + def __init__(self, valtype: BaseType, + none_ok: bool = False, + length: int = None) -> None: super().__init__(none_ok) self.valtype = valtype self.length = length - def get_name(self): + def get_name(self) -> str: name = super().get_name() if self._show_valtype: name += " of " + self.valtype.get_name() return name - def get_valid_values(self): + def get_valid_values(self) -> typing.Optional[ValidValues]: return self.valtype.get_valid_values() - def from_str(self, value): + def from_str(self, value: str) -> typing.Optional[typing.List]: self._basic_str_validation(value) if not value: return None @@ -457,14 +483,17 @@ class List(BaseType): self.to_py(yaml_val) return yaml_val - def from_obj(self, value): + def from_obj(self, value: typing.Optional[typing.List]) -> typing.List: if value is None: return [] return [self.valtype.from_obj(v) for v in value] - def to_py(self, value): + def to_py( + self, + value: typing.Union[typing.List, configutils.Unset] + ) -> typing.Union[typing.List, configutils.Unset]: self._basic_py_validation(value, list) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return [] @@ -477,13 +506,13 @@ class List(BaseType): "be set!".format(self.length)) return [self.valtype.to_py(v) for v in value] - def to_str(self, value): + def to_str(self, value: typing.List) -> str: if not value: # An empty list is treated just like None -> empty string return '' return json.dumps(value) - def to_doc(self, value, indent=0): + def to_doc(self, value: typing.List, indent: int = 0) -> str: if not value: return 'empty' @@ -511,13 +540,16 @@ class ListOrValue(BaseType): _show_valtype = True - def __init__(self, valtype, *args, none_ok=False, **kwargs): + def __init__(self, valtype: BaseType, *, + none_ok: bool = False, + **kwargs: typing.Any) -> None: super().__init__(none_ok) assert not isinstance(valtype, (List, ListOrValue)), valtype - self.listtype = List(valtype, none_ok=none_ok, *args, **kwargs) + self.listtype = List(valtype, none_ok=none_ok, **kwargs) self.valtype = valtype - def _val_and_type(self, value): + def _val_and_type(self, + value: typing.Any) -> typing.Tuple[typing.Any, BaseType]: """Get the value and type to use for to_str/to_doc/from_str.""" if isinstance(value, list): if len(value) == 1: @@ -527,25 +559,25 @@ class ListOrValue(BaseType): else: return value, self.valtype - def get_name(self): + def get_name(self) -> str: return self.listtype.get_name() + ', or ' + self.valtype.get_name() - def get_valid_values(self): + def get_valid_values(self) -> typing.Optional[ValidValues]: return self.valtype.get_valid_values() - def from_str(self, value): + def from_str(self, value: str) -> typing.Any: try: return self.listtype.from_str(value) except configexc.ValidationError: return self.valtype.from_str(value) - def from_obj(self, value): + def from_obj(self, value: typing.Any) -> typing.Any: if value is None: return [] return value - def to_py(self, value): - if value is configutils.UNSET: + def to_py(self, value: typing.Any) -> typing.Any: + if isinstance(value, configutils.Unset): return value try: @@ -553,14 +585,14 @@ class ListOrValue(BaseType): except configexc.ValidationError: return self.listtype.to_py(value) - def to_str(self, value): + def to_str(self, value: typing.Any) -> str: if value is None: return '' val, typ = self._val_and_type(value) return typ.to_str(val) - def to_doc(self, value, indent=0): + def to_doc(self, value: typing.Any, indent: int = 0) -> str: if value is None: return 'empty' @@ -576,26 +608,31 @@ class FlagList(List): the valid values of the setting. """ - combinable_values = None + combinable_values = None # type: typing.Optional[typing.Sequence] _show_valtype = False - def __init__(self, none_ok=False, valid_values=None, length=None): + def __init__(self, none_ok: bool = False, + valid_values: ValidValues = None, + length: int = None) -> None: super().__init__(valtype=String(), none_ok=none_ok, length=length) self.valtype.valid_values = valid_values - def _check_duplicates(self, values): + def _check_duplicates(self, values: typing.List) -> None: if len(set(values)) != len(values): raise configexc.ValidationError( values, "List contains duplicate values!") - def to_py(self, value): + def to_py( + self, + value: typing.Union[configutils.Unset, typing.List], + ) -> typing.Union[configutils.Unset, typing.List]: vals = super().to_py(value) - if vals is not configutils.UNSET: + if not isinstance(vals, configutils.Unset): self._check_duplicates(vals) return vals - def complete(self): + def complete(self) -> _Completions: valid_values = self.valtype.valid_values if valid_values is None: return None @@ -624,15 +661,15 @@ class Bool(BaseType): while `0`, `no`, `off` and `false` count as false (case-insensitive). """ - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: super().__init__(none_ok) self.valid_values = ValidValues('true', 'false', generate_docs=False) - def to_py(self, value): + def to_py(self, value: typing.Optional[bool]) -> typing.Optional[bool]: self._basic_py_validation(value, bool) return value - def from_str(self, value): + def from_str(self, value: str) -> typing.Optional[bool]: self._basic_str_validation(value) if not value: return None @@ -642,7 +679,7 @@ class Bool(BaseType): except KeyError: raise configexc.ValidationError(value, "must be a boolean!") - def to_str(self, value): + def to_str(self, value: typing.Optional[bool]) -> str: mapping = { None: '', True: 'true', @@ -655,25 +692,27 @@ class BoolAsk(Bool): """Like `Bool`, but `ask` is allowed as additional value.""" - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: super().__init__(none_ok) self.valid_values = ValidValues('true', 'false', 'ask') - def to_py(self, value): + def to_py(self, # type: ignore + value: typing.Union[bool, str]) -> typing.Union[bool, str, None]: # basic validation unneeded if it's == 'ask' and done by Bool if we # call super().to_py if isinstance(value, str) and value.lower() == 'ask': return 'ask' - return super().to_py(value) + return super().to_py(value) # type: ignore - def from_str(self, value): + def from_str(self, # type: ignore + value: str) -> typing.Union[bool, str, None]: # basic validation unneeded if it's == 'ask' and done by Bool if we # call super().from_str if isinstance(value, str) and value.lower() == 'ask': return 'ask' return super().from_str(value) - def to_str(self, value): + def to_str(self, value: typing.Union[bool, str, None]) -> str: mapping = { None: '', True: 'true', @@ -692,7 +731,9 @@ class _Numeric(BaseType): # pylint: disable=abstract-method maxval: Maximum value (inclusive). """ - def __init__(self, minval=None, maxval=None, none_ok=False): + def __init__(self, minval: int = None, + maxval: int = None, + none_ok: bool = False) -> None: super().__init__(none_ok) self.minval = self._parse_bound(minval) self.maxval = self._parse_bound(maxval) @@ -701,7 +742,9 @@ class _Numeric(BaseType): # pylint: disable=abstract-method raise ValueError("minval ({}) needs to be <= maxval ({})!" .format(self.minval, self.maxval)) - def _parse_bound(self, bound): + def _parse_bound( + self, bound: typing.Union[None, str, int, float] + ) -> typing.Union[None, int, float]: """Get a numeric bound from a string like 'maxint'.""" if bound == 'maxint': return qtutils.MAXVALS['int'] @@ -712,7 +755,8 @@ class _Numeric(BaseType): # pylint: disable=abstract-method assert isinstance(bound, (int, float)), bound return bound - def _validate_bounds(self, value, suffix=''): + def _validate_bounds(self, value: typing.Union[None, int, float], + suffix: str = '') -> None: """Validate self.minval and self.maxval.""" if value is None: return @@ -723,7 +767,7 @@ class _Numeric(BaseType): # pylint: disable=abstract-method raise configexc.ValidationError( value, "must be {}{} or smaller!".format(self.maxval, suffix)) - def to_str(self, value): + def to_str(self, value: typing.Union[None, int, float]) -> str: if value is None: return '' return str(value) @@ -733,7 +777,7 @@ class Int(_Numeric): """Base class for an integer setting.""" - def from_str(self, value): + def from_str(self, value: str) -> typing.Optional[int]: self._basic_str_validation(value) if not value: return None @@ -745,7 +789,7 @@ class Int(_Numeric): self.to_py(intval) return intval - def to_py(self, value): + def to_py(self, value: typing.Optional[int]) -> typing.Optional[int]: self._basic_py_validation(value, int) self._validate_bounds(value) return value @@ -755,7 +799,7 @@ class Float(_Numeric): """Base class for a float setting.""" - def from_str(self, value): + def from_str(self, value: str) -> typing.Optional[float]: self._basic_str_validation(value) if not value: return None @@ -767,7 +811,10 @@ class Float(_Numeric): self.to_py(floatval) return floatval - def to_py(self, value): + def to_py( + self, + value: typing.Union[None, int, float], + ) -> typing.Union[None, int, float]: self._basic_py_validation(value, (int, float)) self._validate_bounds(value) return value @@ -777,9 +824,12 @@ class Perc(_Numeric): """A percentage.""" - def to_py(self, value): + def to_py( + self, + value: typing.Union[None, float, int, str, configutils.Unset] + ) -> typing.Union[None, float, int, configutils.Unset]: self._basic_py_validation(value, (float, int, str)) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -794,10 +844,13 @@ class Perc(_Numeric): self._validate_bounds(value, suffix='%') return value - def to_str(self, value): + def to_str(self, value: typing.Union[None, float, int, str]) -> str: if value is None: return '' - return value + elif isinstance(value, str): + return value + else: + return '{}%'.format(value) class PercOrInt(_Numeric): @@ -811,8 +864,9 @@ class PercOrInt(_Numeric): maxint: Maximum value for integer (inclusive). """ - def __init__(self, minperc=None, maxperc=None, minint=None, maxint=None, - none_ok=False): + def __init__(self, minperc: int = None, maxperc: int = None, + minint: int = None, maxint: int = None, + none_ok: bool = False) -> None: super().__init__(minval=minint, maxval=maxint, none_ok=none_ok) self.minperc = self._parse_bound(minperc) self.maxperc = self._parse_bound(maxperc) @@ -821,7 +875,7 @@ class PercOrInt(_Numeric): raise ValueError("minperc ({}) needs to be <= maxperc " "({})!".format(self.minperc, self.maxperc)) - def from_str(self, value): + def from_str(self, value: str) -> typing.Union[None, str, int]: self._basic_str_validation(value) if not value: return None @@ -838,7 +892,10 @@ class PercOrInt(_Numeric): self.to_py(intval) return intval - def to_py(self, value): + def to_py( + self, + value: typing.Union[None, str, int] + ) -> typing.Union[None, str, int]: """Expect a value like '42%' as string, or 23 as int.""" self._basic_py_validation(value, (int, str)) if value is None: @@ -878,13 +935,13 @@ class Command(BaseType): invalid commands (in bindings/aliases) fail when used. """ - def complete(self): + def complete(self) -> _Completions: out = [] - for cmdname, obj in cmdutils.cmd_dict.items(): + for cmdname, obj in objects.commands.items(): out.append((cmdname, obj.desc)) return out - def to_py(self, value): + def to_py(self, value: str) -> str: self._basic_py_validation(value, str) return value @@ -893,7 +950,7 @@ class ColorSystem(MappingType): """The color system to use for color interpolation.""" - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: super().__init__( none_ok, valid_values=ValidValues( @@ -910,6 +967,26 @@ class ColorSystem(MappingType): } +class IgnoreCase(MappingType): + + """Whether to search case insensitively.""" + + def __init__(self, none_ok: bool = False) -> None: + super().__init__( + none_ok, + valid_values=ValidValues( + ('always', "Search case-insensitively."), + ('never', "Search case-sensitively."), + ('smart', ("Search case-sensitively if there are capital " + "characters.")))) + + MAPPING = { + 'always': usertypes.IgnoreCase.always, + 'never': usertypes.IgnoreCase.never, + 'smart': usertypes.IgnoreCase.smart, + } + + class QtColor(BaseType): """A color value. @@ -924,7 +1001,7 @@ class QtColor(BaseType): * `hsv(h, s, v)` / `hsva(h, s, v, a)` (values 0-255, hue 0-359) """ - def _parse_value(self, val): + def _parse_value(self, val: str) -> int: try: return int(val) except ValueError: @@ -940,9 +1017,10 @@ class QtColor(BaseType): except ValueError: raise configexc.ValidationError(val, "must be a valid color value") - def to_py(self, value): + def to_py(self, value: _StrUnset) -> typing.Union[configutils.Unset, + None, QColor]: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -951,15 +1029,15 @@ class QtColor(BaseType): openparen = value.index('(') kind = value[:openparen] vals = value[openparen+1:-1].split(',') - vals = [self._parse_value(v) for v in vals] - if kind == 'rgba' and len(vals) == 4: - return QColor.fromRgb(*vals) - elif kind == 'rgb' and len(vals) == 3: - return QColor.fromRgb(*vals) - elif kind == 'hsva' and len(vals) == 4: - return QColor.fromHsv(*vals) - elif kind == 'hsv' and len(vals) == 3: - return QColor.fromHsv(*vals) + int_vals = [self._parse_value(v) for v in vals] + if kind == 'rgba' and len(int_vals) == 4: + return QColor.fromRgb(*int_vals) + elif kind == 'rgb' and len(int_vals) == 3: + return QColor.fromRgb(*int_vals) + elif kind == 'hsva' and len(int_vals) == 4: + return QColor.fromHsv(*int_vals) + elif kind == 'hsv' and len(int_vals) == 3: + return QColor.fromHsv(*int_vals) else: raise configexc.ValidationError(value, "must be a valid color") @@ -987,9 +1065,9 @@ class QssColor(BaseType): under ``Gradient'' """ - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1034,9 +1112,9 @@ class Font(BaseType): )* # 0-inf size/weight/style tags (?P.+) # mandatory font family""", re.VERBOSE) - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1055,9 +1133,9 @@ class FontFamily(Font): """A Qt font family.""" - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1081,9 +1159,10 @@ class QtFont(Font): __doc__ = Font.__doc__ # for src2asciidoc.py - def to_py(self, value): + def to_py(self, value: _StrUnset) -> typing.Union[configutils.Unset, + None, QFont]: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1118,7 +1197,7 @@ class QtFont(Font): font.setWeight(weight_map[namedweight]) if weight: # based on qcssparser.cpp:setFontWeightFromValue - font.setWeight(min(int(weight) / 8, 99)) + font.setWeight(min(int(weight) // 8, 99)) if size: if size.lower().endswith('pt'): font.setPointSizeF(float(size[:-2])) @@ -1154,18 +1233,19 @@ class Regex(BaseType): _regex_type: The Python type of a regex object. """ - def __init__(self, flags=0, none_ok=False): + def __init__(self, flags: str = None, + none_ok: bool = False) -> None: super().__init__(none_ok) self._regex_type = type(re.compile('')) # Parse flags from configdata.yml - if flags == 0: - self.flags = flags + if flags is None: + self.flags = 0 else: self.flags = functools.reduce( operator.or_, (getattr(re, flag.strip()) for flag in flags.split(' | '))) - def _compile_regex(self, pattern): + def _compile_regex(self, pattern: str) -> typing.Pattern[str]: """Check if the given regex is valid. This is more complicated than it could be since there's a warning on @@ -1184,6 +1264,8 @@ class Regex(BaseType): pattern, "must be a valid regex - recursion depth " "exceeded") + assert recorded_warnings is not None + for w in recorded_warnings: if (issubclass(w.category, DeprecationWarning) and str(w.message).startswith('bad escape')): @@ -1194,10 +1276,13 @@ class Regex(BaseType): return compiled - def to_py(self, value): + def to_py( + self, + value: typing.Union[str, typing.Pattern[str]] + ) -> typing.Union[configutils.Unset, None, typing.Pattern[str]]: """Get a compiled regex from either a string or a regex object.""" self._basic_py_validation(value, (str, self._regex_type)) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1206,12 +1291,14 @@ class Regex(BaseType): else: return value - def to_str(self, value): + def to_str(self, + value: typing.Union[None, str, typing.Pattern[str]]) -> str: if value is None: return '' elif isinstance(value, self._regex_type): return value.pattern else: + assert isinstance(value, str) return value @@ -1222,8 +1309,11 @@ class Dict(BaseType): When setting from a string, pass a json-like dict, e.g. `{"key", "value"}`. """ - def __init__(self, keytype, valtype, *, fixed_keys=None, - required_keys=None, none_ok=False): + def __init__(self, keytype: typing.Union[String, 'Key'], + valtype: BaseType, *, + fixed_keys: typing.Iterable = None, + required_keys: typing.Iterable = None, + none_ok: bool = False) -> None: super().__init__(none_ok) # If the keytype is not a string, we'll get problems with showing it as # json in to_str() as json converts keys to strings. @@ -1233,7 +1323,7 @@ class Dict(BaseType): self.fixed_keys = fixed_keys self.required_keys = required_keys - def _validate_keys(self, value): + def _validate_keys(self, value: typing.Dict) -> None: if (self.fixed_keys is not None and not set(value.keys()).issubset(self.fixed_keys)): raise configexc.ValidationError( @@ -1244,7 +1334,7 @@ class Dict(BaseType): raise configexc.ValidationError( value, "Required keys {}".format(self.required_keys)) - def from_str(self, value): + def from_str(self, value: str) -> typing.Optional[typing.Dict]: self._basic_str_validation(value) if not value: return None @@ -1259,14 +1349,14 @@ class Dict(BaseType): self.to_py(yaml_val) return yaml_val - def from_obj(self, value): + def from_obj(self, value: typing.Optional[typing.Dict]) -> typing.Dict: if value is None: return {} return {self.keytype.from_obj(key): self.valtype.from_obj(val) for key, val in value.items()} - def _fill_fixed_keys(self, value): + def _fill_fixed_keys(self, value: typing.Dict) -> typing.Dict: """Fill missing fixed keys with a None-value.""" if self.fixed_keys is None: return value @@ -1275,9 +1365,12 @@ class Dict(BaseType): value[key] = self.valtype.to_py(None) return value - def to_py(self, value): + def to_py( + self, + value: typing.Union[typing.Dict, configutils.Unset, None] + ) -> typing.Union[typing.Dict, configutils.Unset]: self._basic_py_validation(value, dict) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return self._fill_fixed_keys({}) @@ -1291,13 +1384,13 @@ class Dict(BaseType): for key, val in value.items()} return self._fill_fixed_keys(d) - def to_str(self, value): + def to_str(self, value: typing.Dict) -> str: if not value: # An empty Dict is treated just like None -> empty string return '' return json.dumps(value, sort_keys=True) - def to_doc(self, value, indent=0): + def to_doc(self, value: typing.Dict, indent: int = 0) -> str: if not value: return 'empty' lines = ['\n'] @@ -1315,13 +1408,13 @@ class File(BaseType): """A file on the local filesystem.""" - def __init__(self, required=True, **kwargs): + def __init__(self, required: bool = True, **kwargs: typing.Any) -> None: super().__init__(**kwargs) self.required = required - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1347,9 +1440,9 @@ class Directory(BaseType): """A directory on the local filesystem.""" - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1372,13 +1465,14 @@ class FormatString(BaseType): """A string with placeholders.""" - def __init__(self, fields, none_ok=False): + def __init__(self, fields: typing.Iterable[str], + none_ok: bool = False) -> None: super().__init__(none_ok) self.fields = fields - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1406,39 +1500,46 @@ class ShellCommand(List): _show_valtype = False - def __init__(self, placeholder=False, none_ok=False): + def __init__(self, placeholder: bool = False, + none_ok: bool = False) -> None: super().__init__(valtype=String(), none_ok=none_ok) self.placeholder = placeholder - def to_py(self, value): - value = super().to_py(value) - if value is configutils.UNSET: - return value - elif not value: + def to_py( + self, + value: typing.Union[typing.List, configutils.Unset], + ) -> typing.Union[typing.List, configutils.Unset]: + py_value = super().to_py(value) + if isinstance(py_value, configutils.Unset): + return py_value + elif not py_value: return [] if (self.placeholder and - '{}' not in ' '.join(value) and - '{file}' not in ' '.join(value)): - raise configexc.ValidationError(value, "needs to contain a " + '{}' not in ' '.join(py_value) and + '{file}' not in ' '.join(py_value)): + raise configexc.ValidationError(py_value, "needs to contain a " "{}-placeholder or a " "{file}-placeholder.") - return value + return py_value class Proxy(BaseType): """A proxy URL, or `system`/`none`.""" - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: super().__init__(none_ok) self.valid_values = ValidValues( ('system', "Use the system wide proxy."), ('none', "Don't use any proxy")) - def to_py(self, value): + def to_py( + self, + value: _StrUnset + ) -> typing.Union[configutils.Unset, None, QNetworkProxy, _SystemProxy]: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1452,13 +1553,15 @@ class Proxy(BaseType): else: # If we add a special value to valid_values, we need to handle # it here! + assert self.valid_values is not None assert value not in self.valid_values, value url = QUrl(value) return urlutils.proxy_from_url(url) except (urlutils.InvalidUrlError, urlutils.InvalidProxyTypeError) as e: raise configexc.ValidationError(value, e) - def complete(self): + def complete(self) -> _Completions: + assert self.valid_values is not None out = [] for val in self.valid_values: out.append((val, self.valid_values.descriptions[val])) @@ -1474,9 +1577,9 @@ class SearchEngineUrl(BaseType): """A search engine URL.""" - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1504,9 +1607,9 @@ class FuzzyUrl(BaseType): """A URL which gets interpreted as search if needed.""" - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1522,10 +1625,10 @@ class PaddingValues: """Four padding values.""" - top = attr.ib() - bottom = attr.ib() - left = attr.ib() - right = attr.ib() + top = attr.ib() # type: int + bottom = attr.ib() # type: int + left = attr.ib() # type: int + right = attr.ib() # type: int class Padding(Dict): @@ -1534,15 +1637,18 @@ class Padding(Dict): _show_valtype = False - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: super().__init__(keytype=String(), valtype=Int(minval=0, none_ok=none_ok), fixed_keys=['top', 'bottom', 'left', 'right'], none_ok=none_ok) - def to_py(self, value): + def to_py( # type: ignore + self, + value: typing.Union[configutils.Unset, typing.Dict, None], + ) -> typing.Union[configutils.Unset, PaddingValues]: d = super().to_py(value) - if d is configutils.UNSET: + if isinstance(d, configutils.Unset): return d return PaddingValues(**d) @@ -1552,9 +1658,9 @@ class Encoding(BaseType): """Setting for a python encoding.""" - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1576,7 +1682,7 @@ class Position(MappingType): 'right': QTabWidget.East, } - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: super().__init__( none_ok, valid_values=ValidValues('top', 'bottom', 'left', 'right')) @@ -1592,7 +1698,7 @@ class TextAlignment(MappingType): 'center': Qt.AlignCenter, } - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: super().__init__( none_ok, valid_values=ValidValues('left', 'right', 'center')) @@ -1602,7 +1708,7 @@ class VerticalPosition(String): """The position of the download bar.""" - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: super().__init__(none_ok=none_ok) self.valid_values = ValidValues('top', 'bottom') @@ -1611,9 +1717,12 @@ class Url(BaseType): """A URL as a string.""" - def to_py(self, value): + def to_py( + self, + value: _StrUnset + ) -> typing.Union[configutils.Unset, None, QUrl]: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1629,9 +1738,9 @@ class SessionName(BaseType): """The name of a session.""" - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1650,7 +1759,7 @@ class SelectOnRemove(MappingType): 'last-used': QTabBar.SelectPreviousTab, } - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: super().__init__( none_ok, valid_values=ValidValues( @@ -1668,7 +1777,7 @@ class ConfirmQuit(FlagList): # Values that can be combined with commas combinable_values = ('multiple-tabs', 'downloads') - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: super().__init__(none_ok) self.valtype.none_ok = none_ok self.valtype.valid_values = ValidValues( @@ -1679,9 +1788,12 @@ class ConfirmQuit(FlagList): "downloads are running"), ('never', "Never show a confirmation.")) - def to_py(self, value): + def to_py( + self, + value: typing.Union[configutils.Unset, typing.List], + ) -> typing.Union[typing.List, configutils.Unset]: values = super().to_py(value) - if values is configutils.UNSET: + if isinstance(values, configutils.Unset): return values elif not values: return [] @@ -1702,7 +1814,7 @@ class NewTabPosition(String): """How new tabs are positioned.""" - def __init__(self, none_ok=False): + def __init__(self, none_ok: bool = False) -> None: super().__init__(none_ok=none_ok) self.valid_values = ValidValues( ('prev', "Before the current tab."), @@ -1718,9 +1830,9 @@ class TimestampTemplate(BaseType): See https://sqlite.org/lang_datefunc.html for reference. """ - def to_py(self, value): + def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1740,13 +1852,16 @@ class Key(BaseType): """A name of a key.""" - def from_obj(self, value): + def from_obj(self, value: str) -> str: """Make sure key sequences are always normalized.""" return str(keyutils.KeySequence.parse(value)) - def to_py(self, value): + def to_py( + self, + value: _StrUnset + ) -> typing.Union[configutils.Unset, None, keyutils.KeySequence]: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None @@ -1765,9 +1880,12 @@ class UrlPattern(BaseType): syntax. """ - def to_py(self, value): + def to_py( + self, + value: _StrUnset + ) -> typing.Union[configutils.Unset, None, urlmatch.UrlPattern]: self._basic_py_validation(value, str) - if value is configutils.UNSET: + if isinstance(value, configutils.Unset): return value elif not value: return None diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py index 96fc0f02d..47cac4bff 100644 --- a/qutebrowser/config/configutils.py +++ b/qutebrowser/config/configutils.py @@ -21,23 +21,31 @@ """Utilities and data structures used by various config code.""" -import attr +import typing -from qutebrowser.utils import utils +import attr +from PyQt5.QtCore import QUrl + +from qutebrowser.utils import utils, urlmatch from qutebrowser.config import configexc +MYPY = False +if MYPY: + # pylint: disable=unused-import,useless-suppression + from qutebrowser.config import configdata -class _UnsetObject: + +class Unset: """Sentinel object.""" __slots__ = () - def __repr__(self): + def __repr__(self) -> str: return '' -UNSET = _UnsetObject() +UNSET = Unset() @attr.s @@ -50,8 +58,8 @@ class ScopedValue: pattern: The UrlPattern for the value, or None for global values. """ - value = attr.ib() - pattern = attr.ib() + value = attr.ib() # type: typing.Any + pattern = attr.ib() # type: typing.Optional[urlmatch.UrlPattern] class Values: @@ -73,15 +81,17 @@ class Values: opt: The Option being customized. """ - def __init__(self, opt, values=None): + def __init__(self, + opt: 'configdata.Option', + values: typing.MutableSequence = None) -> None: self.opt = opt self._values = values or [] - def __repr__(self): + def __repr__(self) -> str: return utils.get_repr(self, opt=self.opt, values=self._values, constructor=True) - def __str__(self): + def __str__(self) -> str: """Get the values as human-readable string.""" if not self: return '{}: '.format(self.opt.name) @@ -96,7 +106,7 @@ class Values: scoped.pattern, self.opt.name, str_value)) return '\n'.join(lines) - def __iter__(self): + def __iter__(self) -> typing.Iterator['ScopedValue']: """Yield ScopedValue elements. This yields in "normal" order, i.e. global and then first-set settings @@ -104,23 +114,25 @@ class Values: """ yield from self._values - def __bool__(self): + def __bool__(self) -> bool: """Check whether this value is customized.""" return bool(self._values) - def _check_pattern_support(self, arg): + def _check_pattern_support( + self, arg: typing.Optional[urlmatch.UrlPattern]) -> None: """Make sure patterns are supported if one was given.""" if arg is not None and not self.opt.supports_pattern: raise configexc.NoPatternError(self.opt.name) - def add(self, value, pattern=None): + def add(self, value: typing.Any, + pattern: urlmatch.UrlPattern = None) -> None: """Add a value with the given pattern to the list of values.""" self._check_pattern_support(pattern) self.remove(pattern) scoped = ScopedValue(value, pattern) self._values.append(scoped) - def remove(self, pattern=None): + def remove(self, pattern: urlmatch.UrlPattern = None) -> bool: """Remove the value with the given pattern. If a matching pattern was removed, True is returned. @@ -131,11 +143,11 @@ class Values: self._values = [v for v in self._values if v.pattern != pattern] return old_len != len(self._values) - def clear(self): + def clear(self) -> None: """Clear all customization for this value.""" self._values = [] - def _get_fallback(self, fallback): + def _get_fallback(self, fallback: typing.Any) -> typing.Any: """Get the fallback global/default value.""" for scoped in self._values: if scoped.pattern is None: @@ -146,7 +158,8 @@ class Values: else: return UNSET - def get_for_url(self, url=None, *, fallback=True): + def get_for_url(self, url: QUrl = None, *, + fallback: bool = True) -> typing.Any: """Get a config value, falling back when needed. This first tries to find a value matching the URL (if given). @@ -165,7 +178,9 @@ class Values: return self._get_fallback(fallback) - def get_for_pattern(self, pattern, *, fallback=True): + def get_for_pattern(self, + pattern: typing.Optional[urlmatch.UrlPattern], *, + fallback: bool = True) -> typing.Any: """Get a value only if it's been overridden for the given pattern. This is useful when showing values to the user. diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index fb80c543b..5a21af7e3 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -19,6 +19,10 @@ """Bridge from QWeb(Engine)Settings to our own settings.""" +import typing +import argparse + +from PyQt5.QtCore import QUrl from PyQt5.QtGui import QFont from qutebrowser.config import config, configutils @@ -32,7 +36,8 @@ class AttributeInfo: """Info about a settings attribute.""" - def __init__(self, *attributes, converter=None): + def __init__(self, *attributes: typing.Any, + converter: typing.Callable = None) -> None: self.attributes = attributes if converter is None: self.converter = lambda val: val @@ -44,15 +49,15 @@ class AbstractSettings: """Abstract base class for settings set via QWeb(Engine)Settings.""" - _ATTRIBUTES = None - _FONT_SIZES = None - _FONT_FAMILIES = None - _FONT_TO_QFONT = None + _ATTRIBUTES = {} # type: typing.Dict[str, AttributeInfo] + _FONT_SIZES = {} # type: typing.Dict[str, typing.Any] + _FONT_FAMILIES = {} # type: typing.Dict[str, typing.Any] + _FONT_TO_QFONT = {} # type: typing.Dict[typing.Any, QFont.StyleHint] - def __init__(self, settings): + def __init__(self, settings: typing.Any) -> None: self._settings = settings - def set_attribute(self, name, value): + def set_attribute(self, name: str, value: typing.Any) -> bool: """Set the given QWebSettings/QWebEngineSettings attribute. If the value is configutils.UNSET, the value is reset instead. @@ -73,7 +78,7 @@ class AbstractSettings: return old_value != new_value - def test_attribute(self, name): + def test_attribute(self, name: str) -> bool: """Get the value for the given attribute. If the setting resolves to a list of attributes, only the first @@ -82,7 +87,7 @@ class AbstractSettings: info = self._ATTRIBUTES[name] return self._settings.testAttribute(info.attributes[0]) - def set_font_size(self, name, value): + def set_font_size(self, name: str, value: int) -> bool: """Set the given QWebSettings/QWebEngineSettings font size. Return: @@ -94,7 +99,7 @@ class AbstractSettings: self._settings.setFontSize(family, value) return old_value != value - def set_font_family(self, name, value): + def set_font_family(self, name: str, value: typing.Optional[str]) -> bool: """Set the given QWebSettings/QWebEngineSettings font family. With None (the default), QFont is used to get the default font for the @@ -115,7 +120,7 @@ class AbstractSettings: return value != old_value - def set_default_text_encoding(self, encoding): + def set_default_text_encoding(self, encoding: str) -> bool: """Set the default text encoding to use. Return: @@ -126,7 +131,7 @@ class AbstractSettings: self._settings.setDefaultTextEncoding(encoding) return old_value != encoding - def _update_setting(self, setting, value): + def _update_setting(self, setting: str, value: typing.Any) -> bool: """Update the given setting/value. Unknown settings are ignored. @@ -144,12 +149,12 @@ class AbstractSettings: return self.set_default_text_encoding(value) return False - def update_setting(self, setting): + def update_setting(self, setting: str) -> None: """Update the given setting.""" value = config.instance.get(setting) self._update_setting(setting, value) - def update_for_url(self, url): + def update_for_url(self, url: QUrl) -> typing.Set[str]: """Update settings customized for the given tab. Return: @@ -171,14 +176,14 @@ class AbstractSettings: return changed_settings - def init_settings(self): + def init_settings(self) -> None: """Set all supported settings correctly.""" for setting in (list(self._ATTRIBUTES) + list(self._FONT_SIZES) + list(self._FONT_FAMILIES)): self.update_setting(setting) -def init(args): +def init(args: argparse.Namespace) -> None: """Initialize all QWeb(Engine)Settings.""" if objects.backend == usertypes.Backend.QtWebEngine: from qutebrowser.browser.webengine import webenginesettings @@ -193,7 +198,7 @@ def init(args): pattern=urlmatch.UrlPattern(pattern)) -def shutdown(): +def shutdown() -> None: """Shut down QWeb(Engine)Settings.""" if objects.backend == usertypes.Backend.QtWebEngine: from qutebrowser.browser.webengine import webenginesettings diff --git a/qutebrowser/extensions/__init__.py b/qutebrowser/extensions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/qutebrowser/extensions/interceptors.py b/qutebrowser/extensions/interceptors.py new file mode 100644 index 000000000..7defcf213 --- /dev/null +++ b/qutebrowser/extensions/interceptors.py @@ -0,0 +1,63 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Infrastructure for intercepting requests.""" + +import typing + +import attr + +MYPY = False +if MYPY: + # pylint: disable=unused-import,useless-suppression + from PyQt5.QtCore import QUrl + + +@attr.s +class Request: + + """A request which can be intercepted/blocked.""" + + #: The URL of the page being shown. + first_party_url = attr.ib() # type: QUrl + + #: The URL of the file being requested. + request_url = attr.ib() # type: QUrl + + is_blocked = attr.ib(False) # type: bool + + def block(self) -> None: + """Block this request.""" + self.is_blocked = True + + +#: Type annotation for an interceptor function. +InterceptorType = typing.Callable[[Request], None] + + +_interceptors = [] # type: typing.List[InterceptorType] + + +def register(interceptor: InterceptorType) -> None: + _interceptors.append(interceptor) + + +def run(info: Request) -> None: + for interceptor in _interceptors: + interceptor(info) diff --git a/qutebrowser/extensions/loader.py b/qutebrowser/extensions/loader.py new file mode 100644 index 000000000..1383adfef --- /dev/null +++ b/qutebrowser/extensions/loader.py @@ -0,0 +1,187 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Loader for qutebrowser extensions.""" + +import importlib.abc +import pkgutil +import types +import typing +import sys +import pathlib + +import attr + +from PyQt5.QtCore import pyqtSlot + +from qutebrowser import components +from qutebrowser.config import config +from qutebrowser.utils import log, standarddir, objreg + +MYPY = False +if MYPY: + # pylint: disable=unused-import,useless-suppression + import argparse + + +# ModuleInfo objects for all loaded plugins +_module_infos = [] + + +@attr.s +class InitContext: + + """Context an extension gets in its init hook.""" + + data_dir = attr.ib() # type: pathlib.Path + config_dir = attr.ib() # type: pathlib.Path + args = attr.ib() # type: argparse.Namespace + + +@attr.s +class ModuleInfo: + + """Information attached to an extension module. + + This gets used by qutebrowser.api.hook. + """ + + _ConfigChangedHooksType = typing.List[typing.Tuple[typing.Optional[str], + typing.Callable]] + + skip_hooks = attr.ib(False) # type: bool + init_hook = attr.ib(None) # type: typing.Optional[typing.Callable] + config_changed_hooks = attr.ib( + attr.Factory(list)) # type: _ConfigChangedHooksType + + +@attr.s +class ExtensionInfo: + + """Information about a qutebrowser extension.""" + + name = attr.ib() # type: str + + +def add_module_info(module: types.ModuleType) -> ModuleInfo: + """Add ModuleInfo to a module (if not added yet).""" + # pylint: disable=protected-access + if not hasattr(module, '__qute_module_info'): + module.__qute_module_info = ModuleInfo() # type: ignore + return module.__qute_module_info # type: ignore + + +def load_components(*, skip_hooks: bool = False) -> None: + """Load everything from qutebrowser.components.""" + for info in walk_components(): + _load_component(info, skip_hooks=skip_hooks) + + +def walk_components() -> typing.Iterator[ExtensionInfo]: + """Yield ExtensionInfo objects for all modules.""" + if hasattr(sys, 'frozen'): + yield from _walk_pyinstaller() + else: + yield from _walk_normal() + + +def _on_walk_error(name: str) -> None: + raise ImportError("Failed to import {}".format(name)) + + +def _walk_normal() -> typing.Iterator[ExtensionInfo]: + """Walk extensions when not using PyInstaller.""" + for _finder, name, ispkg in pkgutil.walk_packages( + # Only packages have a __path__ attribute, + # but we're sure this is one. + path=components.__path__, # type: ignore + prefix=components.__name__ + '.', + onerror=_on_walk_error): + if ispkg: + continue + yield ExtensionInfo(name=name) + + +def _walk_pyinstaller() -> typing.Iterator[ExtensionInfo]: + """Walk extensions when using PyInstaller. + + See https://github.com/pyinstaller/pyinstaller/issues/1905 + + Inspired by: + https://github.com/webcomics/dosage/blob/master/dosagelib/loader.py + """ + toc = set() # type: typing.Set[str] + for importer in pkgutil.iter_importers('qutebrowser'): + if hasattr(importer, 'toc'): + toc |= importer.toc + for name in toc: + if name.startswith(components.__name__ + '.'): + yield ExtensionInfo(name=name) + + +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')) + + +def _load_component(info: ExtensionInfo, *, + skip_hooks: bool = False) -> types.ModuleType: + """Load the given extension and run its init hook (if any). + + Args: + skip_hooks: Whether to skip all hooks for this module. + This is used to only run @cmdutils.register decorators. + """ + log.extensions.debug("Importing {}".format(info.name)) + mod = importlib.import_module(info.name) + + mod_info = add_module_info(mod) + if skip_hooks: + mod_info.skip_hooks = True + + if mod_info.init_hook is not None and not skip_hooks: + log.extensions.debug("Running init hook {!r}" + .format(mod_info.init_hook.__name__)) + mod_info.init_hook(_get_init_context()) + + _module_infos.append(mod_info) + + return mod + + +@pyqtSlot(str) +def _on_config_changed(changed_name: str) -> None: + """Call config_changed hooks if the config changed.""" + for mod_info in _module_infos: + if mod_info.skip_hooks: + continue + for option, hook in mod_info.config_changed_hooks: + if option is None: + hook() + else: + cfilter = config.change_filter(option) + cfilter.validate() + if cfilter.check_match(changed_name): + hook() + + +def init() -> None: + config.instance.changed.connect(_on_config_changed) diff --git a/qutebrowser/html/error.html b/qutebrowser/html/error.html index b3e6f576e..e03ddad67 100644 --- a/qutebrowser/html/error.html +++ b/qutebrowser/html/error.html @@ -61,7 +61,7 @@ li { {{ super() }} function tryagain() { - location.href = "{{ url }}"; + location.href = {{ url|tojson }}; } {% endblock %} diff --git a/qutebrowser/javascript/caret.js b/qutebrowser/javascript/caret.js index 28ab3fab0..5e6640311 100644 --- a/qutebrowser/javascript/caret.js +++ b/qutebrowser/javascript/caret.js @@ -1,5 +1,5 @@ /* eslint-disable max-len, max-statements, complexity, -default-case, valid-jsdoc */ +default-case */ // Copyright 2014 The Chromium Authors. All rights reserved. // diff --git a/qutebrowser/javascript/greasemonkey_wrapper.js b/qutebrowser/javascript/greasemonkey_wrapper.js index 457118696..f8c36151a 100644 --- a/qutebrowser/javascript/greasemonkey_wrapper.js +++ b/qutebrowser/javascript/greasemonkey_wrapper.js @@ -60,7 +60,7 @@ details.method = details.method ? details.method.toUpperCase() : "GET"; if (!details.url) { - throw new Error("GM_xmlhttpRequest requires an URL."); + throw new Error("GM_xmlhttpRequest requires a URL."); } // build XMLHttpRequest object diff --git a/qutebrowser/keyinput/macros.py b/qutebrowser/keyinput/macros.py index 97b0b103b..5bf1ab18b 100644 --- a/qutebrowser/keyinput/macros.py +++ b/qutebrowser/keyinput/macros.py @@ -19,7 +19,8 @@ """Keyboard macro system.""" -from qutebrowser.commands import cmdexc, cmdutils, runners +from qutebrowser.commands import runners +from qutebrowser.api import cmdutils from qutebrowser.keyinput import modeman from qutebrowser.utils import message, objreg, usertypes @@ -44,7 +45,7 @@ class MacroRecorder: self._last_register = None @cmdutils.register(instance='macro-recorder', name='record-macro') - @cmdutils.argument('win_id', win_id=True) + @cmdutils.argument('win_id', value=cmdutils.Value.win_id) def record_macro_command(self, win_id, register=None): """Start or stop recording a macro. @@ -69,8 +70,8 @@ class MacroRecorder: self._recording_macro = register @cmdutils.register(instance='macro-recorder', name='run-macro') - @cmdutils.argument('win_id', win_id=True) - @cmdutils.argument('count', count=True) + @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): """Run a recorded macro. @@ -89,12 +90,12 @@ class MacroRecorder: """Run a recorded macro.""" if register == '@': if self._last_register is None: - raise cmdexc.CommandError("No previous macro") + raise cmdutils.CommandError("No previous macro") register = self._last_register self._last_register = register if register not in self._macros: - raise cmdexc.CommandError( + raise cmdutils.CommandError( "No macro recorded in '{}'!".format(register)) commandrunner = runners.CommandRunner(win_id) diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index c06f18a1c..edb443eec 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -27,7 +27,7 @@ from PyQt5.QtWidgets import QApplication from qutebrowser.keyinput import modeparsers from qutebrowser.config import config -from qutebrowser.commands import cmdexc, cmdutils +from qutebrowser.api import cmdutils from qutebrowser.utils import usertypes, log, objreg, utils INPUT_MODES = [usertypes.KeyMode.insert, usertypes.KeyMode.passthrough] @@ -64,7 +64,7 @@ class NotInModeError(Exception): def init(win_id, parent): """Initialize the mode manager and the keyparsers for the given win_id.""" - KM = usertypes.KeyMode # noqa: N801,N806 pylint: disable=invalid-name + KM = usertypes.KeyMode # noqa: N806 modeman = ModeManager(win_id, parent) objreg.register('mode-manager', modeman, scope='window', window=win_id) keyparsers = { @@ -282,11 +282,11 @@ class ModeManager(QObject): try: m = usertypes.KeyMode[mode] except KeyError: - raise cmdexc.CommandError("Mode {} does not exist!".format(mode)) + raise cmdutils.CommandError("Mode {} does not exist!".format(mode)) if m in [usertypes.KeyMode.hint, usertypes.KeyMode.command, usertypes.KeyMode.yesno, usertypes.KeyMode.prompt]: - raise cmdexc.CommandError( + raise cmdutils.CommandError( "Mode {} can't be entered manually!".format(mode)) self.enter(m, 'command') diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 3fdad13d1..34dc5c507 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -28,10 +28,11 @@ from PyQt5.QtCore import (pyqtSlot, QRect, QPoint, QTimer, Qt, QCoreApplication, QEventLoop) from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QSizePolicy -from qutebrowser.commands import runners, cmdutils +from qutebrowser.commands import runners +from qutebrowser.api import cmdutils from qutebrowser.config import config, configfiles from qutebrowser.utils import (message, log, usertypes, qtutils, objreg, utils, - jinja) + jinja, debug) from qutebrowser.mainwindow import messageview, prompt from qutebrowser.completion import completionwidget, completer from qutebrowser.keyinput import modeman @@ -136,6 +137,7 @@ class MainWindow(QWidget): Attributes: status: The StatusBar widget. tabbed_browser: The TabbedBrowser widget. + state_before_fullscreen: window state before activation of fullscreen. _downloadview: The DownloadView widget. _vbox: The main QVBoxLayout. _commandrunner: The main CommandRunner instance. @@ -237,6 +239,8 @@ class MainWindow(QWidget): objreg.get("app").new_window.emit(self) self._set_decoration(config.val.window.hide_decoration) + self.state_before_fullscreen = self.windowState() + def _init_geometry(self, geometry): """Initialize the window geometry or load it from disk.""" if geometry is not None: @@ -516,9 +520,13 @@ class MainWindow(QWidget): def _on_fullscreen_requested(self, on): if not config.val.content.windowed_fullscreen: if on: - self.setWindowState(self.windowState() | Qt.WindowFullScreen) + self.state_before_fullscreen = self.windowState() + self.setWindowState( + Qt.WindowFullScreen | self.state_before_fullscreen) elif self.isFullScreen(): - self.setWindowState(self.windowState() & ~Qt.WindowFullScreen) + self.setWindowState(self.state_before_fullscreen) + log.misc.debug('on: {}, state before fullscreen: {}'.format( + on, debug.qflags_key(Qt, self.state_before_fullscreen))) @cmdutils.register(instance='main-window', scope='window') @pyqtSlot() diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py index 5eb76c86e..f666aa837 100644 --- a/qutebrowser/mainwindow/prompt.py +++ b/qutebrowser/mainwindow/prompt.py @@ -34,7 +34,7 @@ from qutebrowser.browser import downloads from qutebrowser.config import config from qutebrowser.utils import usertypes, log, utils, qtutils, objreg, message from qutebrowser.keyinput import modeman -from qutebrowser.commands import cmdutils, cmdexc +from qutebrowser.api import cmdutils from qutebrowser.qt import sip @@ -384,14 +384,15 @@ class PromptContainer(QWidget): try: done = self._prompt.accept(value) except Error as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) if done: message.global_bridge.prompt_done.emit(self._prompt.KEY_MODE) question.done() @cmdutils.register(instance='prompt-container', scope='window', modes=[usertypes.KeyMode.prompt], maxsplit=0) - def prompt_open_download(self, cmdline: str = None, pdfjs=False): + def prompt_open_download(self, cmdline: str = None, + pdfjs: bool = False) -> None: """Immediately open a download. If no specific command is given, this will use the system's default diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py index c3ef53b1b..13a368f05 100644 --- a/qutebrowser/mainwindow/statusbar/bar.py +++ b/qutebrowser/mainwindow/statusbar/bar.py @@ -145,7 +145,7 @@ class StatusBar(QWidget): resized = pyqtSignal('QRect') moved = pyqtSignal('QPoint') _severity = None - _color_flags = [] + _color_flags = None STYLESHEET = _generate_stylesheet() @@ -367,7 +367,7 @@ class StatusBar(QWidget): self.percentage.on_tab_changed(tab) self.backforward.on_tab_changed(tab) self.maybe_hide() - assert tab.private == self._color_flags.private + assert tab.is_private == self._color_flags.private @pyqtSlot(bool) def on_caret_selection_toggled(self, selection): diff --git a/qutebrowser/mainwindow/statusbar/command.py b/qutebrowser/mainwindow/statusbar/command.py index c04dab03e..1661d2362 100644 --- a/qutebrowser/mainwindow/statusbar/command.py +++ b/qutebrowser/mainwindow/statusbar/command.py @@ -25,7 +25,7 @@ from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QSize from PyQt5.QtWidgets import QSizePolicy from qutebrowser.keyinput import modeman, modeparsers -from qutebrowser.commands import cmdexc, cmdutils +from qutebrowser.api import cmdutils from qutebrowser.misc import cmdhistory, editor from qutebrowser.misc import miscwidgets as misc from qutebrowser.utils import usertypes, log, objreg, message, utils @@ -115,7 +115,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): @cmdutils.register(instance='status-command', name='set-cmd-text', scope='window', maxsplit=0) - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def set_cmd_text_command(self, text, count=None, space=False, append=False, run_on_count=False): """Preset the statusbar to some text. @@ -137,11 +137,11 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): text += ' ' if append: if not self.text(): - raise cmdexc.CommandError("No current text!") + raise cmdutils.CommandError("No current text!") text = self.text() + text if not text or text[0] not in modeparsers.STARTCHARS: - raise cmdexc.CommandError( + 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) diff --git a/qutebrowser/mainwindow/statusbar/keystring.py b/qutebrowser/mainwindow/statusbar/keystring.py index 29dd3b790..73b23a65d 100644 --- a/qutebrowser/mainwindow/statusbar/keystring.py +++ b/qutebrowser/mainwindow/statusbar/keystring.py @@ -25,5 +25,3 @@ from qutebrowser.mainwindow.statusbar import textbase class KeyString(textbase.TextBase): """Keychain string displayed in the statusbar.""" - - pass diff --git a/qutebrowser/mainwindow/statusbar/url.py b/qutebrowser/mainwindow/statusbar/url.py index fda09d642..c6f436617 100644 --- a/qutebrowser/mainwindow/statusbar/url.py +++ b/qutebrowser/mainwindow/statusbar/url.py @@ -112,19 +112,19 @@ class UrlText(textbase.TextBase): self._urltype = UrlType.normal config.set_register_stylesheet(self, update=False) - @pyqtSlot(str) - def on_load_status_changed(self, status_str): + @pyqtSlot(usertypes.LoadStatus) + def on_load_status_changed(self, status): """Slot for load_status_changed. Sets URL color accordingly. Args: - status_str: The LoadStatus as string. + status: The usertypes.LoadStatus. """ - status = usertypes.LoadStatus[status_str] + assert isinstance(status, usertypes.LoadStatus), status if status in [usertypes.LoadStatus.success, usertypes.LoadStatus.success_https, usertypes.LoadStatus.error, usertypes.LoadStatus.warn]: - self._normal_url_type = UrlType[status_str] + self._normal_url_type = UrlType[status.name] else: self._normal_url_type = UrlType.normal self._update_url() @@ -172,5 +172,5 @@ class UrlText(textbase.TextBase): self._normal_url = urlutils.safe_display_string(tab.url()) else: self._normal_url = '' - self.on_load_status_changed(tab.load_status().name) + self.on_load_status_changed(tab.load_status()) self._update_url() diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 1f3484663..9c14df3ae 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -76,7 +76,7 @@ class TabbedBrowser(QWidget): _local_marks: Jump markers local to each page _global_marks: Jump markers used across all pages default_window_icon: The qutebrowser window icon - private: Whether private browsing is on for this window. + is_private: Whether private browsing is on for this window. Signals: cur_progress: Progress of the current tab changed (load_progress). @@ -102,7 +102,7 @@ class TabbedBrowser(QWidget): cur_url_changed = pyqtSignal(QUrl) cur_link_hovered = pyqtSignal(str) cur_scroll_perc_changed = pyqtSignal(int, int) - cur_load_status_changed = pyqtSignal(str) + cur_load_status_changed = pyqtSignal(usertypes.LoadStatus) cur_fullscreen_requested = pyqtSignal(bool) cur_caret_selection_toggled = pyqtSignal(bool) close_window = pyqtSignal() @@ -131,7 +131,7 @@ class TabbedBrowser(QWidget): self._local_marks = {} self._global_marks = {} self.default_window_icon = self.widget.window().windowIcon() - self.private = private + self.is_private = private config.instance.changed.connect(self._on_config_changed) def __repr__(self): @@ -222,6 +222,7 @@ class TabbedBrowser(QWidget): self._filter.create(self.cur_caret_selection_toggled, tab)) # misc tab.scroller.perc_changed.connect(self.on_scroll_pos_changed) + tab.scroller.before_jump_requested.connect(lambda: self.set_mark("'")) tab.url_changed.connect( functools.partial(self.on_url_changed, tab)) tab.title_changed.connect( @@ -243,9 +244,9 @@ class TabbedBrowser(QWidget): tab.audio.recently_audible_changed.connect( functools.partial(self._on_audio_changed, tab)) tab.new_tab_requested.connect(self.tabopen) - if not self.private: + if not self.is_private: web_history = objreg.get('web-history') - tab.add_history_item.connect(web_history.add_from_tab) + tab.history_item_triggered.connect(web_history.add_from_tab) def current_url(self): """Get the URL of the current tab. @@ -303,12 +304,12 @@ class TabbedBrowser(QWidget): if last_close == 'close': self.close_window.emit() elif last_close == 'blank': - self.openurl(QUrl('about:blank'), newtab=True) + self.load_url(QUrl('about:blank'), newtab=True) elif last_close == 'startpage': for url in config.val.url.start_pages: - self.openurl(url, newtab=True) + self.load_url(url, newtab=True) elif last_close == 'default-page': - self.openurl(config.val.url.default_page, newtab=True) + self.load_url(config.val.url.default_page, newtab=True) def _remove_tab(self, tab, *, add_undo=True, new_undo=True, crashed=False): """Remove a tab from the tab list and delete it properly. @@ -345,7 +346,7 @@ class TabbedBrowser(QWidget): urlutils.invalid_url_error(tab.url(), "saving tab") elif add_undo: try: - history_data = tab.history.serialize() + history_data = tab.history.private_api.serialize() except browsertab.WebTabError: pass # special URL else: @@ -356,7 +357,7 @@ class TabbedBrowser(QWidget): else: self._undo_stack[-1].append(entry) - tab.shutdown() + tab.private_api.shutdown() self.widget.removeTab(idx) if not crashed: # WORKAROUND for a segfault when we delete the crashed tab. @@ -391,11 +392,11 @@ class TabbedBrowser(QWidget): else: newtab = self.tabopen(background=False, idx=entry.index) - newtab.history.deserialize(entry.history) + newtab.history.private_api.deserialize(entry.history) self.widget.set_tab_pinned(newtab, entry.pinned) @pyqtSlot('QUrl', bool) - def openurl(self, url, newtab): + def load_url(self, url, newtab): """Open a URL, used as a slot. Args: @@ -406,7 +407,7 @@ class TabbedBrowser(QWidget): if newtab or self.widget.currentWidget() is None: self.tabopen(url, background=False) else: - self.widget.currentWidget().openurl(url) + self.widget.currentWidget().load_url(url) @pyqtSlot(int) def on_tab_close_requested(self, idx): @@ -466,14 +467,15 @@ class TabbedBrowser(QWidget): if (config.val.tabs.tabs_are_windows and self.widget.count() > 0 and not ignore_tabs_are_windows): - window = mainwindow.MainWindow(private=self.private) + window = mainwindow.MainWindow(private=self.is_private) window.show() tabbed_browser = objreg.get('tabbed-browser', scope='window', window=window.win_id) return tabbed_browser.tabopen(url=url, background=background, related=related) - tab = browsertab.create(win_id=self._win_id, private=self.private, + tab = browsertab.create(win_id=self._win_id, + private=self.is_private, parent=self.widget) self._connect_tab_signals(tab) @@ -482,7 +484,7 @@ class TabbedBrowser(QWidget): self.widget.insertTab(idx, tab, "") if url is not None: - tab.openurl(url) + tab.load_url(url) if background is None: background = config.val.tabs.background @@ -752,7 +754,7 @@ class TabbedBrowser(QWidget): self.widget.update_tab_title(idx) if idx == self.widget.currentIndex(): self._update_window_title() - tab.handle_auto_insert_mode(ok) + tab.private_api.handle_auto_insert_mode(ok) @pyqtSlot() def on_scroll_pos_changed(self): @@ -878,7 +880,7 @@ class TabbedBrowser(QWidget): self.cur_load_finished.disconnect(callback) tab.scroller.to_point(point) - self.openurl(url, newtab=False) + self.load_url(url, newtab=False) self.cur_load_finished.connect(callback) else: message.error("Mark {} is not set".format(key)) @@ -890,8 +892,7 @@ class TabbedBrowser(QWidget): # save the pre-jump position in the special ' mark # this has to happen after we read the mark, otherwise jump_mark # "'" would just jump to the current position every time - self.set_mark("'") - + tab.scroller.before_jump_requested.emit() tab.scroller.to_point(point) else: message.error("Mark {} is not set".format(key)) diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index f2605e7d3..ca4be6d50 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -37,8 +37,11 @@ from qutebrowser.misc import objects from qutebrowser.browser import browsertab -PixelMetrics = enum.IntEnum('PixelMetrics', ['icon_padding'], - start=QStyle.PM_CustomBase) +class PixelMetrics(enum.IntEnum): + + """Custom PixelMetrics attributes.""" + + icon_padding = QStyle.PM_CustomBase class TabWidget(QTabWidget): @@ -174,7 +177,7 @@ class TabWidget(QTabWidget): fields['title_sep'] = ' - ' if page_title else '' fields['perc_raw'] = tab.progress() fields['backend'] = objects.backend.name - fields['private'] = ' [Private Mode] ' if tab.private else '' + fields['private'] = ' [Private Mode] ' if tab.is_private else '' try: if tab.audio.is_muted(): fields['audio'] = TabWidget.MUTE_STRING @@ -339,7 +342,7 @@ class TabWidget(QTabWidget): qtutils.ensure_valid(url) return url - def update_tab_favicon(self, tab: QWidget): + def update_tab_favicon(self, tab: QWidget) -> None: """Update favicon of the given tab.""" idx = self.indexOf(tab) @@ -397,7 +400,7 @@ class TabBar(QTabBar): return self.parent().currentWidget() @pyqtSlot(str) - def _on_config_changed(self, option: str): + def _on_config_changed(self, option: str) -> None: if option == 'fonts.tabs': self._set_font() elif option == 'tabs.favicons.scale': @@ -540,7 +543,7 @@ class TabBar(QTabBar): return super().mousePressEvent(e) - def minimumTabSizeHint(self, index, ellipsis: bool = True) -> QSize: + def minimumTabSizeHint(self, index: int, ellipsis: bool = True) -> QSize: """Set the minimum tab size to indicator/icon/... text. Args: @@ -620,7 +623,7 @@ class TabBar(QTabBar): return False return widget.data.pinned - def tabSizeHint(self, index: int): + def tabSizeHint(self, index: int) -> QSize: """Override tabSizeHint to customize qb's tab size. https://wiki.python.org/moin/PyQt/Customising%20tab%20bars diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index d5f7c9680..74a2ad372 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -38,10 +38,14 @@ from qutebrowser.utils import usertypes, objreg, version, qtutils, log, utils from qutebrowser.misc import objects, msgbox -_Result = enum.IntEnum( - '_Result', - ['quit', 'restart', 'restart_webkit', 'restart_webengine'], - start=QDialog.Accepted + 1) +class _Result(enum.IntEnum): + + """The result code returned by the backend problem dialog.""" + + quit = QDialog.Accepted + 1 + restart = QDialog.Accepted + 2 + restart_webkit = QDialog.Accepted + 3 + restart_webengine = QDialog.Accepted + 4 @attr.s @@ -234,6 +238,9 @@ def _handle_wayland(): 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:

" @@ -280,7 +287,7 @@ class BackendImports: def _try_import_backends(): """Check whether backends can be imported and return BackendImports.""" - # pylint: disable=unused-variable + # pylint: disable=unused-import results = BackendImports() try: diff --git a/qutebrowser/misc/checkpyver.py b/qutebrowser/misc/checkpyver.py index 50330ef88..cf8e13810 100644 --- a/qutebrowser/misc/checkpyver.py +++ b/qutebrowser/misc/checkpyver.py @@ -30,12 +30,12 @@ try: except ImportError: # pragma: no cover try: # Python2 - from Tkinter import Tk - import tkMessageBox as messagebox + from Tkinter import Tk # type: ignore + import tkMessageBox as messagebox # type: ignore except ImportError: # Some Python without Tk - Tk = None - messagebox = None + Tk = None # type: ignore + messagebox = None # type: ignore # First we check the version of Python. This code should run fine with python2 diff --git a/qutebrowser/misc/cmdhistory.py b/qutebrowser/misc/cmdhistory.py index 9fa273c1c..0a3ac9fa9 100644 --- a/qutebrowser/misc/cmdhistory.py +++ b/qutebrowser/misc/cmdhistory.py @@ -29,15 +29,11 @@ class HistoryEmptyError(Exception): """Raised when the history is empty.""" - pass - class HistoryEndReachedError(Exception): """Raised when the end of the history is reached.""" - pass - class History(QObject): diff --git a/qutebrowser/misc/crashdialog.py b/qutebrowser/misc/crashdialog.py index 27dec3345..06a5aabc5 100644 --- a/qutebrowser/misc/crashdialog.py +++ b/qutebrowser/misc/crashdialog.py @@ -42,8 +42,12 @@ from qutebrowser.misc import (miscwidgets, autoupdate, msgbox, httpclient, from qutebrowser.config import config, configfiles -Result = enum.IntEnum('Result', ['restore', 'no_restore'], - start=QDialog.Accepted + 1) +class Result(enum.IntEnum): + + """The result code returned by the crash dialog.""" + + restore = QDialog.Accepted + 1 + no_restore = QDialog.Accepted + 2 def parse_fatal_stacktrace(text): @@ -197,7 +201,6 @@ class _CrashDialog(QDialog): def _init_checkboxes(self): """Initialize the checkboxes.""" - pass def _init_buttons(self): """Initialize the buttons.""" @@ -569,7 +572,6 @@ class ReportDialog(_CrashDialog): def _init_info_text(self): """We don't want an info text as the user wanted to report.""" - pass def _get_error_type(self): return 'report' diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py index 196613d62..7890380e8 100644 --- a/qutebrowser/misc/crashsignal.py +++ b/qutebrowser/misc/crashsignal.py @@ -37,7 +37,7 @@ import attr from PyQt5.QtCore import (pyqtSlot, qInstallMessageHandler, QObject, QSocketNotifier, QTimer, QUrl) -from qutebrowser.commands import cmdutils +from qutebrowser.api import cmdutils from qutebrowser.misc import earlyinit, crashdialog, ipc from qutebrowser.utils import usertypes, standarddir, log, objreg, debug, utils diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index b29e1508f..690ede60f 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -38,7 +38,7 @@ import datetime try: import tkinter except ImportError: - tkinter = None + tkinter = None # type: ignore # NOTE: No qutebrowser or PyQt import should be done here, as some early # initialization needs to take place before that! @@ -133,7 +133,7 @@ def init_faulthandler(fileobj=sys.__stderr__): def check_pyqt_core(): """Check if PyQt core is installed.""" try: - import PyQt5.QtCore # pylint: disable=unused-variable + import PyQt5.QtCore # pylint: disable=unused-import except ImportError as e: text = _missing_str('PyQt5') text = text.replace('', '') @@ -187,9 +187,8 @@ def check_qt_version(): def check_ssl_support(): """Check if SSL support is available.""" - # pylint: disable=unused-variable try: - from PyQt5.QtNetwork import QSslSocket + from PyQt5.QtNetwork import QSslSocket # pylint: disable=unused-import except ImportError: _die("Fatal error: Your Qt is built without SSL support.") diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py index 038331c9b..3c5b7471e 100644 --- a/qutebrowser/misc/editor.py +++ b/qutebrowser/misc/editor.py @@ -84,7 +84,7 @@ class ExternalEditor(QObject): message.error("Failed to delete tempfile... ({})".format(e)) @pyqtSlot(int, QProcess.ExitStatus) - def on_proc_closed(self, _exitcode, exitstatus): + def _on_proc_closed(self, _exitcode, exitstatus): """Write the editor text into the form field and clean up tempfile. Callback for QProcess when the editor was closed. @@ -100,7 +100,7 @@ class ExternalEditor(QObject): self._cleanup() @pyqtSlot(QProcess.ProcessError) - def on_proc_error(self, _err): + def _on_proc_error(self, _err): self._cleanup() def edit(self, text, caret_position=None): @@ -176,8 +176,8 @@ class ExternalEditor(QObject): column: the column number to pass to the editor """ self._proc = guiprocess.GUIProcess(what='editor', parent=self) - self._proc.finished.connect(self.on_proc_closed) - self._proc.error.connect(self.on_proc_error) + self._proc.finished.connect(self._on_proc_closed) + self._proc.error.connect(self._on_proc_error) editor = config.val.editor.command executable = editor[0] diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index e1de9a6cc..9d3f4c594 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -33,7 +33,7 @@ from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt from qutebrowser.config import config from qutebrowser.utils import utils, usertypes -from qutebrowser.commands import cmdutils +from qutebrowser.misc import objects from qutebrowser.keyinput import keyutils @@ -101,7 +101,7 @@ class KeyHintView(QLabel): def takes_count(cmdstr): """Return true iff this command can take a count argument.""" cmdname = cmdstr.split(' ')[0] - cmd = cmdutils.cmd_dict.get(cmdname) + cmd = objects.commands.get(cmdname) return cmd and cmd.takes_count() bindings_dict = config.key_instance.get_bindings_for(modename) diff --git a/qutebrowser/misc/objects.py b/qutebrowser/misc/objects.py index d6c116eab..0bb26954c 100644 --- a/qutebrowser/misc/objects.py +++ b/qutebrowser/misc/objects.py @@ -22,14 +22,22 @@ # NOTE: We need to be careful with imports here, as this is imported from # earlyinit. +import typing + +MYPY = False +if MYPY: + # pylint: disable=unused-import,useless-suppression + from qutebrowser.utils import usertypes + from qutebrowser.commands import command + class NoBackend: """Special object when there's no backend set so we notice that.""" - def __eq__(self, other): + def __eq__(self, other: typing.Any) -> bool: raise AssertionError("No backend set!") -# A usertypes.Backend member -backend = NoBackend() +backend = NoBackend() # type: typing.Union[usertypes.Backend, NoBackend] +commands = {} # type: typing.Dict[str, command.Command] diff --git a/qutebrowser/misc/readline.py b/qutebrowser/misc/readline.py index 3846b77e0..14c25cd6d 100644 --- a/qutebrowser/misc/readline.py +++ b/qutebrowser/misc/readline.py @@ -21,7 +21,7 @@ from PyQt5.QtWidgets import QApplication, QLineEdit -from qutebrowser.commands import cmdutils +from qutebrowser.api import cmdutils from qutebrowser.utils import usertypes as typ from qutebrowser.utils import utils diff --git a/qutebrowser/misc/savemanager.py b/qutebrowser/misc/savemanager.py index 0d79c97db..9985c5191 100644 --- a/qutebrowser/misc/savemanager.py +++ b/qutebrowser/misc/savemanager.py @@ -25,7 +25,7 @@ import collections from PyQt5.QtCore import pyqtSlot, QObject, QTimer from qutebrowser.config import config -from qutebrowser.commands import cmdutils +from qutebrowser.api import cmdutils from qutebrowser.utils import utils, log, message, usertypes diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index 78adeb983..2a557ef50 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -23,6 +23,7 @@ import os import os.path import itertools import urllib +import typing from PyQt5.QtCore import QUrl, QObject, QPoint, QTimer from PyQt5.QtWidgets import QApplication @@ -30,14 +31,19 @@ import yaml from qutebrowser.utils import (standarddir, objreg, qtutils, log, message, utils) -from qutebrowser.commands import cmdexc, cmdutils +from qutebrowser.api import cmdutils from qutebrowser.config import config, configfiles from qutebrowser.completion.models import miscmodels from qutebrowser.mainwindow import mainwindow from qutebrowser.qt import sip -default = object() # Sentinel value +class Sentinel: + + """Sentinel value for default argument.""" + + +default = Sentinel() def init(parent=None): @@ -109,7 +115,7 @@ class SessionManager(QObject): def __init__(self, base_path, parent=None): super().__init__(parent) - self._current = None + self._current = None # type: typing.Optional[str] self._base_path = base_path self._last_window_session = None self.did_load = False @@ -234,7 +240,7 @@ class SessionManager(QObject): if sip.isdeleted(main_window): continue - if tabbed_browser.private and not with_private: + if tabbed_browser.is_private and not with_private: continue win_data = {} @@ -243,7 +249,7 @@ class SessionManager(QObject): win_data['active'] = True win_data['geometry'] = bytes(main_window.saveGeometry()) win_data['tabs'] = [] - if tabbed_browser.private: + if tabbed_browser.is_private: win_data['private'] = True for i, tab in enumerate(tabbed_browser.widgets()): active = i == tabbed_browser.widget.currentIndex() @@ -395,7 +401,7 @@ class SessionManager(QObject): new_tab.title_changed.emit(histentry['title']) try: - new_tab.history.load_items(entries) + new_tab.history.private_api.load_items(entries) except ValueError as e: raise SessionError(e) @@ -474,16 +480,17 @@ class SessionManager(QObject): delete: Delete the saved session once it has loaded. """ if name.startswith('_') and not force: - raise cmdexc.CommandError("{} is an internal session, use --force " - "to load anyways.".format(name)) + raise cmdutils.CommandError("{} is an internal session, use " + "--force to load anyways." + .format(name)) old_windows = list(objreg.window_registry.values()) try: self.load(name, temp=temp) except SessionNotFoundError: - raise cmdexc.CommandError("Session {} not found!".format(name)) + raise cmdutils.CommandError("Session {} not found!".format(name)) except SessionError as e: - raise cmdexc.CommandError("Error while loading session: {}" - .format(e)) + raise cmdutils.CommandError("Error while loading session: {}" + .format(e)) else: if clear: for win in old_windows: @@ -493,20 +500,23 @@ class SessionManager(QObject): self.delete(name) except SessionError as e: log.sessions.exception("Error while deleting session!") - raise cmdexc.CommandError( - "Error while deleting session: {}" - .format(e)) + raise cmdutils.CommandError("Error while deleting " + "session: {}".format(e)) else: log.sessions.debug( "Loaded & deleted session {}.".format(name)) @cmdutils.register(instance='session-manager') @cmdutils.argument('name', completion=miscmodels.session) - @cmdutils.argument('win_id', win_id=True) + @cmdutils.argument('win_id', value=cmdutils.Value.win_id) @cmdutils.argument('with_private', flag='p') - def session_save(self, name: str = default, current=False, quiet=False, - force=False, only_active_window=False, with_private=False, - win_id=None): + def session_save(self, name: typing.Union[str, Sentinel] = default, + current: bool = False, + quiet: bool = False, + force: bool = False, + only_active_window: bool = False, + with_private: bool = False, + win_id: int = None) -> None: """Save a session. Args: @@ -518,12 +528,15 @@ class SessionManager(QObject): only_active_window: Saves only tabs of the currently active window. with_private: Include private windows. """ - if name is not default and name.startswith('_') and not force: - raise cmdexc.CommandError("{} is an internal session, use --force " - "to save anyways.".format(name)) + if (not isinstance(name, Sentinel) and + name.startswith('_') and + not force): + raise cmdutils.CommandError("{} is an internal session, use " + "--force to save anyways." + .format(name)) if current: if self._current is None: - raise cmdexc.CommandError("No session loaded currently!") + raise cmdutils.CommandError("No session loaded currently!") name = self._current assert not name.startswith('_') try: @@ -533,8 +546,8 @@ class SessionManager(QObject): else: name = self.save(name, with_private=with_private) except SessionError as e: - raise cmdexc.CommandError("Error while saving session: {}" - .format(e)) + raise cmdutils.CommandError("Error while saving session: {}" + .format(e)) else: if quiet: log.sessions.debug("Saved session {}.".format(name)) @@ -552,15 +565,16 @@ class SessionManager(QObject): underline). """ if name.startswith('_') and not force: - raise cmdexc.CommandError("{} is an internal session, use --force " - "to delete anyways.".format(name)) + raise cmdutils.CommandError("{} is an internal session, use " + "--force to delete anyways." + .format(name)) try: self.delete(name) except SessionNotFoundError: - raise cmdexc.CommandError("Session {} not found!".format(name)) + raise cmdutils.CommandError("Session {} not found!".format(name)) except SessionError as e: log.sessions.exception("Error while deleting session!") - raise cmdexc.CommandError("Error while deleting session: {}" - .format(e)) + raise cmdutils.CommandError("Error while deleting session: {}" + .format(e)) else: log.sessions.debug("Deleted session {}.".format(name)) diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py index 4c300a3da..eda778194 100644 --- a/qutebrowser/misc/sql.py +++ b/qutebrowser/misc/sql.py @@ -72,8 +72,6 @@ class SqlEnvironmentError(SqlError): disk or I/O errors), where qutebrowser isn't to blame. """ - pass - class SqlBugError(SqlError): @@ -82,8 +80,6 @@ class SqlBugError(SqlError): This is raised for errors resulting from a qutebrowser bug. """ - pass - def raise_sqlite_error(msg, error): """Raise either a SqlBugError or SqlEnvironmentError.""" diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index d108a56ac..c2b2e6168 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -21,21 +21,16 @@ import functools import os -import signal import traceback -try: - import hunter -except ImportError: - hunter = None - from PyQt5.QtCore import QUrl # so it's available for :debug-pyeval from PyQt5.QtWidgets import QApplication # pylint: disable=unused-import from qutebrowser.browser import qutescheme from qutebrowser.utils import log, objreg, usertypes, message, debug, utils -from qutebrowser.commands import cmdutils, runners, cmdexc +from qutebrowser.commands import runners +from qutebrowser.api import cmdutils from qutebrowser.config import config, configdata from qutebrowser.misc import consolewidget from qutebrowser.utils.version import pastebin_version @@ -43,8 +38,8 @@ from qutebrowser.qt import sip @cmdutils.register(maxsplit=1, no_cmd_split=True, no_replace_variables=True) -@cmdutils.argument('win_id', win_id=True) -def later(ms: int, command, win_id): +@cmdutils.argument('win_id', value=cmdutils.Value.win_id) +def later(ms: int, command: str, win_id: int) -> None: """Execute a command after some time. Args: @@ -52,7 +47,7 @@ def later(ms: int, command, win_id): command: The command to run, with optional args. """ if ms < 0: - raise cmdexc.CommandError("I can't run something in the past!") + 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) @@ -61,8 +56,8 @@ def later(ms: int, command, win_id): try: timer.setInterval(ms) except OverflowError: - raise cmdexc.CommandError("Numeric argument is too large for " - "internal int representation.") + raise cmdutils.CommandError("Numeric argument is too large for " + "internal int representation.") timer.timeout.connect( functools.partial(commandrunner.run_safely, command)) timer.timeout.connect(timer.deleteLater) @@ -73,9 +68,9 @@ def later(ms: int, command, win_id): @cmdutils.register(maxsplit=1, no_cmd_split=True, no_replace_variables=True) -@cmdutils.argument('win_id', win_id=True) -@cmdutils.argument('count', count=True) -def repeat(times: int, command, win_id, count=None): +@cmdutils.argument('win_id', value=cmdutils.Value.win_id) +@cmdutils.argument('count', value=cmdutils.Value.count) +def repeat(times: int, command: str, win_id: int, count: int = None) -> None: """Repeat a given command. Args: @@ -87,16 +82,17 @@ def repeat(times: int, command, win_id, count=None): times *= count if times < 0: - raise cmdexc.CommandError("A negative count doesn't make sense.") + raise cmdutils.CommandError("A negative count doesn't make sense.") commandrunner = runners.CommandRunner(win_id) for _ in range(times): commandrunner.run_safely(command) @cmdutils.register(maxsplit=1, no_cmd_split=True, no_replace_variables=True) -@cmdutils.argument('win_id', win_id=True) -@cmdutils.argument('count', count=True) -def run_with_count(count_arg: int, command, win_id, count=1): +@cmdutils.argument('win_id', value=cmdutils.Value.win_id) +@cmdutils.argument('count', value=cmdutils.Value.count) +def run_with_count(count_arg: int, command: str, win_id: int, + count: int = 1) -> None: """Run a command with the given count. If run_with_count itself is run with a count, it multiplies count_arg. @@ -109,60 +105,12 @@ def run_with_count(count_arg: int, command, win_id, count=1): runners.CommandRunner(win_id).run(command, count_arg * count) -@cmdutils.register() -def message_error(text): - """Show an error message in the statusbar. - - Args: - text: The text to show. - """ - message.error(text) - - -@cmdutils.register() -@cmdutils.argument('count', count=True) -def message_info(text, count=1): - """Show an info message in the statusbar. - - Args: - text: The text to show. - count: How many times to show the message - """ - for _ in range(count): - message.info(text) - - -@cmdutils.register() -def message_warning(text): - """Show a warning message in the statusbar. - - Args: - text: The text to show. - """ - message.warning(text) - - @cmdutils.register() def clear_messages(): """Clear all message notifications.""" message.global_bridge.clear_messages.emit() -@cmdutils.register(debug=True) -@cmdutils.argument('typ', choices=['exception', 'segfault']) -def debug_crash(typ='exception'): - """Crash for debugging purposes. - - Args: - typ: either 'exception' or 'segfault'. - """ - if typ == 'segfault': - os.kill(os.getpid(), signal.SIGSEGV) - raise Exception("Segfault failed (wat.)") - else: - raise Exception("Forced crash") - - @cmdutils.register(debug=True) def debug_all_objects(): """Print a list of all objects to the debug log.""" @@ -218,22 +166,6 @@ def debug_console(): con_widget.show() -@cmdutils.register(debug=True, maxsplit=0, no_cmd_split=True) -def debug_trace(expr=""): - """Trace executed code via hunter. - - Args: - expr: What to trace, passed to hunter. - """ - if hunter is None: - raise cmdexc.CommandError("You need to install 'hunter' to use this " - "command!") - try: - eval('hunter.trace({})'.format(expr)) - except Exception as e: - raise cmdexc.CommandError("{}: {}".format(e.__class__.__name__, e)) - - @cmdutils.register(maxsplit=0, debug=True, no_cmd_split=True) def debug_pyeval(s, file=False, quiet=False): """Evaluate a python string and display the results as a web page. @@ -250,7 +182,7 @@ def debug_pyeval(s, file=False, quiet=False): with open(path, 'r', encoding='utf-8') as f: s = f.read() except OSError as e: - raise cmdexc.CommandError(str(e)) + raise cmdutils.CommandError(str(e)) try: exec(s) out = "No error" @@ -269,7 +201,7 @@ def debug_pyeval(s, file=False, quiet=False): else: tabbed_browser = objreg.get('tabbed-browser', scope='window', window='last-focused') - tabbed_browser.openurl(QUrl('qute://pyeval'), newtab=True) + tabbed_browser.load_url(QUrl('qute://pyeval'), newtab=True) @cmdutils.register(debug=True) @@ -286,8 +218,8 @@ def debug_set_fake_clipboard(s=None): @cmdutils.register() -@cmdutils.argument('win_id', win_id=True) -@cmdutils.argument('count', count=True) +@cmdutils.argument('win_id', value=cmdutils.Value.win_id) +@cmdutils.argument('count', value=cmdutils.Value.count) def repeat_command(win_id, count=None): """Repeat the last executed command. @@ -296,22 +228,23 @@ def repeat_command(win_id, count=None): """ mode_manager = objreg.get('mode-manager', scope='window', window=win_id) if mode_manager.mode not in runners.last_command: - raise cmdexc.CommandError("You didn't do anything yet.") + raise cmdutils.CommandError("You didn't do anything yet.") cmd = runners.last_command[mode_manager.mode] commandrunner = runners.CommandRunner(win_id) commandrunner.run(cmd[0], count if count is not None else cmd[1]) @cmdutils.register(debug=True, name='debug-log-capacity') -def log_capacity(capacity: int): +def log_capacity(capacity: int) -> None: """Change the number of log lines to be stored in RAM. Args: capacity: Number of lines for the log. """ if capacity < 0: - raise cmdexc.CommandError("Can't set a negative log capacity!") + raise cmdutils.CommandError("Can't set a negative log capacity!") else: + assert log.ram_handler is not None log.ram_handler.change_log_capacity(capacity) @@ -319,18 +252,19 @@ def log_capacity(capacity: int): @cmdutils.argument('level', choices=sorted( (level.lower() for level in log.LOG_LEVELS), key=lambda e: log.LOG_LEVELS[e.upper()])) -def debug_log_level(level: str): +def debug_log_level(level: str) -> None: """Change the log level for console logging. Args: level: The log level to set. """ 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()]) @cmdutils.register(debug=True) -def debug_log_filter(filters: str): +def debug_log_filter(filters: str) -> None: """Change the log filter for console logging. Args: @@ -338,23 +272,23 @@ def debug_log_filter(filters: str): clear any existing filters. """ if log.console_filter is None: - raise cmdexc.CommandError("No log.console_filter. Not attached " - "to a console?") + raise cmdutils.CommandError("No log.console_filter. Not attached " + "to a console?") if filters.strip().lower() == 'none': log.console_filter.names = None return if not set(filters.split(',')).issubset(log.LOGGER_NAMES): - raise cmdexc.CommandError("filters: Invalid value {} - expected one " - "of: {}".format(filters, - ', '.join(log.LOGGER_NAMES))) + raise cmdutils.CommandError("filters: Invalid value {} - expected one " + "of: {}".format( + filters, ', '.join(log.LOGGER_NAMES))) log.console_filter.names = filters.split(',') @cmdutils.register() -@cmdutils.argument('current_win_id', win_id=True) +@cmdutils.argument('current_win_id', value=cmdutils.Value.win_id) def window_only(current_win_id): """Close all windows except for the current one.""" for win_id, window in objreg.window_registry.items(): @@ -368,13 +302,7 @@ def window_only(current_win_id): @cmdutils.register() -def nop(): - """Do nothing.""" - pass - - -@cmdutils.register() -@cmdutils.argument('win_id', win_id=True) +@cmdutils.argument('win_id', value=cmdutils.Value.win_id) def version(win_id, paste=False): """Show version information. @@ -383,7 +311,7 @@ def version(win_id, paste=False): """ tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) - tabbed_browser.openurl(QUrl('qute://version'), newtab=True) + tabbed_browser.load_url(QUrl('qute://version'), newtab=True) if paste: pastebin_version() diff --git a/qutebrowser/qt.py b/qutebrowser/qt.py index 2878bbe98..d9f1dc58d 100644 --- a/qutebrowser/qt.py +++ b/qutebrowser/qt.py @@ -25,4 +25,4 @@ try: from PyQt5 import sip except ImportError: - import sip + import sip # type: ignore diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index 381e9ca5d..115c53352 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -76,12 +76,13 @@ LOG_COLORS = { # We first monkey-patch logging to support our VDEBUG level before getting the # loggers. Based on http://stackoverflow.com/a/13638084 +# mypy doesn't know about this, so we need to ignore it. VDEBUG_LEVEL = 9 logging.addLevelName(VDEBUG_LEVEL, 'VDEBUG') -logging.VDEBUG = VDEBUG_LEVEL +logging.VDEBUG = VDEBUG_LEVEL # type: ignore LOG_LEVELS = { - 'VDEBUG': logging.VDEBUG, + 'VDEBUG': logging.VDEBUG, # type: ignore 'DEBUG': logging.DEBUG, 'INFO': logging.INFO, 'WARNING': logging.WARNING, @@ -89,17 +90,6 @@ LOG_LEVELS = { 'CRITICAL': logging.CRITICAL, } -LOGGER_NAMES = [ - 'statusbar', 'completion', 'init', 'url', - 'destroy', 'modes', 'webview', 'misc', - 'mouse', 'procs', 'hints', 'keyboard', - 'commands', 'signals', 'downloads', - 'js', 'qt', 'rfc6266', 'ipc', 'shlexer', - 'save', 'message', 'config', 'sessions', - 'webelem', 'prompt', 'network', 'sql', - 'greasemonkey' -] - def vdebug(self, msg, *args, **kwargs): """Log with a VDEBUG level. @@ -114,7 +104,7 @@ def vdebug(self, msg, *args, **kwargs): # pylint: enable=protected-access -logging.Logger.vdebug = vdebug +logging.Logger.vdebug = vdebug # type: ignore # The different loggers used. @@ -147,6 +137,18 @@ prompt = logging.getLogger('prompt') network = logging.getLogger('network') sql = logging.getLogger('sql') greasemonkey = logging.getLogger('greasemonkey') +extensions = logging.getLogger('extensions') + +LOGGER_NAMES = [ + 'statusbar', 'completion', 'init', 'url', + 'destroy', 'modes', 'webview', 'misc', + 'mouse', 'procs', 'hints', 'keyboard', + 'commands', 'signals', 'downloads', + 'js', 'qt', 'rfc6266', 'ipc', 'shlexer', + 'save', 'message', 'config', 'sessions', + 'webelem', 'prompt', 'network', 'sql', + 'greasemonkey', 'extensions', +] ram_handler = None @@ -467,7 +469,7 @@ def qt_message_handler(msg_type, context, msg): stack = ''.join(traceback.format_stack()) else: stack = None - record = qt.makeRecord(name, level, context.file, context.line, msg, None, + record = qt.makeRecord(name, level, context.file, context.line, msg, (), None, func, sinfo=stack) qt.handle(record) diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py index 9dc8d7411..6731721aa 100644 --- a/qutebrowser/utils/message.py +++ b/qutebrowser/utils/message.py @@ -29,33 +29,28 @@ from PyQt5.QtCore import pyqtSignal, QObject from qutebrowser.utils import usertypes, log, utils -def _log_stack(typ, stack): +def _log_stack(typ: str, stack: str) -> None: """Log the given message stacktrace. Args: - typ: The type of the message (str) - stack: The stack as an iterable of strings or a single string + typ: The type of the message. + stack: An optional stacktrace. """ - try: - # traceback.format_exc() produces a list of strings, while - # traceback.format_stack() produces a single string... - stack = stack.splitlines() - except AttributeError: - pass - stack_text = '\n'.join(line.rstrip() for line in stack) + lines = stack.splitlines() + stack_text = '\n'.join(line.rstrip() for line in lines) log.message.debug("Stack for {} message:\n{}".format(typ, stack_text)) -def error(message, *, stack=None, replace=False): - """Convenience function to display an error message in the statusbar. +def error(message: str, *, stack: str = None, replace: bool = False) -> None: + """Display an error message. Args: - message: The message to show - stack: The stack trace to show. - replace: Replace existing messages with replace=True + message: The message to show. + stack: The stack trace to show (if any). + replace: Replace existing messages which are still being shown. """ if stack is None: - stack = traceback.format_stack() + stack = ''.join(traceback.format_stack()) typ = 'error' else: typ = 'error (from exception)' @@ -64,24 +59,24 @@ def error(message, *, stack=None, replace=False): global_bridge.show(usertypes.MessageLevel.error, message, replace) -def warning(message, *, replace=False): - """Convenience function to display a warning message in the statusbar. +def warning(message: str, *, replace: bool = False) -> None: + """Display a warning message. Args: - message: The message to show - replace: Replace existing messages with replace=True + message: The message to show. + replace: Replace existing messages which are still being shown. """ - _log_stack('warning', traceback.format_stack()) + _log_stack('warning', ''.join(traceback.format_stack())) log.message.warning(message) global_bridge.show(usertypes.MessageLevel.warning, message, replace) -def info(message, *, replace=False): - """Convenience function to display an info message in the statusbar. +def info(message: str, *, replace: bool = False) -> None: + """Display an info message. Args: - message: The message to show - replace: Replace existing messages with replace=True + message: The message to show. + replace: Replace existing messages which are still being shown. """ log.message.info(message) global_bridge.show(usertypes.MessageLevel.info, message, replace) diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py index 17fc34b92..b68ca133c 100644 --- a/qutebrowser/utils/objreg.py +++ b/qutebrowser/utils/objreg.py @@ -42,8 +42,6 @@ class RegistryUnavailableError(Exception): """Exception raised when a certain registry does not exist yet.""" - pass - class NoWindow(Exception): diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py index c634eb95f..5373e76aa 100644 --- a/qutebrowser/utils/qtutils.py +++ b/qutebrowser/utils/qtutils.py @@ -31,15 +31,16 @@ Module attributes: import io import operator import contextlib +import typing # pylint: disable=unused-import,useless-suppression import pkg_resources from PyQt5.QtCore import (qVersion, QEventLoop, QDataStream, QByteArray, QIODevice, QSaveFile, QT_VERSION_STR, - PYQT_VERSION_STR) + PYQT_VERSION_STR, QFileDevice, QObject) try: from PyQt5.QtWebKit import qWebKitVersion except ImportError: # pragma: no cover - qWebKitVersion = None + qWebKitVersion = None # type: ignore MAXVALS = { @@ -61,19 +62,22 @@ class QtOSError(OSError): qt_errno: The error attribute of the given QFileDevice, if applicable. """ - def __init__(self, dev, msg=None): + def __init__(self, dev: QFileDevice, msg: str = None) -> None: if msg is None: msg = dev.errorString() super().__init__(msg) + self.qt_errno = None # type: typing.Optional[QFileDevice.FileError] try: self.qt_errno = dev.error() except AttributeError: - self.qt_errno = None + pass -def version_check(version, exact=False, compiled=True): +def version_check(version: str, + exact: bool = False, + compiled: bool = True) -> bool: """Check if the Qt runtime version is the version supplied or newer. Args: @@ -103,14 +107,14 @@ def version_check(version, exact=False, compiled=True): MAX_WORLD_ID = 256 if version_check('5.11.2') else 11 -def is_new_qtwebkit(): +def is_new_qtwebkit() -> bool: """Check if the given version is a new QtWebKit.""" assert qWebKitVersion is not None return (pkg_resources.parse_version(qWebKitVersion()) > pkg_resources.parse_version('538.1')) -def check_overflow(arg, ctype, fatal=True): +def check_overflow(arg: int, ctype: str, fatal: bool = True) -> int: """Check if the given argument is in bounds for the given type. Args: @@ -138,13 +142,13 @@ def check_overflow(arg, ctype, fatal=True): return arg -def ensure_valid(obj): +def ensure_valid(obj: QObject) -> None: """Ensure a Qt object with an .isValid() method is valid.""" if not obj.isValid(): raise QtValueError(obj) -def check_qdatastream(stream): +def check_qdatastream(stream: QDataStream) -> None: """Check the status of a QDataStream and raise OSError if it's not ok.""" status_to_str = { QDataStream.Ok: "The data stream is operating normally.", @@ -158,7 +162,7 @@ def check_qdatastream(stream): raise OSError(status_to_str[stream.status()]) -def serialize(obj): +def serialize(obj: QObject) -> QByteArray: """Serialize an object into a QByteArray.""" data = QByteArray() stream = QDataStream(data, QIODevice.WriteOnly) @@ -166,20 +170,20 @@ def serialize(obj): return data -def deserialize(data, obj): +def deserialize(data: QByteArray, obj: QObject) -> None: """Deserialize an object from a QByteArray.""" stream = QDataStream(data, QIODevice.ReadOnly) deserialize_stream(stream, obj) -def serialize_stream(stream, obj): +def serialize_stream(stream: QDataStream, obj: QObject) -> None: """Serialize an object into a QDataStream.""" check_qdatastream(stream) stream << obj # pylint: disable=pointless-statement check_qdatastream(stream) -def deserialize_stream(stream, obj): +def deserialize_stream(stream: QDataStream, obj: QObject) -> None: """Deserialize a QDataStream into an object.""" check_qdatastream(stream) stream >> obj # pylint: disable=pointless-statement @@ -195,11 +199,14 @@ def savefile_open(filename, binary=False, encoding='utf-8'): open_ok = f.open(QIODevice.WriteOnly) if not open_ok: raise QtOSError(f) + if binary: new_f = PyQIODevice(f) else: new_f = io.TextIOWrapper(PyQIODevice(f), encoding=encoding) + yield new_f + new_f.flush() except: f.cancelWriting() @@ -219,29 +226,29 @@ class PyQIODevice(io.BufferedIOBase): dev: The underlying QIODevice. """ - def __init__(self, dev): + def __init__(self, dev: QIODevice) -> None: super().__init__() self.dev = dev - def __len__(self): + def __len__(self) -> int: return self.dev.size() - def _check_open(self): + def _check_open(self) -> None: """Check if the device is open, raise ValueError if not.""" if not self.dev.isOpen(): raise ValueError("IO operation on closed device!") - def _check_random(self): + def _check_random(self) -> None: """Check if the device supports random access, raise OSError if not.""" if not self.seekable(): raise OSError("Random access not allowed!") - def _check_readable(self): + def _check_readable(self) -> None: """Check if the device is readable, raise OSError if not.""" if not self.dev.isReadable(): raise OSError("Trying to read unreadable file!") - def _check_writable(self): + def _check_writable(self) -> None: """Check if the device is writable, raise OSError if not.""" if not self.writable(): raise OSError("Trying to write to unwritable file!") @@ -263,7 +270,7 @@ class PyQIODevice(io.BufferedIOBase): raise QtOSError(self.dev) return contextlib.closing(self) - def close(self): + def close(self) -> None: """Close the underlying device.""" self.dev.close() @@ -285,22 +292,22 @@ class PyQIODevice(io.BufferedIOBase): if not ok: raise QtOSError(self.dev, msg="seek failed!") - def truncate(self, size=None): # pylint: disable=unused-argument + def truncate(self, size=None): raise io.UnsupportedOperation @property - def closed(self): + def closed(self) -> bool: return not self.dev.isOpen() - def flush(self): + def flush(self) -> None: self._check_open() self.dev.waitForBytesWritten(-1) - def isatty(self): + def isatty(self) -> bool: self._check_open() return False - def readable(self): + def readable(self) -> bool: return self.dev.isReadable() def readline(self, size=-1): @@ -326,18 +333,18 @@ class PyQIODevice(io.BufferedIOBase): raise QtOSError(self.dev) return buf - def seekable(self): + def seekable(self) -> bool: return not self.dev.isSequential() - def tell(self): + def tell(self) -> int: self._check_open() self._check_random() return self.dev.pos() - def writable(self): + def writable(self) -> bool: return self.dev.isWritable() - def write(self, b): + def write(self, b: bytes) -> int: self._check_open() self._check_writable() num = self.dev.write(b) @@ -361,7 +368,7 @@ class QtValueError(ValueError): """Exception which gets raised by ensure_valid.""" - def __init__(self, obj): + def __init__(self, obj: QObject) -> None: try: self.reason = obj.errorString() except AttributeError: @@ -379,7 +386,7 @@ class EventLoop(QEventLoop): Raises an exception when doing exec_() multiple times. """ - def __init__(self, parent=None): + def __init__(self, parent: QObject = None) -> None: super().__init__(parent) self._executing = False @@ -388,5 +395,6 @@ class EventLoop(QEventLoop): if self._executing: raise AssertionError("Eventloop is already running!") self._executing = True - super().exec_(flags) + status = super().exec_(flags) self._executing = False + return status diff --git a/qutebrowser/utils/standarddir.py b/qutebrowser/utils/standarddir.py index 356178404..7abb4429d 100644 --- a/qutebrowser/utils/standarddir.py +++ b/qutebrowser/utils/standarddir.py @@ -35,9 +35,17 @@ from qutebrowser.utils import log, debug, message, utils _locations = {} -Location = enum.Enum('Location', ['config', 'auto_config', - 'data', 'system_data', - 'cache', 'download', 'runtime']) +class _Location(enum.Enum): + + """A key for _locations.""" + + config = 1 + auto_config = 2 + data = 3 + system_data = 4 + cache = 5 + download = 6 + runtime = 7 APPNAME = 'qutebrowser' @@ -77,8 +85,8 @@ def _init_config(args): else: path = _writable_location(typ) _create(path) - _locations[Location.config] = path - _locations[Location.auto_config] = path + _locations[_Location.config] = path + _locations[_Location.auto_config] = path # Override the normal (non-auto) config on macOS if utils.is_mac: @@ -86,7 +94,7 @@ def _init_config(args): if not overridden: # pragma: no branch path = os.path.expanduser('~/.' + APPNAME) _create(path) - _locations[Location.config] = path + _locations[_Location.config] = path def config(auto=False): @@ -96,8 +104,8 @@ def config(auto=False): which is different on macOS. """ if auto: - return _locations[Location.auto_config] - return _locations[Location.config] + return _locations[_Location.auto_config] + return _locations[_Location.config] def _init_data(args): @@ -115,14 +123,14 @@ def _init_data(args): else: path = _writable_location(typ) _create(path) - _locations[Location.data] = path + _locations[_Location.data] = path # system_data - _locations.pop(Location.system_data, None) # Remove old state + _locations.pop(_Location.system_data, None) # Remove old state if utils.is_linux: path = '/usr/share/' + APPNAME if os.path.exists(path): - _locations[Location.system_data] = path + _locations[_Location.system_data] = path def data(system=False): @@ -133,10 +141,10 @@ def data(system=False): """ if system: try: - return _locations[Location.system_data] + return _locations[_Location.system_data] except KeyError: pass - return _locations[Location.data] + return _locations[_Location.data] def _init_cache(args): @@ -151,11 +159,11 @@ def _init_cache(args): else: path = _writable_location(typ) _create(path) - _locations[Location.cache] = path + _locations[_Location.cache] = path def cache(): - return _locations[Location.cache] + return _locations[_Location.cache] def _init_download(args): @@ -168,11 +176,11 @@ def _init_download(args): overridden, path = _from_args(typ, args) if not overridden: path = _writable_location(typ) - _locations[Location.download] = path + _locations[_Location.download] = path def download(): - return _locations[Location.download] + return _locations[_Location.download] def _init_runtime(args): @@ -205,11 +213,11 @@ def _init_runtime(args): # maximum length of 104 chars), so we don't add the username here... _create(path) - _locations[Location.runtime] = path + _locations[_Location.runtime] = path def runtime(): - return _locations[Location.runtime] + return _locations[_Location.runtime] def _writable_location(typ): diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index 4dda6b3fc..1acd9cd4e 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -29,9 +29,9 @@ import urllib.parse from PyQt5.QtCore import QUrl, QUrlQuery from PyQt5.QtNetwork import QHostInfo, QHostAddress, QNetworkProxy +from qutebrowser.api import cmdutils from qutebrowser.config import config from qutebrowser.utils import log, qtutils, message, utils -from qutebrowser.commands import cmdexc from qutebrowser.browser.network import pac @@ -116,7 +116,8 @@ def _get_search_url(txt): if engine is None: engine = 'DEFAULT' template = config.val.url.searchengines[engine] - url = qurl_from_user_input(template.format(urllib.parse.quote(term))) + quoted_term = urllib.parse.quote(term, safe='') + url = qurl_from_user_input(template.format(quoted_term)) if config.val.url.open_base_url and term in config.val.url.searchengines: url = qurl_from_user_input(config.val.url.searchengines[term]) @@ -360,7 +361,7 @@ def invalid_url_error(url, action): def raise_cmdexc_if_invalid(url): """Check if the given QUrl is invalid, and if so, raise a CommandError.""" if not url.isValid(): - raise cmdexc.CommandError(get_errstring(url)) + raise cmdutils.CommandError(get_errstring(url)) def get_path_if_valid(pathstr, cwd=None, relative=False, check_exists=False): diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 039d805f9..84b7e7f9b 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -210,21 +210,44 @@ PromptMode = enum.Enum('PromptMode', ['yesno', 'text', 'user_pwd', 'alert', 'download']) -# Where to open a clicked link. -ClickTarget = enum.Enum('ClickTarget', ['normal', 'tab', 'tab_bg', 'window', - 'hover']) +class ClickTarget(enum.Enum): + + """How to open a clicked link.""" + + normal = 0 #: Open the link in the current tab + tab = 1 #: Open the link in a new foreground tab + tab_bg = 2 #: Open the link in a new background tab + window = 3 #: Open the link in a new window + hover = 4 #: Only hover over the link -# Key input modes -KeyMode = enum.Enum('KeyMode', ['normal', 'hint', 'command', 'yesno', 'prompt', - 'insert', 'passthrough', 'caret', 'set_mark', - 'jump_mark', 'record_macro', 'run_macro']) +class KeyMode(enum.Enum): + + """Key input modes.""" + + normal = 1 #: Normal mode (no mode was entered) + hint = 2 #: Hint mode (showing labels for links) + command = 3 #: Command mode (after pressing the colon key) + yesno = 4 #: Yes/No prompts + prompt = 5 #: Text prompts + insert = 6 #: Insert mode (passing through most keys) + passthrough = 7 #: Passthrough mode (passing through all keys) + caret = 8 #: Caret mode (moving cursor with keys) + set_mark = 9 + jump_mark = 10 + record_macro = 11 + run_macro = 12 -# Exit statuses for errors. Needs to be an int for sys.exit. -Exit = enum.IntEnum('Exit', ['ok', 'reserved', 'exception', 'err_ipc', - 'err_init', 'err_config', 'err_key_config'], - start=0) +class Exit(enum.IntEnum): + + """Exit statuses for errors. Needs to be an int for sys.exit.""" + + ok = 0 + reserved = 1 + exception = 2 + err_ipc = 3 + err_init = 4 # Load status of a tab @@ -236,8 +259,14 @@ LoadStatus = enum.Enum('LoadStatus', ['none', 'success', 'success_https', Backend = enum.Enum('Backend', ['QtWebKit', 'QtWebEngine']) -# JS world for QtWebEngine -JsWorld = enum.Enum('JsWorld', ['main', 'application', 'user', 'jseval']) +class JsWorld(enum.Enum): + + """World/context to run JavaScript code in.""" + + main = 1 #: Same world as the web page's JavaScript. + application = 2 #: Application world, used by qutebrowser internally. + user = 3 #: User world, currently not used. + jseval = 4 #: World used for the jseval-command. # Log level of a JS message. This needs to match up with the keys allowed for @@ -248,6 +277,19 @@ JsLogLevel = enum.Enum('JsLogLevel', ['unknown', 'info', 'warning', 'error']) MessageLevel = enum.Enum('MessageLevel', ['error', 'warning', 'info']) +IgnoreCase = enum.Enum('IgnoreCase', ['smart', 'never', 'always']) + + +class CommandValue(enum.Enum): + + """Special values which are injected when running a command handler.""" + + count = 1 + win_id = 2 + cur_tab = 3 + count_tab = 4 + + class Question(QObject): """A question asked to the user, e.g. via the status bar. diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 6119675ba..2d517043a 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -41,10 +41,12 @@ from PyQt5.QtWidgets import QApplication import pkg_resources import yaml try: - from yaml import CSafeLoader as YamlLoader, CSafeDumper as YamlDumper + from yaml import (CSafeLoader as YamlLoader, # type: ignore + CSafeDumper as YamlDumper) YAML_C_EXT = True except ImportError: # pragma: no cover - from yaml import SafeLoader as YamlLoader, SafeDumper as YamlDumper + from yaml import (SafeLoader as YamlLoader, # type: ignore + SafeDumper as YamlDumper) YAML_C_EXT = False import qutebrowser @@ -630,7 +632,6 @@ def open_file(filename, cmdline=None): def unused(_arg): """Function which does nothing to avoid pylint complaining.""" - pass def expand_windows_drive(path): diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 7a99a4b65..a52e31ed8 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -42,12 +42,12 @@ from PyQt5.QtWidgets import QApplication try: from PyQt5.QtWebKit import qWebKitVersion except ImportError: # pragma: no cover - qWebKitVersion = None + qWebKitVersion = None # type: ignore try: from PyQt5.QtWebEngineWidgets import QWebEngineProfile except ImportError: # pragma: no cover - QWebEngineProfile = None + QWebEngineProfile = None # type: ignore import qutebrowser from qutebrowser.utils import log, utils, standarddir, usertypes, message diff --git a/requirements.txt b/requirements.txt index 022a6214f..68bd341bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py attrs==18.2.0 -colorama==0.4.0 +colorama==0.4.1 cssutils==1.0.2 Jinja2==2.10 MarkupSafe==1.1.0 -Pygments==2.2.0 +Pygments==2.3.1 pyPEG2==2.15.2 PyYAML==3.13 diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index e3f039336..8591f1c31 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -370,7 +370,7 @@ def main(): if args.upload is not None: # Fail early when trying to upload without github3 installed # or without API token - import github3 # pylint: disable=unused-variable + import github3 # pylint: disable=unused-import read_github_token() if args.no_asciidoc: diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 5c678ac96..d42ce1d71 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -53,11 +53,18 @@ MsgType = enum.Enum('MsgType', 'insufficent_coverage, perfect_file') PERFECT_FILES = [ (None, 'commands/cmdexc.py'), - ('tests/unit/commands/test_cmdutils.py', - 'commands/cmdutils.py'), ('tests/unit/commands/test_argparser.py', 'commands/argparser.py'), + ('tests/unit/api/test_cmdutils.py', + 'api/cmdutils.py'), + (None, + 'api/apitypes.py'), + (None, + 'api/config.py'), + (None, + 'api/message.py'), + ('tests/unit/browser/webkit/test_cache.py', 'browser/webkit/cache.py'), ('tests/unit/browser/webkit/test_cookies.py', @@ -113,7 +120,7 @@ PERFECT_FILES = [ 'misc/keyhintwidget.py'), ('tests/unit/misc/test_pastebin.py', 'misc/pastebin.py'), - (None, + ('tests/unit/misc/test_objects.py', 'misc/objects.py'), (None, @@ -197,6 +204,7 @@ WHITELISTED_FILES = [ 'browser/webkit/webkitinspector.py', 'keyinput/macros.py', 'browser/webkit/webkitelem.py', + 'api/interceptor.py', ] diff --git a/scripts/dev/misc_checks.py b/scripts/dev/misc_checks.py index 299246448..27ff0105b 100644 --- a/scripts/dev/misc_checks.py +++ b/scripts/dev/misc_checks.py @@ -89,7 +89,8 @@ def check_spelling(): '[Ww]ether', '[Pp]rogramatically', '[Ss]plitted', '[Ee]xitted', '[Mm]ininum', '[Rr]esett?ed', '[Rr]ecieved', '[Rr]egularily', '[Uu]nderlaying', '[Ii]nexistant', '[Ee]lipsis', 'commiting', - 'existant', '[Rr]esetted', '[Ss]imilarily', '[Ii]nformations'} + 'existant', '[Rr]esetted', '[Ss]imilarily', '[Ii]nformations', + '[Aa]n [Uu][Rr][Ll]'} # Words which look better when splitted, but might need some fine tuning. words |= {'[Ww]ebelements', '[Mm]ouseevent', '[Kk]eysequence', diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py index 0015539f9..f9262c946 100755 --- a/scripts/dev/run_vulture.py +++ b/scripts/dev/run_vulture.py @@ -30,7 +30,8 @@ import argparse import vulture import qutebrowser.app # pylint: disable=unused-import -from qutebrowser.commands import cmdutils +from qutebrowser.extensions import loader +from qutebrowser.misc import objects from qutebrowser.utils import utils from qutebrowser.browser.webkit import rfc6266 # To run the decorators from there @@ -43,8 +44,10 @@ from qutebrowser.config import configtypes def whitelist_generator(): # noqa """Generator which yields lines to add to a vulture whitelist.""" + loader.load_components(skip_hooks=True) + # qutebrowser commands - for cmd in cmdutils.cmd_dict.values(): + for cmd in objects.commands.values(): yield utils.qualname(cmd.handler) # pyPEG2 classes @@ -84,6 +87,7 @@ def whitelist_generator(): # noqa yield 'qutebrowser.utils.log.QtWarningFilter.filter' yield 'qutebrowser.browser.pdfjs.is_available' yield 'qutebrowser.misc.guiprocess.spawn_output' + yield 'qutebrowser.utils.usertypes.ExitStatus.reserved' yield 'QEvent.posted' yield 'log_stack' # from message.py yield 'propagate' # logging.getLogger('...).propagate = False @@ -126,6 +130,9 @@ def whitelist_generator(): # noqa yield 'scripts.get_coredumpctl_traces.Line.gid' yield 'scripts.importer.import_moz_places.places.row_factory' + # component hooks + yield 'qutebrowser.components.adblock.on_config_changed' + def filter_func(item): """Check if a missing function should be filtered or not. diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py index cc00c3757..1ba272fba 100755 --- a/scripts/dev/src2asciidoc.py +++ b/scripts/dev/src2asciidoc.py @@ -35,9 +35,11 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, # We import qutebrowser.app so all @cmdutils-register decorators are run. import qutebrowser.app from qutebrowser import qutebrowser, commands -from qutebrowser.commands import cmdutils, argparser +from qutebrowser.extensions import loader +from qutebrowser.commands import argparser from qutebrowser.config import configdata, configtypes from qutebrowser.utils import docutils, usertypes +from qutebrowser.misc import objects from scripts import asciidoc2html, utils FILE_HEADER = """ @@ -135,7 +137,7 @@ def _get_command_quickref(cmds): out.append('|Command|Description') for name, cmd in cmds: desc = inspect.getdoc(cmd.handler).splitlines()[0] - out.append('|<<{},{}>>|{}'.format(name, name, desc)) + out.append('|<<{name},{name}>>|{desc}'.format(name=name, desc=desc)) out.append('|==============') return '\n'.join(out) @@ -251,14 +253,18 @@ def _get_command_doc_count(cmd, parser): Strings which should be added to the docs. """ for param in inspect.signature(cmd.handler).parameters.values(): - if cmd.get_arg_info(param).count: + if cmd.get_arg_info(param).value in cmd.COUNT_COMMAND_VALUES: yield "" yield "==== count" try: yield parser.arg_descs[param.name] - except KeyError as e: - raise KeyError("No description for count arg {!r} of command " - "{!r}!".format(param.name, cmd.name)) from e + except KeyError: + try: + yield parser.arg_descs['count'] + except KeyError as e: + raise KeyError("No description for count arg {!r} of " + "command {!r}!" + .format(param.name, cmd.name)) from e def _get_command_doc_notes(cmd): @@ -350,7 +356,7 @@ def generate_commands(filename): normal_cmds = [] other_cmds = [] debug_cmds = [] - for name, cmd in cmdutils.cmd_dict.items(): + for name, cmd in objects.commands.items(): if cmd.deprecated: continue if usertypes.KeyMode.normal not in cmd.modes: @@ -544,6 +550,7 @@ def regenerate_cheatsheet(): def main(): """Regenerate all documentation.""" utils.change_cwd() + loader.load_components(skip_hooks=True) print("Generating manpage...") regenerate_manpage('doc/qutebrowser.1.asciidoc') print("Generating settings help...") diff --git a/scripts/hist_importer.py b/scripts/hist_importer.py index 914701a19..31936b4c1 100755 --- a/scripts/hist_importer.py +++ b/scripts/hist_importer.py @@ -33,8 +33,6 @@ class Error(Exception): """Exception for errors in this module.""" - pass - def parse(): """Parse command line arguments.""" diff --git a/scripts/hostblock_blame.py b/scripts/hostblock_blame.py index 2f68d2961..e5508f515 100644 --- a/scripts/hostblock_blame.py +++ b/scripts/hostblock_blame.py @@ -27,7 +27,7 @@ import os.path import urllib.request sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) -from qutebrowser.browser import adblock +from qutebrowser.components import adblock from qutebrowser.config import configdata diff --git a/scripts/link_pyqt.py b/scripts/link_pyqt.py index ae7eaa622..e16056fa8 100644 --- a/scripts/link_pyqt.py +++ b/scripts/link_pyqt.py @@ -34,8 +34,6 @@ class Error(Exception): """Exception raised when linking fails.""" - pass - def run_py(executable, *code): """Run the given python code with the given executable.""" diff --git a/scripts/open_url_in_instance.sh b/scripts/open_url_in_instance.sh index a6ce0ed91..ec2a5a26d 100755 --- a/scripts/open_url_in_instance.sh +++ b/scripts/open_url_in_instance.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # initial idea: Florian Bruhin (The-Compiler) # author: Thore Bödecker (foxxx0) diff --git a/tests/conftest.py b/tests/conftest.py index f53a70054..d06dfcfa6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -206,7 +206,6 @@ def pytest_configure(config): webengine_env = os.environ.get('QUTE_BDD_WEBENGINE', '') config.webengine = bool(webengine_arg or webengine_env) # Fail early if QtWebEngine is not available - # pylint: disable=unused-variable if config.webengine: import PyQt5.QtWebEngineWidgets @@ -283,7 +282,7 @@ def apply_fake_os(monkeypatch, request): def check_yaml_c_exts(): """Make sure PyYAML C extensions are available on Travis.""" if 'TRAVIS' in os.environ: - from yaml import CLoader # pylint: disable=unused-variable + from yaml import CLoader @pytest.hookimpl(tryfirst=True, hookwrapper=True) diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature index 9f0d2f14b..8fedf5af1 100644 --- a/tests/end2end/features/javascript.feature +++ b/tests/end2end/features/javascript.feature @@ -124,9 +124,9 @@ Feature: Javascript stuff # https://github.com/qutebrowser/qutebrowser/issues/1190 # https://github.com/qutebrowser/qutebrowser/issues/2495 - # Currently broken on Windows: + # Currently broken on Windows and on Qt 5.12 # https://github.com/qutebrowser/qutebrowser/issues/4230 - @posix + @posix @qt<5.12 Scenario: Checking visible/invisible window size When I run :tab-only And I open data/javascript/windowsize.html in a new background tab @@ -134,7 +134,7 @@ Feature: Javascript stuff And I run :tab-next Then the window sizes should be the same - @flaky + @flaky @qt<5.12 Scenario: Checking visible/invisible window size with vertical tabbar When I run :tab-only And I set tabs.position to left @@ -188,6 +188,7 @@ Feature: Javascript stuff And I open 500 without waiting Then "Showing error page for* 500" should be logged + @flaky Scenario: Using JS after window.open When I open data/hello.txt And I set content.javascript.can_open_tabs_automatically to true diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index a8e81b7b5..b9677a158 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -197,7 +197,7 @@ Feature: Various utility commands. # We can't use "When I open" because we don't want to wait for load # finished When I run :open http://localhost:(port)/redirect-later?delay=-1 - And I wait for "emitting: cur_load_status_changed('loading') (tab *)" in the log + And I wait for "emitting: cur_load_status_changed() (tab *)" in the log And I wait 1s And I run :stop And I open redirect-later-continue in a new tab diff --git a/tests/end2end/features/yankpaste.feature b/tests/end2end/features/yankpaste.feature index 806bbd2d2..08a62d302 100644 --- a/tests/end2end/features/yankpaste.feature +++ b/tests/end2end/features/yankpaste.feature @@ -187,10 +187,10 @@ Feature: Yanking and pasting. http://qutebrowser.org should not open And I run :open -t {clipboard} - And I wait until data/hello.txt?q=this%20url%3A%0Ahttp%3A//qutebrowser.org%0Ashould%20not%20open is loaded + And I wait until data/hello.txt?q=this%20url%3A%0Ahttp%3A%2F%2Fqutebrowser.org%0Ashould%20not%20open is loaded Then the following tabs should be open: - about:blank - - data/hello.txt?q=this%20url%3A%0Ahttp%3A//qutebrowser.org%0Ashould%20not%20open (active) + - data/hello.txt?q=this%20url%3A%0Ahttp%3A%2F%2Fqutebrowser.org%0Ashould%20not%20open (active) Scenario: Pasting multiline whose first line looks like a URI When I set url.auto_search to naive diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index f4e5d1486..146817c12 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -356,7 +356,10 @@ class QuteProc(testprocess.Process): self._focus_ready = True else: raise ValueError("Invalid value {!r} for 'what'.".format(what)) - if self._load_ready and self._focus_ready: + + is_qt_5_12 = qtutils.version_check('5.12', compiled=False) + if ((self._load_ready and self._focus_ready) or + (self._load_ready and is_qt_5_12)): self._load_ready = False self._focus_ready = False self.ready.emit() @@ -842,8 +845,6 @@ class YamlLoader(yaml.SafeLoader): """Custom YAML loader used in compare_session.""" - pass - # Translate ... to ellipsis in YAML. YamlLoader.add_constructor('!ellipsis', lambda loader, node: ...) diff --git a/tests/end2end/fixtures/testprocess.py b/tests/end2end/fixtures/testprocess.py index d0af5bebc..eab914a1a 100644 --- a/tests/end2end/fixtures/testprocess.py +++ b/tests/end2end/fixtures/testprocess.py @@ -39,15 +39,11 @@ class InvalidLine(Exception): """Raised when the process prints a line which is not parsable.""" - pass - class ProcessExited(Exception): """Raised when the child process did exit.""" - pass - class WaitForTimeout(Exception): @@ -271,7 +267,6 @@ class Process(QObject): def _after_start(self): """Do things which should be done immediately after starting.""" - pass def before_test(self): """Restart process before a test if it exited before.""" @@ -443,7 +438,6 @@ class Process(QObject): QuteProc._maybe_skip, and call _maybe_skip after every parsed message in wait_for (where it's most likely that new messages arrive). """ - pass def wait_for(self, timeout=None, *, override_waited_for=False, do_skip=False, divisor=1, after=None, **kwargs): diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 254b5ffaf..d1e45409e 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -82,7 +82,8 @@ class Request(testprocess.Line): for i in range(15): path_to_statuses['/redirect/{}'.format(i)] = [HTTPStatus.FOUND] for suffix in ['', '1', '2', '3', '4', '5', '6']: - key = '/basic-auth/user{}/password{}'.format(suffix, suffix) + key = ('/basic-auth/user{suffix}/password{suffix}' + .format(suffix=suffix)) path_to_statuses[key] = [HTTPStatus.UNAUTHORIZED, HTTPStatus.OK] default_statuses = [HTTPStatus.OK, HTTPStatus.NOT_MODIFIED] diff --git a/tests/end2end/test_mhtml_e2e.py b/tests/end2end/test_mhtml_e2e.py index 953824b8c..27fdd2abf 100644 --- a/tests/end2end/test_mhtml_e2e.py +++ b/tests/end2end/test_mhtml_e2e.py @@ -61,8 +61,8 @@ def normalize_line(line): return line -def normalize_whole(s): - if qtutils.version_check('5.12', compiled=False): +def normalize_whole(s, webengine): + if qtutils.version_check('5.12', compiled=False) and webengine: s = s.replace('\n\n-----=_qute-UUID', '\n-----=_qute-UUID') return s @@ -71,8 +71,9 @@ class DownloadDir: """Abstraction over a download directory.""" - def __init__(self, tmpdir): + def __init__(self, tmpdir, config): self._tmpdir = tmpdir + self._config = config self.location = str(tmpdir) def read_file(self): @@ -92,14 +93,15 @@ class DownloadDir: if normalize_line(line) is not None) actual_data = '\n'.join(normalize_line(line) for line in self.read_file()) - actual_data = normalize_whole(actual_data) + actual_data = normalize_whole(actual_data, + webengine=self._config.webengine) assert actual_data == expected_data @pytest.fixture -def download_dir(tmpdir): - return DownloadDir(tmpdir) +def download_dir(tmpdir, pytestconfig): + return DownloadDir(tmpdir, pytestconfig) def _test_mhtml_requests(test_dir, test_path, server): diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index fac23ae1c..f993cbf19 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -44,6 +44,7 @@ from PyQt5.QtNetwork import QNetworkCookieJar import helpers.stubs as stubsmod from qutebrowser.config import (config, configdata, configtypes, configexc, configfiles, configcache) +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 @@ -190,8 +191,8 @@ def testdata_scheme(qapp): @pytest.fixture def web_tab_setup(qtbot, tab_registry, session_manager_stub, - greasemonkey_manager, fake_args, host_blocker_stub, - config_stub, testdata_scheme): + greasemonkey_manager, fake_args, config_stub, + testdata_scheme): """Shared setup for webkit_tab/webengine_tab.""" # Make sure error logging via JS fails tests config_stub.val.content.javascript.log = { @@ -306,6 +307,7 @@ def config_stub(stubs, monkeypatch, configdata_init, yaml_config_stub): container = config.ConfigContainer(conf) monkeypatch.setattr(config, 'val', container) + monkeypatch.setattr(configapi, 'val', container) cache = configcache.ConfigCache() monkeypatch.setattr(config, 'cache', cache) @@ -328,15 +330,6 @@ def key_config_stub(config_stub, monkeypatch): return keyconf -@pytest.fixture -def host_blocker_stub(stubs): - """Fixture which provides a fake host blocker object.""" - stub = stubs.HostBlockerStub() - objreg.register('host-blocker', stub) - yield stub - objreg.delete('host-blocker') - - @pytest.fixture def quickmark_manager_stub(stubs): """Fixture which provides a fake quickmark manager object.""" diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 9bf5b837d..38d82c004 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -241,6 +241,12 @@ class FakeWebTabAudio(browsertab.AbstractAudio): return False +class FakeWebTabPrivate(browsertab.AbstractTabPrivate): + + def shutdown(self): + pass + + class FakeWebTab(browsertab.AbstractTab): """Fake AbstractTab to use in tests.""" @@ -249,7 +255,7 @@ class FakeWebTab(browsertab.AbstractTab): scroll_pos_perc=(0, 0), load_status=usertypes.LoadStatus.success, progress=0, can_go_back=None, can_go_forward=None): - super().__init__(win_id=0, mode_manager=None, private=False) + super().__init__(win_id=0, private=False) self._load_status = load_status self._title = title self._url = url @@ -258,10 +264,11 @@ class FakeWebTab(browsertab.AbstractTab): can_go_forward=can_go_forward) self.scroller = FakeWebTabScroller(self, scroll_pos_perc) self.audio = FakeWebTabAudio(self) + self.private_api = FakeWebTabPrivate(tab=self, mode_manager=None) wrapped = QWidget() self._layout.wrap(self, wrapped) - def url(self, requested=False): + def url(self, *, requested=False): assert not requested return self._url @@ -274,9 +281,6 @@ class FakeWebTab(browsertab.AbstractTab): def load_status(self): return self._load_status - def shutdown(self): - pass - def icon(self): return QIcon() @@ -306,7 +310,6 @@ class FakeSignal: Currently does nothing, but could be improved to do some sanity checking on the slot. """ - pass def disconnect(self, slot=None): """Disconnect the signal from a slot. @@ -314,7 +317,6 @@ class FakeSignal: Currently does nothing, but could be improved to do some sanity checking on the slot and see if it actually got connected. """ - pass def emit(self, *args): """Emit the signal. @@ -322,15 +324,6 @@ class FakeSignal: Currently does nothing, but could be improved to do type checking based on a signature given to __init__. """ - pass - - -@attr.s -class FakeCmdUtils: - - """Stub for cmdutils which provides a cmd_dict.""" - - cmd_dict = attr.ib() @attr.s(frozen=True) @@ -457,8 +450,6 @@ class BookmarkManagerStub(UrlMarkManagerStub): """Stub for the bookmark-manager object.""" - pass - class QuickmarkManagerStub(UrlMarkManagerStub): @@ -468,17 +459,6 @@ class QuickmarkManagerStub(UrlMarkManagerStub): self.delete(key) -class HostBlockerStub: - - """Stub for the host-blocker object.""" - - def __init__(self): - self.blocked_hosts = set() - - def is_blocked(self, url, first_party_url=None): - return url in self.blocked_hosts - - class SessionManagerStub: """Stub for the session-manager object.""" @@ -501,7 +481,7 @@ class TabbedBrowserStub(QObject): super().__init__(parent) self.widget = TabWidgetStub() self.shutting_down = False - self.opened_url = None + self.loaded_url = None self.cur_url = None def on_tab_close_requested(self, idx): @@ -511,10 +491,10 @@ class TabbedBrowserStub(QObject): return self.widget.tabs def tabopen(self, url): - self.opened_url = url + self.loaded_url = url - def openurl(self, url, *, newtab): - self.opened_url = url + def load_url(self, url, *, newtab): + self.loaded_url = url def current_url(self): if self.current_url is None: diff --git a/tests/unit/commands/test_cmdutils.py b/tests/unit/api/test_cmdutils.py similarity index 77% rename from tests/unit/commands/test_cmdutils.py rename to tests/unit/api/test_cmdutils.py index fc68fa920..4116045ae 100644 --- a/tests/unit/commands/test_cmdutils.py +++ b/tests/unit/api/test_cmdutils.py @@ -19,7 +19,7 @@ # pylint: disable=unused-variable -"""Tests for qutebrowser.commands.cmdutils.""" +"""Tests for qutebrowser.api.cmdutils.""" import sys import logging @@ -29,14 +29,15 @@ import enum import pytest -from qutebrowser.commands import cmdutils, cmdexc, argparser, command +from qutebrowser.misc import objects +from qutebrowser.commands import cmdexc, argparser, command +from qutebrowser.api import cmdutils from qutebrowser.utils import usertypes @pytest.fixture(autouse=True) def clear_globals(monkeypatch): - """Clear the cmdutils globals between each test.""" - monkeypatch.setattr(cmdutils, 'cmd_dict', {}) + monkeypatch.setattr(objects, 'commands', {}) def _get_cmd(*args, **kwargs): @@ -48,8 +49,7 @@ def _get_cmd(*args, **kwargs): @cmdutils.register(*args, **kwargs) def fun(): """Blah.""" - pass - return cmdutils.cmd_dict['fun'] + return objects.commands['fun'] class TestCheckOverflow: @@ -60,7 +60,7 @@ class TestCheckOverflow: def test_bad(self): int32_max = 2 ** 31 - 1 - with pytest.raises(cmdexc.CommandError, match="Numeric argument is " + with pytest.raises(cmdutils.CommandError, match="Numeric argument is " "too large for internal int representation."): cmdutils.check_overflow(int32_max + 1, 'int') @@ -72,7 +72,7 @@ class TestCheckExclusive: cmdutils.check_exclusive(flags, []) def test_bad(self): - with pytest.raises(cmdexc.CommandError, + with pytest.raises(cmdutils.CommandError, match="Only one of -x/-y/-z can be given!"): cmdutils.check_exclusive([True, True], 'xyz') @@ -83,73 +83,65 @@ class TestRegister: @cmdutils.register() def fun(): """Blah.""" - pass - cmd = cmdutils.cmd_dict['fun'] + cmd = objects.commands['fun'] assert cmd.handler is fun assert cmd.name == 'fun' - assert len(cmdutils.cmd_dict) == 1 + assert len(objects.commands) == 1 def test_underlines(self): """Make sure the function name is normalized correctly (_ -> -).""" @cmdutils.register() def eggs_bacon(): """Blah.""" - pass - assert cmdutils.cmd_dict['eggs-bacon'].name == 'eggs-bacon' - assert 'eggs_bacon' not in cmdutils.cmd_dict + assert objects.commands['eggs-bacon'].name == 'eggs-bacon' + assert 'eggs_bacon' not in objects.commands def test_lowercasing(self): """Make sure the function name is normalized correctly (uppercase).""" @cmdutils.register() def Test(): # noqa: N801,N806 pylint: disable=invalid-name """Blah.""" - pass - assert cmdutils.cmd_dict['test'].name == 'test' - assert 'Test' not in cmdutils.cmd_dict + assert objects.commands['test'].name == 'test' + assert 'Test' not in objects.commands def test_explicit_name(self): """Test register with explicit name.""" @cmdutils.register(name='foobar') def fun(): """Blah.""" - pass - assert cmdutils.cmd_dict['foobar'].name == 'foobar' - assert 'fun' not in cmdutils.cmd_dict - assert len(cmdutils.cmd_dict) == 1 + assert objects.commands['foobar'].name == 'foobar' + assert 'fun' not in objects.commands + assert len(objects.commands) == 1 def test_multiple_registrations(self): """Make sure registering the same name twice raises ValueError.""" @cmdutils.register(name='foobar') def fun(): """Blah.""" - pass with pytest.raises(ValueError): @cmdutils.register(name='foobar') def fun2(): """Blah.""" - pass def test_instance(self): """Make sure the instance gets passed to Command.""" @cmdutils.register(instance='foobar') def fun(self): """Blah.""" - pass - assert cmdutils.cmd_dict['fun']._instance == 'foobar' + assert objects.commands['fun']._instance == 'foobar' def test_star_args(self): """Check handling of *args.""" @cmdutils.register() def fun(*args): """Blah.""" - pass with pytest.raises(argparser.ArgumentParserError): - cmdutils.cmd_dict['fun'].parser.parse_args([]) + objects.commands['fun'].parser.parse_args([]) def test_star_args_optional(self): """Check handling of *args withstar_args_optional.""" @@ -157,11 +149,20 @@ class TestRegister: def fun(*args): """Blah.""" assert not args - cmd = cmdutils.cmd_dict['fun'] + cmd = objects.commands['fun'] cmd.namespace = cmd.parser.parse_args([]) args, kwargs = cmd._get_call_args(win_id=0) fun(*args, **kwargs) + def test_star_args_optional_annotated(self): + @cmdutils.register(star_args_optional=True) + def fun(*args: str): + """Blah.""" + + cmd = objects.commands['fun'] + cmd.namespace = cmd.parser.parse_args([]) + cmd._get_call_args(win_id=0) + @pytest.mark.parametrize('inp, expected', [ (['--arg'], True), (['-a'], True), ([], False)]) def test_flag(self, inp, expected): @@ -169,7 +170,7 @@ class TestRegister: def fun(arg=False): """Blah.""" assert arg == expected - cmd = cmdutils.cmd_dict['fun'] + cmd = objects.commands['fun'] cmd.namespace = cmd.parser.parse_args(inp) assert cmd.namespace.arg == expected @@ -179,7 +180,7 @@ class TestRegister: def fun(arg=False): """Blah.""" assert arg - cmd = cmdutils.cmd_dict['fun'] + cmd = objects.commands['fun'] with pytest.raises(argparser.ArgumentParserError): cmd.parser.parse_args(['-a']) @@ -189,38 +190,71 @@ class TestRegister: args, kwargs = cmd._get_call_args(win_id=0) fun(*args, **kwargs) + def test_self_without_instance(self): + with pytest.raises(TypeError, match="fun is a class method, but " + "instance was not given!"): + @cmdutils.register() + def fun(self): + """Blah.""" + + def test_instance_without_self(self): + with pytest.raises(TypeError, match="fun is not a class method, but " + "instance was given!"): + @cmdutils.register(instance='inst') + def fun(): + """Blah.""" + + def test_var_kw(self): + with pytest.raises(TypeError, match="fun: functions with varkw " + "arguments are not supported!"): + @cmdutils.register() + def fun(**kwargs): + """Blah.""" + def test_partial_arg(self): """Test with only some arguments decorated with @cmdutils.argument.""" @cmdutils.register() @cmdutils.argument('arg1', flag='b') def fun(arg1=False, arg2=False): """Blah.""" - pass def test_win_id(self): @cmdutils.register() - @cmdutils.argument('win_id', win_id=True) + @cmdutils.argument('win_id', value=cmdutils.Value.win_id) def fun(win_id): """Blah.""" - pass - assert cmdutils.cmd_dict['fun']._get_call_args(42) == ([42], {}) + assert objects.commands['fun']._get_call_args(42) == ([42], {}) def test_count(self): @cmdutils.register() - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def fun(count=0): """Blah.""" - pass - assert cmdutils.cmd_dict['fun']._get_call_args(42) == ([0], {}) + assert objects.commands['fun']._get_call_args(42) == ([0], {}) + + def test_fill_self(self): + with pytest.raises(TypeError, match="fun: Can't fill 'self' with " + "value!"): + @cmdutils.register(instance='foobar') + @cmdutils.argument('self', value=cmdutils.Value.count) + def fun(self): + """Blah.""" + + def test_fill_invalid(self): + with pytest.raises(TypeError, match="fun: Invalid value='foo' for " + "argument 'arg'!"): + @cmdutils.register() + @cmdutils.argument('arg', value='foo') + def fun(arg): + """Blah.""" def test_count_without_default(self): with pytest.raises(TypeError, match="fun: handler has count parameter " "without default!"): @cmdutils.register() - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def fun(count): """Blah.""" - pass @pytest.mark.parametrize('hide', [True, False]) def test_pos_args(self, hide): @@ -228,9 +262,8 @@ class TestRegister: @cmdutils.argument('arg', hide=hide) def fun(arg): """Blah.""" - pass - pos_args = cmdutils.cmd_dict['fun'].pos_args + pos_args = objects.commands['fun'].pos_args if hide: assert pos_args == [] else: @@ -265,7 +298,7 @@ class TestRegister: """Blah.""" assert arg == expected - cmd = cmdutils.cmd_dict['fun'] + cmd = objects.commands['fun'] cmd.namespace = cmd.parser.parse_args([inp]) if expected is cmdexc.ArgumentTypeError: @@ -283,9 +316,8 @@ class TestRegister: @cmdutils.argument('arg', choices=['foo', 'bar']) def fun(arg): """Blah.""" - pass - cmd = cmdutils.cmd_dict['fun'] + cmd = objects.commands['fun'] cmd.namespace = cmd.parser.parse_args(['fish']) with pytest.raises(cmdexc.ArgumentTypeError): @@ -297,9 +329,8 @@ class TestRegister: @cmdutils.argument('arg', choices=['foo', 'bar']) def fun(*, arg='foo'): """Blah.""" - pass - cmd = cmdutils.cmd_dict['fun'] + cmd = objects.commands['fun'] cmd.namespace = cmd.parser.parse_args(['--arg=fish']) with pytest.raises(cmdexc.ArgumentTypeError): @@ -312,9 +343,8 @@ class TestRegister: @cmdutils.argument('opt') def fun(foo, bar, opt=False): """Blah.""" - pass - cmd = cmdutils.cmd_dict['fun'] + cmd = objects.commands['fun'] assert cmd.get_pos_arg_info(0) == command.ArgInfo(choices=('a', 'b')) assert cmd.get_pos_arg_info(1) == command.ArgInfo(choices=('x', 'y')) with pytest.raises(IndexError): @@ -324,7 +354,6 @@ class TestRegister: # https://github.com/qutebrowser/qutebrowser/issues/1872 def fun(*, target): """Blah.""" - pass with pytest.raises(TypeError, match="fun: handler has keyword only " "argument 'target' without default!"): @@ -334,7 +363,6 @@ class TestRegister: # https://github.com/qutebrowser/qutebrowser/issues/1872 def fun(*, target: int): """Blah.""" - pass with pytest.raises(TypeError, match="fun: handler has keyword only " "argument 'target' without default!"): @@ -350,20 +378,29 @@ class TestArgument: @cmdutils.argument('foo') def fun(bar): """Blah.""" - pass def test_storage(self): @cmdutils.argument('foo', flag='x') @cmdutils.argument('bar', flag='y') def fun(foo, bar): """Blah.""" - pass expected = { 'foo': command.ArgInfo(flag='x'), 'bar': command.ArgInfo(flag='y') } assert fun.qute_args == expected + def test_arginfo_boolean(self): + @cmdutils.argument('special1', value=cmdutils.Value.count) + @cmdutils.argument('special2', value=cmdutils.Value.win_id) + @cmdutils.argument('normal') + def fun(special1, special2, normal): + """Blah.""" + + assert fun.qute_args['special1'].value + assert fun.qute_args['special2'].value + assert not fun.qute_args['normal'].value + def test_wrong_order(self): """When @cmdutils.argument is used above (after) @register, fail.""" with pytest.raises(ValueError, match=r"@cmdutils.argument got called " @@ -372,15 +409,6 @@ class TestArgument: @cmdutils.register() def fun(bar): """Blah.""" - pass - - def test_count_and_win_id_same_arg(self): - with pytest.raises(TypeError, - match="Argument marked as both count/win_id!"): - @cmdutils.argument('arg', count=True, win_id=True) - def fun(arg=0): - """Blah.""" - pass def test_no_docstring(self, caplog): with caplog.at_level(logging.WARNING): @@ -388,6 +416,7 @@ class TestArgument: def fun(): # no docstring pass + assert len(caplog.records) == 1 assert caplog.messages[0].endswith('test_cmdutils.py has no docstring') @@ -441,10 +470,9 @@ class TestRun: backend=usertypes.Backend.QtWebEngine) def fun(self): """Blah.""" - pass monkeypatch.setattr(command.objects, 'backend', usertypes.Backend.QtWebKit) - cmd = cmdutils.cmd_dict['fun'] + cmd = objects.commands['fun'] with pytest.raises(cmdexc.PrerequisitesError, match=r'.* backend\.'): cmd.run(win_id=0) diff --git a/tests/unit/browser/test_caret.py b/tests/unit/browser/test_caret.py index 6165546e5..b3706ffca 100644 --- a/tests/unit/browser/test_caret.py +++ b/tests/unit/browser/test_caret.py @@ -30,7 +30,7 @@ from qutebrowser.utils import usertypes, qtutils @pytest.fixture def caret(web_tab, qtbot, mode_manager): with qtbot.wait_signal(web_tab.load_finished): - web_tab.openurl(QUrl('qute://testdata/data/caret.html')) + web_tab.load_url(QUrl('qute://testdata/data/caret.html')) mode_manager.enter(usertypes.KeyMode.caret) diff --git a/tests/unit/browser/test_hints.py b/tests/unit/browser/test_hints.py index 5c2758d84..609fb3dc6 100644 --- a/tests/unit/browser/test_hints.py +++ b/tests/unit/browser/test_hints.py @@ -56,7 +56,7 @@ def test_show_benchmark(benchmark, tabbed_browser, qtbot, message_bridge, tab = tabbed_browser.widget.tabs[0] with qtbot.wait_signal(tab.load_finished): - tab.openurl(QUrl('qute://testdata/data/hints/benchmark.html')) + tab.load_url(QUrl('qute://testdata/data/hints/benchmark.html')) manager = qutebrowser.browser.hints.HintManager(0, 0) @@ -76,7 +76,7 @@ def test_match_benchmark(benchmark, tabbed_browser, qtbot, message_bridge, tab = tabbed_browser.widget.tabs[0] with qtbot.wait_signal(tab.load_finished): - tab.openurl(QUrl('qute://testdata/data/hints/benchmark.html')) + tab.load_url(QUrl('qute://testdata/data/hints/benchmark.html')) manager = qutebrowser.browser.hints.HintManager(0, 0) diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py index 5b84eac4c..715b597b0 100644 --- a/tests/unit/browser/test_history.py +++ b/tests/unit/browser/test_history.py @@ -26,7 +26,7 @@ from PyQt5.QtCore import QUrl from qutebrowser.browser import history from qutebrowser.utils import objreg, urlutils, usertypes -from qutebrowser.commands import cmdexc +from qutebrowser.api import cmdutils from qutebrowser.misc import sql @@ -324,7 +324,7 @@ class TestDump: def test_nonexistent(self, web_history, tmpdir): histfile = tmpdir / 'nonexistent' / 'history' - with pytest.raises(cmdexc.CommandError): + with pytest.raises(cmdutils.CommandError): web_history.debug_dump_history(str(histfile)) diff --git a/tests/unit/browser/test_pdfjs.py b/tests/unit/browser/test_pdfjs.py index 7fa1a8f6c..dcee2b82b 100644 --- a/tests/unit/browser/test_pdfjs.py +++ b/tests/unit/browser/test_pdfjs.py @@ -82,14 +82,14 @@ def test_generate_pdfjs_script_disable_object_url(monkeypatch, if qt == 'new': monkeypatch.setattr(pdfjs.qtutils, 'version_check', lambda version, exact=False, compiled=True: - False if version == '5.7.1' else True) + version != '5.7.1') elif qt == 'old': monkeypatch.setattr(pdfjs.qtutils, 'version_check', lambda version, exact=False, compiled=True: False) elif qt == '5.7': monkeypatch.setattr(pdfjs.qtutils, 'version_check', lambda version, exact=False, compiled=True: - True if version == '5.7.1' else False) + version == '5.7.1') else: raise utils.Unreachable diff --git a/tests/unit/browser/webkit/http/test_http_hypothesis.py b/tests/unit/browser/webkit/http/test_http_hypothesis.py index ec8ee4aff..5727bf1bc 100644 --- a/tests/unit/browser/webkit/http/test_http_hypothesis.py +++ b/tests/unit/browser/webkit/http/test_http_hypothesis.py @@ -31,7 +31,7 @@ from qutebrowser.browser.webkit import http, rfc6266 'attachment; filename="{}"', 'inline; {}', 'attachment; {}="foo"', - 'attachment; filename*=iso-8859-1''{}', + "attachment; filename*=iso-8859-1''{}", 'attachment; filename*={}', ]) @hypothesis.given(strategies.text(alphabet=[chr(x) for x in range(255)])) diff --git a/tests/unit/browser/webkit/network/test_pac.py b/tests/unit/browser/webkit/network/test_pac.py index ff0346411..8c03c6cee 100644 --- a/tests/unit/browser/webkit/network/test_pac.py +++ b/tests/unit/browser/webkit/network/test_pac.py @@ -183,7 +183,7 @@ def test_fail_return(): ]) @pytest.mark.parametrize('from_file', [True, False]) def test_secret_url(url, has_secret, from_file): - """Make sure secret parts in an URL are stripped correctly. + """Make sure secret parts in a URL are stripped correctly. The following parts are considered secret: - If the PAC info is loaded from a local file, nothing. diff --git a/tests/unit/browser/webkit/test_webkitelem.py b/tests/unit/browser/webkit/test_webkitelem.py index 09be16848..17eae9c09 100644 --- a/tests/unit/browser/webkit/test_webkitelem.py +++ b/tests/unit/browser/webkit/test_webkitelem.py @@ -247,7 +247,7 @@ class TestWebKitElement: pytest.param(lambda e: e[None], id='getitem'), pytest.param(lambda e: operator.setitem(e, None, None), id='setitem'), pytest.param(lambda e: operator.delitem(e, None), id='delitem'), - pytest.param(lambda e: None in e, id='contains'), + pytest.param(lambda e: '' in e, id='contains'), pytest.param(list, id='iter'), pytest.param(len, id='len'), pytest.param(lambda e: e.has_frame(), id='has_frame'), diff --git a/tests/unit/commands/test_argparser.py b/tests/unit/commands/test_argparser.py index 624306ded..0b6afc210 100644 --- a/tests/unit/commands/test_argparser.py +++ b/tests/unit/commands/test_argparser.py @@ -60,7 +60,7 @@ class TestArgumentParser: parser.parse_args(['--help']) expected_url = QUrl('qute://help/commands.html#foo') - assert tabbed_browser_stubs[1].opened_url == expected_url + assert tabbed_browser_stubs[1].loaded_url == expected_url @pytest.mark.parametrize('types, value, expected', [ diff --git a/tests/unit/commands/test_runners.py b/tests/unit/commands/test_runners.py index db831bd7e..cd2dea1d4 100644 --- a/tests/unit/commands/test_runners.py +++ b/tests/unit/commands/test_runners.py @@ -21,7 +21,8 @@ import pytest -from qutebrowser.commands import runners, cmdexc, cmdutils +from qutebrowser.misc import objects +from qutebrowser.commands import runners, cmdexc class TestCommandParser: @@ -74,7 +75,7 @@ class TestCompletions: @pytest.fixture(autouse=True) def cmdutils_stub(self, monkeypatch, stubs): """Patch the cmdutils module to provide fake commands.""" - monkeypatch.setattr(cmdutils, 'cmd_dict', { + monkeypatch.setattr(objects, 'commands', { 'one': stubs.FakeCommand(name='one'), 'two': stubs.FakeCommand(name='two'), 'two-foo': stubs.FakeCommand(name='two-foo'), diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py index dc98f0efb..224268c90 100644 --- a/tests/unit/completion/test_completer.py +++ b/tests/unit/completion/test_completer.py @@ -26,7 +26,8 @@ from PyQt5.QtCore import QObject from PyQt5.QtGui import QStandardItemModel from qutebrowser.completion import completer -from qutebrowser.commands import command, cmdutils +from qutebrowser.commands import command +from qutebrowser.api import cmdutils class FakeCompletionModel(QStandardItemModel): @@ -105,37 +106,31 @@ def cmdutils_patch(monkeypatch, stubs, miscmodels_patch): @cmdutils.argument('value', completion=miscmodels_patch.value) def set_command(section_=None, option=None, value=None): """docstring.""" - pass @cmdutils.argument('topic', completion=miscmodels_patch.helptopic) def show_help(tab=False, bg=False, window=False, topic=None): """docstring.""" - pass @cmdutils.argument('url', completion=miscmodels_patch.url) - @cmdutils.argument('count', count=True) + @cmdutils.argument('count', value=cmdutils.Value.count) def openurl(url=None, related=False, bg=False, tab=False, window=False, count=None): """docstring.""" - pass - @cmdutils.argument('win_id', win_id=True) + @cmdutils.argument('win_id', value=cmdutils.Value.win_id) @cmdutils.argument('command', completion=miscmodels_patch.command) def bind(key, win_id, command=None, *, mode='normal'): """docstring.""" - pass def tab_give(): """docstring.""" - pass @cmdutils.argument('option', completion=miscmodels_patch.option) @cmdutils.argument('values', completion=miscmodels_patch.value) def config_cycle(option, *values): """For testing varargs.""" - pass - cmd_utils = stubs.FakeCmdUtils({ + commands = { 'set': command.Command(name='set', handler=set_command), 'help': command.Command(name='help', handler=show_help), 'open': command.Command(name='open', handler=openurl, maxsplit=0), @@ -143,8 +138,8 @@ def cmdutils_patch(monkeypatch, stubs, miscmodels_patch): 'tab-give': command.Command(name='tab-give', handler=tab_give), 'config-cycle': command.Command(name='config-cycle', handler=config_cycle), - }) - monkeypatch.setattr(completer, 'cmdutils', cmd_utils) + } + monkeypatch.setattr(completer.objects, 'commands', commands) def _set_cmd_prompt(cmd, txt): diff --git a/tests/unit/completion/test_completionmodel.py b/tests/unit/completion/test_completionmodel.py index e0e044ffb..24f5bdf0d 100644 --- a/tests/unit/completion/test_completionmodel.py +++ b/tests/unit/completion/test_completionmodel.py @@ -28,7 +28,7 @@ from PyQt5.QtCore import QModelIndex from qutebrowser.completion.models import completionmodel, listcategory from qutebrowser.utils import qtutils -from qutebrowser.commands import cmdexc +from qutebrowser.api import cmdutils @hypothesis.given(strategies.lists( @@ -102,7 +102,7 @@ def test_delete_cur_item_no_func(): model.rowsRemoved.connect(callback) model.add_category(cat) parent = model.index(0, 0) - with pytest.raises(cmdexc.CommandError): + with pytest.raises(cmdutils.CommandError): model.delete_cur_item(model.index(0, 0, parent)) callback.assert_not_called() diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index 01bd3ec03..3d66e1145 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -25,7 +25,7 @@ import pytest from qutebrowser.completion import completionwidget from qutebrowser.completion.models import completionmodel, listcategory -from qutebrowser.commands import cmdexc +from qutebrowser.api import cmdutils @pytest.fixture @@ -241,7 +241,7 @@ def test_completion_item_del_no_selection(completionview): cat = listcategory.ListCategory('', [('foo',)], delete_func=func) model.add_category(cat) completionview.set_model(model) - with pytest.raises(cmdexc.CommandError, match='No item selected!'): + with pytest.raises(cmdutils.CommandError, match='No item selected!'): completionview.completion_item_del() func.assert_not_called() diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py index b36125a4a..02a6cfd1e 100644 --- a/tests/unit/completion/test_histcategory.py +++ b/tests/unit/completion/test_histcategory.py @@ -82,6 +82,14 @@ def hist(init_sql, config_stub): ("ample itle", [('example.com', 'title'), ('example.com', 'nope')], [('example.com', 'title')]), + + # https://github.com/qutebrowser/qutebrowser/issues/4411 + ("mlfreq", + [('https://qutebrowser.org/FAQ.html', 'Frequently Asked Questions')], + []), + ("ml freq", + [('https://qutebrowser.org/FAQ.html', 'Frequently Asked Questions')], + [('https://qutebrowser.org/FAQ.html', 'Frequently Asked Questions')]), ]) def test_set_pattern(pattern, before, after, model_validator, hist): """Validate the filtering and sorting results of set_pattern.""" diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 28265689c..9e75daae8 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -27,11 +27,11 @@ from datetime import datetime import pytest from PyQt5.QtCore import QUrl +from qutebrowser.misc import objects from qutebrowser.completion import completer from qutebrowser.completion.models import miscmodels, urlmodel, configmodel from qutebrowser.config import configdata, configtypes from qutebrowser.utils import usertypes -from qutebrowser.commands import cmdutils def _check_completions(model, expected): @@ -66,7 +66,7 @@ def _check_completions(model, expected): @pytest.fixture() def cmdutils_stub(monkeypatch, stubs): """Patch the cmdutils module to provide fake commands.""" - return monkeypatch.setattr(cmdutils, 'cmd_dict', { + return monkeypatch.setattr(objects, 'commands', { 'quit': stubs.FakeCommand(name='quit', desc='quit qutebrowser'), 'open': stubs.FakeCommand(name='open', desc='open a url'), 'prompt-yes': stubs.FakeCommand(name='prompt-yes', deprecated=True), diff --git a/tests/unit/browser/test_adblock.py b/tests/unit/components/test_adblock.py similarity index 83% rename from tests/unit/browser/test_adblock.py rename to tests/unit/components/test_adblock.py index e18c990cc..f37b57962 100644 --- a/tests/unit/browser/test_adblock.py +++ b/tests/unit/components/test_adblock.py @@ -28,12 +28,12 @@ import pytest from PyQt5.QtCore import QUrl -from qutebrowser.browser import adblock +from qutebrowser.components import adblock from qutebrowser.utils import urlmatch from tests.helpers import utils -pytestmark = pytest.mark.usefixtures('qapp', 'config_tmpdir') +pytestmark = pytest.mark.usefixtures('qapp') # TODO See ../utils/test_standarddirutils for OSError and caplog assertion @@ -58,18 +58,13 @@ URLS_TO_CHECK = ('http://localhost', 'http://veryverygoodhost.edu') -class BaseDirStub: - - """Mock for objreg.get('args') called in adblock.HostBlocker.read_hosts.""" - - def __init__(self): - self.basedir = None - - @pytest.fixture -def basedir(fake_args): - """Register a Fake basedir.""" - fake_args.basedir = None +def host_blocker_factory(config_tmpdir, data_tmpdir, download_stub, + config_stub): + def factory(): + return adblock.HostBlocker(config_dir=config_tmpdir, + data_dir=data_tmpdir) + return factory def create_zipfile(directory, files, zipname='test'): @@ -133,9 +128,9 @@ def assert_urls(host_blocker, blocked=BLOCKLIST_HOSTS, url = QUrl(str_url) host = url.host() if host in blocked and host not in whitelisted: - assert host_blocker.is_blocked(url) + assert host_blocker._is_blocked(url) else: - assert not host_blocker.is_blocked(url) + assert not host_blocker._is_blocked(url) def blocklist_to_url(filename): @@ -202,13 +197,13 @@ def generic_blocklists(directory): blocklist5.toString()] -def test_disabled_blocking_update(basedir, config_stub, download_stub, - data_tmpdir, tmpdir, win_registry, caplog): +def test_disabled_blocking_update(config_stub, tmpdir, caplog, + host_blocker_factory): """Ensure no URL is blocked when host blocking is disabled.""" config_stub.val.content.host_blocking.lists = generic_blocklists(tmpdir) config_stub.val.content.host_blocking.enabled = False - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() host_blocker.adblock_update() while host_blocker._in_progress: current_download = host_blocker._in_progress[0] @@ -217,10 +212,10 @@ def test_disabled_blocking_update(basedir, config_stub, download_stub, current_download.finished.emit() host_blocker.read_hosts() for str_url in URLS_TO_CHECK: - assert not host_blocker.is_blocked(QUrl(str_url)) + assert not host_blocker._is_blocked(QUrl(str_url)) -def test_disabled_blocking_per_url(config_stub, data_tmpdir): +def test_disabled_blocking_per_url(config_stub, host_blocker_factory): example_com = 'https://www.example.com/' config_stub.val.content.host_blocking.lists = [] @@ -230,36 +225,34 @@ def test_disabled_blocking_per_url(config_stub, data_tmpdir): url = QUrl('blocked.example.com') - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() host_blocker._blocked_hosts.add(url.host()) - assert host_blocker.is_blocked(url) - assert not host_blocker.is_blocked(url, first_party_url=QUrl(example_com)) + assert host_blocker._is_blocked(url) + assert not host_blocker._is_blocked(url, first_party_url=QUrl(example_com)) -def test_no_blocklist_update(config_stub, download_stub, - data_tmpdir, basedir, tmpdir, win_registry): +def test_no_blocklist_update(config_stub, download_stub, host_blocker_factory): """Ensure no URL is blocked when no block list exists.""" config_stub.val.content.host_blocking.lists = None config_stub.val.content.host_blocking.enabled = True - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() host_blocker.adblock_update() host_blocker.read_hosts() for dl in download_stub.downloads: dl.successful = True for str_url in URLS_TO_CHECK: - assert not host_blocker.is_blocked(QUrl(str_url)) + assert not host_blocker._is_blocked(QUrl(str_url)) -def test_successful_update(config_stub, basedir, download_stub, - data_tmpdir, tmpdir, win_registry, caplog): +def test_successful_update(config_stub, tmpdir, caplog, host_blocker_factory): """Ensure hosts from host_blocking.lists are blocked after an update.""" config_stub.val.content.host_blocking.lists = generic_blocklists(tmpdir) config_stub.val.content.host_blocking.enabled = True config_stub.val.content.host_blocking.whitelist = None - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() host_blocker.adblock_update() # Simulate download is finished while host_blocker._in_progress: @@ -271,11 +264,9 @@ def test_successful_update(config_stub, basedir, download_stub, assert_urls(host_blocker, whitelisted=[]) -def test_parsing_multiple_hosts_on_line(config_stub, basedir, download_stub, - data_tmpdir, tmpdir, win_registry, - caplog): +def test_parsing_multiple_hosts_on_line(host_blocker_factory): """Ensure multiple hosts on a line get parsed correctly.""" - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() bytes_host_line = ' '.join(BLOCKLIST_HOSTS).encode('utf-8') host_blocker._parse_line(bytes_host_line) assert_urls(host_blocker, whitelisted=[]) @@ -299,17 +290,15 @@ def test_parsing_multiple_hosts_on_line(config_stub, basedir, download_stub, ('127.0.1.1', 'myhostname'), ('127.0.0.53', 'myhostname'), ]) -def test_whitelisted_lines(config_stub, basedir, download_stub, data_tmpdir, - tmpdir, win_registry, caplog, ip, host): +def test_whitelisted_lines(host_blocker_factory, ip, host): """Make sure we don't block hosts we don't want to.""" - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() line = ('{} {}'.format(ip, host)).encode('ascii') host_blocker._parse_line(line) assert host not in host_blocker._blocked_hosts -def test_failed_dl_update(config_stub, basedir, download_stub, - data_tmpdir, tmpdir, win_registry, caplog): +def test_failed_dl_update(config_stub, tmpdir, caplog, host_blocker_factory): """One blocklist fails to download. Ensure hosts from this list are not blocked. @@ -323,7 +312,7 @@ def test_failed_dl_update(config_stub, basedir, download_stub, config_stub.val.content.host_blocking.enabled = True config_stub.val.content.host_blocking.whitelist = None - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() host_blocker.adblock_update() while host_blocker._in_progress: current_download = host_blocker._in_progress[0] @@ -339,8 +328,8 @@ def test_failed_dl_update(config_stub, basedir, download_stub, @pytest.mark.parametrize('location', ['content', 'comment']) -def test_invalid_utf8(config_stub, download_stub, tmpdir, data_tmpdir, - caplog, location): +def test_invalid_utf8(config_stub, tmpdir, caplog, host_blocker_factory, + location): """Make sure invalid UTF-8 is handled correctly. See https://github.com/qutebrowser/qutebrowser/issues/2301 @@ -359,7 +348,7 @@ def test_invalid_utf8(config_stub, download_stub, tmpdir, data_tmpdir, config_stub.val.content.host_blocking.enabled = True config_stub.val.content.host_blocking.whitelist = None - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() host_blocker.adblock_update() current_download = host_blocker._in_progress[0] @@ -379,26 +368,25 @@ def test_invalid_utf8(config_stub, download_stub, tmpdir, data_tmpdir, def test_invalid_utf8_compiled(config_stub, config_tmpdir, data_tmpdir, - monkeypatch, caplog): + monkeypatch, caplog, host_blocker_factory): """Make sure invalid UTF-8 in the compiled file is handled.""" config_stub.val.content.host_blocking.lists = [] # Make sure the HostBlocker doesn't delete blocked-hosts in __init__ - monkeypatch.setattr(adblock.HostBlocker, '_update_files', + monkeypatch.setattr(adblock.HostBlocker, 'update_files', lambda _self: None) (config_tmpdir / 'blocked-hosts').write_binary( b'https://www.example.org/\xa0') (data_tmpdir / 'blocked-hosts').ensure() - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() with caplog.at_level(logging.ERROR): host_blocker.read_hosts() assert caplog.messages[-1] == "Failed to read host blocklist!" -def test_blocking_with_whitelist(config_stub, basedir, download_stub, - data_tmpdir, tmpdir): +def test_blocking_with_whitelist(config_stub, data_tmpdir, host_blocker_factory): """Ensure hosts in content.host_blocking.whitelist are never blocked.""" # Simulate adblock_update has already been run # by creating a file named blocked-hosts, @@ -412,13 +400,12 @@ def test_blocking_with_whitelist(config_stub, basedir, download_stub, config_stub.val.content.host_blocking.enabled = True config_stub.val.content.host_blocking.whitelist = list(WHITELISTED_HOSTS) - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() host_blocker.read_hosts() assert_urls(host_blocker) -def test_config_change_initial(config_stub, basedir, download_stub, - data_tmpdir, tmpdir): +def test_config_change_initial(config_stub, tmpdir, host_blocker_factory): """Test emptying host_blocking.lists with existing blocked_hosts. - A blocklist is present in host_blocking.lists and blocked_hosts is @@ -432,14 +419,13 @@ def test_config_change_initial(config_stub, basedir, download_stub, config_stub.val.content.host_blocking.enabled = True config_stub.val.content.host_blocking.whitelist = None - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() host_blocker.read_hosts() for str_url in URLS_TO_CHECK: - assert not host_blocker.is_blocked(QUrl(str_url)) + assert not host_blocker._is_blocked(QUrl(str_url)) -def test_config_change(config_stub, basedir, download_stub, - data_tmpdir, tmpdir): +def test_config_change(config_stub, tmpdir, host_blocker_factory): """Ensure blocked-hosts resets if host-block-list is changed to None.""" filtered_blocked_hosts = BLOCKLIST_HOSTS[1:] # Exclude localhost blocklist = blocklist_to_url(create_blocklist( @@ -449,16 +435,15 @@ def test_config_change(config_stub, basedir, download_stub, config_stub.val.content.host_blocking.enabled = True config_stub.val.content.host_blocking.whitelist = None - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() host_blocker.read_hosts() config_stub.val.content.host_blocking.lists = None host_blocker.read_hosts() for str_url in URLS_TO_CHECK: - assert not host_blocker.is_blocked(QUrl(str_url)) + assert not host_blocker._is_blocked(QUrl(str_url)) -def test_add_directory(config_stub, basedir, download_stub, - data_tmpdir, tmpdir): +def test_add_directory(config_stub, tmpdir, host_blocker_factory): """Ensure adblocker can import all files in a directory.""" blocklist_hosts2 = [] for i in BLOCKLIST_HOSTS[1:]: @@ -471,18 +456,18 @@ def test_add_directory(config_stub, basedir, download_stub, config_stub.val.content.host_blocking.lists = [tmpdir.strpath] config_stub.val.content.host_blocking.enabled = True - host_blocker = adblock.HostBlocker() + host_blocker = host_blocker_factory() host_blocker.adblock_update() assert len(host_blocker._blocked_hosts) == len(blocklist_hosts2) * 2 -def test_adblock_benchmark(config_stub, data_tmpdir, basedir, benchmark): +def test_adblock_benchmark(data_tmpdir, benchmark, host_blocker_factory): blocked_hosts = os.path.join(utils.abs_datapath(), 'blocked-hosts') shutil.copy(blocked_hosts, str(data_tmpdir)) url = QUrl('https://www.example.org/') - blocker = adblock.HostBlocker() + blocker = host_blocker_factory() blocker.read_hosts() assert blocker._blocked_hosts - benchmark(lambda: blocker.is_blocked(url)) + benchmark(lambda: blocker._is_blocked(url)) diff --git a/tests/unit/components/test_misccommands.py b/tests/unit/components/test_misccommands.py new file mode 100644 index 000000000..95eb0c6e3 --- /dev/null +++ b/tests/unit/components/test_misccommands.py @@ -0,0 +1,93 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Tests for qutebrowser.components.misccommands.""" + +import signal +import contextlib +import time + +import pytest + +from qutebrowser.api import cmdutils +from qutebrowser.utils import utils +from qutebrowser.components import misccommands + + +@contextlib.contextmanager +def _trapped_segv(handler): + """Temporarily install given signal handler for SIGSEGV.""" + old_handler = signal.signal(signal.SIGSEGV, handler) + yield + signal.signal(signal.SIGSEGV, old_handler) + + +def test_debug_crash_exception(): + """Verify that debug_crash crashes as intended.""" + with pytest.raises(Exception, match="Forced crash"): + misccommands.debug_crash(typ='exception') + + +@pytest.mark.skipif(utils.is_windows, + reason="current CPython/win can't recover from SIGSEGV") +def test_debug_crash_segfault(): + """Verify that debug_crash crashes as intended.""" + caught = False + + def _handler(num, frame): + """Temporary handler for segfault.""" + nonlocal caught + caught = num == signal.SIGSEGV + + with _trapped_segv(_handler): + # since we handle the segfault, execution will continue and run into + # the "Segfault failed (wat.)" Exception + with pytest.raises(Exception, match="Segfault failed"): + misccommands.debug_crash(typ='segfault') + time.sleep(0.001) + assert caught + + +def test_debug_trace(mocker): + """Check if hunter.trace is properly called.""" + # but only if hunter is available + pytest.importorskip('hunter') + hunter_mock = mocker.patch.object(misccommands, 'hunter') + misccommands.debug_trace(1) + hunter_mock.trace.assert_called_with(1) + + +def test_debug_trace_exception(mocker): + """Check that exceptions thrown by hunter.trace are handled.""" + def _mock_exception(): + """Side effect for testing debug_trace's reraise.""" + raise Exception('message') + + hunter_mock = mocker.patch.object(misccommands, 'hunter') + hunter_mock.trace.side_effect = _mock_exception + with pytest.raises(cmdutils.CommandError, match='Exception: message'): + misccommands.debug_trace() + + +def test_debug_trace_no_hunter(monkeypatch): + """Test that an error is shown if debug_trace is called without hunter.""" + monkeypatch.setattr(misccommands, 'hunter', None) + with pytest.raises(cmdutils.CommandError, match="You need to install " + "'hunter' to use this command!"): + misccommands.debug_trace() diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 672faf04a..946770bf1 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -473,7 +473,7 @@ class TestConfig: assert conf.get('colors.completion.category.fg') == QColor('white') def test_get_for_url(self, conf): - """Test conf.get() with an URL/pattern.""" + """Test conf.get() with a URL/pattern.""" pattern = urlmatch.UrlPattern('*://example.com/') name = 'content.javascript.enabled' conf.set_obj(name, False, pattern=pattern) @@ -484,7 +484,7 @@ class TestConfig: (False, configutils.UNSET) ]) def test_get_for_url_fallback(self, conf, fallback, expected): - """Test conf.get() with an URL and fallback.""" + """Test conf.get() with a URL and fallback.""" value = conf.get('content.javascript.enabled', url=QUrl('https://example.com/'), fallback=fallback) diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 47bcaaa74..21c01ea5c 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -26,7 +26,7 @@ import pytest from PyQt5.QtCore import QUrl from qutebrowser.config import configcommands, configutils -from qutebrowser.commands import cmdexc +from qutebrowser.api import cmdutils from qutebrowser.utils import usertypes, urlmatch from qutebrowser.keyinput import keyutils from qutebrowser.misc import objects @@ -59,7 +59,7 @@ class TestSet: Should open qute://settings.""" commands.set(win_id=0) - assert tabbed_browser_stubs[0].opened_url == QUrl('qute://settings') + assert tabbed_browser_stubs[0].loaded_url == QUrl('qute://settings') @pytest.mark.parametrize('option', ['url.auto_search?', 'url.auto_search']) def test_get(self, config_stub, commands, message_mock, option): @@ -108,7 +108,7 @@ class TestSet: monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit) option = 'content.javascript.enabled' - with pytest.raises(cmdexc.CommandError, + with pytest.raises(cmdutils.CommandError, match=('Error while parsing http://: Pattern ' 'without host')): commands.set(0, option, 'false', pattern='http://') @@ -118,7 +118,7 @@ class TestSet: Should show an error as patterns are unsupported. """ - with pytest.raises(cmdexc.CommandError, + with pytest.raises(cmdutils.CommandError, match='does not support URL patterns'): commands.set(0, 'colors.statusbar.normal.bg', '#abcdef', pattern='*://*') @@ -165,7 +165,7 @@ class TestSet: Should show an error. """ - with pytest.raises(cmdexc.CommandError, match="No option 'foo'"): + with pytest.raises(cmdutils.CommandError, match="No option 'foo'"): commands.set(0, 'foo', 'bar') def test_set_invalid_value(self, commands): @@ -173,13 +173,13 @@ class TestSet: Should show an error. """ - with pytest.raises(cmdexc.CommandError, + with pytest.raises(cmdutils.CommandError, match="Invalid value 'blah' - must be a boolean!"): commands.set(0, 'auto_save.session', 'blah') def test_set_wrong_backend(self, commands, monkeypatch): monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine) - with pytest.raises(cmdexc.CommandError, + with pytest.raises(cmdutils.CommandError, match="The hints.find_implementation setting is " "not available with the QtWebEngine backend!"): commands.set(0, 'hints.find_implementation', 'javascript') @@ -190,7 +190,7 @@ class TestSet: Should show an error. See https://github.com/qutebrowser/qutebrowser/issues/1109 """ - with pytest.raises(cmdexc.CommandError, match="No option '?'"): + with pytest.raises(cmdutils.CommandError, match="No option '?'"): commands.set(win_id=0, option='?') def test_toggle(self, commands): @@ -198,7 +198,7 @@ class TestSet: Should show an nicer error. """ - with pytest.raises(cmdexc.CommandError, + with pytest.raises(cmdutils.CommandError, match="Toggling values was moved to the " ":config-cycle command"): commands.set(win_id=0, option='javascript.enabled!') @@ -208,7 +208,7 @@ class TestSet: Should show an error. """ - with pytest.raises(cmdexc.CommandError, match="No option 'foo'"): + with pytest.raises(cmdutils.CommandError, match="No option 'foo'"): commands.set(win_id=0, option='foo?') @@ -267,7 +267,7 @@ class TestCycle: Should show an error. """ assert config_stub.val.url.auto_search == 'naive' - with pytest.raises(cmdexc.CommandError, match="Need at least " + with pytest.raises(cmdutils.CommandError, match="Need at least " "two values for non-boolean settings."): commands.config_cycle(*args) assert config_stub.val.url.auto_search == 'naive' @@ -301,14 +301,14 @@ class TestAdd: def test_list_add_non_list(self, commands): with pytest.raises( - cmdexc.CommandError, + cmdutils.CommandError, match=":config-list-add can only be used for lists"): commands.config_list_add('history_gap_interval', 'value') @pytest.mark.parametrize('value', ['', None, 42]) def test_list_add_invalid_values(self, commands, value): with pytest.raises( - cmdexc.CommandError, + cmdutils.CommandError, match="Invalid value '{}'".format(value)): commands.config_list_add('content.host_blocking.whitelist', value) @@ -337,20 +337,20 @@ class TestAdd: assert str(config_stub.get(name)[key]) == value else: with pytest.raises( - cmdexc.CommandError, + cmdutils.CommandError, match="w already exists in aliases - use --replace to " "overwrite!"): commands.config_dict_add(name, key, value, replace=False) def test_dict_add_non_dict(self, commands): with pytest.raises( - cmdexc.CommandError, + cmdutils.CommandError, match=":config-dict-add can only be used for dicts"): commands.config_dict_add('history_gap_interval', 'key', 'value') @pytest.mark.parametrize('value', ['', None, 42]) def test_dict_add_invalid_values(self, commands, value): - with pytest.raises(cmdexc.CommandError, + with pytest.raises(cmdutils.CommandError, match="Invalid value '{}'".format(value)): commands.config_dict_add('aliases', 'missingkey', value) @@ -373,14 +373,14 @@ class TestRemove: def test_list_remove_non_list(self, commands): with pytest.raises( - cmdexc.CommandError, + cmdutils.CommandError, match=":config-list-remove can only be used for lists"): commands.config_list_remove('content.javascript.enabled', 'never') def test_list_remove_no_value(self, commands): with pytest.raises( - cmdexc.CommandError, + cmdutils.CommandError, match="never is not in colors.completion.fg!"): commands.config_list_remove('colors.completion.fg', 'never') @@ -398,14 +398,14 @@ class TestRemove: def test_dict_remove_non_dict(self, commands): with pytest.raises( - cmdexc.CommandError, + cmdutils.CommandError, match=":config-dict-remove can only be used for dicts"): commands.config_dict_remove('content.javascript.enabled', 'never') def test_dict_remove_no_value(self, commands): with pytest.raises( - cmdexc.CommandError, + cmdutils.CommandError, match="never is not in aliases!"): commands.config_dict_remove('aliases', 'never') @@ -425,7 +425,7 @@ class TestUnsetAndClear: assert yaml_value(name) == ('never' if temp else configutils.UNSET) def test_unset_unknown_option(self, commands): - with pytest.raises(cmdexc.CommandError, match="No option 'tabs'"): + with pytest.raises(cmdutils.CommandError, match="No option 'tabs'"): commands.config_unset('tabs') @pytest.mark.parametrize('save', [True, False]) @@ -466,13 +466,14 @@ class TestSource: assert not config_stub.val.content.javascript.enabled ignore_case = config_stub.val.search.ignore_case - assert ignore_case == ('smart' if clear else 'always') + assert ignore_case == (usertypes.IgnoreCase.smart if clear + else usertypes.IgnoreCase.always) def test_errors(self, commands, config_tmpdir): pyfile = config_tmpdir / 'config.py' pyfile.write_text('c.foo = 42', encoding='utf-8') - with pytest.raises(cmdexc.CommandError) as excinfo: + with pytest.raises(cmdutils.CommandError) as excinfo: commands.config_source() expected = ("Errors occurred while reading config.py:\n" @@ -483,7 +484,7 @@ class TestSource: pyfile = config_tmpdir / 'config.py' pyfile.write_text('1/0', encoding='utf-8') - with pytest.raises(cmdexc.CommandError) as excinfo: + with pytest.raises(cmdutils.CommandError) as excinfo: commands.config_source() expected = ("Errors occurred while reading config.py:\n" @@ -582,7 +583,7 @@ class TestWritePy: confpy = tmpdir / 'config.py' confpy.ensure() - with pytest.raises(cmdexc.CommandError) as excinfo: + with pytest.raises(cmdutils.CommandError) as excinfo: commands.config_write_py(str(confpy)) expected = " already exists - use --force to overwrite!" @@ -599,7 +600,7 @@ class TestWritePy: def test_oserror(self, commands, tmpdir): """Test writing to a directory which does not exist.""" - with pytest.raises(cmdexc.CommandError): + with pytest.raises(cmdutils.CommandError): commands.config_write_py(str(tmpdir / 'foo' / 'config.py')) @@ -620,7 +621,7 @@ class TestBind: config_stub.val.bindings.default = no_bindings config_stub.val.bindings.commands = no_bindings commands.bind(win_id=0) - assert tabbed_browser_stubs[0].opened_url == QUrl('qute://bindings') + assert tabbed_browser_stubs[0].loaded_url == QUrl('qute://bindings') @pytest.mark.parametrize('command', ['nop', 'nope']) def test_bind(self, commands, config_stub, no_bindings, key_config_stub, @@ -709,7 +710,7 @@ class TestBind: elif command == 'unbind': func = commands.unbind - with pytest.raises(cmdexc.CommandError, match=expected): + with pytest.raises(cmdutils.CommandError, match=expected): func(*args, **kwargs) @pytest.mark.parametrize('key', ['a', 'b', '']) diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index b2775de2f..b0b85d997 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -33,6 +33,7 @@ from PyQt5.QtCore import QUrl from PyQt5.QtGui import QColor, QFont from PyQt5.QtNetwork import QNetworkProxy +from qutebrowser.misc import objects from qutebrowser.config import configtypes, configexc, configutils from qutebrowser.utils import debug, utils, qtutils, urlmatch from qutebrowser.browser.network import pac @@ -1112,8 +1113,13 @@ class TestPerc: with pytest.raises(configexc.ValidationError): klass(**kwargs).to_py(val) - def test_to_str(self, klass): - assert klass().to_str('42%') == '42%' + @pytest.mark.parametrize('value, expected', [ + ('42%', '42%'), + (42, '42%'), + (42.5, '42.5%'), + ]) + def test_to_str(self, klass, value, expected): + assert klass().to_str(value) == expected class TestPercOrInt: @@ -1208,11 +1214,11 @@ class TestCommand: @pytest.fixture def patch_cmdutils(self, monkeypatch, stubs): """Patch the cmdutils module to provide fake commands.""" - cmd_utils = stubs.FakeCmdUtils({ + commands = { 'cmd1': stubs.FakeCommand(desc="desc 1"), - 'cmd2': stubs.FakeCommand(desc="desc 2")}) - monkeypatch.setattr(configtypes, 'cmdutils', cmd_utils) - monkeypatch.setattr('qutebrowser.commands.runners.cmdutils', cmd_utils) + 'cmd2': stubs.FakeCommand(desc="desc 2"), + } + monkeypatch.setattr(objects, 'commands', commands) @pytest.fixture def klass(self): @@ -1541,7 +1547,7 @@ class TestRegex: regex.to_py('foo') @pytest.mark.parametrize('flags, expected', [ - (0, 0), + (None, 0), ('IGNORECASE', re.IGNORECASE), ('IGNORECASE | VERBOSE', re.IGNORECASE | re.VERBOSE), ]) @@ -2117,6 +2123,9 @@ class TestKey: with pytest.raises(configexc.ValidationError): klass().to_py(val) + def test_normalized(self, klass): + assert klass().from_obj('') == '' + class TestUrlPattern: diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py index 587a0bd68..e8a7bfb38 100644 --- a/tests/unit/config/test_configutils.py +++ b/tests/unit/config/test_configutils.py @@ -26,7 +26,7 @@ from qutebrowser.utils import urlmatch def test_unset_object_identity(): - assert configutils._UnsetObject() is not configutils._UnsetObject() + assert configutils.Unset() is not configutils.Unset() assert configutils.UNSET is configutils.UNSET diff --git a/tests/unit/extensions/test_loader.py b/tests/unit/extensions/test_loader.py new file mode 100644 index 000000000..5265c7cdf --- /dev/null +++ b/tests/unit/extensions/test_loader.py @@ -0,0 +1,143 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +import types + +import pytest + +from qutebrowser.extensions import loader +from qutebrowser.misc import objects + + +pytestmark = pytest.mark.usefixtures('data_tmpdir', 'config_tmpdir', + 'fake_args') + + +def test_on_walk_error(): + with pytest.raises(ImportError, match='Failed to import foo'): + loader._on_walk_error('foo') + + +def test_walk_normal(): + names = [info.name for info in loader._walk_normal()] + assert 'qutebrowser.components.scrollcommands' in names + + +def test_walk_pyinstaller(): + # We can't test whether we get something back without being frozen by + # PyInstaller, but at least we can test that we don't crash. + list(loader._walk_pyinstaller()) + + +def test_load_component(monkeypatch): + monkeypatch.setattr(objects, 'commands', {}) + + info = loader.ExtensionInfo(name='qutebrowser.components.scrollcommands') + mod = loader._load_component(info, skip_hooks=True) + + assert hasattr(mod, 'scroll_to_perc') + assert 'scroll-to-perc' in objects.commands + + +@pytest.fixture +def module(monkeypatch, request): + mod = types.ModuleType('testmodule') + + monkeypatch.setattr(loader, '_module_infos', []) + monkeypatch.setattr(loader.importlib, 'import_module', + lambda _name: mod) + + mod.info = loader.add_module_info(mod) + return mod + + +def test_get_init_context(data_tmpdir, config_tmpdir, fake_args): + ctx = loader._get_init_context() + assert str(ctx.data_dir) == data_tmpdir + assert str(ctx.config_dir) == config_tmpdir + assert ctx.args == fake_args + + +def test_add_module_info(): + # pylint: disable=no-member + mod = types.ModuleType('testmodule') + info1 = loader.add_module_info(mod) + assert mod.__qute_module_info is info1 + + info2 = loader.add_module_info(mod) + assert mod.__qute_module_info is info1 + assert info2 is info1 + + +class _Hook: + + """Hook to use in tests.""" + + __name__ = '_Hook' + + def __init__(self): + self.called = False + self.raising = False + + def __call__(self, *args): + if self.raising: + raise Exception("Should not be called!") + self.called = True + + +@pytest.fixture +def hook(): + return _Hook() + + +def test_skip_hooks(hook, module): + hook.raising = True + + module.info.init_hook = hook + module.info.config_changed_hooks = [(None, hook)] + + info = loader.ExtensionInfo(name='testmodule') + loader._load_component(info, skip_hooks=True) + loader._on_config_changed('test') + + assert not hook.called + + +@pytest.mark.parametrize('option_filter, option, called', [ + (None, 'content.javascript.enabled', True), + ('content.javascript', 'content.javascript.enabled', True), + ('content.javascript.enabled', 'content.javascript.enabled', True), + ('content.javascript.log', 'content.javascript.enabled', False), +]) +def test_on_config_changed(configdata_init, hook, module, + option_filter, option, called): + module.info.config_changed_hooks = [(option_filter, hook)] + + info = loader.ExtensionInfo(name='testmodule') + loader._load_component(info) + loader._on_config_changed(option) + + assert hook.called == called + + +def test_init_hook(hook, module): + module.info.init_hook = hook + info = loader.ExtensionInfo(name='testmodule') + loader._load_component(info) + assert hook.called diff --git a/tests/unit/javascript/conftest.py b/tests/unit/javascript/conftest.py index 2078513b4..486839237 100644 --- a/tests/unit/javascript/conftest.py +++ b/tests/unit/javascript/conftest.py @@ -86,7 +86,7 @@ class JSTester: """ with self.qtbot.waitSignal(self.tab.load_finished, timeout=2000) as blocker: - self.tab.openurl(url) + self.tab.load_url(url) if not force: assert blocker.args == [True] diff --git a/tests/unit/javascript/stylesheet/test_stylesheet.py b/tests/unit/javascript/stylesheet/test_stylesheet.py index 2b1c7ced5..145e8ee5e 100644 --- a/tests/unit/javascript/stylesheet/test_stylesheet.py +++ b/tests/unit/javascript/stylesheet/test_stylesheet.py @@ -25,7 +25,7 @@ import pytest QtWebEngineWidgets = pytest.importorskip("PyQt5.QtWebEngineWidgets") QWebEngineProfile = QtWebEngineWidgets.QWebEngineProfile -from qutebrowser.utils import javascript +from qutebrowser.utils import javascript, qtutils DEFAULT_BODY_BG = "rgba(0, 0, 0, 0)" @@ -128,6 +128,8 @@ def test_set_error(stylesheet_tester, config_stub): stylesheet_tester.check_set(GREEN_BODY_BG) +@pytest.mark.skip(qtutils.version_check('5.12', compiled=False), + reason='Broken with Qt 5.12') def test_appendchild(stylesheet_tester): stylesheet_tester.js.load('stylesheet/simple.html') stylesheet_tester.init_stylesheet() diff --git a/tests/unit/mainwindow/statusbar/test_url.py b/tests/unit/mainwindow/statusbar/test_url.py index bda51b504..7d06cb774 100644 --- a/tests/unit/mainwindow/statusbar/test_url.py +++ b/tests/unit/mainwindow/statusbar/test_url.py @@ -89,25 +89,33 @@ def test_set_url(url_widget, url_text, expected, which): def test_on_load_status_changed(url_widget, status, expected): """Test text when status is changed.""" url_widget.set_url(QUrl('www.example.com')) - url_widget.on_load_status_changed(status.name) + url_widget.on_load_status_changed(status) assert url_widget._urltype == expected @pytest.mark.parametrize('load_status, qurl', [ - (url.UrlType.success, QUrl('http://abc123.com/this/awesome/url.html')), - (url.UrlType.success, QUrl('http://reddit.com/r/linux')), - (url.UrlType.success, QUrl('http://ä.com/')), - (url.UrlType.success_https, QUrl('www.google.com')), - (url.UrlType.success_https, QUrl('https://supersecret.gov/nsa/files.txt')), - (url.UrlType.warn, QUrl('www.shadysite.org/some/file/with/issues.htm')), - (url.UrlType.error, QUrl('invalid::/url')), - (url.UrlType.error, QUrl()), + (usertypes.LoadStatus.success, + QUrl('http://abc123.com/this/awesome/url.html')), + (usertypes.LoadStatus.success, + QUrl('http://reddit.com/r/linux')), + (usertypes.LoadStatus.success, + QUrl('http://ä.com/')), + (usertypes.LoadStatus.success_https, + QUrl('www.google.com')), + (usertypes.LoadStatus.success_https, + QUrl('https://supersecret.gov/nsa/files.txt')), + (usertypes.LoadStatus.warn, + QUrl('www.shadysite.org/some/file/with/issues.htm')), + (usertypes.LoadStatus.error, + QUrl('invalid::/url')), + (usertypes.LoadStatus.error, + QUrl()), ]) def test_on_tab_changed(url_widget, fake_web_tab, load_status, qurl): tab_widget = fake_web_tab(load_status=load_status, url=qurl) url_widget.on_tab_changed(tab_widget) - assert url_widget._urltype == load_status + assert url_widget._urltype.name == load_status.name if not qurl.isValid(): expected = '' else: @@ -139,7 +147,7 @@ def test_on_tab_changed(url_widget, fake_web_tab, load_status, qurl): ]) def test_normal_url(url_widget, qurl, load_status, expected_status): url_widget.set_url(qurl) - url_widget.on_load_status_changed(load_status.name) + url_widget.on_load_status_changed(load_status) url_widget.set_hover_url(qurl.toDisplayString()) url_widget.set_hover_url("") assert url_widget.text() == qurl.toDisplayString() diff --git a/tests/unit/misc/test_checkpyver.py b/tests/unit/misc/test_checkpyver.py index 17e910a5f..a02e2f8e0 100644 --- a/tests/unit/misc/test_checkpyver.py +++ b/tests/unit/misc/test_checkpyver.py @@ -73,7 +73,7 @@ def test_patched_errwindow(capfd, mocker, monkeypatch): monkeypatch.setattr(checkpyver.sys, 'exit', lambda status: None) try: - import tkinter # pylint: disable=unused-variable + import tkinter # pylint: disable=unused-import except ImportError: tk_mock = mocker.patch('qutebrowser.misc.checkpyver.Tk', spec=['withdraw'], new_callable=mocker.Mock) diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py index 93b6581b8..29ca0ff9d 100644 --- a/tests/unit/misc/test_ipc.py +++ b/tests/unit/misc/test_ipc.py @@ -34,7 +34,7 @@ from PyQt5.QtTest import QSignalSpy import qutebrowser from qutebrowser.misc import ipc -from qutebrowser.utils import standarddir, utils +from qutebrowser.utils import standarddir, utils, qtutils from helpers import stubs @@ -630,6 +630,8 @@ class TestSendOrListen: assert ret_client is None @pytest.mark.posix(reason="Unneeded on Windows") + @pytest.mark.xfail(qtutils.version_check('5.12', compiled=False) and + utils.is_mac, reason="Broken, see #4471") def test_correct_socket_name(self, args): server = ipc.send_or_listen(args) expected_dir = ipc._get_socketname(args.basedir) diff --git a/tests/unit/misc/test_keyhints.py b/tests/unit/misc/test_keyhints.py index 7c9727b65..9af30fd16 100644 --- a/tests/unit/misc/test_keyhints.py +++ b/tests/unit/misc/test_keyhints.py @@ -21,6 +21,7 @@ import pytest +from qutebrowser.misc import objects from qutebrowser.misc.keyhintwidget import KeyHintView @@ -120,7 +121,7 @@ def test_suggestions_special(keyhint, config_stub): def test_suggestions_with_count(keyhint, config_stub, monkeypatch, stubs): """Test that a count prefix filters out commands that take no count.""" - monkeypatch.setattr('qutebrowser.commands.cmdutils.cmd_dict', { + monkeypatch.setattr(objects, 'commands', { 'foo': stubs.FakeCommand(name='foo', takes_count=lambda: False), 'bar': stubs.FakeCommand(name='bar', takes_count=lambda: True), }) diff --git a/tests/unit/misc/test_utilcmds.py b/tests/unit/misc/test_utilcmds.py index cfa115412..1b71d5ddc 100644 --- a/tests/unit/misc/test_utilcmds.py +++ b/tests/unit/misc/test_utilcmds.py @@ -19,80 +19,14 @@ """Tests for qutebrowser.misc.utilcmds.""" -import contextlib import logging -import signal -import time import pytest from PyQt5.QtCore import QUrl from qutebrowser.misc import utilcmds -from qutebrowser.commands import cmdexc -from qutebrowser.utils import utils, objreg - - -@contextlib.contextmanager -def _trapped_segv(handler): - """Temporarily install given signal handler for SIGSEGV.""" - old_handler = signal.signal(signal.SIGSEGV, handler) - yield - signal.signal(signal.SIGSEGV, old_handler) - - -def test_debug_crash_exception(): - """Verify that debug_crash crashes as intended.""" - with pytest.raises(Exception, match="Forced crash"): - utilcmds.debug_crash(typ='exception') - - -@pytest.mark.skipif(utils.is_windows, - reason="current CPython/win can't recover from SIGSEGV") -def test_debug_crash_segfault(): - """Verify that debug_crash crashes as intended.""" - caught = False - - def _handler(num, frame): - """Temporary handler for segfault.""" - nonlocal caught - caught = num == signal.SIGSEGV - - with _trapped_segv(_handler): - # since we handle the segfault, execution will continue and run into - # the "Segfault failed (wat.)" Exception - with pytest.raises(Exception, match="Segfault failed"): - utilcmds.debug_crash(typ='segfault') - time.sleep(0.001) - assert caught - - -def test_debug_trace(mocker): - """Check if hunter.trace is properly called.""" - # but only if hunter is available - pytest.importorskip('hunter') - hunter_mock = mocker.patch('qutebrowser.misc.utilcmds.hunter') - utilcmds.debug_trace(1) - hunter_mock.trace.assert_called_with(1) - - -def test_debug_trace_exception(mocker): - """Check that exceptions thrown by hunter.trace are handled.""" - def _mock_exception(): - """Side effect for testing debug_trace's reraise.""" - raise Exception('message') - - hunter_mock = mocker.patch('qutebrowser.misc.utilcmds.hunter') - hunter_mock.trace.side_effect = _mock_exception - with pytest.raises(cmdexc.CommandError, match='Exception: message'): - utilcmds.debug_trace() - - -def test_debug_trace_no_hunter(monkeypatch): - """Test that an error is shown if debug_trace is called without hunter.""" - monkeypatch.setattr(utilcmds, 'hunter', None) - with pytest.raises(cmdexc.CommandError, match="You need to install " - "'hunter' to use this command!"): - utilcmds.debug_trace() +from qutebrowser.api import cmdutils +from qutebrowser.utils import objreg def test_repeat_command_initial(mocker, mode_manager): @@ -103,7 +37,7 @@ def test_repeat_command_initial(mocker, mode_manager): """ objreg_mock = mocker.patch('qutebrowser.misc.utilcmds.objreg') objreg_mock.get.return_value = mode_manager - with pytest.raises(cmdexc.CommandError, + with pytest.raises(cmdutils.CommandError, match="You didn't do anything yet."): utilcmds.repeat_command(win_id=0) @@ -155,4 +89,4 @@ def tabbed_browser(stubs, win_registry): def test_version(tabbed_browser, qapp): utilcmds.version(win_id=0) - assert tabbed_browser.opened_url == QUrl('qute://version') + assert tabbed_browser.loaded_url == QUrl('qute://version') diff --git a/tests/unit/utils/test_qtutils.py b/tests/unit/utils/test_qtutils.py index 0825d1fb3..3b625b3a5 100644 --- a/tests/unit/utils/test_qtutils.py +++ b/tests/unit/utils/test_qtutils.py @@ -336,8 +336,6 @@ class SavefileTestException(Exception): """Exception raised in TestSavefileOpen for testing.""" - pass - @pytest.mark.usefixtures('qapp') class TestSavefileOpen: @@ -541,7 +539,6 @@ if test_file is not None: def testReadinto_text(self): """Skip this test as BufferedIOBase seems to fail it.""" - pass class PyOtherFileTests(PyIODeviceTestMixin, test_file.OtherFileTests, unittest.TestCase): @@ -550,11 +547,9 @@ if test_file is not None: def testSetBufferSize(self): """Skip this test as setting buffer size is unsupported.""" - pass def testTruncateOnWindows(self): """Skip this test truncating is unsupported.""" - pass class FailingQIODevice(QIODevice): diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py index e569c51b8..1dd57a5e9 100644 --- a/tests/unit/utils/test_urlmatch.py +++ b/tests/unit/utils/test_urlmatch.py @@ -142,7 +142,7 @@ def test_parse_path(pattern, path): ("data:monkey", 'data', None, 'monkey'), # existing scheme ]) def test_lightweight_patterns(pattern, scheme, host, path): - """Make sure we can leave off parts of an URL. + """Make sure we can leave off parts of a URL. This is a deviation from Chromium to make patterns more user-friendly. """ diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py index e77d33783..1c1efffab 100644 --- a/tests/unit/utils/test_urlutils.py +++ b/tests/unit/utils/test_urlutils.py @@ -27,7 +27,7 @@ from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkProxy import pytest -from qutebrowser.commands import cmdexc +from qutebrowser.api import cmdutils from qutebrowser.browser.network import pac from qutebrowser.utils import utils, urlutils, qtutils, usertypes from helpers import utils as testutils @@ -97,6 +97,7 @@ def init_config(config_stub): config_stub.val.url.searchengines = { 'test': 'http://www.qutebrowser.org/?q={}', 'test-with-dash': 'http://www.example.org/?q={}', + 'path-search': 'http://www.example.org/{}', 'DEFAULT': 'http://www.example.com/?q={}', } @@ -288,6 +289,7 @@ def test_special_urls(url, special): ('blub testfoo', 'www.example.com', 'q=blub testfoo'), ('stripped ', 'www.example.com', 'q=stripped'), ('test-with-dash testfoo', 'www.example.org', 'q=testfoo'), + ('test/with/slashes', 'www.example.com', 'q=test%2Fwith%2Fslashes'), ]) def test_get_search_url(config_stub, url, host, query, open_base_url): """Test _get_search_url(). @@ -493,7 +495,7 @@ def test_raise_cmdexc_if_invalid(url, valid, has_err_string): expected_text = "Invalid URL - " + qurl.errorString() else: expected_text = "Invalid URL" - with pytest.raises(cmdexc.CommandError, match=expected_text): + with pytest.raises(cmdutils.CommandError, match=expected_text): urlutils.raise_cmdexc_if_invalid(qurl) diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index e25ecfe4a..37bd9faaa 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -409,8 +409,6 @@ class GotException(Exception): """Exception used for TestDisabledExcepthook.""" - pass - def excepthook(_exc, _val, _tb): pass @@ -465,9 +463,7 @@ class TestPreventExceptions: def test_raising(self, caplog): """Test with a raising function.""" with caplog.at_level(logging.ERROR, 'misc'): - # pylint: disable=assignment-from-no-return ret = self.func_raising() - # pylint: enable=assignment-from-no-return assert ret == 42 expected = 'Error in test_utils.TestPreventExceptions.func_raising' assert caplog.messages == [expected] @@ -490,9 +486,7 @@ class TestPreventExceptions: def test_predicate_true(self, caplog): """Test with a True predicate.""" with caplog.at_level(logging.ERROR, 'misc'): - # pylint: disable=assignment-from-no-return ret = self.func_predicate_true() - # enable: disable=assignment-from-no-return assert ret == 42 assert len(caplog.records) == 1 @@ -512,8 +506,6 @@ class Obj: """Test object for test_get_repr().""" - pass - @pytest.mark.parametrize('constructor, attrs, expected', [ (False, {}, ''), @@ -534,12 +526,10 @@ class QualnameObj(): def func(self): """Test method for test_qualname.""" - pass def qualname_func(_blah): """Test function for test_qualname.""" - pass QUALNAME_OBJ = QualnameObj() @@ -578,8 +568,6 @@ class TestIsEnum: """Test class for is_enum.""" - pass - assert not utils.is_enum(Test) def test_object(self): @@ -597,7 +585,6 @@ class TestRaises: def do_nothing(self): """Helper function which does nothing.""" - pass @pytest.mark.parametrize('exception, value, expected', [ (ValueError, 'a', True), diff --git a/tests/unit/utils/usertypes/test_timer.py b/tests/unit/utils/usertypes/test_timer.py index 2ead0dc60..928e9d6a8 100644 --- a/tests/unit/utils/usertypes/test_timer.py +++ b/tests/unit/utils/usertypes/test_timer.py @@ -29,8 +29,6 @@ class Parent(QObject): """Class for test_parent().""" - pass - def test_parent(): """Make sure the parent is set correctly.""" diff --git a/tox.ini b/tox.ini index 8e9a54f11..75a00961e 100644 --- a/tox.ini +++ b/tox.ini @@ -188,3 +188,25 @@ deps = whitelist_externals = eslint changedir = {toxinidir}/qutebrowser/javascript commands = eslint --color --report-unused-disable-directives . + +[testenv:mypy] +basepython = {env:PYTHON:python3} +passenv = +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/misc/requirements/requirements-optional.txt + -r{toxinidir}/misc/requirements/requirements-pyqt.txt + -r{toxinidir}/misc/requirements/requirements-mypy.txt +commands = + {envpython} -m mypy qutebrowser {posargs} + +[testenv:sphinx] +basepython = {env:PYTHON:python3} +passenv = +usedevelop = true +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/misc/requirements/requirements-pyqt.txt + -r{toxinidir}/misc/requirements/requirements-sphinx.txt +commands = + {envpython} -m sphinx -jauto -W --color {posargs} {toxinidir}/doc/extapi/ {toxinidir}/doc/extapi/_build/