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/.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 663c4c6f9..dfa566671 100644 --- a/.travis.yml +++ b/.travis.yml @@ -68,16 +68,3 @@ after_success: after_failure: - bash scripts/dev/ci/travis_backtrace.sh - -notifications: - webhooks: - - https://buildtimetrend.herokuapp.com/travis - irc: - channels: - - "chat.freenode.net#qutebrowser-dev" - on_success: always - on_failure: always - skip_join: true - template: - - "%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}" - - "%{compare_url} - %{build_url}" diff --git a/MANIFEST.in b/MANIFEST.in index 3a29ba690..a0e61c30e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -13,7 +13,7 @@ include qutebrowser/utils/testfile include qutebrowser/git-commit-id include LICENSE doc/* README.asciidoc include misc/qutebrowser.desktop -include misc/qutebrowser.appdata.xml +include misc/org.qutebrowser.qutebrowser.appdata.xml include misc/Makefile include requirements.txt include tox.ini @@ -40,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/doc/changelog.asciidoc b/doc/changelog.asciidoc index e4da4de4c..6b2bc1d71 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -25,6 +25,7 @@ Added opened from a page should stack on each other or not. - New `completion.open_categories` setting which allows to configure which categories are shown in the `:open` completion, and how they are ordered. +- New `tabs.pinned.frozen` setting to allow/deny navigating in pinned tabs. - New config manipulation commands: * `:config-dict-add` and `:config-list-add` to a new element to a dict/list setting. @@ -51,6 +52,13 @@ 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. +- The JavaScript `console` object is now available in PAC files. +- The metainfo file `qutebrowser.appdata.xml` is now renamed to + `org.qutebrowser.qutebrowser.appdata.xml`. +- The `qute-pass` userscript now understands domains in gpg filenames + in addition to directory names. Fixed ~~~~~ @@ -65,9 +73,12 @@ Fixed `content.cookies.accept = no-3rdparty` from working properly on some pages 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. +- `:navigate` not incrementing in anchors or queries. - Crash when trying to use a proxy requiring authentication with QtWebKit. - Slashes in search terms are now percent-escaped. +- When `scrolling.bar = True` was set in versions before v1.5.0, this now + correctly gets migrated to `always` instead of `when-searching`. +- Completion highlighting now works again on Qt 5.11.3 and 5.12.1. v1.5.2 ------ 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 113a11f09..10661cb9e 100644 --- a/doc/faq.asciidoc +++ b/doc/faq.asciidoc @@ -211,9 +211,10 @@ Why does J move to the next (right) tab, and K to the previous (left) one?:: What's the difference between insert and passthrough mode?:: They are quite similar, but insert mode has some bindings (like `Ctrl-e` to - open an editor) while passthrough mode only has escape bound. It might also - be useful to rebind escape to something else in passthrough mode only, to be - able to send an escape keypress to the website. + open an editor) while passthrough mode only has shift+escape bound. This is + because shift+escape is unlikely to be a useful binding to be passed to a + webpage. However, any other keys may be assigned to leaving passthrough mode + instead of shift+escape should this be desired. 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 diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 33711b755..b04c9e9df 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -396,6 +396,7 @@ Pre-built colorschemes - A collection of https://github.com/chriskempson/base16[base16] color-schemes can be found in https://github.com/theova/base16-qutebrowser[base16-qutebrowser] and used with https://github.com/AuditeMarlow/base16-manager[base16-manager]. - Two implementations of the https://github.com/arcticicestudio/nord[Nord] colorscheme for qutebrowser exist: https://github.com/Linuus/nord-qutebrowser[Linuus], https://github.com/KnownAsDon/QuteBrowser-Nord-Theme[KnownAsDon] - https://github.com/evannagle/qutebrowser-dracula-theme[Dracula] +- https://github.com/jjzmajic/qutewal[Pywal theme] Avoiding flake8 errors ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 7da6e543e..eb3907cce 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -261,6 +261,7 @@ |<>|Stack related tabs on top of each other when opened consecutively. |<>|Position of new tabs which are not opened from another tab. |<>|Padding (in pixels) around text for tabs. +|<>|Force pinned tabs to stay at fixed URL. |<>|Shrink pinned tabs down to their contents. |<>|Position of the tab bar. |<>|Which tab to select when the focused tab is removed. @@ -3307,6 +3308,14 @@ Default: - +pass:[right]+: +pass:[5]+ - +pass:[top]+: +pass:[0]+ +[[tabs.pinned.frozen]] +=== tabs.pinned.frozen +Force pinned tabs to stay at fixed URL. + +Type: <> + +Default: +pass:[true]+ + [[tabs.pinned.shrink]] === tabs.pinned.shrink Shrink pinned tabs down to their contents. diff --git a/doc/install.asciidoc b/doc/install.asciidoc index 37f6ebb12..c9ae54cd2 100644 --- a/doc/install.asciidoc +++ b/doc/install.asciidoc @@ -102,18 +102,12 @@ $ python3 scripts/asciidoc2html.py On Fedora --------- -NOTE: Fedora's packages used to be outdated for a long time, but are -now (November 2017) maintained and up-to-date again. - qutebrowser is available in the official repositories: ----- # dnf install qutebrowser ----- -However, note that Fedora 25/26 won't be updated to qutebrowser v1.0, so you -might want to <> instead there. - Additional hints ~~~~~~~~~~~~~~~~ diff --git a/misc/Makefile b/misc/Makefile index 4625b288e..526f7adce 100644 --- a/misc/Makefile +++ b/misc/Makefile @@ -17,8 +17,8 @@ doc/qutebrowser.1.html: install: doc/qutebrowser.1.html $(PYTHON) setup.py install --prefix="$(PREFIX)" --optimize=1 $(SETUPTOOLSOPTS) - install -Dm644 misc/qutebrowser.appdata.xml \ - "$(DESTDIR)$(DATADIR)/metainfo/qutebrowser.appdata.xml" + install -Dm644 misc/org.qutebrowser.qutebrowser.appdata.xml \ + "$(DESTDIR)$(DATADIR)/metainfo/org.qutebrowser.qutebrowser.appdata.xml" install -Dm644 doc/qutebrowser.1 \ "$(DESTDIR)$(MANDIR)/man1/qutebrowser.1" install -Dm644 misc/qutebrowser.desktop \ diff --git a/misc/qutebrowser.appdata.xml b/misc/org.qutebrowser.qutebrowser.appdata.xml similarity index 100% rename from misc/qutebrowser.appdata.xml rename to misc/org.qutebrowser.qutebrowser.appdata.xml 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 4ce97a976..19ae758af 100644 --- a/misc/requirements/requirements-codecov.txt +++ b/misc/requirements/requirements-codecov.txt @@ -4,6 +4,6 @@ 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..42255f825 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -11,7 +11,7 @@ flake8-deprecated==1.3 flake8-docstrings==1.3.0 flake8-future-import==0.4.5 flake8-mock==0.3 -flake8-per-file-ignores==0.6 +flake8-per-file-ignores==0.7 flake8-polyfill==1.0.2 flake8-string-format==0.2.3 flake8-tidy-imports==1.1.0 @@ -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 index f2951fdf5..6b8c63e97 100644 --- a/misc/requirements/requirements-mypy.txt +++ b/misc/requirements/requirements-mypy.txt @@ -1,8 +1,8 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -mypy==0.641 +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.0 +typed-ast==1.1.1 diff --git a/misc/requirements/requirements-optional.txt b/misc/requirements/requirements-optional.txt index 8f1e2498a..aafa38e46 100644 --- a/misc/requirements/requirements-optional.txt +++ b/misc/requirements/requirements-optional.txt @@ -4,4 +4,4 @@ colorama==0.4.1 cssutils==1.0.2 hunter==2.1.0 Pympler==0.6 -six==1.11.0 +six==1.12.0 diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt index 43085ddd7..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.6.2 -six==1.11.0 +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 39e731984..b3ecdaf70 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -7,7 +7,7 @@ cffi==1.11.5 chardet==3.0.4 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 @@ -16,8 +16,8 @@ pycparser==2.19 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..c089895d1 --- /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.3 +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 7d8a4f33e..228045f8c 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -3,39 +3,39 @@ atomicwrites==1.2.1 attrs==18.2.0 backports.functools-lru-cache==1.5 -beautifulsoup4==4.6.3 -cheroot==6.5.2 +beautifulsoup4==4.7.0 +cheroot==6.5.3 Click==7.0 # colorama==0.4.1 coverage==4.5.2 -EasyProcess==0.2.3 +EasyProcess==0.2.5 Flask==1.0.2 glob2==0.6 hunter==2.1.0 -hypothesis==3.82.1 +hypothesis==3.85.2 itsdangerous==1.1.0 # Jinja2==2.10 Mako==1.0.7 # MarkupSafe==1.1.0 -more-itertools==4.3.0 +more-itertools==5.0.0 parse==1.9.0 parse-type==0.4.2 pluggy==0.8.0 py==1.7.0 py-cpuinfo==4.0.0 -pytest==4.0.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 097859b6e..ed0db2870 100644 --- a/misc/requirements/requirements-tox.txt +++ b/misc/requirements/requirements-tox.txt @@ -3,7 +3,7 @@ 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/password_fill b/misc/userscripts/password_fill index 2e4f01b66..7d4be0467 100755 --- a/misc/userscripts/password_fill +++ b/misc/userscripts/password_fill @@ -64,6 +64,7 @@ die() { javascript_escape() { # print the first argument in an escaped way, such that it can safely # be used within javascripts double quotes + # shellcheck disable=SC2001 sed "s,[\\\\'\"],\\\\&,g" <<< "$1" } @@ -111,6 +112,7 @@ simplify_url() { # are found: no_entries_found() { while [ 0 -eq "${#files[@]}" ] && [ -n "$simple_url" ]; do + # shellcheck disable=SC2001 shorter_simple_url=$(sed 's,^[^.]*\.,,' <<< "$simple_url") if [ "$shorter_simple_url" = "$simple_url" ] ; then # if no dot, then even remove the top level domain diff --git a/misc/userscripts/qute-pass b/misc/userscripts/qute-pass index ca9c4d4ca..bfc2cbeb9 100755 --- a/misc/userscripts/qute-pass +++ b/misc/userscripts/qute-pass @@ -97,13 +97,19 @@ def qute_command(command): def find_pass_candidates(domain, password_store_path): candidates = [] for path, directories, file_names in os.walk(password_store_path, followlinks=True): - if directories or domain not in path.split(os.path.sep): + secrets = fnmatch.filter(file_names, '*.gpg') + if not secrets: continue # Strip password store path prefix to get the relative pass path pass_path = path[len(password_store_path) + 1:] - secrets = fnmatch.filter(file_names, '*.gpg') - candidates.extend(os.path.join(pass_path, os.path.splitext(secret)[0]) for secret in secrets) + split_path = pass_path.split(os.path.sep) + for secret in secrets: + secret_base = os.path.splitext(secret)[0] + if domain not in (split_path + [secret_base]): + continue + + candidates.append(os.path.join(pass_path, secret_base)) return candidates diff --git a/misc/userscripts/qutedmenu b/misc/userscripts/qutedmenu index de1b8d641..cc5a44413 100755 --- a/misc/userscripts/qutedmenu +++ b/misc/userscripts/qutedmenu @@ -37,7 +37,7 @@ get_selection() { # https://github.com/halfwit/dotfiles/blob/master/.config/dmenu/font [[ -s $confdir/dmenu/font ]] && read -r font < "$confdir"/dmenu/font -[[ $font ]] && opts+=(-fn "$font") +[[ -n $font ]] && opts+=(-fn "$font") # shellcheck source=/dev/null [[ -s $optsfile ]] && source "$optsfile" @@ -46,7 +46,7 @@ url=$(get_selection) url=${url/*http/http} # If no selection is made, exit (escape pressed, e.g.) -[[ ! $url ]] && exit 0 +[[ -z $url ]] && exit 0 case $1 in open) printf '%s' "open $url" >> "$QUTE_FIFO" || qutebrowser "$url" ;; diff --git a/mypy.ini b/mypy.ini index 4526e4e48..d8c7221ad 100644 --- a/mypy.ini +++ b/mypy.ini @@ -18,10 +18,6 @@ disallow_untyped_decorators = True # no_implicit_optional = True # warn_return_any = True -[mypy-faulthandler] -# https://github.com/python/typeshed/pull/2627 -ignore_missing_imports = True - [mypy-colorama] # https://github.com/tartley/colorama/issues/206 ignore_missing_imports = True @@ -73,3 +69,19 @@ 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/pytest.ini b/pytest.ini index c907173e0..c278b0591 100644 --- a/pytest.ini +++ b/pytest.ini @@ -64,6 +64,7 @@ qt_log_ignore = ^QSettings::value: Empty key passed ^Icon theme ".*" not found ^Error receiving trust for a CA certificate + ^QBackingStore::endPaint\(\) called with active painter on backingstore paint device xfail_strict = true filterwarnings = error diff --git a/qutebrowser/api/apitypes.py b/qutebrowser/api/apitypes.py index 9fec0a6cb..8fbc1a9a7 100644 --- a/qutebrowser/api/apitypes.py +++ b/qutebrowser/api/apitypes.py @@ -24,3 +24,4 @@ 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 index 093244727..cd43079ad 100644 --- a/qutebrowser/api/cmdutils.py +++ b/qutebrowser/api/cmdutils.py @@ -17,7 +17,37 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Utilities for command handlers.""" +"""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 @@ -33,15 +63,17 @@ 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: + exception with an appropriate error message:: raise cmdexc.CommandError("Message") The message will then be shown in the qutebrowser status bar. - Note that 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. + .. 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. """ @@ -76,13 +108,7 @@ def check_exclusive(flags: typing.Iterable[bool], class register: # noqa: N801,N806 pylint: disable=invalid-name - """Decorator to register a new command handler. - - 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. - """ + """Decorator to register a new command handler.""" def __init__(self, *, instance: str = None, @@ -95,8 +121,11 @@ class register: # noqa: N801,N806 pylint: disable=invalid-name 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: @@ -127,16 +156,50 @@ class register: # noqa: N801,N806 pylint: disable=invalid-name class argument: # noqa: N801,N806 pylint: disable=invalid-name - """Decorator to customize an argument for @cmdutils.register. + """Decorator to customize an argument. - Attributes: - _argname: The name of the argument to handle. - _kwargs: Keyword arguments, valid ArgInfo members + 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 - self._kwargs = kwargs + 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__ diff --git a/qutebrowser/api/config.py b/qutebrowser/api/config.py index 6558cf42a..0c633e54d 100644 --- a/qutebrowser/api/config.py +++ b/qutebrowser/api/config.py @@ -21,9 +21,23 @@ import typing -MYPY = False -if MYPY: - # pylint: disable=unused-import,useless-suppression - from qutebrowser.config import config +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/app.py b/qutebrowser/app.py index 6c948e10c..2b6896b76 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -63,11 +63,12 @@ from qutebrowser.completion.models import miscmodels 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, @@ -77,8 +78,6 @@ from qutebrowser.utils import (log, version, message, utils, urlutils, objreg, usertypes, standarddir, error, qtutils) # pylint: disable=unused-import # We import those to run the cmdutils.register decorators. -from qutebrowser.components import (scrollcommands, caretcommands, - zoomcommands, misccommands) from qutebrowser.mainwindow.statusbar import command from qutebrowser.misc import utilcmds # pylint: enable=unused-import @@ -166,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: @@ -468,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) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 3bd4c55c3..55ab89a20 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -141,14 +141,11 @@ class TabData: class AbstractAction: - """Attribute 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) - """ + """Attribute ``action`` of AbstractTab for Qt WebActions.""" + # 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 def __init__(self, tab: 'AbstractTab') -> None: @@ -200,7 +197,7 @@ class AbstractAction: class AbstractPrinting: - """Attribute of AbstractTab for printing the page.""" + """Attribute ``printing`` of AbstractTab for printing the page.""" def __init__(self, tab: 'AbstractTab') -> None: self._widget = None @@ -271,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. @@ -279,15 +276,14 @@ 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() + _Callback = typing.Callable[[bool], None] def __init__(self, tab: 'AbstractTab', parent: QWidget = None): @@ -350,17 +346,13 @@ class AbstractSearch(QObject): class AbstractZoom(QObject): - """Attribute of AbstractTab for controlling zoom. - - Attributes: - _neighborlist: A NeighborList with the zoom levels. - _default_zoom_changed: Whether the zoom was changed from the default. - """ + """Attribute ``zoom`` of AbstractTab for controlling zoom.""" 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) @@ -375,7 +367,9 @@ class AbstractZoom(QObject): self._init_neighborlist() def _init_neighborlist(self) -> None: - """Initialize self._neighborlist.""" + """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) @@ -427,15 +421,12 @@ class AbstractZoom(QObject): 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, @@ -522,16 +513,12 @@ class AbstractCaret(QObject): class AbstractScroller(QObject): - """Attribute of AbstractTab to manage scroll position. - - Signals: - perc_changed: The scroll position changed. - before_jump_requested: - Emitted by other code when the user requested a jump. - Used to set the special ' mark so the user can return. - """ + """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: 'AbstractTab', parent: QWidget = None): @@ -833,42 +820,46 @@ class AbstractTabPrivate: class AbstractTab(QWidget): - """An adapter for QWebView/QWebEngineView representing a single tab. - - 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. - before_load_started: 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(usertypes.LoadStatus) + #: 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) - # url, requested url, title - history_item_triggered = pyqtSignal(QUrl, QUrl, str) + #: Signal emitted when a page requested full-screen (bool) fullscreen_requested = pyqtSignal(bool) - renderer_process_terminated = pyqtSignal(TerminationStatus, int) + #: Signal emitted before load starts (URL as QUrl) before_load_started = pyqtSignal(QUrl) + # 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 @@ -952,6 +943,10 @@ class AbstractTab(QWidget): evt.posted = True QApplication.postEvent(recipient, evt) + def navigation_blocked(self) -> bool: + """Test if navigation is allowed on the current tab.""" + return self.data.pinned and config.val.tabs.pinned.frozen + @pyqtSlot(QUrl) def _on_before_load_started(self, url: QUrl) -> None: """Adjust the title if we are going to visit a URL soon.""" diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 19ae41a68..6ae47f9d9 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -34,7 +34,7 @@ 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, objects from qutebrowser.completion.models import urlmodel, miscmodels @@ -316,7 +316,7 @@ class CommandDispatcher: else: # Explicit count with a tab that doesn't exist. return - elif curtab.data.pinned: + elif curtab.navigation_blocked(): message.info("Tab is pinned!") else: curtab.load_url(cur_url) @@ -1721,4 +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) + + log.misc.debug('state before fullscreen: {}'.format( + debug.qflags_key(Qt, window.state_before_fullscreen))) 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/network/pac.py b/qutebrowser/browser/network/pac.py index 1c6075945..bd060820b 100644 --- a/qutebrowser/browser/network/pac.py +++ b/qutebrowser/browser/network/pac.py @@ -180,6 +180,8 @@ class PACResolver: """ self._engine = QJSEngine() + self._engine.installExtensions(QJSEngine.ConsoleExtension) + self._ctx = _PACContext(self._engine) self._engine.globalObject().setProperty( "PAC", self._engine.newQObject(self._ctx)) diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index a22facfbd..ac46fdcb9 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -19,15 +19,23 @@ """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): @@ -40,7 +48,7 @@ class OrphanedError(Error): """Raised when a webelement's parent has vanished.""" -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: @@ -54,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: @@ -134,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: @@ -177,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...") @@ -193,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: @@ -210,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: @@ -229,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: @@ -260,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: @@ -297,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 @@ -322,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: @@ -358,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) @@ -388,7 +387,8 @@ class AbstractWebElement(collections.abc.MutableMapping): 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: @@ -425,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/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/webenginequtescheme.py b/qutebrowser/browser/webengine/webenginequtescheme.py index 68ffa019d..816589514 100644 --- a/qutebrowser/browser/webengine/webenginequtescheme.py +++ b/qutebrowser/browser/webengine/webenginequtescheme.py @@ -22,6 +22,11 @@ from PyQt5.QtCore import QBuffer, QIODevice, QUrl from PyQt5.QtWebEngineCore import (QWebEngineUrlSchemeHandler, QWebEngineUrlRequestJob) +try: + from PyQt5.QtWebEngineCore import QWebEngineUrlScheme # type: ignore +except ImportError: + # Added in Qt 5.12 + QWebEngineUrlScheme = None from qutebrowser.browser import qutescheme from qutebrowser.utils import log, qtutils @@ -33,8 +38,12 @@ class QuteSchemeHandler(QWebEngineUrlSchemeHandler): def install(self, profile): """Install the handler for qute:// URLs on the given profile.""" + if QWebEngineUrlScheme is not None: + assert QWebEngineUrlScheme.schemeByName(b'qute') is not None + profile.installUrlSchemeHandler(b'qute', self) - if qtutils.version_check('5.11', compiled=False): + if (qtutils.version_check('5.11', compiled=False) and + not qtutils.version_check('5.12', compiled=False)): # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-63378 profile.installUrlSchemeHandler(b'chrome-error', self) profile.installUrlSchemeHandler(b'chrome-extension', self) @@ -130,3 +139,16 @@ class QuteSchemeHandler(QWebEngineUrlSchemeHandler): buf.seek(0) buf.close() job.reply(mimetype.encode('ascii'), buf) + + +def init(): + """Register the qute:// scheme. + + Note this needs to be called early, before constructing any QtWebEngine + classes. + """ + if QWebEngineUrlScheme is not None: + scheme = QWebEngineUrlScheme(b'qute') + scheme.setFlags(QWebEngineUrlScheme.LocalScheme | + QWebEngineUrlScheme.LocalAccessAllowed) + QWebEngineUrlScheme.registerScheme(scheme) diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index da569eef6..10c4d4e6b 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -30,7 +30,7 @@ from PyQt5.QtGui import QFont from PyQt5.QtWebEngineWidgets import (QWebEngineSettings, QWebEngineProfile, QWebEnginePage) -from qutebrowser.browser.webengine import spell +from qutebrowser.browser.webengine import spell, webenginequtescheme from qutebrowser.config import config, websettings from qutebrowser.config.websettings import AttributeInfo as Attr from qutebrowser.utils import utils, standarddir, qtutils, message, log @@ -298,6 +298,7 @@ def init(args): not hasattr(QWebEnginePage, 'setInspectedPage')): # only Qt < 5.11 os.environ['QTWEBENGINE_REMOTE_DEBUGGING'] = str(utils.random_port()) + webenginequtescheme.init() spell.init() _init_profiles() diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index a74d866ea..22380cb1f 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -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) diff --git a/qutebrowser/browser/webkit/mhtml.py b/qutebrowser/browser/webkit/mhtml.py index 1ecebed2d..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 diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py index 2ca1ae0d9..dd3643c87 100644 --- a/qutebrowser/browser/webkit/network/networkmanager.py +++ b/qutebrowser/browser/webkit/network/networkmanager.py @@ -38,6 +38,7 @@ if MYPY: 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) @@ -405,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/webkitelem.py b/qutebrowser/browser/webkit/webkitelem.py index 66d5e59b8..af0db295d 100644 --- a/qutebrowser/browser/webkit/webkitelem.py +++ b/qutebrowser/browser/webkit/webkitelem.py @@ -19,12 +19,19 @@ """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): @@ -36,7 +43,7 @@ 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!") @@ -44,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( @@ -138,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') @@ -146,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!") @@ -158,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() @@ -166,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()") @@ -178,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)] @@ -204,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() @@ -218,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 @@ -248,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 @@ -300,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 @@ -311,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() @@ -326,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) @@ -337,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 @@ -356,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/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py index 3ed5f0a64..b58f36372 100644 --- a/qutebrowser/completion/completiondelegate.py +++ b/qutebrowser/completion/completiondelegate.py @@ -212,11 +212,11 @@ class CompletionItemDelegate(QStyledItemDelegate): view = self.parent() pattern = view.pattern columns_to_filter = index.model().columns_to_filter(index) - self._doc.setPlainText(self._opt.text) if index.column() in columns_to_filter and pattern: pat = re.escape(pattern).replace(r'\ ', r'|') _Highlighter(self._doc, pat, config.val.colors.completion.match.fg) + self._doc.setPlainText(self._opt.text) else: self._doc.setHtml( '{}'.format( diff --git a/qutebrowser/components/__init__.py b/qutebrowser/components/__init__.py index b42c87fb6..1a13763bf 100644 --- a/qutebrowser/components/__init__.py +++ b/qutebrowser/components/__init__.py @@ -17,4 +17,4 @@ # 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.""" +"""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 fdec79d0f..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.api 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/misccommands.py b/qutebrowser/components/misccommands.py index 60715c65b..a65bdd235 100644 --- a/qutebrowser/components/misccommands.py +++ b/qutebrowser/components/misccommands.py @@ -118,7 +118,7 @@ def printpage(tab: apitypes.Tab, @cmdutils.argument('tab', value=cmdutils.Value.cur_tab) def home(tab: apitypes.Tab) -> None: """Open main startpage in current tab.""" - if tab.data.pinned: + if tab.navigation_blocked(): message.info("Tab is pinned!") else: tab.load_url(config.val.url.start_pages[0]) @@ -238,7 +238,7 @@ def tab_mute(tab: apitypes.Tab) -> None: if tab is None: return try: - tab.audio.set_muted(tab.audio.is_muted(), override=True) + tab.audio.set_muted(not tab.audio.is_muted(), override=True) except apitypes.WebTabError as e: raise cmdutils.CommandError(e) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 80826beeb..201b87fde 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -86,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: typing.Optional[str]) -> bool: + 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. @@ -119,7 +119,7 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name @functools.wraps(func) 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 @@ -128,7 +128,7 @@ class change_filter: # noqa: N801,N806 pylint: disable=invalid-name 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 meth_wrapper diff --git a/qutebrowser/config/configcache.py b/qutebrowser/config/configcache.py index a421ba85c..15f343478 100644 --- a/qutebrowser/config/configcache.py +++ b/qutebrowser/config/configcache.py @@ -46,7 +46,9 @@ class ConfigCache: self._cache[attr] = config.instance.get(attr) def __getitem__(self, attr: str) -> typing.Any: - if attr not in self._cache: + try: + return self._cache[attr] + except KeyError: assert not config.instance.get_opt(attr).supports_pattern self._cache[attr] = config.instance.get(attr) - return self._cache[attr] + return self._cache[attr] diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index c93032387..61e35fd53 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -25,7 +25,7 @@ DATA: A dict of Option objects after init() has been called. """ import typing -from typing import Optional # pylint: disable=unused-import +from typing import Optional # pylint: disable=unused-import,useless-suppression import functools import attr diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index bed4d9659..b6d4736ae 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -1768,6 +1768,11 @@ tabs.pinned.shrink: type: Bool desc: Shrink pinned tabs down to their contents. +tabs.pinned.frozen: + type: Bool + default: True + desc: Force pinned tabs to stay at fixed URL. + tabs.wrap: default: true type: Bool diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 54ca91488..de50dfca9 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -308,7 +308,7 @@ class YamlConfig(QObject): self._migrate_bool(settings, 'tabs.favicons.show', 'always', 'never') self._migrate_bool(settings, 'scrolling.bar', - 'when-searching', 'never') + 'always', 'when-searching') self._migrate_bool(settings, 'qt.force_software_rendering', 'software-opengl', 'none') 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/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/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index c06700b6c..edb443eec 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -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 = { diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index ed0a78469..34dc5c507 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -32,7 +32,7 @@ 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 @@ -137,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. @@ -238,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: @@ -517,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/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index 176746759..74a2ad372 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -238,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:

" diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index bbc025515..115c53352 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -137,6 +137,7 @@ 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', @@ -146,7 +147,7 @@ LOGGER_NAMES = [ 'js', 'qt', 'rfc6266', 'ipc', 'shlexer', 'save', 'message', 'config', 'sessions', 'webelem', 'prompt', 'network', 'sql', - 'greasemonkey' + 'greasemonkey', 'extensions', ] diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py index b496273f8..6731721aa 100644 --- a/qutebrowser/utils/message.py +++ b/qutebrowser/utils/message.py @@ -42,12 +42,12 @@ def _log_stack(typ: str, stack: str) -> None: def error(message: str, *, stack: str = None, replace: bool = False) -> None: - """Convenience function to display an error message in the statusbar. + """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 = ''.join(traceback.format_stack()) @@ -60,11 +60,11 @@ def error(message: str, *, stack: str = None, replace: bool = False) -> None: def warning(message: str, *, replace: bool = False) -> None: - """Convenience function to display a warning message in the statusbar. + """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', ''.join(traceback.format_stack())) log.message.warning(message) @@ -72,11 +72,11 @@ def warning(message: str, *, replace: bool = False) -> None: def info(message: str, *, replace: bool = False) -> None: - """Convenience function to display an info message in the statusbar. + """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/usertypes.py b/qutebrowser/utils/usertypes.py index c948df48f..84b7e7f9b 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -210,15 +210,33 @@ 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 class Exit(enum.IntEnum): @@ -241,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 diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 8574a3c2b..2d517043a 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -45,7 +45,8 @@ try: 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 diff --git a/requirements.txt b/requirements.txt index 66dcf23ef..68bd341bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ colorama==0.4.1 cssutils==1.0.2 Jinja2==2.10 MarkupSafe==1.1.0 -Pygments==2.3.0 +Pygments==2.3.1 pyPEG2==2.15.2 PyYAML==3.13 diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 62c0b5142..d42ce1d71 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -204,6 +204,7 @@ WHITELISTED_FILES = [ 'browser/webkit/webkitinspector.py', 'keyinput/macros.py', 'browser/webkit/webkitelem.py', + 'api/interceptor.py', ] diff --git a/scripts/dev/ci/travis_install.sh b/scripts/dev/ci/travis_install.sh index 18f5aa9ec..c736a01d3 100644 --- a/scripts/dev/ci/travis_install.sh +++ b/scripts/dev/ci/travis_install.sh @@ -71,7 +71,7 @@ EOF set -e -if [[ $DOCKER ]]; then +if [[ -n $DOCKER ]]; then exit 0 elif [[ $TRAVIS_OS_NAME == osx ]]; then # Disable App Nap diff --git a/scripts/dev/ci/travis_run.sh b/scripts/dev/ci/travis_run.sh index a287e844e..4e338221f 100644 --- a/scripts/dev/ci/travis_run.sh +++ b/scripts/dev/ci/travis_run.sh @@ -1,6 +1,6 @@ #!/bin/bash -if [[ $DOCKER ]]; then +if [[ -n $DOCKER ]]; then docker run \ --privileged \ -v "$PWD:/outside" \ diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py index f3217694e..f9262c946 100755 --- a/scripts/dev/run_vulture.py +++ b/scripts/dev/run_vulture.py @@ -30,6 +30,7 @@ import argparse import vulture import qutebrowser.app # pylint: disable=unused-import +from qutebrowser.extensions import loader from qutebrowser.misc import objects from qutebrowser.utils import utils from qutebrowser.browser.webkit import rfc6266 @@ -43,6 +44,8 @@ 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 objects.commands.values(): yield utils.qualname(cmd.handler) @@ -127,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 ba4e9b69c..1ba272fba 100755 --- a/scripts/dev/src2asciidoc.py +++ b/scripts/dev/src2asciidoc.py @@ -35,6 +35,7 @@ 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.extensions import loader from qutebrowser.commands import argparser from qutebrowser.config import configdata, configtypes from qutebrowser.utils import docutils, usertypes @@ -549,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/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/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature index 7bde58b4f..b1d92145d 100644 --- a/tests/end2end/features/downloads.feature +++ b/tests/end2end/features/downloads.feature @@ -93,7 +93,7 @@ Feature: Downloading things from a website. Then no crash should happen # https://github.com/qutebrowser/qutebrowser/issues/4240 - @qt!=5.11.2 + @qt<5.11.2 Scenario: Downloading with SSL errors (issue 1413) When SSL is supported And I clear SSL errors diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature index e4b477e50..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 diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index 7f4d4635c..804f72bbf 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -1289,6 +1289,14 @@ Feature: Tab management And the following tabs should be open: - data/numbers/1.txt (active) (pinned) + Scenario: :tab-pin open url with tabs.pinned.frozen = false + When I set tabs.pinned.frozen to false + And I open data/numbers/1.txt + And I run :tab-pin + And I open data/numbers/2.txt + Then the following tabs should be open: + - data/numbers/2.txt (active) (pinned) + Scenario: :home on a pinned tab When I open data/numbers/1.txt And I run :tab-pin @@ -1297,6 +1305,16 @@ Feature: Tab management And the following tabs should be open: - data/numbers/1.txt (active) (pinned) + Scenario: :home on a pinned tab with tabs.pinned.frozen = false + When I set url.start_pages to ["http://localhost:(port)/data/numbers/2.txt"] + And I set tabs.pinned.frozen to false + And I open data/numbers/1.txt + And I run :tab-pin + And I run :home + Then data/numbers/2.txt should be loaded + And the following tabs should be open: + - data/numbers/2.txt (active) (pinned) + Scenario: Cloning a pinned tab When I open data/numbers/1.txt And I run :tab-pin diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index 561d92a80..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() 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 89330ab64..38d82c004 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -459,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.""" diff --git a/tests/unit/browser/webkit/network/test_pac.py b/tests/unit/browser/webkit/network/test_pac.py index 8c03c6cee..5aebecbf2 100644 --- a/tests/unit/browser/webkit/network/test_pac.py +++ b/tests/unit/browser/webkit/network/test_pac.py @@ -205,6 +205,20 @@ def test_secret_url(url, has_secret, from_file): res.resolve(QNetworkProxyQuery(QUrl(url)), from_file=from_file) +def test_logging(qtlog): + """Make sure console.log() works for PAC files.""" + test_str = """ + function FindProxyForURL(domain, host) { + console.log("logging test"); + return "DIRECT"; + } + """ + res = pac.PACResolver(test_str) + res.resolve(QNetworkProxyQuery(QUrl("https://example.com/test"))) + assert len(qtlog.records) == 1 + assert qtlog.records[0].message == 'logging test' + + def fetcher_test(test_str): class PACHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): 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/completion/test_completiondelegate.py b/tests/unit/completion/test_completiondelegate.py index 2d122927d..7d310380e 100644 --- a/tests/unit/completion/test_completiondelegate.py +++ b/tests/unit/completion/test_completiondelegate.py @@ -20,7 +20,8 @@ from unittest import mock import pytest from PyQt5.QtCore import Qt -from PyQt5.QtGui import QTextDocument +from PyQt5.QtGui import QTextDocument, QColor +from PyQt5.QtWidgets import QTextEdit from qutebrowser.completion import completiondelegate @@ -50,3 +51,24 @@ def test_highlight(pat, txt, segments): highlighter.setFormat.assert_has_calls([ mock.call(s[0], s[1], mock.ANY) for s in segments ]) + + +def test_highlighted(qtbot): + """Make sure highlighting works. + + Note that with Qt 5.11.3 and > 5.12.1 we need to call setPlainText *after* + creating the highlighter for highlighting to work. Ideally, we'd test + whether CompletionItemDelegate._get_textdoc() works properly, but testing + that is kind of hard, so we just test it in isolation here. + """ + doc = QTextDocument() + completiondelegate._Highlighter(doc, 'Hello', Qt.red) + doc.setPlainText('Hello World') + + # Needed so the highlighting actually works. + edit = QTextEdit() + qtbot.addWidget(edit) + edit.setDocument(doc) + + colors = [f.foreground().color() for f in doc.allFormats()] + assert QColor('red') in colors 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/config/test_configcache.py b/tests/unit/config/test_configcache.py index 91e2f22fa..7c0f6012f 100644 --- a/tests/unit/config/test_configcache.py +++ b/tests/unit/config/test_configcache.py @@ -50,3 +50,17 @@ def test_configcache_get_after_set(config_stub): assert not config.cache['auto_save.session'] config_stub.val.auto_save.session = True assert config.cache['auto_save.session'] + + +def test_configcache_naive_benchmark(config_stub, benchmark): + def _run_bench(): + for _i in range(10000): + # pylint: disable=pointless-statement + config.cache['tabs.padding'] + config.cache['tabs.indicator.width'] + config.cache['tabs.indicator.padding'] + config.cache['tabs.min_width'] + config.cache['tabs.max_width'] + config.cache['tabs.pinned.shrink'] + # pylint: enable=pointless-statement + benchmark(_run_bench) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 2b793b58b..79d4b9d89 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -250,36 +250,28 @@ class TestYaml: data = autoconfig.read() assert data['content.webrtc_ip_handling_policy']['global'] == expected - @pytest.mark.parametrize('show, expected', [ - (True, 'always'), - (False, 'never'), - ('always', 'always'), - ('never', 'never'), - ('pinned', 'pinned'), + @pytest.mark.parametrize('setting, old, new', [ + ('tabs.favicons.show', True, 'always'), + ('tabs.favicons.show', False, 'never'), + ('tabs.favicons.show', 'always', 'always'), + + ('scrolling.bar', True, 'always'), + ('scrolling.bar', False, 'when-searching'), + ('scrolling.bar', 'always', 'always'), + + ('qt.force_software_rendering', True, 'software-opengl'), + ('qt.force_software_rendering', False, 'none'), + ('qt.force_software_rendering', 'chromium', 'chromium'), ]) - def test_tabs_favicons_show(self, yaml, autoconfig, show, expected): - """Tests for migration of tabs.favicons.show.""" - autoconfig.write({'tabs.favicons.show': {'global': show}}) + def test_bool_migrations(self, yaml, autoconfig, setting, old, new): + """Tests for migration of former boolean settings.""" + autoconfig.write({setting: {'global': old}}) yaml.load() yaml._save() data = autoconfig.read() - assert data['tabs.favicons.show']['global'] == expected - - @pytest.mark.parametrize('force, expected', [ - (True, 'software-opengl'), - (False, 'none'), - ('chromium', 'chromium'), - ]) - def test_force_software_rendering(self, yaml, autoconfig, force, expected): - autoconfig.write({'qt.force_software_rendering': {'global': force}}) - - yaml.load() - yaml._save() - - data = autoconfig.read() - assert data['qt.force_software_rendering']['global'] == expected + assert data[setting]['global'] == new def test_renamed_key_unknown_target(self, monkeypatch, yaml, autoconfig): 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/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/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/tox.ini b/tox.ini index 8a4232aaa..75a00961e 100644 --- a/tox.ini +++ b/tox.ini @@ -199,3 +199,14 @@ deps = -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/