diff --git a/MANIFEST.in b/MANIFEST.in index ec906aaf4..a3ae1ee28 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -27,6 +27,7 @@ exclude scripts/asciidoc2html.py exclude doc/notes recursive-exclude doc *.asciidoc include doc/qutebrowser.1.asciidoc +include doc/changelog.asciidoc prune tests prune qutebrowser/3rdparty prune misc/requirements diff --git a/README.asciidoc b/README.asciidoc index a7094b971..09317c0cd 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -11,7 +11,6 @@ image:icons/qutebrowser-64x64.png[qutebrowser logo] *A keyboard-driven, vim-like image:https://img.shields.io/pypi/l/qutebrowser.svg?style=flat["license badge",link="https://github.com/qutebrowser/qutebrowser/blob/master/LICENSE"] image:https://img.shields.io/pypi/v/qutebrowser.svg?style=flat["version badge",link="https://pypi.python.org/pypi/qutebrowser/"] -image:https://requires.io/github/qutebrowser/qutebrowser/requirements.svg?branch=master["requirements badge",link="https://requires.io/github/qutebrowser/qutebrowser/requirements/?branch=master"] image:https://travis-ci.org/qutebrowser/qutebrowser.svg?branch=master["Build Status", link="https://travis-ci.org/qutebrowser/qutebrowser"] image:https://ci.appveyor.com/api/projects/status/5pyauww2k68bbow2/branch/master?svg=true["AppVeyor build status", link="https://ci.appveyor.com/project/qutebrowser/qutebrowser"] image:https://codecov.io/github/qutebrowser/qutebrowser/coverage.svg?branch=master["coverage badge",link="https://codecov.io/github/qutebrowser/qutebrowser?branch=master"] diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 0d07b47e7..91b8efb66 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -39,7 +39,10 @@ Breaking changes v0.9.0) is now not supported anymore. - Upgrading qutebrowser with a version older than v0.4.0 still running now won't work properly anymore. -- The `--harfbuzz` commandline argument got dropped. +- The `--harfbuzz` and `--relaxed-config` commandline arguments got dropped. +- `:set` now doesn't support toggling/cycling values anymore, that functionality + got moved to `:config-cycle`. +- `:scroll-perc` got renamed to `:scroll-to-perc`. Major changes ~~~~~~~~~~~~~ @@ -57,6 +60,14 @@ Added - New `backend` setting to select the backend to use (auto/webengine/webkit). Together with the previous setting, this should make wrapper scripts unnecessary. +- Proxy authentication is now supported with the QtWebEngine backend. +- New config commands: + - `:config-cycle` to cycle an option between multiple values. + - `:config-unset` to remove a configured option + - `:config-clear` to remove all configured options + - `:config-source` to (re-)read a `config.py` file + - `:config-edit` to open the `config.py` file in an editor +- New `:version` command which opens `qute://version`. Changed ~~~~~~~ @@ -66,12 +77,20 @@ Changed - When there are multiple messages shown, the timeout is increased. - `:search` now only clears the search if one was displayed before, so pressing `` doesn't un-focus inputs anymore. +- Pinned tabs now adjust to their text's width, so the `tabs.width.pinned` + setting got removed. +- `:set-cmd-text` now has a `--run-on-count` argument to run the underlying + command directly if a count was given. Fixes ~~~~~ - Exiting fullscreen via `:fullscreen` or buttons on a page now restores the correct previous window state (maximized/fullscreen). +- When `input.insert_mode.auto_load` is set, background tabs now don't enter + insert mode anymore. +- The keybinding help widget now works correctly when using keybindings with a + count. v0.11.1 (unreleased) -------------------- diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 25c4c463f..6083d1e3a 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -31,6 +31,11 @@ It is possible to run or bind multiple commands by separating them with `;;`. |<>|Load a bookmark. |<>|Select tab by index or url/title best match. |<>|Close the current window. +|<>|Set all settings back to their default. +|<>|Cycle an option between multiple values. +|<>|Open the config.py file in the editor. +|<>|Read a config.py file. +|<>|Unset an option. |<>|Download a given URL, or current page if no URL given. |<>|Cancel the last/[count]th download. |<>|Remove all finished downloads from the list. @@ -86,6 +91,7 @@ It is possible to run or bind multiple commands by separating them with `;;`. |<>|Switch to the previous tab, or switch [count] tabs back. |<>|Unbind a keychain. |<>|Re-open a closed tab. +|<>|Show version information. |<>|Show the source of the current page in a new tab. |<>|Close all windows except for the current one. |<>|Yank something to the clipboard or primary selection. @@ -115,7 +121,7 @@ How many pages to go back. [[bind]] === bind -Syntax: +:bind [*--mode* 'mode'] [*--force*] 'key' ['command']+ +Syntax: +:bind [*--mode* 'mode'] 'key' ['command']+ Bind a key to a command. @@ -128,7 +134,6 @@ Bind a key to a command. * +*-m*+, +*--mode*+: A comma-separated list of modes to bind the key in (default: `normal`). See `:help bindings.commands` for the available modes. -* +*-f*+, +*--force*+: Rebind the key if it is already bound. ==== note * This command does not split arguments after the last argument and handles quotes literally. @@ -184,20 +189,83 @@ Load a bookmark. [[buffer]] === buffer -Syntax: +:buffer 'index'+ +Syntax: +:buffer ['index']+ Select tab by index or url/title best match. -Focuses window if necessary. +Focuses window if necessary when index is given. If both index and count are given, use count. ==== positional arguments * +'index'+: The [win_id/]index of the tab to focus. Or a substring in which case the closest match will be focused. +==== count +The tab index to focus, starting with 1. + [[close]] === close Close the current window. +[[config-clear]] +=== config-clear +Syntax: +:config-clear [*--save*]+ + +Set all settings back to their default. + +==== optional arguments +* +*-s*+, +*--save*+: If given, all configuration in autoconfig.yml is also removed. + + +[[config-cycle]] +=== config-cycle +Syntax: +:config-cycle [*--temp*] [*--print*] 'option' ['values' ['values' ...]]+ + +Cycle an option between multiple values. + +==== positional arguments +* +'option'+: The name of the option. +* +'values'+: The values to cycle through. + +==== optional arguments +* +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed. +* +*-p*+, +*--print*+: Print the value after setting. + +[[config-edit]] +=== config-edit +Syntax: +:config-edit [*--no-source*]+ + +Open the config.py file in the editor. + +==== optional arguments +* +*-n*+, +*--no-source*+: Don't re-source the config file after editing. + +[[config-source]] +=== config-source +Syntax: +:config-source [*--clear*] ['filename']+ + +Read a config.py file. + +==== positional arguments +* +'filename'+: The file to load. If not given, loads the default config.py. + + +==== optional arguments +* +*-c*+, +*--clear*+: Clear current settings first. + +[[config-unset]] +=== config-unset +Syntax: +:config-unset [*--temp*] 'option'+ + +Unset an option. + +This sets an option back to its default and removes it from autoconfig.yml. + +==== positional arguments +* +'option'+: The name of the option. + +==== optional arguments +* +*-t*+, +*--temp*+: Don't touch autoconfig.yml. + [[download]] === download Syntax: +:download [*--mhtml*] [*--dest* 'dest'] ['url'] ['dest-old']+ @@ -773,15 +841,15 @@ Save a session. [[set]] === set -Syntax: +:set [*--temp*] [*--print*] ['option'] ['values' ['values' ...]]+ +Syntax: +:set [*--temp*] [*--print*] ['option'] ['value']+ Set an option. -If the option name ends with '?', the value of the option is shown instead. If the option name ends with '!' and it is a boolean value, toggle it. +If the option name ends with '?', the value of the option is shown instead. ==== positional arguments * +'option'+: The name of the option. -* +'values'+: The value to set, or the values to cycle through. +* +'value'+: The value to set. ==== optional arguments * +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed. @@ -789,7 +857,7 @@ If the option name ends with '?', the value of the option is shown instead. If t [[set-cmd-text]] === set-cmd-text -Syntax: +:set-cmd-text [*--space*] [*--append*] 'text'+ +Syntax: +:set-cmd-text [*--space*] [*--append*] [*--run-on-count*] 'text'+ Preset the statusbar to some text. @@ -799,6 +867,11 @@ Preset the statusbar to some text. ==== optional arguments * +*-s*+, +*--space*+: If given, a space is added to the end. * +*-a*+, +*--append*+: If given, the text is appended to the current text. +* +*-r*+, +*--run-on-count*+: If given with a count, the command is run with the given count rather than setting the command text. + + +==== count +The count if given. ==== note * This command does not split arguments after the last argument and handles quotes literally. @@ -919,7 +992,7 @@ Close all tabs except for the current one. === tab-pin Pin/Unpin the current/[count]th tab. -Pinning a tab shrinks it to `tabs.width.pinned` size. Attempting to close a pinned tab will cause a confirmation, unless --force is passed. +Pinning a tab shrinks it to the size of its title text. Attempting to close a pinned tab will cause a confirmation, unless --force is passed. ==== count The tab index to pin or unpin @@ -948,6 +1021,10 @@ Unbind a keychain. === undo Re-open a closed tab. +[[version]] +=== version +Show version information. + [[view-source]] === view-source Show the source of the current page in a new tab. @@ -1068,8 +1145,8 @@ How many steps to zoom out. |<>|Run a command with the given count. |<>|Scroll the current tab in the given direction. |<>|Scroll the frame page-wise. -|<>|Scroll to a specific percentage of the page. |<>|Scroll the current tab by 'count * dx/dy' pixels. +|<>|Scroll to a specific percentage of the page. |<>|Continue the search to the ([count]th) next term. |<>|Continue the search to the ([count]th) previous term. |<>|Set a mark at the current scroll position in the current tab. @@ -1489,9 +1566,22 @@ Scroll the frame page-wise. ==== count multiplier -[[scroll-perc]] -=== scroll-perc -Syntax: +:scroll-perc [*--horizontal*] ['perc']+ +[[scroll-px]] +=== scroll-px +Syntax: +:scroll-px 'dx' 'dy'+ + +Scroll the current tab by 'count * dx/dy' pixels. + +==== positional arguments +* +'dx'+: How much to scroll in x-direction. +* +'dy'+: How much to scroll in y-direction. + +==== count +multiplier + +[[scroll-to-perc]] +=== scroll-to-perc +Syntax: +:scroll-to-perc [*--horizontal*] ['perc']+ Scroll to a specific percentage of the page. @@ -1506,19 +1596,6 @@ The percentage can be given either as argument or as count. If no percentage is ==== count Percentage to scroll. -[[scroll-px]] -=== scroll-px -Syntax: +:scroll-px 'dx' 'dy'+ - -Scroll the current tab by 'count * dx/dy' pixels. - -==== positional arguments -* +'dx'+: How much to scroll in x-direction. -* +'dy'+: How much to scroll in y-direction. - -==== count -multiplier - [[search-next]] === search-next Continue the search to the ([count]th) next term. diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index dd2612045..e296eaac4 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -11,8 +11,9 @@ Migrating older configurations ------------------------------ qutebrowser does no automatic migration for the new configuration. However, -there's a special link:qute://configdiff[] page in qutebrowser, which will show -you the changes you did in your old configuration, compared to the old defaults. +there's a special link:qute://configdiff/old[configdiff] page in qutebrowser, +which will show you the changes you did in your old configuration, compared to +the old defaults. Other changes in default settings: @@ -57,11 +58,9 @@ To get more help about a setting, use e.g. `:help tabs.position`. To bind and unbind keys, you can use the link:commands.html#bind[`:bind`] and link:commands.html#unbind[`:unbind`] commands: -- Binding the key chain "`,`, `v`" to the `:spawn mpv {url}` command: +- Binding the key chain `,v` to the `:spawn mpv {url}` command: `:bind ,v spawn mpv {url}` - Unbinding the same key chain: `:unbind ,v` -- Changing an existing binding: `bind --force ,v message-info foo`. Without - `--force`, qutebrowser will show an error because `,v` is already bound. Key chains starting with a comma are ideal for custom bindings, as the comma key will never be used in a default keybinding. @@ -88,7 +87,9 @@ Two global objects are pre-defined when running `config.py`: `c` and `config`. Changing settings ~~~~~~~~~~~~~~~~~ -`c` is a shorthand object to easily set settings like this: +While you can set settings using the `config.set()` method (which is explained +in the next section), it's easier to use the `c` shorthand object to easily set +settings like this: .config.py: [source,python] @@ -110,7 +111,7 @@ accepted values depend on the type of the option. Commonly used are: - Dictionaries: * `c.headers.custom = {'X-Hello': 'World', 'X-Awesome': 'yes'}` to override any other values in the dictionary. - * `c.aliases['foo'] = ':message-info foo'` to add a single value. + * `c.aliases['foo'] = 'message-info foo'` to add a single value. - Lists: * `c.url.start_pages = ["https://www.qutebrowser.org/"]` to override any previous elements. @@ -136,6 +137,8 @@ If you want to set settings based on their name as a string, use the .config.py: [source,python] ---- +# Equivalent to: +# c.content.javascript.enabled = False config.set('content.javascript.enabled', False) ---- @@ -143,6 +146,8 @@ To read a setting, use the `config.get` method: [source,python] ---- +# Equivalent to: +# color = c.colors.completion.fg color = config.get('colors.completion.fg') ---- @@ -172,13 +177,6 @@ To bind a key in a mode other than `'normal'`, add a `mode` argument: config.bind('', 'prompt-yes', mode='prompt') ---- -If the key is already bound, `force=True` needs to be given to rebind it: - -[source,python] ----- -config.bind('', 'message-info foo', force=True) ----- - To unbind a key (either a key which has been bound before, or a default binding): [source,python] @@ -198,17 +196,52 @@ config.bind(',v', 'spawn mpv {url}') To suppress loading of any default keybindings, you can set `c.bindings.default = {}`. -Prevent loading `autoconfig.yml` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Loading `autoconfig.yml` +~~~~~~~~~~~~~~~~~~~~~~~~ -If you want all customization done via `:set`, `:bind` and `:unbind` to be -temporary, you can suppress loading `autoconfig.yml` in your `config.py` by -doing: +By default, all customization done via `:set`, `:bind` and `:unbind` is +temporary as soon as a `config.py` exists. The settings done that way are always +saved in the `autoconfig.yml` file, but you'll need to explicitly load it in +your `config.py` by doing: .config.py: [source,python] ---- -config.load_autoconfig = False +config.load_autoconfig() +---- + +If you do so at the top of your file, your `config.py` settings will take +precedence as they overwrite the settings done in `autoconfig.yml`. + +Importing other modules +~~~~~~~~~~~~~~~~~~~~~~~ + +You can import any module from the +https://docs.python.org/3/library/index.html[Python standard library] (e.g. +`import os.path`), as well as any module installed in the environment +qutebrowser is run with. + +If you have an `utils.py` file in your qutebrowser config folder, you can import +that via `import utils` as well. + +While it's in some cases possible to import code from the qutebrowser +installation, doing so is unsupported and discouraged. + +Getting the config directory +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you need to get the qutebrowser config directory, you can do so by reading +`config.configdir`. Similarily, you can get the qutebrowser data directory via +`config.datadir`. + +This gives you a https://docs.python.org/3/library/pathlib.html[`pathlib.Path` +object], on which you can use `/` to add more directory parts, or `str(...)` to +get a string: + +.config.py: +[source,python] +---- +print(str(config.configdir / 'config.py') ---- Handling errors @@ -221,3 +254,106 @@ qutebrowser tries to display errors which are easy to understand even for people who are not used to writing Python. If you see a config error which you find confusing or you think qutebrowser could handle better, please https://github.com/qutebrowser/qutebrowser/issues[open an issue]! + +Recipes +~~~~~~~ + +Reading a YAML file +^^^^^^^^^^^^^^^^^^^ + +To read a YAML config like this: + +.config.yml: +---- +tabs.position: left +tabs.show: switching +---- + +You can use: + +.config.py: +[source,python] +---- +import yaml + +with (config.configdir / 'config.yml').open() as f: + yaml_data = yaml.load(f) + +for k, v in yaml_data.items(): + config.set(k, v) +---- + +Reading a nested YAML file +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To read a YAML file with nested values like this: + +.colors.yml: +---- +colors: + statusbar: + normal: + bg: lime + fg: black + url: + fg: red +---- + +You can use: + +.config.py: +[source,python] +---- +import yaml + +with (config.configdir / 'colors.yml').open() as f: + yaml_data = yaml.load(f) + +def dict_attrs(obj, path=''): + if isinstance(obj, dict): + for k, v in obj.items(): + yield from dict_attrs(v, '{}.{}'.format(path, k) if path else k) + else: + yield path, obj + +for k, v in dict_attrs(yaml_data): + config.set(k, v) +---- + +Note that this won't work for values which are dictionaries. + +Binding chained commands +^^^^^^^^^^^^^^^^^^^^^^^^ + +If you have a lot of chained commands you want to bind, you can write a helper +to do so: + +[source,python] +---- +def bind_chained(key, *commands): + config.bind(key, ' ;; '.join(commands)) + +bind_chained('', 'clear-keychain', 'search') +---- + +Avoiding flake8 errors +^^^^^^^^^^^^^^^^^^^^^^ + +If you use an editor with flake8 integration which complains about `c` and `config` being undefined, you can use: + +[source,python] +---- +c = c # noqa: F821 +config = config # noqa: F821 +---- + +For type annotation support (note that those imports aren't guaranteed to be +stable across qutebrowser versions): + +[source,python] +---- +from qutebrowser.config.configfiles import ConfigAPI # noqa: F401 +from qutebrowser.config.config import ConfigContainer # noqa: F401 +config = config # type: ConfigAPI # noqa: F821 +c = c # type: ConfigContainer # noqa: F821 +---- diff --git a/doc/help/index.asciidoc b/doc/help/index.asciidoc index 4edea719e..e90d472b5 100644 --- a/doc/help/index.asciidoc +++ b/doc/help/index.asciidoc @@ -7,7 +7,7 @@ Documentation The following help pages are currently available: * link:../quickstart.html[Quick start guide] -* link:../doc.html[Frequently asked questions] +* link:../faq.html[Frequently asked questions] * link:../changelog.html[Change Log] * link:commands.html[Documentation of commands] * link:configuring.html[Configuring qutebrowser] diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 243e60023..7aee1d66e 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -55,6 +55,7 @@ |<>|Border color of an error message. |<>|Foreground color a warning message. |<>|Background color for prompts. +|<>|Border used around UI elements in prompts. |<>|Foreground color for prompts. |<>|Background color for the selected item in filename prompts. |<>|Background color of the statusbar in caret mode. @@ -178,6 +179,7 @@ |<>|The default font size for fixed-pitch text. |<>|The hard minimum font size. |<>|The minimum logical font size that is applied when zooming out. +|<>|Force software rendering for QtWebEngine. |<>|Controls when a hint can be automatically followed without pressing Enter. |<>|A timeout (in milliseconds) to ignore normal-mode key bindings after a successful auto-follow. |<>|CSS border value for hints. @@ -201,7 +203,7 @@ |<>|Timeout (in milliseconds) for partially typed key bindings. |<>|Enable Opera-like mouse rocker gestures. |<>|Enable Spatial Navigation. -|<>|Keychains that shouldn\'t be shown in the keyhint dialog. +|<>|Keychains that shouldn't be shown in the keyhint dialog. |<>|Time from pressing a key to seeing the keyhint dialog (ms). |<>|Time (in ms) to show messages in the statusbar for. |<>|Show messages in unfocused windows. @@ -236,7 +238,6 @@ |<>|The format to use for the tab title for pinned tabs. The same placeholders like for `tabs.title.format` are defined. |<>|The width of the tab bar if it's vertical, in px or as percentage of the window. |<>|Width of the progress indicator (0 to disable). -|<>|The width for pinned tabs with a horizontal tabbar, in px. |<>|Whether to wrap when changing tabs. |<>|Whether to start a search when something else than a URL is entered. |<>|The page to open if :open -t/-b/-w is used without URL. @@ -284,24 +285,24 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[backend]] === backend The backend to use to display websites. qutebrowser supports two different web rendering engines / backends, QtWebKit and QtWebEngine. -QtWebKit is based on WebKit (similar to Safari). It was discontinued by the Qt project with Qt 5.6, but picked up as a well maintained fork: https://github.com/annulen/webkit/wiki - qutebrowser only supports the fork. -QtWebEngine is Qt's official successor to QtWebKit and based on the Chromium project. It's slightly more resource hungry that QtWebKit and has a couple of missing features in qutebrowser, but is generally the preferred choice. +QtWebKit was discontinued by the Qt project with Qt 5.6, but picked up as a well maintained fork: https://github.com/annulen/webkit/wiki - qutebrowser only supports the fork. +QtWebEngine is Qt's official successor to QtWebKit. It's slightly more resource hungry that QtWebKit and has a couple of missing features in qutebrowser, but is generally the preferred choice. +This setting requires a restart. Type: <> Valid values: - * +auto+: Automatically select either QtWebEngine or QtWebKit - * +webkit+: Force QtWebKit - * +webengine+: Force QtWebEngine + * +webengine+: Use QtWebEngine (based on Chromium) + * +webkit+: Use QtWebKit (based on WebKit, similar to Safari) -Default: +pass:[auto]+ +Default: +pass:[webengine]+ [[bindings.commands]] === bindings.commands @@ -506,7 +507,7 @@ Default: * +pass:[B]+: +pass:[set-cmd-text -s :quickmark-load -t]+ * +pass:[D]+: +pass:[tab-close -o]+ * +pass:[F]+: +pass:[hint all tab]+ -* +pass:[G]+: +pass:[scroll-perc]+ +* +pass:[G]+: +pass:[scroll-to-perc]+ * +pass:[H]+: +pass:[back]+ * +pass:[J]+: +pass:[tab-next]+ * +pass:[K]+: +pass:[tab-prev]+ @@ -541,7 +542,7 @@ Default: * +pass:[gb]+: +pass:[set-cmd-text -s :bookmark-load]+ * +pass:[gd]+: +pass:[download]+ * +pass:[gf]+: +pass:[view-source]+ -* +pass:[gg]+: +pass:[scroll-perc 0]+ +* +pass:[gg]+: +pass:[scroll-to-perc 0]+ * +pass:[gl]+: +pass:[tab-move -]+ * +pass:[gm]+: +pass:[tab-move]+ * +pass:[go]+: +pass:[set-cmd-text :open {url:pretty}]+ @@ -627,6 +628,7 @@ Default: This setting can be used to map keys to other keys. When the key used as dictionary-key is pressed, the binding for the key used as dictionary-value is invoked instead. This is useful for global remappings of keys, for example to map Ctrl-[ to Escape. +Note that when a key is bound (via `bindings.default` or `bindings.commands`), the mapping is ignored. Type: <> @@ -966,7 +968,15 @@ Background color for prompts. Type: <> -Default: +pass:[darkblue]+ +Default: +pass:[#444444]+ + +[[colors.prompts.border]] +=== colors.prompts.border +Border used around UI elements in prompts. + +Type: <> + +Default: +pass:[1px solid gray]+ [[colors.prompts.fg]] === colors.prompts.fg @@ -982,7 +992,7 @@ Background color for the selected item in filename prompts. Type: <> -Default: +pass:[#308cc6]+ +Default: +pass:[grey]+ [[colors.statusbar.caret.bg]] === colors.statusbar.caret.bg @@ -1342,7 +1352,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[completion.timestamp_format]] === completion.timestamp_format @@ -1402,7 +1412,7 @@ For more information about the feature, please refer to: http://webkit.org/blog/ Type: <> -Default: empty +Default: +pass:[0]+ This setting is only available with the QtWebKit backend. @@ -1466,7 +1476,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ This setting is only available with the QtWebKit backend. @@ -1497,7 +1507,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ This setting is only available with the QtWebKit backend. @@ -1595,7 +1605,7 @@ The file can be in one of the following formats: `hosts` (with any extension). -Type: <> +Type: <> Default: @@ -1611,7 +1621,7 @@ List of domains that should always be loaded, despite being ad-blocked. Domains may contain * and ? wildcards and are otherwise required to exactly match the requested domain. Local domains are always exempt from hostblocking. -Type: <> +Type: <> Default: @@ -1628,7 +1638,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.images]] === content.images @@ -1668,7 +1678,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.javascript.can_close_tabs]] === content.javascript.can_close_tabs @@ -1681,7 +1691,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ This setting is only available with the QtWebKit backend. @@ -1696,7 +1706,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.javascript.enabled]] === content.javascript.enabled @@ -1737,7 +1747,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.javascript.prompt]] === content.javascript.prompt @@ -1776,7 +1786,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.local_storage]] === content.local_storage @@ -1842,7 +1852,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ This setting is only available with the QtWebKit backend. @@ -1857,7 +1867,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.print_element_backgrounds]] === content.print_element_backgrounds @@ -1885,7 +1895,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[content.proxy]] === content.proxy @@ -1934,7 +1944,7 @@ Default: +pass:[ask]+ === content.user_stylesheets A list of user stylesheet filenames to use. -Type: <> +Type: <> Default: empty @@ -1954,7 +1964,7 @@ Default: +pass:[true]+ [[content.xss_auditing]] === content.xss_auditing Whether load requests should be monitored for cross-site scripting attempts. -Suspicious scripts will be blocked and reported in the inspector\'s JavaScript console. Enabling this feature might have an impact on performance. +Suspicious scripts will be blocked and reported in the inspector's JavaScript console. Enabling this feature might have an impact on performance. Type: <> @@ -1963,7 +1973,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[downloads.location.directory]] === downloads.location.directory @@ -2143,7 +2153,7 @@ Default: +pass:[8pt monospace]+ [[fonts.monospace]] === fonts.monospace Default monospace fonts. -Whenever "monospace" is used in a font setting, it\'s replaced with the fonts listed here. +Whenever "monospace" is used in a font setting, it's replaced with the fonts listed here. Type: <> @@ -2243,7 +2253,7 @@ The hard minimum font size. Type: <> -Default: empty +Default: +pass:[0]+ [[fonts.web.size.minimum_logical]] === fonts.web.size.minimum_logical @@ -2253,6 +2263,22 @@ Type: <> Default: +pass:[6]+ +[[force_software_rendering]] +=== force_software_rendering +Force software rendering for QtWebEngine. +This is needed for QtWebEngine to work with Nouveau drivers. This setting requires a restart. + +Type: <> + +Valid values: + + * +true+ + * +false+ + +Default: +pass:[false]+ + +This setting is only available with the QtWebEngine backend. + [[hints.auto_follow]] === hints.auto_follow Controls when a hint can be automatically followed without pressing Enter. @@ -2274,7 +2300,7 @@ A timeout (in milliseconds) to ignore normal-mode key bindings after a successfu Type: <> -Default: empty +Default: +pass:[0]+ [[hints.border]] === hints.border @@ -2354,7 +2380,7 @@ Default: +pass:[letter]+ === hints.next_regexes A comma-separated list of regexes to use for 'next' links. -Type: <> +Type: <> Default: @@ -2369,7 +2395,7 @@ Default: === hints.prev_regexes A comma-separated list of regexes to use for 'prev' links. -Type: <> +Type: <> Default: @@ -2404,7 +2430,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[history_gap_interval]] === history_gap_interval @@ -2467,7 +2493,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[input.insert_mode.plugins]] === input.insert_mode.plugins @@ -2480,7 +2506,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[input.links_included_in_focus_chain]] === input.links_included_in_focus_chain @@ -2516,7 +2542,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[input.spatial_navigation]] === input.spatial_navigation @@ -2530,14 +2556,14 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[keyhint.blacklist]] === keyhint.blacklist -Keychains that shouldn\'t be shown in the keyhint dialog. +Keychains that shouldn't be shown in the keyhint dialog. Globs are supported, so `;*` will blacklist all keychains starting with `;`. Use `*` to disable keyhints. -Type: <> +Type: <> Default: empty @@ -2569,7 +2595,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[new_instance_open_target]] === new_instance_open_target @@ -2630,8 +2656,9 @@ Default: +pass:[8]+ === qt_args Additional arguments to pass to Qt, without leading `--`. With QtWebEngine, some Chromium arguments (see https://peter.sh/experiments/chromium-command-line-switches/ for a list) will work. +This setting requires a restart. -Type: <> +Type: <> Default: empty @@ -2646,7 +2673,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[scrolling.smooth]] === scrolling.smooth @@ -2660,7 +2687,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[session_default_name]] === session_default_name @@ -2682,7 +2709,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[statusbar.padding]] === statusbar.padding @@ -2693,8 +2720,8 @@ Type: <> Default: - +pass:[bottom]+: +pass:[1]+ -- +pass:[left]+: empty -- +pass:[right]+: empty +- +pass:[left]+: +pass:[0]+ +- +pass:[right]+: +pass:[0]+ - +pass:[top]+: +pass:[1]+ [[statusbar.position]] @@ -2721,7 +2748,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[tabs.close_mouse_button]] === tabs.close_mouse_button @@ -2768,7 +2795,7 @@ Type: <> Default: - +pass:[bottom]+: +pass:[2]+ -- +pass:[left]+: empty +- +pass:[left]+: +pass:[0]+ - +pass:[right]+: +pass:[4]+ - +pass:[top]+: +pass:[2]+ @@ -2839,10 +2866,10 @@ Type: <> Default: -- +pass:[bottom]+: empty +- +pass:[bottom]+: +pass:[0]+ - +pass:[left]+: +pass:[5]+ - +pass:[right]+: +pass:[5]+ -- +pass:[top]+: empty +- +pass:[top]+: +pass:[0]+ [[tabs.position]] === tabs.position @@ -2907,7 +2934,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[tabs.title.alignment]] === tabs.title.alignment @@ -2968,14 +2995,6 @@ Type: <> Default: +pass:[3]+ -[[tabs.width.pinned]] -=== tabs.width.pinned -The width for pinned tabs with a horizontal tabbar, in px. - -Type: <> - -Default: +pass:[43]+ - [[tabs.wrap]] === tabs.wrap Whether to wrap when changing tabs. @@ -3046,17 +3065,15 @@ Default: === url.start_pages The page(s) to open at the start. -Type: <> +Type: <> -Default: - -- +pass:[https://start.duckduckgo.com]+ +Default: +pass:[https://start.duckduckgo.com]+ [[url.yank_ignored_parameters]] === url.yank_ignored_parameters The URL parameters to strip with `:yank url`. -Type: <> +Type: <> Default: @@ -3078,7 +3095,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ [[window.title_format]] === window.title_format @@ -3112,7 +3129,7 @@ Default: +pass:[100%]+ === zoom.levels The available zoom levels. -Type: <> +Type: <> Default: @@ -3152,7 +3169,7 @@ Valid values: * +true+ * +false+ -Default: empty +Default: +pass:[false]+ This setting is only available with the QtWebKit backend. @@ -3166,7 +3183,7 @@ This setting is only available with the QtWebKit backend. When setting from a string, `1`, `yes`, `on` and `true` count as true, while `0`, `no`, `off` and `false` count as false (case-insensitive). |BoolAsk|Like `Bool`, but `ask` is allowed as additional value. |ColorSystem|The color system to use for color interpolation. -|Command|Base class for a command value with arguments. +|Command|A qutebrowser command with arguments. |ConfirmQuit|Whether to display a confirmation when the window is closed. |Dict|A dictionary of values. @@ -3189,6 +3206,7 @@ Lists with duplicate flags are invalid. Each item is checked against the valid v |List|A list of values. When setting from a string, pass a json-like list, e.g. `["one", "two"]`. +|ListOrValue|A list of values, or a single value. |NewTabPosition|How new tabs are positioned. |Padding|Setting for paddings around elements. |Perc|A percentage. diff --git a/doc/install.asciidoc b/doc/install.asciidoc index 08f80903f..07892c876 100644 --- a/doc/install.asciidoc +++ b/doc/install.asciidoc @@ -264,9 +264,6 @@ Manual install * Use the installer from http://www.python.org/downloads[python.org] to get Python 3 (be sure to install pip). -* Use the installer from -http://www.riverbankcomputing.com/software/pyqt/download5[Riverbank computing] -to get Qt and PyQt5. * Install https://testrun.org/tox/latest/index.html[tox] via https://pip.pypa.io/en/latest/[pip]: diff --git a/doc/quickstart.asciidoc b/doc/quickstart.asciidoc index 4881cca62..6188b6a54 100644 --- a/doc/quickstart.asciidoc +++ b/doc/quickstart.asciidoc @@ -31,7 +31,7 @@ image:http://qutebrowser.org/img/cheatsheet-small.png["qutebrowser key binding c * Run `:adblock-update` to download adblock lists and activate adblocking. * If you just cloned the repository, you'll need to run `scripts/asciidoc2html.py` to generate the documentation. -* Go to the link:qute://settings[settings page] to set up qutebrowser the way you want it. (Currently not available with the QtWebEngine backend and on the macOS build - use the `:set` command instead) +* Go to the link:qute://settings[settings page] to set up qutebrowser the way you want it. * Subscribe to https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[the mailinglist] or https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser-announce[the announce-only mailinglist]. diff --git a/doc/qutebrowser.1.asciidoc b/doc/qutebrowser.1.asciidoc index fc70427f6..726511624 100644 --- a/doc/qutebrowser.1.asciidoc +++ b/doc/qutebrowser.1.asciidoc @@ -84,9 +84,6 @@ show it. *--force-color*:: Force colored logging -*--relaxed-config*:: - Silently remove unknown config options. - *--nowindow*:: Don't show the main window. diff --git a/misc/qutebrowser.spec b/misc/qutebrowser.spec index e9ccc1619..bcbd67405 100644 --- a/misc/qutebrowser.spec +++ b/misc/qutebrowser.spec @@ -5,7 +5,6 @@ import os sys.path.insert(0, os.getcwd()) from scripts import setupcommon -from qutebrowser import utils block_cipher = None @@ -31,9 +30,9 @@ def get_data_files(): setupcommon.write_git_file() -if utils.is_windows: +if os.name == 'nt': icon = 'icons/qutebrowser.ico' -elif utils.is_mac: +elif sys.platform == 'darwin': icon = 'icons/qutebrowser.icns' else: icon = None diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt index c6894673f..b8d75bcc1 100644 --- a/misc/requirements/requirements-pip.txt +++ b/misc/requirements/requirements-pip.txt @@ -3,6 +3,6 @@ appdirs==1.4.3 packaging==16.8 pyparsing==2.2.0 -setuptools==36.2.7 -six==1.10.0 -wheel==0.29.0 +setuptools==36.5.0 +six==1.11.0 +wheel==0.30.0 diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index a5fc30e64..0d0d39f50 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -11,13 +11,13 @@ fields==5.0.0 Flask==0.12.2 glob2==0.6 hunter==2.0.1 -hypothesis==3.28.3 +hypothesis==3.30.3 itsdangerous==0.24 # Jinja2==2.9.6 Mako==1.0.7 # MarkupSafe==1.0 parse==1.8.2 -parse-type==0.3.4 +parse-type==0.4.1 py==1.4.34 py-cpuinfo==3.3.0 pytest==3.2.2 diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt index d83654c98..33dec30aa 100644 --- a/misc/requirements/requirements-tox.txt +++ b/misc/requirements/requirements-tox.txt @@ -1,6 +1,6 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -pluggy==0.4.0 +pluggy==0.5.2 py==1.4.34 tox==2.8.2 virtualenv==15.1.0 diff --git a/misc/userscripts/readability b/misc/userscripts/readability index 639e3a111..a5425dbac 100755 --- a/misc/userscripts/readability +++ b/misc/userscripts/readability @@ -1,4 +1,4 @@ -#!/usr/bin/env python2 +#!/usr/bin/env python # # Executes python-readability on current page and opens the summary as new tab. # diff --git a/misc/userscripts/ripbang b/misc/userscripts/ripbang index 4b418443d..b35ff7777 100755 --- a/misc/userscripts/ripbang +++ b/misc/userscripts/ripbang @@ -1,4 +1,4 @@ -#!/usr/bin/env python2 +#!/usr/bin/env python # # Adds DuckDuckGo bang as searchengine. # @@ -8,14 +8,21 @@ # Example: # :spawn --userscript ripbang amazon maps # -import os, re, requests, sys, urllib + +from __future__ import print_function +import os, re, requests, sys + +try: + from urllib.parse import unquote +except ImportError: + from urllib import unquote for argument in sys.argv[1:]: bang = '!' + argument r = requests.get('https://duckduckgo.com/', params={'q': bang + ' SEARCHTEXT'}) - searchengine = urllib.unquote(re.search("url=[^']+", r.text).group(0)) + searchengine = unquote(re.search("url=[^']+", r.text).group(0)) searchengine = searchengine.replace('url=', '') searchengine = searchengine.replace('/l/?kh=-1&uddg=', '') searchengine = searchengine.replace('SEARCHTEXT', '{}') @@ -24,4 +31,4 @@ for argument in sys.argv[1:]: with open(os.environ['QUTE_FIFO'], 'w') as fifo: fifo.write('set searchengines %s %s' % (bang, searchengine)) else: - print '%s %s' % (bang, searchengine) + print('%s %s' % (bang, searchengine)) diff --git a/pytest.ini b/pytest.ini index b853c8ca8..c35328cd6 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -addopts = --strict -rfEw --faulthandler-timeout=70 --instafail --pythonwarnings error --benchmark-columns=Min,Max,Median +addopts = --strict -rfEw --faulthandler-timeout=90 --instafail --pythonwarnings error --benchmark-columns=Min,Max,Median testpaths = tests markers = gui: Tests using the GUI (e.g. spawning widgets) diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 5bca765eb..b2654e2ba 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -17,7 +17,25 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Initialization of qutebrowser and application-wide things.""" +"""Initialization of qutebrowser and application-wide things. + +The run() function will get called once early initialization (in +qutebrowser.py/earlyinit.py) is done. See the qutebrowser.py docstring for +details about early initialization. + +As we need to access the config before the QApplication is created, we +initialize everything the config needs before the QApplication is created, and +then leave it in a partially initialized state (no saving, no config errors +shown yet). + +We then set up the QApplication object and initialize a few more low-level +things. + +After that, init() and _init_modules() take over and initialize the rest. + +After all initialization is done, the qt_mainloop() function is called, which +blocks and spins the Qt mainloop. +""" import os import sys @@ -41,9 +59,10 @@ except ImportError: import qutebrowser import qutebrowser.resources +from qutebrowser.completion import completiondelegate from qutebrowser.completion.models import miscmodels from qutebrowser.commands import cmdutils, runners, cmdexc -from qutebrowser.config import config, websettings, configexc, configfiles +from qutebrowser.config import config, websettings, configfiles, configinit from qutebrowser.browser import (urlmarks, adblock, history, browsertab, downloads) from qutebrowser.browser.network import proxy @@ -52,7 +71,8 @@ from qutebrowser.browser.webkit.network import networkmanager from qutebrowser.keyinput import macros from qutebrowser.mainwindow import mainwindow, prompt from qutebrowser.misc import (readline, ipc, savemanager, sessions, - crashsignal, earlyinit, sql, cmdhistory) + crashsignal, earlyinit, sql, cmdhistory, + backendproblem) from qutebrowser.utils import (log, version, message, utils, urlutils, objreg, usertypes, standarddir, error) # pylint: disable=unused-import @@ -77,7 +97,7 @@ def run(args): standarddir.init(args) log.init.debug("Initializing config...") - config.early_init(args) + configinit.early_init(args) global qApp qApp = Application(args) @@ -186,12 +206,6 @@ def _init_icon(): def _process_args(args): """Open startpage etc. and process commandline args.""" - for opt, val in args.temp_settings: - try: - config.instance.set_str(opt, val) - except configexc.Error as e: - message.error("set: {} - {}".format(e.__class__.__name__, e)) - if not args.override_restore: _load_session(args.session) session_manager = objreg.get('session-manager') @@ -387,13 +401,16 @@ def _init_modules(args, crash_handler): crash_handler: The CrashHandler instance. """ # pylint: disable=too-many-statements - log.init.debug("Initializing prompts...") - prompt.init() - log.init.debug("Initializing save manager...") save_manager = savemanager.SaveManager(qApp) objreg.register('save-manager', save_manager) - config.late_init(save_manager) + configinit.late_init(save_manager) + + log.init.debug("Checking backend requirements...") + backendproblem.init() + + log.init.debug("Initializing prompts...") + prompt.init() log.init.debug("Initializing network...") networkmanager.init() @@ -408,11 +425,14 @@ def _init_modules(args, crash_handler): log.init.debug("Initializing sql...") try: sql.init(os.path.join(standarddir.data(), 'history.sqlite')) - except sql.SqlException as e: + except sql.SqlError as e: error.handle_fatal_exc(e, args, 'Error initializing SQL', pre_text='Error initializing SQL') sys.exit(usertypes.Exit.err_init) + log.init.debug("Initializing completion...") + completiondelegate.init() + log.init.debug("Initializing command history...") cmdhistory.init() @@ -504,12 +524,13 @@ class Quitter: with tokenize.open(os.path.join(dirpath, fn)) as f: compile(f.read(), fn, 'exec') - def _get_restart_args(self, pages=(), session=None): + def _get_restart_args(self, pages=(), session=None, override_args=None): """Get the current working directory and args to relaunch qutebrowser. Args: pages: The pages to re-open. session: The session to load, or None. + override_args: Argument overrides as a dict. Return: An (args, cwd) tuple. @@ -560,6 +581,9 @@ class Quitter: argdict['temp_basedir'] = False argdict['temp_basedir_restarted'] = True + if override_args is not None: + argdict.update(override_args) + # Dump the data data = json.dumps(argdict) args += ['--json-args', data] @@ -584,7 +608,7 @@ class Quitter: if ok: self.shutdown(restart=True) - def restart(self, pages=(), session=None): + def restart(self, pages=(), session=None, override_args=None): """Inner logic to restart qutebrowser. The "better" way to restart is to pass a session (_restart usually) as @@ -597,6 +621,7 @@ class Quitter: Args: pages: A list of URLs to open. session: The session to load, or None. + override_args: Argument overrides as a dict. Return: True if the restart succeeded, False otherwise. @@ -606,13 +631,19 @@ class Quitter: log.destroy.debug("sys.path: {}".format(sys.path)) log.destroy.debug("sys.argv: {}".format(sys.argv)) log.destroy.debug("frozen: {}".format(hasattr(sys, 'frozen'))) + # Save the session if one is given. if session is not None: session_manager = objreg.get('session-manager') session_manager.save(session, with_private=True) + + # Make sure we're not accepting a connection from the new process + # before we fully exited. + ipc.server.shutdown() + # Open a new process and immediately shutdown the existing one try: - args, cwd = self._get_restart_args(pages, session) + args, cwd = self._get_restart_args(pages, session, override_args) if cwd is None: subprocess.Popen(args) else: @@ -700,7 +731,7 @@ class Quitter: QApplication.closeAllWindows() # Shut down IPC try: - objreg.get('ipc-server').shutdown() + ipc.server.shutdown() except KeyError: pass # Save everything @@ -762,7 +793,7 @@ class Application(QApplication): """ self._last_focus_object = None - qt_args = config.qt_args(args) + qt_args = configinit.qt_args(args) log.init.debug("Qt arguments: {}, based on {}".format(qt_args, args)) super().__init__(qt_args) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 0b21b6b05..34a28756e 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -716,7 +716,7 @@ class AbstractTab(QWidget): self._set_load_status(usertypes.LoadStatus.loading) self.load_started.emit() - def _handle_auto_insert_mode(self, ok): + def handle_auto_insert_mode(self, ok): """Handle `input.insert_mode.auto_load` after loading finished.""" if not config.val.input.insert_mode.auto_load or not ok: return @@ -753,7 +753,6 @@ class AbstractTab(QWidget): self.load_finished.emit(ok) if not self.title(): self.title_changed.emit(self.url().toDisplayString()) - self._handle_auto_insert_mode(ok) @pyqtSlot() def _on_history_trigger(self): diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index f1dcc19e8..c0c06c8ec 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -256,7 +256,7 @@ class CommandDispatcher: def tab_pin(self, count=None): """Pin/Unpin the current/[count]th tab. - Pinning a tab shrinks it to `tabs.width.pinned` size. + Pinning a tab shrinks it to the size of its title text. Attempting to close a pinned tab will cause a confirmation, unless --force is passed. @@ -688,7 +688,7 @@ class CommandDispatcher: scope='window') @cmdutils.argument('count', count=True) @cmdutils.argument('horizontal', flag='x') - def scroll_perc(self, perc: float = None, horizontal=False, count=None): + def scroll_to_perc(self, perc: float = None, horizontal=False, count=None): """Scroll to a specific percentage of the page. The percentage can be given either as argument or as count. @@ -1011,29 +1011,38 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('index', completion=miscmodels.buffer) - def buffer(self, index): + @cmdutils.argument('count', count=True) + def buffer(self, index=None, count=None): """Select tab by index or url/title best match. - Focuses window if necessary. + Focuses window if necessary when index is given. If both index and + count are given, use count. Args: index: The [win_id/]index of the tab to focus. Or a substring in which case the closest match will be focused. + count: The tab index to focus, starting with 1. """ - index_parts = index.split('/', 1) + if count is not None: + index_parts = [count] + elif index is None: + raise cmdexc.CommandError("buffer: Either a count or the argument " + "index must be specified.") + else: + index_parts = index.split('/', 1) - try: - for part in index_parts: - int(part) - except ValueError: - model = miscmodels.buffer() - model.set_pattern(index) - if model.count() > 0: - index = model.data(model.first_item()) - index_parts = index.split('/', 1) - else: - raise cmdexc.CommandError( - "No matching tab for: {}".format(index)) + try: + for part in index_parts: + int(part) + except ValueError: + model = miscmodels.buffer() + model.set_pattern(index) + if model.count() > 0: + index = model.data(model.first_item()) + index_parts = index.split('/', 1) + else: + raise cmdexc.CommandError( + "No matching tab for: {}".format(index)) if len(index_parts) == 2: win_id = int(index_parts[0]) diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index c9609bc03..2c4c1f226 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -40,7 +40,10 @@ class CompletionHistory(sql.SqlTable): def __init__(self, parent=None): super().__init__("CompletionHistory", ['url', 'title', 'last_atime'], - constraints={'url': 'PRIMARY KEY'}, parent=parent) + constraints={'url': 'PRIMARY KEY', + 'title': 'NOT NULL', + 'last_atime': 'NOT NULL'}, + parent=parent) self.create_index('CompletionHistoryAtimeIndex', 'last_atime') @@ -50,6 +53,10 @@ class WebHistory(sql.SqlTable): def __init__(self, parent=None): super().__init__("History", ['url', 'title', 'atime', 'redirect'], + constraints={'url': 'NOT NULL', + 'title': 'NOT NULL', + 'atime': 'NOT NULL', + 'redirect': 'NOT NULL'}, parent=parent) self.completion = CompletionHistory(parent=self) if sql.Query('pragma user_version').run().value() < _USER_VERSION: @@ -252,10 +259,7 @@ class WebHistory(sql.SqlTable): except ValueError as ex: message.error('Failed to import history: {}'.format(ex)) else: - bakpath = path + '.bak' - message.info('History import complete. Moving {} to {}' - .format(path, bakpath)) - os.rename(path, bakpath) + self._write_backup(path) # delay to give message time to appear before locking down for import message.info('Converting {} to sqlite...'.format(path)) @@ -287,6 +291,16 @@ class WebHistory(sql.SqlTable): self.insert_batch(data) self.completion.insert_batch(completion_data, replace=True) + def _write_backup(self, path): + bak = path + '.bak' + message.info('History import complete. Appending {} to {}' + .format(path, bak)) + with open(path, 'r', encoding='utf-8') as infile: + with open(bak, 'a', encoding='utf-8') as outfile: + for line in infile: + outfile.write('\n' + line) + os.remove(path) + def _format_url(self, url): return url.toString(QUrl.RemovePassword | QUrl.FullyEncoded) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 6bf27e7d8..b49520002 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -408,11 +408,15 @@ def qute_settings(url): @add_handler('configdiff') -def qute_configdiff(_url): +def qute_configdiff(url): """Handler for qute://configdiff.""" - try: - return 'text/html', configdiff.get_diff() - except OSError as e: - error = (b'Failed to read old config: ' + - str(e.strerror).encode('utf-8')) - return 'text/plain', error + if url.path() == '/old': + try: + return 'text/html', configdiff.get_diff() + except OSError as e: + error = (b'Failed to read old config: ' + + str(e.strerror).encode('utf-8')) + return 'text/plain', error + else: + data = config.instance.dump_userconfig().encode('utf-8') + return 'text/plain', data diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index c2ae7a018..4248be03c 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -194,7 +194,7 @@ def _set_http_headers(profile): def _update_settings(option): """Update global settings when qwebsettings changed.""" websettings.update_mappings(MAPPINGS, option) - if option in ['scrollbar.hide', 'content.user_stylesheets']: + if option in ['scrolling.bar', 'content.user_stylesheets']: _init_stylesheet(default_profile) _init_stylesheet(private_profile) elif option in ['content.headers.user_agent', diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 7dd8b0629..4de4bf26f 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -19,9 +19,9 @@ """Wrapper over a QWebEngineView.""" -import os import math import functools +import html as html_utils import sip from PyQt5.QtCore import pyqtSlot, Qt, QEvent, QPoint, QUrl, QTimer @@ -37,7 +37,7 @@ from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory, webenginesettings) from qutebrowser.misc import miscwidgets from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils, - objreg, jinja, debug, version) + message, objreg, jinja, debug) _qute_scheme_handler = None @@ -49,16 +49,8 @@ def init(): # won't work... # https://www.riverbankcomputing.com/pipermail/pyqt/2016-September/038075.html global _qute_scheme_handler + app = QApplication.instance() - - software_rendering = (os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1' or - 'QT_XCB_FORCE_SOFTWARE_OPENGL' in os.environ) - if version.opengl_vendor() == 'nouveau' and not software_rendering: - # FIXME:qtwebengine display something more sophisticated here - raise browsertab.WebTabError( - "QtWebEngine is not supported with Nouveau graphics (unless " - "QT_XCB_FORCE_SOFTWARE_OPENGL is set as environment variable).") - log.init.debug("Initializing qute://* handler...") _qute_scheme_handler = webenginequtescheme.QuteSchemeHandler(parent=app) _qute_scheme_handler.install(webenginesettings.default_profile) @@ -678,6 +670,32 @@ class WebEngineTab(browsertab.AbstractTab): self.add_history_item.emit(url, requested_url, title) + @pyqtSlot(QUrl, 'QAuthenticator*', 'QString') + def _on_proxy_authentication_required(self, url, authenticator, + proxy_host): + """Called when a proxy needs authentication.""" + msg = "{} requires a username and password.".format( + html_utils.escape(proxy_host)) + answer = message.ask( + title="Proxy authentication required", text=msg, + mode=usertypes.PromptMode.user_pwd, + abort_on=[self.shutting_down, self.load_started]) + if answer is not None: + authenticator.setUser(answer.user) + authenticator.setPassword(answer.password) + else: + try: + # pylint: disable=no-member, useless-suppression + sip.assign(authenticator, QAuthenticator()) + except AttributeError: + url_string = url.toDisplayString() + error_page = jinja.render( + 'error.html', + title="Error loading page: {}".format(url_string), + url=url_string, error="Proxy authentication required", + icon='') + self.set_html(error_page) + @pyqtSlot(QUrl, 'QAuthenticator*') def _on_authentication_required(self, url, authenticator): # FIXME:qtwebengine support .netrc @@ -755,6 +773,8 @@ class WebEngineTab(browsertab.AbstractTab): page.loadFinished.connect(self._on_load_finished) page.certificate_error.connect(self._on_ssl_errors) page.authenticationRequired.connect(self._on_authentication_required) + page.proxyAuthenticationRequired.connect( + self._on_proxy_authentication_required) page.fullScreenRequested.connect(self._on_fullscreen_requested) page.contentsSizeChanged.connect(self.contents_size_changed) diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index fd9b2382f..9baa4efe7 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -515,3 +515,7 @@ class Command: raise cmdexc.PrerequisitesError( "{}: This command is only allowed in {} mode, not {}.".format( self.name, mode_names, mode.name)) + + def takes_count(self): + """Return true iff this command can take a count argument.""" + return any(arg.count for arg in self._qute_args) diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 854231305..efd6b3490 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -154,6 +154,9 @@ class Completer(QObject): "partitioned: {} '{}' {}".format(prefix, center, postfix)) return prefix, center, postfix + # We should always return above + assert False, parts + @pyqtSlot(str) def on_selection_changed(self, text): """Change the completed part if a new item was selected. diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py index c59d81022..8248b3745 100644 --- a/qutebrowser/completion/completiondelegate.py +++ b/qutebrowser/completion/completiondelegate.py @@ -34,6 +34,9 @@ from qutebrowser.config import config from qutebrowser.utils import qtutils, jinja +_cached_stylesheet = None + + class CompletionItemDelegate(QStyledItemDelegate): """Delegate used by CompletionView to draw individual items. @@ -189,14 +192,8 @@ class CompletionItemDelegate(QStyledItemDelegate): self._doc.setDefaultTextOption(text_option) self._doc.setDocumentMargin(2) - stylesheet = """ - .highlight { - color: {{ conf.colors.completion.match.fg }}; - } - """ - with jinja.environment.no_autoescape(): - template = jinja.environment.from_string(stylesheet) - self._doc.setDefaultStyleSheet(template.render(conf=config.val)) + assert _cached_stylesheet is not None + self._doc.setDefaultStyleSheet(_cached_stylesheet) if index.parent().isValid(): view = self.parent() @@ -283,3 +280,24 @@ class CompletionItemDelegate(QStyledItemDelegate): self._draw_focus_rect() self._painter.restore() + + +@config.change_filter('colors.completion.match.fg', function=True) +def _update_stylesheet(): + """Update the cached stylesheet.""" + stylesheet = """ + .highlight { + color: {{ conf.colors.completion.match.fg }}; + } + """ + with jinja.environment.no_autoescape(): + template = jinja.environment.from_string(stylesheet) + + global _cached_stylesheet + _cached_stylesheet = template.render(conf=config.val) + + +def init(): + """Initialize the cached stylesheet.""" + _update_stylesheet() + config.instance.changed.connect(_update_stylesheet) diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index 78ec53338..ffa9bf4db 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -21,7 +21,7 @@ from qutebrowser.config import configdata, configexc from qutebrowser.completion.models import completionmodel, listcategory, util -from qutebrowser.commands import runners +from qutebrowser.commands import runners, cmdexc def option(*, info): @@ -44,7 +44,7 @@ def value(optname, *_values, info): model = completionmodel.CompletionModel(column_widths=(30, 70, 0)) try: - current = info.config.get_str(optname) or '""' + current = info.config.get_str(optname) except configexc.NoOptionError: return None @@ -72,8 +72,12 @@ def bind(key, *, info): if cmd_text: parser = runners.CommandParser() - cmd = parser.parse(cmd_text).cmd - data = [(cmd_text, cmd.desc, key)] + try: + cmd = parser.parse(cmd_text).cmd + except cmdexc.NoSuchCommandError: + data = [(cmd_text, 'Invalid command!', key)] + else: + data = [(cmd_text, cmd.desc, key)] model.add_category(listcategory.ListCategory("Current", data)) cmdlist = util.get_cmd_completions(info, include_hidden=True, diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 1efc96856..d6dbd1e86 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -19,20 +19,15 @@ """Configuration storage and config-related utilities.""" -import sys import copy import contextlib import functools -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl -from PyQt5.QtWidgets import QMessageBox +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject -from qutebrowser.config import configdata, configexc, configtypes, configfiles -from qutebrowser.utils import (utils, objreg, message, log, usertypes, jinja, - qtutils) -from qutebrowser.misc import objects, msgbox, earlyinit -from qutebrowser.commands import cmdexc, cmdutils, runners -from qutebrowser.completion.models import configmodel +from qutebrowser.config import configdata, configexc +from qutebrowser.utils import utils, log, jinja +from qutebrowser.misc import objects # An easy way to access the config from other code via config.val.foo val = None @@ -40,9 +35,7 @@ instance = None key_instance = None # Keeping track of all change filters to validate them later. -_change_filters = [] -# Errors which happened during init, so we can show a message box. -_init_errors = [] +change_filters = [] class change_filter: # pylint: disable=invalid-name @@ -68,7 +61,7 @@ class change_filter: # pylint: disable=invalid-name """ self._option = option self._function = function - _change_filters.append(self) + change_filters.append(self) def validate(self): """Make sure the configured option or prefix exists. @@ -175,26 +168,11 @@ class KeyConfig: bindings = self.get_bindings_for(mode) return bindings.get(key, None) - def bind(self, key, command, *, mode, force=False, save_yaml=False): + def bind(self, key, command, *, mode, save_yaml=False): """Add a new binding from key to command.""" key = self._prepare(key, mode) - - parser = runners.CommandParser() - try: - results = parser.parse_all(command) - except cmdexc.Error as e: - raise configexc.KeybindingError("Invalid command: {}".format(e)) - - for result in results: # pragma: no branch - try: - result.cmd.validate_mode(usertypes.KeyMode[mode]) - except cmdexc.PrerequisitesError as e: - raise configexc.KeybindingError(str(e)) - log.keyboard.vdebug("Adding binding {} -> {} in mode {}.".format( key, command, mode)) - if key in self.get_bindings_for(mode) and not force: - raise configexc.DuplicateKeyError(key) bindings = self._config.get_obj('bindings.commands') if mode not in bindings: @@ -223,145 +201,6 @@ class KeyConfig: self._config.update_mutables(save_yaml=save_yaml) -class ConfigCommands: - - """qutebrowser commands related to the configuration.""" - - def __init__(self, config, keyconfig): - self._config = config - self._keyconfig = keyconfig - - @cmdutils.register(instance='config-commands', star_args_optional=True) - @cmdutils.argument('option', completion=configmodel.option) - @cmdutils.argument('values', completion=configmodel.value) - @cmdutils.argument('win_id', win_id=True) - def set(self, win_id, option=None, *values, temp=False, print_=False): - """Set an option. - - If the option name ends with '?', the value of the option is shown - instead. - - If the option name ends with '!' and it is a boolean value, toggle it. - - Args: - option: The name of the option. - values: The value to set, or the values to cycle through. - temp: Set value temporarily until qutebrowser is closed. - print_: Print the value after setting. - """ - if option is None: - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=win_id) - tabbed_browser.openurl(QUrl('qute://settings'), newtab=False) - return - - if option.endswith('?') and option != '?': - self._print_value(option[:-1]) - return - - with self._handle_config_error(): - if option.endswith('!') and option != '!' and not values: - # Handle inversion as special cases of the cycle code path - option = option[:-1] - opt = self._config.get_opt(option) - if isinstance(opt.typ, configtypes.Bool): - values = ['false', 'true'] - else: - raise cmdexc.CommandError( - "set: Can't toggle non-bool setting {}".format(option)) - elif not values: - raise cmdexc.CommandError("set: The following arguments " - "are required: value") - self._set_next(option, values, temp=temp) - - if print_: - self._print_value(option) - - def _print_value(self, option): - """Print the value of the given option.""" - with self._handle_config_error(): - value = self._config.get_str(option) - message.info("{} = {}".format(option, value)) - - def _set_next(self, option, values, *, temp): - """Set the next value out of a list of values.""" - if len(values) == 1: - # If we have only one value, just set it directly (avoid - # breaking stuff like aliases or other pseudo-settings) - self._config.set_str(option, values[0], save_yaml=not temp) - return - - # Use the next valid value from values, or the first if the current - # value does not appear in the list - old_value = self._config.get_str(option) - try: - idx = values.index(str(old_value)) - idx = (idx + 1) % len(values) - value = values[idx] - except ValueError: - value = values[0] - self._config.set_str(option, value, save_yaml=not temp) - - @contextlib.contextmanager - def _handle_config_error(self): - """Catch errors in set_command and raise CommandError.""" - try: - yield - except configexc.Error as e: - raise cmdexc.CommandError("set: {}".format(e)) - - @cmdutils.register(instance='config-commands', maxsplit=1, - no_cmd_split=True, no_replace_variables=True) - @cmdutils.argument('command', completion=configmodel.bind) - def bind(self, key, command=None, *, mode='normal', force=False): - """Bind a key to a command. - - Args: - key: The keychain or special key (inside `<...>`) to bind. - command: The command to execute, with optional args, or None to - print the current binding. - mode: A comma-separated list of modes to bind the key in - (default: `normal`). See `:help bindings.commands` for the - available modes. - force: Rebind the key if it is already bound. - """ - if command is None: - if utils.is_special_key(key): - # self._keyconfig.get_command does this, but we also need it - # normalized for the output below - key = utils.normalize_keystr(key) - cmd = self._keyconfig.get_command(key, mode) - if cmd is None: - message.info("{} is unbound in {} mode".format(key, mode)) - else: - message.info("{} is bound to '{}' in {} mode".format( - key, cmd, mode)) - return - - try: - self._keyconfig.bind(key, command, mode=mode, force=force, - save_yaml=True) - except configexc.DuplicateKeyError as e: - raise cmdexc.CommandError("bind: {} - use --force to override!" - .format(e)) - except configexc.KeybindingError as e: - raise cmdexc.CommandError("bind: {}".format(e)) - - @cmdutils.register(instance='config-commands') - def unbind(self, key, *, mode='normal'): - """Unbind a keychain. - - Args: - key: The keychain or special key (inside <...>) to unbind. - mode: A mode to unbind the key in (default: `normal`). - See `:help bindings.commands` for the available modes. - """ - try: - self._keyconfig.unbind(key, mode=mode, save_yaml=True) - except configexc.KeybindingError as e: - raise cmdexc.CommandError('unbind: {}'.format(e)) - - class Config(QObject): """Main config object. @@ -399,7 +238,7 @@ class Config(QObject): raise configexc.BackendError(objects.backend) opt.typ.to_py(value) # for validation - self._values[opt.name] = value + self._values[opt.name] = opt.typ.from_obj(value) self.changed.emit(opt.name) log.config.debug("Config option changed: {} = {}".format( @@ -478,6 +317,32 @@ class Config(QObject): if save_yaml: self._yaml[name] = converted + def unset(self, name, *, save_yaml=False): + """Set the given setting back to its default.""" + self.get_opt(name) + try: + del self._values[name] + except KeyError: + return + self.changed.emit(name) + + if save_yaml: + self._yaml.unset(name) + + def clear(self, *, save_yaml=False): + """Clear all settings in the config. + + If save_yaml=True is given, also remove all customization from the YAML + file. + """ + old_values = self._values + self._values = {} + for name in old_values: + self.changed.emit(name) + + if save_yaml: + self._yaml.clear() + def update_mutables(self, *, save_yaml=False): """Update mutable settings if they changed. @@ -647,114 +512,3 @@ class StyleSheetObserver(QObject): self._obj.setStyleSheet(qss) if update: instance.changed.connect(self._update_stylesheet) - - -def early_init(args): - """Initialize the part of the config which works without a QApplication.""" - configdata.init() - - yaml_config = configfiles.YamlConfig() - - global val, instance, key_instance - instance = Config(yaml_config=yaml_config) - val = ConfigContainer(instance) - key_instance = KeyConfig(instance) - - for cf in _change_filters: - cf.validate() - - configtypes.Font.monospace_fonts = val.fonts.monospace - - config_commands = ConfigCommands(instance, key_instance) - objreg.register('config-commands', config_commands) - - config_api = None - - try: - config_api = configfiles.read_config_py() - # Raised here so we get the config_api back. - if config_api.errors: - raise configexc.ConfigFileErrors('config.py', config_api.errors) - except configexc.ConfigFileErrors as e: - log.config.exception("Error while loading config.py") - _init_errors.append(e) - - try: - if getattr(config_api, 'load_autoconfig', True): - try: - instance.read_yaml() - except configexc.ConfigFileErrors as e: - raise # caught in outer block - except configexc.Error as e: - desc = configexc.ConfigErrorDesc("Error", e) - raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) - except configexc.ConfigFileErrors as e: - log.config.exception("Error while loading config.py") - _init_errors.append(e) - - configfiles.init() - - objects.backend = get_backend(args) - earlyinit.init_with_backend(objects.backend) - - -def get_backend(args): - """Find out what backend to use based on available libraries.""" - try: - import PyQt5.QtWebKit # pylint: disable=unused-variable - except ImportError: - webkit_available = False - else: - webkit_available = qtutils.is_new_qtwebkit() - - str_to_backend = { - 'webkit': usertypes.Backend.QtWebKit, - 'webengine': usertypes.Backend.QtWebEngine, - } - - if args.backend is not None: - return str_to_backend[args.backend] - elif val.backend != 'auto': - return str_to_backend[val.backend] - elif webkit_available: - return usertypes.Backend.QtWebKit - else: - return usertypes.Backend.QtWebEngine - - -def late_init(save_manager): - """Initialize the rest of the config after the QApplication is created.""" - global _init_errors - for err in _init_errors: - errbox = msgbox.msgbox(parent=None, - title="Error while reading config", - text=err.to_html(), - icon=QMessageBox.Warning, - plain_text=False) - errbox.exec_() - _init_errors = [] - - instance.init_save_manager(save_manager) - configfiles.state.init_save_manager(save_manager) - - -def qt_args(namespace): - """Get the Qt QApplication arguments based on an argparse namespace. - - Args: - namespace: The argparse namespace. - - Return: - The argv list to be passed to Qt. - """ - argv = [sys.argv[0]] - - if namespace.qt_flag is not None: - argv += ['--' + flag[0] for flag in namespace.qt_flag] - - if namespace.qt_arg is not None: - for name, value in namespace.qt_arg: - argv += ['--' + name, value] - - argv += ['--' + arg for arg in val.qt_args] - return argv diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py new file mode 100644 index 000000000..0eb93387e --- /dev/null +++ b/qutebrowser/config/configcommands.py @@ -0,0 +1,248 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2017 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Commands related to the configuration.""" + +import os.path +import contextlib + +from PyQt5.QtCore import QUrl + +from qutebrowser.commands import cmdexc, cmdutils +from qutebrowser.completion.models import configmodel +from qutebrowser.utils import objreg, utils, message, standarddir +from qutebrowser.config import configtypes, configexc, configfiles +from qutebrowser.misc import editor + + +class ConfigCommands: + + """qutebrowser commands related to the configuration.""" + + def __init__(self, config, keyconfig): + self._config = config + self._keyconfig = keyconfig + + @contextlib.contextmanager + def _handle_config_error(self): + """Catch errors in set_command and raise CommandError.""" + try: + yield + except configexc.Error as e: + raise cmdexc.CommandError("set: {}".format(e)) + + def _print_value(self, option): + """Print the value of the given option.""" + with self._handle_config_error(): + value = self._config.get_str(option) + message.info("{} = {}".format(option, value)) + + @cmdutils.register(instance='config-commands') + @cmdutils.argument('option', completion=configmodel.option) + @cmdutils.argument('value', completion=configmodel.value) + @cmdutils.argument('win_id', win_id=True) + def set(self, win_id, option=None, value=None, temp=False, print_=False): + """Set an option. + + If the option name ends with '?', the value of the option is shown + instead. + + Args: + option: The name of the option. + value: The value to set. + temp: Set value temporarily until qutebrowser is closed. + print_: Print the value after setting. + """ + if option is None: + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + tabbed_browser.openurl(QUrl('qute://settings'), newtab=False) + return + + if option.endswith('?') and option != '?': + self._print_value(option[:-1]) + return + + with self._handle_config_error(): + if value is None: + raise cmdexc.CommandError("set: The following arguments " + "are required: value") + else: + self._config.set_str(option, value, save_yaml=not temp) + + if print_: + self._print_value(option) + + @cmdutils.register(instance='config-commands', maxsplit=1, + no_cmd_split=True, no_replace_variables=True) + @cmdutils.argument('command', completion=configmodel.bind) + def bind(self, key, command=None, *, mode='normal'): + """Bind a key to a command. + + Args: + key: The keychain or special key (inside `<...>`) to bind. + command: The command to execute, with optional args, or None to + print the current binding. + mode: A comma-separated list of modes to bind the key in + (default: `normal`). See `:help bindings.commands` for the + available modes. + """ + if command is None: + if utils.is_special_key(key): + # self._keyconfig.get_command does this, but we also need it + # normalized for the output below + key = utils.normalize_keystr(key) + cmd = self._keyconfig.get_command(key, mode) + if cmd is None: + message.info("{} is unbound in {} mode".format(key, mode)) + else: + message.info("{} is bound to '{}' in {} mode".format( + key, cmd, mode)) + return + + try: + self._keyconfig.bind(key, command, mode=mode, save_yaml=True) + except configexc.KeybindingError as e: + raise cmdexc.CommandError("bind: {}".format(e)) + + @cmdutils.register(instance='config-commands') + def unbind(self, key, *, mode='normal'): + """Unbind a keychain. + + Args: + key: The keychain or special key (inside <...>) to unbind. + mode: A mode to unbind the key in (default: `normal`). + See `:help bindings.commands` for the available modes. + """ + try: + self._keyconfig.unbind(key, mode=mode, save_yaml=True) + except configexc.KeybindingError as e: + raise cmdexc.CommandError('unbind: {}'.format(e)) + + @cmdutils.register(instance='config-commands', star_args_optional=True) + @cmdutils.argument('option', completion=configmodel.option) + @cmdutils.argument('values', completion=configmodel.value) + def config_cycle(self, option, *values, temp=False, print_=False): + """Cycle an option between multiple values. + + Args: + option: The name of the option. + values: The values to cycle through. + temp: Set value temporarily until qutebrowser is closed. + print_: Print the value after setting. + """ + with self._handle_config_error(): + opt = self._config.get_opt(option) + old_value = self._config.get_obj(option, mutable=False) + + if not values and isinstance(opt.typ, configtypes.Bool): + values = ['true', 'false'] + + if len(values) < 2: + raise cmdexc.CommandError("Need at least two values for " + "non-boolean settings.") + + # Use the next valid value from values, or the first if the current + # value does not appear in the list + with self._handle_config_error(): + values = [opt.typ.from_str(val) for val in values] + + try: + idx = values.index(old_value) + idx = (idx + 1) % len(values) + value = values[idx] + except ValueError: + value = values[0] + + with self._handle_config_error(): + self._config.set_obj(option, value, save_yaml=not temp) + + if print_: + self._print_value(option) + + @cmdutils.register(instance='config-commands') + @cmdutils.argument('option', completion=configmodel.option) + def config_unset(self, option, temp=False): + """Unset an option. + + This sets an option back to its default and removes it from + autoconfig.yml. + + Args: + option: The name of the option. + temp: Don't touch autoconfig.yml. + """ + with self._handle_config_error(): + self._config.unset(option, save_yaml=not temp) + + @cmdutils.register(instance='config-commands') + def config_clear(self, save=False): + """Set all settings back to their default. + + Args: + save: If given, all configuration in autoconfig.yml is also + removed. + """ + self._config.clear(save_yaml=save) + + @cmdutils.register(instance='config-commands') + def config_source(self, filename=None, clear=False): + """Read a config.py file. + + Args: + filename: The file to load. If not given, loads the default + config.py. + clear: Clear current settings first. + """ + if filename is None: + filename = os.path.join(standarddir.config(), 'config.py') + else: + filename = os.path.expanduser(filename) + + if clear: + self.config_clear() + + try: + configfiles.read_config_py(filename) + except configexc.ConfigFileErrors as e: + raise cmdexc.CommandError(e) + + @cmdutils.register(instance='config-commands') + def config_edit(self, no_source=False): + """Open the config.py file in the editor. + + Args: + no_source: Don't re-source the config file after editing. + """ + def on_editing_finished(): + """Source the new config when editing finished. + + This can't use cmdexc.CommandError as it's run async. + """ + try: + configfiles.read_config_py(filename) + except configexc.ConfigFileErrors as e: + message.error(str(e)) + + ed = editor.ExternalEditor(self._config) + if not no_source: + ed.editing_finished.connect(on_editing_finished) + + filename = os.path.join(standarddir.config(), 'config.py') + ed.edit_file(filename) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 739086628..d58168ab3 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -93,7 +93,7 @@ def _parse_yaml_type(name, node): if typ is configtypes.Dict: kwargs['keytype'] = _parse_yaml_type(name, kwargs['keytype']) kwargs['valtype'] = _parse_yaml_type(name, kwargs['valtype']) - elif typ is configtypes.List: + elif typ is configtypes.List or typ is configtypes.ListOrValue: kwargs['valtype'] = _parse_yaml_type(name, kwargs['valtype']) except KeyError as e: _raise_invalid_node(name, str(e), node) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index d051b3e8a..48e6fab6d 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -101,27 +101,40 @@ qt_args: https://peter.sh/experiments/chromium-command-line-switches/ for a list) will work. + This setting requires a restart. + +force_software_rendering: + type: Bool + default: false + backend: QtWebEngine + desc: >- + Force software rendering for QtWebEngine. + + This is needed for QtWebEngine to work with Nouveau drivers. + This setting requires a restart. + backend: type: name: String valid_values: - - auto: Automatically select either QtWebEngine or QtWebKit - - webkit: Force QtWebKit - - webengine: Force QtWebEngine - default: auto + - webengine: Use QtWebEngine (based on Chromium) + - webkit: Use QtWebKit (based on WebKit, similar to Safari) + default: webengine desc: >- The backend to use to display websites. qutebrowser supports two different web rendering engines / backends, QtWebKit and QtWebEngine. - QtWebKit is based on WebKit (similar to Safari). It was discontinued by the - Qt project with Qt 5.6, but picked up as a well maintained fork: - https://github.com/annulen/webkit/wiki - qutebrowser only supports the fork. + QtWebKit was discontinued by the Qt project with Qt 5.6, but picked up as a + well maintained fork: https://github.com/annulen/webkit/wiki - qutebrowser + only supports the fork. - QtWebEngine is Qt's official successor to QtWebKit and based on the Chromium - project. It's slightly more resource hungry that QtWebKit and has a couple - of missing features in qutebrowser, but is generally the preferred choice. + QtWebEngine is Qt's official successor to QtWebKit. It's slightly more + resource hungry that QtWebKit and has a couple of missing features in + qutebrowser, but is generally the preferred choice. + + This setting requires a restart. ## auto_save @@ -545,7 +558,7 @@ content.ssl_strict: content.user_stylesheets: type: - name: List + name: ListOrValue valtype: File none_ok: True default: null @@ -562,10 +575,12 @@ content.xss_auditing: desc: >- Whether load requests should be monitored for cross-site scripting attempts. - Suspicious scripts will be blocked and reported in the inspector\'s + Suspicious scripts will be blocked and reported in the inspector's JavaScript console. Enabling this feature might have an impact on performance. +# emacs: ' + ## completion completion.cmd_history_max_items: @@ -917,11 +932,13 @@ keyhint.blacklist: name: String default: [] desc: >- - Keychains that shouldn\'t be shown in the keyhint dialog. + Keychains that shouldn't be shown in the keyhint dialog. Globs are supported, so `;*` will blacklist all keychains starting with `;`. Use `*` to disable keyhints. +# emacs: ' + keyhint.delay: type: name: Int @@ -1243,13 +1260,6 @@ tabs.width.indicator: minval: 0 desc: Width of the progress indicator (0 to disable). -tabs.width.pinned: - default: 43 - type: - name: Int - minval: 10 - desc: The width for pinned tabs with a horizontal tabbar, in px. - tabs.wrap: default: true type: Bool @@ -1305,9 +1315,9 @@ url.searchengines: url.start_pages: type: - name: List + name: ListOrValue valtype: FuzzyUrl - default: ["https://start.duckduckgo.com"] + default: "https://start.duckduckgo.com" desc: The page(s) to open at the start. url.yank_ignored_parameters: @@ -1616,13 +1626,18 @@ colors.prompts.fg: type: QssColor desc: Foreground color for prompts. +colors.prompts.border: + default: 1px solid gray + type: String + desc: Border used around UI elements in prompts. + colors.prompts.bg: - default: darkblue + default: '#444444' type: QssColor desc: Background color for prompts. colors.prompts.selected.bg: - default: '#308cc6' + default: grey type: QssColor desc: Background color for the selected item in filename prompts. @@ -1803,9 +1818,11 @@ fonts.monospace: desc: >- Default monospace fonts. - Whenever "monospace" is used in a font setting, it\'s replaced with the + Whenever "monospace" is used in a font setting, it's replaced with the fonts listed here. +# emacs: ' + fonts.completion.entry: default: 8pt monospace type: Font @@ -1972,6 +1989,9 @@ bindings.key_mappings: This is useful for global remappings of keys, for example to map Ctrl-[ to Escape. + Note that when a key is bound (via `bindings.default` or + `bindings.commands`), the mapping is ignored. + bindings.default: default: normal: @@ -2040,8 +2060,8 @@ bindings.default: l: scroll right u: undo : undo - gg: scroll-perc 0 - G: scroll-perc + gg: scroll-to-perc 0 + G: scroll-to-perc n: search-next N: search-prev i: enter-mode insert diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index 4d283d21f..7b2d0aa04 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -59,14 +59,6 @@ class KeybindingError(Error): """Raised for issues with keybindings.""" -class DuplicateKeyError(KeybindingError): - - """Raised when there was a duplicate key.""" - - def __init__(self, key): - super().__init__("Duplicate key {}".format(key)) - - class NoOptionError(Error): """Raised when an option was not found.""" @@ -94,6 +86,12 @@ class ConfigErrorDesc: def __str__(self): return '{}: {}'.format(self.text, self.exception) + def with_text(self, text): + """Get a new ConfigErrorDesc with the given text appended.""" + return self.__class__(text='{} ({})'.format(self.text, text), + exception=self.exception, + traceback=self.traceback) + class ConfigFileErrors(Error): diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index e26a03454..e1c6f720c 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -19,18 +19,20 @@ """Configuration files residing on disk.""" +import pathlib import types import os.path +import sys import textwrap import traceback import configparser import contextlib import yaml -from PyQt5.QtCore import QSettings +from PyQt5.QtCore import pyqtSignal, QObject, QSettings import qutebrowser -from qutebrowser.config import configexc, config +from qutebrowser.config import configexc, config, configdata from qutebrowser.utils import standarddir, utils, qtutils @@ -70,7 +72,7 @@ class StateConfig(configparser.ConfigParser): self.write(f) -class YamlConfig: +class YamlConfig(QObject): """A config stored on disk as YAML file. @@ -79,8 +81,10 @@ class YamlConfig: """ VERSION = 1 + changed = pyqtSignal() - def __init__(self): + def __init__(self, parent=None): + super().__init__(parent) self._filename = os.path.join(standarddir.config(auto=True), 'autoconfig.yml') self._values = {} @@ -92,20 +96,25 @@ class YamlConfig: We do this outside of __init__ because the config gets created before the save_manager exists. """ - save_manager.add_saveable('yaml-config', self._save) + save_manager.add_saveable('yaml-config', self._save, self.changed) def __getitem__(self, name): return self._values[name] def __setitem__(self, name, value): - self._dirty = True self._values[name] = value + self._mark_changed() def __contains__(self, name): return name in self._values def __iter__(self): - return iter(self._values.items()) + return iter(sorted(self._values.items())) + + def _mark_changed(self): + """Mark the YAML config as changed.""" + self._dirty = True + self.changed.emit() def _save(self): """Save the settings to the YAML file if they've changed.""" @@ -153,9 +162,28 @@ class YamlConfig: "'global' object is not a dict") raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) + # Delete unknown values + # (e.g. options which were removed from configdata.yml) + for name in list(global_obj): + if name not in configdata.DATA: + del global_obj[name] + self._values = global_obj self._dirty = False + def unset(self, name): + """Remove the given option name if it's configured.""" + try: + del self._values[name] + except KeyError: + return + self._mark_changed() + + def clear(self): + """Clear all values from the YAML file.""" + self._values = [] + self._mark_changed() + class ConfigAPI: @@ -168,20 +196,26 @@ class ConfigAPI: Attributes: _config: The main Config object to use. _keyconfig: The KeyConfig object. - load_autoconfig: Whether autoconfig.yml should be loaded. errors: Errors which occurred while setting options. + configdir: The qutebrowser config directory, as pathlib.Path. + datadir: The qutebrowser data directory, as pathlib.Path. """ def __init__(self, conf, keyconfig): self._config = conf self._keyconfig = keyconfig - self.load_autoconfig = True self.errors = [] + self.configdir = pathlib.Path(standarddir.config()) + self.datadir = pathlib.Path(standarddir.data()) @contextlib.contextmanager def _handle_error(self, action, name): try: yield + except configexc.ConfigFileErrors as e: + for err in e.errors: + new_err = err.with_text(e.basename) + self.errors.append(new_err) except configexc.Error as e: text = "While {} '{}'".format(action, name) self.errors.append(configexc.ConfigErrorDesc(text, e)) @@ -190,6 +224,10 @@ class ConfigAPI: """Do work which needs to be done after reading config.py.""" self._config.update_mutables() + def load_autoconfig(self): + with self._handle_error('reading', 'autoconfig.yml'): + read_autoconfig() + def get(self, name): with self._handle_error('getting', name): return self._config.get_obj(name) @@ -198,24 +236,24 @@ class ConfigAPI: with self._handle_error('setting', name): self._config.set_obj(name, value) - def bind(self, key, command, mode='normal', *, force=False): + def bind(self, key, command, mode='normal'): with self._handle_error('binding', key): - self._keyconfig.bind(key, command, mode=mode, force=force) + self._keyconfig.bind(key, command, mode=mode) def unbind(self, key, mode='normal'): with self._handle_error('unbinding', key): self._keyconfig.unbind(key, mode=mode) -def read_config_py(filename=None): - """Read a config.py file.""" +def read_config_py(filename, raising=False): + """Read a config.py file. + + Arguments; + filename: The name of the file to read. + raising: Raise exceptions happening in config.py. + This is needed during tests to use pytest's inspection. + """ api = ConfigAPI(config.instance, config.key_instance) - - if filename is None: - filename = os.path.join(standarddir.config(), 'config.py') - if not os.path.exists(filename): - return api - container = config.ConfigContainer(config.instance, configapi=api) basename = os.path.basename(filename) @@ -234,7 +272,7 @@ def read_config_py(filename=None): try: code = compile(source, filename, 'exec') - except (ValueError, TypeError) as e: + except ValueError as e: # source contains NUL bytes desc = configexc.ConfigErrorDesc("Error while compiling", e) raise configexc.ConfigFileErrors(basename, [desc]) @@ -244,14 +282,51 @@ def read_config_py(filename=None): raise configexc.ConfigFileErrors(basename, [desc]) try: - exec(code, module.__dict__) + # Save and restore sys variables + with saved_sys_properties(): + # Add config directory to python path, so config.py can import + # other files in logical places + config_dir = os.path.dirname(filename) + if config_dir not in sys.path: + sys.path.insert(0, config_dir) + + exec(code, module.__dict__) except Exception as e: + if raising: + raise api.errors.append(configexc.ConfigErrorDesc( "Unhandled exception", exception=e, traceback=traceback.format_exc())) api.finalize() - return api + + if api.errors: + raise configexc.ConfigFileErrors('config.py', api.errors) + + +def read_autoconfig(): + """Read the autoconfig.yml file.""" + try: + config.instance.read_yaml() + except configexc.ConfigFileErrors as e: + raise # caught in outer block + except configexc.Error as e: + desc = configexc.ConfigErrorDesc("Error", e) + raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) + + +@contextlib.contextmanager +def saved_sys_properties(): + """Save various sys properties such as sys.path and sys.modules.""" + old_path = sys.path.copy() + old_modules = sys.modules.copy() + + try: + yield + finally: + sys.path = old_path + for module in set(sys.modules).difference(old_modules): + del sys.modules[module] def init(): diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py new file mode 100644 index 000000000..209e6428d --- /dev/null +++ b/qutebrowser/config/configinit.py @@ -0,0 +1,132 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 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 . + +"""Initialization of the configuration.""" + +import os.path +import sys + +from PyQt5.QtWidgets import QMessageBox + +from qutebrowser.config import (config, configdata, configfiles, configtypes, + configexc, configcommands) +from qutebrowser.utils import objreg, usertypes, log, standarddir, message +from qutebrowser.misc import msgbox, objects + + +# Error which happened during init, so we can show a message box. +_init_errors = None + + +def early_init(args): + """Initialize the part of the config which works without a QApplication.""" + configdata.init() + + yaml_config = configfiles.YamlConfig() + + config.instance = config.Config(yaml_config=yaml_config) + config.val = config.ConfigContainer(config.instance) + config.key_instance = config.KeyConfig(config.instance) + yaml_config.setParent(config.instance) + + for cf in config.change_filters: + cf.validate() + + configtypes.Font.monospace_fonts = config.val.fonts.monospace + + config_commands = configcommands.ConfigCommands( + config.instance, config.key_instance) + objreg.register('config-commands', config_commands) + + config_file = os.path.join(standarddir.config(), 'config.py') + + try: + if os.path.exists(config_file): + configfiles.read_config_py(config_file) + else: + configfiles.read_autoconfig() + except configexc.ConfigFileErrors as e: + log.config.exception("Error while loading {}".format(e.basename)) + global _init_errors + _init_errors = e + + configfiles.init() + + objects.backend = get_backend(args) + + for opt, val in args.temp_settings: + try: + config.instance.set_str(opt, val) + except configexc.Error as e: + message.error("set: {} - {}".format(e.__class__.__name__, e)) + + if (objects.backend == usertypes.Backend.QtWebEngine and + config.val.force_software_rendering): + os.environ['QT_XCB_FORCE_SOFTWARE_OPENGL'] = '1' + + +def get_backend(args): + """Find out what backend to use based on available libraries.""" + str_to_backend = { + 'webkit': usertypes.Backend.QtWebKit, + 'webengine': usertypes.Backend.QtWebEngine, + } + + if args.backend is not None: + return str_to_backend[args.backend] + else: + return str_to_backend[config.val.backend] + + +def late_init(save_manager): + """Initialize the rest of the config after the QApplication is created.""" + global _init_errors + if _init_errors is not None: + errbox = msgbox.msgbox(parent=None, + title="Error while reading config", + text=_init_errors.to_html(), + icon=QMessageBox.Warning, + plain_text=False) + errbox.exec_() + _init_errors = None + + config.instance.init_save_manager(save_manager) + configfiles.state.init_save_manager(save_manager) + + +def qt_args(namespace): + """Get the Qt QApplication arguments based on an argparse namespace. + + Args: + namespace: The argparse namespace. + + Return: + The argv list to be passed to Qt. + """ + argv = [sys.argv[0]] + + if namespace.qt_flag is not None: + argv += ['--' + flag[0] for flag in namespace.qt_flag] + + if namespace.qt_arg is not None: + for name, value in namespace.qt_arg: + argv += ['--' + name, value] + + argv += ['--' + arg for arg in config.val.qt_args] + return argv diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index c669ff426..afe9eb372 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -59,7 +59,7 @@ from PyQt5.QtCore import QUrl, Qt from PyQt5.QtGui import QColor, QFont from PyQt5.QtWidgets import QTabWidget, QTabBar -from qutebrowser.commands import cmdutils, runners, cmdexc +from qutebrowser.commands import cmdutils from qutebrowser.config import configexc from qutebrowser.utils import standarddir, utils, qtutils, urlutils @@ -227,6 +227,10 @@ class BaseType: return None return value + def from_obj(self, value): + """Get the setting value from a config.py/YAML object.""" + return value + def to_py(self, value): """Get the setting value from a Python value. @@ -257,9 +261,10 @@ class BaseType: This currently uses asciidoc syntax. """ utils.unused(indent) # only needed for Dict/List - if not value: + str_value = self.to_str(value) + if not str_value: return 'empty' - return '+pass:[{}]+'.format(html.escape(self.to_str(value))) + return '+pass:[{}]+'.format(html.escape(str_value)) def complete(self): """Return a list of possible values for completion. @@ -440,6 +445,11 @@ class List(BaseType): self.to_py(yaml_val) return yaml_val + def from_obj(self, value): + if value is None: + return [] + return value + def to_py(self, value): self._basic_py_validation(value, list) if not value: @@ -475,6 +485,72 @@ class List(BaseType): return '\n'.join(lines) +class ListOrValue(BaseType): + + """A list of values, or a single value. + + // + + Internally, the value is stored as either a value (of valtype), or a list. + to_py() then ensures that it's always a list. + """ + + _show_valtype = True + + def __init__(self, valtype, none_ok=False, *args, **kwargs): + super().__init__(none_ok) + assert not isinstance(valtype, (List, ListOrValue)), valtype + self.listtype = List(valtype, none_ok=none_ok, *args, **kwargs) + self.valtype = valtype + + def get_name(self): + return self.listtype.get_name() + ', or ' + self.valtype.get_name() + + def get_valid_values(self): + return self.valtype.get_valid_values() + + def from_str(self, value): + try: + return self.listtype.from_str(value) + except configexc.ValidationError: + return self.valtype.from_str(value) + + def from_obj(self, value): + if value is None: + return [] + return value + + def to_py(self, value): + try: + return [self.valtype.to_py(value)] + except configexc.ValidationError: + return self.listtype.to_py(value) + + def to_str(self, value): + if value is None: + return '' + + if isinstance(value, list): + if len(value) == 1: + return self.valtype.to_str(value[0]) + else: + return self.listtype.to_str(value) + else: + return self.valtype.to_str(value) + + def to_doc(self, value, indent=0): + if value is None: + return 'empty' + + if isinstance(value, list): + if len(value) == 1: + return self.valtype.to_doc(value[0], indent) + else: + return self.listtype.to_doc(value, indent) + else: + return self.valtype.to_doc(value, indent) + + class FlagList(List): """A list of flags. @@ -773,33 +849,13 @@ class PercOrInt(_Numeric): class Command(BaseType): - """Base class for a command value with arguments.""" + """A qutebrowser command with arguments. - # See to_py for details - unvalidated = False + // - def to_py(self, value): - self._basic_py_validation(value, str) - if not value: - return None - - # This requires some trickery, as runners.CommandParser uses - # conf.val.aliases, which in turn map to a command again, - # leading to an endless recursion. - # To fix that, we turn off validating other commands (alias values) - # while validating a command. - if not Command.unvalidated: - Command.unvalidated = True - try: - parser = runners.CommandParser() - try: - parser.parse_all(value) - except cmdexc.Error as e: - raise configexc.ValidationError(value, str(e)) - finally: - Command.unvalidated = False - - return value + Since validation is quite tricky here, we don't do so, and instead let + invalid commands (in bindings/aliases) fail when used. + """ def complete(self): out = [] @@ -807,6 +863,10 @@ class Command(BaseType): out.append((cmdname, obj.desc)) return out + def to_py(self, value): + self._basic_py_validation(value, str) + return value + class ColorSystem(MappingType): @@ -1130,6 +1190,11 @@ class Dict(BaseType): self.to_py(yaml_val) return yaml_val + def from_obj(self, value): + if value is None: + return {} + return value + def _fill_fixed_keys(self, value): """Fill missing fixed keys with a None-value.""" if self.fixed_keys is None: @@ -1260,6 +1325,8 @@ class ShellCommand(List): placeholder: If there should be a placeholder. """ + _show_valtype = False + def __init__(self, placeholder=False, none_ok=False): super().__init__(valtype=String(), none_ok=none_ok) self.placeholder = placeholder diff --git a/qutebrowser/html/settings.html b/qutebrowser/html/settings.html index 217e052af..b370c0d91 100644 --- a/qutebrowser/html/settings.html +++ b/qutebrowser/html/settings.html @@ -17,6 +17,9 @@ pre { margin: 2px; } th, td { border: 1px solid grey; padding: 0px 5px; } th { background: lightgrey; } th pre { color: grey; text-align: left; } +input { width: 98%; } +.setting { width: 75%; } +.value { width: 25%; text-align: center; } .noscript, .noscript-text { color:red; } .noscript-text { margin-bottom: 5cm; } .option_description { margin: .5ex 0; color: grey; font-size: 80%; font-style: italic; white-space: pre-line; } @@ -26,15 +29,19 @@ th pre { color: grey; text-align: left; }

{{ title }}

+ + + + {% for option in configdata.DATA.values() %} - -
SettingValue
{{ option.name }} (Current: {{ confget(option.name) | string |truncate(100) }}) + {{ option.name }} (Current: {{ confget(option.name) | string |truncate(100) }}) {% if option.description %}

{{ option.description|e }}

{% endif %}
+ . */ -/* eslint-disable max-len */ - /** * Snippet to position caret at top of the page when caret mode is enabled. * Some code was borrowed from: @@ -28,8 +26,6 @@ * https://github.com/1995eaton/chromium-vim/blob/master/content_scripts/visual.js */ -/* eslint-enable max-len */ - "use strict"; (function() { diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 9eec4b28f..2f75cbdfe 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -122,37 +122,40 @@ class BaseKeyParser(QObject): self._debug_log("Ignoring only-modifier keyeevent.") return False - key_mappings = config.val.bindings.key_mappings - try: - binding = key_mappings['<{}>'.format(binding)][1:-1] - except KeyError: - pass + if binding not in self.special_bindings: + key_mappings = config.val.bindings.key_mappings + try: + binding = key_mappings['<{}>'.format(binding)][1:-1] + except KeyError: + pass try: cmdstr = self.special_bindings[binding] except KeyError: self._debug_log("No special binding found for {}.".format(binding)) return False - count, _command = self._split_count() + count, _command = self._split_count(self._keystring) self.execute(cmdstr, self.Type.special, count) self.clear_keystring() return True - def _split_count(self): + def _split_count(self, keystring): """Get count and command from the current keystring. + Args: + keystring: The key string to split. + Return: A (count, command) tuple. """ if self._supports_count: - (countstr, cmd_input) = re.match(r'^(\d*)(.*)', - self._keystring).groups() + (countstr, cmd_input) = re.match(r'^(\d*)(.*)', keystring).groups() count = int(countstr) if countstr else None if count == 0 and not cmd_input: - cmd_input = self._keystring + cmd_input = keystring count = None else: - cmd_input = self._keystring + cmd_input = keystring count = None return count, cmd_input @@ -183,18 +186,17 @@ class BaseKeyParser(QObject): self._debug_log("Ignoring, no text char") return self.Match.none - key_mappings = config.val.bindings.key_mappings - txt = key_mappings.get(txt, txt) - self._keystring += txt - - count, cmd_input = self._split_count() - - if not cmd_input: - # Only a count, no command yet, but we handled it - return self.Match.other - + count, cmd_input = self._split_count(self._keystring + txt) match, binding = self._match_key(cmd_input) + if match == self.Match.none: + mappings = config.val.bindings.key_mappings + mapped = mappings.get(txt, None) + if mapped is not None: + txt = mapped + count, cmd_input = self._split_count(self._keystring + txt) + match, binding = self._match_key(cmd_input) + self._keystring += txt if match == self.Match.definitive: self._debug_log("Definitive match for '{}'.".format( self._keystring)) @@ -207,6 +209,8 @@ class BaseKeyParser(QObject): self._debug_log("Giving up with '{}', no matches".format( self._keystring)) self.clear_keystring() + elif match == self.Match.other: + pass else: raise AssertionError("Invalid match value {!r}".format(match)) return match @@ -223,6 +227,9 @@ class BaseKeyParser(QObject): binding: - None with Match.partial/Match.none. - The found binding with Match.definitive. """ + if not cmd_input: + # Only a count, no command yet, but we handled it + return (self.Match.other, None) # A (cmd_input, binding) tuple (k, v of bindings) or None. definitive_match = None partial_match = False diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 373a9030a..7e24c0f8d 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -437,7 +437,8 @@ class MainWindow(QWidget): # commands keyparsers[usertypes.KeyMode.normal].keystring_updated.connect( status.keystring.setText) - cmd.got_cmd.connect(self._commandrunner.run_safely) + cmd.got_cmd[str].connect(self._commandrunner.run_safely) + cmd.got_cmd[str, int].connect(self._commandrunner.run_safely) cmd.returnPressed.connect(tabs.on_cmd_return_pressed) # key hint popup diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py index 4242ceb8e..1f4c47c78 100644 --- a/qutebrowser/mainwindow/prompt.py +++ b/qutebrowser/mainwindow/prompt.py @@ -28,7 +28,8 @@ import sip from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QTimer, QDir, QModelIndex, QItemSelectionModel, QObject, QEventLoop) from PyQt5.QtWidgets import (QWidget, QGridLayout, QVBoxLayout, QLineEdit, - QLabel, QFileSystemModel, QTreeView, QSizePolicy) + QLabel, QFileSystemModel, QTreeView, QSizePolicy, + QSpacerItem) from qutebrowser.browser import downloads from qutebrowser.config import config @@ -256,11 +257,21 @@ class PromptContainer(QWidget): background-color: {{ conf.colors.prompts.bg }}; } - QTreeView { - selection-background-color: {{ conf.colors.prompts.selected.bg }}; + QLineEdit { + border: {{ conf.colors.prompts.border }}; } - QTreeView::item:selected, QTreeView::item:selected:hover { + QTreeView { + selection-background-color: {{ conf.colors.prompts.selected.bg }}; + border: {{ conf.colors.prompts.border }}; + } + + QTreeView::branch { + background-color: {{ conf.colors.prompts.bg }}; + } + + QTreeView::item:selected, QTreeView::item:selected:hover, + QTreeView::branch:selected { background-color: {{ conf.colors.prompts.selected.bg }}; } """ @@ -433,7 +444,6 @@ class LineEdit(QLineEdit): super().__init__(parent) self.setStyleSheet(""" QLineEdit { - border: 1px solid grey; background-color: transparent; } """) @@ -511,6 +521,9 @@ class _BasePrompt(QWidget): self._key_grid.addWidget(key_label, i, 0) self._key_grid.addWidget(text_label, i, 1) + spacer = QSpacerItem(0, 0, QSizePolicy.Expanding) + self._key_grid.addItem(spacer, 0, 2) + self._vbox.addLayout(self._key_grid) def accept(self, value=None): @@ -559,8 +572,7 @@ class FilenamePrompt(_BasePrompt): def __init__(self, question, parent=None): super().__init__(question, parent) self._init_texts(question) - self._init_fileview() - self._set_fileview_root(question.default) + self._init_key_label() self._lineedit = LineEdit(self) if question.default: @@ -569,7 +581,9 @@ class FilenamePrompt(_BasePrompt): self._vbox.addWidget(self._lineedit) self.setFocusProxy(self._lineedit) - self._init_key_label() + + self._init_fileview() + self._set_fileview_root(question.default) if config.val.prompt.filebrowser: self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) diff --git a/qutebrowser/mainwindow/statusbar/command.py b/qutebrowser/mainwindow/statusbar/command.py index 3647d9859..b3d7a50e6 100644 --- a/qutebrowser/mainwindow/statusbar/command.py +++ b/qutebrowser/mainwindow/statusbar/command.py @@ -38,7 +38,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): Signals: got_cmd: Emitted when a command is triggered by the user. - arg: The command string. + arg: The command string and also potentially the count. clear_completion_selection: Emitted before the completion widget is hidden. hide_completion: Emitted when the completion widget should be hidden. @@ -47,7 +47,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): hide_cmd: Emitted when command input can be hidden. """ - got_cmd = pyqtSignal(str) + got_cmd = pyqtSignal([str], [str, int]) clear_completion_selection = pyqtSignal() hide_completion = pyqtSignal() update_completion = pyqtSignal() @@ -91,7 +91,9 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): @cmdutils.register(instance='status-command', name='set-cmd-text', scope='window', maxsplit=0) - def set_cmd_text_command(self, text, space=False, append=False): + @cmdutils.argument('count', count=True) + def set_cmd_text_command(self, text, count=None, space=False, append=False, + run_on_count=False): """Preset the statusbar to some text. // @@ -101,8 +103,11 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): Args: text: The commandline to set. + count: The count if given. space: If given, a space is added to the end. append: If given, the text is appended to the current text. + run_on_count: If given with a count, the command is run with the + given count rather than setting the command text. """ if space: text += ' ' @@ -114,7 +119,10 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): if not text or text[0] not in modeparsers.STARTCHARS: raise cmdexc.CommandError( "Invalid command text '{}'.".format(text)) - self.set_cmd_text(text) + if run_on_count and count is not None: + self.got_cmd[str, int].emit(text, count) + else: + self.set_cmd_text(text) @cmdutils.register(instance='status-command', hide=True, modes=[usertypes.KeyMode.command], scope='window') @@ -156,7 +164,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): text = self.text() self.history.append(text) modeman.leave(self._win_id, usertypes.KeyMode.command, 'cmd accept') - self.got_cmd.emit(prefixes[text[0]] + text[1:]) + self.got_cmd[str].emit(prefixes[text[0]] + text[1:]) @pyqtSlot(usertypes.KeyMode) def on_mode_left(self, mode): diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 13865ba90..a63377d22 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -272,10 +272,6 @@ class TabbedBrowser(tabwidget.TabWidget): if last_close == 'ignore' and count == 1: return - # If we are removing a pinned tab, decrease count - if tab.data.pinned: - self.tabBar().pinned_count -= 1 - self._remove_tab(tab, add_undo=add_undo) if count == 1: # We just closed the last tab above. @@ -689,6 +685,7 @@ class TabbedBrowser(tabwidget.TabWidget): self._update_tab_title(idx) if idx == self.currentIndex(): self._update_window_title() + tab.handle_auto_insert_mode(ok) @pyqtSlot() def on_scroll_pos_changed(self): diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 111be2931..c5566f877 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -92,26 +92,16 @@ class TabWidget(QTabWidget): bar.update(bar.tabRect(idx)) def set_tab_pinned(self, tab: QWidget, - pinned: bool, *, loading: bool = False) -> None: + pinned: bool) -> None: """Set the tab status as pinned. Args: tab: The tab to pin pinned: Pinned tab state to set. - loading: Whether to ignore current data state when - counting pinned_count. """ bar = self.tabBar() idx = self.indexOf(tab) - # Only modify pinned_count if we had a change - # always modify pinned_count if we are loading - if tab.data.pinned != pinned or loading: - if pinned: - bar.pinned_count += 1 - elif not pinned: - bar.pinned_count -= 1 - bar.set_tab_data(idx, 'pinned', pinned) tab.data.pinned = pinned self._update_tab_title(idx) @@ -310,7 +300,6 @@ class TabBar(QTabBar): self._on_show_switching_delay_changed() self.setAutoFillBackground(True) self._set_colors() - self.pinned_count = 0 QTimer.singleShot(0, self.maybe_hide) def __repr__(self): @@ -435,18 +424,25 @@ class TabBar(QTabBar): return super().mousePressEvent(e) - def minimumTabSizeHint(self, index): + def minimumTabSizeHint(self, index, ellipsis: bool = True): """Set the minimum tab size to indicator/icon/... text. Args: index: The index of the tab to get a size hint for. - + ellipsis: Whether to use ellipsis to calculate width + instead of the tab's text. Return: - A QSize. + A QSize of the smallest tab size we can make. """ + text = '\u2026' if ellipsis else self.tabText(index) + # Don't ever shorten if text is shorter than the ellipsis + text_width = min(self.fontMetrics().width(text), + self.fontMetrics().width(self.tabText(index))) icon = self.tabIcon(index) padding = config.val.tabs.padding + indicator_padding = config.val.tabs.indicator_padding padding_h = padding.left + padding.right + padding_h += indicator_padding.left + indicator_padding.right padding_v = padding.top + padding.bottom if icon.isNull(): icon_size = QSize(0, 0) @@ -454,15 +450,32 @@ class TabBar(QTabBar): extent = self.style().pixelMetric(QStyle.PM_TabBarIconSize, None, self) icon_size = icon.actualSize(QSize(extent, extent)) - padding_h += self.style().pixelMetric( - PixelMetrics.icon_padding, None, self) height = self.fontMetrics().height() + padding_v - width = (self.fontMetrics().width('\u2026') + icon_size.width() + + width = (text_width + icon_size.width() + padding_h + config.val.tabs.width.indicator) return QSize(width, height) - def tabSizeHint(self, index): - """Override tabSizeHint so all tabs are the same size. + def _tab_total_width_pinned(self): + """Get the current total width of pinned tabs. + + This width is calculated assuming no shortening due to ellipsis.""" + return sum(self.minimumTabSizeHint(idx, ellipsis=False).width() + for idx in range(self.count()) + if self._tab_pinned(idx)) + + def _pinnedCount(self) -> int: + """Get the number of pinned tabs.""" + return sum(self._tab_pinned(idx) for idx in range(self.count())) + + def _tab_pinned(self, index: int) -> bool: + """Return True if tab is pinned.""" + try: + return self.tab_data(index, 'pinned') + except KeyError: + return False + + def tabSizeHint(self, index: int): + """Override tabSizeHint to customize qb's tab size. https://wiki.python.org/moin/PyQt/Customising%20tab%20bars @@ -490,43 +503,17 @@ class TabBar(QTabBar): # want to ensure it's valid in this special case. return QSize() else: - try: - pinned = self.tab_data(index, 'pinned') - except KeyError: - pinned = False - - no_pinned_count = self.count() - self.pinned_count - pinned_width = config.val.tabs.width.pinned * self.pinned_count + pinned = self._tab_pinned(index) + no_pinned_count = self.count() - self._pinnedCount() + pinned_width = self._tab_total_width_pinned() no_pinned_width = self.width() - pinned_width if pinned: - size = QSize(config.val.tabs.width.pinned, height) - qtutils.ensure_valid(size) - return size - - # If we *do* have enough space, tabs should occupy the whole window - # width. If there are pinned tabs their size will be subtracted - # from the total window width. - # During shutdown the self.count goes down, - # but the self.pinned_count not - this generates some odd behavior. - # To avoid this we compare self.count against self.pinned_count. - if self.pinned_count > 0 and self.count() > self.pinned_count: - pinned_width = config.val.tabs.width.pinned * self.pinned_count - no_pinned_width = self.width() - pinned_width - width = no_pinned_width / (self.count() - self.pinned_count) + # Give pinned tabs the minimum size they need to display their + # titles, let Qt handle scaling it down if we get too small. + width = self.minimumTabSizeHint(index, ellipsis=False).width() else: - - # Tabs should attempt to occupy the whole window width. If - # there are pinned tabs their size will be subtracted from the - # total window width. During shutdown the self.count goes - # down, but the self.pinned_count not - this generates some odd - # behavior. To avoid this we compare self.count against - # self.pinned_count. If we end up having too little space, we - # set the minimum size below. - if self.pinned_count > 0 and no_pinned_count > 0: - width = no_pinned_width / no_pinned_count - else: - width = self.width() / self.count() + width = no_pinned_width / no_pinned_count # If no_pinned_width is not divisible by no_pinned_count, add a # pixel to some tabs so that there is no ugly leftover space. diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py new file mode 100644 index 000000000..d975e29cf --- /dev/null +++ b/qutebrowser/misc/backendproblem.py @@ -0,0 +1,335 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 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 . + +"""Dialogs shown when there was a problem with a backend choice.""" + +import os +import sys +import functools +import html + +import attr +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import (QApplication, QDialog, QPushButton, QHBoxLayout, + QVBoxLayout, QLabel, QMessageBox) +from PyQt5.QtNetwork import QSslSocket + +from qutebrowser.config import config +from qutebrowser.utils import usertypes, objreg, version, qtutils, log +from qutebrowser.misc import objects, msgbox + + +_Result = usertypes.enum( + '_Result', + ['quit', 'restart', 'restart_webkit', 'restart_webengine'], + is_int=True, start=QDialog.Accepted + 1) + + +@attr.s +class _Button: + + """A button passed to BackendProblemDialog.""" + + text = attr.ib() + setting = attr.ib() + value = attr.ib() + default = attr.ib(default=False) + + +def _other_backend(backend): + """Get the other backend enum/setting for a given backend.""" + other_backend = { + usertypes.Backend.QtWebKit: usertypes.Backend.QtWebEngine, + usertypes.Backend.QtWebEngine: usertypes.Backend.QtWebKit, + }[backend] + other_setting = other_backend.name.lower()[2:] + return (other_backend, other_setting) + + +def _error_text(because, text, backend): + """Get an error text for the given information.""" + other_backend, other_setting = _other_backend(backend) + return ("Failed to start with the {backend} backend!" + "

qutebrowser tried to start with the {backend} backend but " + "failed because {because}.

{text}" + "

Forcing the {other_backend.name} backend

" + "

This forces usage of the {other_backend.name} backend by " + "setting the backend = '{other_setting}' option " + "(if you have a config.py file, you'll need to set " + "this manually).

".format( + backend=backend.name, because=because, text=text, + other_backend=other_backend, other_setting=other_setting)) + + +class _Dialog(QDialog): + + """A dialog which gets shown if there are issues with the backend.""" + + def __init__(self, because, text, backend, buttons=None, parent=None): + super().__init__(parent) + vbox = QVBoxLayout(self) + + other_backend, other_setting = _other_backend(backend) + text = _error_text(because, text, backend) + + label = QLabel(text, wordWrap=True) + label.setTextFormat(Qt.RichText) + vbox.addWidget(label) + + hbox = QHBoxLayout() + buttons = [] if buttons is None else buttons + + quit_button = QPushButton("Quit") + quit_button.clicked.connect(lambda: self.done(_Result.quit)) + hbox.addWidget(quit_button) + + backend_button = QPushButton("Force {} backend".format( + other_backend.name)) + backend_button.clicked.connect(functools.partial( + self._change_setting, 'backend', other_setting)) + hbox.addWidget(backend_button) + + for button in buttons: + btn = QPushButton(button.text, default=button.default) + btn.clicked.connect(functools.partial( + self._change_setting, button.setting, button.value)) + hbox.addWidget(btn) + + vbox.addLayout(hbox) + + def _change_setting(self, setting, value): + """Change the given setting and restart.""" + config.instance.set_obj(setting, value, save_yaml=True) + save_manager = objreg.get('save-manager') + save_manager.save_all(is_exit=True) + + if setting == 'backend' and value == 'webkit': + self.done(_Result.restart_webkit) + elif setting == 'backend' and value == 'webengine': + self.done(_Result.restart_webengine) + else: + self.done(_Result.restart) + + +def _show_dialog(*args, **kwargs): + """Show a dialog for a backend problem.""" + cmd_args = objreg.get('args') + if cmd_args.no_err_windows: + text = _error_text(*args, **kwargs) + print(text, file=sys.stderr) + sys.exit(usertypes.Exit.err_init) + + dialog = _Dialog(*args, **kwargs) + + status = dialog.exec_() + quitter = objreg.get('quitter') + + if status in [_Result.quit, QDialog.Rejected]: + pass + elif status == _Result.restart_webkit: + quitter.restart(override_args={'backend': 'webkit'}) + elif status == _Result.restart_webengine: + quitter.restart(override_args={'backend': 'webengine'}) + elif status == _Result.restart: + quitter.restart() + else: + assert False, status + + sys.exit(usertypes.Exit.err_init) + + +def _handle_nouveau_graphics(): + assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend + + if version.opengl_vendor() != 'nouveau': + return + + if (os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1' or + 'QT_XCB_FORCE_SOFTWARE_OPENGL' in os.environ): + return + + button = _Button("Force software rendering", 'force_software_rendering', + True) + _show_dialog( + backend=usertypes.Backend.QtWebEngine, + because="you're using Nouveau graphics", + text="

There are two ways to fix this:

" + "

Forcing software rendering

" + "

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

", + buttons=[button], + ) + + # Should never be reached + assert False + + +def _handle_wayland(): + assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend + + platform = QApplication.instance().platformName() + if platform not in ['wayland', 'wayland-egl']: + return + + _show_dialog( + backend=usertypes.Backend.QtWebEngine, + because="you're using Wayland", + text="

There are two ways to fix this:

" + "

Set up XWayland

" + "

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

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

" + "

The errors encountered were:

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

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

The error encountered was:
{}

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

The error encountered was:
{}

".format( + html.escape(imports.webengine_error)) + ) + + # Should never be reached + assert False + + +def init(): + _check_backend_modules() + if objects.backend == usertypes.Backend.QtWebEngine: + _handle_ssl_support() + _handle_wayland() + _handle_nouveau_graphics() + else: + assert objects.backend == usertypes.Backend.QtWebKit, objects.backend + _handle_ssl_support(fatal=True) diff --git a/qutebrowser/misc/crashdialog.py b/qutebrowser/misc/crashdialog.py index 9efb0b7f2..60c83d36b 100644 --- a/qutebrowser/misc/crashdialog.py +++ b/qutebrowser/misc/crashdialog.py @@ -32,15 +32,19 @@ import pkg_resources from PyQt5.QtCore import pyqtSlot, Qt, QSize from PyQt5.QtWidgets import (QDialog, QLabel, QTextEdit, QPushButton, QVBoxLayout, QHBoxLayout, QCheckBox, - QDialogButtonBox, QMessageBox, QApplication) + QDialogButtonBox, QApplication) import qutebrowser from qutebrowser.utils import version, log, utils, objreg, usertypes from qutebrowser.misc import (miscwidgets, autoupdate, msgbox, httpclient, - pastebin, objects) + pastebin) from qutebrowser.config import config, configfiles +Result = usertypes.enum('Result', ['restore', 'no_restore'], is_int=True, + start=QDialog.Accepted + 1) + + def parse_fatal_stacktrace(text): """Get useful information from a fatal faulthandler stacktrace. @@ -65,41 +69,6 @@ def parse_fatal_stacktrace(text): return (m.group(1), m.group(3)) -def get_fatal_crash_dialog(debug, data): - """Get a fatal crash dialog based on a crash log. - - If the crash is a segfault in qt_mainloop and we're on an old Qt version - this is a simple error dialog which lets the user know they should upgrade - if possible. - - If it's anything else, it's a normal FatalCrashDialog with the possibility - to report the crash. - - Args: - debug: Whether the debug flag (--debug) was given. - data: The crash log data. - """ - ignored_frames = ['qt_mainloop', 'paintEvent'] - errtype, frame = parse_fatal_stacktrace(data) - - if (errtype == 'Segmentation fault' and - frame in ignored_frames and - objects.backend == usertypes.Backend.QtWebKit): - title = "qutebrowser was restarted after a fatal crash!" - text = ("qutebrowser was restarted after a fatal crash!
" - "Unfortunately, this crash occurred in Qt (the library " - "qutebrowser uses), and QtWebKit (the current backend) is not " - "maintained anymore.

Since I can't do much about " - "those crashes I disabled the crash reporter for this case, " - "but this will likely be resolved in the future with the new " - "QtWebEngine backend.") - box = QMessageBox(QMessageBox.Critical, title, text, QMessageBox.Ok) - box.setAttribute(Qt.WA_DeleteOnClose) - return box - else: - return FatalCrashDialog(debug, data) - - def _get_environment_vars(): """Gather environment variables for the crash info.""" masks = ('DESKTOP_SESSION', 'DE', 'QT_*', 'PYTHON*', 'LC_*', 'LANG', @@ -478,9 +447,9 @@ class ExceptionCrashDialog(_CrashDialog): def finish(self): self._save_contact_info() if self._chk_restore.isChecked(): - self.accept() + self.done(Result.restore) else: - self.reject() + self.done(Result.no_restore) class FatalCrashDialog(_CrashDialog): diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py index 9a1b88942..b90eae829 100644 --- a/qutebrowser/misc/crashsignal.py +++ b/qutebrowser/misc/crashsignal.py @@ -36,10 +36,10 @@ except ImportError: import attr from PyQt5.QtCore import (pyqtSlot, qInstallMessageHandler, QObject, QSocketNotifier, QTimer, QUrl) -from PyQt5.QtWidgets import QApplication, QDialog +from PyQt5.QtWidgets import QApplication from qutebrowser.commands import cmdutils -from qutebrowser.misc import earlyinit, crashdialog +from qutebrowser.misc import earlyinit, crashdialog, ipc from qutebrowser.utils import usertypes, standarddir, log, objreg, debug, utils @@ -94,7 +94,7 @@ class CrashHandler(QObject): if data: # Crashlog exists and has data in it, so something crashed # previously. - self._crash_dialog = crashdialog.get_fatal_crash_dialog( + self._crash_dialog = crashdialog.FatalCrashDialog( self._args.debug, data) self._crash_dialog.show() else: @@ -236,7 +236,7 @@ class CrashHandler(QObject): info = self._get_exception_info() try: - objreg.get('ipc-server').ignored = True + ipc.server.ignored = True except Exception: log.destroy.exception("Error while ignoring ipc") @@ -258,7 +258,7 @@ class CrashHandler(QObject): self._args.debug, info.pages, info.cmd_history, exc, info.objects) ret = self._crash_dialog.exec_() - if ret == QDialog.Accepted: # restore + if ret == crashdialog.Result.restore: self._quitter.restart(info.pages) # We might risk a segfault here, but that's better than continuing to diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index 032dfc53b..ca12cd901 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -47,33 +47,25 @@ except ImportError: START_TIME = datetime.datetime.now() -def _missing_str(name, *, windows=None, pip=None, webengine=False): +def _missing_str(name, *, webengine=False): """Get an error string for missing packages. Args: name: The name of the package. - windows: String to be displayed for Windows. - pip: pypi package name. webengine: Whether this is checking the QtWebEngine package """ blocks = ["Fatal error: {} is required to run qutebrowser but " "could not be imported! Maybe it's not installed?".format(name), "The error encountered was:
%ERROR%"] lines = ['Please search for the python3 version of {} in your ' - 'distributions packages, or install it via pip.'.format(name)] + 'distributions packages, or see ' + 'https://github.com/qutebrowser/qutebrowser/blob/master/doc/install.asciidoc' + .format(name)] blocks.append('
'.join(lines)) if not webengine: lines = ['If you installed a qutebrowser package for your ' 'distribution, please report this as a bug.'] blocks.append('
'.join(lines)) - if windows is not None: - lines = ["On Windows:"] - lines += windows.splitlines() - blocks.append('
'.join(lines)) - if pip is not None: - lines = ["Using pip:"] - lines.append("pip3 install {}".format(pip)) - blocks.append('
'.join(lines)) return '

'.join(blocks) @@ -142,11 +134,7 @@ def check_pyqt_core(): try: import PyQt5.QtCore # pylint: disable=unused-variable except ImportError as e: - text = _missing_str('PyQt5', - windows="Use the installer by Riverbank computing " - "or the standalone qutebrowser exe.
" - "http://www.riverbankcomputing.co.uk/" - "software/pyqt/download5") + text = _missing_str('PyQt5') text = text.replace('', '') text = text.replace('', '') text = text.replace('
', '\n') @@ -199,23 +187,6 @@ def check_ssl_support(): _die("Fatal error: Your Qt is built without SSL support.") -def check_backend_ssl_support(backend): - """Check for full SSL availability when we know the backend.""" - from PyQt5.QtNetwork import QSslSocket - from qutebrowser.utils import log, usertypes - text = ("Could not initialize QtNetwork SSL support. If you use " - "OpenSSL 1.1 with a PyQt package from PyPI (e.g. on Archlinux " - "or Debian Stretch), you need to set LD_LIBRARY_PATH to the path " - "of OpenSSL 1.0. This only affects downloads.") - - if not QSslSocket.supportsSsl(): - if backend == usertypes.Backend.QtWebKit: - _die("Could not initialize SSL support.") - else: - assert backend == usertypes.Backend.QtWebEngine - log.init.warning(text) - - def _check_modules(modules): """Make sure the given modules are available.""" from qutebrowser.utils import log @@ -230,7 +201,14 @@ def _check_modules(modules): 'Flags not at the start of the expression'] with log.ignore_py_warnings( category=DeprecationWarning, - message=r'({})'.format('|'.join(messages))): + message=r'({})'.format('|'.join(messages)) + ), log.ignore_py_warnings( + category=PendingDeprecationWarning, + module='imp' + ), log.ignore_py_warnings( + category=ImportWarning, + message=r'Not importing directory .*: missing __init__' + ): importlib.import_module(name) except ImportError as e: _die(text, e) @@ -239,31 +217,12 @@ def _check_modules(modules): def check_libraries(): """Check if all needed Python libraries are installed.""" modules = { - 'pkg_resources': - _missing_str("pkg_resources/setuptools", - windows="Run python -m ensurepip."), - 'pypeg2': - _missing_str("pypeg2", - pip="pypeg2"), - 'jinja2': - _missing_str("jinja2", - windows="Install from http://www.lfd.uci.edu/" - "~gohlke/pythonlibs/#jinja2 or via pip.", - pip="jinja2"), - 'pygments': - _missing_str("pygments", - windows="Install from http://www.lfd.uci.edu/" - "~gohlke/pythonlibs/#pygments or via pip.", - pip="pygments"), - 'yaml': - _missing_str("PyYAML", - windows="Use the installers at " - "http://pyyaml.org/download/pyyaml/ (py3.4) " - "or Install via pip.", - pip="PyYAML"), - 'attr': - _missing_str("attrs", - pip="attrs"), + 'pkg_resources': _missing_str("pkg_resources/setuptools"), + 'pypeg2': _missing_str("pypeg2"), + 'jinja2': _missing_str("jinja2"), + 'pygments': _missing_str("pygments"), + 'yaml': _missing_str("PyYAML"), + 'attr': _missing_str("attrs"), 'PyQt5.QtQml': _missing_str("PyQt5.QtQml"), 'PyQt5.QtSql': _missing_str("PyQt5.QtSql"), 'PyQt5.QtOpenGL': _missing_str("PyQt5.QtOpenGL"), @@ -271,35 +230,6 @@ def check_libraries(): _check_modules(modules) -def check_backend_libraries(backend): - """Make sure the libraries needed by the given backend are available. - - Args: - backend: The backend as usertypes.Backend member. - """ - from qutebrowser.utils import usertypes - if backend == usertypes.Backend.QtWebEngine: - modules = { - 'PyQt5.QtWebEngineWidgets': - _missing_str("QtWebEngine", webengine=True), - } - else: - assert backend == usertypes.Backend.QtWebKit, backend - modules = { - 'PyQt5.QtWebKit': _missing_str("PyQt5.QtWebKit"), - 'PyQt5.QtWebKitWidgets': _missing_str("PyQt5.QtWebKitWidgets"), - } - _check_modules(modules) - - -def check_new_webkit(backend): - """Make sure we use QtWebEngine or a new QtWebKit.""" - from qutebrowser.utils import usertypes, qtutils - if backend == usertypes.Backend.QtWebKit and not qtutils.is_new_qtwebkit(): - _die("qutebrowser does not support legacy QtWebKit versions anymore, " - "see the installation docs for details.") - - def remove_inputhook(): """Remove the PyQt input hook. @@ -352,16 +282,3 @@ def early_init(args): remove_inputhook() check_ssl_support() check_optimize_flag() - - -def init_with_backend(backend): - """Do later stages of init when we know the backend. - - Args: - backend: The backend as usertypes.Backend member. - """ - assert not isinstance(backend, str), backend - assert backend is not None - check_backend_libraries(backend) - check_backend_ssl_support(backend) - check_new_webkit(backend) diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py index 524588b54..3686e028f 100644 --- a/qutebrowser/misc/editor.py +++ b/qutebrowser/misc/editor.py @@ -35,8 +35,9 @@ class ExternalEditor(QObject): Attributes: _text: The current text before the editor is opened. - _file: The file handle as tempfile.NamedTemporaryFile. Note that this - handle will be closed after the initial file has been created. + _filename: The name of the file to be edited. + _remove_file: Whether the file should be removed when the editor is + closed. _proc: The GUIProcess of the editor. """ @@ -44,18 +45,20 @@ class ExternalEditor(QObject): def __init__(self, parent=None): super().__init__(parent) - self._text = None - self._file = None + self._filename = None self._proc = None + self._remove_file = None def _cleanup(self): """Clean up temporary files after the editor closed.""" - if self._file is None: + assert self._remove_file is not None + if self._filename is None or not self._remove_file: # Could not create initial file. return + try: if self._proc.exit_status() != QProcess.CrashExit: - os.remove(self._file.name) + os.remove(self._filename) except OSError as e: # NOTE: Do not replace this with "raise CommandError" as it's # executed async. @@ -77,7 +80,7 @@ class ExternalEditor(QObject): return encoding = config.val.editor.encoding try: - with open(self._file.name, 'r', encoding=encoding) as f: + with open(self._filename, 'r', encoding=encoding) as f: text = f.read() except OSError as e: # NOTE: Do not replace this with "raise CommandError" as it's @@ -99,9 +102,8 @@ class ExternalEditor(QObject): Args: text: The initial text to edit. """ - if self._text is not None: + if self._filename is not None: raise ValueError("Already editing a file!") - self._text = text try: # Close while the external process is running, as otherwise systems # with exclusive write access (e.g. Windows) may fail to update @@ -113,15 +115,27 @@ class ExternalEditor(QObject): delete=False) as fobj: if text: fobj.write(text) - self._file = fobj + self._filename = fobj.name except OSError as e: message.error("Failed to create initial file: {}".format(e)) return + + self._remove_file = True + self._start_editor() + + def edit_file(self, filename): + """Edit the file with the given filename.""" + self._filename = filename + self._remove_file = False + self._start_editor() + + def _start_editor(self): + """Start the editor with the file opened as self._filename.""" self._proc = guiprocess.GUIProcess(what='editor', parent=self) self._proc.finished.connect(self.on_proc_closed) self._proc.error.connect(self.on_proc_error) editor = config.val.editor.command executable = editor[0] - args = [arg.replace('{}', self._file.name) for arg in editor[1:]] + args = [arg.replace('{}', self._filename) for arg in editor[1:]] log.procs.debug("Calling \"{}\" with args {}".format(executable, args)) self._proc.start(executable, args) diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py index e308ac8a0..c9f982365 100644 --- a/qutebrowser/misc/ipc.py +++ b/qutebrowser/misc/ipc.py @@ -30,7 +30,7 @@ from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, Qt from PyQt5.QtNetwork import QLocalSocket, QLocalServer, QAbstractSocket import qutebrowser -from qutebrowser.utils import log, usertypes, error, objreg, standarddir, utils +from qutebrowser.utils import log, usertypes, error, standarddir, utils CONNECT_TIMEOUT = 100 # timeout for connecting/disconnecting @@ -40,6 +40,10 @@ ATIME_INTERVAL = 60 * 60 * 3 * 1000 # 3 hours PROTOCOL_VERSION = 1 +# The ipc server instance +server = None + + def _get_socketname_windows(basedir): """Get a socketname to use for Windows.""" parts = ['qutebrowser', getpass.getuser()] @@ -109,15 +113,15 @@ class ListenError(Error): message: The error message. """ - def __init__(self, server): + def __init__(self, local_server): """Constructor. Args: - server: The QLocalServer which has the error set. + local_server: The QLocalServer which has the error set. """ super().__init__() - self.code = server.serverError() - self.message = server.errorString() + self.code = local_server.serverError() + self.message = local_server.errorString() def __str__(self): return "Error while listening to IPC server: {} (error {})".format( @@ -482,6 +486,7 @@ def send_or_listen(args): The IPCServer instance if no running instance was detected. None if an instance was running and received our request. """ + global server socketname = _get_socketname(args.basedir) try: try: @@ -492,7 +497,6 @@ def send_or_listen(args): log.init.debug("Starting IPC server...") server = IPCServer(socketname) server.listen() - objreg.register('ipc-server', server) return server except AddressInUseError as e: # This could be a race condition... diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 63b8f017c..ce1f324e4 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -26,12 +26,14 @@ It is intended to help discoverability of keybindings. import html import fnmatch +import re from PyQt5.QtWidgets import QLabel, QSizePolicy from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt from qutebrowser.config import config from qutebrowser.utils import utils, usertypes +from qutebrowser.commands import cmdutils class KeyHintView(QLabel): @@ -85,6 +87,7 @@ class KeyHintView(QLabel): Args: prefix: The current partial keystring. """ + countstr, prefix = re.match(r'^(\d*)(.*)', prefix).groups() if not prefix: self._show_timer.stop() self.hide() @@ -94,11 +97,18 @@ class KeyHintView(QLabel): return any(fnmatch.fnmatchcase(keychain, glob) for glob in config.val.keyhint.blacklist) + def takes_count(cmdstr): + """Return true iff this command can take a count argument.""" + cmdname = cmdstr.split(' ')[0] + cmd = cmdutils.cmd_dict.get(cmdname) + return cmd and cmd.takes_count() + bindings_dict = config.key_instance.get_bindings_for(modename) bindings = [(k, v) for (k, v) in sorted(bindings_dict.items()) if k.startswith(prefix) and not utils.is_special_key(k) and - not blacklisted(k)] + not blacklisted(k) and + (takes_count(v) or not countstr)] if not bindings: self._show_timer.stop() diff --git a/qutebrowser/misc/msgbox.py b/qutebrowser/misc/msgbox.py index e4e77330a..459ab8b61 100644 --- a/qutebrowser/misc/msgbox.py +++ b/qutebrowser/misc/msgbox.py @@ -19,10 +19,21 @@ """Convenience functions to show message boxes.""" +import sys from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMessageBox +from qutebrowser.utils import objreg + + +class DummyBox: + + """A dummy QMessageBox returned when --no-err-windows is used.""" + + def exec_(self): + pass + def msgbox(parent, title, text, *, icon, buttons=QMessageBox.Ok, on_finished=None, plain_text=None): @@ -40,6 +51,11 @@ def msgbox(parent, title, text, *, icon, buttons=QMessageBox.Ok, Return: A new QMessageBox. """ + args = objreg.get('args') + if args.no_err_windows: + print('Message box: {}; {}'.format(title, text), file=sys.stderr) + return DummyBox() + box = QMessageBox(parent) box.setAttribute(Qt.WA_DeleteOnClose) box.setIcon(icon) diff --git a/qutebrowser/misc/savemanager.py b/qutebrowser/misc/savemanager.py index ddda5325b..02001902c 100644 --- a/qutebrowser/misc/savemanager.py +++ b/qutebrowser/misc/savemanager.py @@ -164,6 +164,11 @@ class SaveManager(QObject): self.saveables[name].save(is_exit=is_exit, explicit=explicit, silent=silent, force=force) + def save_all(self, *args, **kwargs): + """Save all saveables.""" + for saveable in self.saveables: + self.save(saveable, *args, **kwargs) + @pyqtSlot() def autosave(self): """Slot used when the configs are auto-saved.""" diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index 7c42b231b..064d8c9e9 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -393,8 +393,7 @@ class SessionManager(QObject): if tab.get('active', False): tab_to_focus = i if new_tab.data.pinned: - tabbed_browser.set_tab_pinned( - new_tab, new_tab.data.pinned, loading=True) + tabbed_browser.set_tab_pinned(new_tab, new_tab.data.pinned) if tab_to_focus is not None: tabbed_browser.setCurrentIndex(tab_to_focus) if win.get('active', False): diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py index a288df475..24e2035e6 100644 --- a/qutebrowser/misc/sql.py +++ b/qutebrowser/misc/sql.py @@ -22,12 +22,12 @@ import collections from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtSql import QSqlDatabase, QSqlQuery +from PyQt5.QtSql import QSqlDatabase, QSqlQuery, QSqlError -from qutebrowser.utils import log +from qutebrowser.utils import log, debug -class SqlException(Exception): +class SqlError(Exception): """Raised on an error interacting with the SQL database.""" @@ -38,12 +38,14 @@ def init(db_path): """Initialize the SQL database connection.""" database = QSqlDatabase.addDatabase('QSQLITE') if not database.isValid(): - raise SqlException('Failed to add database. ' + raise SqlError('Failed to add database. ' 'Are sqlite and Qt sqlite support installed?') database.setDatabaseName(db_path) if not database.open(): - raise SqlException("Failed to open sqlite database at {}: {}" - .format(db_path, database.lastError().text())) + error = database.lastError() + _log_error(error) + raise SqlError("Failed to open sqlite database at {}: {}" + .format(db_path, error.text())) def close(): @@ -60,10 +62,32 @@ def version(): close() return ver return Query("select sqlite_version()").run().value() - except SqlException as e: + except SqlError as e: return 'UNAVAILABLE ({})'.format(e) +def _log_error(error): + """Log informations about a SQL error to the debug log.""" + log.sql.debug("SQL error:") + log.sql.debug("type: {}".format(debug.qenum_key(QSqlError, error.type()))) + log.sql.debug("database text: {}".format(error.databaseText())) + log.sql.debug("driver text: {}".format(error.driverText())) + log.sql.debug("error code: {}".format(error.nativeErrorCode())) + + +def _handle_query_error(what, query, error): + """Handle a sqlite error. + + Arguments: + what: What we were doing when the error happened. + query: The query which was executed. + error: The QSqlError object. + """ + _log_error(error) + msg = 'Failed to {} query "{}": "{}"'.format(what, query, error.text()) + raise SqlError(msg) + + class Query(QSqlQuery): """A prepared SQL Query.""" @@ -79,13 +103,12 @@ class Query(QSqlQuery): super().__init__(QSqlDatabase.database()) log.sql.debug('Preparing SQL query: "{}"'.format(querystr)) if not self.prepare(querystr): - raise SqlException('Failed to prepare query "{}": "{}"'.format( - querystr, self.lastError().text())) + _handle_query_error('prepare', querystr, self.lastError()) self.setForwardOnly(forward_only) def __iter__(self): if not self.isActive(): - raise SqlException("Cannot iterate inactive query") + raise SqlError("Cannot iterate inactive query") rec = self.record() fields = [rec.fieldName(i) for i in range(rec.count())] rowtype = collections.namedtuple('ResultRow', fields) @@ -101,14 +124,13 @@ class Query(QSqlQuery): self.bindValue(':{}'.format(key), val) log.sql.debug('query bindings: {}'.format(self.boundValues())) if not self.exec_(): - raise SqlException('Failed to exec query "{}": "{}"'.format( - self.lastQuery(), self.lastError().text())) + _handle_query_error('exec', self.lastQuery(), self.lastError()) return self def value(self): """Return the result of a single-value query (e.g. an EXISTS).""" if not self.next(): - raise SqlException("No result for single-result query") + raise SqlError("No result for single-result query") return self.record().value(0) @@ -128,7 +150,7 @@ class SqlTable(QObject): def __init__(self, name, fields, constraints=None, parent=None): """Create a new table in the sql database. - Raises SqlException if the table already exists. + Does nothing if the table already exists. Args: name: Name of the table. @@ -228,8 +250,7 @@ class SqlTable(QObject): db = QSqlDatabase.database() db.transaction() if not q.execBatch(): - raise SqlException('Failed to exec query "{}": "{}"'.format( - q.lastQuery(), q.lastError().text())) + _handle_query_error('exec', q.lastQuery(), q.lastError()) db.commit() self.changed.emit() diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index 4b6909344..bf1e94586 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -171,12 +171,15 @@ def debug_cache_stats(): prefix_info = configdata.is_valid_prefix.cache_info() # pylint: disable=protected-access render_stylesheet_info = config._render_stylesheet.cache_info() + + history_info = None try: from PyQt5.QtWebKit import QWebHistoryInterface interface = QWebHistoryInterface.defaultInterface() - history_info = interface.historyContains.cache_info() + if interface is not None: + history_info = interface.historyContains.cache_info() except ImportError: - history_info = None + pass log.misc.debug('is_valid_prefix: {}'.format(prefix_info)) log.misc.debug('_render_stylesheet: {}'.format(render_stylesheet_info)) @@ -339,3 +342,12 @@ def window_only(current_win_id): def nop(): """Do nothing.""" return + + +@cmdutils.register() +@cmdutils.argument('win_id', win_id=True) +def version(win_id): + """Show version information.""" + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + tabbed_browser.openurl(QUrl('qute://version'), newtab=True) diff --git a/qutebrowser/qutebrowser.py b/qutebrowser/qutebrowser.py index 1ae4ce192..1b1cfb013 100644 --- a/qutebrowser/qutebrowser.py +++ b/qutebrowser/qutebrowser.py @@ -17,7 +17,21 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Early initialization and main entry point.""" +"""Early initialization and main entry point. + +qutebrowser's initialization process roughly looks like this: + +- This file gets imported, either via the setuptools entry point or + __main__.py. +- At import time, we check for the correct Python version and show an error if + it's too old. +- The main() function in this file gets invoked +- Argument parsing takes place +- earlyinit.early_init() gets invoked to do various low-level initialization + and checks whether all dependencies are met. +- app.run() gets called, which takes over. + See the docstring of app.py for details. +""" import sys import json @@ -95,8 +109,6 @@ def get_argparser(): action='store_false', dest='color') debug.add_argument('--force-color', help="Force colored logging", action='store_true') - debug.add_argument('--relaxed-config', action='store_true', - help="Silently remove unknown config options.") debug.add_argument('--nowindow', action='store_true', help="Don't show " "the main window.") debug.add_argument('--temp-basedir', action='store_true', help="Use a " diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index fa0208ea7..98f14a454 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -39,6 +39,7 @@ except ImportError: colorama = None _log_inited = False +_args = None COLORS = ['black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white'] COLOR_ESCAPES = {color: '\033[{}m'.format(i) @@ -189,8 +190,9 @@ def init_log(args): logging.captureWarnings(True) _init_py_warnings() QtCore.qInstallMessageHandler(qt_message_handler) - global _log_inited + global _log_inited, _args _log_inited = True + _args = args def _init_py_warnings(): @@ -442,7 +444,11 @@ def qt_message_handler(msg_type, context, msg): msg += ("\n\nOn Archlinux, this should fix the problem:\n" " pacman -S libxkbcommon-x11") faulthandler.disable() - stack = ''.join(traceback.format_stack()) + + if _args.debug: + stack = ''.join(traceback.format_stack()) + else: + stack = None record = qt.makeRecord(name, level, context.file, context.line, msg, None, None, func, sinfo=stack) qt.handle(record) diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index a51c8f2b3..1e2bd5b6d 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -36,8 +36,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) import qutebrowser -from qutebrowser.utils import utils -from scripts import utils as scriptutils +from scripts import utils # from scripts.dev import update_3rdparty @@ -71,7 +70,7 @@ def call_tox(toxenv, *args, python=sys.executable): def run_asciidoc2html(args): """Common buildsteps used for all OS'.""" - scriptutils.print_title("Running asciidoc2html.py") + utils.print_title("Running asciidoc2html.py") if args.asciidoc is not None: a2h_args = ['--asciidoc'] + args.asciidoc else: @@ -128,7 +127,7 @@ def patch_mac_app(): def build_mac(): """Build macOS .dmg/.app.""" - scriptutils.print_title("Cleaning up...") + utils.print_title("Cleaning up...") for f in ['wc.dmg', 'template.dmg']: try: os.remove(f) @@ -136,20 +135,20 @@ def build_mac(): pass for d in ['dist', 'build']: shutil.rmtree(d, ignore_errors=True) - scriptutils.print_title("Updating 3rdparty content") + utils.print_title("Updating 3rdparty content") # Currently disabled because QtWebEngine has no pdfjs support # update_3rdparty.run(ace=False, pdfjs=True, fancy_dmg=False) - scriptutils.print_title("Building .app via pyinstaller") + utils.print_title("Building .app via pyinstaller") call_tox('pyinstaller', '-r') - scriptutils.print_title("Patching .app") + utils.print_title("Patching .app") patch_mac_app() - scriptutils.print_title("Building .dmg") + utils.print_title("Building .dmg") subprocess.check_call(['make', '-f', 'scripts/dev/Makefile-dmg']) dmg_name = 'qutebrowser-{}.dmg'.format(qutebrowser.__version__) os.rename('qutebrowser.dmg', dmg_name) - scriptutils.print_title("Running smoke test") + utils.print_title("Running smoke test") try: with tempfile.TemporaryDirectory() as tmpdir: @@ -178,11 +177,11 @@ def patch_windows(out_dir): def build_windows(): """Build windows executables/setups.""" - scriptutils.print_title("Updating 3rdparty content") + utils.print_title("Updating 3rdparty content") # Currently disabled because QtWebEngine has no pdfjs support # update_3rdparty.run(ace=False, pdfjs=True, fancy_dmg=False) - scriptutils.print_title("Building Windows binaries") + utils.print_title("Building Windows binaries") parts = str(sys.version_info.major), str(sys.version_info.minor) ver = ''.join(parts) python_x86 = r'C:\Python{}-32\python.exe'.format(ver) @@ -195,19 +194,19 @@ def build_windows(): artifacts = [] - scriptutils.print_title("Running pyinstaller 32bit") + utils.print_title("Running pyinstaller 32bit") _maybe_remove(out_32) call_tox('pyinstaller', '-r', python=python_x86) shutil.move(out_pyinstaller, out_32) patch_windows(out_32) - scriptutils.print_title("Running pyinstaller 64bit") + utils.print_title("Running pyinstaller 64bit") _maybe_remove(out_64) call_tox('pyinstaller', '-r', python=python_x64) shutil.move(out_pyinstaller, out_64) patch_windows(out_64) - scriptutils.print_title("Building installers") + utils.print_title("Building installers") subprocess.check_call(['makensis.exe', '/DVERSION={}'.format(qutebrowser.__version__), 'misc/qutebrowser.nsi']) @@ -228,12 +227,12 @@ def build_windows(): 'Windows 64bit installer'), ] - scriptutils.print_title("Running 32bit smoke test") + utils.print_title("Running 32bit smoke test") smoke_test(os.path.join(out_32, 'qutebrowser.exe')) - scriptutils.print_title("Running 64bit smoke test") + utils.print_title("Running 64bit smoke test") smoke_test(os.path.join(out_64, 'qutebrowser.exe')) - scriptutils.print_title("Zipping 32bit standalone...") + utils.print_title("Zipping 32bit standalone...") name = 'qutebrowser-{}-windows-standalone-win32'.format( qutebrowser.__version__) shutil.make_archive(name, 'zip', 'dist', os.path.basename(out_32)) @@ -241,7 +240,7 @@ def build_windows(): 'application/zip', 'Windows 32bit standalone')) - scriptutils.print_title("Zipping 64bit standalone...") + utils.print_title("Zipping 64bit standalone...") name = 'qutebrowser-{}-windows-standalone-amd64'.format( qutebrowser.__version__) shutil.make_archive(name, 'zip', 'dist', os.path.basename(out_64)) @@ -254,7 +253,7 @@ def build_windows(): def build_sdist(): """Build an sdist and list the contents.""" - scriptutils.print_title("Building sdist") + utils.print_title("Building sdist") _maybe_remove('dist') @@ -277,10 +276,10 @@ def build_sdist(): assert '.pyc' not in by_ext - scriptutils.print_title("sdist contents") + utils.print_title("sdist contents") for ext, files in sorted(by_ext.items()): - scriptutils.print_subtitle(ext) + utils.print_subtitle(ext) print('\n'.join(files)) filename = 'qutebrowser-{}.tar.gz'.format(qutebrowser.__version__) @@ -309,7 +308,7 @@ def github_upload(artifacts, tag): tag: The name of the release tag """ import github3 - scriptutils.print_title("Uploading to github...") + utils.print_title("Uploading to github...") token = read_github_token() gh = github3.login(token=token) @@ -344,7 +343,7 @@ def main(): parser.add_argument('--upload', help="Tag to upload the release for", nargs=1, required=False, metavar='TAG') args = parser.parse_args() - scriptutils.change_cwd() + utils.change_cwd() upload_to_pypi = False @@ -354,7 +353,8 @@ def main(): import github3 # pylint: disable=unused-variable read_github_token() - if utils.is_windows: + run_asciidoc2html(args) + if os.name == 'nt': if sys.maxsize > 2**32: # WORKAROUND print("Due to a python/Windows bug, this script needs to be run ") @@ -363,21 +363,24 @@ def main(): print("See http://bugs.python.org/issue24493 and ") print("https://github.com/pypa/virtualenv/issues/774") sys.exit(1) - run_asciidoc2html(args) artifacts = build_windows() - elif utils.is_mac: - run_asciidoc2html(args) + elif sys.platform == 'darwin': artifacts = build_mac() else: artifacts = build_sdist() upload_to_pypi = True if args.upload is not None: - scriptutils.print_title("Press enter to release...") + utils.print_title("Press enter to release...") input() github_upload(artifacts, args.upload[0]) if upload_to_pypi: pypi_upload(artifacts) + else: + print() + utils.print_title("Artifacts") + for artifact in artifacts: + print(artifact) if __name__ == '__main__': diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index e60ee78fd..ba4e7a7a7 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -141,6 +141,10 @@ PERFECT_FILES = [ 'config/configfiles.py'), ('tests/unit/config/test_configtypes.py', 'config/configtypes.py'), + ('tests/unit/config/test_configinit.py', + 'config/configinit.py'), + ('tests/unit/config/test_configcommands.py', + 'config/configcommands.py'), ('tests/unit/utils/test_qtutils.py', 'utils/qtutils.py'), diff --git a/scripts/dev/ci/travis_run.sh b/scripts/dev/ci/travis_run.sh index a1f498b62..46a64bc9c 100644 --- a/scripts/dev/ci/travis_run.sh +++ b/scripts/dev/ci/travis_run.sh @@ -5,7 +5,7 @@ if [[ $DOCKER ]]; then elif [[ $TESTENV == eslint ]]; then # Can't run this via tox as we can't easily install tox in the javascript travis env cd qutebrowser/javascript || exit 1 - eslint --color . + eslint --color --report-unused-disable-directives . else args=() [[ $TRAVIS_OS_NAME == osx ]] && args=('--qute-bdd-webengine' '--no-xvfb') diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py index 3e88da43b..1c0b2224b 100755 --- a/scripts/dev/run_vulture.py +++ b/scripts/dev/run_vulture.py @@ -41,7 +41,7 @@ from qutebrowser.browser import qutescheme from qutebrowser.config import configtypes -def whitelist_generator(): +def whitelist_generator(): # noqa """Generator which yields lines to add to a vulture whitelist.""" # qutebrowser commands for cmd in cmdutils.cmd_dict.values(): @@ -108,6 +108,8 @@ def whitelist_generator(): yield 'qutebrowser.config.configexc.ConfigErrorDesc.traceback' yield 'qutebrowser.config.configfiles.ConfigAPI.load_autoconfig' yield 'types.ModuleType.c' # configfiles:read_config_py + for name in ['configdir', 'datadir']: + yield 'qutebrowser.config.configfiles.ConfigAPI.' + name yield 'include_aliases' diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py index 9bfd346b2..f12d6601b 100755 --- a/scripts/dev/src2asciidoc.py +++ b/scripts/dev/src2asciidoc.py @@ -416,8 +416,8 @@ def _generate_setting_option(f, opt): f.write("=== {}".format(opt.name) + "\n") f.write(opt.description + "\n") f.write("\n") - f.write('Type: <>\n'.format( - typ=opt.typ.__class__.__name__)) + typ = opt.typ.get_name().replace(',', ',') + f.write('Type: <>\n'.format(typ=typ)) f.write("\n") valid_values = opt.typ.get_valid_values() diff --git a/scripts/setupcommon.py b/scripts/setupcommon.py index d5e13ec72..a2e4dfca9 100644 --- a/scripts/setupcommon.py +++ b/scripts/setupcommon.py @@ -18,11 +18,9 @@ # along with qutebrowser. If not, see . -"""Data used by setup.py and scripts/freeze.py.""" +"""Data used by setup.py and the PyInstaller qutebrowser.spec.""" import sys -import re -import ast import os import os.path import subprocess @@ -30,42 +28,16 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) if sys.hexversion >= 0x03000000: - _open = open + open_file = open else: import codecs - _open = codecs.open + open_file = codecs.open BASEDIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), os.path.pardir) -def read_file(name): - """Get the string contained in the file named name.""" - with _open(name, 'r', encoding='utf-8') as f: - return f.read() - - -def _get_constant(name): - """Read a __magic__ constant from qutebrowser/__init__.py. - - We don't import qutebrowser here because it can go wrong for multiple - reasons. Instead we use re/ast to get the value directly from the source - file. - - Args: - name: The name of the argument to get. - - Return: - The value of the argument. - """ - field_re = re.compile(r'__{}__\s+=\s+(.*)'.format(re.escape(name))) - path = os.path.join(BASEDIR, 'qutebrowser', '__init__.py') - line = field_re.search(read_file(path)).group(1) - value = ast.literal_eval(line) - return value - - def _git_str(): """Try to find out git version. @@ -95,37 +67,5 @@ def write_git_file(): if gitstr is None: gitstr = '' path = os.path.join(BASEDIR, 'qutebrowser', 'git-commit-id') - with _open(path, 'w', encoding='ascii') as f: + with open_file(path, 'w', encoding='ascii') as f: f.write(gitstr) - - -setupdata = { - 'name': 'qutebrowser', - 'version': '.'.join(str(e) for e in _get_constant('version_info')), - 'description': _get_constant('description'), - 'long_description': read_file('README.asciidoc'), - 'url': 'https://www.qutebrowser.org/', - 'requires': ['pypeg2', 'jinja2', 'pygments', 'PyYAML', 'attrs'], - 'author': _get_constant('author'), - 'author_email': _get_constant('email'), - 'license': _get_constant('license'), - 'classifiers': [ - 'Development Status :: 3 - Alpha', - 'Environment :: X11 Applications :: Qt', - 'Intended Audience :: End Users/Desktop', - 'License :: OSI Approved :: GNU General Public License v3 or later ' - '(GPLv3+)', - 'Natural Language :: English', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: Microsoft :: Windows :: Windows XP', - 'Operating System :: Microsoft :: Windows :: Windows 7', - 'Operating System :: POSIX :: Linux', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Topic :: Internet', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Internet :: WWW/HTTP :: Browsers', - ], - 'keywords': 'pyqt browser web qt webkit', -} diff --git a/setup.py b/setup.py index 7bfd968f6..712383839 100755 --- a/setup.py +++ b/setup.py @@ -21,6 +21,8 @@ """setuptools installer script for qutebrowser.""" +import re +import ast import os import os.path @@ -35,6 +37,32 @@ except NameError: BASEDIR = None +def read_file(name): + """Get the string contained in the file named name.""" + with common.open_file(name, 'r', encoding='utf-8') as f: + return f.read() + + +def _get_constant(name): + """Read a __magic__ constant from qutebrowser/__init__.py. + + We don't import qutebrowser here because it can go wrong for multiple + reasons. Instead we use re/ast to get the value directly from the source + file. + + Args: + name: The name of the argument to get. + + Return: + The value of the argument. + """ + field_re = re.compile(r'__{}__\s+=\s+(.*)'.format(re.escape(name))) + path = os.path.join(BASEDIR, 'qutebrowser', '__init__.py') + line = field_re.search(read_file(path)).group(1) + value = ast.literal_eval(line) + return value + + try: common.write_git_file() setuptools.setup( @@ -42,10 +70,35 @@ try: include_package_data=True, entry_points={'gui_scripts': ['qutebrowser = qutebrowser.qutebrowser:main']}, - test_suite='qutebrowser.test', zip_safe=True, install_requires=['pypeg2', 'jinja2', 'pygments', 'PyYAML', 'attrs'], - **common.setupdata + name='qutebrowser', + version='.'.join(str(e) for e in _get_constant('version_info')), + description=_get_constant('description'), + long_description=read_file('README.asciidoc'), + url='https://www.qutebrowser.org/', + author=_get_constant('author'), + author_email=_get_constant('email'), + license=_get_constant('license'), + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: X11 Applications :: Qt', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: GNU General Public License v3 or later ' + '(GPLv3+)', + 'Natural Language :: English', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX :: Linux', + 'Operating System :: MacOS', + 'Operating System :: POSIX :: BSD', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Topic :: Internet', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Browsers', + ], + keywords='pyqt browser web qt webkit qtwebkit qtwebengine', ) finally: if BASEDIR is not None: diff --git a/tests/conftest.py b/tests/conftest.py index db50afcd6..2ccf646dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,7 @@ import qutebrowser.app # To register commands # Set hypothesis settings hypothesis.settings.register_profile('default', - hypothesis.settings(deadline=400)) + hypothesis.settings(deadline=600)) hypothesis.settings.load_profile('default') diff --git a/tests/end2end/features/adblock.feature b/tests/end2end/features/adblock.feature deleted file mode 100644 index c400df25f..000000000 --- a/tests/end2end/features/adblock.feature +++ /dev/null @@ -1,8 +0,0 @@ -# vim: ft=cucumber fileencoding=utf-8 sts=4 sw=4 et: - -Feature: Ad blocking - - Scenario: Simple adblock update - When I set up "simple" as block lists - And I run :adblock-update - Then the message "adblock: Read 1 hosts from 1 sources." should be shown diff --git a/tests/end2end/features/completion.feature b/tests/end2end/features/completion.feature index d78d170c6..153c53d71 100644 --- a/tests/end2end/features/completion.feature +++ b/tests/end2end/features/completion.feature @@ -76,7 +76,9 @@ Feature: Using completion And I open data/hello2.txt in a new tab And I run :set-cmd-text -s :buffer And I run :completion-item-focus next + And I wait for "setting text = ':buffer 0/1', *" in the log And I run :completion-item-focus next + And I wait for "setting text = ':buffer 0/2', *" in the log And I run :completion-item-del Then the following tabs should be open: - data/hello.txt (active) diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index 889e9e03e..07db63a5f 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -246,7 +246,7 @@ Feature: Using hints Scenario: Ignoring key presses after auto-following hints When I set hints.auto_follow_timeout to 1000 And I set hints.mode to number - And I run :bind --force , message-error "This error message was triggered via a keybinding which should have been inhibited" + And I run :bind , message-error "This error message was triggered via a keybinding which should have been inhibited" And I open data/hints/html/simple.html And I hint with args "all" And I press the key "f" @@ -259,7 +259,7 @@ Feature: Using hints Scenario: Turning off auto_follow_timeout When I set hints.auto_follow_timeout to 0 And I set hints.mode to number - And I run :bind --force , message-info "Keypress worked!" + And I run :bind , message-info "Keypress worked!" And I open data/hints/html/simple.html And I hint with args "all" And I press the key "f" @@ -362,6 +362,7 @@ Feature: Using hints And I set hints.mode to letter And I hint with args "--mode number all" And I press the key "s" + And I wait for "Filtering hints on 's'" in the log And I run :follow-hint 1 Then data/numbers/7.txt should be loaded diff --git a/tests/end2end/features/marks.feature b/tests/end2end/features/marks.feature index 31ddd034d..605bd3971 100644 --- a/tests/end2end/features/marks.feature +++ b/tests/end2end/features/marks.feature @@ -21,7 +21,7 @@ Feature: Setting positional marks Scenario: Jumping back after jumping to a particular percentage When I run :scroll-px 10 20 And I wait until the scroll position changed to 10/20 - And I run :scroll-perc 100 + And I run :scroll-to-perc 100 And I wait until the scroll position changed And I run :jump-mark "'" And I wait until the scroll position changed to 10/20 @@ -116,7 +116,7 @@ Feature: Setting positional marks Scenario: Hovering a hint does not set the ' mark When I run :scroll-px 30 20 And I wait until the scroll position changed to 30/20 - And I run :scroll-perc 0 + And I run :scroll-to-perc 0 And I wait until the scroll position changed And I hint with args "links hover" and follow s And I run :jump-mark "'" diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 110401048..b40eb815f 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -47,6 +47,14 @@ Feature: Various utility commands. When I run :set-cmd-text foo Then the error "Invalid command text 'foo'." should be shown + Scenario: :set-cmd-text with run on count flag and no count + When I run :set-cmd-text --run-on-count :message-info "Hello World" + Then "message:info:86 Hello World" should not be logged + + Scenario: :set-cmd-text with run on count flag and a count + When I run :set-cmd-text --run-on-count :message-info "Hello World" with count 1 + Then the message "Hello World" should be shown + ## :jseval Scenario: :jseval @@ -399,6 +407,7 @@ Feature: Various utility commands. When I open data/hello2.txt in a new tab And I open data/hello3.txt in a new window And I run :window-only + And I wait for "Closing window *" in the log Then the session should look like: windows: - tabs: @@ -527,6 +536,11 @@ Feature: Various utility commands. And I open data/numbers/3.txt Then no crash should happen + Scenario: Simple adblock update + When I set up "simple" as block lists + And I run :adblock-update + Then the message "adblock: Read 1 hosts from 1 sources." should be shown + ## Spellcheck @qtwebkit_skip @qt>=5.8 @cannot_have_dict=af-ZA @@ -537,4 +551,4 @@ Feature: Various utility commands. @qtwebkit_skip @qt>=5.8 @must_have_dict=en-US Scenario: Set valid and installed language When I run :set spellcheck.languages ["en-US"] - Then the option spellcheck.languages should be set to ["en-US"] + Then the option spellcheck.languages should be set to ["en-US"] \ No newline at end of file diff --git a/tests/end2end/features/qutescheme.feature b/tests/end2end/features/qutescheme.feature index 10ef9932f..c60e18f3a 100644 --- a/tests/end2end/features/qutescheme.feature +++ b/tests/end2end/features/qutescheme.feature @@ -77,7 +77,7 @@ Feature: Special qute:// pages When I set ignore_case to never And I open qute://settings # scroll to the right - the table does not fit in the default screen - And I run :scroll-perc -x 100 + And I run :scroll-to-perc -x 100 And I run :jseval document.getElementById('input-ignore_case').value = '' And I run :click-element id input-ignore_case And I wait for "Entering mode KeyMode.insert *" in the log @@ -91,7 +91,7 @@ Feature: Special qute:// pages Scenario: Focusing input fields in qute://settings and entering invalid value When I open qute://settings # scroll to the right - the table does not fit in the default screen - And I run :scroll-perc -x 100 + And I run :scroll-to-perc -x 100 And I run :jseval document.getElementById('input-ignore_case').value = '' And I run :click-element id input-ignore_case And I wait for "Entering mode KeyMode.insert *" in the log diff --git a/tests/end2end/features/scroll.feature b/tests/end2end/features/scroll.feature index d5e339f1a..e77b57dc8 100644 --- a/tests/end2end/features/scroll.feature +++ b/tests/end2end/features/scroll.feature @@ -156,86 +156,86 @@ Feature: Scrolling And I run :scroll down Then the page should not be scrolled - ## :scroll-perc + ## :scroll-to-perc - Scenario: Scrolling to bottom with :scroll-perc - When I run :scroll-perc 100 + Scenario: Scrolling to bottom with :scroll-to-perc + When I run :scroll-to-perc 100 Then the page should be scrolled vertically - Scenario: Scrolling to bottom and to top with :scroll-perc - When I run :scroll-perc 100 + Scenario: Scrolling to bottom and to top with :scroll-to-perc + When I run :scroll-to-perc 100 And I wait until the scroll position changed - And I run :scroll-perc 0 + And I run :scroll-to-perc 0 And I wait until the scroll position changed to 0/0 Then the page should not be scrolled - Scenario: Scrolling to middle with :scroll-perc - When I run :scroll-perc 50 + Scenario: Scrolling to middle with :scroll-to-perc + When I run :scroll-to-perc 50 Then the page should be scrolled vertically - Scenario: Scrolling to middle with :scroll-perc (float) - When I run :scroll-perc 50.5 + Scenario: Scrolling to middle with :scroll-to-perc (float) + When I run :scroll-to-perc 50.5 Then the page should be scrolled vertically - Scenario: Scrolling to middle and to top with :scroll-perc - When I run :scroll-perc 50 + Scenario: Scrolling to middle and to top with :scroll-to-perc + When I run :scroll-to-perc 50 And I wait until the scroll position changed - And I run :scroll-perc 0 + And I run :scroll-to-perc 0 And I wait until the scroll position changed to 0/0 Then the page should not be scrolled - Scenario: Scrolling to right with :scroll-perc - When I run :scroll-perc --horizontal 100 + Scenario: Scrolling to right with :scroll-to-perc + When I run :scroll-to-perc --horizontal 100 Then the page should be scrolled horizontally - Scenario: Scrolling to right and to left with :scroll-perc - When I run :scroll-perc --horizontal 100 + Scenario: Scrolling to right and to left with :scroll-to-perc + When I run :scroll-to-perc --horizontal 100 And I wait until the scroll position changed - And I run :scroll-perc --horizontal 0 + And I run :scroll-to-perc --horizontal 0 And I wait until the scroll position changed to 0/0 Then the page should not be scrolled - Scenario: Scrolling to middle (horizontally) with :scroll-perc - When I run :scroll-perc --horizontal 50 + Scenario: Scrolling to middle (horizontally) with :scroll-to-perc + When I run :scroll-to-perc --horizontal 50 Then the page should be scrolled horizontally - Scenario: Scrolling to middle and to left with :scroll-perc - When I run :scroll-perc --horizontal 50 + Scenario: Scrolling to middle and to left with :scroll-to-perc + When I run :scroll-to-perc --horizontal 50 And I wait until the scroll position changed - And I run :scroll-perc --horizontal 0 + And I run :scroll-to-perc --horizontal 0 And I wait until the scroll position changed to 0/0 Then the page should not be scrolled - Scenario: :scroll-perc without argument - When I run :scroll-perc + Scenario: :scroll-to-perc without argument + When I run :scroll-to-perc Then the page should be scrolled vertically - Scenario: :scroll-perc without argument and --horizontal - When I run :scroll-perc --horizontal + Scenario: :scroll-to-perc without argument and --horizontal + When I run :scroll-to-perc --horizontal Then the page should be scrolled horizontally - Scenario: :scroll-perc with count - When I run :scroll-perc with count 50 + Scenario: :scroll-to-perc with count + When I run :scroll-to-perc with count 50 Then the page should be scrolled vertically @qtwebengine_skip: Causes memory leak... - Scenario: :scroll-perc with a very big value - When I run :scroll-perc 99999999999 + Scenario: :scroll-to-perc with a very big value + When I run :scroll-to-perc 99999999999 Then no crash should happen - Scenario: :scroll-perc on a page without scrolling + Scenario: :scroll-to-perc on a page without scrolling When I open data/hello.txt - And I run :scroll-perc 20 + And I run :scroll-to-perc 20 Then the page should not be scrolled - Scenario: :scroll-perc with count and argument - When I run :scroll-perc 0 with count 50 + Scenario: :scroll-to-perc with count and argument + When I run :scroll-to-perc 0 with count 50 Then the page should be scrolled vertically # https://github.com/qutebrowser/qutebrowser/issues/1821 - Scenario: :scroll-perc without doctype + Scenario: :scroll-to-perc without doctype When I open data/scroll/no_doctype.html - And I run :scroll-perc 100 + And I run :scroll-to-perc 100 Then the page should be scrolled vertically ## :scroll-page @@ -280,14 +280,14 @@ Feature: Scrolling Then the page should not be scrolled Scenario: :scroll-page with --bottom-navigate - When I run :scroll-perc 100 + When I run :scroll-to-perc 100 And I wait until the scroll position changed And I run :scroll-page --bottom-navigate next 0 1 Then data/hello2.txt should be loaded Scenario: :scroll-page with --bottom-navigate and zoom When I run :zoom 200 - And I run :scroll-perc 100 + And I run :scroll-to-perc 100 And I wait until the scroll position changed And I run :scroll-page --bottom-navigate next 0 1 Then data/hello2.txt should be loaded @@ -317,7 +317,7 @@ Feature: Scrolling Scenario: Relative scroll position with a position:absolute page When I open data/scroll/position_absolute.html - And I run :scroll-perc 100 + And I run :scroll-to-perc 100 And I wait until the scroll position changed And I run :scroll-page --bottom-navigate next 0 1 Then data/hello2.txt should be loaded diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index bcf7f15f0..b510a4f66 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -897,9 +897,9 @@ Feature: Tab management # :buffer - Scenario: :buffer without args + Scenario: :buffer without args or count When I run :buffer - Then the error "buffer: The following arguments are required: index" should be shown + Then the error "buffer: Either a count or the argument index must be specified." should be shown Scenario: :buffer with a matching title When I open data/title.html @@ -953,7 +953,7 @@ Feature: Tab management And I run :buffer "99/1" Then the error "There's no window with id 99!" should be shown - @qtwebengine_flaky + @skip # Too flaky Scenario: :buffer with matching window index Given I have a fresh instance When I open data/title.html diff --git a/tests/end2end/features/test_misc_bdd.py b/tests/end2end/features/test_misc_bdd.py index d8e2fd07e..19d94ce6a 100644 --- a/tests/end2end/features/test_misc_bdd.py +++ b/tests/end2end/features/test_misc_bdd.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import json + import pytest_bdd as bdd bdd.scenarios('misc.feature') @@ -26,3 +28,10 @@ def pdf_exists(quteproc, tmpdir, filename): path = tmpdir / filename data = path.read_binary() assert data.startswith(b'%PDF') + + +@bdd.when(bdd.parsers.parse('I set up "{lists}" as block lists')) +def set_up_blocking(quteproc, lists, server): + url = 'http://localhost:{}/data/adblock/'.format(server.port) + urls = [url + item.strip() for item in lists.split(',')] + quteproc.set_setting('content.host_blocking.lists', json.dumps(urls)) diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index 73f885cfe..b8105841b 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -458,7 +458,7 @@ class QuteProc(testprocess.Process): __tracebackhide__ = (lambda e: e.errisinstance(testprocess.WaitForTimeout)) xfail = self.request.node.get_marker('xfail') - if xfail and xfail.args[0]: + if xfail and (not xfail.args or xfail.args[0]): kwargs['divisor'] = 10 else: kwargs['divisor'] = 1 @@ -494,7 +494,13 @@ class QuteProc(testprocess.Process): if skip_texts: pytest.skip(', '.join(skip_texts)) - def _after_start(self): + def before_test(self): + """Clear settings before every test.""" + super().before_test() + self.send_cmd(':config-clear') + self._init_settings() + + def _init_settings(self): """Adjust some qutebrowser settings after starting.""" settings = [ ('messages.timeout', '0'), diff --git a/tests/end2end/fixtures/test_quteprocess.py b/tests/end2end/fixtures/test_quteprocess.py index 39cbe598c..b537960f4 100644 --- a/tests/end2end/fixtures/test_quteprocess.py +++ b/tests/end2end/fixtures/test_quteprocess.py @@ -47,6 +47,9 @@ class FakeConfig: '--verbose': False, } + def __init__(self): + self.webengine = False + def getoption(self, name): return self.ARGS[name] diff --git a/tests/end2end/fixtures/testprocess.py b/tests/end2end/fixtures/testprocess.py index 60874e622..a4b136193 100644 --- a/tests/end2end/fixtures/testprocess.py +++ b/tests/end2end/fixtures/testprocess.py @@ -242,7 +242,8 @@ class Process(QObject): self._after_start() return - raise WaitForTimeout("Timed out while waiting for process start.") + raise WaitForTimeout("Timed out while waiting for process start.\n" + + _render_log(self.captured_log)) def _start(self, args, env): """Actually start the process.""" diff --git a/tests/end2end/test_insert_mode.py b/tests/end2end/test_insert_mode.py index a0a1ef033..746fde50f 100644 --- a/tests/end2end/test_insert_mode.py +++ b/tests/end2end/test_insert_mode.py @@ -22,23 +22,18 @@ import pytest -@pytest.mark.parametrize(['file_name', 'elem_id', 'source', 'input_text', - 'auto_insert'], [ - ('textarea.html', 'qute-textarea', 'clipboard', 'qutebrowser', 'false'), - ('textarea.html', 'qute-textarea', 'keypress', 'superqutebrowser', - 'false'), - ('input.html', 'qute-input', 'clipboard', 'amazingqutebrowser', 'false'), - ('input.html', 'qute-input', 'keypress', 'awesomequtebrowser', 'false'), - ('autofocus.html', 'qute-input-autofocus', 'keypress', 'cutebrowser', - 'true'), +@pytest.mark.parametrize(['file_name', 'elem_id', 'source', 'input_text'], [ + ('textarea.html', 'qute-textarea', 'clipboard', 'qutebrowser'), + ('textarea.html', 'qute-textarea', 'keypress', 'superqutebrowser'), + ('input.html', 'qute-input', 'clipboard', 'amazingqutebrowser'), + ('input.html', 'qute-input', 'keypress', 'awesomequtebrowser'), + ('autofocus.html', 'qute-input-autofocus', 'keypress', 'cutebrowser'), ]) @pytest.mark.parametrize('zoom', [100, 125, 250]) -def test_insert_mode(file_name, elem_id, source, input_text, auto_insert, zoom, +def test_insert_mode(file_name, elem_id, source, input_text, zoom, quteproc, request): url_path = 'data/insert_mode_settings/html/{}'.format(file_name) quteproc.open_path(url_path) - - quteproc.set_setting('input.insert_mode.auto_load', auto_insert) quteproc.send_cmd(':zoom {}'.format(zoom)) quteproc.send_cmd(':click-element --force-event id {}'.format(elem_id)) @@ -57,6 +52,24 @@ def test_insert_mode(file_name, elem_id, source, input_text, auto_insert, zoom, quteproc.send_cmd(':leave-mode') +@pytest.mark.parametrize('auto_load, background, insert_mode', [ + (False, False, False), # auto_load disabled + (True, False, True), # enabled and foreground tab + (True, True, False), # background tab +]) +def test_auto_load(quteproc, auto_load, background, insert_mode): + quteproc.set_setting('input.insert_mode.auto_load', str(auto_load)) + url_path = 'data/insert_mode_settings/html/autofocus.html' + quteproc.open_path(url_path, new_bg_tab=background) + + log_message = 'Entering mode KeyMode.insert (reason: *)' + if insert_mode: + quteproc.wait_for(message=log_message) + quteproc.send_cmd(':leave-mode') + else: + quteproc.ensure_not_logged(message=log_message) + + def test_auto_leave_insert_mode(quteproc): url_path = 'data/insert_mode_settings/html/autofocus.html' quteproc.open_path(url_path) diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index 03626dc14..ecb824f95 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -319,3 +319,18 @@ def test_qute_settings_persistence(short_tmpdir, request, quteproc_new): quteproc_new.start(args) assert quteproc_new.get_setting('ignore_case') == 'always' + + +@pytest.mark.no_xvfb +@pytest.mark.no_ci +def test_force_software_rendering(request, quteproc_new): + """Make sure we can force software rendering with -s.""" + if not request.config.webengine: + pytest.skip("Only runs with QtWebEngine") + + args = (_base_args(request.config) + + ['--temp-basedir', '-s', 'force_software_rendering', 'true']) + quteproc_new.start(args) + quteproc_new.open_path('chrome://gpu') + message = 'Canvas: Software only, hardware acceleration unavailable' + assert message in quteproc_new.get_content() diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 0bab0ce96..15d2ce8a0 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -336,6 +336,7 @@ class FakeCommand: deprecated = attr.ib(False) completion = attr.ib(None) maxsplit = attr.ib(None) + takes_count = attr.ib(lambda: False) class FakeTimer(QObject): @@ -417,9 +418,6 @@ class FakeYamlConfig: self.loaded = False self._values = {} - def load(self): - self.loaded = True - def __contains__(self, item): return item in self._values @@ -432,6 +430,12 @@ class FakeYamlConfig: def __getitem__(self, key): return self._values[key] + def unset(self, name): + self._values.pop(name, None) + + def clear(self): + self._values = [] + class StatusBarCommandStub(QLineEdit): diff --git a/tests/helpers/test_stubs.py b/tests/helpers/test_stubs.py index 10fa9e5db..a5c1d27fb 100644 --- a/tests/helpers/test_stubs.py +++ b/tests/helpers/test_stubs.py @@ -36,8 +36,8 @@ def test_timeout(timer): func2 = mock.Mock() timer.timeout.connect(func) timer.timeout.connect(func2) - assert not func.called - assert not func2.called + func.assert_not_called() + func2.assert_not_called() timer.timeout.emit() func.assert_called_once_with() func2.assert_called_once_with() @@ -49,7 +49,7 @@ def test_disconnect_all(timer): timer.timeout.connect(func) timer.timeout.disconnect() timer.timeout.emit() - assert not func.called + func.assert_not_called() def test_disconnect_one(timer): @@ -58,7 +58,7 @@ def test_disconnect_one(timer): timer.timeout.connect(func) timer.timeout.disconnect(func) timer.timeout.emit() - assert not func.called + func.assert_not_called() def test_disconnect_all_invalid(timer): @@ -74,8 +74,8 @@ def test_disconnect_one_invalid(timer): timer.timeout.connect(func1) with pytest.raises(TypeError): timer.timeout.disconnect(func2) - assert not func1.called - assert not func2.called + func1.assert_not_called() + func2.assert_not_called() timer.timeout.emit() func1.assert_called_once_with() diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py index 51157fccb..1454e259b 100644 --- a/tests/unit/browser/test_history.py +++ b/tests/unit/browser/test_history.py @@ -284,6 +284,22 @@ def test_import_txt(hist, data_tmpdir, monkeypatch, stubs): assert (data_tmpdir / 'history.bak').exists() +def test_import_txt_existing_backup(hist, data_tmpdir, monkeypatch, stubs): + monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer) + histfile = data_tmpdir / 'history' + bakfile = data_tmpdir / 'history.bak' + histfile.write('12345 http://example.com/ title') + bakfile.write('12346 http://qutebrowser.org/') + + hist.import_txt() + + assert list(hist) == [('http://example.com/', 'title', 12345, False)] + + assert not histfile.exists() + assert bakfile.read().split('\n') == ['12346 http://qutebrowser.org/', + '12345 http://example.com/ title'] + + @pytest.mark.parametrize('line', [ '', '#12345 http://example.com/commented', diff --git a/tests/unit/browser/test_signalfilter.py b/tests/unit/browser/test_signalfilter.py index 9d191e7c6..fb4ed474c 100644 --- a/tests/unit/browser/test_signalfilter.py +++ b/tests/unit/browser/test_signalfilter.py @@ -26,7 +26,6 @@ import pytest from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject from qutebrowser.browser import signalfilter -from qutebrowser.utils import objreg class Signaller(QObject): @@ -66,18 +65,11 @@ def objects(): return Objects(signal_filter=signal_filter, signaller=signaller) -@pytest.fixture -def tabbed_browser(stubs, win_registry): - tb = stubs.TabbedBrowserStub() - objreg.register('tabbed-browser', tb, scope='window', window=0) - yield tb - objreg.delete('tabbed-browser', scope='window', window=0) - - @pytest.mark.parametrize('index_of, emitted', [(0, True), (1, False)]) -def test_filtering(objects, tabbed_browser, index_of, emitted): - tabbed_browser.current_index = 0 - tabbed_browser.index_of = index_of +def test_filtering(objects, tabbed_browser_stubs, index_of, emitted): + browser = tabbed_browser_stubs[0] + browser.current_index = 0 + browser.index_of = index_of objects.signaller.signal.emit('foo') if emitted: assert objects.signaller.filtered_signal_arg == 'foo' @@ -86,9 +78,10 @@ def test_filtering(objects, tabbed_browser, index_of, emitted): @pytest.mark.parametrize('index_of, verb', [(0, 'emitting'), (1, 'ignoring')]) -def test_logging(caplog, objects, tabbed_browser, index_of, verb): - tabbed_browser.current_index = 0 - tabbed_browser.index_of = index_of +def test_logging(caplog, objects, tabbed_browser_stubs, index_of, verb): + browser = tabbed_browser_stubs[0] + browser.current_index = 0 + browser.index_of = index_of with caplog.at_level(logging.DEBUG, logger='signals'): objects.signaller.signal.emit('foo') @@ -99,9 +92,10 @@ def test_logging(caplog, objects, tabbed_browser, index_of, verb): @pytest.mark.parametrize('index_of', [0, 1]) -def test_no_logging(caplog, objects, tabbed_browser, index_of): - tabbed_browser.current_index = 0 - tabbed_browser.index_of = index_of +def test_no_logging(caplog, objects, tabbed_browser_stubs, index_of): + browser = tabbed_browser_stubs[0] + browser.current_index = 0 + browser.index_of = index_of with caplog.at_level(logging.DEBUG, logger='signals'): objects.signaller.link_hovered.emit('foo') @@ -109,9 +103,10 @@ def test_no_logging(caplog, objects, tabbed_browser, index_of): assert not caplog.records -def test_runtime_error(objects, tabbed_browser): +def test_runtime_error(objects, tabbed_browser_stubs): """Test that there's no crash if indexOf() raises RuntimeError.""" - tabbed_browser.current_index = 0 - tabbed_browser.index_of = RuntimeError + browser = tabbed_browser_stubs[0] + browser.current_index = 0 + browser.index_of = RuntimeError objects.signaller.signal.emit('foo') assert objects.signaller.filtered_signal_arg is None diff --git a/tests/unit/commands/test_argparser.py b/tests/unit/commands/test_argparser.py index b44fd5dce..4cd3e7ee1 100644 --- a/tests/unit/commands/test_argparser.py +++ b/tests/unit/commands/test_argparser.py @@ -25,7 +25,7 @@ import pytest from PyQt5.QtCore import QUrl from qutebrowser.commands import argparser, cmdexc -from qutebrowser.utils import usertypes, objreg +from qutebrowser.utils import usertypes Enum = usertypes.enum('Enum', ['foo', 'foo_bar']) @@ -37,13 +37,6 @@ class TestArgumentParser: def parser(self): return argparser.ArgumentParser('foo') - @pytest.fixture - def tabbed_browser(self, stubs, win_registry): - tb = stubs.TabbedBrowserStub() - objreg.register('tabbed-browser', tb, scope='window', window=0) - yield tb - objreg.delete('tabbed-browser', scope='window', window=0) - def test_name(self, parser): assert parser.name == 'foo' @@ -60,14 +53,14 @@ class TestArgumentParser: match="Unrecognized arguments: --foo"): parser.parse_args(['--foo']) - def test_help(self, parser, tabbed_browser): + def test_help(self, parser, tabbed_browser_stubs): parser.add_argument('--help', action=argparser.HelpAction, nargs=0) with pytest.raises(argparser.ArgumentParserExit): parser.parse_args(['--help']) expected_url = QUrl('qute://help/commands.html#foo') - assert tabbed_browser.opened_url == expected_url + assert tabbed_browser_stubs[1].opened_url == expected_url @pytest.mark.parametrize('types, value, expected', [ diff --git a/tests/unit/commands/test_runners.py b/tests/unit/commands/test_runners.py index 5e9aa3dfc..75558b390 100644 --- a/tests/unit/commands/test_runners.py +++ b/tests/unit/commands/test_runners.py @@ -22,7 +22,6 @@ import pytest from qutebrowser.commands import runners, cmdexc -from qutebrowser.config import configtypes class TestCommandParser: @@ -47,7 +46,6 @@ class TestCommandParser: if not cmdline_test.cmd: pytest.skip("Empty command") - monkeypatch.setattr(configtypes.Command, 'unvalidated', True) config_stub.val.aliases = {'alias_name': cmdline_test.cmd} parser = runners.CommandParser() diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py index 8914a956b..8575cf02d 100644 --- a/tests/unit/completion/test_completer.py +++ b/tests/unit/completion/test_completer.py @@ -120,7 +120,7 @@ def cmdutils_patch(monkeypatch, stubs, miscmodels_patch): @cmdutils.argument('win_id', win_id=True) @cmdutils.argument('command', completion=miscmodels_patch.command) - def bind(key, win_id, command=None, *, mode='normal', force=False): + def bind(key, win_id, command=None, *, mode='normal'): """docstring.""" pass diff --git a/tests/unit/completion/test_completionmodel.py b/tests/unit/completion/test_completionmodel.py index 9e73e533a..292349730 100644 --- a/tests/unit/completion/test_completionmodel.py +++ b/tests/unit/completion/test_completionmodel.py @@ -103,7 +103,7 @@ def test_delete_cur_item_no_func(): parent = model.index(0, 0) with pytest.raises(cmdexc.CommandError): model.delete_cur_item(model.index(0, 0, parent)) - assert not callback.called + callback.assert_not_called() def test_delete_cur_item_no_cat(): @@ -114,4 +114,4 @@ def test_delete_cur_item_no_cat(): model.rowsRemoved.connect(callback) with pytest.raises(qtutils.QtValueError): model.delete_cur_item(QModelIndex()) - assert not callback.called + callback.assert_not_called() diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index 7eb7fe2b5..22c71a2b7 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -242,7 +242,7 @@ def test_completion_item_del_no_selection(completionview): completionview.set_model(model) with pytest.raises(cmdexc.CommandError, match='No item selected!'): completionview.completion_item_del() - assert not func.called + func.assert_not_called() def test_resize_no_model(completionview, qtbot): diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py index 8ae3bb1f4..c093513cb 100644 --- a/tests/unit/completion/test_histcategory.py +++ b/tests/unit/completion/test_histcategory.py @@ -140,20 +140,24 @@ def test_sorting(max_items, before, after, model_validator, hist, config_stub): def test_remove_rows(hist, model_validator): - hist.insert({'url': 'foo', 'title': 'Foo'}) - hist.insert({'url': 'bar', 'title': 'Bar'}) + hist.insert({'url': 'foo', 'title': 'Foo', 'last_atime': 0}) + hist.insert({'url': 'bar', 'title': 'Bar', 'last_atime': 0}) cat = histcategory.HistoryCategory() model_validator.set_model(cat) cat.set_pattern('') hist.delete('url', 'foo') cat.removeRows(0, 1) - model_validator.validate([('bar', 'Bar', '')]) + model_validator.validate([('bar', 'Bar', '1970-01-01')]) def test_remove_rows_fetch(hist): """removeRows should fetch enough data to make the current index valid.""" # we cannot use model_validator as it will fetch everything up front - hist.insert_batch({'url': [str(i) for i in range(300)]}) + hist.insert_batch({ + 'url': [str(i) for i in range(300)], + 'title': [str(i) for i in range(300)], + 'last_atime': [0] * 300, + }) cat = histcategory.HistoryCategory() cat.set_pattern('') diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 4e65254c9..30241b1b3 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -119,6 +119,7 @@ def configdata_stub(monkeypatch, configdata_init): 'normal': collections.OrderedDict([ ('', 'quit'), ('ZQ', 'quit'), + ('I', 'invalid'), ]) }, backends=[], @@ -538,7 +539,8 @@ def test_setting_option_completion(qtmodeltester, config_stub, "Options": [ ('aliases', 'Aliases for commands.', '{"q": "quit"}'), ('bindings.commands', 'Default keybindings', - '{"normal": {"": "quit", "ZQ": "quit"}}'), + '{"normal": {"": "quit", "ZQ": "quit", ' + '"I": "invalid"}}'), ('bindings.default', 'Default keybindings', '{"normal": {"": "quit"}}'), ] @@ -573,6 +575,25 @@ def test_bind_completion(qtmodeltester, cmdutils_stub, config_stub, }) +def test_bind_completion_invalid(cmdutils_stub, config_stub, key_config_stub, + configdata_stub, info): + """Test command completion with an invalid command bound.""" + model = configmodel.bind('I', info=info) + model.set_pattern('') + + _check_completions(model, { + "Current": [ + ('invalid', 'Invalid command!', 'I'), + ], + "Commands": [ + ('open', 'open a url', ''), + ('q', "Alias for 'quit'", ''), + ('quit', 'quit qutebrowser', 'ZQ, '), + ('scroll', 'Scroll the current tab in the given direction.', '') + ], + }) + + def test_bind_completion_no_current(qtmodeltester, cmdutils_stub, config_stub, key_config_stub, configdata_stub, info): """Test keybinding completion with no current binding.""" diff --git a/tests/end2end/features/test_adblock_bdd.py b/tests/unit/config/conftest.py similarity index 61% rename from tests/end2end/features/test_adblock_bdd.py rename to tests/unit/config/conftest.py index 780e55a59..89767b786 100644 --- a/tests/end2end/features/test_adblock_bdd.py +++ b/tests/unit/config/conftest.py @@ -1,7 +1,6 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: +# Copyright 2017 Florian Bruhin (The Compiler) -# Copyright 2016-2017 Florian Bruhin (The Compiler) -# # This file is part of qutebrowser. # # qutebrowser is free software: you can redistribute it and/or modify @@ -17,15 +16,14 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -import json +"""Fixtures needed in various config test files.""" -import pytest_bdd as bdd +import pytest -bdd.scenarios('adblock.feature') +from qutebrowser.config import config -@bdd.when(bdd.parsers.parse('I set up "{lists}" as block lists')) -def set_up_blocking(quteproc, lists, server): - url = 'http://localhost:{}/data/adblock/'.format(server.port) - urls = [url + item.strip() for item in lists.split(',')] - quteproc.set_setting('content.host_blocking.lists', json.dumps(urls)) +@pytest.fixture +def keyconf(config_stub): + config_stub.val.aliases = {} + return config.KeyConfig(config_stub) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 6dff3c30a..a46e8e3de 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -18,20 +18,16 @@ """Tests for qutebrowser.config.config.""" -import sys import copy import types -import logging import unittest.mock import pytest -from PyQt5.QtCore import QObject, QUrl +from PyQt5.QtCore import QObject from PyQt5.QtGui import QColor -from qutebrowser import qutebrowser -from qutebrowser.commands import cmdexc from qutebrowser.config import config, configdata, configexc, configfiles -from qutebrowser.utils import objreg, usertypes +from qutebrowser.utils import usertypes from qutebrowser.misc import objects @@ -42,18 +38,12 @@ def configdata_init(): configdata.init() -@pytest.fixture -def keyconf(config_stub): - config_stub.val.aliases = {} - return config.KeyConfig(config_stub) - - class TestChangeFilter: @pytest.fixture(autouse=True) def cleanup_globals(self, monkeypatch): - """Make sure config._change_filters is cleaned up.""" - monkeypatch.setattr(config, '_change_filters', []) + """Make sure config.change_filters is cleaned up.""" + monkeypatch.setattr(config, 'change_filters', []) @pytest.mark.parametrize('option', ['foobar', 'tab', 'tabss', 'tabs.']) def test_unknown_option(self, option): @@ -65,7 +55,7 @@ class TestChangeFilter: def test_validate(self, option): cf = config.change_filter(option) cf.validate() - assert cf in config._change_filters + assert cf in config.change_filters @pytest.mark.parametrize('method', [True, False]) @pytest.mark.parametrize('option, changed, matches', [ @@ -182,38 +172,24 @@ class TestKeyConfig: config_stub.val.bindings.commands = {'normal': bindings} assert keyconf.get_reverse_bindings_for('normal') == expected - def test_bind_invalid_command(self, keyconf): - with pytest.raises(configexc.KeybindingError, - match='Invalid command: foobar'): - keyconf.bind('a', 'foobar', mode='normal') - - def test_bind_invalid_mode(self, keyconf): - with pytest.raises(configexc.KeybindingError, - match='completion-item-del: This command is only ' - 'allowed in command mode, not normal.'): - keyconf.bind('a', 'completion-item-del', mode='normal') - - @pytest.mark.parametrize('force', [True, False]) @pytest.mark.parametrize('key', ['a', '', 'b']) - def test_bind_duplicate(self, keyconf, config_stub, force, key): + def test_bind_duplicate(self, keyconf, config_stub, key): config_stub.val.bindings.default = {'normal': {'a': 'nop', '': 'nop'}} config_stub.val.bindings.commands = {'normal': {'b': 'nop'}} - if force: - keyconf.bind(key, 'message-info foo', mode='normal', force=True) - assert keyconf.get_command(key, 'normal') == 'message-info foo' - else: - with pytest.raises(configexc.DuplicateKeyError): - keyconf.bind(key, 'message-info foo', mode='normal') - assert keyconf.get_command(key, 'normal') == 'nop' + keyconf.bind(key, 'message-info foo', mode='normal') + assert keyconf.get_command(key, 'normal') == 'message-info foo' @pytest.mark.parametrize('mode', ['normal', 'caret']) - def test_bind(self, keyconf, config_stub, qtbot, no_bindings, mode): + @pytest.mark.parametrize('command', [ + 'message-info foo', + 'nop ;; wq', # https://github.com/qutebrowser/qutebrowser/issues/3002 + ]) + def test_bind(self, keyconf, config_stub, qtbot, no_bindings, + mode, command): config_stub.val.bindings.default = no_bindings config_stub.val.bindings.commands = no_bindings - command = 'message-info foo' - with qtbot.wait_signal(config_stub.changed): keyconf.bind('a', command, mode=mode) @@ -221,6 +197,16 @@ class TestKeyConfig: assert keyconf.get_bindings_for(mode)['a'] == command assert keyconf.get_command('a', mode) == command + def test_bind_mode_changing(self, keyconf, config_stub, no_bindings): + """Make sure we can bind to a command which changes the mode. + + https://github.com/qutebrowser/qutebrowser/issues/2989 + """ + config_stub.val.bindings.default = no_bindings + config_stub.val.bindings.commands = no_bindings + keyconf.bind('a', 'set-cmd-text :nop ;; rl-beginning-of-line', + mode='normal') + @pytest.mark.parametrize('key, normalized', [ ('a', 'a'), # default bindings ('b', 'b'), # custom bindings @@ -264,335 +250,18 @@ class TestKeyConfig: keyconf.unbind('foobar', mode='normal') -class TestSetConfigCommand: - - """Tests for :set.""" - - @pytest.fixture - def commands(self, config_stub, keyconf): - return config.ConfigCommands(config_stub, keyconf) - - @pytest.fixture - def tabbed_browser(self, stubs, win_registry): - tb = stubs.TabbedBrowserStub() - objreg.register('tabbed-browser', tb, scope='window', window=0) - yield tb - objreg.delete('tabbed-browser', scope='window', window=0) - - def test_set_no_args(self, commands, tabbed_browser): - """Run ':set'. - - Should open qute://settings.""" - commands.set(win_id=0) - assert tabbed_browser.opened_url == QUrl('qute://settings') - - def test_get(self, config_stub, commands, message_mock): - """Run ':set url.auto_search?'. - - Should show the value. - """ - config_stub.val.url.auto_search = 'never' - commands.set(win_id=0, option='url.auto_search?') - msg = message_mock.getmsg(usertypes.MessageLevel.info) - assert msg.text == 'url.auto_search = never' - - @pytest.mark.parametrize('temp', [True, False]) - @pytest.mark.parametrize('option, old_value, inp, new_value', [ - ('url.auto_search', 'naive', 'dns', 'dns'), - # https://github.com/qutebrowser/qutebrowser/issues/2962 - ('editor.command', ['gvim', '-f', '{}'], '[emacs, "{}"]', - ['emacs', '{}']), - ]) - def test_set_simple(self, monkeypatch, commands, config_stub, - temp, option, old_value, inp, new_value): - """Run ':set [-t] option value'. - - Should set the setting accordingly. - """ - monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit) - assert config_stub.get(option) == old_value - - commands.set(0, option, inp, temp=temp) - - assert config_stub.get(option) == new_value - - if temp: - assert option not in config_stub._yaml - else: - assert config_stub._yaml[option] == new_value - - @pytest.mark.parametrize('temp', [True, False]) - def test_set_temp_override(self, commands, config_stub, temp): - """Invoking :set twice. - - :set url.auto_search dns - :set -t url.auto_search never - - Should set the setting accordingly. - """ - assert config_stub.val.url.auto_search == 'naive' - commands.set(0, 'url.auto_search', 'dns') - commands.set(0, 'url.auto_search', 'never', temp=True) - - assert config_stub.val.url.auto_search == 'never' - assert config_stub._yaml['url.auto_search'] == 'dns' - - def test_set_print(self, config_stub, commands, message_mock): - """Run ':set -p url.auto_search never'. - - Should set show the value. - """ - assert config_stub.val.url.auto_search == 'naive' - commands.set(0, 'url.auto_search', 'dns', print_=True) - - assert config_stub.val.url.auto_search == 'dns' - msg = message_mock.getmsg(usertypes.MessageLevel.info) - assert msg.text == 'url.auto_search = dns' - - def test_set_toggle(self, commands, config_stub): - """Run ':set auto_save.session!'. - - Should toggle the value. - """ - assert not config_stub.val.auto_save.session - commands.set(0, 'auto_save.session!') - assert config_stub.val.auto_save.session - assert config_stub._yaml['auto_save.session'] - - def test_set_toggle_nonbool(self, commands, config_stub): - """Run ':set url.auto_search!'. - - Should show an error - """ - assert config_stub.val.url.auto_search == 'naive' - with pytest.raises(cmdexc.CommandError, match="set: Can't toggle " - "non-bool setting url.auto_search"): - commands.set(0, 'url.auto_search!') - assert config_stub.val.url.auto_search == 'naive' - - def test_set_toggle_print(self, commands, config_stub, message_mock): - """Run ':set -p auto_save.session!'. - - Should toggle the value and show the new value. - """ - commands.set(0, 'auto_save.session!', print_=True) - msg = message_mock.getmsg(usertypes.MessageLevel.info) - assert msg.text == 'auto_save.session = true' - - def test_set_invalid_option(self, commands): - """Run ':set foo bar'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, match="set: No option 'foo'"): - commands.set(0, 'foo', 'bar') - - def test_set_invalid_value(self, commands): - """Run ':set auto_save.session blah'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, - match="set: Invalid value 'blah' - must be a " - "boolean!"): - commands.set(0, 'auto_save.session', 'blah') - - def test_set_wrong_backend(self, commands, monkeypatch): - monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine) - with pytest.raises(cmdexc.CommandError, - match="set: This setting is not available with the " - "QtWebEngine backend!"): - commands.set(0, 'content.cookies.accept', 'all') - - @pytest.mark.parametrize('option', ['?', '!', 'url.auto_search']) - def test_empty(self, commands, option): - """Run ':set ?' / ':set !' / ':set url.auto_search'. - - Should show an error. - See https://github.com/qutebrowser/qutebrowser/issues/1109 - """ - with pytest.raises(cmdexc.CommandError, - match="set: The following arguments are required: " - "value"): - commands.set(win_id=0, option=option) - - @pytest.mark.parametrize('suffix', '?!') - def test_invalid(self, commands, suffix): - """Run ':set foo?' / ':set foo!'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, match="set: No option 'foo'"): - commands.set(win_id=0, option='foo' + suffix) - - @pytest.mark.parametrize('initial, expected', [ - # Normal cycling - ('magenta', 'blue'), - # Through the end of the list - ('yellow', 'green'), - # Value which is not in the list - ('red', 'green'), - ]) - def test_cycling(self, commands, config_stub, initial, expected): - """Run ':set' with multiple values.""" - opt = 'colors.statusbar.normal.bg' - config_stub.set_obj(opt, initial) - commands.set(0, opt, 'green', 'magenta', 'blue', 'yellow') - assert config_stub.get(opt) == expected - assert config_stub._yaml[opt] == expected - - -class TestBindConfigCommand: - - """Tests for :bind and :unbind.""" - - @pytest.fixture - def commands(self, config_stub, keyconf): - return config.ConfigCommands(config_stub, keyconf) - - @pytest.fixture - def no_bindings(self): - """Get a dict with no bindings.""" - return {'normal': {}} - - @pytest.mark.parametrize('command', ['nop', 'nope']) - def test_bind(self, commands, config_stub, no_bindings, keyconf, command): - """Simple :bind test (and aliases).""" - config_stub.val.aliases = {'nope': 'nop'} - config_stub.val.bindings.default = no_bindings - config_stub.val.bindings.commands = no_bindings - - commands.bind('a', command) - assert keyconf.get_command('a', 'normal') == command - yaml_bindings = config_stub._yaml['bindings.commands']['normal'] - assert yaml_bindings['a'] == command - - @pytest.mark.parametrize('key, mode, expected', [ - # Simple - ('a', 'normal', "a is bound to 'message-info a' in normal mode"), - # Alias - ('b', 'normal', "b is bound to 'mib' in normal mode"), - # Custom binding - ('c', 'normal', "c is bound to 'message-info c' in normal mode"), - # Special key - ('', 'normal', - " is bound to 'message-info C-x' in normal mode"), - # unbound - ('x', 'normal', "x is unbound in normal mode"), - # non-default mode - ('x', 'caret', "x is bound to 'nop' in caret mode"), - ]) - def test_bind_print(self, commands, config_stub, message_mock, - key, mode, expected): - """Run ':bind key'. - - Should print the binding. - """ - config_stub.val.aliases = {'mib': 'message-info b'} - config_stub.val.bindings.default = { - 'normal': {'a': 'message-info a', - 'b': 'mib', - '': 'message-info C-x'}, - 'caret': {'x': 'nop'} - } - config_stub.val.bindings.commands = { - 'normal': {'c': 'message-info c'} - } - - commands.bind(key, mode=mode) - - msg = message_mock.getmsg(usertypes.MessageLevel.info) - assert msg.text == expected - - @pytest.mark.parametrize('command, mode, expected', [ - ('foobar', 'normal', "bind: Invalid command: foobar"), - ('completion-item-del', 'normal', - "bind: completion-item-del: This command is only allowed in " - "command mode, not normal."), - ('nop', 'wrongmode', "bind: Invalid mode wrongmode!"), - ]) - def test_bind_invalid(self, commands, command, mode, expected): - """Run ':bind a foobar' / ':bind a completion-item-del'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, match=expected): - commands.bind('a', command, mode=mode) - - @pytest.mark.parametrize('force', [True, False]) - @pytest.mark.parametrize('key', ['a', 'b', '']) - def test_bind_duplicate(self, commands, config_stub, keyconf, force, key): - """Run ':bind' with a key which already has been bound.'. - - Also tests for https://github.com/qutebrowser/qutebrowser/issues/1544 - """ - config_stub.val.bindings.default = { - 'normal': {'a': 'nop', '': 'nop'} - } - config_stub.val.bindings.commands = { - 'normal': {'b': 'nop'}, - } - - if force: - commands.bind(key, 'message-info foo', mode='normal', force=True) - assert keyconf.get_command(key, 'normal') == 'message-info foo' - else: - with pytest.raises(cmdexc.CommandError, - match="bind: Duplicate key .* - use --force to " - "override"): - commands.bind(key, 'message-info foo', mode='normal') - assert keyconf.get_command(key, 'normal') == 'nop' - - @pytest.mark.parametrize('key, normalized', [ - ('a', 'a'), # default bindings - ('b', 'b'), # custom bindings - ('c', 'c'), # :bind then :unbind - ('', '') # normalized special binding - ]) - def test_unbind(self, commands, keyconf, config_stub, key, normalized): - config_stub.val.bindings.default = { - 'normal': {'a': 'nop', '': 'nop'}, - 'caret': {'a': 'nop', '': 'nop'}, - } - config_stub.val.bindings.commands = { - 'normal': {'b': 'nop'}, - 'caret': {'b': 'nop'}, - } - if key == 'c': - # Test :bind and :unbind - commands.bind(key, 'nop') - - commands.unbind(key) - assert keyconf.get_command(key, 'normal') is None - - yaml_bindings = config_stub._yaml['bindings.commands']['normal'] - if key in 'bc': - # Custom binding - assert normalized not in yaml_bindings - else: - assert yaml_bindings[normalized] is None - - @pytest.mark.parametrize('key, mode, expected', [ - ('foobar', 'normal', - "unbind: Can't find binding 'foobar' in normal mode"), - ('x', 'wrongmode', "unbind: Invalid mode wrongmode!"), - ]) - def test_unbind_invalid(self, commands, key, mode, expected): - """Run ':unbind foobar' / ':unbind x wrongmode'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, match=expected): - commands.unbind(key, mode=mode) - - class TestConfig: @pytest.fixture - def conf(self, stubs): - yaml_config = stubs.FakeYamlConfig() + def conf(self, config_tmpdir): + yaml_config = configfiles.YamlConfig() return config.Config(yaml_config) + def test_init_save_manager(self, conf, fake_save_manager): + conf.init_save_manager(fake_save_manager) + fake_save_manager.add_saveable.assert_called_once_with( + 'yaml-config', unittest.mock.ANY, unittest.mock.ANY) + def test_set_value(self, qtbot, conf, caplog): opt = conf.get_opt('tabs.show') with qtbot.wait_signal(conf.changed) as blocker: @@ -610,19 +279,60 @@ class TestConfig: conf._set_value(opt, 'never') assert conf._values['tabs.show'] == 'never' - def test_read_yaml(self, conf): - assert not conf._yaml.loaded - conf._yaml['content.plugins'] = True + @pytest.mark.parametrize('save_yaml', [True, False]) + def test_unset(self, conf, qtbot, save_yaml): + name = 'tabs.show' + conf.set_obj(name, 'never', save_yaml=True) + assert conf.get(name) == 'never' - conf.read_yaml() + with qtbot.wait_signal(conf.changed): + conf.unset(name, save_yaml=save_yaml) - assert conf._yaml.loaded - assert conf._values['content.plugins'] is True + assert conf.get(name) == 'always' + if save_yaml: + assert name not in conf._yaml + else: + assert conf._yaml[name] == 'never' - def test_read_yaml_invalid(self, conf): - conf._yaml['foo.bar'] = True + def test_unset_never_set(self, conf, qtbot): + name = 'tabs.show' + assert conf.get(name) == 'always' + + with qtbot.assert_not_emitted(conf.changed): + conf.unset(name) + + assert conf.get(name) == 'always' + + def test_unset_unknown(self, conf): with pytest.raises(configexc.NoOptionError): - conf.read_yaml() + conf.unset('tabs') + + @pytest.mark.parametrize('save_yaml', [True, False]) + def test_clear(self, conf, qtbot, save_yaml): + name1 = 'tabs.show' + name2 = 'content.plugins' + conf.set_obj(name1, 'never', save_yaml=True) + conf.set_obj(name2, True, save_yaml=True) + assert conf._values[name1] == 'never' + assert conf._values[name2] is True + + with qtbot.waitSignals([conf.changed, conf.changed]) as blocker: + conf.clear(save_yaml=save_yaml) + + options = {e.args[0] for e in blocker.all_signals_and_args} + assert options == {name1, name2} + + if save_yaml: + assert name1 not in conf._yaml + assert name2 not in conf._yaml + else: + assert conf._yaml[name1] == 'never' + assert conf._yaml[name2] is True + + def test_read_yaml(self, conf): + conf._yaml['content.plugins'] = True + conf.read_yaml() + assert conf._values['content.plugins'] is True def test_get_opt_valid(self, conf): assert conf.get_opt('tabs.show') == configdata.DATA['tabs.show'] @@ -722,6 +432,19 @@ class TestConfig: assert not conf._mutables assert conf.get_obj(option) == new + def test_get_mutable_twice(self, conf): + """Get a mutable value twice.""" + option = 'content.headers.custom' + obj = conf.get_obj(option, mutable=True) + obj['X-Foo'] = 'fooval' + obj2 = conf.get_obj(option, mutable=True) + obj2['X-Bar'] = 'barval' + + conf.update_mutables() + + expected = {'X-Foo': 'fooval', 'X-Bar': 'barval'} + assert conf.get_obj(option) == expected + def test_get_obj_unknown_mutable(self, conf): """Make sure we don't have unknown mutable types.""" conf._values['aliases'] = set() # This would never happen @@ -873,205 +596,3 @@ def test_set_register_stylesheet(delete, stylesheet_param, update, qtbot, expected = 'yellow' assert obj.rendered_stylesheet == expected - - -@pytest.fixture -def init_patch(qapp, fake_save_manager, monkeypatch, config_tmpdir, - data_tmpdir): - monkeypatch.setattr(configdata, 'DATA', None) - monkeypatch.setattr(configfiles, 'state', None) - monkeypatch.setattr(config, 'instance', None) - monkeypatch.setattr(config, 'key_instance', None) - monkeypatch.setattr(config, '_change_filters', []) - monkeypatch.setattr(config, '_init_errors', []) - # Make sure we get no SSL warning - monkeypatch.setattr(config.earlyinit, 'check_backend_ssl_support', - lambda _backend: None) - yield - try: - objreg.delete('config-commands') - except KeyError: - pass - - -@pytest.mark.parametrize('load_autoconfig', [True, False]) # noqa -@pytest.mark.parametrize('config_py', [True, 'error', False]) -@pytest.mark.parametrize('invalid_yaml', ['42', 'unknown', False]) -# pylint: disable=too-many-branches -def test_early_init(init_patch, config_tmpdir, caplog, fake_args, - load_autoconfig, config_py, invalid_yaml): - # Prepare files - autoconfig_file = config_tmpdir / 'autoconfig.yml' - config_py_file = config_tmpdir / 'config.py' - - if invalid_yaml == '42': - autoconfig_file.write_text('42', 'utf-8', ensure=True) - elif invalid_yaml == 'unknown': - autoconfig_file.write_text('global:\n colors.foobar: magenta\n', - 'utf-8', ensure=True) - else: - assert not invalid_yaml - autoconfig_file.write_text('global:\n colors.hints.fg: magenta\n', - 'utf-8', ensure=True) - - if config_py: - config_py_lines = ['c.colors.hints.bg = "red"'] - if not load_autoconfig: - config_py_lines.append('config.load_autoconfig = False') - if config_py == 'error': - config_py_lines.append('c.foo = 42') - config_py_file.write_text('\n'.join(config_py_lines), - 'utf-8', ensure=True) - - with caplog.at_level(logging.ERROR): - config.early_init(fake_args) - - # Check error messages - expected_errors = [] - if config_py == 'error': - expected_errors.append( - "Errors occurred while reading config.py:\n" - " While setting 'foo': No option 'foo'") - if invalid_yaml and (load_autoconfig or not config_py): - error = "Errors occurred while reading autoconfig.yml:\n" - if invalid_yaml == '42': - error += " While loading data: Toplevel object is not a dict" - elif invalid_yaml == 'unknown': - error += " Error: No option 'colors.foobar'" - else: - assert False, invalid_yaml - expected_errors.append(error) - - actual_errors = [str(err) for err in config._init_errors] - assert actual_errors == expected_errors - - # Make sure things have been init'ed - objreg.get('config-commands') - assert isinstance(config.instance, config.Config) - assert isinstance(config.key_instance, config.KeyConfig) - - # Check config values - if config_py and load_autoconfig and not invalid_yaml: - assert config.instance._values == { - 'colors.hints.bg': 'red', - 'colors.hints.fg': 'magenta', - } - elif config_py: - assert config.instance._values == {'colors.hints.bg': 'red'} - elif invalid_yaml: - assert config.instance._values == {} - else: - assert config.instance._values == {'colors.hints.fg': 'magenta'} - - -def test_early_init_invalid_change_filter(init_patch, fake_args): - config.change_filter('foobar') - with pytest.raises(configexc.NoOptionError): - config.early_init(fake_args) - - -@pytest.mark.parametrize('errors', [True, False]) -def test_late_init(init_patch, monkeypatch, fake_save_manager, fake_args, - mocker, errors): - config.early_init(fake_args) - if errors: - err = configexc.ConfigErrorDesc("Error text", Exception("Exception")) - errs = configexc.ConfigFileErrors("config.py", [err]) - monkeypatch.setattr(config, '_init_errors', [errs]) - msgbox_mock = mocker.patch('qutebrowser.config.config.msgbox.msgbox', - autospec=True) - - config.late_init(fake_save_manager) - - fake_save_manager.add_saveable.assert_any_call( - 'state-config', unittest.mock.ANY) - fake_save_manager.add_saveable.assert_any_call( - 'yaml-config', unittest.mock.ANY) - if errors: - assert len(msgbox_mock.call_args_list) == 1 - _call_posargs, call_kwargs = msgbox_mock.call_args_list[0] - text = call_kwargs['text'].strip() - assert text.startswith('Errors occurred while reading config.py:') - assert 'Error text: Exception' in text - else: - assert not msgbox_mock.called - - -class TestQtArgs: - - @pytest.fixture - def parser(self, mocker): - """Fixture to provide an argparser. - - Monkey-patches .exit() of the argparser so it doesn't exit on errors. - """ - parser = qutebrowser.get_argparser() - mocker.patch.object(parser, 'exit', side_effect=Exception) - return parser - - @pytest.mark.parametrize('args, expected', [ - # No Qt arguments - (['--debug'], [sys.argv[0]]), - # Qt flag - (['--debug', '--qt-flag', 'reverse'], [sys.argv[0], '--reverse']), - # Qt argument with value - (['--qt-arg', 'stylesheet', 'foo'], - [sys.argv[0], '--stylesheet', 'foo']), - # --qt-arg given twice - (['--qt-arg', 'stylesheet', 'foo', '--qt-arg', 'geometry', 'bar'], - [sys.argv[0], '--stylesheet', 'foo', '--geometry', 'bar']), - # --qt-flag given twice - (['--qt-flag', 'foo', '--qt-flag', 'bar'], - [sys.argv[0], '--foo', '--bar']), - ]) - def test_qt_args(self, config_stub, args, expected, parser): - """Test commandline with no Qt arguments given.""" - parsed = parser.parse_args(args) - assert config.qt_args(parsed) == expected - - def test_qt_both(self, config_stub, parser): - """Test commandline with a Qt argument and flag.""" - args = parser.parse_args(['--qt-arg', 'stylesheet', 'foobar', - '--qt-flag', 'reverse']) - qt_args = config.qt_args(args) - assert qt_args[0] == sys.argv[0] - assert '--reverse' in qt_args - assert '--stylesheet' in qt_args - assert 'foobar' in qt_args - - def test_with_settings(self, config_stub, parser): - parsed = parser.parse_args(['--qt-flag', 'foo']) - config_stub.val.qt_args = ['bar'] - assert config.qt_args(parsed) == [sys.argv[0], '--foo', '--bar'] - - -@pytest.mark.parametrize('arg, confval, can_import, is_new_webkit, used', [ - # overridden by commandline arg - ('webkit', 'auto', False, False, usertypes.Backend.QtWebKit), - # overridden by config - (None, 'webkit', False, False, usertypes.Backend.QtWebKit), - # WebKit available but too old - (None, 'auto', True, False, usertypes.Backend.QtWebEngine), - # WebKit available and new - (None, 'auto', True, True, usertypes.Backend.QtWebKit), - # WebKit unavailable - (None, 'auto', False, False, usertypes.Backend.QtWebEngine), -]) -def test_get_backend(monkeypatch, fake_args, config_stub, - arg, confval, can_import, is_new_webkit, used): - real_import = __import__ - - def fake_import(name, *args, **kwargs): - if name != 'PyQt5.QtWebKit': - return real_import(name, *args, **kwargs) - if can_import: - return None - raise ImportError - - fake_args.backend = arg - config_stub.val.backend = confval - monkeypatch.setattr(config.qtutils, 'is_new_qtwebkit', - lambda: is_new_webkit) - monkeypatch.setattr('builtins.__import__', fake_import) - - assert config.get_backend(fake_args) == used diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py new file mode 100644 index 000000000..9c6a9e460 --- /dev/null +++ b/tests/unit/config/test_configcommands.py @@ -0,0 +1,479 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: +# Copyright 2014-2017 Florian Bruhin (The Compiler) + +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Tests for qutebrowser.config.configcommands.""" + +import logging +import unittest.mock + +import pytest +from PyQt5.QtCore import QUrl, QProcess + +from qutebrowser.config import configcommands +from qutebrowser.commands import cmdexc +from qutebrowser.utils import usertypes +from qutebrowser.misc import objects + + +@pytest.fixture +def commands(config_stub, keyconf): + return configcommands.ConfigCommands(config_stub, keyconf) + + +class TestSet: + + """Tests for :set.""" + + def test_set_no_args(self, commands, tabbed_browser_stubs): + """Run ':set'. + + Should open qute://settings.""" + commands.set(win_id=0) + assert tabbed_browser_stubs[0].opened_url == QUrl('qute://settings') + + def test_get(self, config_stub, commands, message_mock): + """Run ':set url.auto_search?'. + + Should show the value. + """ + config_stub.val.url.auto_search = 'never' + commands.set(win_id=0, option='url.auto_search?') + msg = message_mock.getmsg(usertypes.MessageLevel.info) + assert msg.text == 'url.auto_search = never' + + @pytest.mark.parametrize('temp', [True, False]) + @pytest.mark.parametrize('option, old_value, inp, new_value', [ + ('url.auto_search', 'naive', 'dns', 'dns'), + # https://github.com/qutebrowser/qutebrowser/issues/2962 + ('editor.command', ['gvim', '-f', '{}'], '[emacs, "{}"]', + ['emacs', '{}']), + ]) + def test_set_simple(self, monkeypatch, commands, config_stub, + temp, option, old_value, inp, new_value): + """Run ':set [-t] option value'. + + Should set the setting accordingly. + """ + monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit) + assert config_stub.get(option) == old_value + + commands.set(0, option, inp, temp=temp) + + assert config_stub.get(option) == new_value + + if temp: + assert option not in config_stub._yaml + else: + assert config_stub._yaml[option] == new_value + + @pytest.mark.parametrize('temp', [True, False]) + def test_set_temp_override(self, commands, config_stub, temp): + """Invoking :set twice. + + :set url.auto_search dns + :set -t url.auto_search never + + Should set the setting accordingly. + """ + assert config_stub.val.url.auto_search == 'naive' + commands.set(0, 'url.auto_search', 'dns') + commands.set(0, 'url.auto_search', 'never', temp=True) + + assert config_stub.val.url.auto_search == 'never' + assert config_stub._yaml['url.auto_search'] == 'dns' + + def test_set_print(self, config_stub, commands, message_mock): + """Run ':set -p url.auto_search never'. + + Should set show the value. + """ + assert config_stub.val.url.auto_search == 'naive' + commands.set(0, 'url.auto_search', 'dns', print_=True) + + assert config_stub.val.url.auto_search == 'dns' + msg = message_mock.getmsg(usertypes.MessageLevel.info) + assert msg.text == 'url.auto_search = dns' + + def test_set_invalid_option(self, commands): + """Run ':set foo bar'. + + Should show an error. + """ + with pytest.raises(cmdexc.CommandError, match="set: No option 'foo'"): + commands.set(0, 'foo', 'bar') + + def test_set_invalid_value(self, commands): + """Run ':set auto_save.session blah'. + + Should show an error. + """ + with pytest.raises(cmdexc.CommandError, + match="set: Invalid value 'blah' - must be a " + "boolean!"): + commands.set(0, 'auto_save.session', 'blah') + + def test_set_wrong_backend(self, commands, monkeypatch): + monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine) + with pytest.raises(cmdexc.CommandError, + match="set: This setting is not available with the " + "QtWebEngine backend!"): + commands.set(0, 'content.cookies.accept', 'all') + + @pytest.mark.parametrize('option', ['?', '!', 'url.auto_search']) + def test_empty(self, commands, option): + """Run ':set ?' / ':set !' / ':set url.auto_search'. + + Should show an error. + See https://github.com/qutebrowser/qutebrowser/issues/1109 + """ + with pytest.raises(cmdexc.CommandError, + match="set: The following arguments are required: " + "value"): + commands.set(win_id=0, option=option) + + def test_invalid(self, commands): + """Run ':set foo?'. + + Should show an error. + """ + with pytest.raises(cmdexc.CommandError, match="set: No option 'foo'"): + commands.set(win_id=0, option='foo?') + + +class TestCycle: + + """Test :config-cycle.""" + + @pytest.mark.parametrize('initial, expected', [ + # Normal cycling + ('magenta', 'blue'), + # Through the end of the list + ('yellow', 'green'), + # Value which is not in the list + ('red', 'green'), + ]) + def test_cycling(self, commands, config_stub, initial, expected): + """Run ':set' with multiple values.""" + opt = 'colors.statusbar.normal.bg' + config_stub.set_obj(opt, initial) + commands.config_cycle(opt, 'green', 'magenta', 'blue', 'yellow') + assert config_stub.get(opt) == expected + assert config_stub._yaml[opt] == expected + + def test_different_representation(self, commands, config_stub): + """When using a different representation, cycling should work. + + For example, we use [foo] which is represented as ["foo"]. + """ + opt = 'qt_args' + config_stub.set_obj(opt, ['foo']) + commands.config_cycle(opt, '[foo]', '[bar]') + assert config_stub.get(opt) == ['bar'] + commands.config_cycle(opt, '[foo]', '[bar]') + assert config_stub.get(opt) == ['foo'] + + def test_toggle(self, commands, config_stub): + """Run ':config-cycle auto_save.session'. + + Should toggle the value. + """ + assert not config_stub.val.auto_save.session + commands.config_cycle('auto_save.session') + assert config_stub.val.auto_save.session + assert config_stub._yaml['auto_save.session'] + + @pytest.mark.parametrize('args', [ + ['url.auto_search'], ['url.auto_search', 'foo'] + ]) + def test_toggle_nonbool(self, commands, config_stub, args): + """Run :config-cycle without a bool and 0/1 value. + + :config-cycle url.auto_search + :config-cycle url.auto_search foo + + Should show an error. + """ + assert config_stub.val.url.auto_search == 'naive' + with pytest.raises(cmdexc.CommandError, match="Need at least " + "two values for non-boolean settings."): + commands.config_cycle(*args) + assert config_stub.val.url.auto_search == 'naive' + + def test_set_toggle_print(self, commands, config_stub, message_mock): + """Run ':config-cycle -p auto_save.session'. + + Should toggle the value and show the new value. + """ + commands.config_cycle('auto_save.session', print_=True) + msg = message_mock.getmsg(usertypes.MessageLevel.info) + assert msg.text == 'auto_save.session = true' + + +class TestUnsetAndClear: + + """Test :config-unset and :config-clear.""" + + @pytest.mark.parametrize('temp', [True, False]) + def test_unset(self, commands, config_stub, temp): + name = 'tabs.show' + config_stub.set_obj(name, 'never', save_yaml=True) + + commands.config_unset(name, temp=temp) + + assert config_stub.get(name) == 'always' + if temp: + assert config_stub._yaml[name] == 'never' + else: + assert name not in config_stub._yaml + + def test_unset_unknown_option(self, commands): + with pytest.raises(cmdexc.CommandError, match="No option 'tabs'"): + commands.config_unset('tabs') + + @pytest.mark.parametrize('save', [True, False]) + def test_clear(self, commands, config_stub, save): + name = 'tabs.show' + config_stub.set_obj(name, 'never', save_yaml=True) + + commands.config_clear(save=save) + + assert config_stub.get(name) == 'always' + if save: + assert name not in config_stub._yaml + else: + assert config_stub._yaml[name] == 'never' + + +class TestSource: + + """Test :config-source.""" + + pytestmark = pytest.mark.usefixtures('config_tmpdir', 'data_tmpdir') + + @pytest.mark.parametrize('use_default_dir', [True, False]) + @pytest.mark.parametrize('clear', [True, False]) + def test_config_source(self, tmpdir, commands, config_stub, config_tmpdir, + use_default_dir, clear): + assert config_stub.val.content.javascript.enabled + config_stub.val.ignore_case = 'always' + + if use_default_dir: + pyfile = config_tmpdir / 'config.py' + arg = None + else: + pyfile = tmpdir / 'sourced.py' + arg = str(pyfile) + pyfile.write_text('c.content.javascript.enabled = False\n', + encoding='utf-8') + + commands.config_source(arg, clear=clear) + + assert not config_stub.val.content.javascript.enabled + assert config_stub.val.ignore_case == ('smart' if clear else 'always') + + def test_errors(self, commands, config_tmpdir): + pyfile = config_tmpdir / 'config.py' + pyfile.write_text('c.foo = 42', encoding='utf-8') + + with pytest.raises(cmdexc.CommandError) as excinfo: + commands.config_source() + + expected = ("Errors occurred while reading config.py:\n" + " While setting 'foo': No option 'foo'") + assert str(excinfo.value) == expected + + +class TestEdit: + + """Tests for :config-edit.""" + + def test_no_source(self, commands, mocker, config_tmpdir): + mock = mocker.patch('qutebrowser.config.configcommands.editor.' + 'ExternalEditor._start_editor', autospec=True) + commands.config_edit(no_source=True) + mock.assert_called_once_with(unittest.mock.ANY) + + @pytest.fixture + def patch_editor(self, mocker, config_tmpdir, data_tmpdir): + """Write a config.py file.""" + def do_patch(text): + def _write_file(editor_self): + with open(editor_self._filename, 'w', encoding='utf-8') as f: + f.write(text) + editor_self.on_proc_closed(0, QProcess.NormalExit) + + return mocker.patch('qutebrowser.config.configcommands.editor.' + 'ExternalEditor._start_editor', autospec=True, + side_effect=_write_file) + + return do_patch + + def test_with_sourcing(self, commands, config_stub, patch_editor): + assert config_stub.val.content.javascript.enabled + mock = patch_editor('c.content.javascript.enabled = False') + + commands.config_edit() + + mock.assert_called_once_with(unittest.mock.ANY) + assert not config_stub.val.content.javascript.enabled + + def test_error(self, commands, config_stub, patch_editor, message_mock, + caplog): + patch_editor('c.foo = 42') + + with caplog.at_level(logging.ERROR): + commands.config_edit() + + msg = message_mock.getmsg() + expected = ("Errors occurred while reading config.py:\n" + " While setting 'foo': No option 'foo'") + assert msg.text == expected + + +class TestBind: + + """Tests for :bind and :unbind.""" + + @pytest.fixture + def no_bindings(self): + """Get a dict with no bindings.""" + return {'normal': {}} + + @pytest.mark.parametrize('command', ['nop', 'nope']) + def test_bind(self, commands, config_stub, no_bindings, keyconf, command): + """Simple :bind test (and aliases).""" + config_stub.val.aliases = {'nope': 'nop'} + config_stub.val.bindings.default = no_bindings + config_stub.val.bindings.commands = no_bindings + + commands.bind('a', command) + assert keyconf.get_command('a', 'normal') == command + yaml_bindings = config_stub._yaml['bindings.commands']['normal'] + assert yaml_bindings['a'] == command + + @pytest.mark.parametrize('key, mode, expected', [ + # Simple + ('a', 'normal', "a is bound to 'message-info a' in normal mode"), + # Alias + ('b', 'normal', "b is bound to 'mib' in normal mode"), + # Custom binding + ('c', 'normal', "c is bound to 'message-info c' in normal mode"), + # Special key + ('', 'normal', + " is bound to 'message-info C-x' in normal mode"), + # unbound + ('x', 'normal', "x is unbound in normal mode"), + # non-default mode + ('x', 'caret', "x is bound to 'nop' in caret mode"), + ]) + def test_bind_print(self, commands, config_stub, message_mock, + key, mode, expected): + """Run ':bind key'. + + Should print the binding. + """ + config_stub.val.aliases = {'mib': 'message-info b'} + config_stub.val.bindings.default = { + 'normal': {'a': 'message-info a', + 'b': 'mib', + '': 'message-info C-x'}, + 'caret': {'x': 'nop'} + } + config_stub.val.bindings.commands = { + 'normal': {'c': 'message-info c'} + } + + commands.bind(key, mode=mode) + + msg = message_mock.getmsg(usertypes.MessageLevel.info) + assert msg.text == expected + + def test_bind_invalid_mode(self, commands): + """Run ':bind --mode=wrongmode nop'. + + Should show an error. + """ + with pytest.raises(cmdexc.CommandError, + match='bind: Invalid mode wrongmode!'): + commands.bind('a', 'nop', mode='wrongmode') + + @pytest.mark.parametrize('key', ['a', 'b', '']) + def test_bind_duplicate(self, commands, config_stub, keyconf, key): + """Run ':bind' with a key which already has been bound.'. + + Also tests for https://github.com/qutebrowser/qutebrowser/issues/1544 + """ + config_stub.val.bindings.default = { + 'normal': {'a': 'nop', '': 'nop'} + } + config_stub.val.bindings.commands = { + 'normal': {'b': 'nop'}, + } + + commands.bind(key, 'message-info foo', mode='normal') + assert keyconf.get_command(key, 'normal') == 'message-info foo' + + def test_bind_none(self, commands, config_stub): + config_stub.val.bindings.commands = None + commands.bind(',x', 'nop') + + def test_unbind_none(self, commands, config_stub): + config_stub.val.bindings.commands = None + commands.unbind('H') + + @pytest.mark.parametrize('key, normalized', [ + ('a', 'a'), # default bindings + ('b', 'b'), # custom bindings + ('c', 'c'), # :bind then :unbind + ('', '') # normalized special binding + ]) + def test_unbind(self, commands, keyconf, config_stub, key, normalized): + config_stub.val.bindings.default = { + 'normal': {'a': 'nop', '': 'nop'}, + 'caret': {'a': 'nop', '': 'nop'}, + } + config_stub.val.bindings.commands = { + 'normal': {'b': 'nop'}, + 'caret': {'b': 'nop'}, + } + if key == 'c': + # Test :bind and :unbind + commands.bind(key, 'nop') + + commands.unbind(key) + assert keyconf.get_command(key, 'normal') is None + + yaml_bindings = config_stub._yaml['bindings.commands']['normal'] + if key in 'bc': + # Custom binding + assert normalized not in yaml_bindings + else: + assert yaml_bindings[normalized] is None + + @pytest.mark.parametrize('key, mode, expected', [ + ('foobar', 'normal', + "unbind: Can't find binding 'foobar' in normal mode"), + ('x', 'wrongmode', "unbind: Invalid mode wrongmode!"), + ]) + def test_unbind_invalid(self, commands, key, mode, expected): + """Run ':unbind foobar' / ':unbind x wrongmode'. + + Should show an error. + """ + with pytest.raises(cmdexc.CommandError, match=expected): + commands.unbind(key, mode=mode) diff --git a/tests/unit/config/test_configexc.py b/tests/unit/config/test_configexc.py index 8eaa21f05..38a74bcef 100644 --- a/tests/unit/config/test_configexc.py +++ b/tests/unit/config/test_configexc.py @@ -43,10 +43,11 @@ def test_backend_error(): assert str(e) == "This setting is not available with the QtWebKit backend!" -def test_duplicate_key_error(): - e = configexc.DuplicateKeyError('asdf') - assert isinstance(e, configexc.KeybindingError) - assert str(e) == "Duplicate key asdf" +def test_desc_with_text(): + """Test ConfigErrorDesc.with_text.""" + old = configexc.ConfigErrorDesc("Error text", Exception("Exception text")) + new = old.with_text("additional text") + assert str(new) == 'Error text (additional text): Exception text' @pytest.fixture diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 219b92395..6233369f7 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -19,15 +19,24 @@ """Tests for qutebrowser.config.configfiles.""" import os +import sys +import unittest.mock import pytest -from qutebrowser.config import config, configfiles, configexc +from qutebrowser.config import config, configfiles, configexc, configdata from qutebrowser.utils import utils from PyQt5.QtCore import QSettings +@pytest.fixture(autouse=True) +def configdata_init(): + """Initialize configdata if needed.""" + if configdata.DATA is None: + configdata.init() + + @pytest.mark.parametrize('old_data, insert, new_data', [ (None, False, '[general]\n\n[geometry]\n\n'), ('[general]\nfooled = true', False, '[general]\n\n[geometry]\n\n'), @@ -42,6 +51,7 @@ def test_state_config(fake_save_manager, data_tmpdir, statefile.write_text(old_data, 'utf-8') state = configfiles.StateConfig() + state.init_save_manager(fake_save_manager) if insert: state['general']['newval'] = '23' @@ -52,141 +62,276 @@ def test_state_config(fake_save_manager, data_tmpdir, state._save() assert statefile.read_text('utf-8') == new_data + fake_save_manager.add_saveable('state-config', unittest.mock.ANY) -@pytest.mark.parametrize('old_config', [ - None, - 'global:\n colors.hints.fg: magenta', -]) -@pytest.mark.parametrize('insert', [True, False]) -def test_yaml_config(fake_save_manager, config_tmpdir, old_config, insert): - autoconfig = config_tmpdir / 'autoconfig.yml' - if old_config is not None: - autoconfig.write_text(old_config, 'utf-8') +class TestYaml: - yaml = configfiles.YamlConfig() - yaml.load() + pytestmark = pytest.mark.usefixtures('config_tmpdir') - if insert: - yaml['tabs.show'] = 'never' + @pytest.fixture + def yaml(self): + return configfiles.YamlConfig() - yaml._save() + @pytest.mark.parametrize('old_config', [ + None, + 'global:\n colors.hints.fg: magenta', + ]) + @pytest.mark.parametrize('insert', [True, False]) + def test_yaml_config(self, yaml, config_tmpdir, old_config, insert): + autoconfig = config_tmpdir / 'autoconfig.yml' + if old_config is not None: + autoconfig.write_text(old_config, 'utf-8') - if not insert and old_config is None: - lines = [] - else: - text = autoconfig.read_text('utf-8') - lines = text.splitlines() + yaml.load() if insert: - assert lines[0].startswith('# DO NOT edit this file by hand,') - assert 'config_version: {}'.format(yaml.VERSION) in lines + yaml['tabs.show'] = 'never' - assert 'global:' in lines + yaml._save() - print(lines) + if not insert and old_config is None: + lines = [] + else: + text = autoconfig.read_text('utf-8') + lines = text.splitlines() - # WORKAROUND for https://github.com/PyCQA/pylint/issues/574 - if 'magenta' in (old_config or ''): # pylint: disable=superfluous-parens - assert ' colors.hints.fg: magenta' in lines - if insert: - assert ' tabs.show: never' in lines + if insert: + assert lines[0].startswith('# DO NOT edit this file by hand,') + assert 'config_version: {}'.format(yaml.VERSION) in lines + assert 'global:' in lines -@pytest.mark.parametrize('old_config', [ - None, - 'global:\n colors.hints.fg: magenta', -]) -@pytest.mark.parametrize('key, value', [ - ('colors.hints.fg', 'green'), - ('colors.hints.bg', None), - ('confirm_quit', True), - ('confirm_quit', False), -]) -def test_yaml_config_changed(fake_save_manager, config_tmpdir, old_config, - key, value): - autoconfig = config_tmpdir / 'autoconfig.yml' - if old_config is not None: - autoconfig.write_text(old_config, 'utf-8') + print(lines) - yaml = configfiles.YamlConfig() - yaml.load() + # WORKAROUND for https://github.com/PyCQA/pylint/issues/574 + # pylint: disable=superfluous-parens + if 'magenta' in (old_config or ''): + assert ' colors.hints.fg: magenta' in lines + if insert: + assert ' tabs.show: never' in lines - yaml[key] = value - assert key in yaml - assert yaml[key] == value + def test_init_save_manager(self, yaml, fake_save_manager): + yaml.init_save_manager(fake_save_manager) + fake_save_manager.add_saveable.assert_called_with( + 'yaml-config', unittest.mock.ANY, unittest.mock.ANY) - yaml._save() + def test_unknown_key(self, yaml, config_tmpdir): + """An unknown setting should be deleted.""" + autoconfig = config_tmpdir / 'autoconfig.yml' + autoconfig.write_text('global:\n hello: world', encoding='utf-8') - yaml = configfiles.YamlConfig() - yaml.load() + yaml.load() + yaml._save() - assert key in yaml - assert yaml[key] == value + lines = autoconfig.read_text('utf-8').splitlines() + assert ' hello:' not in lines + @pytest.mark.parametrize('old_config', [ + None, + 'global:\n colors.hints.fg: magenta', + ]) + @pytest.mark.parametrize('key, value', [ + ('colors.hints.fg', 'green'), + ('colors.hints.bg', None), + ('confirm_quit', True), + ('confirm_quit', False), + ]) + def test_changed(self, yaml, qtbot, config_tmpdir, old_config, key, value): + autoconfig = config_tmpdir / 'autoconfig.yml' + if old_config is not None: + autoconfig.write_text(old_config, 'utf-8') -@pytest.mark.parametrize('old_config', [ - None, - 'global:\n colors.hints.fg: magenta', -]) -def test_yaml_config_unchanged(fake_save_manager, config_tmpdir, old_config): - autoconfig = config_tmpdir / 'autoconfig.yml' - mtime = None - if old_config is not None: - autoconfig.write_text(old_config, 'utf-8') - mtime = autoconfig.stat().mtime - - yaml = configfiles.YamlConfig() - yaml.load() - yaml._save() - - if old_config is None: - assert not autoconfig.exists() - else: - assert autoconfig.stat().mtime == mtime - - -@pytest.mark.parametrize('line, text, exception', [ - ('%', 'While parsing', 'while scanning a directive'), - ('global: 42', 'While loading data', "'global' object is not a dict"), - ('foo: 42', 'While loading data', - "Toplevel object does not contain 'global' key"), - ('42', 'While loading data', "Toplevel object is not a dict"), -]) -def test_yaml_config_invalid(fake_save_manager, config_tmpdir, - line, text, exception): - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.write_text(line, 'utf-8', ensure=True) - - yaml = configfiles.YamlConfig() - - with pytest.raises(configexc.ConfigFileErrors) as excinfo: yaml.load() - assert len(excinfo.value.errors) == 1 - error = excinfo.value.errors[0] - assert error.text == text - assert str(error.exception).splitlines()[0] == exception - assert error.traceback is None + with qtbot.wait_signal(yaml.changed): + yaml[key] = value + assert key in yaml + assert yaml[key] == value -def test_yaml_oserror(fake_save_manager, config_tmpdir): - autoconfig = config_tmpdir / 'autoconfig.yml' - autoconfig.ensure() - autoconfig.chmod(0) - if os.access(str(autoconfig), os.R_OK): - # Docker container or similar - pytest.skip("File was still readable") + yaml._save() - yaml = configfiles.YamlConfig() - with pytest.raises(configexc.ConfigFileErrors) as excinfo: + yaml = configfiles.YamlConfig() yaml.load() - assert len(excinfo.value.errors) == 1 - error = excinfo.value.errors[0] - assert error.text == "While reading" - assert isinstance(error.exception, OSError) - assert error.traceback is None + assert key in yaml + assert yaml[key] == value + + def test_iter(self, yaml): + yaml['foo'] = 23 + yaml['bar'] = 42 + assert list(iter(yaml)) == [('bar', 42), ('foo', 23)] + + @pytest.mark.parametrize('old_config', [ + None, + 'global:\n colors.hints.fg: magenta', + ]) + def test_unchanged(self, yaml, config_tmpdir, old_config): + autoconfig = config_tmpdir / 'autoconfig.yml' + mtime = None + if old_config is not None: + autoconfig.write_text(old_config, 'utf-8') + mtime = autoconfig.stat().mtime + + yaml.load() + yaml._save() + + if old_config is None: + assert not autoconfig.exists() + else: + assert autoconfig.stat().mtime == mtime + + @pytest.mark.parametrize('line, text, exception', [ + ('%', 'While parsing', 'while scanning a directive'), + ('global: 42', 'While loading data', "'global' object is not a dict"), + ('foo: 42', 'While loading data', + "Toplevel object does not contain 'global' key"), + ('42', 'While loading data', "Toplevel object is not a dict"), + ]) + def test_invalid(self, yaml, config_tmpdir, line, text, exception): + autoconfig = config_tmpdir / 'autoconfig.yml' + autoconfig.write_text(line, 'utf-8', ensure=True) + + with pytest.raises(configexc.ConfigFileErrors) as excinfo: + yaml.load() + + assert len(excinfo.value.errors) == 1 + error = excinfo.value.errors[0] + assert error.text == text + assert str(error.exception).splitlines()[0] == exception + assert error.traceback is None + + def test_oserror(self, yaml, config_tmpdir): + autoconfig = config_tmpdir / 'autoconfig.yml' + autoconfig.ensure() + autoconfig.chmod(0) + if os.access(str(autoconfig), os.R_OK): + # Docker container or similar + pytest.skip("File was still readable") + + with pytest.raises(configexc.ConfigFileErrors) as excinfo: + yaml.load() + + assert len(excinfo.value.errors) == 1 + error = excinfo.value.errors[0] + assert error.text == "While reading" + assert isinstance(error.exception, OSError) + assert error.traceback is None + + def test_unset(self, yaml, qtbot, config_tmpdir): + name = 'tabs.show' + yaml[name] = 'never' + + with qtbot.wait_signal(yaml.changed): + yaml.unset(name) + + assert name not in yaml + + def test_unset_never_set(self, yaml, qtbot, config_tmpdir): + with qtbot.assert_not_emitted(yaml.changed): + yaml.unset('tabs.show') + + def test_clear(self, yaml, qtbot, config_tmpdir): + name = 'tabs.show' + yaml[name] = 'never' + + with qtbot.wait_signal(yaml.changed): + yaml.clear() + + assert name not in yaml + + +class ConfPy: + + """Helper class to get a confpy fixture.""" + + def __init__(self, tmpdir, filename: str = "config.py"): + self._file = tmpdir / filename + self.filename = str(self._file) + + def write(self, *lines): + text = '\n'.join(lines) + self._file.write_text(text, 'utf-8', ensure=True) + + def read(self, error=False): + """Read the config.py via configfiles and check for errors.""" + if error: + with pytest.raises(configexc.ConfigFileErrors) as excinfo: + configfiles.read_config_py(self.filename) + errors = excinfo.value.errors + assert len(errors) == 1 + return errors[0] + else: + configfiles.read_config_py(self.filename, raising=True) + return None + + def write_qbmodule(self): + self.write('import qbmodule', + 'qbmodule.run(config)') + + +class TestConfigPyModules: + + pytestmark = pytest.mark.usefixtures('config_stub', 'key_config_stub') + + @pytest.fixture + def confpy(self, tmpdir, config_tmpdir, data_tmpdir): + return ConfPy(tmpdir) + + @pytest.fixture + def qbmodulepy(self, tmpdir): + return ConfPy(tmpdir, filename="qbmodule.py") + + @pytest.fixture(autouse=True) + def restore_sys_path(self): + old_path = sys.path.copy() + yield + sys.path = old_path + + def test_bind_in_module(self, confpy, qbmodulepy, tmpdir): + qbmodulepy.write('def run(config):', + ' config.bind(",a", "message-info foo", mode="normal")') + confpy.write_qbmodule() + confpy.read() + expected = {'normal': {',a': 'message-info foo'}} + assert config.instance._values['bindings.commands'] == expected + assert "qbmodule" not in sys.modules.keys() + assert tmpdir not in sys.path + + def test_restore_sys_on_err(self, confpy, qbmodulepy, tmpdir): + confpy.write_qbmodule() + qbmodulepy.write('def run(config):', + ' 1/0') + error = confpy.read(error=True) + + assert error.text == "Unhandled exception" + assert isinstance(error.exception, ZeroDivisionError) + assert "qbmodule" not in sys.modules.keys() + assert tmpdir not in sys.path + + def test_fail_on_nonexistent_module(self, confpy, qbmodulepy, tmpdir): + qbmodulepy.write('def run(config):', + ' pass') + confpy.write('import foobar', + 'foobar.run(config)') + + error = confpy.read(error=True) + + assert error.text == "Unhandled exception" + assert isinstance(error.exception, ImportError) + + tblines = error.traceback.strip().splitlines() + assert tblines[0] == "Traceback (most recent call last):" + assert tblines[-1].endswith("Error: No module named 'foobar'") + + def test_no_double_if_path_exists(self, confpy, qbmodulepy, tmpdir): + sys.path.insert(0, tmpdir) + confpy.write('import sys', + 'if sys.path[0] in sys.path[1:]:', + ' raise Exception("Path not expected")') + confpy.read() + assert sys.path.count(tmpdir) == 1 class TestConfigPy: @@ -195,26 +340,23 @@ class TestConfigPy: pytestmark = pytest.mark.usefixtures('config_stub', 'key_config_stub') - class ConfPy: - - """Helper class to get a confpy fixture.""" - - def __init__(self, tmpdir): - self._confpy = tmpdir / 'config.py' - self.filename = str(self._confpy) - - def write(self, *lines): - text = '\n'.join(lines) - self._confpy.write_text(text, 'utf-8', ensure=True) - - def read(self): - """Read the config.py via configfiles and check for errors.""" - api = configfiles.read_config_py(self.filename) - assert not api.errors - @pytest.fixture - def confpy(self, tmpdir): - return self.ConfPy(tmpdir) + def confpy(self, tmpdir, config_tmpdir, data_tmpdir): + return ConfPy(tmpdir) + + def test_assertions(self, confpy): + """Make sure assertions in config.py work for these tests.""" + confpy.write('assert False') + with pytest.raises(AssertionError): + confpy.read() # no errors=True so it gets raised + + @pytest.mark.parametrize('what', ['configdir', 'datadir']) + def test_getting_dirs(self, confpy, what): + confpy.write('import pathlib', + 'directory = config.{}'.format(what), + 'assert isinstance(directory, pathlib.Path)', + 'assert directory.exists()') + confpy.read() @pytest.mark.parametrize('line', [ 'c.colors.hints.bg = "red"', @@ -231,25 +373,15 @@ class TestConfigPy: 'config.get("colors.hints.fg")', ]) def test_get(self, confpy, set_first, get_line): - """Test whether getting options works correctly. - - We test this by doing the following: - - Set colors.hints.fg to some value (inside the config.py with - set_first, outside of it otherwise). - - In the config.py, read .fg and set .bg to the same value. - - Verify that .bg has been set correctly. - """ + """Test whether getting options works correctly.""" # pylint: disable=bad-config-option config.val.colors.hints.fg = 'green' if set_first: confpy.write('c.colors.hints.fg = "red"', - 'c.colors.hints.bg = {}'.format(get_line)) - expected = 'red' + 'assert {} == "red"'.format(get_line)) else: - confpy.write('c.colors.hints.bg = {}'.format(get_line)) - expected = 'green' + confpy.write('assert {} == "green"'.format(get_line)) confpy.read() - assert config.instance._values['colors.hints.bg'] == expected @pytest.mark.parametrize('line, mode', [ ('config.bind(",a", "message-info foo")', 'normal'), @@ -261,6 +393,29 @@ class TestConfigPy: expected = {mode: {',a': 'message-info foo'}} assert config.instance._values['bindings.commands'] == expected + def test_bind_freshly_defined_alias(self, confpy): + """Make sure we can bind to a new alias. + + https://github.com/qutebrowser/qutebrowser/issues/3001 + """ + confpy.write("c.aliases['foo'] = 'message-info foo'", + "config.bind(',f', 'foo')") + confpy.read() + + def test_bind_duplicate_key(self, confpy): + """Make sure overriding a keybinding works.""" + confpy.write("config.bind('H', 'message-info back')") + confpy.read() + expected = {'normal': {'H': 'message-info back'}} + assert config.instance._values['bindings.commands'] == expected + + def test_bind_none(self, confpy): + confpy.write("c.bindings.commands = None", + "config.bind(',x', 'nop')") + confpy.read() + expected = {'normal': {',x': 'nop'}} + assert config.instance._values['bindings.commands'] == expected + @pytest.mark.parametrize('line, key, mode', [ ('config.unbind("o")', 'o', 'normal'), ('config.unbind("y", mode="prompt")', 'y', 'prompt'), @@ -278,17 +433,7 @@ class TestConfigPy: assert config.instance._values['aliases']['foo'] == 'message-info foo' assert config.instance._values['aliases']['bar'] == 'message-info bar' - def test_reading_default_location(self, config_tmpdir): - (config_tmpdir / 'config.py').write_text( - 'c.colors.hints.bg = "red"', 'utf-8') - configfiles.read_config_py() - assert config.instance._values['colors.hints.bg'] == 'red' - - def test_reading_missing_default_location(self, config_tmpdir): - assert not (config_tmpdir / 'config.py').exists() - configfiles.read_config_py() # Should not crash - - def test_oserror(self, tmpdir): + def test_oserror(self, tmpdir, data_tmpdir, config_tmpdir): with pytest.raises(configexc.ConfigFileErrors) as excinfo: configfiles.read_config_py(str(tmpdir / 'foo')) @@ -305,7 +450,7 @@ class TestConfigPy: assert len(excinfo.value.errors) == 1 error = excinfo.value.errors[0] - assert isinstance(error.exception, (TypeError, ValueError)) + assert isinstance(error.exception, ValueError) assert error.text == "Error while compiling" exception_text = 'source code string cannot contain null bytes' assert str(error.exception) == exception_text @@ -330,13 +475,9 @@ class TestConfigPy: assert " ^" in tblines def test_unhandled_exception(self, confpy): - confpy.write("config.load_autoconfig = False", "1/0") - api = configfiles.read_config_py(confpy.filename) + confpy.write("1/0") + error = confpy.read(error=True) - assert not api.load_autoconfig - - assert len(api.errors) == 1 - error = api.errors[0] assert error.text == "Unhandled exception" assert isinstance(error.exception, ZeroDivisionError) @@ -348,9 +489,8 @@ class TestConfigPy: def test_config_val(self, confpy): """Using config.val should not work in config.py files.""" confpy.write("config.val.colors.hints.bg = 'red'") - api = configfiles.read_config_py(confpy.filename) - assert len(api.errors) == 1 - error = api.errors[0] + error = confpy.read(error=True) + assert error.text == "Unhandled exception" assert isinstance(error.exception, AttributeError) message = "'ConfigAPI' object has no attribute 'val'" @@ -358,13 +498,9 @@ class TestConfigPy: @pytest.mark.parametrize('line', ["c.foo = 42", "config.set('foo', 42)"]) def test_config_error(self, confpy, line): - confpy.write(line, "config.load_autoconfig = False") - api = configfiles.read_config_py(confpy.filename) + confpy.write(line) + error = confpy.read(error=True) - assert not api.load_autoconfig - - assert len(api.errors) == 1 - error = api.errors[0] assert error.text == "While setting 'foo'" assert isinstance(error.exception, configexc.NoOptionError) assert str(error.exception) == "No option 'foo'" @@ -372,16 +508,20 @@ class TestConfigPy: def test_multiple_errors(self, confpy): confpy.write("c.foo = 42", "config.set('foo', 42)", "1/0") - api = configfiles.read_config_py(confpy.filename) - assert len(api.errors) == 3 - for error in api.errors[:2]: + with pytest.raises(configexc.ConfigFileErrors) as excinfo: + configfiles.read_config_py(confpy.filename) + + errors = excinfo.value.errors + assert len(errors) == 3 + + for error in errors[:2]: assert error.text == "While setting 'foo'" assert isinstance(error.exception, configexc.NoOptionError) assert str(error.exception) == "No option 'foo'" assert error.traceback is None - error = api.errors[2] + error = errors[2] assert error.text == "Unhandled exception" assert isinstance(error.exception, ZeroDivisionError) assert error.traceback is not None diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py new file mode 100644 index 000000000..3a1fe8d13 --- /dev/null +++ b/tests/unit/config/test_configinit.py @@ -0,0 +1,307 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: +# Copyright 2017 Florian Bruhin (The Compiler) + +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Tests for qutebrowser.config.configinit.""" + +import os +import sys +import logging +import unittest.mock + +import pytest + +from qutebrowser import qutebrowser +from qutebrowser.config import (config, configexc, configfiles, configinit, + configdata) +from qutebrowser.utils import objreg, usertypes + + +@pytest.fixture +def init_patch(qapp, fake_save_manager, monkeypatch, config_tmpdir, + data_tmpdir): + monkeypatch.setattr(configfiles, 'state', None) + monkeypatch.setattr(config, 'instance', None) + monkeypatch.setattr(config, 'key_instance', None) + monkeypatch.setattr(config, 'change_filters', []) + monkeypatch.setattr(configinit, '_init_errors', None) + yield + try: + objreg.delete('config-commands') + except KeyError: + pass + + +@pytest.fixture +def args(fake_args): + """Arguments needed for the config to init.""" + fake_args.temp_settings = [] + return fake_args + + +@pytest.fixture(autouse=True) +def configdata_init(monkeypatch): + """Make sure configdata is init'ed and no test re-init's it.""" + if not configdata.DATA: + configdata.init() + monkeypatch.setattr(configdata, 'init', lambda: None) + + +class TestEarlyInit: + + @pytest.mark.parametrize('config_py', [True, 'error', False]) + def test_config_py(self, init_patch, config_tmpdir, caplog, args, + config_py): + """Test loading with only a config.py.""" + config_py_file = config_tmpdir / 'config.py' + + if config_py: + config_py_lines = ['c.colors.hints.bg = "red"'] + if config_py == 'error': + config_py_lines.append('c.foo = 42') + config_py_file.write_text('\n'.join(config_py_lines), + 'utf-8', ensure=True) + + with caplog.at_level(logging.ERROR): + configinit.early_init(args) + + # Check error messages + expected_errors = [] + if config_py == 'error': + expected_errors.append("While setting 'foo': No option 'foo'") + + if configinit._init_errors is None: + actual_errors = [] + else: + actual_errors = [str(err) + for err in configinit._init_errors.errors] + + assert actual_errors == expected_errors + + # Make sure things have been init'ed + objreg.get('config-commands') + assert isinstance(config.instance, config.Config) + assert isinstance(config.key_instance, config.KeyConfig) + + # Check config values + if config_py: + assert config.instance._values == {'colors.hints.bg': 'red'} + else: + assert config.instance._values == {} + + @pytest.mark.parametrize('load_autoconfig', [True, False]) + @pytest.mark.parametrize('config_py', [True, 'error', False]) + @pytest.mark.parametrize('invalid_yaml', ['42', 'unknown', 'wrong-type', + False]) + def test_autoconfig_yml(self, init_patch, config_tmpdir, caplog, args, + load_autoconfig, config_py, invalid_yaml): + """Test interaction between config.py and autoconfig.yml.""" + # pylint: disable=too-many-locals,too-many-branches + # Prepare files + autoconfig_file = config_tmpdir / 'autoconfig.yml' + config_py_file = config_tmpdir / 'config.py' + + yaml_text = { + '42': '42', + 'unknown': 'global:\n colors.foobar: magenta\n', + 'wrong-type': 'global:\n tabs.position: true\n', + False: 'global:\n colors.hints.fg: magenta\n', + } + autoconfig_file.write_text(yaml_text[invalid_yaml], 'utf-8', + ensure=True) + + if config_py: + config_py_lines = ['c.colors.hints.bg = "red"'] + if load_autoconfig: + config_py_lines.append('config.load_autoconfig()') + if config_py == 'error': + config_py_lines.append('c.foo = 42') + config_py_file.write_text('\n'.join(config_py_lines), + 'utf-8', ensure=True) + + with caplog.at_level(logging.ERROR): + configinit.early_init(args) + + # Check error messages + expected_errors = [] + + if load_autoconfig or not config_py: + suffix = ' (autoconfig.yml)' if config_py else '' + if invalid_yaml == '42': + error = ("While loading data{}: Toplevel object is not a dict" + .format(suffix)) + expected_errors.append(error) + elif invalid_yaml == 'wrong-type': + error = ("Error{}: Invalid value 'True' - expected a value of " + "type str but got bool.".format(suffix)) + expected_errors.append(error) + if config_py == 'error': + expected_errors.append("While setting 'foo': No option 'foo'") + + if configinit._init_errors is None: + actual_errors = [] + else: + actual_errors = [str(err) + for err in configinit._init_errors.errors] + + assert actual_errors == expected_errors + + # Check config values + if config_py and load_autoconfig and not invalid_yaml: + assert config.instance._values == { + 'colors.hints.bg': 'red', + 'colors.hints.fg': 'magenta', + } + elif config_py: + assert config.instance._values == {'colors.hints.bg': 'red'} + elif invalid_yaml: + assert config.instance._values == {} + else: + assert config.instance._values == {'colors.hints.fg': 'magenta'} + + def test_invalid_change_filter(self, init_patch, args): + config.change_filter('foobar') + with pytest.raises(configexc.NoOptionError): + configinit.early_init(args) + + def test_temp_settings_valid(self, init_patch, args): + args.temp_settings = [('colors.completion.fg', 'magenta')] + configinit.early_init(args) + assert config.instance._values['colors.completion.fg'] == 'magenta' + + def test_temp_settings_invalid(self, caplog, init_patch, message_mock, + args): + """Invalid temp settings should show an error.""" + args.temp_settings = [('foo', 'bar')] + + with caplog.at_level(logging.ERROR): + configinit.early_init(args) + + msg = message_mock.getmsg() + assert msg.level == usertypes.MessageLevel.error + assert msg.text == "set: NoOptionError - No option 'foo'" + assert 'colors.completion.fg' not in config.instance._values + + def test_force_software_rendering(self, monkeypatch, init_patch, args): + """Setting force_software_rendering should set the environment var.""" + envvar = 'QT_XCB_FORCE_SOFTWARE_OPENGL' + monkeypatch.setattr(configinit.objects, 'backend', + usertypes.Backend.QtWebEngine) + monkeypatch.delenv(envvar, raising=False) + args.temp_settings = [('force_software_rendering', 'true')] + args.backend = 'webengine' + + configinit.early_init(args) + + assert os.environ[envvar] == '1' + + +@pytest.mark.parametrize('errors', [True, False]) +def test_late_init(init_patch, monkeypatch, fake_save_manager, args, + mocker, errors): + configinit.early_init(args) + if errors: + err = configexc.ConfigErrorDesc("Error text", Exception("Exception")) + errs = configexc.ConfigFileErrors("config.py", [err]) + monkeypatch.setattr(configinit, '_init_errors', errs) + msgbox_mock = mocker.patch('qutebrowser.config.configinit.msgbox.msgbox', + autospec=True) + + configinit.late_init(fake_save_manager) + + fake_save_manager.add_saveable.assert_any_call( + 'state-config', unittest.mock.ANY) + fake_save_manager.add_saveable.assert_any_call( + 'yaml-config', unittest.mock.ANY, unittest.mock.ANY) + if errors: + assert len(msgbox_mock.call_args_list) == 1 + _call_posargs, call_kwargs = msgbox_mock.call_args_list[0] + text = call_kwargs['text'].strip() + assert text.startswith('Errors occurred while reading config.py:') + assert 'Error text: Exception' in text + else: + assert not msgbox_mock.called + + +class TestQtArgs: + + @pytest.fixture + def parser(self, mocker): + """Fixture to provide an argparser. + + Monkey-patches .exit() of the argparser so it doesn't exit on errors. + """ + parser = qutebrowser.get_argparser() + mocker.patch.object(parser, 'exit', side_effect=Exception) + return parser + + @pytest.mark.parametrize('args, expected', [ + # No Qt arguments + (['--debug'], [sys.argv[0]]), + # Qt flag + (['--debug', '--qt-flag', 'reverse'], [sys.argv[0], '--reverse']), + # Qt argument with value + (['--qt-arg', 'stylesheet', 'foo'], + [sys.argv[0], '--stylesheet', 'foo']), + # --qt-arg given twice + (['--qt-arg', 'stylesheet', 'foo', '--qt-arg', 'geometry', 'bar'], + [sys.argv[0], '--stylesheet', 'foo', '--geometry', 'bar']), + # --qt-flag given twice + (['--qt-flag', 'foo', '--qt-flag', 'bar'], + [sys.argv[0], '--foo', '--bar']), + ]) + def test_qt_args(self, config_stub, args, expected, parser): + """Test commandline with no Qt arguments given.""" + parsed = parser.parse_args(args) + assert configinit.qt_args(parsed) == expected + + def test_qt_both(self, config_stub, parser): + """Test commandline with a Qt argument and flag.""" + args = parser.parse_args(['--qt-arg', 'stylesheet', 'foobar', + '--qt-flag', 'reverse']) + qt_args = configinit.qt_args(args) + assert qt_args[0] == sys.argv[0] + assert '--reverse' in qt_args + assert '--stylesheet' in qt_args + assert 'foobar' in qt_args + + def test_with_settings(self, config_stub, parser): + parsed = parser.parse_args(['--qt-flag', 'foo']) + config_stub.val.qt_args = ['bar'] + assert configinit.qt_args(parsed) == [sys.argv[0], '--foo', '--bar'] + + +@pytest.mark.parametrize('arg, confval, used', [ + # overridden by commandline arg + ('webkit', 'webengine', usertypes.Backend.QtWebKit), + # set in config + (None, 'webkit', usertypes.Backend.QtWebKit), +]) +def test_get_backend(monkeypatch, args, config_stub, + arg, confval, used): + real_import = __import__ + + def fake_import(name, *args, **kwargs): + if name != 'PyQt5.QtWebKit': + return real_import(name, *args, **kwargs) + raise ImportError + + args.backend = arg + config_stub.val.backend = confval + monkeypatch.setattr('builtins.__import__', fake_import) + + assert configinit.get_backend(args) == used diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 122a1c20c..9b5e23263 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -193,7 +193,8 @@ class TestAll: if member in [configtypes.BaseType, configtypes.MappingType, configtypes._Numeric]: pass - elif member is configtypes.List: + elif (member is configtypes.List or + member is configtypes.ListOrValue): yield functools.partial(member, valtype=configtypes.Int()) yield functools.partial(member, valtype=configtypes.Url()) elif member is configtypes.Dict: @@ -240,6 +241,9 @@ class TestAll: configtypes.PercOrInt, # ditto ]: return + if (isinstance(typ, configtypes.ListOrValue) and + isinstance(typ.valtype, configtypes.Int)): + return assert converted == s @@ -250,7 +254,7 @@ class TestAll: to_py_expected = configtypes.PaddingValues(None, None, None, None) elif isinstance(typ, configtypes.Dict): to_py_expected = {} - elif isinstance(typ, configtypes.List): + elif isinstance(typ, (configtypes.List, configtypes.ListOrValue)): to_py_expected = [] else: to_py_expected = None @@ -366,6 +370,10 @@ class TestBaseType: def test_to_doc(self, klass, value, expected): assert klass().to_doc(value) == expected + @pytest.mark.parametrize('obj', [42, '', None, 'foo']) + def test_from_obj(self, klass, obj): + assert klass(none_ok=True).from_obj(obj) == obj + class MappingSubclass(configtypes.MappingType): @@ -546,6 +554,14 @@ class TestList: with pytest.raises(configexc.ValidationError): klass().from_str(val) + @pytest.mark.parametrize('obj, expected', [ + ([1], [1]), + ([], []), + (None, []), + ]) + def test_from_obj(self, klass, obj, expected): + assert klass(none_ok_outer=True).from_obj(obj) == expected + @pytest.mark.parametrize('val', [['foo'], ['foo', 'bar']]) def test_to_py_valid(self, klass, val): assert klass().to_py(val) == val @@ -670,6 +686,108 @@ class TestFlagList: assert klass().complete() is None +class TestListOrValue: + + @pytest.fixture + def klass(self): + return configtypes.ListOrValue + + @pytest.fixture + def strtype(self): + return configtypes.String() + + @pytest.mark.parametrize('val, expected', [ + ('["foo"]', ['foo']), + ('["foo", "bar"]', ['foo', 'bar']), + ('foo', 'foo'), + ]) + def test_from_str(self, klass, strtype, val, expected): + assert klass(strtype).from_str(val) == expected + + def test_from_str_invalid(self, klass): + valtype = configtypes.String(minlen=10) + with pytest.raises(configexc.ValidationError): + klass(valtype).from_str('123') + + @pytest.mark.parametrize('val, expected', [ + (['foo'], ['foo']), + ('foo', ['foo']), + ]) + def test_to_py_valid(self, klass, strtype, val, expected): + assert klass(strtype).to_py(val) == expected + + @pytest.mark.parametrize('val', [[42], ['\U00010000']]) + def test_to_py_invalid(self, klass, strtype, val): + with pytest.raises(configexc.ValidationError): + klass(strtype).to_py(val) + + @pytest.mark.parametrize('val', [None, ['foo', 'bar'], 'abcd']) + def test_to_py_length(self, strtype, klass, val): + klass(strtype, none_ok=True, length=2).to_py(val) + + @pytest.mark.parametrize('obj, expected', [ + (['a'], ['a']), + ([], []), + (None, []), + ]) + def test_from_obj(self, klass, obj, expected): + typ = klass(none_ok=True, valtype=configtypes.String()) + assert typ.from_obj(obj) == expected + + @pytest.mark.parametrize('val', [['a'], ['a', 'b'], ['a', 'b', 'c', 'd']]) + def test_wrong_length(self, strtype, klass, val): + with pytest.raises(configexc.ValidationError, + match='Exactly 3 values need to be set!'): + klass(strtype, length=3).to_py(val) + + def test_get_name(self, strtype, klass): + assert klass(strtype).get_name() == 'List of String, or String' + + def test_get_valid_values(self, klass): + valid_values = configtypes.ValidValues('foo', 'bar', 'baz') + valtype = configtypes.String(valid_values=valid_values) + assert klass(valtype).get_valid_values() == valid_values + + def test_to_str(self, strtype, klass): + assert klass(strtype).to_str(["a", True]) == '["a", true]' + + @hypothesis.given(val=strategies.lists(strategies.just('foo'))) + def test_hypothesis(self, strtype, klass, val): + typ = klass(strtype, none_ok=True) + try: + converted = typ.to_py(val) + except configexc.ValidationError: + pass + else: + expected = converted if converted else [] + assert typ.to_py(typ.from_str(typ.to_str(converted))) == expected + + @hypothesis.given(val=strategies.lists(strategies.just('foo'))) + def test_hypothesis_text(self, strtype, klass, val): + typ = klass(strtype) + text = json.dumps(val) + try: + typ.to_str(typ.from_str(text)) + except configexc.ValidationError: + pass + + @pytest.mark.parametrize('val, expected', [ + # simple list + (['foo', 'bar'], '\n\n- +pass:[foo]+\n- +pass:[bar]+'), + # only one value + (['foo'], '+pass:[foo]+'), + # value without list + ('foo', '+pass:[foo]+'), + # empty + ([], 'empty'), + (None, 'empty'), + ]) + def test_to_doc(self, klass, strtype, val, expected): + doc = klass(strtype).to_doc(val) + print(doc) + assert doc == expected + + class TestBool: TESTS = { @@ -718,8 +836,10 @@ class TestBool: def test_to_str(self, klass, val, expected): assert klass().to_str(val) == expected - def test_to_doc(self, klass): - assert klass().to_doc(True) == '+pass:[true]+' + @pytest.mark.parametrize('value, expected', [(True, '+pass:[true]+'), + (False, '+pass:[false]+')]) + def test_to_doc(self, klass, value, expected): + assert klass().to_doc(value) == expected class TestBoolAsk: @@ -1072,37 +1192,10 @@ class TestCommand: monkeypatch.setattr(configtypes, 'cmdutils', cmd_utils) monkeypatch.setattr('qutebrowser.commands.runners.cmdutils', cmd_utils) - @pytest.fixture(autouse=True) - def patch_aliases(self, config_stub): - """Patch the aliases setting.""" - configtypes.Command.unvalidated = True - config_stub.val.aliases = {'alias': 'cmd1'} - configtypes.Command.unvalidated = False - @pytest.fixture def klass(self): return configtypes.Command - @pytest.mark.parametrize('val', ['cmd1', 'cmd2', 'cmd1 foo bar', - 'cmd2 baz fish', 'alias foo']) - def test_to_py_valid(self, patch_cmdutils, klass, val): - expected = None if not val else val - assert klass().to_py(val) == expected - - @pytest.mark.parametrize('val', ['cmd3', 'cmd3 foo bar', ' ']) - def test_to_py_invalid(self, patch_cmdutils, klass, val): - with pytest.raises(configexc.ValidationError): - klass().to_py(val) - - def test_cmdline(self, klass, cmdline_test): - """Test some commandlines from the cmdline_test fixture.""" - typ = klass() - if cmdline_test.valid: - typ.to_py(cmdline_test.cmd) - else: - with pytest.raises(configexc.ValidationError): - typ.to_py(cmdline_test.cmd) - def test_complete(self, patch_cmdutils, klass): """Test completion.""" items = klass().complete() @@ -1461,6 +1554,16 @@ class TestDict: valtype=configtypes.Int()) assert typ.from_str('{"answer": 42}') == {"answer": 42} + @pytest.mark.parametrize('obj, expected', [ + ({'a': 'b'}, {'a': 'b'}), + ({}, {}), + (None, {}), + ]) + def test_from_obj(self, klass, obj, expected): + d = klass(keytype=configtypes.String(), valtype=configtypes.String(), + none_ok=True) + assert d.from_obj(obj) == expected + @pytest.mark.parametrize('keytype, valtype, val', [ (configtypes.String(), configtypes.String(), {'hello': 'world'}), (configtypes.String(), configtypes.Int(), {'hello': 42}), diff --git a/tests/unit/keyinput/conftest.py b/tests/unit/keyinput/conftest.py index bdae15272..684e5792e 100644 --- a/tests/unit/keyinput/conftest.py +++ b/tests/unit/keyinput/conftest.py @@ -31,6 +31,12 @@ BINDINGS = {'prompt': {'': 'message-info ctrla', 'command': {'foo': 'message-info bar', '': 'message-info ctrlx'}, 'normal': {'a': 'message-info a', 'ba': 'message-info ba'}} +MAPPINGS = { + '': 'a', + '': '', + 'x': 'a', + 'b': 'a', +} @pytest.fixture @@ -38,3 +44,4 @@ def keyinput_bindings(config_stub, key_config_stub): """Register some test bindings.""" config_stub.val.bindings.default = {} config_stub.val.bindings.commands = dict(BINDINGS) + config_stub.val.bindings.key_mappings = dict(MAPPINGS) diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index e7131f92c..c4ce838da 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -91,8 +91,7 @@ class TestDebugLog: ]) def test_split_count(config_stub, input_key, supports_count, expected): kp = basekeyparser.BaseKeyParser(0, supports_count=supports_count) - kp._keystring = input_key - assert kp._split_count() == expected + assert kp._split_count(input_key) == expected @pytest.mark.usefixtures('keyinput_bindings') @@ -165,20 +164,14 @@ class TestSpecialKeys: keyparser._read_config('prompt') def test_valid_key(self, fake_keyevent_factory, keyparser): - if utils.is_mac: - modifier = Qt.MetaModifier - else: - modifier = Qt.ControlModifier + modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier)) keyparser.execute.assert_called_once_with( 'message-info ctrla', keyparser.Type.special, None) def test_valid_key_count(self, fake_keyevent_factory, keyparser): - if utils.is_mac: - modifier = Qt.MetaModifier - else: - modifier = Qt.ControlModifier + modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier keyparser.handle(fake_keyevent_factory(5, text='5')) keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier, text='A')) keyparser.execute.assert_called_once_with( @@ -199,6 +192,22 @@ class TestSpecialKeys: keyparser.handle(fake_keyevent_factory(Qt.Key_A, Qt.NoModifier)) assert not keyparser.execute.called + def test_mapping(self, config_stub, fake_keyevent_factory, keyparser): + modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier + + keyparser.handle(fake_keyevent_factory(Qt.Key_B, modifier)) + keyparser.execute.assert_called_once_with( + 'message-info ctrla', keyparser.Type.special, None) + + def test_binding_and_mapping(self, config_stub, fake_keyevent_factory, + keyparser): + """with a conflicting binding/mapping, the binding should win.""" + modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier + + keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) + keyparser.execute.assert_called_once_with( + 'message-info ctrla', keyparser.Type.special, None) + class TestKeyChain: @@ -230,7 +239,7 @@ class TestKeyChain: handle_text((Qt.Key_X, 'x'), # Then start the real chain (Qt.Key_B, 'b'), (Qt.Key_A, 'a')) - keyparser.execute.assert_called_once_with( + keyparser.execute.assert_called_with( 'message-info ba', keyparser.Type.chain, None) assert keyparser._keystring == '' @@ -249,6 +258,16 @@ class TestKeyChain: handle_text((Qt.Key_C, 'c')) assert keyparser._keystring == '' + def test_mapping(self, config_stub, handle_text, keyparser): + handle_text((Qt.Key_X, 'x')) + keyparser.execute.assert_called_once_with( + 'message-info a', keyparser.Type.chain, None) + + def test_binding_and_mapping(self, config_stub, handle_text, keyparser): + """with a conflicting binding/mapping, the binding should win.""" + handle_text((Qt.Key_B, 'b')) + assert not keyparser.execute.called + class TestCount: diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py index 5010b8efa..4d2024eac 100644 --- a/tests/unit/keyinput/test_modeparsers.py +++ b/tests/unit/keyinput/test_modeparsers.py @@ -56,7 +56,7 @@ class TestsNormalKeyParser: # Then start the real chain keyparser.handle(fake_keyevent_factory(Qt.Key_B, text='b')) keyparser.handle(fake_keyevent_factory(Qt.Key_A, text='a')) - keyparser.execute.assert_called_once_with( + keyparser.execute.assert_called_with( 'message-info ba', keyparser.Type.chain, None) assert keyparser._keystring == '' diff --git a/tests/unit/mainwindow/statusbar/test_backforward.py b/tests/unit/mainwindow/statusbar/test_backforward.py index f2dec3d3f..6a3df1947 100644 --- a/tests/unit/mainwindow/statusbar/test_backforward.py +++ b/tests/unit/mainwindow/statusbar/test_backforward.py @@ -37,12 +37,12 @@ def backforward_widget(qtbot): (False, True, '[>]'), (True, True, '[<>]'), ]) -def test_backforward_widget(backforward_widget, stubs, +def test_backforward_widget(backforward_widget, tabbed_browser_stubs, fake_web_tab, can_go_back, can_go_forward, expected_text): """Ensure the Backforward widget shows the correct text.""" tab = fake_web_tab(can_go_back=can_go_back, can_go_forward=can_go_forward) - tabbed_browser = stubs.TabbedBrowserStub() + tabbed_browser = tabbed_browser_stubs[0] tabbed_browser.current_index = 1 tabbed_browser.tabs = [tab] backforward_widget.on_tab_cur_url_changed(tabbed_browser) @@ -58,10 +58,10 @@ def test_backforward_widget(backforward_widget, stubs, assert not backforward_widget.isVisible() -def test_none_tab(backforward_widget, stubs, fake_web_tab): +def test_none_tab(backforward_widget, tabbed_browser_stubs, fake_web_tab): """Make sure nothing crashes when passing None as tab.""" tab = fake_web_tab(can_go_back=True, can_go_forward=True) - tabbed_browser = stubs.TabbedBrowserStub() + tabbed_browser = tabbed_browser_stubs[0] tabbed_browser.current_index = 1 tabbed_browser.tabs = [tab] backforward_widget.on_tab_cur_url_changed(tabbed_browser) diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py index 9ca4fdcbb..d97a39f7b 100644 --- a/tests/unit/misc/test_editor.py +++ b/tests/unit/misc/test_editor.py @@ -22,7 +22,6 @@ import os import os.path import logging -from unittest import mock from PyQt5.QtCore import QProcess import pytest @@ -40,9 +39,9 @@ def patch_things(config_stub, monkeypatch, stubs): @pytest.fixture def editor(caplog): ed = editormod.ExternalEditor() - ed.editing_finished = mock.Mock() yield ed with caplog.at_level(logging.ERROR): + ed._remove_file = True ed._cleanup() @@ -59,14 +58,14 @@ class TestArg: config_stub.val.editor.command = ['bin', 'foo', '{}', 'bar'] editor.edit("") editor._proc._proc.start.assert_called_with( - "bin", ["foo", editor._file.name, "bar"]) + "bin", ["foo", editor._filename, "bar"]) def test_placeholder_inline(self, config_stub, editor): """Test starting editor with placeholder arg inside of another arg.""" config_stub.val.editor.command = ['bin', 'foo{}', 'bar'] editor.edit("") editor._proc._proc.start.assert_called_with( - "bin", ["foo" + editor._file.name, "bar"]) + "bin", ["foo" + editor._filename, "bar"]) class TestFileHandling: @@ -76,20 +75,29 @@ class TestFileHandling: def test_ok(self, editor): """Test file handling when closing with an exit status == 0.""" editor.edit("") - filename = editor._file.name + filename = editor._filename assert os.path.exists(filename) assert os.path.basename(filename).startswith('qutebrowser-editor-') editor._proc.finished.emit(0, QProcess.NormalExit) assert not os.path.exists(filename) + def test_existing_file(self, editor, tmpdir): + """Test editing an existing file.""" + path = tmpdir / 'foo.txt' + path.ensure() + + editor.edit_file(str(path)) + editor._proc.finished.emit(0, QProcess.NormalExit) + + assert path.exists() + def test_error(self, editor): """Test file handling when closing with an exit status != 0.""" editor.edit("") - filename = editor._file.name + filename = editor._filename assert os.path.exists(filename) - editor._proc._proc.exitStatus = mock.Mock( - return_value=QProcess.CrashExit) + editor._proc._proc.exitStatus = lambda: QProcess.CrashExit editor._proc.finished.emit(1, QProcess.NormalExit) assert os.path.exists(filename) @@ -99,11 +107,10 @@ class TestFileHandling: def test_crash(self, editor): """Test file handling when closing with a crash.""" editor.edit("") - filename = editor._file.name + filename = editor._filename assert os.path.exists(filename) - editor._proc._proc.exitStatus = mock.Mock( - return_value=QProcess.CrashExit) + editor._proc._proc.exitStatus = lambda: QProcess.CrashExit editor._proc.error.emit(QProcess.Crashed) editor._proc.finished.emit(0, QProcess.CrashExit) @@ -114,7 +121,7 @@ class TestFileHandling: def test_unreadable(self, message_mock, editor, caplog): """Test file handling when closing with an unreadable file.""" editor.edit("") - filename = editor._file.name + filename = editor._filename assert os.path.exists(filename) os.chmod(filename, 0o077) if os.access(filename, os.R_OK): @@ -156,15 +163,17 @@ class TestFileHandling: ('Hällö Wörld', 'Überprüfung'), ('\u2603', '\u2601') # Unicode snowman -> cloud ]) -def test_modify(editor, initial_text, edited_text): +def test_modify(qtbot, editor, initial_text, edited_text): """Test if inputs get modified correctly.""" editor.edit(initial_text) - with open(editor._file.name, 'r', encoding='utf-8') as f: + with open(editor._filename, 'r', encoding='utf-8') as f: assert f.read() == initial_text - with open(editor._file.name, 'w', encoding='utf-8') as f: + with open(editor._filename, 'w', encoding='utf-8') as f: f.write(edited_text) - editor._proc.finished.emit(0, QProcess.NormalExit) - editor.editing_finished.emit.assert_called_with(edited_text) + with qtbot.wait_signal(editor.editing_finished) as blocker: + editor._proc.finished.emit(0, QProcess.NormalExit) + + assert blocker.args == [edited_text] diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py index d8024b207..b515535bb 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 objreg, standarddir, utils +from qutebrowser.utils import standarddir, utils from helpers import stubs @@ -45,12 +45,8 @@ pytestmark = pytest.mark.usefixtures('qapp') def shutdown_server(): """If ipc.send_or_listen was called, make sure to shut server down.""" yield - try: - server = objreg.get('ipc-server') - except KeyError: - pass - else: - server.shutdown() + if ipc.server is not None: + ipc.server.shutdown() @pytest.fixture @@ -380,7 +376,7 @@ class TestHandleConnection: monkeypatch.setattr(ipc_server._server, 'nextPendingConnection', m) ipc_server.ignored = True ipc_server.handle_connection() - assert not m.called + m.assert_not_called() def test_no_connection(self, ipc_server, caplog): ipc_server.handle_connection() @@ -609,13 +605,6 @@ class TestSendOrListen: return self.Args(no_err_windows=True, basedir='/basedir/for/testing', command=['test'], target=None) - @pytest.fixture(autouse=True) - def cleanup(self): - try: - objreg.delete('ipc-server') - except KeyError: - pass - @pytest.fixture def qlocalserver_mock(self, mocker): m = mocker.patch('qutebrowser.misc.ipc.QLocalServer', autospec=True) @@ -639,8 +628,7 @@ class TestSendOrListen: assert isinstance(ret_server, ipc.IPCServer) msgs = [e.message for e in caplog.records] assert "Starting IPC server..." in msgs - objreg_server = objreg.get('ipc-server') - assert objreg_server is ret_server + assert ret_server is ipc.server with qtbot.waitSignal(ret_server.got_args): ret_client = ipc.send_or_listen(args) diff --git a/tests/unit/misc/test_keyhints.py b/tests/unit/misc/test_keyhints.py index c40958c86..3e94f1271 100644 --- a/tests/unit/misc/test_keyhints.py +++ b/tests/unit/misc/test_keyhints.py @@ -92,6 +92,23 @@ def test_suggestions(keyhint, config_stub): ('a', 'yellow', 'c', 'message-info cmd-ac')) +def test_suggestions_with_count(keyhint, config_stub, monkeypatch, stubs): + """Test that a count prefix filters out commands that take no count.""" + monkeypatch.setattr('qutebrowser.commands.cmdutils.cmd_dict', { + 'foo': stubs.FakeCommand(name='foo', takes_count=lambda: False), + 'bar': stubs.FakeCommand(name='bar', takes_count=lambda: True), + }) + + bindings = {'normal': {'aa': 'foo', 'ab': 'bar'}} + config_stub.val.bindings.default = bindings + config_stub.val.bindings.commands = bindings + + keyhint.update_keyhint('normal', '2a') + assert keyhint.text() == expected_text( + ('a', 'yellow', 'b', 'bar'), + ) + + def test_special_bindings(keyhint, config_stub): """Ensure a prefix of '<' doesn't suggest special keys.""" bindings = {'normal': { diff --git a/tests/unit/misc/test_msgbox.py b/tests/unit/misc/test_msgbox.py index 5f0058dea..cab72c251 100644 --- a/tests/unit/misc/test_msgbox.py +++ b/tests/unit/misc/test_msgbox.py @@ -27,6 +27,11 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMessageBox, QWidget +@pytest.fixture(autouse=True) +def patch_args(fake_args): + fake_args.no_err_windows = False + + def test_attributes(qtbot): """Test basic QMessageBox attributes.""" title = 'title' @@ -85,3 +90,12 @@ def test_information(qtbot): assert box.windowTitle() == 'foo' assert box.text() == 'bar' assert box.icon() == QMessageBox.Information + + +def test_no_err_windows(fake_args, capsys): + fake_args.no_err_windows = True + box = msgbox.information(parent=None, title='foo', text='bar') + box.exec_() # should do nothing + out, err = capsys.readouterr() + assert not out + assert err == 'Message box: foo; bar\n' diff --git a/tests/unit/misc/test_sessions.py b/tests/unit/misc/test_sessions.py index 3a387d0dc..771430d5b 100644 --- a/tests/unit/misc/test_sessions.py +++ b/tests/unit/misc/test_sessions.py @@ -138,20 +138,12 @@ class FakeMainWindow(QObject): @pytest.fixture -def fake_window(win_registry, stubs, monkeypatch, qtbot): +def fake_window(tabbed_browser_stubs): """Fixture which provides a fake main windows with a tabbedbrowser.""" win0 = FakeMainWindow(b'fake-geometry-0', win_id=0) objreg.register('main-window', win0, scope='window', window=0) - - webview = QWebView() - qtbot.add_widget(webview) - browser = stubs.TabbedBrowserStub([webview]) - objreg.register('tabbed-browser', browser, scope='window', window=0) - yield - objreg.delete('main-window', scope='window', window=0) - objreg.delete('tabbed-browser', scope='window', window=0) class TestSaveAll: @@ -192,13 +184,12 @@ class TestSave: return state @pytest.fixture - def fake_history(self, win_registry, stubs, monkeypatch, webview): + def fake_history(self, stubs, tabbed_browser_stubs, monkeypatch, webview): """Fixture which provides a window with a fake history.""" win = FakeMainWindow(b'fake-geometry-0', win_id=0) objreg.register('main-window', win, scope='window', window=0) - browser = stubs.TabbedBrowserStub([webview]) - objreg.register('tabbed-browser', browser, scope='window', window=0) + browser = tabbed_browser_stubs[0] qapp = stubs.FakeQApplication(active_window=win) monkeypatch.setattr(sessions, 'QApplication', qapp) diff --git a/tests/unit/misc/test_sql.py b/tests/unit/misc/test_sql.py index 8997afc3b..953c8c498 100644 --- a/tests/unit/misc/test_sql.py +++ b/tests/unit/misc/test_sql.py @@ -49,7 +49,7 @@ def test_insert_replace(qtbot): table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=True) assert list(table) == [('one', 11, True)] - with pytest.raises(sql.SqlException): + with pytest.raises(sql.SqlError): table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=False) @@ -85,7 +85,7 @@ def test_insert_batch_replace(qtbot): ('one', 11, True), ('nine', 19, True)] - with pytest.raises(sql.SqlException): + with pytest.raises(sql.SqlError): table.insert_batch({'name': ['one', 'nine'], 'val': [11, 19], 'lucky': [True, True]}) diff --git a/tests/unit/misc/test_utilcmds.py b/tests/unit/misc/test_utilcmds.py index dfb99115d..9c679774a 100644 --- a/tests/unit/misc/test_utilcmds.py +++ b/tests/unit/misc/test_utilcmds.py @@ -25,10 +25,11 @@ import signal import time import pytest +from PyQt5.QtCore import QUrl from qutebrowser.misc import utilcmds from qutebrowser.commands import cmdexc -from qutebrowser.utils import utils +from qutebrowser.utils import utils, objreg @contextlib.contextmanager @@ -142,3 +143,16 @@ def test_window_only(mocker, monkeypatch): assert not test_windows[0].closed assert not test_windows[1].closed assert test_windows[2].closed + + +@pytest.fixture +def tabbed_browser(stubs, win_registry): + tb = stubs.TabbedBrowserStub() + objreg.register('tabbed-browser', tb, scope='window', window=0) + yield tb + objreg.delete('tabbed-browser', scope='window', window=0) + + +def test_version(tabbed_browser): + utilcmds.version(win_id=0) + assert tabbed_browser.opened_url == QUrl('qute://version') diff --git a/tests/unit/utils/test_standarddir.py b/tests/unit/utils/test_standarddir.py index 3dcb282d2..20ea88645 100644 --- a/tests/unit/utils/test_standarddir.py +++ b/tests/unit/utils/test_standarddir.py @@ -494,17 +494,17 @@ def test_init(mocker, tmpdir, args_kind): assert standarddir._locations != {} if args_kind == 'normal': if utils.is_mac: - assert not m_windows.called + m_windows.assert_not_called() assert m_mac.called elif utils.is_windows: assert m_windows.called - assert not m_mac.called + m_mac.assert_not_called() else: - assert not m_windows.called - assert not m_mac.called + m_windows.assert_not_called() + m_mac.assert_not_called() else: - assert not m_windows.called - assert not m_mac.called + m_windows.assert_not_called() + m_mac.assert_not_called() @pytest.mark.linux diff --git a/tox.ini b/tox.ini index 83fb3d91d..704e93744 100644 --- a/tox.ini +++ b/tox.ini @@ -260,6 +260,7 @@ commands = # WORKAROUND for https://github.com/tox-dev/tox/issues/503 install_command = pip install --exists-action w {opts} {packages} basepython = {env:PYTHON:python3} +passenv = APPDATA deps = -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-pyinstaller.txt @@ -273,4 +274,4 @@ commands = deps = whitelist_externals = eslint changedir = {toxinidir}/qutebrowser/javascript -commands = eslint --color . +commands = eslint --color --report-unused-disable-directives .