summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRyan Roden-Corrent <ryan@rcorre.net>2017-08-06 10:54:19 -0400
committerRyan Roden-Corrent <ryan@rcorre.net>2017-08-06 18:13:49 -0400
commit71b71dbc58092b6068a3f5ee4b1c287193ad348d (patch)
tree28c7ccffac920c433d92740a0f575f9d8357ef09
parent0f85898137e6065e8558b3b01b010255f3ca3404 (diff)
parent49b858e3599a361cf5c994358c6b189dddfb522c (diff)
downloadqutebrowser-71b71dbc58092b6068a3f5ee4b1c287193ad348d.tar.gz
qutebrowser-71b71dbc58092b6068a3f5ee4b1c287193ad348d.zip
Merge remote-tracking branch 'upstream/master' into HEAD
-rw-r--r--.flake83
-rw-r--r--.github/CODEOWNERS8
-rw-r--r--.travis.yml8
-rw-r--r--CHANGELOG.asciidoc157
-rw-r--r--CONTRIBUTING.asciidoc17
-rw-r--r--FAQ.asciidoc26
-rw-r--r--INSTALL.asciidoc10
-rw-r--r--MANIFEST.in2
-rw-r--r--README.asciidoc292
-rw-r--r--doc/help/commands.asciidoc12
-rw-r--r--doc/help/settings.asciidoc8
-rw-r--r--doc/notes196
-rw-r--r--doc/quickstart.asciidoc2
-rw-r--r--doc/userscripts.asciidoc2
-rw-r--r--misc/qutebrowser.spec2
-rw-r--r--misc/requirements/requirements-codecov.txt6
-rw-r--r--misc/requirements/requirements-flake8.txt4
-rw-r--r--misc/requirements/requirements-pip.txt2
-rw-r--r--misc/requirements/requirements-pylint-master.txt6
-rw-r--r--misc/requirements/requirements-pylint.txt6
-rw-r--r--misc/requirements/requirements-pyqt.txt4
-rw-r--r--misc/requirements/requirements-tests.txt16
-rw-r--r--misc/requirements/requirements-vulture.txt2
-rwxr-xr-xmisc/userscripts/readability18
-rw-r--r--pytest.ini10
-rw-r--r--qutebrowser/__init__.py2
-rw-r--r--qutebrowser/app.py50
-rw-r--r--qutebrowser/browser/browsertab.py24
-rw-r--r--qutebrowser/browser/commands.py107
-rw-r--r--qutebrowser/browser/downloads.py22
-rw-r--r--qutebrowser/browser/history.py397
-rw-r--r--qutebrowser/browser/qtnetworkdownloads.py7
-rw-r--r--qutebrowser/browser/qutescheme.py95
-rw-r--r--qutebrowser/browser/urlmarks.py9
-rw-r--r--qutebrowser/browser/webengine/webenginesettings.py14
-rw-r--r--qutebrowser/browser/webengine/webenginetab.py21
-rw-r--r--qutebrowser/browser/webkit/webkithistory.py8
-rw-r--r--qutebrowser/browser/webkit/webkittab.py13
-rw-r--r--qutebrowser/completion/completer.py70
-rw-r--r--qutebrowser/completion/completiondelegate.py5
-rw-r--r--qutebrowser/completion/completionwidget.py76
-rw-r--r--qutebrowser/completion/models/base.py130
-rw-r--r--qutebrowser/completion/models/completionmodel.py232
-rw-r--r--qutebrowser/completion/models/configmodel.py189
-rw-r--r--qutebrowser/completion/models/histcategory.py104
-rw-r--r--qutebrowser/completion/models/instances.py162
-rw-r--r--qutebrowser/completion/models/listcategory.py92
-rw-r--r--qutebrowser/completion/models/miscmodels.py319
-rw-r--r--qutebrowser/completion/models/sortfilter.py191
-rw-r--r--qutebrowser/completion/models/urlmodel.py204
-rw-r--r--qutebrowser/config/config.py3
-rw-r--r--qutebrowser/html/backend-warning.html2
-rw-r--r--qutebrowser/html/error.html2
-rw-r--r--qutebrowser/javascript/history.js40
-rw-r--r--qutebrowser/mainwindow/mainwindow.py12
-rw-r--r--qutebrowser/mainwindow/messageview.py11
-rw-r--r--qutebrowser/mainwindow/statusbar/backforward.py49
-rw-r--r--qutebrowser/mainwindow/statusbar/bar.py9
-rw-r--r--qutebrowser/mainwindow/tabbedbrowser.py36
-rw-r--r--qutebrowser/mainwindow/tabwidget.py42
-rw-r--r--qutebrowser/misc/crashsignal.py5
-rw-r--r--qutebrowser/misc/earlyinit.py2
-rw-r--r--qutebrowser/misc/guiprocess.py4
-rw-r--r--qutebrowser/misc/ipc.py64
-rw-r--r--qutebrowser/misc/lineparser.py72
-rw-r--r--qutebrowser/misc/sessions.py25
-rw-r--r--qutebrowser/misc/sql.py256
-rw-r--r--qutebrowser/utils/log.py3
-rw-r--r--qutebrowser/utils/standarddir.py2
-rw-r--r--qutebrowser/utils/usertypes.py7
-rw-r--r--qutebrowser/utils/utils.py24
-rw-r--r--qutebrowser/utils/version.py11
-rw-r--r--requirements.txt1
-rwxr-xr-xscripts/asciidoc2html.py2
-rwxr-xr-xscripts/dev/build_release.py78
-rw-r--r--scripts/dev/check_coverage.py14
-rw-r--r--scripts/dev/ci/travis_install.sh2
-rw-r--r--scripts/dev/ci/travis_run.sh1
-rwxr-xr-xscripts/dev/run_vulture.py10
-rwxr-xr-xscripts/dev/src2asciidoc.py38
-rwxr-xr-xscripts/open_url_in_instance.sh18
-rw-r--r--tests/conftest.py4
-rw-r--r--tests/end2end/conftest.py2
-rw-r--r--tests/end2end/data/downloads/download with no title.html8
-rw-r--r--tests/end2end/data/downloads/qutebrowser.pngbin0 -> 4278 bytes
-rw-r--r--tests/end2end/features/completion.feature52
-rw-r--r--tests/end2end/features/conftest.py4
-rw-r--r--tests/end2end/features/downloads.feature17
-rw-r--r--tests/end2end/features/hints.feature2
-rw-r--r--tests/end2end/features/history.feature30
-rw-r--r--tests/end2end/features/misc.feature8
-rw-r--r--tests/end2end/features/prompts.feature4
-rw-r--r--tests/end2end/features/spawn.feature5
-rw-r--r--tests/end2end/features/tabs.feature10
-rw-r--r--tests/end2end/features/test_completion_bdd.py2
-rw-r--r--tests/end2end/features/test_downloads_bdd.py9
-rw-r--r--tests/end2end/features/test_history_bdd.py41
-rw-r--r--tests/end2end/features/yankpaste.feature2
-rw-r--r--tests/end2end/fixtures/testprocess.py4
-rw-r--r--tests/end2end/test_invocations.py1
-rw-r--r--tests/helpers/fixtures.py47
-rw-r--r--tests/helpers/stubs.py54
-rw-r--r--tests/unit/browser/test_history.py351
-rw-r--r--tests/unit/browser/test_qutescheme.py54
-rw-r--r--tests/unit/browser/webkit/test_downloads.py36
-rw-r--r--tests/unit/browser/webkit/test_history.py383
-rw-r--r--tests/unit/completion/test_column_widths.py50
-rw-r--r--tests/unit/completion/test_completer.py149
-rw-r--r--tests/unit/completion/test_completionmodel.py117
-rw-r--r--tests/unit/completion/test_completionwidget.py124
-rw-r--r--tests/unit/completion/test_histcategory.py167
-rw-r--r--tests/unit/completion/test_listcategory.py50
-rw-r--r--tests/unit/completion/test_models.py446
-rw-r--r--tests/unit/completion/test_sortfilter.py230
-rw-r--r--tests/unit/config/old_configs/qutebrowser-v0.11.0.conf251
-rw-r--r--tests/unit/mainwindow/statusbar/test_backforward.py76
-rw-r--r--tests/unit/mainwindow/test_messageview.py13
-rw-r--r--tests/unit/misc/test_editor.py10
-rw-r--r--tests/unit/misc/test_guiprocess.py6
-rw-r--r--tests/unit/misc/test_ipc.py97
-rw-r--r--tests/unit/misc/test_lineparser.py87
-rw-r--r--tests/unit/misc/test_sessions.py13
-rw-r--r--tests/unit/misc/test_sql.py179
-rw-r--r--tests/unit/utils/test_qtutils.py2
-rw-r--r--tests/unit/utils/test_standarddir.py4
-rw-r--r--tests/unit/utils/test_utils.py31
-rw-r--r--tests/unit/utils/test_version.py165
-rw-r--r--tox.ini22
128 files changed, 3955 insertions, 3671 deletions
diff --git a/.flake8 b/.flake8
index 14fd08034..1d33859fc 100644
--- a/.flake8
+++ b/.flake8
@@ -11,6 +11,7 @@ exclude = .*,__pycache__,resources.py
# (for pytest's __tracebackhide__)
# F401: Unused import
# N802: function name should be lowercase
+# N806: variable in function should be lowercase
# P101: format string does contain unindexed parameters
# P102: docstring does contain unindexed parameters
# P103: other string does contain unindexed parameters
@@ -38,7 +39,7 @@ putty-ignore =
/# pragma: no mccabe/ : +C901
tests/*/test_*.py : +D100,D101,D401
tests/conftest.py : +F403
- tests/unit/browser/webkit/test_history.py : +N806
+ tests/unit/browser/test_history.py : +N806
tests/helpers/fixtures.py : +N806
tests/unit/browser/webkit/http/test_content_disposition.py : +D400
scripts/dev/ci/appveyor_install.py : +FI53
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 000000000..2b8c12de9
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,8 @@
+qutebrowser/browser/history.py @rcorre
+qutebrowser/completion/* @rcorre
+qutebrowser/misc/sql.py @rcorre
+tests/end2end/features/completion.feature @rcorre
+tests/end2end/features/test_completion_bdd.py @rcorre
+tests/unit/browser/test_history.py @rcorre
+tests/unit/completion/* @rcorre
+tests/unit/misc/test_sql.py @rcorre
diff --git a/.travis.yml b/.travis.yml
index e18bd2efa..ec2868730 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -25,12 +25,16 @@ matrix:
env: TESTENV=py36-pyqt571
- os: linux
language: python
+ python: 3.6
+ env: TESTENV=py36-pyqt58
+ - os: linux
+ language: python
python: 3.5
- env: TESTENV=py35-pyqt58
+ env: TESTENV=py35-pyqt59
- os: linux
language: python
python: 3.6
- env: TESTENV=py36-pyqt58
+ env: TESTENV=py36-pyqt59
- os: osx
env: TESTENV=py36 OSX=elcapitan
osx_image: xcode7.3
diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc
index 406400413..9f93d26bd 100644
--- a/CHANGELOG.asciidoc
+++ b/CHANGELOG.asciidoc
@@ -14,9 +14,69 @@ This project adheres to http://semver.org/[Semantic Versioning].
// `Fixed` for any bug fixes.
// `Security` to invite users to upgrade in case of vulnerabilities.
-v0.11.0 (unreleased)
+v1.0.0 (unreleased)
+-------------------
+
+Breaking changes
+~~~~~~~~~~~~~~~~
+
+- Support for legacy QtWebKit (before 5.212 which is distributed
+ independently from Qt) is dropped.
+- Support for Python 3.4 is dropped.
+- Support for Qt before 5.7 is dropped.
+- New dependency on the QtSql module and Qt sqlite support.
+- New dependency on ruamel.yaml; dropped PyYAML dependency.
+- The QtWebEngine backend is now used by default if available.
+- New config system which ignores the old config file.
+- The depedency on PyOpenGL (when using QtWebEngine) got removed. Note
+ that PyQt5.QtOpenGL is still a dependency.
+
+Major changes
+~~~~~~~~~~~~~
+
+- New completion engine based on sqlite, which allows to complete
+ the entire browsing history.
+- Completely rewritten configuration system.
+
+Added
+~~~~~
+
+- New back/forward indicator in the statusbar
+
+Changed
+~~~~~~~
+
+- Upgrading qutebrowser with a version older than v0.4.0 still running now won't
+ work properly anymore.
+- Using `:download` now uses the page's title as filename.
+- Using `:back` or `:forward` with a count now skips intermediate pages.
+- When there are multiple messages shown, the timeout is increased.
+- `:search` now only clears the search if one was displayed before, so pressing
+ `<Escape>` doesn't un-focus inputs anymore.
+
+Fixes
+~~~~~
+
+- Exiting fullscreen via `:fullscreen` or buttons on a page now
+ restores the correct previous window state (maximized/fullscreen).
+
+v0.11.1 (unreleased)
--------------------
+Fixes
+~~~~~
+
+- Fixed empty space being shown after tabs in the tabbar in some cases.
+- Fixed `:restart` in private browsing mode.
+- Fixed printing on macOS.
+- Closing a pinned tab via mouse now also prompts for confirmation.
+- The "try again" button on error pages works correctly again.
+- :spawn -u -d is now disallowed.
+- :spawn -d shows error messages correctly now.
+
+v0.11.0
+-------
+
New dependencies
~~~~~~~~~~~~~~~~
@@ -28,7 +88,10 @@ New dependencies
Added
~~~~~
-- New `-p` flag for `:open` to open a private window.
+- Private browsing is now implemented for QtWebEngine, *and changed its
+ behavior*: The `general -> private-browsing` setting now only applies to newly
+ opened windows, and you can use the `-p` flag to `:open` to open a private
+ window.
- New "pinned tabs" feature, with a new `:tab-pin` command (bound
to `<Ctrl-p>` by default).
- (QtWebEngine) Implemented `:follow-selected`.
@@ -45,6 +108,8 @@ Added
customize statusbar colors for private windows.
- New `{private}` field displaying `[Private Mode]` for
`ui -> window-title-format` and `tabs -> title-format`.
+- (QtWebEngine) Proxy support with Qt 5.7.1 (already was supported for 5.8 and
+ newer)
Changed
~~~~~~~
@@ -52,62 +117,51 @@ Changed
- To prevent elaborate phishing attacks, the Punycode version (`xn--*`) is now
shown in addition to the decoded version for international domain names
(IDN).
-- Private browsing is now implemented for QtWebEngine, and changed it's
- behavior: The `general -> private-browsing` setting now only applies to newly
- opened windows, and you can use the `-p` flag to `:open` to open a private
- window.
+- Starting with legacy QtWebKit now shows a warning message.
+ *With the next release, support for it will be removed.*
+- The Windows releases are redone from scratch, which means:
+ - They now use the new QtWebEngine backend
+ - The bundled Qt is updated from 5.5 to 5.9
+ - The bundled Python is updated from 3.4 to 3.6
+ - They are now generated with PyInstaller instead of cx_Freeze
+ - The installer is now generated using NSIS instead of being a MSI
- Improved `qute://history` page (with lazy loading)
-- Starting with legacy QtWebKit now shows a warning message once.
- Crash reports are not public anymore.
- Paths like `C:` are now treated as absolute paths on Windows for downloads,
and invalid paths are handled properly.
-- PAC on QtWebKit now supports SOCKS5 as type.
-- Comments in the config file are now before the individual options instead of
- being before sections.
+- Comments in the config file are now placed before the individual options
+ instead of being before sections.
- Messages are now hidden when clicked.
- stdin is now closed immediately for processes spawned from qutebrowser.
- When `ui -> message-timeout` is set to 0, messages are now never cleared.
- Middle/right-clicking the blank parts of the tab bar (when vertical) now
closes the current tab.
-- (QtWebEngine) With Qt 5.9, `content -> cookies-store` can now be set without
- a restart.
-- (QtWebEngine) With Qt 5.9, better error messages are now shown for failed
- downloads.
- The adblocker now also blocks non-GET requests (e.g. POST).
- `javascript:` links can now be hinted.
- `:view-source`, `:tab-clone` and `:navigate --tab` now don't open the tab as
"explicit" anymore, i.e. (with the default settings) open it next to the
active tab.
-- (QtWebEngine) The underlying Chromium version is now shown in the version
- info.
- `qute:*` pages now use `qute://*` instead (e.g. `qute://version` instead of
`qute:version`), but the old versions are automatically redirected.
-- The Windows releases are redone from scratch, which means:
- - They now use the new QtWebEngine backend
- - The bundled Qt is updated from 5.5 to 5.9
- - The bundled Python is updated from 3.4 to 3.6
- - They are now generated with PyInstaller instead of cx_Freeze
- - The installer is now generated using NSIS instead of being a MSI
- Texts in prompts are now selectable.
-- Renderer process crashes now show an error page.
-- (QtWebKit) storage -> offline-web-application-storage` got renamed to `...-cache`
- The default level for `:messages` is now `info`, not `error`
- Trying to focus the currently focused tab with `:tab-focus` now focuses the
last viewed tab.
+- (QtWebEngine) With Qt 5.9, `content -> cookies-store` can now be set without
+ a restart.
+- (QtWebEngine) With Qt 5.9, better error messages are now shown for failed
+ downloads.
+- (QtWebEngine) The underlying Chromium version is now shown in the version
+ info.
+- (QtWebKit) Renderer process crashes now show an error page on Qt 5.9 or newer.
+- (QtWebKit) storage -> offline-web-application-storage` got renamed to `...-cache`
+- (QtWebKit) PAC now supports SOCKS5 as type.
Fixed
~~~~~
- The macOS .dmg is now built against Qt 5.9 which fixes various
important issues (such as not being able to type dead keys).
-- (QtWebEngine) Added a workaround for a black screen with some setups
- (the workaround requires PyOpenGL to be installed, but it's optional)
-- (QtWebEngine) Starting with Nouveau graphics now shows an error message
- instead of crashing in Qt. This adds a new dependency on `PyQt5.QtOpenGL`.
-- (QtWebEngine) Retrying downloads now shows an error instead of crashing.
-- (QtWebEngine) Cloning a view-source tab now doesn't crash anymore.
-- (QtWebKit) The HTTP cache is disabled on Qt 5.7.1 and 5.8 now as it leads to
- frequent crashes due to a Qt bug.
- Fixed crash with `:download` on PyQt 5.9.
- Cloning a page without history doesn't crash anymore.
- When a download results in a HTTP error, it now shows the error correctly
@@ -117,7 +171,6 @@ Fixed
- Fixed crash when unbinding an unbound key in the key config.
- Fixed crash when using `:debug-log-filter` when `--filter` wasn't given on startup.
- Fixed crash with some invalid setting values.
-- (QtWebKit) Fixed Crash when a PAC file returns an invalid value.
- Continuing a search after clearing it now works correctly.
- The tabbar and completion should now be more consistently and correctly
styled with various system styles.
@@ -125,19 +178,27 @@ Fixed
- The validation for colors in stylesheets is now less strict,
allowing for all valid Qt values.
- `data:` URLs now aren't added to the history anymore.
-- (QtWebEngine) `window.navigator.userAgent` is now set correctly when
- customizing the user agent.
- Accidentally starting with Python 2 now shows a proper error message again.
-- (QtWebEngine) HTML fullscreen is now tracked for each tab separately, which
- means it's not possible anymore to accidentally get stuck in fullscreen state
- by closing a tab with a fullscreen video.
- For some people, running some userscripts crashed - this should now be fixed.
- Various other rare crashes should now be fixed.
- The settings documentation was truncated with v0.10.1 which should now be
fixed.
- Scrolling to an anchor in a background tab now works correctly, and javascript
gets the correct window size for background tabs.
-- (QtWebEngine) `:scroll-page` with `--bottom-navigate` now works correctly
+- (QtWebEngine) Added a workaround for a black screen with some setups
+- (QtWebEngine) Starting with Nouveau graphics now shows an error message
+ instead of crashing in Qt.
+- (QtWebEngine) Retrying downloads now shows an error instead of crashing.
+- (QtWebEngine) Cloning a view-source tab now doesn't crash anymore.
+- (QtWebEngine) `window.navigator.userAgent` is now set correctly when
+ customizing the user agent.
+- (QtWebEngine) HTML fullscreen is now tracked for each tab separately, which
+ means it's not possible anymore to accidentally get stuck in fullscreen state
+ by closing a tab with a fullscreen video.
+- (QtWebEngine) `:scroll-page` with `--bottom-navigate` now works correctly.
+- (QtWebKit) The HTTP cache is disabled on Qt 5.7.1 and 5.8 now as it leads to
+ frequent crashes due to a Qt bug.
+- (QtWebKit) Fixed Crash when a PAC file returns an invalid value.
v0.10.1
-------
@@ -182,7 +243,7 @@ Added
- Open tabs are now auto-saved on each successful load and restored in case of a crash
- `:jseval` now has a `--file` flag so you can pass a javascript file
- `:session-save` now has a `--only-active-window` flag to only save the active window
-- OS X builds are back, and built with QtWebEngine
+- macOS builds are back, and built with QtWebEngine
Changed
~~~~~~~
@@ -484,7 +545,7 @@ Fixed
- Fix crash when pressing enter without a command
- Adjust error message to point out QtWebEngine is unsupported with the OS
X .app currently.
-- Hide Harfbuzz warning with the OS X .app
+- Hide Harfbuzz warning with the macOS .app
v0.8.0
------
@@ -847,7 +908,7 @@ Fixed
- Fixed scrolling to the very left/right with `:scroll-perc`.
- Using an external editor should now work correctly with some funny chars
(U+2028/U+2029/BOM).
-- Movements in caret mode now should work correctly on OS X and Windows.
+- Movements in caret mode now should work correctly on macOS and Windows.
- Fixed upgrade from earlier config versions.
- Fixed crash when killing a running userscript.
- Fixed characters being passed through when shifted with
@@ -922,7 +983,7 @@ Changed
- The completion widget doesn't show a border anymore.
- The tabbar doesn't display ugly arrows anymore if there isn't enough space
for all tabs.
-- Some insignificant Qt warnings which were printed on OS X are now hidden.
+- Some insignificant Qt warnings which were printed on macOS are now hidden.
- Better support for Qt 5.5 and Python 3.5.
Fixed
@@ -1033,7 +1094,7 @@ Fixed
- Fixed AssertionError when closing many windows quickly.
- Various fixes for deprecated key bindings and auto-migrations.
- Workaround for qutebrowser not starting when there are NUL-bytes in the history (because of a currently unknown bug).
-- Fixed handling of keybindings containing Ctrl/Meta on OS X.
+- Fixed handling of keybindings containing Ctrl/Meta on macOS.
- Fixed crash when downloading a URL without filename (e.g. magnet links) via "Save as...".
- Fixed exception when starting qutebrowser with `:set` as argument.
- Fixed horrible completion performance when the `shrink` option was set.
@@ -1131,7 +1192,7 @@ Changed
- Add a `:search` command in addition to `/foo` so it's more visible and can be used from scripts.
- Various improvements to documentation, logging, and the crash reporter.
- Expand `~` to the users home directory with `:run-userscript`.
-- Improve the userscript runner on Linux/OS X by using `QSocketNotifier`.
+- Improve the userscript runner on Linux/macOS by using `QSocketNotifier`.
- Add luakit-like `gt`/`gT` keybindings to cycle through tabs.
- Show default value for config values in the completion.
- Clone tab icon, tab text and zoom level when cloning tabs.
@@ -1151,7 +1212,7 @@ Changed
* `init_venv.py` and `run_checks.py` have been replaced by http://tox.readthedocs.org/[tox]. Install tox and run `tox -e mkvenv` instead.
* The tests now use http://pytest.org/[pytest]
* Many new tests added
- * Mac Mini buildbot to run the tests on OS X.
+ * Mac Mini buildbot to run the tests on macOS.
* Coverage recording via http://nedbatchelder.com/code/coverage/[coverage.py].
* New `--pdb-postmortem argument` to drop into the pdb debugger on exceptions.
* Use https://github.com/ionelmc/python-hunter[hunter] for line tracing instead of a selfmade solution.
@@ -1287,7 +1348,7 @@ Fixed
* Fix rare exception when a key is pressed shortly after opening a window
* Fix exception with certain invalid URLs like `http:foo:0`
-* Work around Qt bug which renders checkboxes on OS X unusable
+* Work around Qt bug which renders checkboxes on macOS unusable
* Fix exception when a local files can't be read in `:adblock-update`
* Hide 2 more Qt warnings.
* Add `!important` to hint CSS so websites don't override the hint look
@@ -1323,7 +1384,7 @@ Changes
* Set zoom to default instead of 100% with `:zoom`/`=`.
* Adjust page zoom if default zoom changed.
* Force tabs to be focused on `:undo`.
-* Replace manual installation instructions on OS X with homebrew/macports.
+* Replace manual installation instructions on macOS with homebrew/macports.
* Allow min-/maximizing of print preview on Windows.
* Various documentation improvements.
* Various other small improvements and cleanups.
diff --git a/CONTRIBUTING.asciidoc b/CONTRIBUTING.asciidoc
index d5c77f521..7d42cad28 100644
--- a/CONTRIBUTING.asciidoc
+++ b/CONTRIBUTING.asciidoc
@@ -5,6 +5,12 @@ The Compiler <mail@qutebrowser.org>
:data-uri:
:toc:
+IMPORTANT: I'm currently (July 2017) more busy than usual until September,
+because of exams coming up. In addition to that, a new config system is coming
+which will conflict with many non-trivial contributions. Because of that, please
+refrain from contributing new features until then. If you're reading this note
+after mid-September, please open an issue.
+
I `&lt;3` footnote:[Of course, that says `<3` in HTML.] contributors!
This document contains guidelines for contributing to qutebrowser, as well as
@@ -39,8 +45,8 @@ pointers:
* https://github.com/qutebrowser/qutebrowser/labels/easy[Issues which should
be easy to solve]
-* https://github.com/qutebrowser/qutebrowser/labels/not%20code[Issues which
-require little/no coding]
+* https://github.com/qutebrowser/qutebrowser/labels/component%3A%20docs[Documentation
+* issues which require little/no coding]
If you prefer C++ or Javascript to Python, see the relevant issues which involve
work in those languages:
@@ -682,8 +688,9 @@ qutebrowser release
* Add newest config to `tests/unit/config/old_configs` and update `test_upgrade_version`
- `python -m qutebrowser --basedir conf :quit`
- - `sed '/^#/d' conf/config/qutebrowser.conf > tests/unit/config/old_configs/qutebrowser-v0.x.y.conf`
+ - `sed '/^#/d' conf/config/qutebrowser.conf > tests/unit/config/old_configs/qutebrowser-v0.$x.$y.conf`
- `rm -r conf`
+ - git add
- commit
* Adjust `__version_info__` in `qutebrowser/__init__.py`.
* Update changelog (remove *(unreleased)*)
@@ -698,8 +705,8 @@ qutebrowser release
as closed.
* Linux: Run `python3 scripts/dev/build_release.py --upload v0.$x.$y`
-* Windows: Run `C:\Python34_x32\python scripts\dev\build_release.py --asciidoc C:\Python27\python C:\asciidoc-8.6.9\asciidoc.py --upload v0.X.Y` (replace X/Y by hand)
-* OS X: Run `python3 scripts/dev/build_release.py --upload v0.X.Y` (replace X/Y by hand)
+* Windows: Run `C:\Python36-32\python scripts\dev\build_release.py --asciidoc C:\Python27\python C:\asciidoc-8.6.9\asciidoc.py --upload v0.X.Y` (replace X/Y by hand)
+* macOS: Run `python3 scripts/dev/build_release.py --upload v0.X.Y` (replace X/Y by hand)
* On server: Run `python3 scripts/dev/download_release.py v0.X.Y` (replace X/Y by hand)
* Update `qutebrowser-git` PKGBUILD if dependencies/install changed
* Announce to qutebrowser and qutebrowser-announce mailinglist
diff --git a/FAQ.asciidoc b/FAQ.asciidoc
index 75aec1583..0fe340474 100644
--- a/FAQ.asciidoc
+++ b/FAQ.asciidoc
@@ -171,6 +171,20 @@ What's the difference between insert and passthrough mode?::
be useful to rebind escape to something else in passthrough mode only, to be
able to send an escape keypress to the website.
+Why takes it longer to open an URL in qutebrowser than in chromium?::
+ When opening an URL in an existing instance the normal qutebrowser
+ Python script is started and a few PyQt libraries need to be
+ loaded until it is detected that there is an instance running
+ where the URL is then passed to. This takes some time.
+ One workaround is to use this
+ https://github.com/qutebrowser/qutebrowser/blob/master/scripts/open_url_in_instance.sh[script]
+ and place it in your $PATH with the name "qutebrowser". This
+ script passes the URL via an unix socket to qutebrowser (if its
+ running already) using socat which is much faster and starts a new
+ qutebrowser if it is not running already. Also check if you want
+ to use webengine as backend in line 17 and change it to your
+ needs.
+
== Troubleshooting
Configuration not saved after modifying config.::
@@ -211,6 +225,18 @@ it's still
https://bugreports.qt.io/browse/QTBUG-42417?jql=component%20%3D%20WebKit%20and%20resolution%20%3D%20Done%20and%20fixVersion%20in%20(5.4.0%2C%20%225.4.0%20Alpha%22%2C%20%225.4.0%20Beta%22%2C%20%225.4.0%20RC%22)%20and%20priority%20in%20(%22P2%3A%20Important%22%2C%20%22P1%3A%20Critical%22%2C%20%22P0%3A%20Blocker%22)[nearly
20 important bugs].
+When using QtWebEngine, qutebrowser reports "Render Process Crashed" and the console prints a traceback on Gentoo Linux or another Source-Based Distro::
+ As stated in https://gcc.gnu.org/gcc-6/changes.html[GCC's Website] GCC 6 has introduced some optimizations that could break non-conforming codebases, like QtWebEngine. +
+ As a workaround, you can disable the nullpointer check optimization by adding the -fno-delete-null-pointer-checks flag while compiling. +
+ On gentoo, you just need to add it into your make.conf, like this: +
+
+ CFLAGS="... -fno-delete-null-pointer-checks"
+ CXXFLAGS="... -fno-delete-null-pointer-checks"
++
+And then re-emerging qtwebengine with: +
+
+ emerge -1 qtwebengine
+
My issue is not listed.::
If you experience any segfaults or crashes, you can report the issue in
https://github.com/qutebrowser/qutebrowser/issues[the issue tracker] or
diff --git a/INSTALL.asciidoc b/INSTALL.asciidoc
index 425bf738c..0cfc3d651 100644
--- a/INSTALL.asciidoc
+++ b/INSTALL.asciidoc
@@ -27,7 +27,7 @@ Using the packages
Install the dependencies via apt-get:
----
-# apt-get install python3-lxml python-tox python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python3-sip python3-jinja2 python3-pygments python3-yaml
+# apt-get install python3-lxml python-tox python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python3-sip python3-jinja2 python3-pygments python3-yaml python3-pyqt5.qtsql libqt5sql5-sqlite
----
On Debian Stretch or Ubuntu 17.04 or later, it's also recommended to use the
@@ -53,7 +53,7 @@ Build it from git
Install the dependencies via apt-get:
----
-# apt-get install python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python-tox python3-sip python3-dev
+# apt-get install python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python-tox python3-sip python3-dev python3-pyqt5.qtsql libqt5sql5-sqlite
----
On Debian Stretch or Ubuntu 17.04 or later, it's also recommended to install
@@ -277,13 +277,13 @@ $ pip install tox
Then <<tox,install qutebrowser via tox>>.
-On OS X
--------
+On macOS
+--------
Prebuilt binary
~~~~~~~~~~~~~~~
-The easiest way to install qutebrowser on OS X is to use the prebuilt `.app`
+The easiest way to install qutebrowser on macOS is to use the prebuilt `.app`
files from the
https://github.com/qutebrowser/qutebrowser/releases[release page].
diff --git a/MANIFEST.in b/MANIFEST.in
index 88c320868..52beeab1e 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -8,7 +8,7 @@ graft icons
graft doc/img
graft misc/apparmor
graft misc/userscripts
-recursive-include scripts *.py
+recursive-include scripts *.py *.sh
include qutebrowser/utils/testfile
include qutebrowser/git-commit-id
include COPYING doc/* README.asciidoc CONTRIBUTING.asciidoc FAQ.asciidoc INSTALL.asciidoc CHANGELOG.asciidoc
diff --git a/README.asciidoc b/README.asciidoc
index 347b8356b..bf6225062 100644
--- a/README.asciidoc
+++ b/README.asciidoc
@@ -36,11 +36,8 @@ Downloads
---------
See the https://github.com/qutebrowser/qutebrowser/releases[github releases
-page] for available downloads (currently a source archive, and standalone
-packages as well as MSI installers for Windows).
-
-See link:INSTALL.asciidoc[INSTALL] for detailed instructions on how to get
-qutebrowser running for various platforms.
+page] for available downloads and the link:INSTALL.asciidoc[INSTALL] file for
+detailed instructions on how to get qutebrowser running on various platforms.
Documentation
-------------
@@ -74,6 +71,9 @@ There's also a https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser-ann
at mailto:qutebrowser-announce@lists.qutebrowser.org[] (the announcements also
get sent to the general qutebrowser@ list).
+If you're a reddit user, there's a
+https://www.reddit.com/r/qutebrowser/[/r/qutebrowser] subreddit there.
+
Contributions / Bugs
--------------------
@@ -98,27 +98,35 @@ Requirements
The following software and libraries are required to run qutebrowser:
-* http://www.python.org/[Python] 3.4 or newer (3.5 recommended)
-* http://qt.io/[Qt] 5.2.0 or newer (5.9.0 recommended)
-* QtWebKit (old or link:https://github.com/annulen/webkit/wiki[reloaded]/NG) or QtWebEngine
+* http://www.python.org/[Python] 3.4 or newer (3.6 recommended) - note that
+ support for Python 3.4
+ https://github.com/qutebrowser/qutebrowser/issues/2742[will be dropped soon].
+* http://qt.io/[Qt] 5.2.0 or newer (5.9 recommended - note that support for Qt
+ < 5.7.1 will be dropped soon) with the following modules:
+ - QtCore / qtbase
+ - QtQuick (part of qtbase in some distributions)
+ - QtSQL (part of qtbase in some distributions)
+ - QtWebEngine, or
+ - QtWebKit (old or link:https://github.com/annulen/webkit/wiki[reloaded]/NG).
+ Note that support for legacy QtWebKit (before 5.212) will be
+ dropped soon.
* http://www.riverbankcomputing.com/software/pyqt/intro[PyQt] 5.2.0 or newer
-(5.8.1 recommended) for Python 3
+ (5.9 recommended) for Python 3. Note that support for PyQt < 5.7 will be
+ dropped soon.
* https://pypi.python.org/pypi/setuptools/[pkg_resources/setuptools]
* http://fdik.org/pyPEG/[pyPEG2]
* http://jinja.pocoo.org/[jinja2]
* http://pygments.org/[pygments]
* http://pyyaml.org/wiki/PyYAML[PyYAML]
-* http://pyopengl.sourceforge.net/[PyOpenGL] when using QtWebEngine
-
-The following libraries are optional and provide a better user experience:
-
-* http://cthedot.de/cssutils/[cssutils]
-To generate the documentation for the `:help` command, when using the git
-repository (rather than a release), http://asciidoc.org/[asciidoc] is needed.
+The following libraries are optional:
-On Windows, https://pypi.python.org/pypi/colorama/[colorama] is needed to
-display colored log output.
+* http://cthedot.de/cssutils/[cssutils] (for an improved `:download --mhtml`
+ with QtWebKit)
+* On Windows, https://pypi.python.org/pypi/colorama/[colorama] for colored log
+ output.
+* http://asciidoc.org/[asciidoc] to generate the documentation for the `:help`
+ command, when using the git repository (rather than a release).
See link:INSTALL.asciidoc[INSTALL] for directions on how to install qutebrowser
and its dependencies.
@@ -142,219 +150,59 @@ get in touch!
Authors
-------
-Contributors, sorted by the number of commits in descending order:
-
-// QUTE_AUTHORS_START
-* Florian Bruhin
-* Daniel Schadt
-* Ryan Roden-Corrent
-* Jan Verbeek
-* Jakub Klinkovský
-* Antoni Boucher
-* Lamar Pavel
-* Marshall Lochbaum
-* Bruno Oliveira
-* thuck
-* Martin Tournoij
-* Imran Sobir
-* Alexander Cogneau
-* Felix Van der Jeugt
-* Daniel Karbach
-* Kevin Velghe
-* Raphael Pierzina
-* Joel Torstensson
-* Patric Schmitz
-* Tarcisio Fedrizzi
-* Jay Kamat
-* Claude
-* Philipp Hansch
-* Fritz Reichwald
-* Corentin Julé
-* meles5
-* Panagiotis Ktistakis
-* Artur Shaik
-* Nathan Isom
-* Thorsten Wißmann
-* Austin Anderson
-* Jimmy
-* Niklas Haas
-* Maciej Wołczyk
-* Clayton Craft
-* sandrosc
-* Alexey "Averrin" Nabrodov
-* pkill9
-* nanjekyejoannah
-* avk
-* ZDarian
-* Milan Svoboda
-* John ShaggyTwoDope Jenkins
-* Peter Vilim
-* Jacob Sword
-* knaggita
-* Oliver Caldwell
-* Nikolay Amiantov
-* Julian Weigt
-* Tomasz Kramkowski
-* Sebastian Frysztak
-* Julie Engel
-* Jonas Schürmann
-* error800
-* Michael Hoang
-* Liam BEGUIN
-* Daniel Fiser
-* skinnay
-* Zach-Button
-* Samuel Walladge
-* Peter Rice
-* Ismail S
-* Halfwit
-* David Vogt
-* Claire Cavanaugh
-* rikn00
-* kanikaa1234
-* haitaka
-* Nick Ginther
-* Michał Góral
-* Michael Ilsaas
-* Martin Zimmermann
-* Marius
-* Link
-* Jussi Timperi
-* Cosmin Popescu
-* Brian Jackson
-* sbinix
-* rsteube
-* neeasade
-* jnphilipp
-* Yannis Rohloff
-* Tobias Patzl
-* Stefan Tatschner
-* Samuel Loury
-* Peter Michely
-* Panashe M. Fundira
-* Lucas Hoffmann
-* Larry Hynes
-* Kirill A. Shutemov
-* Johannes Altmanninger
-* Jeremy Kaplan
-* Ismail
-* Iordanis Grigoriou
-* Edgar Hipp
-* Daryl Finlay
-* arza
-* adam
-* Samir Benmendil
-* Regina Hug
-* Penaz
-* Matthias Lisin
-* Mathias Fussenegger
-* Marcelo Santos
-* Marcel Schilling
-* Joel Bradshaw
-* Jean-Louis Fuchs
-* Franz Fellner
-* Eric Drechsel
-* zwarag
-* xd1le
-* rmortens
-* oniondreams
-* issue
-* haxwithaxe
-* evan
-* dylan araps
-* caveman
-* addictedtoflames
-* Xitian9
-* Vasilij Schneidermann
-* Tomas Orsava
-* Tom Janson
-* Tobias Werth
-* Tim Harder
-* Thiago Barroso Perrotta
-* Steve Peak
-* Sorokin Alexei
-* Simon Désaulniers
-* Rok Mandeljc
-* Noah Huesser
-* Moez Bouhlel
-* MikeinRealLife
-* Lazlow Carmichael
-* Kevin Wang
-* Ján Kobezda
-* Justin Partain
-* Johannes Martinsson
-* Jean-Christophe Petkovich
-* Helen Sherwood-Taylor
-* HalosGhost
-* Gregor Pohl
-* Eivind Uggedal
-* Dietrich Daroch
-* Derek Sivers
-* Daniel Lu
-* Daniel Jakots
-* Arseniy Seroka
-* Anton Grensjö
-* Andy Balaam
-* Andreas Fischer
-* Amos Bird
-* Akselmo
-// QUTE_AUTHORS_END
-
-The following people have contributed graphics:
+qutebrowser's primary author is Florian Bruhin (The Compiler), but qutebrowser
+wouldn't be what it is without the help of
+https://github.com/qutebrowser/qutebrowser/graphs/contributors[hundreds of contributors]!
+
+Additionally, the following people have contributed graphics:
* Jad/link:http://yelostudio.com[yelo] (new icon)
* WOFall (original icon)
* regines (key binding cheatsheet)
-Thanks / Similar projects
--------------------------
+Also, thanks to everyone who contributed to one of qutebrowser's
+link:doc/backers.asciidoc[crowdfunding campaigns]!
-Many projects with a similar goal as qutebrowser exist:
-
-* http://portix.bitbucket.org/dwb/[dwb] (C, GTK+ with WebKit1, currently
-http://www.reddit.com/r/linux/comments/2huqbc/dwb_abandoned/[unmaintained] -
-main inspiration for qutebrowser)
-* https://github.com/fanglingsu/vimb[vimb] (C, GTK+ with WebKit1, active)
-* http://sourceforge.net/p/vimprobable/wiki/Home/[vimprobable] (C, GTK+ with
-WebKit1, dead)
-* http://surf.suckless.org/[surf] (C, GTK+ with WebKit1, active)
-* https://mason-larobina.github.io/luakit/[luakit] (C/Lua, GTK+ with
-WebKit1, not very active)
-* http://pwmt.org/projects/jumanji/[jumanji] (C, GTK+ with WebKit1, not very
-active)
-* http://www.uzbl.org/[uzbl] (C, GTK+ with WebKit1/WebKit2, active)
-* http://conkeror.org/[conkeror] (Javascript, Emacs-like, XULRunner/Gecko,
-active)
-* https://github.com/AeroNotix/lispkit[lispkit] (quite new, lisp, GTK+ with
-WebKit, active)
-* http://www.vimperator.org/[Vimperator] (Firefox addon)
-* http://5digits.org/pentadactyl/[Pentadactyl] (Firefox addon)
-* https://github.com/akhodakivskiy/VimFx[VimFx] (Firefox addon)
-* https://github.com/1995eaton/chromium-vim[cVim] (Chrome/Chromium addon)
-* http://vimium.github.io/[vimium] (Chrome/Chromium addon)
-* https://chrome.google.com/webstore/detail/vichrome/gghkfhpblkcmlkmpcpgaajbbiikbhpdi?hl=en[ViChrome] (Chrome/Chromium addon)
-* https://github.com/jinzhu/vrome[Vrome] (Chrome/Chromium addon)
+Similar projects
+----------------
+Many projects with a similar goal as qutebrowser exist.
Most of them were inspirations for qutebrowser in some way, thanks for that!
-Thanks as well to the following projects and people for helping me with
-problems and helpful hints:
-
-* http://eric-ide.python-projects.org/[eric5] / Detlev Offenbach
-* https://code.google.com/p/devicenzo/[devicenzo]
-* portix
-* seir
-* nitroxleecher
-
-Also, thanks to:
-
-* Everyone contributing to the link:doc/backers.asciidoc[crowdfunding].
-* Everyone who had the patience to test qutebrowser before v0.1.
-* Everyone triaging/fixing my bugs in the
-https://bugreports.qt.io/secure/Dashboard.jspa[Qt bugtracker]
-* Everyone answering my questions on http://stackoverflow.com/[Stack Overflow]
-and in IRC.
-* All the projects which were a great help while developing qutebrowser.
+Active
+~~~~~~
+
+* https://fanglingsu.github.io/vimb/[vimb] (C, GTK+ with WebKit2)
+* https://luakit.github.io/luakit/[luakit] (C/Lua, GTK+ with WebKit2)
+* http://surf.suckless.org/[surf] (C, GTK+ with WebKit1/WebKit2)
+* http://www.uzbl.org/[uzbl] (C, GTK+ with WebKit1/WebKit2)
+* Chrome/Chromium addons:
+ https://github.com/1995eaton/chromium-vim[cVim],
+ http://vimium.github.io/[Vimium],
+ https://github.com/brookhong/Surfingkeys[Surfingkeys],
+ http://saka-key.lusakasa.com/[Saka Key]
+* Firefox addons (based on WebExtensions):
+ https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/[Vimium-FF] (experimental),
+ http://saka-key.lusakasa.com/[Saka Key]
+
+Inactive
+~~~~~~~~
+
+* https://bitbucket.org/portix/dwb[dwb] (C, GTK+ with WebKit1,
+https://bitbucket.org/portix/dwb/pull-requests/22/several-cleanups-to-increase-portability/diff[unmaintained] -
+main inspiration for qutebrowser)
+* http://sourceforge.net/p/vimprobable/wiki/Home/[vimprobable] (C, GTK+ with
+ WebKit1)
+* http://pwmt.org/projects/jumanji/[jumanji] (C, GTK+ with WebKit1)
+* http://conkeror.org/[conkeror] (Javascript, Emacs-like, XULRunner/Gecko)
+* Firefox addons (not based on WebExtensions or no recent activity):
+ http://www.vimperator.org/[Vimperator],
+ http://5digits.org/pentadactyl/[Pentadactyl],
+ https://github.com/akhodakivskiy/VimFx[VimFx],
+ https://github.com/shinglyu/QuantumVim[QuantumVim]
+* Chrome/Chromium addons:
+ https://chrome.google.com/webstore/detail/vichrome/gghkfhpblkcmlkmpcpgaajbbiikbhpdi?hl=en[ViChrome],
+ https://github.com/jinzhu/vrome[Vrome]
License
-------
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc
index e60c0f01b..3c0344fb8 100644
--- a/doc/help/commands.asciidoc
+++ b/doc/help/commands.asciidoc
@@ -1,5 +1,5 @@
// DO NOT EDIT THIS FILE DIRECTLY!
-// It is autogenerated from docstrings by running:
+// It is autogenerated by running:
// $ python3 scripts/dev/src2asciidoc.py
= Commands
@@ -1565,6 +1565,7 @@ These commands are mainly intended for debugging. They are hidden if qutebrowser
|<<debug-clear-ssl-errors,debug-clear-ssl-errors>>|Clear remembered SSL error answers.
|<<debug-console,debug-console>>|Show the debugging console.
|<<debug-crash,debug-crash>>|Crash for debugging purposes.
+|<<debug-dump-history,debug-dump-history>>|Dump the history to a file in the old pre-SQL format.
|<<debug-dump-page,debug-dump-page>>|Dump the current page's content to a file.
|<<debug-log-capacity,debug-log-capacity>>|Change the number of log lines to be stored in RAM.
|<<debug-log-filter,debug-log-filter>>|Change the log filter for console logging.
@@ -1599,6 +1600,15 @@ Crash for debugging purposes.
==== positional arguments
* +'typ'+: either 'exception' or 'segfault'.
+[[debug-dump-history]]
+=== debug-dump-history
+Syntax: +:debug-dump-history 'dest'+
+
+Dump the history to a file in the old pre-SQL format.
+
+==== positional arguments
+* +'dest'+: Where to write the file to.
+
[[debug-dump-page]]
=== debug-dump-page
Syntax: +:debug-dump-page [*--plain*] 'dest'+
diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc
index 05daf73fe..effe982e0 100644
--- a/doc/help/settings.asciidoc
+++ b/doc/help/settings.asciidoc
@@ -1,5 +1,5 @@
// DO NOT EDIT THIS FILE DIRECTLY!
-// It is autogenerated from docstrings by running:
+// It is autogenerated by running:
// $ python3 scripts/dev/src2asciidoc.py
= Settings
@@ -1067,11 +1067,15 @@ Default: +pass:[white]+
== colors.tabs.selected.even.bg
Background color of selected even tabs.
+<<<<<<< HEAD
Default: +pass:[black]+
[[colors.tabs.selected.even.fg]]
== colors.tabs.selected.even.fg
Foreground color of selected even tabs.
+=======
+Valid values:
+>>>>>>> upstream/master
Default: +pass:[white]+
@@ -1163,7 +1167,7 @@ Default: +pass:[%Y-%m-%d]+
How many URLs to show in the web history.
0: no history / -1: unlimited
-Default: +pass:[1000]+
+Default: +pass:[-1]+
[[confirm_quit]]
== confirm_quit
diff --git a/doc/notes b/doc/notes
deleted file mode 100644
index 2261e3228..000000000
--- a/doc/notes
+++ /dev/null
@@ -1,196 +0,0 @@
-henk's thoughts
-===============
-
-1. Power to the user! Protect privacy!
-Things the browser should only do with explicit consent from the user, if
-applicable the user should be able to choose which protocol/host/port triplets
-to white/blacklist:
-
-- load/run executable code, like js, flash, java applets, ... (think NoScript)
-- requests to other domains, ports or using a different protocol than what the
- user requested (think RequestPolicy)
-- accept cookies
-- storing/saving/caching things, e.g. open tabs ("session"), cookies, page
- contents, browsing/download history, form data, ...
-- send referrer
-- disclose any (presence, type, version, settings, capabilities, etc.)
- information about OS, browser, installed fonts, plugins, addons, etc.
-
-2. Be efficient!
-I tend to leave a lot of tabs open and nobody can deny that some websites
-simply suck, so the browser should, unless told otherwise by the user:
-
-- load tabs only when needed
-- run code in tabs only when needed, i.e. when the tab is currently being
- used/viewed (background tabs doing some JS magic even when they are not being
- used can create a lot of unnecessary load on the machine)
-- finish requests to the domain the user requested (e.g. www.example.org)
- before doing any requests to other subdomains (e.g. images.example.org) and
- finish those before doing requests to thirdparty domains (e.g. example.com)
-
-3. Be stable!
-- one site should not make the complete browser crash, only that site's tab
-
-
-Upstream Bugs
-=============
-
-- Web inspector is blank unless .hide()/.show() is called.
- Asked on SO: http://stackoverflow.com/q/23499159/2085149
- TODO: Report to PyQt/Qt
-
-- Report some other crashes
-
-
-/u/angelic_sedition's thoughts
-==============================
-
-Well support for greasemonkey scripts and bookmarklets/js (which was mentioned
-in the arch forum post) would be a big addition. What I've usually missed when
-using other vim-like browsers is things that allow for different settings and
-key bindings for different contexts. With that implemented I think I could
-switch to a lightweight browser (and believe me, I'd like to) for the most part
-and only use firefox when I needed downthemall or something.
-
-For example, I have different bindings based on tab position that are reloaded
-with a pentadactyl autocmd so that <space><homerow keys> will take me to tab
-1-10 if I'm in that range or 2-20 if I'm in that range. I have an autocmd that
-will run on completed downloads that passes the file path to a script that will
-open ranger in a floating window with that file cut (this is basically like
-using ranger to save files instead of the crappy gui popup).
-
-I also have a few bindings based on tabgroups. Tabgroups are a firefox feature,
-but I find them very useful for sorting things by topic so that only the tabs
-I'm interested at the moment are visible.
-
-Pentadactyl has a feature it calls groups. You can create a group that will
-activate for sites/urls that match a pattern with some regex support. This
-allows me, for example, to set up different (more convenient) bindings for
-zooming only on images. I'll never need use the equivalent of vim n (next text
-search match), so I can bind that to zoom. This allows setting up custom
-quickmarks/gotos using the same keys for different websites. For example, on
-reddit I have different g(some key) bindings to go to different subreddits.
-This can also be used to pass certain keys directly to the site (e.g. for use
-with RES). For sites that don't have modifiable bindings, I can use this with
-pentadactyl's feedkeys or xdotool to create my own custom bindings. I even have
-a binding that will call out to bash script with different arguments depending
-on the site to download an image or an image gallery depending on the site (in
-some cases passing the url to some cli program).
-
-I've also noticed the lack of completion. For example, on "o" pentadactyl will
-show sites (e.g. from history) that can be completed. I think I've been spoiled
-by pentadactyl having completion for just about everything.
-
-
-suckless surf ML post
-=====================
-
-From: Ben Woolley <tautolog_AT_gmail.com>
-Date: Wed, 7 Jan 2015 18:29:25 -0800
-
-Hi all,
-
-This patch is a bit of a beast for surf. It is intended to be applied after
-the disk cache patch. It breaks some internal interfaces, so it could
-conflict with other patches.
-
-I have been wanting a browser to implement a complete same-origin policy,
-and have been investigating how to do this in various browsers for many
-months. When I saw how surf opened new windows in a separate process, and
-was so simple, I knew I could do it quickly. Over the last two weeks, I
-have been developing this implementation on surf.
-
-The basic idea is to prevent browser-based tracking as you browse from site
-to site, or origin to origin. By "origin" domain, I mean the "first-party"
-domain, the domain normally in the location bar (of the typical browser
-interface). Each origin domain effectively gets its own browser profile,
-and a browser process only ever deals with one origin domain at a time.
-This isolates origins vertically, preventing cookies, disk cache, memory
-cache, and window.name vulnerabilities. Basically, all known
-vulnerabilities that google and Mozilla cite as counter-examples when they
-explain why they haven't disabled third-party cookies yet.
-
-When you are on msnbc.com, the tracking pixels will be stored in a cookie
-file for msnbc.com. When you go to cnn.com, the tracking pixels will be
-stored in a cookie file for cnn.com. You will not be tracked between them.
-However, third-party cookies, and the caching of third party resources will
-still work, but they will be isolated between origin domains. Instead of
-blocking cookies and cache entries, they are "double-keyed", or *also*
-keyed by origin.
-
-There is a unidirectional communication channel, however, from one origin
-to the next, through navigation from one origin to the next. That is, the
-query string is passed from one origin to the next, and may embed
-identifiers. One example is an affiliate link that identifies where the
-lead came from. I have implemented what I call "horizontal isolation", in
-the form of an "Origin Crossing Gate".
-
-Whenever you follow a link to a new domain, or even are just redirected to
-a new domain, a new window/tab is opened, and passed the referring origin
-via -R. The page passed to -O, for example -O originprompt.html, is an HTML
-page that is loaded in the new origin's context. That page tells you the
-origin you were on, the new origin, and the full link, and you can decide
-to go just to the new origin, or go to the full URL, after reviewing it for
-tracking data.
-
-Also, you may click links that store your trust of that relationship with
-various expiration times, the same way you would trust geolocation requests
-for a particular origin for a period of time. The database used is actually
-the new origin's cookie file. Since the origin prompt is loaded in the new
-origin's context, I can set a cookie on behalf of the new origin. The
-expiration time of the trust is the expiration time of the cookie. The
-cookie implementation in webkit automatically expires the trust as part of
-how cookies work. Each time you cross an origin, the origin crossing page
-checks the cookie to see if trust is still established. If so, it will use
-window.location.replace() to continue on automatically. The initial page
-renders blank until the trust is invalidated, in which case the content of
-the gate is made visible.
-
-However, the new origin is technically able to mess with those cookies, so
-a website could set trust for an origin crossing. I have addressed that by
-hashing the key with a salt, and setting the real expiration time as the
-value, along with an HMAC to verify the contents of the value. If the
-cookie is messed with in any way, the trust will be disabled, and the
-prompt will appear again. So it has a fail-safe function.
-
-I know it seems a bit convoluted, but it just started out as a nice little
-rabbit hole, and I just wanted to get something workable. At first I
-thought using the cookie expiration time was convenient, but then when I
-realized that I needed to protect the cookie, things got a bit hairy. But
-it works.
-
-Each profile is, by default, stored in ~/.surf/origins/$origin/
-The interesting side effect is that if there is a problem where a website
-relies on the cross-site cookie vulnerability to make a connection, you can
-simply make a symbolic link from one origin folder to another, and they
-will share the same profile. And if you want to delete cookies and/or cache
-for a particular origin, you just rm -rf the origin's profile folder, and
-don't have to interfere with your other sites that are working just fine.
-
-One thing I don't handle are cross-origins POSTs. They just end up as GET
-requests right now. I intend to do something about that, but I haven't
-figured that out yet.
-
-I have only been using this functionality for a few days myself, so I have
-absolutely no feedback yet. I wanted to provide the first implementation of
-the management of identity as a system resource the same way that things
-like geolocation, camera, and microphone resources are managed in browsers
-and mobile apps.
-
-Currently, Mozilla and Tor have are working on third-party tracking issues
-in Firefox.
-https://blog.mozilla.org/privacy/2014/11/10/introducing-polaris-privacy-initiative-to-accelerate-user-focused-privacy-online/
-
-Up to this point, Tor has provided a patch that double-keys cookies with
-the origin domain, but no other progress is visible. I have seen no
-discussion of how horizontal isolation is supposed to happen, and I wanted
-to show people that it can be done, and this is one way it can be done, and
-to compel the other browser makers to catch up, and hopefully the community
-can work toward a standard *without* the tracking loopholes, by showing
-people what a *complete* solution looks like.
-
-Thank you,
-
-Ben Woolley
-
-Patch: http://lists.suckless.org/dev/att-25070/0005-same-origin-policy.patch
diff --git a/doc/quickstart.asciidoc b/doc/quickstart.asciidoc
index 7d597ed2e..4881cca62 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 OS X build - use the `:set` command instead)
+* 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)
* 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/userscripts.asciidoc b/doc/userscripts.asciidoc
index b44a6b8ff..85811266d 100644
--- a/doc/userscripts.asciidoc
+++ b/doc/userscripts.asciidoc
@@ -60,7 +60,7 @@ Sending commands
Normal qutebrowser commands can be written to `$QUTE_FIFO` and will be
executed.
-On Unix/OS X, this is a named pipe and commands written to it will get executed
+On Unix/macOS, this is a named pipe and commands written to it will get executed
immediately.
On Windows, this is a regular file, and the commands in it will be executed as
diff --git a/misc/qutebrowser.spec b/misc/qutebrowser.spec
index 5dc51015d..cd0ce3883 100644
--- a/misc/qutebrowser.spec
+++ b/misc/qutebrowser.spec
@@ -41,7 +41,7 @@ a = Analysis(['../qutebrowser/__main__.py'],
pathex=['misc'],
binaries=None,
datas=get_data_files(),
- hiddenimports=['PyQt5.QtOpenGL'],
+ hiddenimports=['PyQt5.QtOpenGL', 'PyQt5._QOpenGLFunctions_2_0'],
hookspath=[],
runtime_hooks=[],
excludes=['tkinter'],
diff --git a/misc/requirements/requirements-codecov.txt b/misc/requirements/requirements-codecov.txt
index 86f78562d..9d6737a96 100644
--- a/misc/requirements/requirements-codecov.txt
+++ b/misc/requirements/requirements-codecov.txt
@@ -1,9 +1,9 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-certifi==2017.4.17
+certifi==2017.7.27.1
chardet==3.0.4
codecov==2.0.9
coverage==4.4.1
idna==2.5
-requests==2.18.1
-urllib3==1.21.1
+requests==2.18.2
+urllib3==1.22
diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt
index ecec607b5..5e5980525 100644
--- a/misc/requirements/requirements-flake8.txt
+++ b/misc/requirements/requirements-flake8.txt
@@ -3,7 +3,7 @@
flake8==2.6.2 # rq.filter: < 3.0.0
flake8-copyright==0.2.0
flake8-debugger==1.4.0 # rq.filter: != 2.0.0
-flake8-deprecated==1.2
+flake8-deprecated==1.2.1
flake8-docstrings==1.0.3 # rq.filter: < 1.1.0
flake8-future-import==0.4.3
flake8-mock==0.3
@@ -11,7 +11,7 @@ flake8-pep3101==1.0 # rq.filter: < 1.1
flake8-polyfill==1.0.1
flake8-putty==0.4.0
flake8-string-format==0.2.3
-flake8-tidy-imports==1.0.6
+flake8-tidy-imports==1.1.0
flake8-tuple==0.2.13
mccabe==0.6.1
packaging==16.8
diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt
index 9bae64d6d..3b36a0e5c 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.0.1
+setuptools==36.2.5
six==1.10.0
wheel==0.29.0
diff --git a/misc/requirements/requirements-pylint-master.txt b/misc/requirements/requirements-pylint-master.txt
index 37e705b7a..d4058b1d0 100644
--- a/misc/requirements/requirements-pylint-master.txt
+++ b/misc/requirements/requirements-pylint-master.txt
@@ -1,7 +1,7 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-e git+https://github.com/PyCQA/astroid.git#egg=astroid
-certifi==2017.4.17
+certifi==2017.7.27.1
chardet==3.0.4
github3.py==0.9.6
idna==2.5
@@ -10,9 +10,9 @@ lazy-object-proxy==1.3.1
mccabe==0.6.1
-e git+https://github.com/PyCQA/pylint.git#egg=pylint
./scripts/dev/pylint_checkers
-requests==2.18.1
+requests==2.18.2
six==1.10.0
uritemplate==3.0.0
uritemplate.py==3.0.2
-urllib3==1.21.1
+urllib3==1.22
wrapt==1.10.10
diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt
index a76d0dbf4..b5d44cb64 100644
--- a/misc/requirements/requirements-pylint.txt
+++ b/misc/requirements/requirements-pylint.txt
@@ -1,7 +1,7 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
astroid==1.5.3
-certifi==2017.4.17
+certifi==2017.7.27.1
chardet==3.0.4
github3.py==0.9.6
idna==2.5
@@ -10,9 +10,9 @@ lazy-object-proxy==1.3.1
mccabe==0.6.1
pylint==1.7.2
./scripts/dev/pylint_checkers
-requests==2.18.1
+requests==2.18.2
six==1.10.0
uritemplate==3.0.0
uritemplate.py==3.0.2
-urllib3==1.21.1
+urllib3==1.22
wrapt==1.10.10
diff --git a/misc/requirements/requirements-pyqt.txt b/misc/requirements/requirements-pyqt.txt
index da611589a..fffa133ab 100644
--- a/misc/requirements/requirements-pyqt.txt
+++ b/misc/requirements/requirements-pyqt.txt
@@ -1,4 +1,4 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-PyQt5==5.8.2
-sip==4.19.2
+PyQt5==5.9
+sip==4.19.3
diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt
index 57a4daff7..171013afb 100644
--- a/misc/requirements/requirements-tests.txt
+++ b/misc/requirements/requirements-tests.txt
@@ -5,35 +5,35 @@ cheroot==5.7.0
click==6.7
# colorama==0.3.9
coverage==4.4.1
-decorator==4.0.11
+decorator==4.1.2
EasyProcess==0.2.3
fields==5.0.0
Flask==0.12.2
glob2==0.5
httpbin==0.5.0
hunter==1.4.1
-hypothesis==3.11.6
+hypothesis==3.14.0
itsdangerous==0.24
# Jinja2==2.9.6
-Mako==1.0.6
+Mako==1.0.7
# MarkupSafe==1.0
parse==1.8.2
parse-type==0.3.4
py==1.4.34
-pytest==3.1.2
+pytest==3.1.3
pytest-bdd==2.18.2
-pytest-benchmark==3.0.0
+pytest-benchmark==3.1.1
pytest-catchlog==1.2.2
pytest-cov==2.5.1
pytest-faulthandler==1.3.1
pytest-instafail==0.3.0
-pytest-mock==1.6.0
-pytest-qt==2.1.0
+pytest-mock==1.6.2
+pytest-qt==2.1.2
pytest-repeat==0.4.1
pytest-rerunfailures==2.2
pytest-travis-fold==1.2.0
pytest-xvfb==1.0.0
PyVirtualDisplay==0.2.1
six==1.10.0
-vulture==0.14
+vulture==0.21
Werkzeug==0.12.2
diff --git a/misc/requirements/requirements-vulture.txt b/misc/requirements/requirements-vulture.txt
index 56e20c603..d1c1bc41d 100644
--- a/misc/requirements/requirements-vulture.txt
+++ b/misc/requirements/requirements-vulture.txt
@@ -1,3 +1,3 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-vulture==0.14
+vulture==0.21
diff --git a/misc/userscripts/readability b/misc/userscripts/readability
index 2de4be5ab..639e3a111 100755
--- a/misc/userscripts/readability
+++ b/misc/userscripts/readability
@@ -2,20 +2,32 @@
#
# Executes python-readability on current page and opens the summary as new tab.
#
+# Depends on the python-readability package, or its fork:
+#
+# - https://github.com/buriy/python-readability
+# - https://github.com/bookieio/breadability
+#
# Usage:
# :spawn --userscript readability
#
from __future__ import absolute_import
import codecs, os
-from readability.readability import Document
tmpfile=os.path.expanduser('~/.local/share/qutebrowser/userscripts/readability.html')
if not os.path.exists(os.path.dirname(tmpfile)):
os.makedirs(os.path.dirname(tmpfile))
with codecs.open(os.environ['QUTE_HTML'], 'r', 'utf-8') as source:
- doc = Document(source.read())
- content = doc.summary().replace('<html>', '<html><head><title>%s</title></head>' % doc.title())
+ data = source.read()
+
+ try:
+ from breadability.readable import Article as reader
+ doc = reader(data)
+ content = doc.readable
+ except ImportError:
+ from readability import Document
+ doc = Document(data)
+ content = doc.summary().replace('<html>', '<html><head><title>%s</title></head>' % doc.title())
with codecs.open(tmpfile, 'w', 'utf-8') as target:
target.write('<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />')
diff --git a/pytest.ini b/pytest.ini
index e13ab3a3b..0062b26e2 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,12 +1,13 @@
[pytest]
-addopts = --strict -rfEw --faulthandler-timeout=70 --instafail --pythonwarnings error
+addopts = --strict -rfEw --faulthandler-timeout=70 --instafail --pythonwarnings error --benchmark-columns=Min,Max,Median
+testpaths = tests
markers =
gui: Tests using the GUI (e.g. spawning widgets)
posix: Tests which only can run on a POSIX OS.
windows: Tests which only can run on Windows.
linux: Tests which only can run on Linux.
- osx: Tests which only can run on OS X.
- not_osx: Tests which can not run on OS X.
+ mac: Tests which only can run on macOS.
+ not_mac: Tests which can not run on macOS.
not_frozen: Tests which can't be run if sys.frozen is True.
no_xvfb: Tests which can't be run with Xvfb.
frozen: Tests which can only be run if sys.frozen is True.
@@ -21,7 +22,7 @@ markers =
qtwebkit_ng_xfail: Tests failing with QtWebKit-NG
qtwebkit_ng_skip: Tests skipped with QtWebKit-NG
qtwebengine_flaky: Tests which are flaky (and currently skipped) with QtWebEngine
- qtwebengine_osx_xfail: Tests which fail on OS X with QtWebEngine
+ qtwebengine_mac_xfail: Tests which fail on macOS with QtWebEngine
js_prompt: Tests needing to display a javascript prompt
this: Used to mark tests during development
no_invalid_lines: Don't fail on unparseable lines in end2end tests
@@ -47,6 +48,7 @@ qt_log_ignore =
^QGeoclueMaster error creating GeoclueMasterClient\.
^Geoclue error: Process org\.freedesktop\.Geoclue\.Master exited with status 127
^Failed to create Geoclue client interface. Geoclue error: org\.freedesktop\.DBus\.Error\.Disconnected
+ ^QDBusConnection: name 'org.freedesktop.Geoclue.Master' had owner '' but we thought it was ':1.1'
^QObject::connect: Cannot connect \(null\)::stateChanged\(QNetworkSession::State\) to QNetworkReplyHttpImpl::_q_networkSessionStateChanged\(QNetworkSession::State\)
^QXcbClipboard: Cannot transfer data, no data available
^load glyph failed
diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py
index e61419c0c..cca2bf1b8 100644
--- a/qutebrowser/__init__.py
+++ b/qutebrowser/__init__.py
@@ -26,7 +26,7 @@ __copyright__ = "Copyright 2014-2017 Florian Bruhin (The Compiler)"
__license__ = "GPL"
__maintainer__ = __author__
__email__ = "mail@qutebrowser.org"
-__version_info__ = (0, 10, 1)
+__version_info__ = (0, 11, 0)
__version__ = '.'.join(str(e) for e in __version_info__)
__description__ = "A keyboard-driven, vim-like browser based on PyQt5."
diff --git a/qutebrowser/app.py b/qutebrowser/app.py
index 66d4d5eb8..d1aafce19 100644
--- a/qutebrowser/app.py
+++ b/qutebrowser/app.py
@@ -41,9 +41,10 @@ except ImportError:
import qutebrowser
import qutebrowser.resources
-from qutebrowser.completion.models import instances as completionmodels
+from qutebrowser.completion.models import miscmodels
from qutebrowser.commands import cmdutils, runners, cmdexc
from qutebrowser.config import config, websettings, configexc
+from qutebrowser.config.parsers import keyconf
from qutebrowser.browser import (urlmarks, adblock, history, browsertab,
downloads)
from qutebrowser.browser.network import proxy
@@ -52,10 +53,10 @@ 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, objects)
+ crashsignal, earlyinit, objects, sql)
from qutebrowser.misc import utilcmds # pylint: disable=unused-import
from qutebrowser.utils import (log, version, message, utils, qtutils, urlutils,
- objreg, usertypes, standarddir, error, debug)
+ objreg, usertypes, standarddir, error)
# We import utilcmds to run the cmdutils.register decorators.
@@ -154,7 +155,7 @@ def init(args, crash_handler):
QDesktopServices.setUrlHandler('https', open_desktopservices_url)
QDesktopServices.setUrlHandler('qute', open_desktopservices_url)
- QTimer.singleShot(10, functools.partial(_init_late_modules, args))
+ objreg.get('web-history').import_txt()
log.init.debug("Init done!")
crash_handler.raise_crashdlg()
@@ -400,10 +401,8 @@ def _init_modules(args, crash_handler):
log.init.debug("Initializing network...")
networkmanager.init()
- if qtutils.version_check('5.8'):
- # Otherwise we can only initialize it for QtWebKit because of crashes
- log.init.debug("Initializing proxy...")
- proxy.init()
+ log.init.debug("Initializing proxy...")
+ proxy.init()
log.init.debug("Initializing readline-bridge...")
readline_bridge = readline.ReadlineBridge()
@@ -413,6 +412,17 @@ def _init_modules(args, crash_handler):
config.init(qApp)
save_manager.init_autosave()
+ log.init.debug("Initializing keys...")
+ keyconf.init(qApp)
+
+ log.init.debug("Initializing sql...")
+ try:
+ sql.init(os.path.join(standarddir.data(), 'history.sqlite'))
+ except sql.SqlException 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 web history...")
history.init(qApp)
@@ -449,9 +459,6 @@ def _init_modules(args, crash_handler):
diskcache = cache.DiskCache(standarddir.cache(), parent=qApp)
objreg.register('cache', diskcache)
- log.init.debug("Initializing completions...")
- completionmodels.init()
-
log.init.debug("Misc initialization...")
if config.val.window.hide_wayland_decoration:
os.environ['QT_WAYLAND_DISABLE_WINDOWDECORATION'] = '1'
@@ -462,23 +469,6 @@ def _init_modules(args, crash_handler):
browsertab.init()
-def _init_late_modules(args):
- """Initialize modules which can be inited after the window is shown."""
- log.init.debug("Reading web history...")
- reader = objreg.get('web-history').async_read()
- with debug.log_time(log.init, 'Reading history'):
- while True:
- QApplication.processEvents()
- try:
- next(reader)
- except StopIteration:
- break
- except (OSError, UnicodeDecodeError) as e:
- error.handle_fatal_exc(e, args, "Error while initializing!",
- pre_text="Error while initializing")
- sys.exit(usertypes.Exit.err_init)
-
-
class Quitter:
"""Utility class to quit/restart the QApplication.
@@ -626,7 +616,7 @@ class Quitter:
# Save the session if one is given.
if session is not None:
session_manager = objreg.get('session-manager')
- session_manager.save(session)
+ session_manager.save(session, with_private=True)
# Open a new process and immediately shutdown the existing one
try:
args, cwd = self._get_restart_args(pages, session)
@@ -760,7 +750,7 @@ class Quitter:
QTimer.singleShot(0, functools.partial(qApp.exit, status))
@cmdutils.register(instance='quitter', name='wq')
- @cmdutils.argument('name', completion=usertypes.Completion.sessions)
+ @cmdutils.argument('name', completion=miscmodels.session)
def save_and_quit(self, name=sessions.default):
"""Save open pages and quit.
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index c3e511171..6183474a4 100644
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -479,11 +479,21 @@ class AbstractHistory:
def current_idx(self):
raise NotImplementedError
- def back(self):
- raise NotImplementedError
+ def back(self, count=1):
+ idx = self.current_idx() - count
+ if idx >= 0:
+ self._go_to_item(self._item_at(idx))
+ else:
+ self._go_to_item(self._item_at(0))
+ raise WebTabError("At beginning of history.")
- def forward(self):
- raise NotImplementedError
+ def forward(self, count=1):
+ idx = self.current_idx() + count
+ if idx < len(self):
+ self._go_to_item(self._item_at(idx))
+ else:
+ self._go_to_item(self._item_at(len(self) - 1))
+ raise WebTabError("At end of history.")
def can_go_back(self):
raise NotImplementedError
@@ -491,6 +501,12 @@ class AbstractHistory:
def can_go_forward(self):
raise NotImplementedError
+ def _item_at(self, i):
+ raise NotImplementedError
+
+ def _go_to_item(self, item):
+ raise NotImplementedError
+
def serialize(self):
"""Serialize into an opaque format understood by self.deserialize."""
raise NotImplementedError
diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py
index d771414ae..b11c2e277 100644
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -20,11 +20,12 @@
"""Command dispatcher for TabbedBrowser."""
import os
+import sys
import os.path
import shlex
import functools
-from PyQt5.QtWidgets import QApplication, QTabBar
+from PyQt5.QtWidgets import QApplication, QTabBar, QDialog
from PyQt5.QtCore import Qt, QUrl, QEvent, QUrlQuery
from PyQt5.QtGui import QKeyEvent
from PyQt5.QtPrintSupport import QPrintDialog, QPrintPreviewDialog
@@ -38,10 +39,10 @@ from qutebrowser.browser import (urlmarks, browsertab, inspector, navigate,
webelem, downloads)
from qutebrowser.keyinput import modeman
from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils,
- objreg, utils, typing)
+ objreg, utils, typing, debug)
from qutebrowser.utils.usertypes import KeyMode
from qutebrowser.misc import editor, guiprocess
-from qutebrowser.completion.models import instances, sortfilter
+from qutebrowser.completion.models import urlmodel, miscmodels
class CommandDispatcher:
@@ -227,19 +228,6 @@ class CommandDispatcher:
self._tabbed_browser.close_tab(tab)
tabbar.setSelectionBehaviorOnRemove(old_selection_behavior)
- def _tab_close_prompt_if_pinned(self, tab, force, yes_action):
- """Helper method for tab_close.
-
- If tab is pinned, prompt. If everything is good, run yes_action.
- """
- if tab.data.pinned and not force:
- message.confirm_async(
- title='Pinned Tab',
- text="Are you sure you want to close a pinned tab?",
- yes_action=yes_action, default=False)
- else:
- yes_action()
-
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('count', count=True)
def tab_close(self, prev=False, next_=False, opposite=False,
@@ -260,7 +248,7 @@ class CommandDispatcher:
close = functools.partial(self._tab_close, tab, prev,
next_, opposite)
- self._tab_close_prompt_if_pinned(tab, force, close)
+ self._tabbed_browser.tab_close_prompt_if_pinned(tab, force, close)
@cmdutils.register(instance='command-dispatcher', scope='window',
name='tab-pin')
@@ -280,13 +268,11 @@ class CommandDispatcher:
return
to_pin = not tab.data.pinned
- tab_index = self._current_index() if count is None else count - 1
- cmdutils.check_overflow(tab_index + 1, 'int')
- self._tabbed_browser.set_tab_pinned(tab_index, to_pin)
+ self._tabbed_browser.set_tab_pinned(tab, to_pin)
@cmdutils.register(instance='command-dispatcher', name='open',
maxsplit=0, scope='window')
- @cmdutils.argument('url', completion=usertypes.Completion.url)
+ @cmdutils.argument('url', completion=urlmodel.url)
@cmdutils.argument('count', count=True)
def openurl(self, url=None, related=False,
bg=False, tab=False, window=False, count=None, secure=False,
@@ -438,9 +424,18 @@ class CommandDispatcher:
message.error("Printing failed!")
diag.deleteLater()
+ def do_print():
+ """Called when the dialog was closed."""
+ tab.printing.to_printer(diag.printer(), print_callback)
+
diag = QPrintDialog(tab)
- diag.open(lambda: tab.printing.to_printer(diag.printer(),
- print_callback))
+ if sys.platform == 'darwin':
+ # For some reason we get a segfault when using open() on macOS
+ ret = diag.exec_()
+ if ret == QDialog.Accepted:
+ do_print()
+ else:
+ diag.open(do_print)
@cmdutils.register(instance='command-dispatcher', name='print',
scope='window')
@@ -515,7 +510,7 @@ class CommandDispatcher:
newtab.data.keep_icon = True
newtab.history.deserialize(history)
newtab.zoom.set_factor(curtab.zoom.factor())
- new_tabbed_browser.set_tab_pinned(idx, curtab.data.pinned)
+ new_tabbed_browser.set_tab_pinned(newtab, curtab.data.pinned)
return newtab
@cmdutils.register(instance='command-dispatcher', scope='window')
@@ -542,15 +537,13 @@ class CommandDispatcher:
else:
widget = self._current_widget()
- for _ in range(count):
+ try:
if forward:
- if not widget.history.can_go_forward():
- raise cmdexc.CommandError("At end of history.")
- widget.history.forward()
+ widget.history.forward(count)
else:
- if not widget.history.can_go_back():
- raise cmdexc.CommandError("At beginning of history.")
- widget.history.back()
+ widget.history.back(count)
+ except browsertab.WebTabError as e:
+ raise cmdexc.CommandError(e)
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('count', count=True)
@@ -920,8 +913,9 @@ class CommandDispatcher:
if not force:
for i, tab in enumerate(self._tabbed_browser.widgets()):
if _to_close(i) and tab.data.pinned:
- self._tab_close_prompt_if_pinned(
- tab, force,
+ self._tabbed_browser.tab_close_prompt_if_pinned(
+ tab,
+ force,
lambda: self.tab_only(
prev=prev, next_=next_, force=True))
return
@@ -1016,7 +1010,7 @@ class CommandDispatcher:
self._open(url, tab, bg, window)
@cmdutils.register(instance='command-dispatcher', scope='window')
- @cmdutils.argument('index', completion=usertypes.Completion.tab)
+ @cmdutils.argument('index', completion=miscmodels.buffer)
def buffer(self, index):
"""Select tab by index or url/title best match.
@@ -1032,11 +1026,10 @@ class CommandDispatcher:
for part in index_parts:
int(part)
except ValueError:
- model = instances.get(usertypes.Completion.tab)
- sf = sortfilter.CompletionFilterModel(source=model)
- sf.set_pattern(index)
- if sf.count() > 0:
- index = sf.data(sf.first_item())
+ 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(
@@ -1167,6 +1160,7 @@ class CommandDispatcher:
detach: Whether the command should be detached from qutebrowser.
cmdline: The commandline to execute.
"""
+ cmdutils.check_exclusive((userscript, detach), 'ud')
try:
cmd, *args = shlex.split(cmdline)
except ValueError as e:
@@ -1241,8 +1235,7 @@ class CommandDispatcher:
@cmdutils.register(instance='command-dispatcher', scope='window',
maxsplit=0)
- @cmdutils.argument('name',
- completion=usertypes.Completion.quickmark_by_name)
+ @cmdutils.argument('name', completion=miscmodels.quickmark)
def quickmark_load(self, name, tab=False, bg=False, window=False):
"""Load a quickmark.
@@ -1260,8 +1253,7 @@ class CommandDispatcher:
@cmdutils.register(instance='command-dispatcher', scope='window',
maxsplit=0)
- @cmdutils.argument('name',
- completion=usertypes.Completion.quickmark_by_name)
+ @cmdutils.argument('name', completion=miscmodels.quickmark)
def quickmark_del(self, name=None):
"""Delete a quickmark.
@@ -1323,7 +1315,7 @@ class CommandDispatcher:
@cmdutils.register(instance='command-dispatcher', scope='window',
maxsplit=0)
- @cmdutils.argument('url', completion=usertypes.Completion.bookmark_by_url)
+ @cmdutils.argument('url', completion=miscmodels.bookmark)
def bookmark_load(self, url, tab=False, bg=False, window=False,
delete=False):
"""Load a bookmark.
@@ -1345,7 +1337,7 @@ class CommandDispatcher:
@cmdutils.register(instance='command-dispatcher', scope='window',
maxsplit=0)
- @cmdutils.argument('url', completion=usertypes.Completion.bookmark_by_url)
+ @cmdutils.argument('url', completion=miscmodels.bookmark)
def bookmark_del(self, url=None):
"""Delete a bookmark.
@@ -1450,8 +1442,18 @@ class CommandDispatcher:
download_manager.get_mhtml(tab, target)
else:
qnam = tab.networkaccessmanager()
- download_manager.get(self._current_url(), user_agent=user_agent,
- qnam=qnam, target=target)
+
+ suggested_fn = downloads.suggested_fn_from_title(
+ self._current_url().path(), tab.title()
+ )
+
+ download_manager.get(
+ self._current_url(),
+ user_agent=user_agent,
+ qnam=qnam,
+ target=target,
+ suggested_fn=suggested_fn
+ )
@cmdutils.register(instance='command-dispatcher', scope='window')
def view_source(self):
@@ -1519,7 +1521,7 @@ class CommandDispatcher:
@cmdutils.register(instance='command-dispatcher', name='help',
scope='window')
- @cmdutils.argument('topic', completion=usertypes.Completion.helptopic)
+ @cmdutils.argument('topic', completion=miscmodels.helptopic)
def show_help(self, tab=False, bg=False, window=False, topic=None):
r"""Show help about a command or setting.
@@ -1728,7 +1730,8 @@ class CommandDispatcher:
"""
self.set_mark("'")
tab = self._current_widget()
- tab.search.clear()
+ if tab.search.search_displayed:
+ tab.search.clear()
if not text:
return
@@ -2159,6 +2162,10 @@ class CommandDispatcher:
window = self._tabbed_browser.window()
if window.isFullScreen():
- window.showNormal()
+ window.setWindowState(
+ window.state_before_fullscreen & ~Qt.WindowFullScreen)
else:
+ window.state_before_fullscreen = window.windowState()
window.showFullScreen()
+ log.misc.debug('state before fullscreen: {}'.format(
+ debug.qflags_key(Qt, window.state_before_fullscreen)))
diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py
index fa34648e0..478bd7483 100644
--- a/qutebrowser/browser/downloads.py
+++ b/qutebrowser/browser/downloads.py
@@ -181,6 +181,28 @@ def transform_path(path):
return path
+def suggested_fn_from_title(url_path, title=None):
+ """Suggest a filename depending on the URL extension and page title.
+
+ Args:
+ url_path: a string with the URL path
+ title: the page title string
+
+ Return:
+ The download filename based on the title, or None if the extension is
+ not found in the whitelist (or if there is no page title).
+ """
+ ext_whitelist = [".html", ".htm", ".php", ""]
+ _, ext = os.path.splitext(url_path)
+ if ext.lower() in ext_whitelist and title:
+ suggested_fn = utils.sanitize_filename(title)
+ if not suggested_fn.lower().endswith((".html", ".htm")):
+ suggested_fn += ".html"
+ else:
+ suggested_fn = None
+ return suggested_fn
+
+
class NoFilenameError(Exception):
"""Raised when we can't find out a filename in DownloadTarget."""
diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py
index aaad08fb3..86e597bcd 100644
--- a/qutebrowser/browser/history.py
+++ b/qutebrowser/browser/history.py
@@ -19,214 +19,82 @@
"""Simple history which gets written to disk."""
+import os
import time
-import collections
-from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, QObject
+from PyQt5.QtCore import pyqtSlot, QUrl, QTimer
-from qutebrowser.commands import cmdutils
-from qutebrowser.utils import (utils, objreg, standarddir, log, qtutils,
- usertypes, message)
-from qutebrowser.misc import lineparser, objects
+from qutebrowser.commands import cmdutils, cmdexc
+from qutebrowser.utils import (utils, objreg, log, usertypes, message,
+ debug, standarddir)
+from qutebrowser.misc import objects, sql
-class Entry:
+class CompletionHistory(sql.SqlTable):
- """A single entry in the web history.
+ """History which only has the newest entry for each URL."""
- Attributes:
- atime: The time the page was accessed.
- url: The URL which was accessed as QUrl.
- redirect: If True, don't save this entry to disk
- """
-
- def __init__(self, atime, url, title, redirect=False):
- self.atime = float(atime)
- self.url = url
- self.title = title
- self.redirect = redirect
- qtutils.ensure_valid(url)
-
- def __repr__(self):
- return utils.get_repr(self, constructor=True, atime=self.atime,
- url=self.url_str(), title=self.title,
- redirect=self.redirect)
-
- def __str__(self):
- atime = str(int(self.atime))
- if self.redirect:
- atime += '-r' # redirect flag
- elems = [atime, self.url_str()]
- if self.title:
- elems.append(self.title)
- return ' '.join(elems)
-
- def __eq__(self, other):
- return (self.atime == other.atime and
- self.title == other.title and
- self.url == other.url and
- self.redirect == other.redirect)
-
- def url_str(self):
- """Get the URL as a lossless string."""
- return self.url.toString(QUrl.FullyEncoded | QUrl.RemovePassword)
-
- @classmethod
- def from_str(cls, line):
- """Parse a history line like '12345 http://example.com title'."""
- data = line.split(maxsplit=2)
- if len(data) == 2:
- atime, url = data
- title = ""
- elif len(data) == 3:
- atime, url, title = data
- else:
- raise ValueError("2 or 3 fields expected")
-
- url = QUrl(url)
- if not url.isValid():
- raise ValueError("Invalid URL: {}".format(url.errorString()))
-
- # https://github.com/qutebrowser/qutebrowser/issues/670
- atime = atime.lstrip('\0')
-
- if '-' in atime:
- atime, flags = atime.split('-')
- else:
- flags = ''
-
- if not set(flags).issubset('r'):
- raise ValueError("Invalid flags {!r}".format(flags))
-
- redirect = 'r' in flags
-
- return cls(atime, url, title, redirect=redirect)
-
-
-class WebHistory(QObject):
-
- """The global history of visited pages.
-
- This is a little more complex as you'd expect so the history can be read
- from disk async while new history is already arriving.
-
- self.history_dict is the main place where the history is stored, in an
- OrderedDict (sorted by time) of URL strings mapped to Entry objects.
+ def __init__(self, parent=None):
+ super().__init__("CompletionHistory", ['url', 'title', 'last_atime'],
+ constraints={'url': 'PRIMARY KEY'}, parent=parent)
+ self.create_index('CompletionHistoryAtimeIndex', 'last_atime')
- While reading from disk is still ongoing, the history is saved in
- self._temp_history instead, and then appended to self.history_dict once
- that's fully populated.
- All history which is new in this session (rather than read from disk from a
- previous browsing session) is also stored in self._new_history.
- self._saved_count tracks how many of those entries were already written to
- disk, so we can always append to the existing data.
+class WebHistory(sql.SqlTable):
- Attributes:
- history_dict: An OrderedDict of URLs read from the on-disk history.
- _lineparser: The AppendLineParser used to save the history.
- _new_history: A list of Entry items of the current session.
- _saved_count: How many HistoryEntries have been written to disk.
- _initial_read_started: Whether async_read was called.
- _initial_read_done: Whether async_read has completed.
- _temp_history: OrderedDict of temporary history entries before
- async_read was called.
+ """The global history of visited pages."""
- Signals:
- add_completion_item: Emitted before a new Entry is added.
- Used to sync with the completion.
- arg: The new Entry.
- item_added: Emitted after a new Entry is added.
- Used to tell the savemanager that the history is dirty.
- arg: The new Entry.
- cleared: Emitted after the history is cleared.
- """
-
- add_completion_item = pyqtSignal(Entry)
- item_added = pyqtSignal(Entry)
- cleared = pyqtSignal()
- async_read_done = pyqtSignal()
-
- def __init__(self, hist_dir, hist_name, parent=None):
- super().__init__(parent)
- self._initial_read_started = False
- self._initial_read_done = False
- self._lineparser = lineparser.AppendLineParser(hist_dir, hist_name,
- parent=self)
- self.history_dict = collections.OrderedDict()
- self._temp_history = collections.OrderedDict()
- self._new_history = []
- self._saved_count = 0
- objreg.get('save-manager').add_saveable(
- 'history', self.save, self.item_added)
+ def __init__(self, parent=None):
+ super().__init__("History", ['url', 'title', 'atime', 'redirect'],
+ parent=parent)
+ self.completion = CompletionHistory(parent=self)
+ self.create_index('HistoryIndex', 'url')
+ self.create_index('HistoryAtimeIndex', 'atime')
+ self._contains_query = self.contains_query('url')
+ self._between_query = sql.Query('SELECT * FROM History '
+ 'where not redirect '
+ 'and not url like "qute://%" '
+ 'and atime > :earliest '
+ 'and atime <= :latest '
+ 'ORDER BY atime desc')
+
+ self._before_query = sql.Query('SELECT * FROM History '
+ 'where not redirect '
+ 'and not url like "qute://%" '
+ 'and atime <= :latest '
+ 'ORDER BY atime desc '
+ 'limit :limit offset :offset')
def __repr__(self):
return utils.get_repr(self, length=len(self))
- def __iter__(self):
- return iter(self.history_dict.values())
+ def __contains__(self, url):
+ return self._contains_query.run(val=url).value()
- def __len__(self):
- return len(self.history_dict)
-
- def async_read(self):
- """Read the initial history."""
- if self._initial_read_started:
- log.init.debug("Ignoring async_read() because reading is started.")
- return
- self._initial_read_started = True
+ def get_recent(self):
+ """Get the most recent history entries."""
+ return self.select(sort_by='atime', sort_order='desc', limit=100)
- with self._lineparser.open():
- for line in self._lineparser:
- yield
+ def entries_between(self, earliest, latest):
+ """Iterate non-redirect, non-qute entries between two timestamps.
- line = line.rstrip()
- if not line:
- continue
+ Args:
+ earliest: Omit timestamps earlier than this.
+ latest: Omit timestamps later than this.
+ """
+ self._between_query.run(earliest=earliest, latest=latest)
+ return iter(self._between_query)
- try:
- entry = Entry.from_str(line)
- except ValueError as e:
- log.init.warning("Invalid history entry {!r}: {}!".format(
- line, e))
- continue
-
- # This de-duplicates history entries; only the latest
- # entry for each URL is kept. If you want to keep
- # information about previous hits change the items in
- # old_urls to be lists or change Entry to have a
- # list of atimes.
- self._add_entry(entry)
-
- self._initial_read_done = True
- self.async_read_done.emit()
-
- for entry in self._temp_history.values():
- self._add_entry(entry)
- self._new_history.append(entry)
- if not entry.redirect:
- self.add_completion_item.emit(entry)
- self._temp_history.clear()
-
- def _add_entry(self, entry, target=None):
- """Add an entry to self.history_dict or another given OrderedDict."""
- if target is None:
- target = self.history_dict
- url_str = entry.url_str()
- target[url_str] = entry
- target.move_to_end(url_str)
+ def entries_before(self, latest, limit, offset):
+ """Iterate non-redirect, non-qute entries occurring before a timestamp.
- def get_recent(self):
- """Get the most recent history entries."""
- old = self._lineparser.get_recent()
- return old + [str(e) for e in self._new_history]
-
- def save(self):
- """Save the history to disk."""
- new = (str(e) for e in self._new_history[self._saved_count:])
- self._lineparser.new_data = new
- self._lineparser.save()
- self._saved_count = len(self._new_history)
+ Args:
+ latest: Omit timestamps more recent than this.
+ limit: Max number of entries to include.
+ offset: Number of entries to skip.
+ """
+ self._before_query.run(latest=latest, limit=limit, offset=offset)
+ return iter(self._before_query)
@cmdutils.register(name='history-clear', instance='web-history')
def clear(self, force=False):
@@ -246,12 +114,17 @@ class WebHistory(QObject):
"history?")
def _do_clear(self):
- self._lineparser.clear()
- self.history_dict.clear()
- self._temp_history.clear()
- self._new_history.clear()
- self._saved_count = 0
- self.cleared.emit()
+ self.delete_all()
+ self.completion.delete_all()
+
+ def delete_url(self, url):
+ """Remove all history entries with the given url.
+
+ Args:
+ url: URL string to delete.
+ """
+ self.delete('url', url)
+ self.completion.delete('url', url)
@pyqtSlot(QUrl, QUrl, str)
def add_from_tab(self, url, requested_url, title):
@@ -285,17 +158,130 @@ class WebHistory(QObject):
log.misc.warning("Ignoring invalid URL being added to history")
return
- if atime is None:
- atime = time.time()
- entry = Entry(atime, url, title, redirect=redirect)
- if self._initial_read_done:
- self._add_entry(entry)
- self._new_history.append(entry)
- self.item_added.emit(entry)
- if not entry.redirect:
- self.add_completion_item.emit(entry)
+ atime = int(atime) if (atime is not None) else int(time.time())
+ url_str = url.toString(QUrl.FullyEncoded | QUrl.RemovePassword)
+ self.insert({'url': url_str,
+ 'title': title,
+ 'atime': atime,
+ 'redirect': redirect})
+ if not redirect:
+ self.completion.insert({'url': url_str,
+ 'title': title,
+ 'last_atime': atime},
+ replace=True)
+
+ def _parse_entry(self, line):
+ """Parse a history line like '12345 http://example.com title'."""
+ if not line or line.startswith('#'):
+ return None
+ data = line.split(maxsplit=2)
+ if len(data) == 2:
+ atime, url = data
+ title = ""
+ elif len(data) == 3:
+ atime, url, title = data
else:
- self._add_entry(entry, target=self._temp_history)
+ raise ValueError("2 or 3 fields expected")
+
+ # http://xn--pple-43d.com/ with
+ # https://bugreports.qt.io/browse/QTBUG-60364
+ if url in ['http://.com/', 'https://.com/',
+ 'http://www..com/', 'https://www..com/']:
+ return None
+
+ url = QUrl(url)
+ if not url.isValid():
+ raise ValueError("Invalid URL: {}".format(url.errorString()))
+
+ # https://github.com/qutebrowser/qutebrowser/issues/2646
+ if url.scheme() == 'data':
+ return None
+
+ # https://github.com/qutebrowser/qutebrowser/issues/670
+ atime = atime.lstrip('\0')
+
+ if '-' in atime:
+ atime, flags = atime.split('-')
+ else:
+ flags = ''
+
+ if not set(flags).issubset('r'):
+ raise ValueError("Invalid flags {!r}".format(flags))
+
+ redirect = 'r' in flags
+ return (url, title, int(atime), redirect)
+
+ def import_txt(self):
+ """Import a history text file into sqlite if it exists.
+
+ In older versions of qutebrowser, history was stored in a text format.
+ This converts that file into the new sqlite format and moves it to a
+ backup location.
+ """
+ path = os.path.join(standarddir.data(), 'history')
+ if not os.path.isfile(path):
+ return
+
+ def action():
+ with debug.log_time(log.init, 'Import old history file to sqlite'):
+ try:
+ self._read(path)
+ 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)
+
+ # delay to give message time to appear before locking down for import
+ message.info('Converting {} to sqlite...'.format(path))
+ QTimer.singleShot(100, action)
+
+ def _read(self, path):
+ """Import a text file into the sql database."""
+ with open(path, 'r', encoding='utf-8') as f:
+ data = {'url': [], 'title': [], 'atime': [], 'redirect': []}
+ completion_data = {'url': [], 'title': [], 'last_atime': []}
+ for (i, line) in enumerate(f):
+ try:
+ parsed = self._parse_entry(line.strip())
+ if parsed is None:
+ continue
+ url, title, atime, redirect = parsed
+ data['url'].append(url)
+ data['title'].append(title)
+ data['atime'].append(atime)
+ data['redirect'].append(redirect)
+ if not redirect:
+ completion_data['url'].append(url)
+ completion_data['title'].append(title)
+ completion_data['last_atime'].append(atime)
+ except ValueError as ex:
+ raise ValueError('Failed to parse line #{} of {}: "{}"'
+ .format(i, path, ex))
+ self.insert_batch(data)
+ self.completion.insert_batch(completion_data, replace=True)
+
+ @cmdutils.register(instance='web-history', debug=True)
+ def debug_dump_history(self, dest):
+ """Dump the history to a file in the old pre-SQL format.
+
+ Args:
+ dest: Where to write the file to.
+ """
+ dest = os.path.expanduser(dest)
+
+ lines = ('{}{} {} {}'
+ .format(int(x.atime), '-r' * x.redirect, x.url, x.title)
+ for x in self.select(sort_by='atime', sort_order='asc'))
+
+ try:
+ with open(dest, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(lines))
+ message.info("Dumped history to {}".format(dest))
+ except OSError as e:
+ raise cmdexc.CommandError('Could not write history: {}', e)
def init(parent=None):
@@ -304,8 +290,7 @@ def init(parent=None):
Args:
parent: The parent to use for WebHistory.
"""
- history = WebHistory(hist_dir=standarddir.data(), hist_name='history',
- parent=parent)
+ history = WebHistory(parent=parent)
objreg.register('web-history', history)
if objects.backend == usertypes.Backend.QtWebKit:
diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py
index d5f232e8a..22ec4c02c 100644
--- a/qutebrowser/browser/qtnetworkdownloads.py
+++ b/qutebrowser/browser/qtnetworkdownloads.py
@@ -412,7 +412,8 @@ class DownloadManager(downloads.AbstractDownloadManager):
mhtml.start_download_checked, tab=tab))
message.global_bridge.ask(question, blocking=False)
- def get_request(self, request, *, target=None, **kwargs):
+ def get_request(self, request, *, target=None,
+ suggested_fn=None, **kwargs):
"""Start a download with a QNetworkRequest.
Args:
@@ -428,7 +429,9 @@ class DownloadManager(downloads.AbstractDownloadManager):
request.setAttribute(QNetworkRequest.CacheLoadControlAttribute,
QNetworkRequest.AlwaysNetwork)
- if request.url().scheme().lower() != 'data':
+ if suggested_fn is not None:
+ pass
+ elif request.url().scheme().lower() != 'data':
suggested_fn = urlutils.filename_from_url(request.url())
else:
# We might be downloading a binary blob embedded on a page or even
diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py
index 1ef00150e..b6f50c2b3 100644
--- a/qutebrowser/browser/qutescheme.py
+++ b/qutebrowser/browser/qutescheme.py
@@ -26,7 +26,6 @@ Module attributes:
import json
import os
-import sys
import time
import urllib.parse
import datetime
@@ -185,88 +184,36 @@ def qute_bookmarks(_url):
return 'text/html', html
-def history_data(start_time): # noqa
- """Return history data
+def history_data(start_time, offset=None):
+ """Return history data.
Arguments:
- start_time -- select history starting from this timestamp.
+ start_time: select history starting from this timestamp.
+ offset: number of items to skip
"""
- def history_iter(start_time, reverse=False):
- """Iterate through the history and get items we're interested.
-
- Arguments:
- reverse -- whether to reverse the history_dict before iterating.
- """
- history = objreg.get('web-history').history_dict.values()
- if reverse:
- history = reversed(history)
-
- # when history_dict is not reversed, we need to keep track of last item
- # so that we can yield its atime
- last_item = None
-
+ # history atimes are stored as ints, ensure start_time is not a float
+ start_time = int(start_time)
+ hist = objreg.get('web-history')
+ if offset is not None:
+ entries = hist.entries_before(start_time, limit=1000, offset=offset)
+ else:
# end is 24hrs earlier than start
end_time = start_time - 24*60*60
+ entries = hist.entries_between(end_time, start_time)
- for item in history:
- # Skip redirects
- # Skip qute:// links
- if item.redirect or item.url.scheme() == 'qute':
- continue
-
- # Skip items out of time window
- item_newer = item.atime > start_time
- item_older = item.atime <= end_time
- if reverse:
- # history_dict is reversed, we are going back in history.
- # so:
- # abort if item is older than start_time+24hr
- # skip if item is newer than start
- if item_older:
- yield {"next": int(item.atime)}
- return
- if item_newer:
- continue
- else:
- # history_dict isn't reversed, we are going forward in history.
- # so:
- # abort if item is newer than start_time
- # skip if item is older than start_time+24hrs
- if item_older:
- last_item = item
- continue
- if item_newer:
- yield {"next": int(last_item.atime if last_item else -1)}
- return
-
- # Use item's url as title if there's no title.
- item_url = item.url.toDisplayString()
- item_title = item.title if item.title else item_url
- item_time = int(item.atime * 1000)
-
- yield {"url": item_url, "title": item_title, "time": item_time}
-
- # if we reached here, we had reached the end of history
- yield {"next": int(last_item.atime if last_item else -1)}
-
- if sys.hexversion >= 0x03050000:
- # On Python >= 3.5 we can reverse the ordereddict in-place and thus
- # apply an additional performance improvement in history_iter.
- # On my machine, this gets us down from 550ms to 72us with 500k old
- # items.
- history = history_iter(start_time, reverse=True)
- else:
- # On Python 3.4, we can't do that, so we'd need to copy the entire
- # history to a list. There, filter first and then reverse it here.
- history = reversed(list(history_iter(start_time, reverse=False)))
-
- return list(history)
+ return [{"url": e.url, "title": e.title or e.url, "time": e.atime}
+ for e in entries]
@add_handler('history')
def qute_history(url):
"""Handler for qute://history. Display and serve history."""
if url.path() == '/data':
+ try:
+ offset = QUrlQuery(url).queryItemValue("offset")
+ offset = int(offset) if offset else None
+ except ValueError as e:
+ raise QuteSchemeError("Query parameter offset is invalid", e)
# Use start_time in query or current time.
try:
start_time = QUrlQuery(url).queryItemValue("start_time")
@@ -274,7 +221,7 @@ def qute_history(url):
except ValueError as e:
raise QuteSchemeError("Query parameter start_time is invalid", e)
- return 'text/html', json.dumps(history_data(start_time))
+ return 'text/html', json.dumps(history_data(start_time, offset))
else:
if (
config.val.content.javascript.enabled and
@@ -306,9 +253,9 @@ def qute_history(url):
start_time = time.mktime(next_date.timetuple()) - 1
history = [
(i["url"], i["title"],
- datetime.datetime.fromtimestamp(i["time"]/1000),
+ datetime.datetime.fromtimestamp(i["time"]),
QUrl(i["url"]).host())
- for i in history_data(start_time) if "next" not in i
+ for i in history_data(start_time)
]
return 'text/html', jinja.render(
diff --git a/qutebrowser/browser/urlmarks.py b/qutebrowser/browser/urlmarks.py
index 013de408c..b7c93a994 100644
--- a/qutebrowser/browser/urlmarks.py
+++ b/qutebrowser/browser/urlmarks.py
@@ -77,13 +77,9 @@ class UrlMarkManager(QObject):
Signals:
changed: Emitted when anything changed.
- added: Emitted when a new quickmark/bookmark was added.
- removed: Emitted when an existing quickmark/bookmark was removed.
"""
changed = pyqtSignal()
- added = pyqtSignal(str, str)
- removed = pyqtSignal(str)
def __init__(self, parent=None):
"""Initialize and read quickmarks."""
@@ -121,7 +117,6 @@ class UrlMarkManager(QObject):
"""
del self.marks[key]
self.changed.emit()
- self.removed.emit(key)
class QuickmarkManager(UrlMarkManager):
@@ -133,7 +128,6 @@ class QuickmarkManager(UrlMarkManager):
- self.marks maps names to URLs.
- changed gets emitted with the name as first argument and the URL as
second argument.
- - removed gets emitted with the name as argument.
"""
def _init_lineparser(self):
@@ -193,7 +187,6 @@ class QuickmarkManager(UrlMarkManager):
"""Really set the quickmark."""
self.marks[name] = url
self.changed.emit()
- self.added.emit(name, url)
log.misc.debug("Added quickmark {} for {}".format(name, url))
if name in self.marks:
@@ -243,7 +236,6 @@ class BookmarkManager(UrlMarkManager):
- self.marks maps URLs to titles.
- changed gets emitted with the URL as first argument and the title as
second argument.
- - removed gets emitted with the URL as argument.
"""
def _init_lineparser(self):
@@ -295,5 +287,4 @@ class BookmarkManager(UrlMarkManager):
else:
self.marks[urlstr] = title
self.changed.emit()
- self.added.emit(title, urlstr)
return True
diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py
index 45a26fbdd..0bac53915 100644
--- a/qutebrowser/browser/webengine/webenginesettings.py
+++ b/qutebrowser/browser/webengine/webenginesettings.py
@@ -28,7 +28,9 @@ Module attributes:
"""
import os
-import logging
+import sys
+import ctypes
+import ctypes.util
from PyQt5.QtGui import QFont
from PyQt5.QtWebEngineWidgets import (QWebEngineSettings, QWebEngineProfile,
@@ -203,12 +205,10 @@ def init(args):
if args.enable_webengine_inspector:
os.environ['QTWEBENGINE_REMOTE_DEBUGGING'] = str(utils.random_port())
- # Workaround for a black screen with some setups
- # https://github.com/spyder-ide/spyder/issues/3226
- if not os.environ.get('QUTE_NO_OPENGL_WORKAROUND'):
- # Hide "No OpenGL_accelerate module loaded: ..." message
- logging.getLogger('OpenGL.acceleratesupport').propagate = False
- from OpenGL import GL # pylint: disable=unused-variable
+ # WORKAROUND for
+ # https://bugs.launchpad.net/ubuntu/+source/python-qt4/+bug/941826
+ if sys.platform == 'linux':
+ ctypes.CDLL(ctypes.util.find_library("GL"), mode=ctypes.RTLD_GLOBAL)
_init_profiles()
diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py
index fa443918f..7dd8b0629 100644
--- a/qutebrowser/browser/webengine/webenginetab.py
+++ b/qutebrowser/browser/webengine/webenginetab.py
@@ -51,12 +51,13 @@ def init():
global _qute_scheme_handler
app = QApplication.instance()
- software_rendering = os.environ.get('LIBGL_ALWAYS_SOFTWARE') == '1'
+ 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 "
- "LIBGL_ALWAYS_SOFTWARE is set as environment variable).")
+ "QT_XCB_FORCE_SOFTWARE_OPENGL is set as environment variable).")
log.init.debug("Initializing qute://* handler...")
_qute_scheme_handler = webenginequtescheme.QuteSchemeHandler(parent=app)
@@ -406,18 +407,18 @@ class WebEngineHistory(browsertab.AbstractHistory):
def current_idx(self):
return self._history.currentItemIndex()
- def back(self):
- self._history.back()
-
- def forward(self):
- self._history.forward()
-
def can_go_back(self):
return self._history.canGoBack()
def can_go_forward(self):
return self._history.canGoForward()
+ def _item_at(self, i):
+ return self._history.itemAt(i)
+
+ def _go_to_item(self, item):
+ return self._history.goToItem(item)
+
def serialize(self):
if not qtutils.version_check('5.9'):
# WORKAROUND for
@@ -611,6 +612,7 @@ class WebEngineTab(browsertab.AbstractTab):
def shutdown(self):
self.shutting_down.emit()
+ self.action.exit_fullscreen()
if qtutils.version_check('5.8', exact=True):
# WORKAROUND for
# https://bugreports.qt.io/browse/QTBUG-58563
@@ -711,7 +713,8 @@ class WebEngineTab(browsertab.AbstractTab):
@pyqtSlot()
def _on_load_started(self):
"""Clear search when a new load is started if needed."""
- if qtutils.version_check('5.9'):
+ if (qtutils.version_check('5.9') and
+ not qtutils.version_check('5.9.2')):
# WORKAROUND for
# https://bugreports.qt.io/browse/QTBUG-61506
self.search.clear()
diff --git a/qutebrowser/browser/webkit/webkithistory.py b/qutebrowser/browser/webkit/webkithistory.py
index 0f9d64460..0edbb3fa3 100644
--- a/qutebrowser/browser/webkit/webkithistory.py
+++ b/qutebrowser/browser/webkit/webkithistory.py
@@ -19,9 +19,12 @@
"""QtWebKit specific part of history."""
+import functools
from PyQt5.QtWebKit import QWebHistoryInterface
+from qutebrowser.utils import debug
+
class WebHistoryInterface(QWebHistoryInterface):
@@ -34,11 +37,13 @@ class WebHistoryInterface(QWebHistoryInterface):
def __init__(self, webhistory, parent=None):
super().__init__(parent)
self._history = webhistory
+ self._history.changed.connect(self.historyContains.cache_clear)
def addHistoryEntry(self, url_string):
"""Required for a QWebHistoryInterface impl, obsoleted by add_url."""
pass
+ @functools.lru_cache(maxsize=32768)
def historyContains(self, url_string):
"""Called by WebKit to determine if a URL is contained in the history.
@@ -48,7 +53,8 @@ class WebHistoryInterface(QWebHistoryInterface):
Return:
True if the url is in the history, False otherwise.
"""
- return url_string in self._history.history_dict
+ with debug.log_time('sql', 'historyContains'):
+ return url_string in self._history
def init(history):
diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py
index abf77f048..005c1ac96 100644
--- a/qutebrowser/browser/webkit/webkittab.py
+++ b/qutebrowser/browser/webkit/webkittab.py
@@ -32,7 +32,6 @@ from PyQt5.QtWebKit import QWebSettings
from PyQt5.QtPrintSupport import QPrinter
from qutebrowser.browser import browsertab
-from qutebrowser.browser.network import proxy
from qutebrowser.browser.webkit import webview, tabhistory, webkitelem
from qutebrowser.utils import qtutils, objreg, usertypes, utils, log, debug
@@ -502,18 +501,18 @@ class WebKitHistory(browsertab.AbstractHistory):
def current_idx(self):
return self._history.currentItemIndex()
- def back(self):
- self._history.back()
-
- def forward(self):
- self._history.forward()
-
def can_go_back(self):
return self._history.canGoBack()
def can_go_forward(self):
return self._history.canGoForward()
+ def _item_at(self, i):
+ return self._history.itemAt(i)
+
+ def _go_to_item(self, item):
+ return self._history.goToItem(item)
+
def serialize(self):
return qtutils.serialize(self._history)
diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py
index 58be86d17..3d8ba3113 100644
--- a/qutebrowser/completion/completer.py
+++ b/qutebrowser/completion/completer.py
@@ -23,8 +23,8 @@ from PyQt5.QtCore import pyqtSlot, QObject, QTimer
from qutebrowser.config import config
from qutebrowser.commands import cmdutils, runners
-from qutebrowser.utils import usertypes, log, utils
-from qutebrowser.completion.models import instances, sortfilter
+from qutebrowser.utils import log, utils, debug
+from qutebrowser.completion.models import miscmodels
class Completer(QObject):
@@ -38,6 +38,7 @@ class Completer(QObject):
_last_cursor_pos: The old cursor position so we avoid double completion
updates.
_last_text: The old command text so we avoid double completion updates.
+ _last_completion_func: The completion function used for the last text.
"""
def __init__(self, cmd, parent=None):
@@ -50,6 +51,7 @@ class Completer(QObject):
self._timer.timeout.connect(self._update_completion)
self._last_cursor_pos = None
self._last_text = None
+ self._last_completion_func = None
self._cmd.update_completion.connect(self.schedule_completion_update)
def __repr__(self):
@@ -60,37 +62,8 @@ class Completer(QObject):
completion = self.parent()
return completion.model()
- def _get_completion_model(self, completion, pos_args):
- """Get a completion model based on an enum member.
-
- Args:
- completion: A usertypes.Completion member.
- pos_args: The positional args entered before the cursor.
-
- Return:
- A completion model or None.
- """
- if completion == usertypes.Completion.option:
- section = pos_args[0]
- model = instances.get(completion).get(section)
- elif completion == usertypes.Completion.value:
- section = pos_args[0]
- option = pos_args[1]
- try:
- model = instances.get(completion)[section][option]
- except KeyError:
- # No completion model for this section/option.
- model = None
- else:
- model = instances.get(completion)
-
- if model is None:
- return None
- else:
- return sortfilter.CompletionFilterModel(source=model, parent=self)
-
def _get_new_completion(self, before_cursor, under_cursor):
- """Get a new completion.
+ """Get the completion function based on the current command text.
Args:
before_cursor: The command chunks before the cursor.
@@ -107,8 +80,8 @@ class Completer(QObject):
log.completion.debug("After removing flags: {}".format(before_cursor))
if not before_cursor:
# '|' or 'set|'
- model = instances.get(usertypes.Completion.command)
- return sortfilter.CompletionFilterModel(source=model, parent=self)
+ log.completion.debug('Starting command completion')
+ return miscmodels.command
try:
cmd = cmdutils.cmd_dict[before_cursor[0]]
except KeyError:
@@ -117,14 +90,11 @@ class Completer(QObject):
return None
argpos = len(before_cursor) - 1
try:
- completion = cmd.get_pos_arg_info(argpos).completion
+ func = cmd.get_pos_arg_info(argpos).completion
except IndexError:
log.completion.debug("No completion in position {}".format(argpos))
return None
- if completion is None:
- return None
- model = self._get_completion_model(completion, before_cursor[1:])
- return model
+ return func
def _quote(self, s):
"""Quote s if it needs quoting for the commandline.
@@ -239,6 +209,7 @@ class Completer(QObject):
# FIXME complete searches
# https://github.com/qutebrowser/qutebrowser/issues/32
completion.set_model(None)
+ self._last_completion_func = None
return
before_cursor, pattern, after_cursor = self._partition()
@@ -247,13 +218,24 @@ class Completer(QObject):
before_cursor, pattern, after_cursor))
pattern = pattern.strip("'\"")
- model = self._get_new_completion(before_cursor, pattern)
+ func = self._get_new_completion(before_cursor, pattern)
+
+ if func is None:
+ log.completion.debug('Clearing completion')
+ completion.set_model(None)
+ self._last_completion_func = None
+ return
- log.completion.debug("Setting completion model to {} with pattern '{}'"
- .format(model.srcmodel.__class__.__name__ if model else 'None',
- pattern))
+ if func != self._last_completion_func:
+ self._last_completion_func = func
+ args = (x for x in before_cursor[1:] if not x.startswith('-'))
+ with debug.log_time(log.completion,
+ 'Starting {} completion'.format(func.__name__)):
+ model = func(*args)
+ with debug.log_time(log.completion, 'Set completion model'):
+ completion.set_model(model)
- completion.set_model(model, pattern)
+ completion.set_pattern(pattern)
def _change_completed_part(self, newtext, before, after, immediate=False):
"""Change the part we're currently completing in the commandline.
diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py
index c7ca94a16..2a0c29bc2 100644
--- a/qutebrowser/completion/completiondelegate.py
+++ b/qutebrowser/completion/completiondelegate.py
@@ -198,8 +198,9 @@ class CompletionItemDelegate(QStyledItemDelegate):
self._doc.setDefaultStyleSheet(template.render(conf=config.val))
if index.parent().isValid():
- pattern = index.model().pattern
- columns_to_filter = index.model().srcmodel.columns_to_filter
+ view = self.parent()
+ pattern = view.pattern
+ columns_to_filter = index.model().columns_to_filter(index)
if index.column() in columns_to_filter and pattern:
repl = r'<span class="highlight">\g<0></span>'
text = re.sub(re.escape(pattern).replace(r'\ ', r'|'),
diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py
index 440b0dc78..43ebffa84 100644
--- a/qutebrowser/completion/completionwidget.py
+++ b/qutebrowser/completion/completionwidget.py
@@ -28,8 +28,7 @@ from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel, QSize
from qutebrowser.config import config
from qutebrowser.completion import completiondelegate
-from qutebrowser.completion.models import base
-from qutebrowser.utils import utils, usertypes
+from qutebrowser.utils import utils, usertypes, debug, log
from qutebrowser.commands import cmdexc, cmdutils
@@ -41,6 +40,7 @@ class CompletionView(QTreeView):
headers, and children show as flat list.
Attributes:
+ pattern: Current filter pattern, used for highlighting.
_win_id: The ID of the window this CompletionView is associated with.
_height: The height to use for the CompletionView.
_height_perc: Either None or a percentage if height should be relative.
@@ -107,12 +107,10 @@ class CompletionView(QTreeView):
def __init__(self, win_id, parent=None):
super().__init__(parent)
+ self.pattern = ''
self._win_id = win_id
- # FIXME handle new aliases.
- # config.instance.changed.connect(self.init_command_completion)
config.instance.changed.connect(self._on_config_changed)
- self._column_widths = base.BaseCompletionModel.COLUMN_WIDTHS
self._active = False
self._delegate = completiondelegate.CompletionItemDelegate(self)
@@ -148,8 +146,11 @@ class CompletionView(QTreeView):
def _resize_columns(self):
"""Resize the completion columns based on column_widths."""
+ if self.model() is None:
+ return
width = self.size().width()
- pixel_widths = [(width * perc // 100) for perc in self._column_widths]
+ column_widths = self.model().column_widths
+ pixel_widths = [(width * perc // 100) for perc in column_widths]
if self.verticalScrollBar().isVisible():
delta = self.style().pixelMetric(QStyle.PM_ScrollBarExtent) + 5
@@ -252,6 +253,10 @@ class CompletionView(QTreeView):
selmodel.setCurrentIndex(
idx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows)
+ # if the last item is focused, try to fetch more
+ if idx.row() == self.model().rowCount(idx.parent()) - 1:
+ self.expandAll()
+
count = self.model().count()
if count == 0:
self.hide()
@@ -260,48 +265,51 @@ class CompletionView(QTreeView):
elif config.val.completion.show == 'auto':
self.show()
- def set_model(self, model, pattern=None):
+ def set_model(self, model):
"""Switch completion to a new model.
Called from on_update_completion().
Args:
model: The model to use.
- pattern: The filter pattern to set (what the user entered).
"""
+ if self.model() is not None and model is not self.model():
+ self.model().deleteLater()
+ self.selectionModel().deleteLater()
+
+ self.setModel(model)
+
if model is None:
self._active = False
self.hide()
return
- old_model = self.model()
- if model is not old_model:
- sel_model = self.selectionModel()
+ model.setParent(self)
+ self._active = True
+ self._maybe_show()
- self.setModel(model)
- self._active = True
-
- if sel_model is not None:
- sel_model.deleteLater()
- if old_model is not None:
- old_model.deleteLater()
+ self._resize_columns()
+ for i in range(model.rowCount()):
+ self.expand(model.index(i, 0))
+ def set_pattern(self, pattern):
+ """Set the pattern on the underlying model."""
+ if not self.model():
+ return
+ self.pattern = pattern
+ with debug.log_time(log.completion, 'Set pattern {}'.format(pattern)):
+ self.model().set_pattern(pattern)
+ self.selectionModel().clear()
+ self._maybe_update_geometry()
+ self._maybe_show()
+
+ def _maybe_show(self):
if (config.val.completion.show == 'always' and
- model.count() > 0):
+ self.model().count() > 0):
self.show()
else:
self.hide()
- for i in range(model.rowCount()):
- self.expand(model.index(i, 0))
-
- if pattern is not None:
- model.set_pattern(pattern)
-
- self._column_widths = model.srcmodel.COLUMN_WIDTHS
- self._resize_columns()
- self._maybe_update_geometry()
-
def _maybe_update_geometry(self):
"""Emit the update_geometry signal if the config says so."""
if config.val.completion.shrink:
@@ -345,7 +353,7 @@ class CompletionView(QTreeView):
indexes = selected.indexes()
if not indexes:
return
- data = self.model().data(indexes[0])
+ data = str(self.model().data(indexes[0]))
self.selection_changed.emit(data)
def resizeEvent(self, e):
@@ -365,9 +373,7 @@ class CompletionView(QTreeView):
modes=[usertypes.KeyMode.command], scope='window')
def completion_item_del(self):
"""Delete the current completion item."""
- if not self.currentIndex().isValid():
+ index = self.currentIndex()
+ if not index.isValid():
raise cmdexc.CommandError("No item selected!")
- try:
- self.model().srcmodel.delete_cur_item(self)
- except NotImplementedError:
- raise cmdexc.CommandError("Cannot delete this item.")
+ self.model().delete_cur_item(index)
diff --git a/qutebrowser/completion/models/base.py b/qutebrowser/completion/models/base.py
deleted file mode 100644
index b1cad276a..000000000
--- a/qutebrowser/completion/models/base.py
+++ /dev/null
@@ -1,130 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2014-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
-#
-# 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 <http://www.gnu.org/licenses/>.
-
-"""The base completion model for completion in the command line.
-
-Module attributes:
- Role: An enum of user defined model roles.
-"""
-
-from PyQt5.QtCore import Qt
-from PyQt5.QtGui import QStandardItemModel, QStandardItem
-
-from qutebrowser.utils import usertypes
-
-
-Role = usertypes.enum('Role', ['sort', 'userdata'], start=Qt.UserRole,
- is_int=True)
-
-
-class BaseCompletionModel(QStandardItemModel):
-
- """A simple QStandardItemModel adopted for completions.
-
- Used for showing completions later in the CompletionView. Supports setting
- marks and adding new categories/items easily.
-
- Class Attributes:
- COLUMN_WIDTHS: The width percentages of the columns used in the
- completion view.
- DUMB_SORT: the dumb sorting used by the model
- """
-
- COLUMN_WIDTHS = (30, 70, 0)
- DUMB_SORT = None
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setColumnCount(3)
- self.columns_to_filter = [0]
-
- def new_category(self, name, sort=None):
- """Add a new category to the model.
-
- Args:
- name: The name of the category to add.
- sort: The value to use for the sort role.
-
- Return:
- The created QStandardItem.
- """
- cat = QStandardItem(name)
- if sort is not None:
- cat.setData(sort, Role.sort)
- self.appendRow(cat)
- return cat
-
- def new_item(self, cat, name, desc='', misc=None, sort=None,
- userdata=None):
- """Add a new item to a category.
-
- Args:
- cat: The parent category.
- name: The name of the item.
- desc: The description of the item.
- misc: Misc text to display.
- sort: Data for the sort role (int).
- userdata: User data to be added for the first column.
-
- Return:
- A (nameitem, descitem, miscitem) tuple.
- """
- assert not isinstance(name, int)
- assert not isinstance(desc, int)
- assert not isinstance(misc, int)
-
- nameitem = QStandardItem(name)
- descitem = QStandardItem(desc)
- if misc is None:
- miscitem = QStandardItem()
- else:
- miscitem = QStandardItem(misc)
-
- cat.appendRow([nameitem, descitem, miscitem])
- if sort is not None:
- nameitem.setData(sort, Role.sort)
- if userdata is not None:
- nameitem.setData(userdata, Role.userdata)
- return nameitem, descitem, miscitem
-
- def delete_cur_item(self, completion):
- """Delete the selected item."""
- raise NotImplementedError
-
- def flags(self, index):
- """Return the item flags for index.
-
- Override QAbstractItemModel::flags.
-
- Args:
- index: The QModelIndex to get item flags for.
-
- Return:
- The item flags, or Qt.NoItemFlags on error.
- """
- if not index.isValid():
- return
-
- if index.parent().isValid():
- # item
- return (Qt.ItemIsEnabled | Qt.ItemIsSelectable |
- Qt.ItemNeverHasChildren)
- else:
- # category
- return Qt.NoItemFlags
diff --git a/qutebrowser/completion/models/completionmodel.py b/qutebrowser/completion/models/completionmodel.py
new file mode 100644
index 000000000..398673200
--- /dev/null
+++ b/qutebrowser/completion/models/completionmodel.py
@@ -0,0 +1,232 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""A model that proxies access to one or more completion categories."""
+
+from PyQt5.QtCore import Qt, QModelIndex, QAbstractItemModel
+
+from qutebrowser.utils import log, qtutils
+from qutebrowser.commands import cmdexc
+
+
+class CompletionModel(QAbstractItemModel):
+
+ """A model that proxies access to one or more completion categories.
+
+ Top level indices represent categories.
+ Child indices represent rows of those tables.
+
+ Attributes:
+ column_widths: The width percentages of the columns used in the
+ completion view.
+ _categories: The sub-categories.
+ """
+
+ def __init__(self, *, column_widths=(30, 70, 0), parent=None):
+ super().__init__(parent)
+ self.column_widths = column_widths
+ self._categories = []
+
+ def _cat_from_idx(self, index):
+ """Return the category pointed to by the given index.
+
+ Args:
+ idx: A QModelIndex
+ Returns:
+ A category if the index points at one, else None
+ """
+ # items hold an index to the parent category in their internalPointer
+ # categories have an empty internalPointer
+ if index.isValid() and not index.internalPointer():
+ return self._categories[index.row()]
+ return None
+
+ def add_category(self, cat):
+ """Add a completion category to the model."""
+ self._categories.append(cat)
+ cat.layoutAboutToBeChanged.connect(self.layoutAboutToBeChanged)
+ cat.layoutChanged.connect(self.layoutChanged)
+
+ def data(self, index, role=Qt.DisplayRole):
+ """Return the item data for index.
+
+ Override QAbstractItemModel::data.
+
+ Args:
+ index: The QModelIndex to get item flags for.
+
+ Return: The item data, or None on an invalid index.
+ """
+ if role != Qt.DisplayRole:
+ return None
+ cat = self._cat_from_idx(index)
+ if cat:
+ # category header
+ if index.column() == 0:
+ return self._categories[index.row()].name
+ return None
+ # item
+ cat = self._cat_from_idx(index.parent())
+ if not cat:
+ return None
+ idx = cat.index(index.row(), index.column())
+ return cat.data(idx)
+
+ def flags(self, index):
+ """Return the item flags for index.
+
+ Override QAbstractItemModel::flags.
+
+ Return: The item flags, or Qt.NoItemFlags on error.
+ """
+ if not index.isValid():
+ return Qt.NoItemFlags
+ if index.parent().isValid():
+ # item
+ return (Qt.ItemIsEnabled | Qt.ItemIsSelectable |
+ Qt.ItemNeverHasChildren)
+ else:
+ # category
+ return Qt.NoItemFlags
+
+ def index(self, row, col, parent=QModelIndex()):
+ """Get an index into the model.
+
+ Override QAbstractItemModel::index.
+
+ Return: A QModelIndex.
+ """
+ if (row < 0 or row >= self.rowCount(parent) or
+ col < 0 or col >= self.columnCount(parent)):
+ return QModelIndex()
+ if parent.isValid():
+ if parent.column() != 0:
+ return QModelIndex()
+ # store a pointer to the parent category in internalPointer
+ return self.createIndex(row, col, self._categories[parent.row()])
+ return self.createIndex(row, col, None)
+
+ def parent(self, index):
+ """Get an index to the parent of the given index.
+
+ Override QAbstractItemModel::parent.
+
+ Args:
+ index: The QModelIndex to get the parent index for.
+ """
+ parent_cat = index.internalPointer()
+ if not parent_cat:
+ # categories have no parent
+ return QModelIndex()
+ row = self._categories.index(parent_cat)
+ return self.createIndex(row, 0, None)
+
+ def rowCount(self, parent=QModelIndex()):
+ """Override QAbstractItemModel::rowCount."""
+ if not parent.isValid():
+ # top-level
+ return len(self._categories)
+ cat = self._cat_from_idx(parent)
+ if not cat or parent.column() != 0:
+ # item or nonzero category column (only first col has children)
+ return 0
+ else:
+ # category
+ return cat.rowCount()
+
+ def columnCount(self, parent=QModelIndex()):
+ """Override QAbstractItemModel::columnCount."""
+ # pylint: disable=unused-argument
+ return 3
+
+ def canFetchMore(self, parent):
+ """Override to forward the call to the categories."""
+ cat = self._cat_from_idx(parent)
+ if cat:
+ return cat.canFetchMore(QModelIndex())
+ return False
+
+ def fetchMore(self, parent):
+ """Override to forward the call to the categories."""
+ cat = self._cat_from_idx(parent)
+ if cat:
+ cat.fetchMore(QModelIndex())
+
+ def count(self):
+ """Return the count of non-category items."""
+ return sum(t.rowCount() for t in self._categories)
+
+ def set_pattern(self, pattern):
+ """Set the filter pattern for all categories.
+
+ Args:
+ pattern: The filter pattern to set.
+ """
+ log.completion.debug("Setting completion pattern '{}'".format(pattern))
+ for cat in self._categories:
+ cat.set_pattern(pattern)
+
+ def first_item(self):
+ """Return the index of the first child (non-category) in the model."""
+ for row, cat in enumerate(self._categories):
+ if cat.rowCount() > 0:
+ parent = self.index(row, 0)
+ index = self.index(0, 0, parent)
+ qtutils.ensure_valid(index)
+ return index
+ return QModelIndex()
+
+ def last_item(self):
+ """Return the index of the last child (non-category) in the model."""
+ for row, cat in reversed(list(enumerate(self._categories))):
+ childcount = cat.rowCount()
+ if childcount > 0:
+ parent = self.index(row, 0)
+ index = self.index(childcount - 1, 0, parent)
+ qtutils.ensure_valid(index)
+ return index
+ return QModelIndex()
+
+ def columns_to_filter(self, index):
+ """Return the column indices the filter pattern applies to.
+
+ Args:
+ index: index of the item to check.
+
+ Return: A list of integers.
+ """
+ cat = self._cat_from_idx(index.parent())
+ return cat.columns_to_filter if cat else []
+
+ def delete_cur_item(self, index):
+ """Delete the row at the given index."""
+ qtutils.ensure_valid(index)
+ parent = index.parent()
+ cat = self._cat_from_idx(parent)
+ assert cat, "CompletionView sent invalid index for deletion"
+ if not cat.delete_func:
+ raise cmdexc.CommandError("Cannot delete this item.")
+
+ data = [cat.data(cat.index(index.row(), i))
+ for i in range(cat.columnCount())]
+ cat.delete_func(data)
+
+ self.beginRemoveRows(parent, index.row(), index.row())
+ cat.removeRow(index.row(), QModelIndex())
+ self.endRemoveRows()
diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py
index 678811f9c..663a0b7f7 100644
--- a/qutebrowser/completion/models/configmodel.py
+++ b/qutebrowser/completion/models/configmodel.py
@@ -17,145 +17,80 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-"""CompletionModels for the config."""
+"""Functions that return config-related completion models."""
-# FIXME:conf
-# pylint: disable=no-member
+from qutebrowser.config import configdata, configexc
+from qutebrowser.completion.models import completionmodel, listcategory
+from qutebrowser.utils import objreg
-from PyQt5.QtCore import pyqtSlot, Qt
-
-from qutebrowser.config import config, configdata
-from qutebrowser.utils import log, qtutils
-from qutebrowser.completion.models import base
-
-
-class SettingSectionCompletionModel(base.BaseCompletionModel):
+def section():
"""A CompletionModel filled with settings sections."""
+ model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
+ sections = ((name, configdata.SECTION_DESC[name].splitlines()[0].strip())
+ for name in configdata.DATA)
+ model.add_category(listcategory.ListCategory("Sections", sections))
+ return model
- # https://github.com/qutebrowser/qutebrowser/issues/545
- # pylint: disable=abstract-method
-
- COLUMN_WIDTHS = (20, 70, 10)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- cat = self.new_category("Sections")
- for name in configdata.DATA:
- desc = configdata.SECTION_DESC[name].splitlines()[0].strip()
- self.new_item(cat, name, desc)
-
-
-class SettingOptionCompletionModel(base.BaseCompletionModel):
+def option(sectname):
"""A CompletionModel filled with settings and their descriptions.
- Attributes:
- _misc_items: A dict of the misc. column items which will be set later.
- _section: The config section this model shows.
+ Args:
+ sectname: The name of the config section this model shows.
"""
-
- # https://github.com/qutebrowser/qutebrowser/issues/545
- # pylint: disable=abstract-method
-
- COLUMN_WIDTHS = (20, 70, 10)
-
- def __init__(self, section, parent=None):
- super().__init__(parent)
- cat = self.new_category(section)
- sectdata = configdata.DATA[section]
- self._misc_items = {}
- self._section = section
- config.instance.changed.connect(self._update_misc_column)
- for name in sectdata:
- try:
- desc = sectdata.descriptions[name]
- except (KeyError, AttributeError):
- # Some stuff (especially ValueList items) don't have a
- # description.
- desc = ""
- else:
- desc = desc.splitlines()[0]
- value = config.get(section, name, raw=True)
- _valitem, _descitem, miscitem = self.new_item(cat, name, desc,
- value)
- self._misc_items[name] = miscitem
-
- @pyqtSlot(str, str)
- def _update_misc_column(self, section, option):
- """Update misc column when config changed."""
- if section != self._section:
- return
+ model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
+ try:
+ sectdata = configdata.DATA[sectname]
+ except KeyError:
+ return None
+ options = []
+ for name in sectdata:
try:
- item = self._misc_items[option]
- except KeyError:
- log.completion.debug("Couldn't get item {}.{} from model!".format(
- section, option))
- # changed before init
- return
- val = config.get(section, option, raw=True)
- idx = item.index()
- qtutils.ensure_valid(idx)
- ok = self.setData(idx, val, Qt.DisplayRole)
- if not ok:
- raise ValueError("Setting data failed! (section: {}, option: {}, "
- "value: {})".format(section, option, val))
-
+ desc = sectdata.descriptions[name]
+ except (KeyError, AttributeError):
+ # Some stuff (especially ValueList items) don't have a
+ # description.
+ desc = ""
+ else:
+ desc = desc.splitlines()[0]
+ config = objreg.get('config')
+ val = config.get(sectname, name, raw=True)
+ options.append((name, desc, val))
+ model.add_category(listcategory.ListCategory(sectname, options))
+ return model
-class SettingValueCompletionModel(base.BaseCompletionModel):
+def value(sectname, optname):
"""A CompletionModel filled with setting values.
- Attributes:
- _section: The config section this model shows.
- _option: The config option this model shows.
+ Args:
+ sectname: The name of the config section this model shows.
+ optname: The name of the config option this model shows.
"""
-
- # https://github.com/qutebrowser/qutebrowser/issues/545
- # pylint: disable=abstract-method
-
- COLUMN_WIDTHS = (20, 70, 10)
-
- def __init__(self, section, option, parent=None):
- super().__init__(parent)
- self._section = section
- self._option = option
- config.instance.changed.connect(self._update_current_value)
- cur_cat = self.new_category("Current/Default", sort=0)
- value = config.get(section, option, raw=True)
- if not value:
- value = '""'
- self.cur_item, _descitem, _miscitem = self.new_item(cur_cat, value,
- "Current value")
- default_value = configdata.DATA[section][option].default()
- if not default_value:
- default_value = '""'
- self.new_item(cur_cat, default_value, "Default value")
- if hasattr(configdata.DATA[section], 'valtype'):
- # Same type for all values (ValueList)
- vals = configdata.DATA[section].valtype.complete()
- else:
- if option is None:
- raise ValueError("option may only be None for ValueList "
- "sections, but {} is not!".format(section))
- # Different type for each value (KeyValue)
- vals = configdata.DATA[section][option].typ.complete()
- if vals is not None:
- cat = self.new_category("Completions", sort=1)
- for (val, desc) in vals:
- self.new_item(cat, val, desc)
-
- @pyqtSlot(str, str)
- def _update_current_value(self, section, option):
- """Update current value when config changed."""
- if (section, option) != (self._section, self._option):
- return
- value = config.get(section, option, raw=True)
- if not value:
- value = '""'
- idx = self.cur_item.index()
- qtutils.ensure_valid(idx)
- ok = self.setData(idx, value, Qt.DisplayRole)
- if not ok:
- raise ValueError("Setting data failed! (section: {}, option: {}, "
- "value: {})".format(section, option, value))
+ model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
+ config = objreg.get('config')
+
+ try:
+ current = config.get(sectname, optname, raw=True) or '""'
+ except (configexc.NoSectionError, configexc.NoOptionError):
+ return None
+
+ default = configdata.DATA[sectname][optname].default() or '""'
+
+ if hasattr(configdata.DATA[sectname], 'valtype'):
+ # Same type for all values (ValueList)
+ vals = configdata.DATA[sectname].valtype.complete()
+ else:
+ if optname is None:
+ raise ValueError("optname may only be None for ValueList "
+ "sections, but {} is not!".format(sectname))
+ # Different type for each value (KeyValue)
+ vals = configdata.DATA[sectname][optname].typ.complete()
+
+ cur_cat = listcategory.ListCategory("Current/Default",
+ [(current, "Current value"), (default, "Default value")])
+ model.add_category(cur_cat)
+ if vals is not None:
+ model.add_category(listcategory.ListCategory("Completions", vals))
+ return model
diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py
new file mode 100644
index 000000000..606351440
--- /dev/null
+++ b/qutebrowser/completion/models/histcategory.py
@@ -0,0 +1,104 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""A completion category that queries the SQL History store."""
+
+import re
+
+from PyQt5.QtSql import QSqlQueryModel
+
+from qutebrowser.misc import sql
+from qutebrowser.utils import debug
+from qutebrowser.config import config
+
+
+class HistoryCategory(QSqlQueryModel):
+
+ """A completion category that queries the SQL History store."""
+
+ def __init__(self, *, delete_func=None, parent=None):
+ """Create a new History completion category."""
+ super().__init__(parent=parent)
+ self.name = "History"
+
+ # replace ' in timestamp-format to avoid breaking the query
+ timefmt = ("strftime('{}', last_atime, 'unixepoch', 'localtime')"
+ .format(config.get('completion', 'timestamp-format')
+ .replace("'", "`")))
+
+ self._query = sql.Query(' '.join([
+ "SELECT url, title, {}".format(timefmt),
+ "FROM CompletionHistory",
+ # the incoming pattern will have literal % and _ escaped with '\'
+ # we need to tell sql to treat '\' as an escape character
+ "WHERE (url LIKE :pat escape '\\' or title LIKE :pat escape '\\')",
+ self._atime_expr(),
+ "ORDER BY last_atime DESC",
+ ]), forward_only=False)
+
+ # advertise that this model filters by URL and title
+ self.columns_to_filter = [0, 1]
+ self.delete_func = delete_func
+
+ def _atime_expr(self):
+ """If max_items is set, return an expression to limit the query."""
+ max_items = config.get('completion', 'web-history-max-items')
+ # HistoryCategory should not be added to the completion in that case.
+ assert max_items != 0
+
+ if max_items < 0:
+ return ''
+
+ min_atime = sql.Query(' '.join([
+ 'SELECT min(last_atime) FROM',
+ '(SELECT last_atime FROM CompletionHistory',
+ 'ORDER BY last_atime DESC LIMIT :limit)',
+ ])).run(limit=max_items).value()
+
+ if not min_atime:
+ # if there are no history items, min_atime may be '' (issue #2849)
+ return ''
+
+ return "AND last_atime >= {}".format(min_atime)
+
+ def set_pattern(self, pattern):
+ """Set the pattern used to filter results.
+
+ Args:
+ pattern: string pattern to filter by.
+ """
+ # escape to treat a user input % or _ as a literal, not a wildcard
+ pattern = pattern.replace('%', '\\%')
+ pattern = pattern.replace('_', '\\_')
+ # treat spaces as wildcards to match any of the typed words
+ pattern = re.sub(r' +', '%', pattern)
+ pattern = '%{}%'.format(pattern)
+ with debug.log_time('sql', 'Running completion query'):
+ self._query.run(pat=pattern)
+ self.setQuery(self._query)
+
+ def removeRows(self, row, _count, _parent=None):
+ """Override QAbstractItemModel::removeRows to re-run sql query."""
+ # re-run query to reload updated table
+ with debug.log_time('sql', 'Re-running completion query post-delete'):
+ self._query.run()
+ self.setQuery(self._query)
+ while self.rowCount() < row:
+ self.fetchMore()
+ return True
diff --git a/qutebrowser/completion/models/instances.py b/qutebrowser/completion/models/instances.py
deleted file mode 100644
index 2e65a3198..000000000
--- a/qutebrowser/completion/models/instances.py
+++ /dev/null
@@ -1,162 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2015-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
-#
-# 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 <http://www.gnu.org/licenses/>.
-
-"""Global instances of the completion models.
-
-Module attributes:
- _instances: A dict of available completions.
- INITIALIZERS: A {usertypes.Completion: callable} dict of functions to
- initialize completions.
-"""
-
-import functools
-
-from qutebrowser.completion.models import miscmodels, urlmodel
-from qutebrowser.utils import objreg, usertypes, log, debug
-
-
-_instances = {}
-
-
-def _init_command_completion():
- """Initialize the command completion model."""
- log.completion.debug("Initializing command completion.")
- model = miscmodels.CommandCompletionModel()
- _instances[usertypes.Completion.command] = model
-
-
-def _init_helptopic_completion():
- """Initialize the helptopic completion model."""
- log.completion.debug("Initializing helptopic completion.")
- model = miscmodels.HelpCompletionModel()
- _instances[usertypes.Completion.helptopic] = model
-
-
-def _init_url_completion():
- """Initialize the URL completion model."""
- log.completion.debug("Initializing URL completion.")
- with debug.log_time(log.completion, 'URL completion init'):
- model = urlmodel.UrlCompletionModel()
- _instances[usertypes.Completion.url] = model
-
-
-def _init_tab_completion():
- """Initialize the tab completion model."""
- log.completion.debug("Initializing tab completion.")
- with debug.log_time(log.completion, 'tab completion init'):
- model = miscmodels.TabCompletionModel()
- _instances[usertypes.Completion.tab] = model
-
-
-def init_quickmark_completions():
- """Initialize quickmark completion models."""
- log.completion.debug("Initializing quickmark completion.")
- try:
- _instances[usertypes.Completion.quickmark_by_name].deleteLater()
- except KeyError:
- pass
- model = miscmodels.QuickmarkCompletionModel()
- _instances[usertypes.Completion.quickmark_by_name] = model
-
-
-def init_bookmark_completions():
- """Initialize bookmark completion models."""
- log.completion.debug("Initializing bookmark completion.")
- try:
- _instances[usertypes.Completion.bookmark_by_url].deleteLater()
- except KeyError:
- pass
- model = miscmodels.BookmarkCompletionModel()
- _instances[usertypes.Completion.bookmark_by_url] = model
-
-
-def init_session_completion():
- """Initialize session completion model."""
- log.completion.debug("Initializing session completion.")
- try:
- _instances[usertypes.Completion.sessions].deleteLater()
- except KeyError:
- pass
- model = miscmodels.SessionCompletionModel()
- _instances[usertypes.Completion.sessions] = model
-
-
-def _init_bind_completion():
- """Initialize the command completion model."""
- log.completion.debug("Initializing bind completion.")
- model = miscmodels.BindCompletionModel()
- _instances[usertypes.Completion.bind] = model
-
-
-INITIALIZERS = {
- usertypes.Completion.command: _init_command_completion,
- usertypes.Completion.helptopic: _init_helptopic_completion,
- usertypes.Completion.url: _init_url_completion,
- usertypes.Completion.tab: _init_tab_completion,
- usertypes.Completion.quickmark_by_name: init_quickmark_completions,
- usertypes.Completion.bookmark_by_url: init_bookmark_completions,
- usertypes.Completion.sessions: init_session_completion,
- usertypes.Completion.bind: _init_bind_completion,
-}
-
-
-def get(completion):
- """Get a certain completion. Initializes the completion if needed."""
- try:
- return _instances[completion]
- except KeyError:
- if completion in INITIALIZERS:
- INITIALIZERS[completion]()
- return _instances[completion]
- else:
- raise
-
-
-def update(completions):
- """Update an already existing completion.
-
- Args:
- completions: An iterable of usertypes.Completions.
- """
- did_run = []
- for completion in completions:
- if completion in _instances:
- func = INITIALIZERS[completion]
- if func not in did_run:
- func()
- did_run.append(func)
-
-
-def init():
- """Initialize completions. Note this only connects signals."""
- quickmark_manager = objreg.get('quickmark-manager')
- quickmark_manager.changed.connect(
- functools.partial(update, [usertypes.Completion.quickmark_by_name]))
-
- bookmark_manager = objreg.get('bookmark-manager')
- bookmark_manager.changed.connect(
- functools.partial(update, [usertypes.Completion.bookmark_by_url]))
-
- session_manager = objreg.get('session-manager')
- session_manager.update_completion.connect(
- functools.partial(update, [usertypes.Completion.sessions]))
-
- history = objreg.get('web-history')
- history.async_read_done.connect(
- functools.partial(update, [usertypes.Completion.url]))
diff --git a/qutebrowser/completion/models/listcategory.py b/qutebrowser/completion/models/listcategory.py
new file mode 100644
index 000000000..b1ad77bae
--- /dev/null
+++ b/qutebrowser/completion/models/listcategory.py
@@ -0,0 +1,92 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Completion category that uses a list of tuples as a data source."""
+
+import re
+
+from PyQt5.QtCore import Qt, QSortFilterProxyModel, QRegExp
+from PyQt5.QtGui import QStandardItem, QStandardItemModel
+
+from qutebrowser.utils import qtutils
+
+
+class ListCategory(QSortFilterProxyModel):
+
+ """Expose a list of items as a category for the CompletionModel."""
+
+ def __init__(self, name, items, delete_func=None, parent=None):
+ super().__init__(parent)
+ self.name = name
+ self.srcmodel = QStandardItemModel(parent=self)
+ self._pattern = ''
+ # ListCategory filters all columns
+ self.columns_to_filter = [0, 1, 2]
+ self.setFilterKeyColumn(-1)
+ for item in items:
+ self.srcmodel.appendRow([QStandardItem(x) for x in item])
+ self.setSourceModel(self.srcmodel)
+ self.delete_func = delete_func
+
+ def set_pattern(self, val):
+ """Setter for pattern.
+
+ Args:
+ val: The value to set.
+ """
+ self._pattern = val
+ val = re.sub(r' +', r' ', val) # See #1919
+ val = re.escape(val)
+ val = val.replace(r'\ ', '.*')
+ rx = QRegExp(val, Qt.CaseInsensitive)
+ self.setFilterRegExp(rx)
+ self.invalidate()
+ sortcol = 0
+ self.sort(sortcol)
+
+ def lessThan(self, lindex, rindex):
+ """Custom sorting implementation.
+
+ Prefers all items which start with self._pattern. Other than that, uses
+ normal Python string sorting.
+
+ Args:
+ lindex: The QModelIndex of the left item (*left* < right)
+ rindex: The QModelIndex of the right item (left < *right*)
+
+ Return:
+ True if left < right, else False
+ """
+ qtutils.ensure_valid(lindex)
+ qtutils.ensure_valid(rindex)
+
+ left = self.srcmodel.data(lindex)
+ right = self.srcmodel.data(rindex)
+
+ leftstart = left.startswith(self._pattern)
+ rightstart = right.startswith(self._pattern)
+
+ if leftstart and rightstart:
+ return left < right
+ elif leftstart:
+ return True
+ elif rightstart:
+ return False
+ else:
+ return left < right
diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py
index a85ba2db9..0e55e6696 100644
--- a/qutebrowser/completion/models/miscmodels.py
+++ b/qutebrowser/completion/models/miscmodels.py
@@ -17,255 +17,142 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-"""Misc. CompletionModels."""
+"""Functions that return miscellaneous completion models."""
-# FIXME:conf
-# pylint: disable=unused-argument
-
-from PyQt5.QtCore import Qt, QTimer, pyqtSlot
-
-from qutebrowser.browser import browsertab
from qutebrowser.config import config, configdata
-from qutebrowser.utils import objreg, log, qtutils
+from qutebrowser.utils import objreg, log
from qutebrowser.commands import cmdutils
-from qutebrowser.completion.models import base
-
+from qutebrowser.completion.models import completionmodel, listcategory
-class CommandCompletionModel(base.BaseCompletionModel):
+def command():
"""A CompletionModel filled with non-hidden commands and descriptions."""
-
- # https://github.com/qutebrowser/qutebrowser/issues/545
- # pylint: disable=abstract-method
-
- COLUMN_WIDTHS = (20, 60, 20)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- cmdlist = _get_cmd_completions(include_aliases=True,
- include_hidden=False)
- cat = self.new_category("Commands")
- for (name, desc, misc) in cmdlist:
- self.new_item(cat, name, desc, misc)
+ model = completionmodel.CompletionModel(column_widths=(20, 60, 20))
+ cmdlist = _get_cmd_completions(include_aliases=True, include_hidden=False)
+ model.add_category(listcategory.ListCategory("Commands", cmdlist))
+ return model
-class HelpCompletionModel(base.BaseCompletionModel):
-
+def helptopic():
"""A CompletionModel filled with help topics."""
+ model = completionmodel.CompletionModel()
+
+ cmdlist = _get_cmd_completions(include_aliases=False, include_hidden=True,
+ prefix=':')
+ settings = []
+ for sectname, sectdata in configdata.DATA.items():
+ for optname in sectdata:
+ try:
+ desc = sectdata.descriptions[optname]
+ except (KeyError, AttributeError):
+ # Some stuff (especially ValueList items) don't have a
+ # description.
+ desc = ""
+ else:
+ desc = desc.splitlines()[0]
+ name = '{}->{}'.format(sectname, optname)
+ settings.append((name, desc))
- # https://github.com/qutebrowser/qutebrowser/issues/545
- # pylint: disable=abstract-method
-
- COLUMN_WIDTHS = (20, 60, 20)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._init_commands()
- self._init_settings()
-
- def _init_commands(self):
- """Fill completion with :command entries."""
- cmdlist = _get_cmd_completions(include_aliases=False,
- include_hidden=True, prefix=':')
- cat = self.new_category("Commands")
- for (name, desc, misc) in cmdlist:
- self.new_item(cat, name, desc, misc)
-
- def _init_settings(self):
- """Fill completion with section->option entries."""
- cat = self.new_category("Settings")
- for sectname, sectdata in configdata.DATA.items():
- for optname in sectdata:
- try:
- desc = sectdata.descriptions[optname]
- except (KeyError, AttributeError):
- # Some stuff (especially ValueList items) don't have a
- # description.
- desc = ""
- else:
- desc = desc.splitlines()[0]
- name = '{}->{}'.format(sectname, optname)
- self.new_item(cat, name, desc)
-
-
-class QuickmarkCompletionModel(base.BaseCompletionModel):
-
- """A CompletionModel filled with all quickmarks."""
+ model.add_category(listcategory.ListCategory("Commands", cmdlist))
+ model.add_category(listcategory.ListCategory("Settings", settings))
+ return model
- # https://github.com/qutebrowser/qutebrowser/issues/545
- # pylint: disable=abstract-method
- def __init__(self, parent=None):
- super().__init__(parent)
- cat = self.new_category("Quickmarks")
- quickmarks = objreg.get('quickmark-manager').marks.items()
- for qm_name, qm_url in quickmarks:
- self.new_item(cat, qm_name, qm_url)
+def quickmark():
+ """A CompletionModel filled with all quickmarks."""
+ def delete(data):
+ """Delete a quickmark from the completion menu."""
+ name = data[0]
+ quickmark_manager = objreg.get('quickmark-manager')
+ log.completion.debug('Deleting quickmark {}'.format(name))
+ quickmark_manager.delete(name)
+ model = completionmodel.CompletionModel(column_widths=(30, 70, 0))
+ marks = objreg.get('quickmark-manager').marks.items()
+ model.add_category(listcategory.ListCategory('Quickmarks', marks,
+ delete_func=delete))
+ return model
-class BookmarkCompletionModel(base.BaseCompletionModel):
+def bookmark():
"""A CompletionModel filled with all bookmarks."""
+ def delete(data):
+ """Delete a bookmark from the completion menu."""
+ urlstr = data[0]
+ log.completion.debug('Deleting bookmark {}'.format(urlstr))
+ bookmark_manager = objreg.get('bookmark-manager')
+ bookmark_manager.delete(urlstr)
- # https://github.com/qutebrowser/qutebrowser/issues/545
- # pylint: disable=abstract-method
-
- def __init__(self, parent=None):
- super().__init__(parent)
- cat = self.new_category("Bookmarks")
- bookmarks = objreg.get('bookmark-manager').marks.items()
- for bm_url, bm_title in bookmarks:
- self.new_item(cat, bm_url, bm_title)
+ model = completionmodel.CompletionModel(column_widths=(30, 70, 0))
+ marks = objreg.get('bookmark-manager').marks.items()
+ model.add_category(listcategory.ListCategory('Bookmarks', marks,
+ delete_func=delete))
+ return model
-class SessionCompletionModel(base.BaseCompletionModel):
-
+def session():
"""A CompletionModel filled with session names."""
-
- # https://github.com/qutebrowser/qutebrowser/issues/545
- # pylint: disable=abstract-method
-
- def __init__(self, parent=None):
- super().__init__(parent)
- cat = self.new_category("Sessions")
- try:
- for name in objreg.get('session-manager').list_sessions():
- if not name.startswith('_'):
- self.new_item(cat, name)
- except OSError:
- log.completion.exception("Failed to list sessions!")
-
-
-class TabCompletionModel(base.BaseCompletionModel):
-
+ model = completionmodel.CompletionModel()
+ try:
+ manager = objreg.get('session-manager')
+ sessions = ((name,) for name in manager.list_sessions()
+ if not name.startswith('_'))
+ model.add_category(listcategory.ListCategory("Sessions", sessions))
+ except OSError:
+ log.completion.exception("Failed to list sessions!")
+ return model
+
+
+def buffer():
"""A model to complete on open tabs across all windows.
Used for switching the buffer command.
"""
-
- IDX_COLUMN = 0
- URL_COLUMN = 1
- TEXT_COLUMN = 2
-
- COLUMN_WIDTHS = (6, 40, 54)
- DUMB_SORT = Qt.DescendingOrder
-
- def __init__(self, parent=None):
- super().__init__(parent)
-
- self.columns_to_filter = [self.IDX_COLUMN, self.URL_COLUMN,
- self.TEXT_COLUMN]
-
- for win_id in objreg.window_registry:
- tabbed_browser = objreg.get('tabbed-browser', scope='window',
- window=win_id)
- for i in range(tabbed_browser.count()):
- tab = tabbed_browser.widget(i)
- tab.url_changed.connect(self.rebuild)
- tab.title_changed.connect(self.rebuild)
- tab.shutting_down.connect(self.delayed_rebuild)
- tabbed_browser.new_tab.connect(self.on_new_tab)
- tabbed_browser.tabBar().tabMoved.connect(self.rebuild)
- objreg.get("app").new_window.connect(self.on_new_window)
- self.rebuild()
-
- def on_new_window(self, window):
- """Add hooks to new windows."""
- window.tabbed_browser.new_tab.connect(self.on_new_tab)
-
- @pyqtSlot(browsertab.AbstractTab)
- def on_new_tab(self, tab):
- """Add hooks to new tabs."""
- tab.url_changed.connect(self.rebuild)
- tab.title_changed.connect(self.rebuild)
- tab.shutting_down.connect(self.delayed_rebuild)
- self.rebuild()
-
- @pyqtSlot()
- def delayed_rebuild(self):
- """Fire a rebuild indirectly so widgets get a chance to update."""
- QTimer.singleShot(0, self.rebuild)
-
- @pyqtSlot()
- def rebuild(self):
- """Rebuild completion model from current tabs.
-
- Very lazy method of keeping the model up to date. We could connect to
- signals for new tab, tab url/title changed, tab close, tab moved and
- make sure we handled background loads too ... but iterating over a
- few/few dozen/few hundred tabs doesn't take very long at all.
- """
- window_count = 0
- for win_id in objreg.window_registry:
- tabbed_browser = objreg.get('tabbed-browser', scope='window',
- window=win_id)
- if not tabbed_browser.shutting_down:
- window_count += 1
-
- if window_count < self.rowCount():
- self.removeRows(window_count, self.rowCount() - window_count)
-
- for i, win_id in enumerate(objreg.window_registry):
- tabbed_browser = objreg.get('tabbed-browser', scope='window',
- window=win_id)
- if tabbed_browser.shutting_down:
- continue
- if i >= self.rowCount():
- c = self.new_category("{}".format(win_id))
- else:
- c = self.item(i, 0)
- c.setData("{}".format(win_id), Qt.DisplayRole)
- if tabbed_browser.count() < c.rowCount():
- c.removeRows(tabbed_browser.count(),
- c.rowCount() - tabbed_browser.count())
- for idx in range(tabbed_browser.count()):
- tab = tabbed_browser.widget(idx)
- if idx >= c.rowCount():
- self.new_item(c, "{}/{}".format(win_id, idx + 1),
- tab.url().toDisplayString(),
- tabbed_browser.page_title(idx))
- else:
- c.child(idx, 0).setData("{}/{}".format(win_id, idx + 1),
- Qt.DisplayRole)
- c.child(idx, 1).setData(tab.url().toDisplayString(),
- Qt.DisplayRole)
- c.child(idx, 2).setData(tabbed_browser.page_title(idx),
- Qt.DisplayRole)
-
- def delete_cur_item(self, completion):
- """Delete the selected item.
-
- Args:
- completion: The Completion object to use.
- """
- index = completion.currentIndex()
- qtutils.ensure_valid(index)
- category = index.parent()
- qtutils.ensure_valid(category)
- index = category.child(index.row(), self.IDX_COLUMN)
- win_id, tab_index = index.data().split('/')
-
+ def delete_buffer(data):
+ """Close the selected tab."""
+ win_id, tab_index = data[0].split('/')
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=int(win_id))
tabbed_browser.on_tab_close_requested(int(tab_index) - 1)
+ model = completionmodel.CompletionModel(column_widths=(6, 40, 54))
-class BindCompletionModel(base.BaseCompletionModel):
+ for win_id in objreg.window_registry:
+ tabbed_browser = objreg.get('tabbed-browser', scope='window',
+ window=win_id)
+ if tabbed_browser.shutting_down:
+ continue
+ tabs = []
+ for idx in range(tabbed_browser.count()):
+ tab = tabbed_browser.widget(idx)
+ tabs.append(("{}/{}".format(win_id, idx + 1),
+ tab.url().toDisplayString(),
+ tabbed_browser.page_title(idx)))
+ cat = listcategory.ListCategory("{}".format(win_id), tabs,
+ delete_func=delete_buffer)
+ model.add_category(cat)
- """A CompletionModel filled with all bindable commands and descriptions."""
+ return model
- # https://github.com/qutebrowser/qutebrowser/issues/545
- # pylint: disable=abstract-method
- COLUMN_WIDTHS = (20, 60, 20)
+def bind(key):
+ """A CompletionModel filled with all bindable commands and descriptions.
- def __init__(self, parent=None):
- super().__init__(parent)
- cmdlist = _get_cmd_completions(include_hidden=True,
- include_aliases=True)
- cat = self.new_category("Commands")
- for (name, desc, misc) in cmdlist:
- self.new_item(cat, name, desc, misc)
+ Args:
+ key: the key being bound.
+ """
+ model = completionmodel.CompletionModel(column_widths=(20, 60, 20))
+ cmd_text = objreg.get('key-config').get_bindings_for('normal').get(key)
+
+ if cmd_text:
+ cmd_name = cmd_text.split(' ')[0]
+ cmd = cmdutils.cmd_dict.get(cmd_name)
+ data = [(cmd_text, cmd.desc, key)]
+ model.add_category(listcategory.ListCategory("Current", data))
+
+ cmdlist = _get_cmd_completions(include_hidden=True, include_aliases=True)
+ model.add_category(listcategory.ListCategory("Commands", cmdlist))
+ return model
def _get_cmd_completions(include_hidden, include_aliases, prefix=''):
diff --git a/qutebrowser/completion/models/sortfilter.py b/qutebrowser/completion/models/sortfilter.py
deleted file mode 100644
index e2db88b9e..000000000
--- a/qutebrowser/completion/models/sortfilter.py
+++ /dev/null
@@ -1,191 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2014-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
-#
-# 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 <http://www.gnu.org/licenses/>.
-
-"""A filtering/sorting base model for completions.
-
-Contains:
- CompletionFilterModel -- A QSortFilterProxyModel subclass for completions.
-"""
-
-import re
-
-from PyQt5.QtCore import QSortFilterProxyModel, QModelIndex, Qt
-
-from qutebrowser.utils import log, qtutils, debug
-from qutebrowser.completion.models import base as completion
-
-
-class CompletionFilterModel(QSortFilterProxyModel):
-
- """Subclass of QSortFilterProxyModel with custom sorting/filtering.
-
- Attributes:
- pattern: The pattern to filter with.
- srcmodel: The current source model.
- Kept as attribute because calling `sourceModel` takes quite
- a long time for some reason.
- _sort_order: The order to use for sorting if using dumb_sort.
- """
-
- def __init__(self, source, parent=None):
- super().__init__(parent)
- super().setSourceModel(source)
- self.srcmodel = source
- self.pattern = ''
- self.pattern_re = None
-
- dumb_sort = self.srcmodel.DUMB_SORT
- if dumb_sort is None:
- # pylint: disable=invalid-name
- self.lessThan = self.intelligentLessThan
- self._sort_order = Qt.AscendingOrder
- else:
- self.setSortRole(completion.Role.sort)
- self._sort_order = dumb_sort
-
- def set_pattern(self, val):
- """Setter for pattern.
-
- Invalidates the filter and re-sorts the model.
-
- Args:
- val: The value to set.
- """
- with debug.log_time(log.completion, 'Setting filter pattern'):
- self.pattern = val
- val = re.sub(r' +', r' ', val) # See #1919
- val = re.escape(val)
- val = val.replace(r'\ ', '.*')
- self.pattern_re = re.compile(val, re.IGNORECASE)
- self.invalidate()
- sortcol = 0
- self.sort(sortcol)
-
- def count(self):
- """Get the count of non-toplevel items currently visible.
-
- Note this only iterates one level deep, as we only need root items
- (categories) and children (items) in our model.
- """
- count = 0
- for i in range(self.rowCount()):
- cat = self.index(i, 0)
- qtutils.ensure_valid(cat)
- count += self.rowCount(cat)
- return count
-
- def first_item(self):
- """Return the first item in the model."""
- for i in range(self.rowCount()):
- cat = self.index(i, 0)
- qtutils.ensure_valid(cat)
- if cat.model().hasChildren(cat):
- index = self.index(0, 0, cat)
- qtutils.ensure_valid(index)
- return index
- return QModelIndex()
-
- def last_item(self):
- """Return the last item in the model."""
- for i in range(self.rowCount() - 1, -1, -1):
- cat = self.index(i, 0)
- qtutils.ensure_valid(cat)
- if cat.model().hasChildren(cat):
- index = self.index(self.rowCount(cat) - 1, 0, cat)
- qtutils.ensure_valid(index)
- return index
- return QModelIndex()
-
- def setSourceModel(self, model):
- """Override QSortFilterProxyModel's setSourceModel to clear pattern."""
- log.completion.debug("Setting source model: {}".format(model))
- self.set_pattern('')
- super().setSourceModel(model)
- self.srcmodel = model
-
- def filterAcceptsRow(self, row, parent):
- """Custom filter implementation.
-
- Override QSortFilterProxyModel::filterAcceptsRow.
-
- Args:
- row: The row of the item.
- parent: The parent item QModelIndex.
-
- Return:
- True if self.pattern is contained in item, or if it's a root item
- (category). False in all other cases
- """
- if parent == QModelIndex() or not self.pattern:
- return True
-
- for col in self.srcmodel.columns_to_filter:
- idx = self.srcmodel.index(row, col, parent)
- if not idx.isValid(): # pragma: no cover
- # this is a sanity check not hit by any test case
- continue
- data = self.srcmodel.data(idx)
- if not data:
- continue
- elif self.pattern_re.search(data):
- return True
- return False
-
- def intelligentLessThan(self, lindex, rindex):
- """Custom sorting implementation.
-
- Prefers all items which start with self.pattern. Other than that, uses
- normal Python string sorting.
-
- Args:
- lindex: The QModelIndex of the left item (*left* < right)
- rindex: The QModelIndex of the right item (left < *right*)
-
- Return:
- True if left < right, else False
- """
- qtutils.ensure_valid(lindex)
- qtutils.ensure_valid(rindex)
-
- left_sort = self.srcmodel.data(lindex, role=completion.Role.sort)
- right_sort = self.srcmodel.data(rindex, role=completion.Role.sort)
-
- if left_sort is not None and right_sort is not None:
- return left_sort < right_sort
-
- left = self.srcmodel.data(lindex)
- right = self.srcmodel.data(rindex)
-
- leftstart = left.startswith(self.pattern)
- rightstart = right.startswith(self.pattern)
-
- if leftstart and rightstart:
- return left < right
- elif leftstart:
- return True
- elif rightstart:
- return False
- else:
- return left < right
-
- def sort(self, column, order=None):
- """Extend sort to respect self._sort_order if no order was given."""
- if order is None:
- order = self._sort_order
- super().sort(column, order)
diff --git a/qutebrowser/completion/models/urlmodel.py b/qutebrowser/completion/models/urlmodel.py
index 3069b5674..341e7c24a 100644
--- a/qutebrowser/completion/models/urlmodel.py
+++ b/qutebrowser/completion/models/urlmodel.py
@@ -17,176 +17,56 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-"""CompletionModels for URLs."""
+"""Function to return the url completion model for the `open` command."""
-import datetime
+from qutebrowser.completion.models import (completionmodel, listcategory,
+ histcategory)
+from qutebrowser.utils import log, objreg
+from qutebrowser.config import config
-from PyQt5.QtCore import pyqtSlot, Qt
-from qutebrowser.utils import objreg, utils, qtutils, log
-from qutebrowser.completion.models import base
-from qutebrowser.config import config
+_URLCOL = 0
+_TEXTCOL = 1
+
+
+def _delete_history(data):
+ urlstr = data[_URLCOL]
+ log.completion.debug('Deleting history entry {}'.format(urlstr))
+ hist = objreg.get('web-history')
+ hist.delete_url(urlstr)
+
+def _delete_bookmark(data):
+ urlstr = data[_URLCOL]
+ log.completion.debug('Deleting bookmark {}'.format(urlstr))
+ bookmark_manager = objreg.get('bookmark-manager')
+ bookmark_manager.delete(urlstr)
-class UrlCompletionModel(base.BaseCompletionModel):
+def _delete_quickmark(data):
+ name = data[_TEXTCOL]
+ quickmark_manager = objreg.get('quickmark-manager')
+ log.completion.debug('Deleting quickmark {}'.format(name))
+ quickmark_manager.delete(name)
+
+
+def url():
"""A model which combines bookmarks, quickmarks and web history URLs.
Used for the `open` command.
"""
+ model = completionmodel.CompletionModel(column_widths=(40, 50, 10))
+
+ quickmarks = ((url, name) for (name, url)
+ in objreg.get('quickmark-manager').marks.items())
+ bookmarks = objreg.get('bookmark-manager').marks.items()
+
+ model.add_category(listcategory.ListCategory(
+ 'Quickmarks', quickmarks, delete_func=_delete_quickmark))
+ model.add_category(listcategory.ListCategory(
+ 'Bookmarks', bookmarks, delete_func=_delete_bookmark))
- URL_COLUMN = 0
- TEXT_COLUMN = 1
- TIME_COLUMN = 2
-
- COLUMN_WIDTHS = (40, 50, 10)
- DUMB_SORT = Qt.DescendingOrder
-
- def __init__(self, parent=None):
- super().__init__(parent)
-
- self.columns_to_filter = [self.URL_COLUMN, self.TEXT_COLUMN]
-
- self._quickmark_cat = self.new_category("Quickmarks")
- self._bookmark_cat = self.new_category("Bookmarks")
- self._history_cat = self.new_category("History")
-
- quickmark_manager = objreg.get('quickmark-manager')
- quickmarks = quickmark_manager.marks.items()
- for qm_name, qm_url in quickmarks:
- self.new_item(self._quickmark_cat, qm_url, qm_name)
- quickmark_manager.added.connect(
- lambda name, url: self.new_item(self._quickmark_cat, url, name))
- quickmark_manager.removed.connect(self.on_quickmark_removed)
-
- bookmark_manager = objreg.get('bookmark-manager')
- bookmarks = bookmark_manager.marks.items()
- for bm_url, bm_title in bookmarks:
- self.new_item(self._bookmark_cat, bm_url, bm_title)
- bookmark_manager.added.connect(
- lambda name, url: self.new_item(self._bookmark_cat, url, name))
- bookmark_manager.removed.connect(self.on_bookmark_removed)
-
- self._history = objreg.get('web-history')
- self._max_history = config.val.completion.web_history_max_items
- history = utils.newest_slice(self._history, self._max_history)
- for entry in history:
- if not entry.redirect:
- self._add_history_entry(entry)
- self._history.add_completion_item.connect(self.on_history_item_added)
- self._history.cleared.connect(self.on_history_cleared)
-
- config.instance.changed.connect(self._reformat_timestamps)
-
- def _fmt_atime(self, atime):
- """Format an atime to a human-readable string."""
- fmt = config.val.completion.timestamp_format
- if fmt is None:
- return ''
- try:
- dt = datetime.datetime.fromtimestamp(atime)
- except (ValueError, OSError, OverflowError):
- # Different errors which can occur for too large values...
- log.misc.error("Got invalid timestamp {}!".format(atime))
- return '(invalid)'
- else:
- return dt.strftime(fmt)
-
- def _remove_oldest_history(self):
- """Remove the oldest history entry."""
- self._history_cat.removeRow(0)
-
- def _add_history_entry(self, entry):
- """Add a new history entry to the completion."""
- self.new_item(self._history_cat, entry.url.toDisplayString(),
- entry.title,
- self._fmt_atime(entry.atime), sort=int(entry.atime),
- userdata=entry.url)
-
- if (self._max_history != -1 and
- self._history_cat.rowCount() > self._max_history):
- self._remove_oldest_history()
-
- @config.change_filter('completion.timestamp_format')
- def _reformat_timestamps(self):
- """Reformat the timestamps if the config option was changed."""
- for i in range(self._history_cat.rowCount()):
- url_item = self._history_cat.child(i, self.URL_COLUMN)
- atime_item = self._history_cat.child(i, self.TIME_COLUMN)
- atime = url_item.data(base.Role.sort)
- atime_item.setText(self._fmt_atime(atime))
-
- @pyqtSlot(object)
- def on_history_item_added(self, entry):
- """Slot called when a new history item was added."""
- for i in range(self._history_cat.rowCount()):
- url_item = self._history_cat.child(i, self.URL_COLUMN)
- atime_item = self._history_cat.child(i, self.TIME_COLUMN)
- title_item = self._history_cat.child(i, self.TEXT_COLUMN)
- url = url_item.data(base.Role.userdata)
- if url == entry.url:
- atime_item.setText(self._fmt_atime(entry.atime))
- title_item.setText(entry.title)
- url_item.setData(int(entry.atime), base.Role.sort)
- break
- else:
- self._add_history_entry(entry)
-
- @pyqtSlot()
- def on_history_cleared(self):
- self._history_cat.removeRows(0, self._history_cat.rowCount())
-
- def _remove_item(self, data, category, column):
- """Helper function for on_quickmark_removed and on_bookmark_removed.
-
- Args:
- data: The item to search for.
- category: The category to search in.
- column: The column to use for matching.
- """
- for i in range(category.rowCount()):
- item = category.child(i, column)
- if item.data(Qt.DisplayRole) == data:
- category.removeRow(i)
- break
-
- @pyqtSlot(str)
- def on_quickmark_removed(self, name):
- """Called when a quickmark has been removed by the user.
-
- Args:
- name: The name of the quickmark which has been removed.
- """
- self._remove_item(name, self._quickmark_cat, self.TEXT_COLUMN)
-
- @pyqtSlot(str)
- def on_bookmark_removed(self, url):
- """Called when a bookmark has been removed by the user.
-
- Args:
- url: The url of the bookmark which has been removed.
- """
- self._remove_item(url, self._bookmark_cat, self.URL_COLUMN)
-
- def delete_cur_item(self, completion):
- """Delete the selected item.
-
- Args:
- completion: The Completion object to use.
- """
- index = completion.currentIndex()
- qtutils.ensure_valid(index)
- category = index.parent()
- index = category.child(index.row(), self.URL_COLUMN)
- url = index.data()
- qtutils.ensure_valid(category)
-
- if category.data() == 'Bookmarks':
- bookmark_manager = objreg.get('bookmark-manager')
- bookmark_manager.delete(url)
- elif category.data() == 'Quickmarks':
- quickmark_manager = objreg.get('quickmark-manager')
- sibling = index.sibling(index.row(), self.TEXT_COLUMN)
- qtutils.ensure_valid(sibling)
- name = sibling.data()
- quickmark_manager.delete(name)
+ if config.val.completion.web_history_max_items != 0:
+ hist_cat = histcategory.HistoryCategory(delete_func=_delete_history)
+ model.add_category(hist_cat)
+ return model
diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py
index 049f0898b..a99f610b4 100644
--- a/qutebrowser/config/config.py
+++ b/qutebrowser/config/config.py
@@ -29,7 +29,7 @@ from qutebrowser.config import configdata, configexc, configtypes, configfiles
from qutebrowser.utils import utils, objreg, message, log, usertypes
from qutebrowser.misc import objects
from qutebrowser.commands import cmdexc, cmdutils
-
+from qutebrowser.completion.models import configmodel
# An easy way to access the config from other code via config.val.foo
val = None
@@ -229,6 +229,7 @@ class ConfigCommands:
self._keyconfig = keyconfig
@cmdutils.register(instance='config-commands', star_args_optional=True)
+ @cmdutils.argument('option', completion=configmodel.option)
@cmdutils.argument('win_id', win_id=True)
def set(self, win_id, option=None, *values, temp=False, print_=False):
"""Set an option.
diff --git a/qutebrowser/html/backend-warning.html b/qutebrowser/html/backend-warning.html
index ffff0e59b..2b631d6a5 100644
--- a/qutebrowser/html/backend-warning.html
+++ b/qutebrowser/html/backend-warning.html
@@ -70,6 +70,8 @@ the <span class="mono">qute://settings</span> page or caret browsing).</span>
{{ install_webengine('qt5-qtwebengine') }}
{% elif distribution.parsed == Distribution.opensuse %}
{{ install_webengine('libqt5-qtwebengine') }}
+{% elif distribution.parsed == Distribution.gentoo %}
+ {{ install_webengine('dev-qt/qtwebengine') }}
{% else %}
{{ unknown_system() }}
{% endif %}
diff --git a/qutebrowser/html/error.html b/qutebrowser/html/error.html
index 06261a06a..615e4ba8b 100644
--- a/qutebrowser/html/error.html
+++ b/qutebrowser/html/error.html
@@ -61,7 +61,7 @@ li {
{{ super() }}
function tryagain()
{
- location.href = url;
+ location.href = "{{ url }}";
}
{% endblock %}
diff --git a/qutebrowser/javascript/history.js b/qutebrowser/javascript/history.js
index 12fa8f014..edc51ae01 100644
--- a/qutebrowser/javascript/history.js
+++ b/qutebrowser/javascript/history.js
@@ -23,8 +23,12 @@ window.loadHistory = (function() {
// Date of last seen item.
var lastItemDate = null;
- // The time to load next.
+ // Each request for new items includes the time of the last item and an
+ // offset. The offset is equal to the number of items from the previous
+ // request that had time=nextTime, and causes the next request to skip
+ // those items to avoid duplicates.
var nextTime = null;
+ var nextOffset = 0;
// The URL to fetch data from.
var DATA_URL = "qute://history/data";
@@ -157,23 +161,28 @@ window.loadHistory = (function() {
return;
}
- for (var i = 0, len = history.length - 1; i < len; i++) {
+ if (history.length === 0) {
+ // Reached end of history
+ window.onscroll = null;
+ EOF_MESSAGE.style.display = "block";
+ LOAD_LINK.style.display = "none";
+ return;
+ }
+
+ nextTime = history[history.length - 1].time;
+ nextOffset = 0;
+
+ for (var i = 0, len = history.length; i < len; i++) {
var item = history[i];
- var currentItemDate = new Date(item.time);
+ // python's time.time returns seconds, but js Date expects ms
+ var currentItemDate = new Date(item.time * 1000);
getSessionNode(currentItemDate).appendChild(makeHistoryRow(
item.url, item.title, currentItemDate.toLocaleTimeString()
));
lastItemDate = currentItemDate;
- }
-
- var next = history[history.length - 1].next;
- if (next === -1) {
- // Reached end of history
- window.onscroll = null;
- EOF_MESSAGE.style.display = "block";
- LOAD_LINK.style.display = "none";
- } else {
- nextTime = next;
+ if (item.time === nextTime) {
+ nextOffset++;
+ }
}
}
@@ -182,10 +191,11 @@ window.loadHistory = (function() {
* @return {void}
*/
function loadHistory() {
+ var url = DATA_URL.concat("?offset=", nextOffset.toString());
if (nextTime === null) {
- getJSON(DATA_URL, receiveHistory);
+ getJSON(url, receiveHistory);
} else {
- var url = DATA_URL.concat("?start_time=", nextTime.toString());
+ url = url.concat("&start_time=", nextTime.toString());
getJSON(url, receiveHistory);
}
}
diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py
index cc060e911..465c1e13d 100644
--- a/qutebrowser/mainwindow/mainwindow.py
+++ b/qutebrowser/mainwindow/mainwindow.py
@@ -123,6 +123,7 @@ class MainWindow(QWidget):
Attributes:
status: The StatusBar widget.
tabbed_browser: The TabbedBrowser widget.
+ state_before_fullscreen: window state before activation of fullscreen.
_downloadview: The DownloadView widget.
_vbox: The main QVBoxLayout.
_commandrunner: The main CommandRunner instance.
@@ -217,6 +218,8 @@ class MainWindow(QWidget):
objreg.get("app").new_window.emit(self)
+ self.state_before_fullscreen = self.windowState()
+
def _init_geometry(self, geometry):
"""Initialize the window geometry or load it from disk."""
if geometry is not None:
@@ -461,6 +464,8 @@ class MainWindow(QWidget):
tabs.tab_index_changed.connect(status.tabindex.on_tab_index_changed)
tabs.cur_url_changed.connect(status.url.set_url)
+ tabs.cur_url_changed.connect(functools.partial(
+ status.backforward.on_tab_cur_url_changed, tabs=tabs))
tabs.cur_link_hovered.connect(status.url.set_hover_url)
tabs.cur_load_status_changed.connect(status.url.on_load_status_changed)
tabs.cur_fullscreen_requested.connect(self._on_fullscreen_requested)
@@ -475,9 +480,12 @@ class MainWindow(QWidget):
@pyqtSlot(bool)
def _on_fullscreen_requested(self, on):
if on:
+ self.state_before_fullscreen = self.windowState()
self.showFullScreen()
- else:
- self.showNormal()
+ elif self.isFullScreen():
+ self.setWindowState(self.state_before_fullscreen)
+ log.misc.debug('on: {}, state before fullscreen: {}'.format(
+ on, debug.qflags_key(Qt, self.state_before_fullscreen)))
@cmdutils.register(instance='main-window', scope='window')
@pyqtSlot()
diff --git a/qutebrowser/mainwindow/messageview.py b/qutebrowser/mainwindow/messageview.py
index d485ae9c1..e83efda0a 100644
--- a/qutebrowser/mainwindow/messageview.py
+++ b/qutebrowser/mainwindow/messageview.py
@@ -99,8 +99,10 @@ class MessageView(QWidget):
@config.change_filter('messages.timeout')
def _set_clear_timer_interval(self):
"""Configure self._clear_timer according to the config."""
- if config.val.messages.timeout != 0:
- self._clear_timer.setInterval(config.val.messages.timeout)
+ interval = config.val.messages.timeout
+ if interval > 0:
+ interval *= min(5, len(self._messages))
+ self._clear_timer.setInterval(interval)
@pyqtSlot()
def clear_messages(self):
@@ -127,12 +129,13 @@ class MessageView(QWidget):
widget = Message(level, text, replace=replace, parent=self)
self._vbox.addWidget(widget)
widget.show()
- if config.val.messages.timeout != 0:
- self._clear_timer.start()
self._messages.append(widget)
self._last_text = text
self.show()
self.update_geometry.emit()
+ if config.val.messages.timeout != 0:
+ self._set_clear_timer_interval()
+ self._clear_timer.start()
def mousePressEvent(self, e):
"""Clear messages when they are clicked on."""
diff --git a/qutebrowser/mainwindow/statusbar/backforward.py b/qutebrowser/mainwindow/statusbar/backforward.py
new file mode 100644
index 000000000..fe044e621
--- /dev/null
+++ b/qutebrowser/mainwindow/statusbar/backforward.py
@@ -0,0 +1,49 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Navigation (back/forward) indicator displayed in the statusbar."""
+
+from qutebrowser.mainwindow.statusbar import textbase
+
+
+class Backforward(textbase.TextBase):
+
+ """Shows navigation indicator (if you can go backward and/or forward)."""
+
+ def on_tab_cur_url_changed(self, tabs):
+ """Called on URL changes."""
+ tab = tabs.currentWidget()
+ if tab is None: # pragma: no cover
+ # WORKAROUND: Doesn't get tested on older PyQt
+ self.setText('')
+ self.hide()
+ return
+ self.on_tab_changed(tab)
+
+ def on_tab_changed(self, tab):
+ """Update the text based on the given tab."""
+ text = ''
+ if tab.history.can_go_back():
+ text += '<'
+ if tab.history.can_go_forward():
+ text += '>'
+ if text:
+ text = '[' + text + ']'
+ self.setText(text)
+ self.setVisible(bool(text))
diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py
index df26d8de1..ca75be1ac 100644
--- a/qutebrowser/mainwindow/statusbar/bar.py
+++ b/qutebrowser/mainwindow/statusbar/bar.py
@@ -25,8 +25,9 @@ from PyQt5.QtWidgets import QWidget, QHBoxLayout, QStackedLayout, QSizePolicy
from qutebrowser.browser import browsertab
from qutebrowser.config import config
from qutebrowser.utils import usertypes, log, objreg, utils
-from qutebrowser.mainwindow.statusbar import (command, progress, keystring,
- percentage, url, tabindex)
+from qutebrowser.mainwindow.statusbar import (backforward, command, progress,
+ keystring, percentage, url,
+ tabindex)
from qutebrowser.mainwindow.statusbar import text as textwidget
@@ -184,6 +185,9 @@ class StatusBar(QWidget):
self.percentage = percentage.Percentage()
self._hbox.addWidget(self.percentage)
+ self.backforward = backforward.Backforward()
+ self._hbox.addWidget(self.backforward)
+
self.tabindex = tabindex.TabIndex()
self._hbox.addWidget(self.tabindex)
@@ -329,6 +333,7 @@ class StatusBar(QWidget):
self.url.on_tab_changed(tab)
self.prog.on_tab_changed(tab)
self.percentage.on_tab_changed(tab)
+ self.backforward.on_tab_changed(tab)
self.maybe_hide()
assert tab.private == self._color_flags.private
diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py
index 66ee899b1..72d700893 100644
--- a/qutebrowser/mainwindow/tabbedbrowser.py
+++ b/qutebrowser/mainwindow/tabbedbrowser.py
@@ -23,7 +23,7 @@ import functools
import collections
from PyQt5.QtWidgets import QSizePolicy
-from PyQt5.QtCore import pyqtSignal, pyqtSlot, QTimer, QUrl, QSize
+from PyQt5.QtCore import pyqtSignal, pyqtSlot, QTimer, QUrl
from PyQt5.QtGui import QIcon
from qutebrowser.config import config
@@ -239,6 +239,19 @@ class TabbedBrowser(tabwidget.TabWidget):
for tab in self.widgets():
self._remove_tab(tab)
+ def tab_close_prompt_if_pinned(self, tab, force, yes_action):
+ """Helper method for tab_close.
+
+ If tab is pinned, prompt. If everything is good, run yes_action.
+ """
+ if tab.data.pinned and not force:
+ message.confirm_async(
+ title='Pinned Tab',
+ text="Are you sure you want to close a pinned tab?",
+ yes_action=yes_action, default=False)
+ else:
+ yes_action()
+
def close_tab(self, tab, *, add_undo=True):
"""Close a tab.
@@ -348,7 +361,7 @@ class TabbedBrowser(tabwidget.TabWidget):
newtab = self.tabopen(url, background=False, idx=idx)
newtab.history.deserialize(history_data)
- self.set_tab_pinned(idx, pinned)
+ self.set_tab_pinned(newtab, pinned)
@pyqtSlot('QUrl', bool)
def openurl(self, url, newtab):
@@ -372,7 +385,8 @@ class TabbedBrowser(tabwidget.TabWidget):
log.webview.debug("Got invalid tab {} for index {}!".format(
tab, idx))
return
- self.close_tab(tab)
+ self.tab_close_prompt_if_pinned(
+ tab, False, lambda: self.close_tab(tab))
@pyqtSlot(browsertab.AbstractTab)
def on_window_close_requested(self, widget):
@@ -443,13 +457,7 @@ class TabbedBrowser(tabwidget.TabWidget):
# Make sure the background tab has the correct initial size.
# With a foreground tab, it's going to be resized correctly by the
# layout anyways.
- if self.tabBar().vertical:
- tab_size = QSize(self.width() - self.tabBar().width(),
- self.height())
- else:
- tab_size = QSize(self.width(),
- self.height() - self.tabBar().height())
- tab.resize(tab_size)
+ tab.resize(self.currentWidget().size())
self.tab_index_changed.emit(self.currentIndex(), self.count())
else:
self.setCurrentWidget(tab)
@@ -705,12 +713,16 @@ class TabbedBrowser(tabwidget.TabWidget):
}
msg = messages[status]
+ def show_error_page(html):
+ tab.set_html(html)
+ log.webview.error(msg)
+
if qtutils.version_check('5.9'):
url_string = tab.url(requested=True).toDisplayString()
error_page = jinja.render(
'error.html', title="Error loading {}".format(url_string),
- url=url_string, error=msg)
- QTimer.singleShot(0, lambda: tab.set_html(error_page))
+ url=url_string, error=msg, icon='')
+ QTimer.singleShot(100, lambda: show_error_page(error_page))
log.webview.error(msg)
else:
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-58698
diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py
index 476e6752e..2e5c0856a 100644
--- a/qutebrowser/mainwindow/tabwidget.py
+++ b/qutebrowser/mainwindow/tabwidget.py
@@ -26,7 +26,7 @@ from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QSize, QRect, QPoint,
QTimer, QUrl)
from PyQt5.QtWidgets import (QTabWidget, QTabBar, QSizePolicy, QCommonStyle,
QStyle, QStylePainter, QStyleOptionTab,
- QStyleFactory)
+ QStyleFactory, QWidget)
from PyQt5.QtGui import QIcon, QPalette, QColor
from qutebrowser.utils import qtutils, objreg, utils, usertypes, log
@@ -94,17 +94,18 @@ class TabWidget(QTabWidget):
bar.set_tab_data(idx, 'indicator-color', color)
bar.update(bar.tabRect(idx))
- def set_tab_pinned(self, idx, pinned, *, loading=False):
+ def set_tab_pinned(self, tab: QWidget,
+ pinned: bool, *, loading: bool = False) -> None:
"""Set the tab status as pinned.
Args:
- idx: The tab index.
+ 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()
- tab = self.widget(idx)
+ idx = self.indexOf(tab)
# Only modify pinned_count if we had a change
# always modify pinned_count if we are loading
@@ -487,14 +488,10 @@ class TabBar(QTabBar):
width = int(confwidth)
size = QSize(max(minimum_size.width(), width), height)
elif self.count() == 0:
- # This happens on startup on OS X.
+ # This happens on startup on macOS.
# We return it directly rather than setting `size' because we don't
# want to ensure it's valid in this special case.
return QSize()
- elif self.count() * minimum_size.width() > self.width():
- # If we don't have enough space, we return the minimum size so we
- # get scroll buttons as soon as needed.
- size = minimum_size
else:
try:
pinned = self.tab_data(index, 'pinned')
@@ -522,13 +519,13 @@ class TabBar(QTabBar):
width = no_pinned_width / (self.count() - self.pinned_count)
else:
- # 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
+ # 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.
+ # 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:
@@ -540,6 +537,10 @@ class TabBar(QTabBar):
index < no_pinned_width % no_pinned_count):
width += 1
+ # If we don't have enough space, we return the minimum size so we
+ # get scroll buttons as soon as needed.
+ width = max(width, minimum_size.width())
+
size = QSize(width, height)
qtutils.ensure_valid(size)
return size
@@ -750,6 +751,17 @@ class TabBarStyle(QCommonStyle):
rct = super().subElementRect(sr, opt, widget)
return rct
else:
+ try:
+ # We need this so the left scroll button is aligned properly.
+ # Otherwise, empty space will be shown after the last tab even
+ # though the button width is set to 0
+ #
+ # QStyle.SE_TabBarScrollLeftButton was added in Qt 5.7
+ if sr == QStyle.SE_TabBarScrollLeftButton:
+ return super().subElementRect(sr, opt, widget)
+ except AttributeError:
+ pass
+
return self._style.subElementRect(sr, opt, widget)
def _tab_layout(self, opt):
diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py
index 5f161312a..e51855af2 100644
--- a/qutebrowser/misc/crashsignal.py
+++ b/qutebrowser/misc/crashsignal.py
@@ -28,6 +28,11 @@ import functools
import faulthandler
import os.path
import collections
+try:
+ # WORKAROUND for segfaults when using pdb in pytest for some reason...
+ import readline # pylint: disable=unused-import
+except ImportError:
+ pass
from PyQt5.QtCore import (pyqtSlot, qInstallMessageHandler, QObject,
QSocketNotifier, QTimer, QUrl)
diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py
index 4993d6927..a64a2799b 100644
--- a/qutebrowser/misc/earlyinit.py
+++ b/qutebrowser/misc/earlyinit.py
@@ -337,12 +337,12 @@ def check_libraries(backend):
"or Install via pip.",
pip="PyYAML"),
'PyQt5.QtQml': _missing_str("PyQt5.QtQml"),
+ 'PyQt5.QtSql': _missing_str("PyQt5.QtSql"),
}
if backend == 'webengine':
modules['PyQt5.QtWebEngineWidgets'] = _missing_str("QtWebEngine",
webengine=True)
modules['PyQt5.QtOpenGL'] = _missing_str("PyQt5.QtOpenGL")
- modules['OpenGL'] = _missing_str("PyOpenGL")
else:
assert backend == 'webkit'
modules['PyQt5.QtWebKit'] = _missing_str("PyQt5.QtWebKit")
diff --git a/qutebrowser/misc/guiprocess.py b/qutebrowser/misc/guiprocess.py
index e8d224f2e..95bfac79e 100644
--- a/qutebrowser/misc/guiprocess.py
+++ b/qutebrowser/misc/guiprocess.py
@@ -154,8 +154,8 @@ class GUIProcess(QObject):
log.procs.debug("Process started.")
self._started = True
else:
- message.error("Error while spawning {}: {}.".format(
- self._what, self._proc.error()))
+ message.error("Error while spawning {}: {}".format(
+ self._what, ERROR_STRINGS[self._proc.error()]))
def exit_status(self):
return self._proc.exitStatus()
diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py
index eb9aa4a3b..562cc84cc 100644
--- a/qutebrowser/misc/ipc.py
+++ b/qutebrowser/misc/ipc.py
@@ -20,7 +20,6 @@
"""Utilities for IPC with existing instances."""
import os
-import sys
import time
import json
import getpass
@@ -41,8 +40,8 @@ ATIME_INTERVAL = 60 * 60 * 3 * 1000 # 3 hours
PROTOCOL_VERSION = 1
-def _get_socketname_legacy(basedir):
- """Legacy implementation of _get_socketname."""
+def _get_socketname_windows(basedir):
+ """Get a socketname to use for Windows."""
parts = ['qutebrowser', getpass.getuser()]
if basedir is not None:
md5 = hashlib.md5(basedir.encode('utf-8')).hexdigest()
@@ -50,10 +49,10 @@ def _get_socketname_legacy(basedir):
return '-'.join(parts)
-def _get_socketname(basedir, legacy=False):
+def _get_socketname(basedir):
"""Get a socketname to use."""
- if legacy or os.name == 'nt':
- return _get_socketname_legacy(basedir)
+ if os.name == 'nt': # pragma: no cover
+ return _get_socketname_windows(basedir)
parts_to_hash = [getpass.getuser()]
if basedir is not None:
@@ -415,41 +414,7 @@ class IPCServer(QObject):
self._remove_server()
-def _has_legacy_server(name):
- """Check if there is a legacy server.
-
- Args:
- name: The name to try to connect to.
-
- Return:
- True if there is a server with the given name, False otherwise.
- """
- socket = QLocalSocket()
- log.ipc.debug("Trying to connect to {}".format(name))
- socket.connectToServer(name)
-
- err = socket.error()
-
- if err != QLocalSocket.UnknownSocketError:
- log.ipc.debug("Socket error: {} ({})".format(
- socket.errorString(), err))
-
- os_x_fail = (sys.platform == 'darwin' and
- socket.errorString() == 'QLocalSocket::connectToServer: '
- 'Unknown error 38')
-
- if err not in [QLocalSocket.ServerNotFoundError,
- QLocalSocket.ConnectionRefusedError] and not os_x_fail:
- return True
-
- socket.disconnectFromServer()
- if socket.state() != QLocalSocket.UnconnectedState:
- socket.waitForDisconnected(CONNECT_TIMEOUT)
- return False
-
-
-def send_to_running_instance(socketname, command, target_arg, *,
- legacy_name=None, socket=None):
+def send_to_running_instance(socketname, command, target_arg, *, socket=None):
"""Try to send a commandline to a running instance.
Blocks for CONNECT_TIMEOUT ms.
@@ -459,7 +424,6 @@ def send_to_running_instance(socketname, command, target_arg, *,
command: The command to send to the running instance.
target_arg: --target command line argument
socket: The socket to read data from, or None.
- legacy_name: The legacy name to first try to connect to.
Return:
True if connecting was successful, False if no connection was made.
@@ -467,13 +431,8 @@ def send_to_running_instance(socketname, command, target_arg, *,
if socket is None:
socket = QLocalSocket()
- if legacy_name is not None and _has_legacy_server(legacy_name):
- name_to_use = legacy_name
- else:
- name_to_use = socketname
-
- log.ipc.debug("Connecting to {}".format(name_to_use))
- socket.connectToServer(name_to_use)
+ log.ipc.debug("Connecting to {}".format(socketname))
+ socket.connectToServer(socketname)
connected = socket.waitForConnected(CONNECT_TIMEOUT)
if connected:
@@ -527,12 +486,10 @@ def send_or_listen(args):
None if an instance was running and received our request.
"""
socketname = _get_socketname(args.basedir)
- legacy_socketname = _get_socketname(args.basedir, legacy=True)
try:
try:
sent = send_to_running_instance(socketname, args.command,
- args.target,
- legacy_name=legacy_socketname)
+ args.target)
if sent:
return None
log.init.debug("Starting IPC server...")
@@ -545,8 +502,7 @@ def send_or_listen(args):
log.init.debug("Got AddressInUseError, trying again.")
time.sleep(0.5)
sent = send_to_running_instance(socketname, args.command,
- args.target,
- legacy_name=legacy_socketname)
+ args.target)
if sent:
return None
else:
diff --git a/qutebrowser/misc/lineparser.py b/qutebrowser/misc/lineparser.py
index bd07f7903..155cbd1b0 100644
--- a/qutebrowser/misc/lineparser.py
+++ b/qutebrowser/misc/lineparser.py
@@ -21,7 +21,6 @@
import os
import os.path
-import itertools
import contextlib
from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject
@@ -96,7 +95,7 @@ class BaseLineParser(QObject):
"""
assert self._configfile is not None
if self._opened:
- raise IOError("Refusing to double-open AppendLineParser.")
+ raise IOError("Refusing to double-open LineParser.")
self._opened = True
try:
if self._binary:
@@ -133,73 +132,6 @@ class BaseLineParser(QObject):
raise NotImplementedError
-class AppendLineParser(BaseLineParser):
-
- """LineParser which reads lazily and appends data to existing one.
-
- Attributes:
- _new_data: The data which was added in this session.
- """
-
- def __init__(self, configdir, fname, *, parent=None):
- super().__init__(configdir, fname, binary=False, parent=parent)
- self.new_data = []
- self._fileobj = None
-
- def __iter__(self):
- if self._fileobj is None:
- raise ValueError("Iterating without open() being called!")
- file_iter = (line.rstrip('\n') for line in self._fileobj)
- return itertools.chain(file_iter, iter(self.new_data))
-
- @contextlib.contextmanager
- def open(self):
- """Open the on-disk history file. Needed for __iter__."""
- try:
- with self._open('r') as f:
- self._fileobj = f
- yield
- except FileNotFoundError:
- self._fileobj = []
- yield
- finally:
- self._fileobj = None
-
- def get_recent(self, count=4096):
- """Get the last count bytes from the underlying file."""
- with self._open('r') as f:
- f.seek(0, os.SEEK_END)
- size = f.tell()
- try:
- if size - count > 0:
- offset = size - count
- else:
- offset = 0
- f.seek(offset)
- data = f.readlines()
- finally:
- f.seek(0, os.SEEK_END)
- return data
-
- def save(self):
- do_save = self._prepare_save()
- if not do_save:
- return
- with self._open('a') as f:
- self._write(f, self.new_data)
- self.new_data = []
- self._after_save()
-
- def clear(self):
- do_save = self._prepare_save()
- if not do_save:
- return
- with self._open('w'):
- pass
- self.new_data = []
- self._after_save()
-
-
class LineParser(BaseLineParser):
"""Parser for configuration files which are simply line-based.
@@ -240,7 +172,7 @@ class LineParser(BaseLineParser):
def save(self):
"""Save the config file."""
if self._opened:
- raise IOError("Refusing to double-open AppendLineParser.")
+ raise IOError("Refusing to double-open LineParser.")
do_save = self._prepare_save()
if not do_save:
return
diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py
index db34a821a..94e9fd8f9 100644
--- a/qutebrowser/misc/sessions.py
+++ b/qutebrowser/misc/sessions.py
@@ -23,14 +23,15 @@ import os
import os.path
import sip
-from PyQt5.QtCore import pyqtSignal, QUrl, QObject, QPoint, QTimer
+from PyQt5.QtCore import QUrl, QObject, QPoint, QTimer
from PyQt5.QtWidgets import QApplication
import yaml
-from qutebrowser.utils import (standarddir, objreg, qtutils, log, usertypes,
- message, utils)
+from qutebrowser.utils import (standarddir, objreg, qtutils, log, message,
+ utils)
from qutebrowser.commands import cmdexc, cmdutils
from qutebrowser.config import config
+from qutebrowser.completion.models import miscmodels
default = object() # Sentinel value
@@ -101,14 +102,8 @@ class SessionManager(QObject):
closed.
_current: The name of the currently loaded session, or None.
did_load: Set when a session was loaded.
-
- Signals:
- update_completion: Emitted when the session completion should get
- updated.
"""
- update_completion = pyqtSignal()
-
def __init__(self, base_path, parent=None):
super().__init__(parent)
self._current = None
@@ -297,8 +292,7 @@ class SessionManager(QObject):
utils.yaml_dump(data, f)
except (OSError, UnicodeEncodeError, yaml.YAMLError) as e:
raise SessionError(e)
- else:
- self.update_completion.emit()
+
if load_next_time:
state_config = objreg.get('state-config')
state_config['general']['session'] = name
@@ -401,7 +395,7 @@ class SessionManager(QObject):
tab_to_focus = i
if new_tab.data.pinned:
tabbed_browser.set_tab_pinned(
- i, new_tab.data.pinned, loading=True)
+ new_tab, new_tab.data.pinned, loading=True)
if tab_to_focus is not None:
tabbed_browser.setCurrentIndex(tab_to_focus)
if win.get('active', False):
@@ -419,7 +413,6 @@ class SessionManager(QObject):
os.remove(path)
except OSError as e:
raise SessionError(e)
- self.update_completion.emit()
def list_sessions(self):
"""Get a list of all session names."""
@@ -431,7 +424,7 @@ class SessionManager(QObject):
return sessions
@cmdutils.register(instance='session-manager')
- @cmdutils.argument('name', completion=usertypes.Completion.sessions)
+ @cmdutils.argument('name', completion=miscmodels.session)
def session_load(self, name, clear=False, temp=False, force=False):
"""Load a session.
@@ -459,7 +452,7 @@ class SessionManager(QObject):
win.close()
@cmdutils.register(instance='session-manager')
- @cmdutils.argument('name', completion=usertypes.Completion.sessions)
+ @cmdutils.argument('name', completion=miscmodels.session)
@cmdutils.argument('win_id', win_id=True)
@cmdutils.argument('with_private', flag='p')
def session_save(self, name: str = default, current=False, quiet=False,
@@ -498,7 +491,7 @@ class SessionManager(QObject):
message.info("Saved session {}.".format(name))
@cmdutils.register(instance='session-manager')
- @cmdutils.argument('name', completion=usertypes.Completion.sessions)
+ @cmdutils.argument('name', completion=miscmodels.session)
def session_delete(self, name, force=False):
"""Delete a session.
diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py
new file mode 100644
index 000000000..a288df475
--- /dev/null
+++ b/qutebrowser/misc/sql.py
@@ -0,0 +1,256 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Provides access to an in-memory sqlite database."""
+
+import collections
+
+from PyQt5.QtCore import QObject, pyqtSignal
+from PyQt5.QtSql import QSqlDatabase, QSqlQuery
+
+from qutebrowser.utils import log
+
+
+class SqlException(Exception):
+
+ """Raised on an error interacting with the SQL database."""
+
+ pass
+
+
+def init(db_path):
+ """Initialize the SQL database connection."""
+ database = QSqlDatabase.addDatabase('QSQLITE')
+ if not database.isValid():
+ raise SqlException('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()))
+
+
+def close():
+ """Close the SQL connection."""
+ QSqlDatabase.removeDatabase(QSqlDatabase.database().connectionName())
+
+
+def version():
+ """Return the sqlite version string."""
+ try:
+ if not QSqlDatabase.database().isOpen():
+ init(':memory:')
+ ver = Query("select sqlite_version()").run().value()
+ close()
+ return ver
+ return Query("select sqlite_version()").run().value()
+ except SqlException as e:
+ return 'UNAVAILABLE ({})'.format(e)
+
+
+class Query(QSqlQuery):
+
+ """A prepared SQL Query."""
+
+ def __init__(self, querystr, forward_only=True):
+ """Prepare a new sql query.
+
+ Args:
+ querystr: String to prepare query from.
+ forward_only: Optimization for queries that will only step forward.
+ Must be false for completion queries.
+ """
+ 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()))
+ self.setForwardOnly(forward_only)
+
+ def __iter__(self):
+ if not self.isActive():
+ raise SqlException("Cannot iterate inactive query")
+ rec = self.record()
+ fields = [rec.fieldName(i) for i in range(rec.count())]
+ rowtype = collections.namedtuple('ResultRow', fields)
+
+ while self.next():
+ rec = self.record()
+ yield rowtype(*[rec.value(i) for i in range(rec.count())])
+
+ def run(self, **values):
+ """Execute the prepared query."""
+ log.sql.debug('Running SQL query: "{}"'.format(self.lastQuery()))
+ for key, val in values.items():
+ 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()))
+ 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")
+ return self.record().value(0)
+
+
+class SqlTable(QObject):
+
+ """Interface to a sql table.
+
+ Attributes:
+ _name: Name of the SQL table this wraps.
+
+ Signals:
+ changed: Emitted when the table is modified.
+ """
+
+ changed = pyqtSignal()
+
+ def __init__(self, name, fields, constraints=None, parent=None):
+ """Create a new table in the sql database.
+
+ Raises SqlException if the table already exists.
+
+ Args:
+ name: Name of the table.
+ fields: A list of field names.
+ constraints: A dict mapping field names to constraint strings.
+ """
+ super().__init__(parent)
+ self._name = name
+
+ constraints = constraints or {}
+ column_defs = ['{} {}'.format(field, constraints.get(field, ''))
+ for field in fields]
+ q = Query("CREATE TABLE IF NOT EXISTS {name} ({column_defs})"
+ .format(name=name, column_defs=', '.join(column_defs)))
+
+ q.run()
+
+ def create_index(self, name, field):
+ """Create an index over this table.
+
+ Args:
+ name: Name of the index, should be unique.
+ field: Name of the field to index.
+ """
+ q = Query("CREATE INDEX IF NOT EXISTS {name} ON {table} ({field})"
+ .format(name=name, table=self._name, field=field))
+ q.run()
+
+ def __iter__(self):
+ """Iterate rows in the table."""
+ q = Query("SELECT * FROM {table}".format(table=self._name))
+ q.run()
+ return iter(q)
+
+ def contains_query(self, field):
+ """Return a prepared query that checks for the existence of an item.
+
+ Args:
+ field: Field to match.
+ """
+ return Query(
+ "SELECT EXISTS(SELECT * FROM {table} WHERE {field} = :val)"
+ .format(table=self._name, field=field))
+
+ def __len__(self):
+ """Return the count of rows in the table."""
+ q = Query("SELECT count(*) FROM {table}".format(table=self._name))
+ q.run()
+ return q.value()
+
+ def delete(self, field, value):
+ """Remove all rows for which `field` equals `value`.
+
+ Args:
+ field: Field to use as the key.
+ value: Key value to delete.
+
+ Return:
+ The number of rows deleted.
+ """
+ q = Query("DELETE FROM {table} where {field} = :val"
+ .format(table=self._name, field=field))
+ q.run(val=value)
+ if not q.numRowsAffected():
+ raise KeyError('No row with {} = "{}"'.format(field, value))
+ self.changed.emit()
+
+ def _insert_query(self, values, replace):
+ params = ', '.join(':{}'.format(key) for key in values)
+ verb = "REPLACE" if replace else "INSERT"
+ return Query("{verb} INTO {table} ({columns}) values({params})".format(
+ verb=verb, table=self._name, columns=', '.join(values),
+ params=params))
+
+ def insert(self, values, replace=False):
+ """Append a row to the table.
+
+ Args:
+ values: A dict with a value to insert for each field name.
+ replace: If set, replace existing values.
+ """
+ q = self._insert_query(values, replace)
+ q.run(**values)
+ self.changed.emit()
+
+ def insert_batch(self, values, replace=False):
+ """Performantly append multiple rows to the table.
+
+ Args:
+ values: A dict with a list of values to insert for each field name.
+ replace: If true, overwrite rows with a primary key match.
+ """
+ q = self._insert_query(values, replace)
+ for key, val in values.items():
+ q.bindValue(':{}'.format(key), val)
+
+ db = QSqlDatabase.database()
+ db.transaction()
+ if not q.execBatch():
+ raise SqlException('Failed to exec query "{}": "{}"'.format(
+ q.lastQuery(), q.lastError().text()))
+ db.commit()
+ self.changed.emit()
+
+ def delete_all(self):
+ """Remove all rows from the table."""
+ Query("DELETE FROM {table}".format(table=self._name)).run()
+ self.changed.emit()
+
+ def select(self, sort_by, sort_order, limit=-1):
+ """Prepare, run, and return a select statement on this table.
+
+ Args:
+ sort_by: name of column to sort by.
+ sort_order: 'asc' or 'desc'.
+ limit: max number of rows in result, defaults to -1 (unlimited).
+
+ Return: A prepared and executed select query.
+ """
+ q = Query("SELECT * FROM {table} ORDER BY {sort_by} {sort_order} "
+ "LIMIT :limit"
+ .format(table=self._name, sort_by=sort_by,
+ sort_order=sort_order))
+ q.run(limit=limit)
+ return q
diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py
index c2abbfb87..6cdc61f41 100644
--- a/qutebrowser/utils/log.py
+++ b/qutebrowser/utils/log.py
@@ -94,7 +94,7 @@ LOGGER_NAMES = [
'commands', 'signals', 'downloads',
'js', 'qt', 'rfc6266', 'ipc', 'shlexer',
'save', 'message', 'config', 'sessions',
- 'webelem', 'prompt', 'network'
+ 'webelem', 'prompt', 'network', 'sql'
]
@@ -141,6 +141,7 @@ sessions = logging.getLogger('sessions')
webelem = logging.getLogger('webelem')
prompt = logging.getLogger('prompt')
network = logging.getLogger('network')
+sql = logging.getLogger('sql')
ram_handler = None
diff --git a/qutebrowser/utils/standarddir.py b/qutebrowser/utils/standarddir.py
index a1cedddd0..28d84764f 100644
--- a/qutebrowser/utils/standarddir.py
+++ b/qutebrowser/utils/standarddir.py
@@ -107,7 +107,7 @@ def runtime():
if sys.platform.startswith('linux'):
typ = QStandardPaths.RuntimeLocation
else: # pragma: no cover
- # RuntimeLocation is a weird path on OS X and Windows.
+ # RuntimeLocation is a weird path on macOS and Windows.
typ = QStandardPaths.TempLocation
overridden, path = _from_args(typ, _args)
diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py
index 7d31ba6ac..31f2f79cb 100644
--- a/qutebrowser/utils/usertypes.py
+++ b/qutebrowser/utils/usertypes.py
@@ -236,13 +236,6 @@ KeyMode = enum('KeyMode', ['normal', 'hint', 'command', 'yesno', 'prompt',
'jump_mark', 'record_macro', 'run_macro'])
-# Available command completions
-Completion = enum('Completion', ['command', 'section', 'option', 'value',
- 'helptopic', 'quickmark_by_name',
- 'bookmark_by_url', 'url', 'tab', 'sessions',
- 'bind'])
-
-
# Exit statuses for errors. Needs to be an int for sys.exit.
Exit = enum('Exit', ['ok', 'reserved', 'exception', 'err_ipc', 'err_init',
'err_config', 'err_key_config'], is_int=True, start=0)
diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py
index 13b432c6a..e795cfdd8 100644
--- a/qutebrowser/utils/utils.py
+++ b/qutebrowser/utils/utils.py
@@ -28,7 +28,6 @@ import os.path
import collections
import functools
import contextlib
-import itertools
import socket
import shlex
@@ -378,8 +377,8 @@ def keyevent_to_string(e):
None if only modifiers are pressed..
"""
if sys.platform == 'darwin':
- # Qt swaps Ctrl/Meta on OS X, so we switch it back here so the user can
- # use it in the config as expected. See:
+ # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the user
+ # can use it in the config as expected. See:
# https://github.com/qutebrowser/qutebrowser/issues/110
# http://doc.qt.io/qt-5.4/osx-issues.html#special-keys
modmask2str = collections.OrderedDict([
@@ -742,25 +741,6 @@ def sanitize_filename(name, replacement='_'):
return name
-def newest_slice(iterable, count):
- """Get an iterable for the n newest items of the given iterable.
-
- Args:
- count: How many elements to get.
- 0: get no items:
- n: get the n newest items
- -1: get all items
- """
- if count < -1:
- raise ValueError("count can't be smaller than -1!")
- elif count == 0:
- return []
- elif count == -1 or len(iterable) < count:
- return iterable
- else:
- return itertools.islice(iterable, len(iterable) - count, len(iterable))
-
-
def set_clipboard(data, selection=False):
"""Set the clipboard to some given data."""
if selection and not supports_selection():
diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py
index e1cc26e64..0b650a97e 100644
--- a/qutebrowser/utils/version.py
+++ b/qutebrowser/utils/version.py
@@ -45,7 +45,7 @@ except ImportError: # pragma: no cover
import qutebrowser
from qutebrowser.utils import log, utils, standarddir, usertypes, qtutils
-from qutebrowser.misc import objects, earlyinit
+from qutebrowser.misc import objects, earlyinit, sql
from qutebrowser.browser import pdfjs
@@ -186,7 +186,6 @@ def _module_versions():
('yaml', ['__version__']),
('cssutils', ['__version__']),
('typing', []),
- ('OpenGL', ['__version__']),
('PyQt5.QtWebEngineWidgets', []),
('PyQt5.QtWebKitWidgets', []),
])
@@ -326,11 +325,11 @@ def version():
lines += _module_versions()
- lines += ['pdf.js: {}'.format(_pdfjs_version())]
-
lines += [
- 'SSL: {}'.format(QSslSocket.sslLibraryVersionString()),
- '',
+ 'pdf.js: {}'.format(_pdfjs_version()),
+ 'sqlite: {}'.format(sql.version()),
+ 'QtNetwork SSL: {}\n'.format(QSslSocket.sslLibraryVersionString()
+ if QSslSocket.supportsSsl() else 'no'),
]
qapp = QApplication.instance()
diff --git a/requirements.txt b/requirements.txt
index cbf9ba407..b2cc93c1f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,4 +7,3 @@ MarkupSafe==1.0
Pygments==2.2.0
pyPEG2==2.15.2
PyYAML==3.12
-PyOpenGL==3.1.0
diff --git a/scripts/asciidoc2html.py b/scripts/asciidoc2html.py
index 39695bd23..6c7fdaf6e 100755
--- a/scripts/asciidoc2html.py
+++ b/scripts/asciidoc2html.py
@@ -280,8 +280,6 @@ def main(colors=False):
"asciidoc.py. If not given, it's searched in PATH.",
nargs=2, required=False,
metavar=('PYTHON', 'ASCIIDOC'))
- parser.add_argument('--no-authors', help=argparse.SUPPRESS,
- action='store_true')
args = parser.parse_args()
try:
os.mkdir('qutebrowser/html/doc')
diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py
index 073b9a58e..d5c03ad02 100755
--- a/scripts/dev/build_release.py
+++ b/scripts/dev/build_release.py
@@ -64,7 +64,7 @@ def call_tox(toxenv, *args, python=sys.executable):
env['PYTHON'] = python
env['PATH'] = os.environ['PATH'] + os.pathsep + os.path.dirname(python)
subprocess.check_call(
- [sys.executable, '-m', 'tox', '-v', '-e', toxenv] + list(args),
+ [sys.executable, '-m', 'tox', '-vv', '-e', toxenv] + list(args),
env=env)
@@ -92,7 +92,7 @@ def smoke_test(executable):
'--temp-basedir', 'about:blank', ':later 500 quit'])
-def patch_osx_app():
+def patch_mac_app():
"""Patch .app to copy missing data and link some libs.
See https://github.com/pyinstaller/pyinstaller/issues/2276
@@ -109,8 +109,11 @@ def patch_osx_app():
for f in glob.glob(os.path.join(qtwe_core_dir, 'Resources', '*')):
dest = os.path.join(app_path, 'Contents', 'Resources')
if os.path.isdir(f):
- shutil.copytree(f, os.path.join(dest, f))
+ dir_dest = os.path.join(dest, os.path.basename(f))
+ print("Copying directory {} to {}".format(f, dir_dest))
+ shutil.copytree(f, dir_dest)
else:
+ print("Copying {} to {}".format(f, dest))
shutil.copy(f, dest)
# Link dependencies
for lib in ['QtCore', 'QtWebEngineCore', 'QtQuick', 'QtQml', 'QtNetwork',
@@ -122,37 +125,45 @@ def patch_osx_app():
os.path.join(dest, lib))
-def build_osx():
- """Build OS X .dmg/.app."""
+def build_mac():
+ """Build macOS .dmg/.app."""
+ utils.print_title("Cleaning up...")
+ for f in ['wc.dmg', 'template.dmg']:
+ try:
+ os.remove(f)
+ except FileNotFoundError:
+ pass
+ for d in ['dist', 'build']:
+ shutil.rmtree(d, ignore_errors=True)
utils.print_title("Updating 3rdparty content")
+ # Currently disabled because QtWebEngine has no pdfjs support
# update_3rdparty.run(ace=False, pdfjs=True, fancy_dmg=False)
utils.print_title("Building .app via pyinstaller")
call_tox('pyinstaller', '-r')
utils.print_title("Patching .app")
- patch_osx_app()
+ patch_mac_app()
utils.print_title("Building .dmg")
subprocess.check_call(['make', '-f', 'scripts/dev/Makefile-dmg'])
- utils.print_title("Cleaning up...")
- for f in ['wc.dmg', 'template.dmg']:
- os.remove(f)
- for d in ['dist', 'build']:
- shutil.rmtree(d)
dmg_name = 'qutebrowser-{}.dmg'.format(qutebrowser.__version__)
os.rename('qutebrowser.dmg', dmg_name)
utils.print_title("Running smoke test")
- with tempfile.TemporaryDirectory() as tmpdir:
- subprocess.check_call(['hdiutil', 'attach', dmg_name,
- '-mountpoint', tmpdir])
- try:
- binary = os.path.join(tmpdir, 'qutebrowser.app', 'Contents',
- 'MacOS', 'qutebrowser')
- smoke_test(binary)
- finally:
- subprocess.check_call(['hdiutil', 'detach', tmpdir])
- return [(dmg_name, 'application/x-apple-diskimage', 'OS X .dmg')]
+ try:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ subprocess.check_call(['hdiutil', 'attach', dmg_name,
+ '-mountpoint', tmpdir])
+ try:
+ binary = os.path.join(tmpdir, 'qutebrowser.app', 'Contents',
+ 'MacOS', 'qutebrowser')
+ smoke_test(binary)
+ finally:
+ subprocess.call(['hdiutil', 'detach', tmpdir])
+ except PermissionError as e:
+ print("Failed to remove tempdir: {}".format(e))
+
+ return [(dmg_name, 'application/x-apple-diskimage', 'macOS .dmg')]
def patch_windows(out_dir):
@@ -167,6 +178,7 @@ def patch_windows(out_dir):
def build_windows():
"""Build windows executables/setups."""
utils.print_title("Updating 3rdparty content")
+ # Currently disabled because QtWebEngine has no pdfjs support
# update_3rdparty.run(ace=False, pdfjs=True, fancy_dmg=False)
utils.print_title("Building Windows binaries")
@@ -203,8 +215,8 @@ def build_windows():
'/DVERSION={}'.format(qutebrowser.__version__),
'misc/qutebrowser.nsi'])
- name_32 = 'qutebrowser-{}-win32.msi'.format(qutebrowser.__version__)
- name_64 = 'qutebrowser-{}-amd64.msi'.format(qutebrowser.__version__)
+ name_32 = 'qutebrowser-{}-win32.exe'.format(qutebrowser.__version__)
+ name_64 = 'qutebrowser-{}-amd64.exe'.format(qutebrowser.__version__)
artifacts += [
(os.path.join('dist', name_32),
@@ -280,6 +292,14 @@ def build_sdist():
return artifacts
+def read_github_token():
+ """Read the GitHub API token from disk."""
+ token_file = os.path.join(os.path.expanduser('~'), '.gh_token')
+ with open(token_file, encoding='ascii') as f:
+ token = f.read().strip()
+ return token
+
+
def github_upload(artifacts, tag):
"""Upload the given artifacts to GitHub.
@@ -290,9 +310,7 @@ def github_upload(artifacts, tag):
import github3
utils.print_title("Uploading to github...")
- token_file = os.path.join(os.path.expanduser('~'), '.gh_token')
- with open(token_file, encoding='ascii') as f:
- token = f.read().strip()
+ token = read_github_token()
gh = github3.login(token=token)
repo = gh.repository('qutebrowser', 'qutebrowser')
@@ -329,6 +347,12 @@ def main():
upload_to_pypi = False
+ if args.upload is not None:
+ # Fail early when trying to upload without github3 installed
+ # or without API token
+ import github3 # pylint: disable=unused-variable
+ read_github_token()
+
if os.name == 'nt':
if sys.maxsize > 2**32:
# WORKAROUND
@@ -342,7 +366,7 @@ def main():
artifacts = build_windows()
elif sys.platform == 'darwin':
run_asciidoc2html(args)
- artifacts = build_osx()
+ artifacts = build_mac()
else:
artifacts = build_sdist()
upload_to_pypi = True
diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py
index 90c142185..815716437 100644
--- a/scripts/dev/check_coverage.py
+++ b/scripts/dev/check_coverage.py
@@ -51,9 +51,9 @@ PERFECT_FILES = [
'browser/webkit/cache.py'),
('tests/unit/browser/webkit/test_cookies.py',
'browser/webkit/cookies.py'),
- ('tests/unit/browser/webkit/test_history.py',
+ ('tests/unit/browser/test_history.py',
'browser/history.py'),
- ('tests/unit/browser/webkit/test_history.py',
+ ('tests/unit/browser/test_history.py',
'browser/webkit/webkithistory.py'),
('tests/unit/browser/webkit/http/test_http.py',
'browser/webkit/http.py'),
@@ -117,6 +117,8 @@ PERFECT_FILES = [
'mainwindow/statusbar/textbase.py'),
('tests/unit/mainwindow/statusbar/test_url.py',
'mainwindow/statusbar/url.py'),
+ ('tests/unit/mainwindow/statusbar/test_backforward.py',
+ 'mainwindow/statusbar/backforward.py'),
('tests/unit/mainwindow/test_messageview.py',
'mainwindow/messageview.py'),
@@ -155,9 +157,11 @@ PERFECT_FILES = [
'utils/javascript.py'),
('tests/unit/completion/test_models.py',
- 'completion/models/base.py'),
- ('tests/unit/completion/test_sortfilter.py',
- 'completion/models/sortfilter.py'),
+ 'completion/models/urlmodel.py'),
+ ('tests/unit/completion/test_histcategory.py',
+ 'completion/models/histcategory.py'),
+ ('tests/unit/completion/test_listcategory.py',
+ 'completion/models/listcategory.py'),
]
diff --git a/scripts/dev/ci/travis_install.sh b/scripts/dev/ci/travis_install.sh
index 9bcb5e07c..53bcf06e8 100644
--- a/scripts/dev/ci/travis_install.sh
+++ b/scripts/dev/ci/travis_install.sh
@@ -109,7 +109,7 @@ elif [[ $TRAVIS_OS_NAME == osx ]]; then
exit 0
fi
-pyqt_pkgs="python3-pyqt5 python3-pyqt5.qtquick python3-pyqt5.qtwebkit"
+pyqt_pkgs="python3-pyqt5 python3-pyqt5.qtquick python3-pyqt5.qtwebkit python3-pyqt5.qtsql libqt5sql5-sqlite"
pip_install pip
pip_install -r misc/requirements/requirements-tox.txt
diff --git a/scripts/dev/ci/travis_run.sh b/scripts/dev/ci/travis_run.sh
index fc4fde0fe..19386a1f5 100644
--- a/scripts/dev/ci/travis_run.sh
+++ b/scripts/dev/ci/travis_run.sh
@@ -4,7 +4,6 @@ if [[ $DOCKER ]]; then
docker run --privileged -v $PWD:/outside -e QUTE_BDD_WEBENGINE=$QUTE_BDD_WEBENGINE -e DOCKER=$DOCKER -e CI=$CI qutebrowser/travis:$DOCKER
else
args=()
- [[ $TESTENV == docs ]] && args=('--no-authors')
[[ $TRAVIS_OS_NAME == osx ]] && args=('--qute-bdd-webengine' '--no-xvfb')
tox -e $TESTENV -- "${args[@]}"
diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py
index 0d4f8dabb..4a31c56c1 100755
--- a/scripts/dev/run_vulture.py
+++ b/scripts/dev/run_vulture.py
@@ -89,6 +89,12 @@ def whitelist_generator():
# vulture doesn't notice the hasattr() and thus thinks netrc_used is unused
# in NetworkManager.on_authentication_required
yield 'PyQt5.QtNetwork.QNetworkReply.netrc_used'
+ yield 'qutebrowser.browser.downloads.last_used_directory'
+ yield 'PaintContext.clip' # from completiondelegate.py
+ yield 'logging.LogRecord.log_color' # from logging.py
+ yield 'scripts.utils.use_color' # from asciidoc2html.py
+ for attr in ['pyeval_output', 'log_clipboard', 'fake_clipboard']:
+ yield 'qutebrowser.misc.utilcmds.' + attr
for attr in ['fileno', 'truncate', 'closed', 'readable']:
yield 'qutebrowser.utils.qtutils.PyQIODevice.' + attr
@@ -123,7 +129,7 @@ def filter_func(item):
True if the missing function should be filtered/ignored, False
otherwise.
"""
- return bool(re.match(r'[a-z]+[A-Z][a-zA-Z]+', str(item)))
+ return bool(re.match(r'[a-z]+[A-Z][a-zA-Z]+', item.name))
def report(items):
@@ -137,7 +143,7 @@ def report(items):
relpath = os.path.relpath(item.filename)
path = relpath if not relpath.startswith('..') else item.filename
output.append("{}:{}: Unused {} '{}'".format(path, item.lineno,
- item.typ, item))
+ item.typ, item.name))
return output
diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py
index 13861e431..8804b5c0d 100755
--- a/scripts/dev/src2asciidoc.py
+++ b/scripts/dev/src2asciidoc.py
@@ -26,7 +26,6 @@ import shutil
import os.path
import inspect
import subprocess
-import collections
import tempfile
import argparse
@@ -43,7 +42,7 @@ from qutebrowser.utils import docutils, usertypes
FILE_HEADER = """
// DO NOT EDIT THIS FILE DIRECTLY!
-// It is autogenerated from docstrings by running:
+// It is autogenerated by running:
// $ python3 scripts/dev/src2asciidoc.py
""".lstrip()
@@ -415,32 +414,6 @@ def generate_settings(filename):
_generate_setting_option(f, opt)
-def _get_authors():
- """Get a list of authors based on git commit logs."""
- corrections = {
- 'binix': 'sbinix',
- 'Averrin': 'Alexey "Averrin" Nabrodov',
- 'Alexey Nabrodov': 'Alexey "Averrin" Nabrodov',
- 'Michael': 'Halfwit',
- 'Error 800': 'error800',
- 'larryhynes': 'Larry Hynes',
- 'Daniel': 'Daniel Schadt',
- 'Alexey Glushko': 'haitaka',
- 'Corentin Jule': 'Corentin Julé',
- 'Claire C.C': 'Claire Cavanaugh',
- 'Rahid': 'Maciej Wołczyk',
- 'Fritz V155 Reichwald': 'Fritz Reichwald',
- 'Spreadyy': 'sandrosc',
- }
- ignored = ['pyup-bot']
- commits = subprocess.check_output(['git', 'log', '--format=%aN'])
- authors = [corrections.get(author, author)
- for author in commits.decode('utf-8').splitlines()
- if author not in ignored]
- cnt = collections.Counter(authors)
- return sorted(cnt, key=lambda k: (cnt[k], k), reverse=True)
-
-
def _format_block(filename, what, data):
"""Format a block in a file.
@@ -487,12 +460,6 @@ def _format_block(filename, what, data):
shutil.move(tmpname, filename)
-def regenerate_authors(filename):
- """Re-generate the authors inside README based on the commits made."""
- data = ['* {}\n'.format(author) for author in _get_authors()]
- _format_block(filename, 'authors', data)
-
-
def regenerate_manpage(filename):
"""Update manpage OPTIONS using an argparse parser."""
# pylint: disable=protected-access
@@ -538,9 +505,6 @@ def main():
generate_settings('doc/help/settings.asciidoc')
print("Generating command help...")
generate_commands('doc/help/commands.asciidoc')
- if '--no-authors' not in sys.argv:
- print("Generating authors in README...")
- regenerate_authors('README.asciidoc')
if '--cheatsheet' in sys.argv:
print("Regenerating cheatsheet .pngs")
regenerate_cheatsheet()
diff --git a/scripts/open_url_in_instance.sh b/scripts/open_url_in_instance.sh
new file mode 100755
index 000000000..119c3aa4f
--- /dev/null
+++ b/scripts/open_url_in_instance.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+# initial idea: Florian Bruhin (The-Compiler)
+# author: Thore Bödecker (foxxx0)
+
+_url="$1"
+_qb_version='0.10.1'
+_proto_version=1
+_ipc_socket="${XDG_RUNTIME_DIR}/qutebrowser/ipc-$(echo -n "$USER" | md5sum | cut -d' ' -f1)"
+
+if [[ -e "${_ipc_socket}" ]]; then
+ exec printf '{"args": ["%s"], "target_arg": null, "version": "%s", "protocol_version": %d, "cwd": "%s"}\n' \
+ "${_url}" \
+ "${_qb_version}" \
+ "${_proto_version}" \
+ "${PWD}" | socat - UNIX-CONNECT:"${_ipc_socket}"
+else
+ exec /usr/bin/qutebrowser --backend webengine "$@"
+fi
diff --git a/tests/conftest.py b/tests/conftest.py
index bebe4e76f..f5018de66 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -52,8 +52,8 @@ def _apply_platform_markers(config, item):
('posix', os.name != 'posix', "Requires a POSIX os"),
('windows', os.name != 'nt', "Requires Windows"),
('linux', not sys.platform.startswith('linux'), "Requires Linux"),
- ('osx', sys.platform != 'darwin', "Requires OS X"),
- ('not_osx', sys.platform == 'darwin', "Skipped on OS X"),
+ ('mac', sys.platform != 'darwin', "Requires macOS"),
+ ('not_mac', sys.platform == 'darwin', "Skipped on macOS"),
('not_frozen', getattr(sys, 'frozen', False),
"Can't be run when frozen"),
('frozen', not getattr(sys, 'frozen', False),
diff --git a/tests/end2end/conftest.py b/tests/end2end/conftest.py
index 8dac6a41d..75c6845f4 100644
--- a/tests/end2end/conftest.py
+++ b/tests/end2end/conftest.py
@@ -149,7 +149,7 @@ def pytest_collection_modifyitems(config, items):
not config.webengine and qtutils.is_qtwebkit_ng()),
('qtwebengine_flaky', 'Flaky with QtWebEngine', pytest.mark.skipif,
config.webengine),
- ('qtwebengine_osx_xfail', 'Fails on OS X with QtWebEngine',
+ ('qtwebengine_mac_xfail', 'Fails on macOS with QtWebEngine',
pytest.mark.xfail, config.webengine and sys.platform == 'darwin'),
]
diff --git a/tests/end2end/data/downloads/download with no title.html b/tests/end2end/data/downloads/download with no title.html
new file mode 100644
index 000000000..da4352e59
--- /dev/null
+++ b/tests/end2end/data/downloads/download with no title.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/tests/end2end/data/downloads/qutebrowser.png b/tests/end2end/data/downloads/qutebrowser.png
new file mode 100644
index 000000000..e8bbb6b56
--- /dev/null
+++ b/tests/end2end/data/downloads/qutebrowser.png
Binary files differ
diff --git a/tests/end2end/features/completion.feature b/tests/end2end/features/completion.feature
index 94c194e4f..e93518199 100644
--- a/tests/end2end/features/completion.feature
+++ b/tests/end2end/features/completion.feature
@@ -32,23 +32,23 @@ Feature: Using completion
Scenario: Using command completion
When I run :set-cmd-text :
- Then the completion model should be CommandCompletionModel
+ Then the completion model should be command
Scenario: Using help completion
When I run :set-cmd-text -s :help
- Then the completion model should be HelpCompletionModel
+ Then the completion model should be helptopic
Scenario: Using quickmark completion
When I run :set-cmd-text -s :quickmark-load
- Then the completion model should be QuickmarkCompletionModel
+ Then the completion model should be quickmark
Scenario: Using bookmark completion
When I run :set-cmd-text -s :bookmark-load
- Then the completion model should be BookmarkCompletionModel
+ Then the completion model should be bookmark
Scenario: Using bind completion
When I run :set-cmd-text -s :bind X
- Then the completion model should be BindCompletionModel
+ Then the completion model should be bind
Scenario: Using session completion
Given I open data/hello.txt
@@ -60,43 +60,13 @@ Feature: Using completion
And I run :command-accept
Then the error "Session hello not found!" should be shown
- # FIXME:conf
+ Scenario: Using option completion
+ When I run :set-cmd-text -s :set colors
+ Then the completion model should be option
- # Scenario: Using option completion
- # When I run :set-cmd-text -s :set colors
- # Then the completion model should be SettingOptionCompletionModel
-
- # Scenario: Using value completion
- # When I run :set-cmd-text -s :set colors statusbar.bg
- # Then the completion model should be SettingValueCompletionModel
-
- Scenario: Updating the completion in realtime
- Given I have a fresh instance
- And I set completion.quick to false
- When I open data/hello.txt
- And I run :set-cmd-text -s :buffer
- And I run :completion-item-focus next
- And I open data/hello2.txt in a new background tab
- And I run :completion-item-focus next
- And I open data/hello3.txt in a new background tab
- And I run :completion-item-focus next
- And I run :command-accept
- Then the following tabs should be open:
- - data/hello.txt
- - data/hello2.txt
- - data/hello3.txt (active)
-
- # FIXME:conf
-
- # Scenario: Updating the value completion in realtime
- # Given I set colors.statusbar.normal.bg to green
- # When I run :set-cmd-text -s :set colors.statusbar.normal.bg
- # And I set colors.statusbar.normal.bg to yellow
- # And I run :completion-item-focus next
- # And I run :completion-item-focus next
- # And I set colors.statusbar.normal.bg to red
- # And I run :command-accept
- # Then the option colors.statusbar.normal.bg should be set to yellow
+ Scenario: Using value completion
+ When I run :set-cmd-text -s :set colors statusbar.bg
+ Then the completion model should be value
Scenario: Deleting an open tab via the completion
Given I have a fresh instance
diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py
index ea910c578..70d25c2fe 100644
--- a/tests/end2end/features/conftest.py
+++ b/tests/end2end/features/conftest.py
@@ -61,8 +61,8 @@ def pytest_runtest_makereport(item, call):
if (not hasattr(report.longrepr, 'addsection') or
not hasattr(report, 'scenario')):
- # In some conditions (on OS X and Windows it seems), report.longrepr is
- # actually a tuple. This is handled similarily in pytest-qt too.
+ # In some conditions (on macOS and Windows it seems), report.longrepr
+ # is actually a tuple. This is handled similarily in pytest-qt too.
#
# Since this hook is invoked for any test, we also need to skip it for
# non-BDD ones.
diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature
index e7c5a1446..3800cdb0b 100644
--- a/tests/end2end/features/downloads.feature
+++ b/tests/end2end/features/downloads.feature
@@ -22,6 +22,20 @@ Feature: Downloading things from a website.
And I wait until the download is finished
Then the downloaded file download.bin should exist
+ Scenario: Using :download with no URL
+ When I set storage -> prompt-download-directory to false
+ And I open data/downloads/downloads.html
+ And I run :download
+ And I wait until the download is finished
+ Then the downloaded file Simple downloads.html should exist
+
+ Scenario: Using :download with no URL on an image
+ When I set storage -> prompt-download-directory to false
+ And I open data/downloads/qutebrowser.png
+ And I run :download
+ And I wait until the download is finished
+ Then the downloaded file qutebrowser.png should exist
+
Scenario: Using hints
When I set downloads.location.prompt to false
And I open data/downloads/downloads.html
@@ -579,7 +593,6 @@ Feature: Downloading things from a website.
And I wait until the download is finished
Then the downloaded file content-size should exist
- @posix
Scenario: Downloading to unwritable destination
When I set downloads.location.prompt to false
And I run :download http://localhost:(port)/data/downloads/download.bin --dest (tmpdir)/downloads/unwritable
@@ -637,7 +650,7 @@ Feature: Downloading things from a website.
@qtwebengine_skip: We can't get the UA from the page there
Scenario: user-agent when using :download
When I open user-agent
- And I run :download
+ And I run :download --dest user-agent
And I wait until the download is finished
Then the downloaded file user-agent should contain Safari/
diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature
index 4ebbfe6dd..e2d15e72d 100644
--- a/tests/end2end/features/hints.feature
+++ b/tests/end2end/features/hints.feature
@@ -243,7 +243,7 @@ Feature: Using hints
### hints.auto_follow.timeout
- @not_osx
+ @not_mac
Scenario: Ignoring key presses after auto-following hints
When I set hints.auto_follow_timeout to 1000
And I set hints.mode to number
diff --git a/tests/end2end/features/history.feature b/tests/end2end/features/history.feature
index 51c12feb7..a340db429 100644
--- a/tests/end2end/features/history.feature
+++ b/tests/end2end/features/history.feature
@@ -11,44 +11,44 @@ Feature: Page history
Scenario: Simple history saving
When I open data/numbers/1.txt
And I open data/numbers/2.txt
- Then the history file should contain:
+ Then the history should contain:
http://localhost:(port)/data/numbers/1.txt
http://localhost:(port)/data/numbers/2.txt
-
+
Scenario: History item with title
When I open data/title.html
- Then the history file should contain:
+ Then the history should contain:
http://localhost:(port)/data/title.html Test title
Scenario: History item with redirect
When I open redirect-to?url=data/title.html without waiting
And I wait until data/title.html is loaded
- Then the history file should contain:
+ Then the history should contain:
r http://localhost:(port)/redirect-to?url=data/title.html Test title
http://localhost:(port)/data/title.html Test title
-
+
Scenario: History item with spaces in URL
When I open data/title with spaces.html
- Then the history file should contain:
+ Then the history should contain:
http://localhost:(port)/data/title%20with%20spaces.html Test title
Scenario: History item with umlauts
When I open data/äöü.html
- Then the history file should contain:
+ Then the history should contain:
http://localhost:(port)/data/%C3%A4%C3%B6%C3%BC.html Chäschüechli
-
+
@flaky @qtwebengine_todo: Error page message is not implemented
Scenario: History with an error
When I run :open file:///does/not/exist
And I wait for "Error while loading file:///does/not/exist: Error opening /does/not/exist: *" in the log
- Then the history file should contain:
+ Then the history should contain:
file:///does/not/exist Error loading page: file:///does/not/exist
@qtwebengine_todo: Error page message is not implemented
Scenario: History with a 404
When I open status/404 without waiting
And I wait for "Error while loading http://localhost:*/status/404: NOT FOUND" in the log
- Then the history file should contain:
+ Then the history should contain:
http://localhost:(port)/status/404 Error loading page: http://localhost:(port)/status/404
Scenario: History with invalid URL
@@ -61,32 +61,32 @@ Feature: Page history
When I open data/data_link.html
And I run :click-element id link
And I wait until data:;base64,cXV0ZWJyb3dzZXI= is loaded
- Then the history file should contain:
+ Then the history should contain:
http://localhost:(port)/data/data_link.html data: link
Scenario: History with view-source URL
When I open data/title.html
And I run :view-source
And I wait for "Changing title for idx * to 'Source for http://localhost:*/data/title.html'" in the log
- Then the history file should contain:
+ Then the history should contain:
http://localhost:(port)/data/title.html Test title
Scenario: Clearing history
When I open data/title.html
And I run :history-clear --force
- Then the history file should be empty
+ Then the history should be empty
Scenario: Clearing history with confirmation
When I open data/title.html
And I run :history-clear
And I wait for "Asking question <* title='Clear all browsing history?'>, *" in the log
And I run :prompt-accept yes
- Then the history file should be empty
+ Then the history should be empty
Scenario: History with yanked URL and 'add to history' flag
When I open data/hints/html/simple.html
And I hint with args "--add-history links yank" and follow a
- Then the history file should contain:
+ Then the history should contain:
http://localhost:(port)/data/hints/html/simple.html Simple link
http://localhost:(port)/data/hello.txt
diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature
index cd6504582..dfc1e0f73 100644
--- a/tests/end2end/features/misc.feature
+++ b/tests/end2end/features/misc.feature
@@ -278,7 +278,7 @@ Feature: Various utility commands.
And I run :debug-pyeval QApplication.instance().activeModalWidget().close()
Then no crash should happen
- # On Windows/OS X, we get a "QPrintDialog: Cannot be used on non-native
+ # On Windows/macOS, we get a "QPrintDialog: Cannot be used on non-native
# printers" qWarning.
#
# Disabled because it causes weird segfaults and QPainter warnings in Qt...
@@ -532,3 +532,9 @@ Feature: Various utility commands.
And I wait for "Renderer process was killed" in the log
And I open data/numbers/3.txt
Then no crash should happen
+
+ ## Other
+
+ Scenario: Open qute://version
+ When I open qute://version
+ Then the page should contain the plaintext "Version info"
diff --git a/tests/end2end/features/prompts.feature b/tests/end2end/features/prompts.feature
index b4b95835c..3b69db6f7 100644
--- a/tests/end2end/features/prompts.feature
+++ b/tests/end2end/features/prompts.feature
@@ -219,14 +219,14 @@ Feature: Prompts
And I run :click-element id button
Then the javascript message "geolocation permission denied" should be logged
- @ci @not_osx @qt!=5.8
+ @ci @not_mac @qt!=5.8
Scenario: Always accepting geolocation
When I set content.geolocation to true
And I open data/prompt/geolocation.html in a new tab
And I run :click-element id button
Then the javascript message "geolocation permission denied" should not be logged
- @ci @not_osx @qt!=5.8
+ @ci @not_mac @qt!=5.8
Scenario: geolocation with ask -> true
When I set content.geolocation to ask
And I open data/prompt/geolocation.html in a new tab
diff --git a/tests/end2end/features/spawn.feature b/tests/end2end/features/spawn.feature
index dc0485391..e2b5fdd5b 100644
--- a/tests/end2end/features/spawn.feature
+++ b/tests/end2end/features/spawn.feature
@@ -60,8 +60,3 @@ Feature: :spawn
Scenario: Running :spawn with userscript that expects the stdin getting closed
When I run :spawn -u (testdata)/userscripts/stdinclose.py
Then the message "stdin closed" should be shown
-
- @posix
- Scenario: Running :spawn -d with userscript that expects the stdin getting closed
- When I run :spawn -d -u (testdata)/userscripts/stdinclose.py
- Then the message "stdin closed" should be shown
diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature
index 771215c0b..bcf7f15f0 100644
--- a/tests/end2end/features/tabs.feature
+++ b/tests/end2end/features/tabs.feature
@@ -1075,6 +1075,16 @@ Feature: Tab management
- data/numbers/2.txt (pinned)
- data/numbers/3.txt (active)
+ Scenario: :tab-pin with an invalid count
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new tab
+ And I open data/numbers/3.txt in a new tab
+ And I run :tab-pin with count 23
+ Then the following tabs should be open:
+ - data/numbers/1.txt
+ - data/numbers/2.txt
+ - data/numbers/3.txt (active)
+
Scenario: Pinned :tab-close prompt yes
When I open data/numbers/1.txt
And I run :tab-pin
diff --git a/tests/end2end/features/test_completion_bdd.py b/tests/end2end/features/test_completion_bdd.py
index f4ada848f..82e2df030 100644
--- a/tests/end2end/features/test_completion_bdd.py
+++ b/tests/end2end/features/test_completion_bdd.py
@@ -24,5 +24,5 @@ bdd.scenarios('completion.feature')
@bdd.then(bdd.parsers.parse("the completion model should be {model}"))
def check_model(quteproc, model):
"""Make sure the completion model was set to something."""
- pattern = "Setting completion model to {} with pattern *".format(model)
+ pattern = "Starting {} completion *".format(model)
quteproc.wait_for(message=pattern)
diff --git a/tests/end2end/features/test_downloads_bdd.py b/tests/end2end/features/test_downloads_bdd.py
index c5bd7cbeb..c42d281c4 100644
--- a/tests/end2end/features/test_downloads_bdd.py
+++ b/tests/end2end/features/test_downloads_bdd.py
@@ -21,6 +21,7 @@ import os
import sys
import shlex
+import pytest
import pytest_bdd as bdd
bdd.scenarios('downloads.feature')
@@ -53,6 +54,14 @@ def clean_old_downloads(quteproc):
quteproc.send_cmd(':download-clear')
+@bdd.when("the unwritable dir is unwritable")
+def check_unwritable(tmpdir):
+ unwritable = tmpdir / 'downloads' / 'unwritable'
+ if os.access(str(unwritable), os.W_OK):
+ # Docker container or similar
+ pytest.skip("Unwritable dir was writable")
+
+
@bdd.when("I wait until the download is finished")
def wait_for_download_finished(quteproc):
quteproc.wait_for(category='downloads', message='Download * finished')
diff --git a/tests/end2end/features/test_history_bdd.py b/tests/end2end/features/test_history_bdd.py
index 1fee533eb..319e36aee 100644
--- a/tests/end2end/features/test_history_bdd.py
+++ b/tests/end2end/features/test_history_bdd.py
@@ -17,36 +17,29 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-import os.path
+import logging
+import re
import pytest_bdd as bdd
-bdd.scenarios('history.feature')
+bdd.scenarios('history.feature')
-@bdd.then(bdd.parsers.parse("the history file should contain:\n{expected}"))
-def check_history(quteproc, httpbin, expected):
- history_file = os.path.join(quteproc.basedir, 'data', 'history')
- quteproc.send_cmd(':save history')
- quteproc.wait_for(message=':save saved history')
- expected = expected.replace('(port)', str(httpbin.port)).splitlines()
+@bdd.then(bdd.parsers.parse("the history should contain:\n{expected}"))
+def check_history(quteproc, httpbin, tmpdir, expected):
+ path = tmpdir / 'history'
+ quteproc.send_cmd(':debug-dump-history "{}"'.format(path))
+ quteproc.wait_for(category='message', loglevel=logging.INFO,
+ message='Dumped history to {}'.format(path))
- with open(history_file, 'r', encoding='utf-8') as f:
- lines = []
- for line in f:
- if not line.strip():
- continue
- print('history line: ' + line)
- atime, line = line.split(' ', maxsplit=1)
- line = line.rstrip()
- if '-' in atime:
- flags = atime.split('-')[1]
- line = '{} {}'.format(flags, line)
- lines.append(line)
+ with path.open('r', encoding='utf-8') as f:
+ # ignore access times, they will differ in each run
+ actual = '\n'.join(re.sub('^\\d+-?', '', line).strip() for line in f)
- assert lines == expected
+ expected = expected.replace('(port)', str(httpbin.port))
+ assert actual == expected
-@bdd.then("the history file should be empty")
-def check_history_empty(quteproc, httpbin):
- check_history(quteproc, httpbin, '')
+@bdd.then("the history should be empty")
+def check_history_empty(quteproc, httpbin, tmpdir):
+ check_history(quteproc, httpbin, tmpdir, '')
diff --git a/tests/end2end/features/yankpaste.feature b/tests/end2end/features/yankpaste.feature
index 7c8cb3ca7..e38b0a8b0 100644
--- a/tests/end2end/features/yankpaste.feature
+++ b/tests/end2end/features/yankpaste.feature
@@ -291,7 +291,7 @@ Feature: Yanking and pasting.
# Compare
Then the javascript message "textarea contents: onHello worlde two three four" should be logged
- @qtwebengine_osx_xfail
+ @qtwebengine_mac_xfail
Scenario: Inserting text into a text field with undo
When I set content.javascript.log to info
And I open data/paste_primary.html
diff --git a/tests/end2end/fixtures/testprocess.py b/tests/end2end/fixtures/testprocess.py
index f94ec4e22..1a2c51baf 100644
--- a/tests/end2end/fixtures/testprocess.py
+++ b/tests/end2end/fixtures/testprocess.py
@@ -103,8 +103,8 @@ def pytest_runtest_makereport(item, call):
httpbin_log = getattr(item, '_httpbin_log', None)
if not hasattr(report.longrepr, 'addsection'):
- # In some conditions (on OS X and Windows it seems), report.longrepr is
- # actually a tuple. This is handled similarily in pytest-qt too.
+ # In some conditions (on macOS and Windows it seems), report.longrepr
+ # is actually a tuple. This is handled similarily in pytest-qt too.
return
if pytest.config.getoption('--capture') == 'no':
diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py
index 5c5a7fbc2..5466b77f9 100644
--- a/tests/end2end/test_invocations.py
+++ b/tests/end2end/test_invocations.py
@@ -163,6 +163,7 @@ def test_optimize(request, quteproc_new, capfd, level):
@pytest.mark.not_frozen
+@pytest.mark.flaky # Fails sometimes with empty output...
def test_version(request):
"""Test invocation with --version argument."""
args = ['-m', 'qutebrowser', '--version'] + _base_args(request.config)
diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py
index 4a0f1e8dc..051b1f2a6 100644
--- a/tests/helpers/fixtures.py
+++ b/tests/helpers/fixtures.py
@@ -41,7 +41,7 @@ import helpers.stubs as stubsmod
from qutebrowser.config import config, configdata
from qutebrowser.utils import objreg, standarddir
from qutebrowser.browser.webkit import cookies
-from qutebrowser.misc import savemanager
+from qutebrowser.misc import savemanager, sql
from qutebrowser.keyinput import modeman
from PyQt5.QtCore import PYQT_VERSION, pyqtSignal, QEvent, QSize, Qt, QObject
@@ -265,17 +265,8 @@ def bookmark_manager_stub(stubs):
@pytest.fixture
-def web_history_stub(stubs):
- """Fixture which provides a fake web-history object."""
- stub = stubs.WebHistoryStub()
- objreg.register('web-history', stub)
- yield stub
- objreg.delete('web-history')
-
-
-@pytest.fixture
def session_manager_stub(stubs):
- """Fixture which provides a fake web-history object."""
+ """Fixture which provides a fake session-manager object."""
stub = stubs.SessionManagerStub()
objreg.register('session-manager', stub)
yield stub
@@ -488,3 +479,37 @@ def short_tmpdir():
"""A short temporary directory for a XDG_RUNTIME_DIR."""
with tempfile.TemporaryDirectory() as tdir:
yield py.path.local(tdir) # pylint: disable=no-member
+
+
+@pytest.fixture
+def init_sql(data_tmpdir):
+ """Initialize the SQL module, and shut it down after the test."""
+ path = str(data_tmpdir / 'test.db')
+ sql.init(path)
+ yield
+ sql.close()
+
+
+class ModelValidator:
+
+ """Validates completion models."""
+
+ def __init__(self, modeltester):
+ modeltester.data_display_may_return_none = True
+ self._model = None
+ self._modeltester = modeltester
+
+ def set_model(self, model):
+ self._model = model
+ self._modeltester.check(model)
+
+ def validate(self, expected):
+ assert self._model.rowCount() == len(expected)
+ for row, items in enumerate(expected):
+ for col, item in enumerate(items):
+ assert self._model.data(self._model.index(row, col)) == item
+
+
+@pytest.fixture
+def model_validator(qtmodeltester):
+ return ModelValidator(qtmodeltester)
diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py
index 06fc5ef39..8f8cd66bb 100644
--- a/tests/helpers/stubs.py
+++ b/tests/helpers/stubs.py
@@ -29,8 +29,9 @@ from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache,
QNetworkCacheMetaData)
from PyQt5.QtWidgets import QCommonStyle, QLineEdit, QWidget, QTabBar
-from qutebrowser.browser import browsertab, history
-from qutebrowser.utils import usertypes
+from qutebrowser.browser import browsertab
+from qutebrowser.config import configexc
+from qutebrowser.utils import usertypes, utils
from qutebrowser.mainwindow import mainwindow
@@ -221,6 +222,24 @@ class FakeWebTabScroller(browsertab.AbstractScroller):
return self._pos_perc
+class FakeWebTabHistory(browsertab.AbstractHistory):
+
+ """Fake for Web{Kit,Engine}History."""
+
+ def __init__(self, tab, *, can_go_back, can_go_forward):
+ super().__init__(tab)
+ self._can_go_back = can_go_back
+ self._can_go_forward = can_go_forward
+
+ def can_go_back(self):
+ assert self._can_go_back is not None
+ return self._can_go_back
+
+ def can_go_forward(self):
+ assert self._can_go_forward is not None
+ return self._can_go_forward
+
+
class FakeWebTab(browsertab.AbstractTab):
"""Fake AbstractTab to use in tests."""
@@ -228,12 +247,14 @@ class FakeWebTab(browsertab.AbstractTab):
def __init__(self, url=FakeUrl(), title='', tab_id=0, *,
scroll_pos_perc=(0, 0),
load_status=usertypes.LoadStatus.success,
- progress=0):
+ progress=0, can_go_back=None, can_go_forward=None):
super().__init__(win_id=0, mode_manager=None, private=False)
self._load_status = load_status
self._title = title
self._url = url
self._progress = progress
+ self.history = FakeWebTabHistory(self, can_go_back=can_go_back,
+ can_go_forward=can_go_forward)
self.scroller = FakeWebTabScroller(self, scroll_pos_perc)
wrapped = QWidget()
self._layout.wrap(self, wrapped)
@@ -385,6 +406,10 @@ class InstaTimer(QObject):
def setInterval(self, interval):
pass
+ @staticmethod
+ def singleShot(_interval, fun):
+ fun()
+
class FakeYamlConfig:
@@ -454,24 +479,6 @@ class QuickmarkManagerStub(UrlMarkManagerStub):
self.delete(key)
-class WebHistoryStub(QObject):
-
- """Stub for the web-history object."""
-
- add_completion_item = pyqtSignal(history.Entry)
- cleared = pyqtSignal()
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.history_dict = collections.OrderedDict()
-
- def __iter__(self):
- return iter(self.history_dict.values())
-
- def __len__(self):
- return len(self.history_dict)
-
-
class HostBlockerStub:
"""Stub for the host-blocker object."""
@@ -536,7 +543,10 @@ class TabbedBrowserStub(QObject):
return self.current_index
def currentWidget(self):
- return self.tabs[self.currentIndex() - 1]
+ idx = self.currentIndex()
+ if idx == -1:
+ return None
+ return self.tabs[idx - 1]
def tabopen(self, url):
self.opened_url = url
diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py
new file mode 100644
index 000000000..c109f44eb
--- /dev/null
+++ b/tests/unit/browser/test_history.py
@@ -0,0 +1,351 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2016-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Tests for the global page history."""
+
+import logging
+
+import pytest
+from PyQt5.QtCore import QUrl
+
+from qutebrowser.browser import history
+from qutebrowser.utils import objreg, urlutils, usertypes
+from qutebrowser.commands import cmdexc
+
+
+@pytest.fixture(autouse=True)
+def prerequisites(config_stub, fake_save_manager, init_sql):
+ """Make sure everything is ready to initialize a WebHistory."""
+ config_stub.data = {'general': {'private-browsing': False}}
+
+
+@pytest.fixture()
+def hist(tmpdir):
+ return history.WebHistory()
+
+
+@pytest.fixture()
+def mock_time(mocker):
+ m = mocker.patch('qutebrowser.browser.history.time')
+ m.time.return_value = 12345
+ return 12345
+
+
+def test_iter(hist):
+ urlstr = 'http://www.example.com/'
+ url = QUrl(urlstr)
+ hist.add_url(url, atime=12345)
+
+ assert list(hist) == [(urlstr, '', 12345, False)]
+
+
+def test_len(hist):
+ assert len(hist) == 0
+
+ url = QUrl('http://www.example.com/')
+ hist.add_url(url)
+
+ assert len(hist) == 1
+
+
+def test_contains(hist):
+ hist.add_url(QUrl('http://www.example.com/'), title='Title', atime=12345)
+ assert 'http://www.example.com/' in hist
+ assert 'www.example.com' not in hist
+ assert 'Title' not in hist
+ assert 12345 not in hist
+
+
+def test_get_recent(hist):
+ hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890)
+ hist.add_url(QUrl('http://example.com/'), atime=12345)
+ assert list(hist.get_recent()) == [
+ ('http://www.qutebrowser.org/', '', 67890, False),
+ ('http://example.com/', '', 12345, False),
+ ]
+
+
+def test_entries_between(hist):
+ hist.add_url(QUrl('http://www.example.com/1'), atime=12345)
+ hist.add_url(QUrl('http://www.example.com/2'), atime=12346)
+ hist.add_url(QUrl('http://www.example.com/3'), atime=12347)
+ hist.add_url(QUrl('http://www.example.com/4'), atime=12348)
+ hist.add_url(QUrl('http://www.example.com/5'), atime=12348)
+ hist.add_url(QUrl('http://www.example.com/6'), atime=12349)
+ hist.add_url(QUrl('http://www.example.com/7'), atime=12350)
+
+ times = [x.atime for x in hist.entries_between(12346, 12349)]
+ assert times == [12349, 12348, 12348, 12347]
+
+
+def test_entries_before(hist):
+ hist.add_url(QUrl('http://www.example.com/1'), atime=12346)
+ hist.add_url(QUrl('http://www.example.com/2'), atime=12346)
+ hist.add_url(QUrl('http://www.example.com/3'), atime=12347)
+ hist.add_url(QUrl('http://www.example.com/4'), atime=12348)
+ hist.add_url(QUrl('http://www.example.com/5'), atime=12348)
+ hist.add_url(QUrl('http://www.example.com/6'), atime=12348)
+ hist.add_url(QUrl('http://www.example.com/7'), atime=12349)
+ hist.add_url(QUrl('http://www.example.com/8'), atime=12349)
+
+ times = [x.atime for x in hist.entries_before(12348, limit=3, offset=2)]
+ assert times == [12348, 12347, 12346]
+
+
+def test_clear(qtbot, tmpdir, hist, mocker):
+ hist.add_url(QUrl('http://example.com/'))
+ hist.add_url(QUrl('http://www.qutebrowser.org/'))
+
+ m = mocker.patch('qutebrowser.browser.history.message.confirm_async',
+ new=mocker.Mock, spec=[])
+ hist.clear()
+ assert m.called
+
+
+def test_clear_force(qtbot, tmpdir, hist):
+ hist.add_url(QUrl('http://example.com/'))
+ hist.add_url(QUrl('http://www.qutebrowser.org/'))
+ hist.clear(force=True)
+ assert not len(hist)
+ assert not len(hist.completion)
+
+
+def test_delete_url(hist):
+ hist.add_url(QUrl('http://example.com/'), atime=0)
+ hist.add_url(QUrl('http://example.com/1'), atime=0)
+ hist.add_url(QUrl('http://example.com/2'), atime=0)
+
+ before = set(hist)
+ completion_before = set(hist.completion)
+
+ hist.delete_url(QUrl('http://example.com/1'))
+
+ diff = before.difference(set(hist))
+ assert diff == {('http://example.com/1', '', 0, False)}
+
+ completion_diff = completion_before.difference(set(hist.completion))
+ assert completion_diff == {('http://example.com/1', '', 0)}
+
+
+@pytest.mark.parametrize('url, atime, title, redirect, expected_url', [
+ ('http://www.example.com', 12346, 'the title', False,
+ 'http://www.example.com'),
+ ('http://www.example.com', 12346, 'the title', True,
+ 'http://www.example.com'),
+ ('http://www.example.com/spa ce', 12346, 'the title', False,
+ 'http://www.example.com/spa%20ce'),
+ ('https://user:pass@example.com', 12346, 'the title', False,
+ 'https://user@example.com'),
+])
+def test_add_url(qtbot, hist, url, atime, title, redirect, expected_url):
+ hist.add_url(QUrl(url), atime=atime, title=title, redirect=redirect)
+ assert list(hist) == [(expected_url, title, atime, redirect)]
+ if redirect:
+ assert not len(hist.completion)
+ else:
+ assert list(hist.completion) == [(expected_url, title, atime)]
+
+
+def test_add_url_invalid(qtbot, hist, caplog):
+ with caplog.at_level(logging.WARNING):
+ hist.add_url(QUrl())
+ assert not list(hist)
+ assert not list(hist.completion)
+
+
+@pytest.mark.parametrize('level, url, req_url, expected', [
+ (logging.DEBUG, 'a.com', 'a.com', [('a.com', 'title', 12345, False)]),
+ (logging.DEBUG, 'a.com', 'b.com', [('a.com', 'title', 12345, False),
+ ('b.com', 'title', 12345, True)]),
+ (logging.WARNING, 'a.com', '', [('a.com', 'title', 12345, False)]),
+ (logging.WARNING, '', '', []),
+ (logging.WARNING, 'data:foo', '', []),
+ (logging.WARNING, 'a.com', 'data:foo', []),
+])
+def test_add_from_tab(hist, level, url, req_url, expected, mock_time, caplog):
+ with caplog.at_level(level):
+ hist.add_from_tab(QUrl(url), QUrl(req_url), 'title')
+ assert set(hist) == set(expected)
+
+
+@pytest.fixture
+def hist_interface(hist):
+ # pylint: disable=invalid-name
+ QtWebKit = pytest.importorskip('PyQt5.QtWebKit')
+ from qutebrowser.browser.webkit import webkithistory
+ QWebHistoryInterface = QtWebKit.QWebHistoryInterface
+ # pylint: enable=invalid-name
+ hist.add_url(url=QUrl('http://www.example.com/'), title='example')
+ interface = webkithistory.WebHistoryInterface(hist)
+ QWebHistoryInterface.setDefaultInterface(interface)
+ yield
+ QWebHistoryInterface.setDefaultInterface(None)
+
+
+def test_history_interface(qtbot, webview, hist_interface):
+ html = b"<a href='about:blank'>foo</a>"
+ url = urlutils.data_url('text/html', html)
+ with qtbot.waitSignal(webview.loadFinished):
+ webview.load(url)
+
+
+@pytest.fixture
+def cleanup_init():
+ # prevent test_init from leaking state
+ yield
+ hist = objreg.get('web-history', None)
+ if hist is not None:
+ hist.setParent(None)
+ objreg.delete('web-history')
+ try:
+ from PyQt5.QtWebKit import QWebHistoryInterface
+ QWebHistoryInterface.setDefaultInterface(None)
+ except ImportError:
+ pass
+
+
+@pytest.mark.parametrize('backend', [usertypes.Backend.QtWebEngine,
+ usertypes.Backend.QtWebKit])
+def test_init(backend, qapp, tmpdir, monkeypatch, cleanup_init):
+ if backend == usertypes.Backend.QtWebKit:
+ pytest.importorskip('PyQt5.QtWebKitWidgets')
+ else:
+ assert backend == usertypes.Backend.QtWebEngine
+
+ monkeypatch.setattr(history.objects, 'backend', backend)
+ history.init(qapp)
+ hist = objreg.get('web-history')
+ assert hist.parent() is qapp
+
+ try:
+ from PyQt5.QtWebKit import QWebHistoryInterface
+ except ImportError:
+ QWebHistoryInterface = None
+
+ if backend == usertypes.Backend.QtWebKit:
+ default_interface = QWebHistoryInterface.defaultInterface()
+ assert default_interface._history is hist
+ else:
+ assert backend == usertypes.Backend.QtWebEngine
+ if QWebHistoryInterface is None:
+ default_interface = None
+ else:
+ default_interface = QWebHistoryInterface.defaultInterface()
+ # For this to work, nothing can ever have called setDefaultInterface
+ # before (so we need to test webengine before webkit)
+ assert default_interface is None
+
+
+def test_import_txt(hist, data_tmpdir, monkeypatch, stubs):
+ monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer)
+ histfile = data_tmpdir / 'history'
+ # empty line is deliberate, to test skipping empty lines
+ histfile.write('''12345 http://example.com/ title
+ 12346 http://qutebrowser.org/
+ 67890 http://example.com/path
+
+ 68891-r http://example.com/path/other ''')
+
+ hist.import_txt()
+
+ assert list(hist) == [
+ ('http://example.com/', 'title', 12345, False),
+ ('http://qutebrowser.org/', '', 12346, False),
+ ('http://example.com/path', '', 67890, False),
+ ('http://example.com/path/other', '', 68891, True)
+ ]
+
+ assert not histfile.exists()
+ assert (data_tmpdir / 'history.bak').exists()
+
+
+@pytest.mark.parametrize('line', [
+ '',
+ '#12345 http://example.com/commented',
+
+ # https://bugreports.qt.io/browse/QTBUG-60364
+ '12345 http://.com/',
+ '12345 https://.com/',
+ '12345 http://www..com/',
+ '12345 https://www..com/',
+
+ # issue #2646
+ '12345 data:text/html;charset=UTF-8,%3C%21DOCTYPE%20html%20PUBLIC%20%22-',
+])
+def test_import_txt_skip(hist, data_tmpdir, line, monkeypatch, stubs):
+ """import_txt should skip certain lines silently."""
+ monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer)
+ histfile = data_tmpdir / 'history'
+ histfile.write(line)
+
+ hist.import_txt()
+
+ assert not histfile.exists()
+ assert not len(hist)
+
+
+@pytest.mark.parametrize('line', [
+ 'xyz http://example.com/bad-timestamp',
+ '12345',
+ 'http://example.com/no-timestamp',
+ '68891-r-r http://example.com/double-flag',
+ '68891-x http://example.com/bad-flag',
+ '68891 http://.com',
+])
+def test_import_txt_invalid(hist, data_tmpdir, line, monkeypatch, stubs,
+ caplog):
+ """import_txt should fail on certain lines."""
+ monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer)
+ histfile = data_tmpdir / 'history'
+ histfile.write(line)
+
+ with caplog.at_level(logging.ERROR):
+ hist.import_txt()
+
+ assert any(rec.msg.startswith("Failed to import history:")
+ for rec in caplog.records)
+
+ assert histfile.exists()
+
+
+def test_import_txt_nonexistent(hist, data_tmpdir, monkeypatch, stubs):
+ """import_txt should do nothing if the history file doesn't exist."""
+ monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer)
+ hist.import_txt()
+
+
+def test_debug_dump_history(hist, tmpdir):
+ hist.add_url(QUrl('http://example.com/1'), title="Title1", atime=12345)
+ hist.add_url(QUrl('http://example.com/2'), title="Title2", atime=12346)
+ hist.add_url(QUrl('http://example.com/3'), title="Title3", atime=12347)
+ hist.add_url(QUrl('http://example.com/4'), title="Title4", atime=12348,
+ redirect=True)
+ histfile = tmpdir / 'history'
+ hist.debug_dump_history(str(histfile))
+ expected = ['12345 http://example.com/1 Title1',
+ '12346 http://example.com/2 Title2',
+ '12347 http://example.com/3 Title3',
+ '12348-r http://example.com/4 Title4']
+ assert histfile.read() == '\n'.join(expected)
+
+
+def test_debug_dump_history_nonexistent(hist, tmpdir):
+ histfile = tmpdir / 'nonexistent' / 'history'
+ with pytest.raises(cmdexc.CommandError):
+ hist.debug_dump_history(str(histfile))
diff --git a/tests/unit/browser/test_qutescheme.py b/tests/unit/browser/test_qutescheme.py
index 92ad30574..693b2607c 100644
--- a/tests/unit/browser/test_qutescheme.py
+++ b/tests/unit/browser/test_qutescheme.py
@@ -89,16 +89,17 @@ class TestHistoryHandler:
items = []
for i in range(entry_count):
entry_atime = now - i * interval
- entry = history.Entry(atime=str(entry_atime),
- url=QUrl("www.x.com/" + str(i)), title="Page " + str(i))
+ entry = {"atime": str(entry_atime),
+ "url": QUrl("www.x.com/" + str(i)),
+ "title": "Page " + str(i)}
items.insert(0, entry)
return items
@pytest.fixture
- def fake_web_history(self, fake_save_manager, tmpdir):
+ def fake_web_history(self, fake_save_manager, tmpdir, init_sql):
"""Create a fake web-history and register it into objreg."""
- web_history = history.WebHistory(tmpdir.dirname, 'fake-history')
+ web_history = history.WebHistory()
objreg.register('web-history', web_history)
yield web_history
objreg.delete('web-history')
@@ -107,8 +108,7 @@ class TestHistoryHandler:
def fake_history(self, fake_web_history, entries):
"""Create fake history."""
for item in entries:
- fake_web_history._add_entry(item)
- fake_web_history.save()
+ fake_web_history.add_url(**item)
@pytest.mark.parametrize("start_time_offset, expected_item_count", [
(0, 4),
@@ -123,45 +123,25 @@ class TestHistoryHandler:
url = QUrl("qute://history/data?start_time=" + str(start_time))
_mimetype, data = qutescheme.qute_history(url)
items = json.loads(data)
- items = [item for item in items if 'time' in item] # skip 'next' item
assert len(items) == expected_item_count
# test times
end_time = start_time - 24*60*60
for item in items:
- assert item['time'] <= start_time * 1000
- assert item['time'] > end_time * 1000
-
- @pytest.mark.parametrize("start_time_offset, next_time", [
- (0, 24*60*60),
- (24*60*60, 48*60*60),
- (48*60*60, -1),
- (72*60*60, -1)
- ])
- def test_qutehistory_next(self, start_time_offset, next_time, now):
- """Ensure qute://history/data returns correct items."""
- start_time = now - start_time_offset
- url = QUrl("qute://history/data?start_time=" + str(start_time))
- _mimetype, data = qutescheme.qute_history(url)
- items = json.loads(data)
- items = [item for item in items if 'next' in item] # 'next' items
- assert len(items) == 1
-
- if next_time == -1:
- assert items[0]["next"] == -1
- else:
- assert items[0]["next"] == now - next_time
+ assert item['time'] <= start_time
+ assert item['time'] > end_time
def test_qute_history_benchmark(self, fake_web_history, benchmark, now):
- # items must be earliest-first to ensure history is sorted properly
- for t in range(100000, 0, -1): # one history per second
- entry = history.Entry(
- atime=str(now - t),
- url=QUrl('www.x.com/{}'.format(t)),
- title='x at {}'.format(t))
- fake_web_history._add_entry(entry)
-
+ r = range(100000)
+ entries = {
+ 'atime': [int(now - t) for t in r],
+ 'url': ['www.x.com/{}'.format(t) for t in r],
+ 'title': ['x at {}'.format(t) for t in r],
+ 'redirect': [False for _ in r],
+ }
+
+ fake_web_history.insert_batch(entries)
url = QUrl("qute://history/data?start_time={}".format(now))
_mimetype, data = benchmark(qutescheme.qute_history, url)
assert len(json.loads(data)) > 1
diff --git a/tests/unit/browser/webkit/test_downloads.py b/tests/unit/browser/webkit/test_downloads.py
index 59613776a..77a1b0298 100644
--- a/tests/unit/browser/webkit/test_downloads.py
+++ b/tests/unit/browser/webkit/test_downloads.py
@@ -29,6 +29,42 @@ def test_download_model(qapp, qtmodeltester, config_stub, cookiejar_and_cache):
qtmodeltester.check(model)
+@pytest.mark.parametrize('url, title, out', [
+ ('http://qutebrowser.org/INSTALL.html',
+ 'Installing qutebrowser | qutebrowser',
+ 'Installing qutebrowser _ qutebrowser.html'),
+ ('http://qutebrowser.org/INSTALL.html',
+ 'Installing qutebrowser | qutebrowser.html',
+ 'Installing qutebrowser _ qutebrowser.html'),
+ ('http://qutebrowser.org/INSTALL.HTML',
+ 'Installing qutebrowser | qutebrowser',
+ 'Installing qutebrowser _ qutebrowser.html'),
+ ('http://qutebrowser.org/INSTALL.html',
+ 'Installing qutebrowser | qutebrowser.HTML',
+ 'Installing qutebrowser _ qutebrowser.HTML'),
+ ('http://qutebrowser.org/',
+ 'qutebrowser | qutebrowser',
+ 'qutebrowser _ qutebrowser.html'),
+ ('https://github.com/qutebrowser/qutebrowser/releases',
+ 'Releases · qutebrowser/qutebrowser',
+ 'Releases · qutebrowser_qutebrowser.html'),
+ ('http://qutebrowser.org/index.php',
+ 'qutebrowser | qutebrowser',
+ 'qutebrowser _ qutebrowser.html'),
+ ('http://qutebrowser.org/index.php',
+ 'qutebrowser | qutebrowser - index.php',
+ 'qutebrowser _ qutebrowser - index.php.html'),
+ ('https://qutebrowser.org/img/cheatsheet-big.png',
+ 'cheatsheet-big.png (3342×2060)',
+ None),
+ ('http://qutebrowser.org/page-with-no-title.html',
+ '',
+ None),
+])
+def test_page_titles(url, title, out):
+ assert downloads.suggested_fn_from_title(url, title) == out
+
+
class TestDownloadTarget:
def test_base(self):
diff --git a/tests/unit/browser/webkit/test_history.py b/tests/unit/browser/webkit/test_history.py
deleted file mode 100644
index f40e41c2c..000000000
--- a/tests/unit/browser/webkit/test_history.py
+++ /dev/null
@@ -1,383 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2016-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
-#
-# 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 <http://www.gnu.org/licenses/>.
-
-"""Tests for the global page history."""
-
-import logging
-
-import pytest
-import hypothesis
-from hypothesis import strategies
-from PyQt5.QtCore import QUrl
-
-from qutebrowser.browser import history
-from qutebrowser.utils import objreg, urlutils, usertypes
-
-
-class FakeWebHistory:
-
- """A fake WebHistory object."""
-
- def __init__(self, history_dict):
- self.history_dict = history_dict
-
-
-@pytest.fixture()
-def hist(tmpdir, fake_save_manager):
- return history.WebHistory(hist_dir=str(tmpdir), hist_name='history')
-
-
-def test_async_read_twice(monkeypatch, qtbot, tmpdir, caplog,
- fake_save_manager):
- (tmpdir / 'filled-history').write('\n'.join([
- '12345 http://example.com/ title',
- '67890 http://example.com/',
- '12345 http://qutebrowser.org/ blah',
- ]))
- hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
- next(hist.async_read())
- with pytest.raises(StopIteration):
- next(hist.async_read())
- expected = "Ignoring async_read() because reading is started."
- assert len(caplog.records) == 1
- assert caplog.records[0].msg == expected
-
-
-@pytest.mark.parametrize('redirect', [True, False])
-def test_adding_item_during_async_read(qtbot, hist, redirect):
- """Check what happens when adding URL while reading the history."""
- url = QUrl('http://www.example.com/')
-
- with qtbot.assertNotEmitted(hist.add_completion_item), \
- qtbot.assertNotEmitted(hist.item_added):
- hist.add_url(url, redirect=redirect, atime=12345)
-
- if redirect:
- with qtbot.assertNotEmitted(hist.add_completion_item):
- with qtbot.waitSignal(hist.async_read_done):
- list(hist.async_read())
- else:
- with qtbot.waitSignals([hist.add_completion_item,
- hist.async_read_done], order='strict'):
- list(hist.async_read())
-
- assert not hist._temp_history
-
- expected = history.Entry(url=url, atime=12345, redirect=redirect, title="")
- assert list(hist.history_dict.values()) == [expected]
-
-
-def test_iter(hist):
- list(hist.async_read())
-
- url = QUrl('http://www.example.com/')
- hist.add_url(url, atime=12345)
-
- entry = history.Entry(url=url, atime=12345, redirect=False, title="")
- assert list(hist) == [entry]
-
-
-def test_len(hist):
- assert len(hist) == 0
- list(hist.async_read())
-
- url = QUrl('http://www.example.com/')
- hist.add_url(url)
-
- assert len(hist) == 1
-
-
-@pytest.mark.parametrize('line', [
- '12345 http://example.com/ title', # with title
- '67890 http://example.com/', # no title
- '12345 http://qutebrowser.org/ ', # trailing space
- ' ',
- '',
-])
-def test_read(hist, tmpdir, line):
- (tmpdir / 'filled-history').write(line + '\n')
- hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
- list(hist.async_read())
-
-
-def test_updated_entries(hist, tmpdir):
- (tmpdir / 'filled-history').write('12345 http://example.com/\n'
- '67890 http://example.com/\n')
- hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
- list(hist.async_read())
-
- assert hist.history_dict['http://example.com/'].atime == 67890
- hist.add_url(QUrl('http://example.com/'), atime=99999)
- assert hist.history_dict['http://example.com/'].atime == 99999
-
-
-def test_invalid_read(hist, tmpdir, caplog):
- (tmpdir / 'filled-history').write('foobar\n12345 http://example.com/')
- hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
- with caplog.at_level(logging.WARNING):
- list(hist.async_read())
-
- entries = list(hist.history_dict.values())
-
- assert len(entries) == 1
- assert len(caplog.records) == 1
- msg = "Invalid history entry 'foobar': 2 or 3 fields expected!"
- assert caplog.records[0].msg == msg
-
-
-def test_get_recent(hist, tmpdir):
- (tmpdir / 'filled-history').write('12345 http://example.com/')
- hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
- list(hist.async_read())
-
- hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890)
- lines = hist.get_recent()
-
- expected = ['12345 http://example.com/',
- '67890 http://www.qutebrowser.org/']
- assert lines == expected
-
-
-def test_save(hist, tmpdir):
- hist_file = tmpdir / 'filled-history'
- hist_file.write('12345 http://example.com/\n')
-
- hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
- list(hist.async_read())
-
- hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890)
- hist.save()
-
- lines = hist_file.read().splitlines()
- expected = ['12345 http://example.com/',
- '67890 http://www.qutebrowser.org/']
- assert lines == expected
-
- hist.add_url(QUrl('http://www.the-compiler.org/'), atime=99999)
- hist.save()
- expected.append('99999 http://www.the-compiler.org/')
-
- lines = hist_file.read().splitlines()
- assert lines == expected
-
-
-def test_clear(qtbot, hist, tmpdir):
- hist_file = tmpdir / 'filled-history'
- hist_file.write('12345 http://example.com/\n')
-
- hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
- list(hist.async_read())
-
- hist.add_url(QUrl('http://www.qutebrowser.org/'))
-
- with qtbot.waitSignal(hist.cleared):
- hist._do_clear()
-
- assert not hist_file.read()
- assert not hist.history_dict
- assert not hist._new_history
-
- hist.add_url(QUrl('http://www.the-compiler.org/'), atime=67890)
- hist.save()
-
- lines = hist_file.read().splitlines()
- assert lines == ['67890 http://www.the-compiler.org/']
-
-
-def test_add_item(qtbot, hist):
- list(hist.async_read())
- url = 'http://www.example.com/'
-
- with qtbot.waitSignals([hist.add_completion_item, hist.item_added],
- order='strict'):
- hist.add_url(QUrl(url), atime=12345, title="the title")
-
- entry = history.Entry(url=QUrl(url), redirect=False, atime=12345,
- title="the title")
- assert hist.history_dict[url] == entry
-
-
-def test_add_item_redirect(qtbot, hist):
- list(hist.async_read())
- url = 'http://www.example.com/'
- with qtbot.assertNotEmitted(hist.add_completion_item):
- with qtbot.waitSignal(hist.item_added):
- hist.add_url(QUrl(url), redirect=True, atime=12345)
-
- entry = history.Entry(url=QUrl(url), redirect=True, atime=12345, title="")
- assert hist.history_dict[url] == entry
-
-
-def test_add_item_redirect_update(qtbot, tmpdir, fake_save_manager):
- """A redirect update added should override a non-redirect one."""
- url = 'http://www.example.com/'
-
- hist_file = tmpdir / 'filled-history'
- hist_file.write('12345 {}\n'.format(url))
- hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
- list(hist.async_read())
-
- with qtbot.assertNotEmitted(hist.add_completion_item):
- with qtbot.waitSignal(hist.item_added):
- hist.add_url(QUrl(url), redirect=True, atime=67890)
-
- entry = history.Entry(url=QUrl(url), redirect=True, atime=67890, title="")
- assert hist.history_dict[url] == entry
-
-
-@pytest.mark.parametrize('line, expected', [
- (
- # old format without title
- '12345 http://example.com/',
- history.Entry(atime=12345, url=QUrl('http://example.com/'), title='',)
- ),
- (
- # trailing space without title
- '12345 http://example.com/ ',
- history.Entry(atime=12345, url=QUrl('http://example.com/'), title='',)
- ),
- (
- # new format with title
- '12345 http://example.com/ this is a title',
- history.Entry(atime=12345, url=QUrl('http://example.com/'),
- title='this is a title')
- ),
- (
- # weird NUL bytes
- '\x0012345 http://example.com/',
- history.Entry(atime=12345, url=QUrl('http://example.com/'), title=''),
- ),
- (
- # redirect flag
- '12345-r http://example.com/ this is a title',
- history.Entry(atime=12345, url=QUrl('http://example.com/'),
- title='this is a title', redirect=True)
- ),
-])
-def test_entry_parse_valid(line, expected):
- entry = history.Entry.from_str(line)
- assert entry == expected
-
-
-@pytest.mark.parametrize('line', [
- '12345', # one field
- '12345 ::', # invalid URL
- 'xyz http://www.example.com/', # invalid timestamp
- '12345-x http://www.example.com/', # invalid flags
- '12345-r-r http://www.example.com/', # double flags
-])
-def test_entry_parse_invalid(line):
- with pytest.raises(ValueError):
- history.Entry.from_str(line)
-
-
-@hypothesis.given(strategies.text())
-def test_entry_parse_hypothesis(text):
- """Make sure parsing works or gives us ValueError."""
- try:
- history.Entry.from_str(text)
- except ValueError:
- pass
-
-
-@pytest.mark.parametrize('entry, expected', [
- # simple
- (
- history.Entry(12345, QUrl('http://example.com/'), "the title"),
- "12345 http://example.com/ the title",
- ),
- # timestamp as float
- (
- history.Entry(12345.678, QUrl('http://example.com/'), "the title"),
- "12345 http://example.com/ the title",
- ),
- # no title
- (
- history.Entry(12345.678, QUrl('http://example.com/'), ""),
- "12345 http://example.com/",
- ),
- # redirect flag
- (
- history.Entry(12345.678, QUrl('http://example.com/'), "",
- redirect=True),
- "12345-r http://example.com/",
- ),
-])
-def test_entry_str(entry, expected):
- assert str(entry) == expected
-
-
-@pytest.fixture
-def hist_interface():
- # pylint: disable=invalid-name
- QtWebKit = pytest.importorskip('PyQt5.QtWebKit')
- from qutebrowser.browser.webkit import webkithistory
- QWebHistoryInterface = QtWebKit.QWebHistoryInterface
- # pylint: enable=invalid-name
- entry = history.Entry(atime=0, url=QUrl('http://www.example.com/'),
- title='example')
- history_dict = {'http://www.example.com/': entry}
- fake_hist = FakeWebHistory(history_dict)
- interface = webkithistory.WebHistoryInterface(fake_hist)
- QWebHistoryInterface.setDefaultInterface(interface)
- yield
- QWebHistoryInterface.setDefaultInterface(None)
-
-
-def test_history_interface(qtbot, webview, hist_interface):
- html = b"<a href='about:blank'>foo</a>"
- url = urlutils.data_url('text/html', html)
- with qtbot.waitSignal(webview.loadFinished):
- webview.load(url)
-
-
-@pytest.mark.parametrize('backend', [usertypes.Backend.QtWebEngine,
- usertypes.Backend.QtWebKit])
-def test_init(backend, qapp, tmpdir, monkeypatch, fake_save_manager):
- if backend == usertypes.Backend.QtWebKit:
- pytest.importorskip('PyQt5.QtWebKitWidgets')
- else:
- assert backend == usertypes.Backend.QtWebEngine
-
- monkeypatch.setattr(history.standarddir, 'data', lambda: str(tmpdir))
- monkeypatch.setattr(history.objects, 'backend', backend)
- history.init(qapp)
- hist = objreg.get('web-history')
- assert hist.parent() is qapp
-
- try:
- from PyQt5.QtWebKit import QWebHistoryInterface
- except ImportError:
- QWebHistoryInterface = None
-
- if backend == usertypes.Backend.QtWebKit:
- default_interface = QWebHistoryInterface.defaultInterface()
- assert default_interface._history is hist
- else:
- assert backend == usertypes.Backend.QtWebEngine
- if QWebHistoryInterface is None:
- default_interface = None
- else:
- default_interface = QWebHistoryInterface.defaultInterface()
- # For this to work, nothing can ever have called setDefaultInterface
- # before (so we need to test webengine before webkit)
- assert default_interface is None
-
- assert fake_save_manager.add_saveable.called
- objreg.delete('web-history')
diff --git a/tests/unit/completion/test_column_widths.py b/tests/unit/completion/test_column_widths.py
deleted file mode 100644
index 21456ed37..000000000
--- a/tests/unit/completion/test_column_widths.py
+++ /dev/null
@@ -1,50 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2015-2017 Alexander Cogneau <alexander.cogneau@gmail.com>
-#
-# 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 <http://www.gnu.org/licenses/>.
-
-"""Tests for qutebrowser.completion.models column widths."""
-
-import pytest
-
-from qutebrowser.completion.models.base import BaseCompletionModel
-from qutebrowser.completion.models.configmodel import (
- SettingOptionCompletionModel, SettingSectionCompletionModel,
- SettingValueCompletionModel)
-from qutebrowser.completion.models.miscmodels import (
- CommandCompletionModel, HelpCompletionModel, QuickmarkCompletionModel,
- BookmarkCompletionModel, SessionCompletionModel)
-from qutebrowser.completion.models.urlmodel import UrlCompletionModel
-
-
-CLASSES = [BaseCompletionModel, SettingOptionCompletionModel,
- SettingOptionCompletionModel, SettingSectionCompletionModel,
- SettingValueCompletionModel, CommandCompletionModel,
- HelpCompletionModel, QuickmarkCompletionModel,
- BookmarkCompletionModel, SessionCompletionModel, UrlCompletionModel]
-
-
-@pytest.mark.parametrize("model", CLASSES)
-def test_list_size(model):
- """Test if there are 3 items in the COLUMN_WIDTHS property."""
- assert len(model.COLUMN_WIDTHS) == 3
-
-
-@pytest.mark.parametrize("model", CLASSES)
-def test_column_width_sum(model):
- """Test if the sum of the widths asserts to 100."""
- assert sum(model.COLUMN_WIDTHS) == 100
diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py
index 80e3cd05a..74b2e51f5 100644
--- a/tests/unit/completion/test_completer.py
+++ b/tests/unit/completion/test_completer.py
@@ -26,7 +26,6 @@ from PyQt5.QtCore import QObject
from PyQt5.QtGui import QStandardItemModel
from qutebrowser.completion import completer
-from qutebrowser.utils import usertypes
from qutebrowser.commands import command, cmdutils
@@ -38,11 +37,10 @@ class FakeCompletionModel(QStandardItemModel):
"""Stub for a completion model."""
- DUMB_SORT = None
-
- def __init__(self, kind, parent=None):
+ def __init__(self, kind, *pos_args, parent=None):
super().__init__(parent)
self.kind = kind
+ self.pos_args = list(pos_args)
class CompletionWidgetStub(QObject):
@@ -74,39 +72,45 @@ def completer_obj(qtbot, status_command_stub, config_stub, monkeypatch, stubs,
@pytest.fixture(autouse=True)
-def instances(monkeypatch):
- """Mock the instances module so get returns a fake completion model."""
- # populate a model for each completion type, with a nested structure for
- # option and value completion
- instances = {kind: FakeCompletionModel(kind)
- for kind in usertypes.Completion}
- instances[usertypes.Completion.option] = {
- 'general': FakeCompletionModel(usertypes.Completion.option),
- }
- instances[usertypes.Completion.value] = {
- 'general': {
- 'editor': FakeCompletionModel(usertypes.Completion.value),
- }
- }
- monkeypatch.setattr(completer, 'instances', instances)
+def miscmodels_patch(mocker):
+ """Patch the miscmodels module to provide fake completion functions.
+
+ Technically some of these are not part of miscmodels, but rolling them into
+ one module is easier and sufficient for mocking. The only one referenced
+ directly by Completer is miscmodels.command.
+ """
+ m = mocker.patch('qutebrowser.completion.completer.miscmodels',
+ autospec=True)
+ m.command = lambda *args: FakeCompletionModel('command', *args)
+ m.helptopic = lambda *args: FakeCompletionModel('helptopic', *args)
+ m.quickmark = lambda *args: FakeCompletionModel('quickmark', *args)
+ m.bookmark = lambda *args: FakeCompletionModel('bookmark', *args)
+ m.session = lambda *args: FakeCompletionModel('session', *args)
+ m.buffer = lambda *args: FakeCompletionModel('buffer', *args)
+ m.bind = lambda *args: FakeCompletionModel('bind', *args)
+ m.url = lambda *args: FakeCompletionModel('url', *args)
+ m.section = lambda *args: FakeCompletionModel('section', *args)
+ m.option = lambda *args: FakeCompletionModel('option', *args)
+ m.value = lambda *args: FakeCompletionModel('value', *args)
+ return m
@pytest.fixture(autouse=True)
-def cmdutils_patch(monkeypatch, stubs):
+def cmdutils_patch(monkeypatch, stubs, miscmodels_patch):
"""Patch the cmdutils module to provide fake commands."""
- @cmdutils.argument('section_', completion=usertypes.Completion.section)
- @cmdutils.argument('option', completion=usertypes.Completion.option)
- @cmdutils.argument('value', completion=usertypes.Completion.value)
+ @cmdutils.argument('section_', completion=miscmodels_patch.section)
+ @cmdutils.argument('option', completion=miscmodels_patch.option)
+ @cmdutils.argument('value', completion=miscmodels_patch.value)
def set_command(section_=None, option=None, value=None):
"""docstring."""
pass
- @cmdutils.argument('topic', completion=usertypes.Completion.helptopic)
+ @cmdutils.argument('topic', completion=miscmodels_patch.helptopic)
def show_help(tab=False, bg=False, window=False, topic=None):
"""docstring."""
pass
- @cmdutils.argument('url', completion=usertypes.Completion.url)
+ @cmdutils.argument('url', completion=miscmodels_patch.url)
@cmdutils.argument('count', count=True)
def openurl(url=None, related=False, bg=False, tab=False, window=False,
count=None):
@@ -114,7 +118,7 @@ def cmdutils_patch(monkeypatch, stubs):
pass
@cmdutils.argument('win_id', win_id=True)
- @cmdutils.argument('command', completion=usertypes.Completion.command)
+ @cmdutils.argument('command', completion=miscmodels_patch.command)
def bind(key, win_id, command=None, *, mode='normal', force=False):
"""docstring."""
pass
@@ -144,60 +148,61 @@ def _set_cmd_prompt(cmd, txt):
cmd.setCursorPosition(txt.index('|'))
-@pytest.mark.parametrize('txt, kind, pattern', [
- (':nope|', usertypes.Completion.command, 'nope'),
- (':nope |', None, ''),
- (':set |', usertypes.Completion.section, ''),
- (':set gen|', usertypes.Completion.section, 'gen'),
- (':set general |', usertypes.Completion.option, ''),
- (':set what |', None, ''),
- (':set general editor |', usertypes.Completion.value, ''),
- (':set general editor gv|', usertypes.Completion.value, 'gv'),
- (':set general editor "gvim -f"|', usertypes.Completion.value, 'gvim -f'),
- (':set general editor "gvim |', usertypes.Completion.value, 'gvim'),
- (':set general huh |', None, ''),
- (':help |', usertypes.Completion.helptopic, ''),
- (':help |', usertypes.Completion.helptopic, ''),
- (':open |', usertypes.Completion.url, ''),
- (':bind |', None, ''),
- (':bind <c-x> |', usertypes.Completion.command, ''),
- (':bind <c-x> foo|', usertypes.Completion.command, 'foo'),
- (':bind <c-x>| foo', None, '<c-x>'),
- (':set| general ', usertypes.Completion.command, 'set'),
- (':|set general ', usertypes.Completion.command, 'set'),
- (':set gene|ral ignore-case', usertypes.Completion.section, 'general'),
- (':|', usertypes.Completion.command, ''),
- (': |', usertypes.Completion.command, ''),
- ('/|', None, ''),
- (':open -t|', None, ''),
- (':open --tab|', None, ''),
- (':open -t |', usertypes.Completion.url, ''),
- (':open --tab |', usertypes.Completion.url, ''),
- (':open | -t', usertypes.Completion.url, ''),
- (':tab-detach |', None, ''),
- (':bind --mode=caret <c-x> |', usertypes.Completion.command, ''),
- pytest.param(':bind --mode caret <c-x> |', usertypes.Completion.command,
- '', marks=pytest.mark.xfail(reason='issue #74')),
- (':set -t -p |', usertypes.Completion.section, ''),
- (':open -- |', None, ''),
- (':gibberish nonesense |', None, ''),
- ('/:help|', None, ''),
- ('::bind|', usertypes.Completion.command, ':bind'),
+@pytest.mark.parametrize('txt, kind, pattern, pos_args', [
+ (':nope|', 'command', 'nope', []),
+ (':nope |', None, '', []),
+ (':set |', 'section', '', []),
+ (':set gen|', 'section', 'gen', []),
+ (':set general |', 'option', '', ['general']),
+ (':set what |', 'option', '', ['what']),
+ (':set general editor |', 'value', '', ['general', 'editor']),
+ (':set general editor gv|', 'value', 'gv', ['general', 'editor']),
+ (':set general editor "gvim -f"|', 'value', 'gvim -f',
+ ['general', 'editor']),
+ (':set general editor "gvim |', 'value', 'gvim', ['general', 'editor']),
+ (':set general huh |', 'value', '', ['general', 'huh']),
+ (':help |', 'helptopic', '', []),
+ (':help |', 'helptopic', '', []),
+ (':open |', 'url', '', []),
+ (':bind |', None, '', []),
+ (':bind <c-x> |', 'command', '', ['<c-x>']),
+ (':bind <c-x> foo|', 'command', 'foo', ['<c-x>']),
+ (':bind <c-x>| foo', None, '<c-x>', []),
+ (':set| general ', 'command', 'set', []),
+ (':|set general ', 'command', 'set', []),
+ (':set gene|ral ignore-case', 'section', 'general', []),
+ (':|', 'command', '', []),
+ (': |', 'command', '', []),
+ ('/|', None, '', []),
+ (':open -t|', None, '', []),
+ (':open --tab|', None, '', []),
+ (':open -t |', 'url', '', []),
+ (':open --tab |', 'url', '', []),
+ (':open | -t', 'url', '', []),
+ (':tab-detach |', None, '', []),
+ (':bind --mode=caret <c-x> |', 'command', '', ['<c-x>']),
+ pytest.param(':bind --mode caret <c-x> |', 'command', '', [],
+ marks=pytest.mark.xfail(reason='issue #74')),
+ (':set -t -p |', 'section', '', []),
+ (':open -- |', None, '', []),
+ (':gibberish nonesense |', None, '', []),
+ ('/:help|', None, '', []),
+ ('::bind|', 'command', ':bind', []),
])
-def test_update_completion(txt, kind, pattern, status_command_stub,
+def test_update_completion(txt, kind, pattern, pos_args, status_command_stub,
completer_obj, completion_widget_stub):
"""Test setting the completion widget's model based on command text."""
# this test uses | as a placeholder for the current cursor position
_set_cmd_prompt(status_command_stub, txt)
completer_obj.schedule_completion_update()
- assert completion_widget_stub.set_model.call_count == 1
- args = completion_widget_stub.set_model.call_args[0]
- # the outer model is just for sorting; srcmodel is the completion model
if kind is None:
- assert args[0] is None
+ assert completion_widget_stub.set_pattern.call_count == 0
else:
- assert args[0].srcmodel.kind == kind
- assert args[1] == pattern
+ assert completion_widget_stub.set_model.call_count == 1
+ model = completion_widget_stub.set_model.call_args[0][0]
+ assert model.kind == kind
+ assert model.pos_args == pos_args
+ completion_widget_stub.set_pattern.assert_called_once_with(pattern)
@pytest.mark.parametrize('before, newtxt, after', [
diff --git a/tests/unit/completion/test_completionmodel.py b/tests/unit/completion/test_completionmodel.py
new file mode 100644
index 000000000..9e73e533a
--- /dev/null
+++ b/tests/unit/completion/test_completionmodel.py
@@ -0,0 +1,117 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Tests for CompletionModel."""
+
+from unittest import mock
+import hypothesis
+from hypothesis import strategies
+
+import pytest
+from PyQt5.QtCore import QModelIndex
+
+from qutebrowser.completion.models import completionmodel, listcategory
+from qutebrowser.utils import qtutils
+from qutebrowser.commands import cmdexc
+
+
+@hypothesis.given(strategies.lists(min_size=0, max_size=3,
+ elements=strategies.integers(min_value=0, max_value=2**31)))
+def test_first_last_item(counts):
+ """Test that first() and last() index to the first and last items."""
+ model = completionmodel.CompletionModel()
+ for c in counts:
+ cat = mock.Mock(spec=['layoutChanged', 'layoutAboutToBeChanged'])
+ cat.rowCount = mock.Mock(return_value=c, spec=[])
+ model.add_category(cat)
+ data = [i for i, rowCount in enumerate(counts) if rowCount > 0]
+ if not data:
+ # with no items, first and last should be an invalid index
+ assert not model.first_item().isValid()
+ assert not model.last_item().isValid()
+ else:
+ first = data[0]
+ last = data[-1]
+ # first item of the first data category
+ assert model.first_item().row() == 0
+ assert model.first_item().parent().row() == first
+ # last item of the last data category
+ assert model.last_item().row() == counts[last] - 1
+ assert model.last_item().parent().row() == last
+
+
+@hypothesis.given(strategies.lists(elements=strategies.integers(),
+ min_size=0, max_size=3))
+def test_count(counts):
+ model = completionmodel.CompletionModel()
+ for c in counts:
+ cat = mock.Mock(spec=['rowCount', 'layoutChanged',
+ 'layoutAboutToBeChanged'])
+ cat.rowCount = mock.Mock(return_value=c, spec=[])
+ model.add_category(cat)
+ assert model.count() == sum(counts)
+
+
+@hypothesis.given(strategies.text())
+def test_set_pattern(pat):
+ """Validate the filtering and sorting results of set_pattern."""
+ model = completionmodel.CompletionModel()
+ cats = [mock.Mock(spec=['set_pattern', 'layoutChanged',
+ 'layoutAboutToBeChanged'])
+ for _ in range(3)]
+ for c in cats:
+ c.set_pattern = mock.Mock(spec=[])
+ model.add_category(c)
+ model.set_pattern(pat)
+ for c in cats:
+ c.set_pattern.assert_called_with(pat)
+
+
+def test_delete_cur_item():
+ func = mock.Mock(spec=[])
+ model = completionmodel.CompletionModel()
+ cat = listcategory.ListCategory('', [('foo', 'bar')], delete_func=func)
+ model.add_category(cat)
+ parent = model.index(0, 0)
+ model.delete_cur_item(model.index(0, 0, parent))
+ func.assert_called_once_with(['foo', 'bar'])
+
+
+def test_delete_cur_item_no_func():
+ callback = mock.Mock(spec=[])
+ model = completionmodel.CompletionModel()
+ cat = listcategory.ListCategory('', [('foo', 'bar')], delete_func=None)
+ model.rowsAboutToBeRemoved.connect(callback)
+ model.rowsRemoved.connect(callback)
+ model.add_category(cat)
+ parent = model.index(0, 0)
+ with pytest.raises(cmdexc.CommandError):
+ model.delete_cur_item(model.index(0, 0, parent))
+ assert not callback.called
+
+
+def test_delete_cur_item_no_cat():
+ """Test completion_item_del with no selected category."""
+ callback = mock.Mock(spec=[])
+ model = completionmodel.CompletionModel()
+ model.rowsAboutToBeRemoved.connect(callback)
+ model.rowsRemoved.connect(callback)
+ with pytest.raises(qtutils.QtValueError):
+ model.delete_cur_item(QModelIndex())
+ assert not callback.called
diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py
index a19161b91..7eb7fe2b5 100644
--- a/tests/unit/completion/test_completionwidget.py
+++ b/tests/unit/completion/test_completionwidget.py
@@ -19,13 +19,13 @@
"""Tests for the CompletionView Object."""
-import unittest.mock
+from unittest import mock
import pytest
-from PyQt5.QtGui import QStandardItem
from qutebrowser.completion import completionwidget
-from qutebrowser.completion.models import base, sortfilter
+from qutebrowser.completion.models import completionmodel, listcategory
+from qutebrowser.commands import cmdexc
@pytest.fixture
@@ -34,6 +34,9 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry,
"""Create the CompletionView used for testing."""
# mock the Completer that the widget creates in its constructor
mocker.patch('qutebrowser.completion.completer.Completer', autospec=True)
+ mocker.patch(
+ 'qutebrowser.completion.completiondelegate.CompletionItemDelegate',
+ new=lambda *_: None)
view = completionwidget.CompletionView(win_id=0)
qtbot.addWidget(view)
return view
@@ -41,21 +44,27 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry,
def test_set_model(completionview):
"""Ensure set_model actually sets the model and expands all categories."""
- model = base.BaseCompletionModel()
- filtermodel = sortfilter.CompletionFilterModel(model)
+ model = completionmodel.CompletionModel()
for i in range(3):
- model.appendRow(QStandardItem(str(i)))
- completionview.set_model(filtermodel)
- assert completionview.model() is filtermodel
- for i in range(model.rowCount()):
- assert completionview.isExpanded(filtermodel.index(i, 0))
+ model.add_category(listcategory.ListCategory('', [('foo',)]))
+ completionview.set_model(model)
+ assert completionview.model() is model
+ for i in range(3):
+ assert completionview.isExpanded(model.index(i, 0))
def test_set_pattern(completionview):
- model = sortfilter.CompletionFilterModel(base.BaseCompletionModel())
- model.set_pattern = unittest.mock.Mock()
- completionview.set_model(model, 'foo')
+ model = completionmodel.CompletionModel()
+ model.set_pattern = mock.Mock(spec=[])
+ completionview.set_model(model)
+ completionview.set_pattern('foo')
model.set_pattern.assert_called_with('foo')
+ assert not completionview.selectionModel().currentIndex().isValid()
+
+
+def test_set_pattern_no_model(completionview):
+ """Ensure that setting a pattern with no model does not fail."""
+ completionview.set_pattern('foo')
def test_maybe_update_geometry(completionview, config_stub, qtbot):
@@ -118,15 +127,11 @@ def test_completion_item_focus(which, tree, expected, completionview, qtbot):
successive movement. None implies no signal should be
emitted.
"""
- model = base.BaseCompletionModel()
+ model = completionmodel.CompletionModel()
for catdata in tree:
- cat = QStandardItem()
- model.appendRow(cat)
- for name in catdata:
- cat.appendRow(QStandardItem(name))
- filtermodel = sortfilter.CompletionFilterModel(model,
- parent=completionview)
- completionview.set_model(filtermodel)
+ cat = listcategory.ListCategory('', ((x,) for x in catdata))
+ model.add_category(cat)
+ completionview.set_model(model)
for entry in expected:
if entry is None:
with qtbot.assertNotEmitted(completionview.selection_changed):
@@ -146,15 +151,44 @@ def test_completion_item_focus_no_model(which, completionview, qtbot):
"""
with qtbot.assertNotEmitted(completionview.selection_changed):
completionview.completion_item_focus(which)
- model = base.BaseCompletionModel()
- filtermodel = sortfilter.CompletionFilterModel(model,
- parent=completionview)
- completionview.set_model(filtermodel)
+ model = completionmodel.CompletionModel()
+ completionview.set_model(model)
completionview.set_model(None)
with qtbot.assertNotEmitted(completionview.selection_changed):
completionview.completion_item_focus(which)
+def test_completion_item_focus_fetch(completionview, qtbot):
+ """Test that on_next_prev_item moves the selection properly.
+
+ Args:
+ which: the direction in which to move the selection.
+ tree: Each list represents a completion category, with each string
+ being an item under that category.
+ expected: expected argument from on_selection_changed for each
+ successive movement. None implies no signal should be
+ emitted.
+ """
+ model = completionmodel.CompletionModel()
+ cat = mock.Mock(spec=['layoutChanged', 'layoutAboutToBeChanged',
+ 'canFetchMore', 'fetchMore', 'rowCount', 'index', 'data'])
+ cat.canFetchMore = lambda *_: True
+ cat.rowCount = lambda *_: 2
+ cat.fetchMore = mock.Mock()
+ model.add_category(cat)
+ completionview.set_model(model)
+ # clear the fetchMore call that happens on set_model
+ cat.reset_mock()
+
+ # not at end, fetchMore shouldn't be called
+ completionview.completion_item_focus('next')
+ assert not cat.fetchMore.called
+
+ # at end, fetchMore should be called
+ completionview.completion_item_focus('next')
+ assert cat.fetchMore.called
+
+
@pytest.mark.parametrize('show', ['always', 'auto', 'never'])
@pytest.mark.parametrize('rows', [[], ['Aa'], ['Aa', 'Bb']])
@pytest.mark.parametrize('quick_complete', [True, False])
@@ -170,16 +204,13 @@ def test_completion_show(show, rows, quick_complete, completionview,
config_stub.val.completion.show = show
config_stub.val.completion.quick = quick_complete
- model = base.BaseCompletionModel()
+ model = completionmodel.CompletionModel()
for name in rows:
- cat = QStandardItem()
- model.appendRow(cat)
- cat.appendRow(QStandardItem(name))
- filtermodel = sortfilter.CompletionFilterModel(model,
- parent=completionview)
+ cat = listcategory.ListCategory('', [(name,)])
+ model.add_category(cat)
assert not completionview.isVisible()
- completionview.set_model(filtermodel)
+ completionview.set_model(model)
assert completionview.isVisible() == (show == 'always' and len(rows) > 0)
completionview.completion_item_focus('next')
expected = (show != 'never' and len(rows) > 0 and
@@ -188,3 +219,32 @@ def test_completion_show(show, rows, quick_complete, completionview,
completionview.set_model(None)
completionview.completion_item_focus('next')
assert not completionview.isVisible()
+
+
+def test_completion_item_del(completionview):
+ """Test that completion_item_del invokes delete_cur_item in the model."""
+ func = mock.Mock(spec=[])
+ model = completionmodel.CompletionModel()
+ cat = listcategory.ListCategory('', [('foo', 'bar')], delete_func=func)
+ model.add_category(cat)
+ completionview.set_model(model)
+ completionview.completion_item_focus('next')
+ completionview.completion_item_del()
+ func.assert_called_once_with(['foo', 'bar'])
+
+
+def test_completion_item_del_no_selection(completionview):
+ """Test that completion_item_del with an invalid index."""
+ func = mock.Mock(spec=[])
+ model = completionmodel.CompletionModel()
+ cat = listcategory.ListCategory('', [('foo',)], delete_func=func)
+ model.add_category(cat)
+ completionview.set_model(model)
+ with pytest.raises(cmdexc.CommandError, match='No item selected!'):
+ completionview.completion_item_del()
+ assert not func.called
+
+
+def test_resize_no_model(completionview, qtbot):
+ """Ensure no crash if resizeEvent is triggered with no model (#2854)."""
+ completionview.resizeEvent(None)
diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py
new file mode 100644
index 000000000..1397b8b5f
--- /dev/null
+++ b/tests/unit/completion/test_histcategory.py
@@ -0,0 +1,167 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Test the web history completion category."""
+
+import datetime
+
+import pytest
+
+from qutebrowser.misc import sql
+from qutebrowser.completion.models import histcategory
+
+
+@pytest.fixture
+def hist(init_sql, config_stub):
+ config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d',
+ 'web-history-max-items': -1}
+ return sql.SqlTable('CompletionHistory', ['url', 'title', 'last_atime'])
+
+
+@pytest.mark.parametrize('pattern, before, after', [
+ ('foo',
+ [('foo', ''), ('bar', ''), ('aafobbb', '')],
+ [('foo',)]),
+
+ ('FOO',
+ [('foo', ''), ('bar', ''), ('aafobbb', '')],
+ [('foo',)]),
+
+ ('foo',
+ [('FOO', ''), ('BAR', ''), ('AAFOBBB', '')],
+ [('FOO',)]),
+
+ ('foo',
+ [('baz', 'bar'), ('foo', ''), ('bar', 'foo')],
+ [('foo', ''), ('bar', 'foo')]),
+
+ ('foo',
+ [('fooa', ''), ('foob', ''), ('fooc', '')],
+ [('fooa', ''), ('foob', ''), ('fooc', '')]),
+
+ ('foo',
+ [('foo', 'bar'), ('bar', 'foo'), ('biz', 'baz')],
+ [('foo', 'bar'), ('bar', 'foo')]),
+
+ ('foo bar',
+ [('foo', ''), ('bar foo', ''), ('xfooyybarz', '')],
+ [('xfooyybarz', '')]),
+
+ ('foo%bar',
+ [('foo%bar', ''), ('foo bar', ''), ('foobar', '')],
+ [('foo%bar', '')]),
+
+ ('_',
+ [('a_b', ''), ('__a', ''), ('abc', '')],
+ [('a_b', ''), ('__a', '')]),
+
+ ('%',
+ [('\\foo', '\\bar')],
+ []),
+
+ ("can't",
+ [("can't touch this", ''), ('a', '')],
+ [("can't touch this", '')]),
+])
+def test_set_pattern(pattern, before, after, model_validator, hist):
+ """Validate the filtering and sorting results of set_pattern."""
+ for row in before:
+ hist.insert({'url': row[0], 'title': row[1], 'last_atime': 1})
+ cat = histcategory.HistoryCategory()
+ model_validator.set_model(cat)
+ cat.set_pattern(pattern)
+ model_validator.validate(after)
+
+
+@pytest.mark.parametrize('max_items, before, after', [
+ (-1, [
+ ('a', 'a', '2017-04-16'),
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ], [
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ('a', 'a', '2017-04-16'),
+ ]),
+ (3, [
+ ('a', 'a', '2017-04-16'),
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ], [
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ('a', 'a', '2017-04-16'),
+ ]),
+ (2 ** 63 - 1, [ # Maximum value sqlite can handle for LIMIT
+ ('a', 'a', '2017-04-16'),
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ], [
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ('a', 'a', '2017-04-16'),
+ ]),
+ (2, [
+ ('a', 'a', '2017-04-16'),
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ], [
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ]),
+ (1, [], []), # issue 2849 (crash with empty history)
+])
+def test_sorting(max_items, before, after, model_validator, hist, config_stub):
+ """Validate the filtering and sorting results of set_pattern."""
+ config_stub.data['completion']['web-history-max-items'] = max_items
+ for url, title, atime in before:
+ timestamp = datetime.datetime.strptime(atime, '%Y-%m-%d').timestamp()
+ hist.insert({'url': url, 'title': title, 'last_atime': timestamp})
+ cat = histcategory.HistoryCategory()
+ model_validator.set_model(cat)
+ cat.set_pattern('')
+ model_validator.validate(after)
+
+
+def test_remove_rows(hist, model_validator):
+ hist.insert({'url': 'foo', 'title': 'Foo'})
+ hist.insert({'url': 'bar', 'title': 'Bar'})
+ cat = histcategory.HistoryCategory()
+ model_validator.set_model(cat)
+ cat.set_pattern('')
+ hist.delete('url', 'foo')
+ cat.removeRows(0, 1)
+ model_validator.validate([('bar', 'Bar', '')])
+
+
+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)]})
+ cat = histcategory.HistoryCategory()
+ cat.set_pattern('')
+
+ # sanity check that we didn't fetch everything up front
+ assert cat.rowCount() < 300
+ cat.fetchMore()
+ assert cat.rowCount() == 300
+
+ hist.delete('url', '298')
+ cat.removeRows(297, 1)
+ assert cat.rowCount() == 299
diff --git a/tests/unit/completion/test_listcategory.py b/tests/unit/completion/test_listcategory.py
new file mode 100644
index 000000000..8d8936167
--- /dev/null
+++ b/tests/unit/completion/test_listcategory.py
@@ -0,0 +1,50 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Tests for CompletionFilterModel."""
+
+import pytest
+
+from qutebrowser.completion.models import listcategory
+
+
+@pytest.mark.parametrize('pattern, before, after', [
+ ('foo',
+ [('foo', ''), ('bar', '')],
+ [('foo', '')]),
+
+ ('foo',
+ [('foob', ''), ('fooc', ''), ('fooa', '')],
+ [('fooa', ''), ('foob', ''), ('fooc', '')]),
+
+ # prefer foobar as it starts with the pattern
+ ('foo',
+ [('barfoo', ''), ('foobar', '')],
+ [('foobar', ''), ('barfoo', '')]),
+
+ ('foo',
+ [('foo', 'bar'), ('bar', 'foo'), ('bar', 'bar')],
+ [('foo', 'bar'), ('bar', 'foo')]),
+])
+def test_set_pattern(pattern, before, after, model_validator):
+ """Validate the filtering and sorting results of set_pattern."""
+ cat = listcategory.ListCategory('Foo', before)
+ model_validator.set_model(cat)
+ cat.set_pattern(pattern)
+ model_validator.validate(after)
diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py
index 5004fe077..2c9607cbd 100644
--- a/tests/unit/completion/test_models.py
+++ b/tests/unit/completion/test_models.py
@@ -27,15 +27,10 @@ from datetime import datetime
import pytest
from PyQt5.QtCore import QUrl
-from PyQt5.QtWidgets import QTreeView
-from qutebrowser.completion.models import (miscmodels, urlmodel, configmodel,
- sortfilter)
-from qutebrowser.browser import history
-
-
-pytestmark = pytest.mark.skip("FIXME:conf reintroduce after new completion "
- "is in")
+from qutebrowser.completion.models import miscmodels, urlmodel, configmodel
+from qutebrowser.config import sections, value
+from qutebrowser.utils import objreg
def _check_completions(model, expected):
@@ -49,19 +44,21 @@ def _check_completions(model, expected):
...
}
"""
+ actual = {}
assert model.rowCount() == len(expected)
for i in range(0, model.rowCount()):
- actual_cat = model.item(i)
- catname = actual_cat.text()
- assert catname in expected
- expected_cat = expected[catname]
- assert actual_cat.rowCount() == len(expected_cat)
- for j in range(0, actual_cat.rowCount()):
- name = actual_cat.child(j, 0)
- desc = actual_cat.child(j, 1)
- misc = actual_cat.child(j, 2)
- actual_item = (name.text(), desc.text(), misc.text())
- assert actual_item in expected_cat
+ catidx = model.index(i, 0)
+ catname = model.data(catidx)
+ actual[catname] = []
+ for j in range(model.rowCount(catidx)):
+ name = model.data(model.index(j, 0, parent=catidx))
+ desc = model.data(model.index(j, 1, parent=catidx))
+ misc = model.data(model.index(j, 2, parent=catidx))
+ actual[catname].append((name, desc, misc))
+ assert actual == expected
+ # sanity-check the column_widths
+ assert len(model.column_widths) == 3
+ assert sum(model.column_widths) == 100
def _patch_cmdutils(monkeypatch, stubs, symbol):
@@ -119,22 +116,6 @@ def _patch_config_section_desc(monkeypatch, stubs, symbol):
monkeypatch.setattr(symbol, section_desc)
-def _mock_view_index(model, category_idx, child_idx, qtbot):
- """Create a tree view from a model and set the current index.
-
- Args:
- model: model to create a fake view for.
- category_idx: index of the category to select.
- child_idx: index of the child item under that category to select.
- """
- view = QTreeView()
- qtbot.add_widget(view)
- view.setModel(model)
- idx = model.indexFromItem(model.item(category_idx).child(child_idx))
- view.setCurrentIndex(idx)
- return view
-
-
@pytest.fixture
def quickmarks(quickmark_manager_stub):
"""Pre-populate the quickmark-manager stub with some quickmarks."""
@@ -158,20 +139,35 @@ def bookmarks(bookmark_manager_stub):
@pytest.fixture
-def web_history(stubs, web_history_stub):
- """Pre-populate the web-history stub with some history entries."""
- web_history_stub.history_dict = collections.OrderedDict([
- ('http://qutebrowser.org', history.Entry(
- datetime(2015, 9, 5).timestamp(),
- QUrl('http://qutebrowser.org'), 'qutebrowser | qutebrowser')),
- ('https://python.org', history.Entry(
- datetime(2016, 3, 8).timestamp(),
- QUrl('https://python.org'), 'Welcome to Python.org')),
- ('https://github.com', history.Entry(
- datetime(2016, 5, 1).timestamp(),
- QUrl('https://github.com'), 'GitHub')),
- ])
- return web_history_stub
+def web_history(init_sql, stubs, config_stub):
+ """Fixture which provides a web-history object."""
+ config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d',
+ 'web-history-max-items': -1}
+ stub = history.WebHistory()
+ objreg.register('web-history', stub)
+ yield stub
+ objreg.delete('web-history')
+
+
+@pytest.fixture
+def web_history_populated(web_history):
+ """Pre-populate the web-history database."""
+ web_history.add_url(
+ url=QUrl('http://qutebrowser.org'),
+ title='qutebrowser',
+ atime=datetime(2015, 9, 5).timestamp()
+ )
+ web_history.add_url(
+ url=QUrl('https://python.org'),
+ title='Welcome to Python.org',
+ atime=datetime(2016, 3, 8).timestamp()
+ )
+ web_history.add_url(
+ url=QUrl('https://github.com'),
+ title='https://github.com',
+ atime=datetime(2016, 5, 1).timestamp()
+ )
+ return web_history
def test_command_completion(qtmodeltester, monkeypatch, stubs, config_stub,
@@ -190,16 +186,17 @@ def test_command_completion(qtmodeltester, monkeypatch, stubs, config_stub,
key_config_stub.set_bindings_for('normal', {'s': 'stop',
'rr': 'roll',
'ro': 'rock'})
- model = miscmodels.CommandCompletionModel()
+ model = miscmodels.command()
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
_check_completions(model, {
"Commands": [
- ('stop', 'stop qutebrowser', 's'),
('drop', 'drop all user data', ''),
- ('roll', 'never gonna give you up', 'rr'),
('rock', "Alias for 'roll'", 'ro'),
+ ('roll', 'never gonna give you up', 'rr'),
+ ('stop', 'stop qutebrowser', 's'),
]
})
@@ -218,135 +215,252 @@ def test_help_completion(qtmodeltester, monkeypatch, stubs, key_config_stub):
key_config_stub.set_bindings_for('normal', {'s': 'stop', 'rr': 'roll'})
_patch_cmdutils(monkeypatch, stubs, module + '.cmdutils')
_patch_configdata(monkeypatch, stubs, module + '.configdata.DATA')
- model = miscmodels.HelpCompletionModel()
+ model = miscmodels.helptopic()
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
_check_completions(model, {
"Commands": [
- (':stop', 'stop qutebrowser', 's'),
(':drop', 'drop all user data', ''),
- (':roll', 'never gonna give you up', 'rr'),
(':hide', '', ''),
+ (':roll', 'never gonna give you up', 'rr'),
+ (':stop', 'stop qutebrowser', 's'),
],
"Settings": [
- ('general->time', 'Is an illusion.', ''),
- ('general->volume', 'Goes to 11', ''),
- ('ui->gesture', 'Waggle your hands to control qutebrowser', ''),
- ('ui->mind', 'Enable mind-control ui (experimental)', ''),
- ('ui->voice', 'Whether to respond to voice commands', ''),
- ('searchengines->DEFAULT', '', ''),
+ ('general->time', 'Is an illusion.', None),
+ ('general->volume', 'Goes to 11', None),
+ ('searchengines->DEFAULT', '', None),
+ ('ui->gesture', 'Waggle your hands to control qutebrowser', None),
+ ('ui->mind', 'Enable mind-control ui (experimental)', None),
+ ('ui->voice', 'Whether to respond to voice commands', None),
]
})
def test_quickmark_completion(qtmodeltester, quickmarks):
"""Test the results of quickmark completion."""
- model = miscmodels.QuickmarkCompletionModel()
+ model = miscmodels.quickmark()
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
_check_completions(model, {
"Quickmarks": [
- ('aw', 'https://wiki.archlinux.org', ''),
- ('ddg', 'https://duckduckgo.com', ''),
- ('wiki', 'https://wikipedia.org', ''),
+ ('aw', 'https://wiki.archlinux.org', None),
+ ('ddg', 'https://duckduckgo.com', None),
+ ('wiki', 'https://wikipedia.org', None),
]
})
+@pytest.mark.parametrize('row, removed', [
+ (0, 'aw'),
+ (1, 'ddg'),
+ (2, 'wiki'),
+])
+def test_quickmark_completion_delete(qtmodeltester, quickmarks, row, removed):
+ """Test deleting a quickmark from the quickmark completion model."""
+ model = miscmodels.quickmark()
+ model.set_pattern('')
+ qtmodeltester.data_display_may_return_none = True
+ qtmodeltester.check(model)
+
+ parent = model.index(0, 0)
+ idx = model.index(row, 0, parent)
+
+ before = set(quickmarks.marks.keys())
+ model.delete_cur_item(idx)
+ after = set(quickmarks.marks.keys())
+ assert before.difference(after) == {removed}
+
+
def test_bookmark_completion(qtmodeltester, bookmarks):
"""Test the results of bookmark completion."""
- model = miscmodels.BookmarkCompletionModel()
+ model = miscmodels.bookmark()
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
_check_completions(model, {
"Bookmarks": [
- ('https://github.com', 'GitHub', ''),
- ('https://python.org', 'Welcome to Python.org', ''),
- ('http://qutebrowser.org', 'qutebrowser | qutebrowser', ''),
+ ('http://qutebrowser.org', 'qutebrowser | qutebrowser', None),
+ ('https://github.com', 'GitHub', None),
+ ('https://python.org', 'Welcome to Python.org', None),
]
})
-def test_url_completion(qtmodeltester, config_stub, web_history, quickmarks,
- bookmarks):
+@pytest.mark.parametrize('row, removed', [
+ (0, 'http://qutebrowser.org'),
+ (1, 'https://github.com'),
+ (2, 'https://python.org'),
+])
+def test_bookmark_completion_delete(qtmodeltester, bookmarks, row, removed):
+ """Test deleting a quickmark from the quickmark completion model."""
+ model = miscmodels.bookmark()
+ model.set_pattern('')
+ qtmodeltester.data_display_may_return_none = True
+ qtmodeltester.check(model)
+
+ parent = model.index(0, 0)
+ idx = model.index(row, 0, parent)
+
+ before = set(bookmarks.marks.keys())
+ model.delete_cur_item(idx)
+ after = set(bookmarks.marks.keys())
+ assert before.difference(after) == {removed}
+
+
+def test_url_completion(qtmodeltester, web_history_populated,
+ quickmarks, bookmarks):
"""Test the results of url completion.
Verify that:
- quickmarks, bookmarks, and urls are included
- - no more than 'web-history-max-items' history entries are included
- - the most recent entries are included
+ - entries are sorted by access time
+ - only the most recent entry is included for each url
"""
- config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d',
- 'web-history-max-items': 2}
- model = urlmodel.UrlCompletionModel()
+ model = urlmodel.url()
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
_check_completions(model, {
"Quickmarks": [
- ('https://wiki.archlinux.org', 'aw', ''),
- ('https://duckduckgo.com', 'ddg', ''),
- ('https://wikipedia.org', 'wiki', ''),
+ ('https://duckduckgo.com', 'ddg', None),
+ ('https://wiki.archlinux.org', 'aw', None),
+ ('https://wikipedia.org', 'wiki', None),
],
"Bookmarks": [
- ('https://github.com', 'GitHub', ''),
- ('https://python.org', 'Welcome to Python.org', ''),
- ('http://qutebrowser.org', 'qutebrowser | qutebrowser', ''),
+ ('http://qutebrowser.org', 'qutebrowser | qutebrowser', None),
+ ('https://github.com', 'GitHub', None),
+ ('https://python.org', 'Welcome to Python.org', None),
],
"History": [
+ ('https://github.com', 'https://github.com', '2016-05-01'),
('https://python.org', 'Welcome to Python.org', '2016-03-08'),
- ('https://github.com', 'GitHub', '2016-05-01'),
+ ('http://qutebrowser.org', 'qutebrowser', '2015-09-05'),
],
})
-def test_url_completion_delete_bookmark(qtmodeltester, config_stub,
- web_history, quickmarks, bookmarks,
- qtbot):
+@pytest.mark.parametrize('url, title, pattern, rowcount', [
+ ('example.com', 'Site Title', '', 1),
+ ('example.com', 'Site Title', 'ex', 1),
+ ('example.com', 'Site Title', 'am', 1),
+ ('example.com', 'Site Title', 'com', 1),
+ ('example.com', 'Site Title', 'ex com', 1),
+ ('example.com', 'Site Title', 'com ex', 0),
+ ('example.com', 'Site Title', 'ex foo', 0),
+ ('example.com', 'Site Title', 'foo com', 0),
+ ('example.com', 'Site Title', 'exm', 0),
+ ('example.com', 'Site Title', 'Si Ti', 1),
+ ('example.com', 'Site Title', 'Ti Si', 0),
+ ('example.com', '', 'foo', 0),
+ ('foo_bar', '', '_', 1),
+ ('foobar', '', '_', 0),
+ ('foo%bar', '', '%', 1),
+ ('foobar', '', '%', 0),
+])
+def test_url_completion_pattern(web_history, quickmark_manager_stub,
+ bookmark_manager_stub, url, title, pattern,
+ rowcount):
+ """Test that url completion filters by url and title."""
+ web_history.add_url(QUrl(url), title)
+ model = urlmodel.url()
+ model.set_pattern(pattern)
+ # 2, 0 is History
+ assert model.rowCount(model.index(2, 0)) == rowcount
+
+
+def test_url_completion_delete_bookmark(qtmodeltester, bookmarks,
+ web_history, quickmarks):
"""Test deleting a bookmark from the url completion model."""
- config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d',
- 'web-history-max-items': 2}
- model = urlmodel.UrlCompletionModel()
+ model = urlmodel.url()
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
- # delete item (1, 0) -> (bookmarks, 'https://github.com' )
- view = _mock_view_index(model, 1, 0, qtbot)
- model.delete_cur_item(view)
+ parent = model.index(1, 0)
+ idx = model.index(1, 0, parent)
+
+ # sanity checks
+ assert model.data(parent) == "Bookmarks"
+ assert model.data(idx) == 'https://github.com'
+ assert 'https://github.com' in bookmarks.marks
+
+ len_before = len(bookmarks.marks)
+ model.delete_cur_item(idx)
assert 'https://github.com' not in bookmarks.marks
- assert 'https://python.org' in bookmarks.marks
- assert 'http://qutebrowser.org' in bookmarks.marks
+ assert len_before == len(bookmarks.marks) + 1
-def test_url_completion_delete_quickmark(qtmodeltester, config_stub,
- web_history, quickmarks, bookmarks,
+def test_url_completion_delete_quickmark(qtmodeltester,
+ quickmarks, web_history, bookmarks,
qtbot):
"""Test deleting a bookmark from the url completion model."""
- config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d',
- 'web-history-max-items': 2}
- model = urlmodel.UrlCompletionModel()
+ model = urlmodel.url()
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
- # delete item (0, 1) -> (quickmarks, 'ddg' )
- view = _mock_view_index(model, 0, 1, qtbot)
- model.delete_cur_item(view)
- assert 'aw' in quickmarks.marks
+ parent = model.index(0, 0)
+ idx = model.index(0, 0, parent)
+
+ # sanity checks
+ assert model.data(parent) == "Quickmarks"
+ assert model.data(idx) == 'https://duckduckgo.com'
+ assert 'ddg' in quickmarks.marks
+
+ len_before = len(quickmarks.marks)
+ model.delete_cur_item(idx)
assert 'ddg' not in quickmarks.marks
- assert 'wiki' in quickmarks.marks
+ assert len_before == len(quickmarks.marks) + 1
+
+
+def test_url_completion_delete_history(qtmodeltester,
+ web_history_populated,
+ quickmarks, bookmarks):
+ """Test deleting a history entry."""
+ model = urlmodel.url()
+ model.set_pattern('')
+ qtmodeltester.data_display_may_return_none = True
+ qtmodeltester.check(model)
+
+ parent = model.index(2, 0)
+ idx = model.index(1, 0, parent)
+
+ # sanity checks
+ assert model.data(parent) == "History"
+ assert model.data(idx) == 'https://python.org'
+
+ assert 'https://python.org' in web_history_populated
+ model.delete_cur_item(idx)
+ assert 'https://python.org' not in web_history_populated
+
+
+def test_url_completion_zero_limit(config_stub, web_history, quickmarks,
+ bookmarks):
+ """Make sure there's no history if the limit was set to zero."""
+ config_stub.data['completion']['web-history-max-items'] = 0
+ model = urlmodel.url()
+ model.set_pattern('')
+ category = model.index(2, 0) # "History" normally
+ assert model.data(category) is None
def test_session_completion(qtmodeltester, session_manager_stub):
session_manager_stub.sessions = ['default', '1', '2']
- model = miscmodels.SessionCompletionModel()
+ model = miscmodels.session()
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
_check_completions(model, {
- "Sessions": [('default', '', ''), ('1', '', ''), ('2', '', '')]
+ "Sessions": [('1', None, None),
+ ('2', None, None),
+ ('default', None, None)]
})
@@ -360,7 +474,8 @@ def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry,
tabbed_browser_stubs[1].tabs = [
fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0),
]
- model = miscmodels.TabCompletionModel()
+ model = miscmodels.buffer()
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
@@ -376,7 +491,7 @@ def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry,
})
-def test_tab_completion_delete(qtmodeltester, fake_web_tab, qtbot, app_stub,
+def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub,
win_registry, tabbed_browser_stubs):
"""Verify closing a tab by deleting it from the completion widget."""
tabbed_browser_stubs[0].tabs = [
@@ -387,13 +502,19 @@ def test_tab_completion_delete(qtmodeltester, fake_web_tab, qtbot, app_stub,
tabbed_browser_stubs[1].tabs = [
fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0),
]
- model = miscmodels.TabCompletionModel()
+ model = miscmodels.buffer()
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
- view = _mock_view_index(model, 0, 1, qtbot)
- qtbot.add_widget(view)
- model.delete_cur_item(view)
+ parent = model.index(0, 0)
+ idx = model.index(1, 0, parent)
+
+ # sanity checks
+ assert model.data(parent) == "0"
+ assert model.data(idx) == '0/2'
+
+ model.delete_cur_item(idx)
actual = [tab.url() for tab in tabbed_browser_stubs[0].tabs]
assert actual == [QUrl('https://github.com'),
QUrl('https://duckduckgo.com')]
@@ -404,15 +525,16 @@ def test_setting_section_completion(qtmodeltester, monkeypatch, stubs):
_patch_configdata(monkeypatch, stubs, module + '.configdata.DATA')
_patch_config_section_desc(monkeypatch, stubs,
module + '.configdata.SECTION_DESC')
- model = configmodel.SettingSectionCompletionModel()
+ model = configmodel.section()
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
_check_completions(model, {
"Sections": [
- ('general', 'General/miscellaneous options.', ''),
- ('ui', 'General options related to the user interface.', ''),
- ('searchengines', 'Definitions of search engines ...', ''),
+ ('general', 'General/miscellaneous options.', None),
+ ('searchengines', 'Definitions of search engines ...', None),
+ ('ui', 'General options related to the user interface.', None),
]
})
@@ -424,7 +546,8 @@ def test_setting_option_completion(qtmodeltester, monkeypatch, stubs,
config_stub.data = {'ui': {'gesture': 'off',
'mind': 'on',
'voice': 'sometimes'}}
- model = configmodel.SettingOptionCompletionModel('ui')
+ model = configmodel.option('ui')
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
@@ -437,6 +560,12 @@ def test_setting_option_completion(qtmodeltester, monkeypatch, stubs,
})
+def test_setting_option_completion_empty(monkeypatch, stubs, config_stub):
+ module = 'qutebrowser.completion.models.configmodel'
+ _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA')
+ assert configmodel.option('typo') is None
+
+
def test_setting_option_completion_valuelist(qtmodeltester, monkeypatch, stubs,
config_stub):
module = 'qutebrowser.completion.models.configmodel'
@@ -446,7 +575,8 @@ def test_setting_option_completion_valuelist(qtmodeltester, monkeypatch, stubs,
'DEFAULT': 'https://duckduckgo.com/?q={}'
}
}
- model = configmodel.SettingOptionCompletionModel('searchengines')
+ model = configmodel.option('searchengines')
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
@@ -460,22 +590,30 @@ def test_setting_value_completion(qtmodeltester, monkeypatch, stubs,
module = 'qutebrowser.completion.models.configmodel'
_patch_configdata(monkeypatch, stubs, module + '.configdata.DATA')
config_stub.data = {'general': {'volume': '0'}}
- model = configmodel.SettingValueCompletionModel('general', 'volume')
+ model = configmodel.value('general', 'volume')
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
_check_completions(model, {
"Current/Default": [
- ('0', 'Current value', ''),
- ('11', 'Default value', ''),
+ ('0', 'Current value', None),
+ ('11', 'Default value', None),
],
"Completions": [
- ('0', '', ''),
- ('11', '', ''),
+ ('0', '', None),
+ ('11', '', None),
]
})
+def test_setting_value_completion_empty(monkeypatch, stubs, config_stub):
+ module = 'qutebrowser.completion.models.configmodel'
+ _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA')
+ config_stub.data = {'general': {}}
+ assert configmodel.value('general', 'typo') is None
+
+
def test_bind_completion(qtmodeltester, monkeypatch, stubs, config_stub,
key_config_stub):
"""Test the results of keybinding command completion.
@@ -489,58 +627,58 @@ def test_bind_completion(qtmodeltester, monkeypatch, stubs, config_stub,
_patch_cmdutils(monkeypatch, stubs,
'qutebrowser.completion.models.miscmodels.cmdutils')
config_stub.data['aliases'] = {'rock': 'roll'}
- key_config_stub.set_bindings_for('normal', {'s': 'stop',
+ key_config_stub.set_bindings_for('normal', {'s': 'stop now',
'rr': 'roll',
'ro': 'rock'})
- model = miscmodels.BindCompletionModel()
+ model = miscmodels.bind('s')
+ model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
qtmodeltester.check(model)
_check_completions(model, {
+ "Current": [
+ ('stop now', 'stop qutebrowser', 's'),
+ ],
"Commands": [
- ('stop', 'stop qutebrowser', 's'),
('drop', 'drop all user data', ''),
('hide', '', ''),
- ('roll', 'never gonna give you up', 'rr'),
('rock', "Alias for 'roll'", 'ro'),
+ ('roll', 'never gonna give you up', 'rr'),
+ ('stop', 'stop qutebrowser', ''),
]
})
-def test_url_completion_benchmark(benchmark, config_stub,
+def test_url_completion_benchmark(benchmark,
quickmark_manager_stub,
bookmark_manager_stub,
- web_history_stub):
+ web_history):
"""Benchmark url completion."""
- config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d',
- 'web-history-max-items': 1000}
-
- entries = [history.Entry(
- atime=i,
- url=QUrl('http://example.com/{}'.format(i)),
- title='title{}'.format(i))
- for i in range(100000)]
+ r = range(100000)
+ entries = {
+ 'last_atime': list(r),
+ 'url': ['http://example.com/{}'.format(i) for i in r],
+ 'title': ['title{}'.format(i) for i in r]
+ }
- web_history_stub.history_dict = collections.OrderedDict(
- ((e.url_str(), e) for e in entries))
+ web_history.completion.insert_batch(entries)
- quickmark_manager_stub.marks = collections.OrderedDict(
- (e.title, e.url_str())
- for e in entries[0:1000])
+ quickmark_manager_stub.marks = collections.OrderedDict([
+ ('title{}'.format(i), 'example.com/{}'.format(i))
+ for i in range(1000)])
- bookmark_manager_stub.marks = collections.OrderedDict(
- (e.url_str(), e.title)
- for e in entries[0:1000])
+ bookmark_manager_stub.marks = collections.OrderedDict([
+ ('example.com/{}'.format(i), 'title{}'.format(i))
+ for i in range(1000)])
def bench():
- model = urlmodel.UrlCompletionModel()
- filtermodel = sortfilter.CompletionFilterModel(model)
- filtermodel.set_pattern('')
- filtermodel.set_pattern('e')
- filtermodel.set_pattern('ex')
- filtermodel.set_pattern('ex ')
- filtermodel.set_pattern('ex 1')
- filtermodel.set_pattern('ex 12')
- filtermodel.set_pattern('ex 123')
+ model = urlmodel.url()
+ model.set_pattern('')
+ model.set_pattern('e')
+ model.set_pattern('ex')
+ model.set_pattern('ex ')
+ model.set_pattern('ex 1')
+ model.set_pattern('ex 12')
+ model.set_pattern('ex 123')
benchmark(bench)
diff --git a/tests/unit/completion/test_sortfilter.py b/tests/unit/completion/test_sortfilter.py
deleted file mode 100644
index 2d4a4e25d..000000000
--- a/tests/unit/completion/test_sortfilter.py
+++ /dev/null
@@ -1,230 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2015-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
-#
-# 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 <http://www.gnu.org/licenses/>.
-
-"""Tests for CompletionFilterModel."""
-
-import pytest
-
-from PyQt5.QtCore import Qt
-
-from qutebrowser.completion.models import base, sortfilter
-
-
-def _create_model(data):
- """Create a completion model populated with the given data.
-
- data: A list of lists, where each sub-list represents a category, each
- tuple in the sub-list represents an item, and each value in the
- tuple represents the item data for that column
- """
- model = base.BaseCompletionModel()
- for catdata in data:
- cat = model.new_category('')
- for itemdata in catdata:
- model.new_item(cat, *itemdata)
- return model
-
-
-def _extract_model_data(model):
- """Express a model's data as a list for easier comparison.
-
- Return: A list of lists, where each sub-list represents a category, each
- tuple in the sub-list represents an item, and each value in the
- tuple represents the item data for that column
- """
- data = []
- for i in range(0, model.rowCount()):
- cat_idx = model.index(i, 0)
- row = []
- for j in range(0, model.rowCount(cat_idx)):
- row.append((model.data(cat_idx.child(j, 0)),
- model.data(cat_idx.child(j, 1)),
- model.data(cat_idx.child(j, 2))))
- data.append(row)
- return data
-
-
-@pytest.mark.parametrize('pattern, data, expected', [
- ('foo', 'barfoobar', True),
- ('foo bar', 'barfoobar', True),
- ('foo bar', 'barfoobar', True),
- ('foo bar', 'barfoobazbar', True),
- ('foo bar', 'barfoobazbar', True),
- ('foo', 'barFOObar', True),
- ('Foo', 'barfOObar', True),
- ('ab', 'aonebtwo', False),
- ('33', 'l33t', True),
- ('x', 'blah', False),
- ('4', 'blah', False),
-])
-def test_filter_accepts_row(pattern, data, expected):
- source_model = base.BaseCompletionModel()
- cat = source_model.new_category('test')
- source_model.new_item(cat, data)
-
- filter_model = sortfilter.CompletionFilterModel(source_model)
- filter_model.set_pattern(pattern)
- assert filter_model.rowCount() == 1 # "test" category
- idx = filter_model.index(0, 0)
- assert idx.isValid()
-
- row_count = filter_model.rowCount(idx)
- assert row_count == (1 if expected else 0)
-
-
-@pytest.mark.parametrize('tree, first, last', [
- ([[('Aa',)]], 'Aa', 'Aa'),
- ([[('Aa',)], [('Ba',)]], 'Aa', 'Ba'),
- ([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]],
- 'Aa', 'Ca'),
- ([[], [('Ba',)]], 'Ba', 'Ba'),
- ([[], [], [('Ca',)]], 'Ca', 'Ca'),
- ([[], [], [('Ca',), ('Cb',)]], 'Ca', 'Cb'),
- ([[('Aa',)], []], 'Aa', 'Aa'),
- ([[('Aa',)], []], 'Aa', 'Aa'),
- ([[('Aa',)], [], []], 'Aa', 'Aa'),
- ([[('Aa',)], [], [('Ca',)]], 'Aa', 'Ca'),
- ([[], []], None, None),
-])
-def test_first_last_item(tree, first, last):
- """Test that first() and last() return indexes to the first and last items.
-
- Args:
- tree: Each list represents a completion category, with each string
- being an item under that category.
- first: text of the first item
- last: text of the last item
- """
- model = _create_model(tree)
- filter_model = sortfilter.CompletionFilterModel(model)
- assert filter_model.data(filter_model.first_item()) == first
- assert filter_model.data(filter_model.last_item()) == last
-
-
-def test_set_source_model():
- """Ensure setSourceModel sets source_model and clears the pattern."""
- model1 = base.BaseCompletionModel()
- model2 = base.BaseCompletionModel()
- filter_model = sortfilter.CompletionFilterModel(model1)
- filter_model.set_pattern('foo')
- # sourceModel() is cached as srcmodel, so make sure both match
- assert filter_model.srcmodel is model1
- assert filter_model.sourceModel() is model1
- assert filter_model.pattern == 'foo'
- filter_model.setSourceModel(model2)
- assert filter_model.srcmodel is model2
- assert filter_model.sourceModel() is model2
- assert not filter_model.pattern
-
-
-@pytest.mark.parametrize('tree, expected', [
- ([[('Aa',)]], 1),
- ([[('Aa',)], [('Ba',)]], 2),
- ([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]], 6),
- ([[], [('Ba',)]], 1),
- ([[], [], [('Ca',)]], 1),
- ([[], [], [('Ca',), ('Cb',)]], 2),
- ([[('Aa',)], []], 1),
- ([[('Aa',)], []], 1),
- ([[('Aa',)], [], []], 1),
- ([[('Aa',)], [], [('Ca',)]], 2),
-])
-def test_count(tree, expected):
- model = _create_model(tree)
- filter_model = sortfilter.CompletionFilterModel(model)
- assert filter_model.count() == expected
-
-
-@pytest.mark.parametrize('pattern, dumb_sort, filter_cols, before, after', [
- ('foo', None, [0],
- [[('foo', '', ''), ('bar', '', '')]],
- [[('foo', '', '')]]),
-
- ('foo', None, [0],
- [[('foob', '', ''), ('fooc', '', ''), ('fooa', '', '')]],
- [[('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')]]),
-
- ('foo', None, [0],
- [[('foo', '', '')], [('bar', '', '')]],
- [[('foo', '', '')], []]),
-
- # prefer foobar as it starts with the pattern
- ('foo', None, [0],
- [[('barfoo', '', ''), ('foobar', '', '')]],
- [[('foobar', '', ''), ('barfoo', '', '')]]),
-
- # however, don't rearrange categories
- ('foo', None, [0],
- [[('barfoo', '', '')], [('foobar', '', '')]],
- [[('barfoo', '', '')], [('foobar', '', '')]]),
-
- ('foo', None, [1],
- [[('foo', 'bar', ''), ('bar', 'foo', '')]],
- [[('bar', 'foo', '')]]),
-
- ('foo', None, [0, 1],
- [[('foo', 'bar', ''), ('bar', 'foo', ''), ('bar', 'bar', '')]],
- [[('foo', 'bar', ''), ('bar', 'foo', '')]]),
-
- ('foo', None, [0, 1, 2],
- [[('foo', '', ''), ('bar', '')]],
- [[('foo', '', '')]]),
-
- # the fourth column is the sort role, which overrides data-based sorting
- ('', None, [0],
- [[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]],
- [[('one', '', ''), ('two', '', ''), ('three', '', '')]]),
-
- ('', Qt.AscendingOrder, [0],
- [[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]],
- [[('one', '', ''), ('two', '', ''), ('three', '', '')]]),
-
- ('', Qt.DescendingOrder, [0],
- [[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]],
- [[('three', '', ''), ('two', '', ''), ('one', '', '')]]),
-])
-def test_set_pattern(pattern, dumb_sort, filter_cols, before, after):
- """Validate the filtering and sorting results of set_pattern."""
- model = _create_model(before)
- model.DUMB_SORT = dumb_sort
- model.columns_to_filter = filter_cols
- filter_model = sortfilter.CompletionFilterModel(model)
- filter_model.set_pattern(pattern)
- actual = _extract_model_data(filter_model)
- assert actual == after
-
-
-def test_sort():
- """Ensure that a sort argument passed to sort overrides DUMB_SORT.
-
- While test_set_pattern above covers most of the sorting logic, this
- particular case is easier to test separately.
- """
- model = _create_model([[('B', '', '', 1),
- ('C', '', '', 2),
- ('A', '', '', 0)]])
- filter_model = sortfilter.CompletionFilterModel(model)
-
- filter_model.sort(0, Qt.AscendingOrder)
- actual = _extract_model_data(filter_model)
- assert actual == [[('A', '', ''), ('B', '', ''), ('C', '', '')]]
-
- filter_model.sort(0, Qt.DescendingOrder)
- actual = _extract_model_data(filter_model)
- assert actual == [[('C', '', ''), ('B', '', ''), ('A', '', '')]]
diff --git a/tests/unit/config/old_configs/qutebrowser-v0.11.0.conf b/tests/unit/config/old_configs/qutebrowser-v0.11.0.conf
new file mode 100644
index 000000000..ba48a6ff5
--- /dev/null
+++ b/tests/unit/config/old_configs/qutebrowser-v0.11.0.conf
@@ -0,0 +1,251 @@
+[general]
+ignore-case = smart
+startpage = https://start.duckduckgo.com
+yank-ignored-url-parameters = ref,utm_source,utm_medium,utm_campaign,utm_term,utm_content
+default-open-dispatcher =
+default-page = ${startpage}
+auto-search = naive
+auto-save-config = true
+auto-save-interval = 15000
+editor = gvim -f "{}"
+editor-encoding = utf-8
+private-browsing = false
+developer-extras = false
+print-element-backgrounds = true
+xss-auditing = false
+default-encoding = iso-8859-1
+new-instance-open-target = tab
+new-instance-open-target.window = last-focused
+log-javascript-console = debug
+save-session = false
+session-default-name =
+url-incdec-segments = path,query
+[ui]
+history-session-interval = 30
+zoom-levels = 25%,33%,50%,67%,75%,90%,100%,110%,125%,150%,175%,200%,250%,300%,400%,500%
+default-zoom = 100%
+downloads-position = top
+status-position = bottom
+message-timeout = 2000
+message-unfocused = false
+confirm-quit = never
+zoom-text-only = false
+frame-flattening = false
+user-stylesheet =
+hide-scrollbar = true
+smooth-scrolling = false
+remove-finished-downloads = -1
+hide-statusbar = false
+statusbar-padding = 1,1,0,0
+window-title-format = {perc}{title}{title_sep}qutebrowser
+modal-js-dialog = false
+hide-wayland-decoration = false
+keyhint-blacklist =
+keyhint-delay = 500
+prompt-radius = 8
+prompt-filebrowser = true
+[network]
+do-not-track = true
+accept-language = en-US,en
+referer-header = same-domain
+user-agent =
+proxy = system
+proxy-dns-requests = true
+ssl-strict = ask
+dns-prefetch = true
+custom-headers =
+netrc-file =
+[completion]
+show = always
+download-path-suggestion = path
+timestamp-format = %Y-%m-%d
+height = 50%
+cmd-history-max-items = 100
+web-history-max-items = 1000
+quick-complete = true
+shrink = false
+scrollbar-width = 12
+scrollbar-padding = 2
+[input]
+timeout = 500
+partial-timeout = 5000
+insert-mode-on-plugins = false
+auto-leave-insert-mode = true
+auto-insert-mode = false
+forward-unbound-keys = auto
+spatial-navigation = false
+links-included-in-focus-chain = true
+rocker-gestures = false
+mouse-zoom-divider = 512
+[tabs]
+background-tabs = false
+select-on-remove = next
+new-tab-position = next
+new-tab-position-explicit = last
+last-close = ignore
+show = always
+show-switching-delay = 800
+wrap = true
+movable = true
+close-mouse-button = middle
+position = top
+show-favicons = true
+favicon-scale = 1.0
+width = 20%
+pinned-width = 43
+indicator-width = 3
+tabs-are-windows = false
+title-format = {index}: {title}
+title-format-pinned = {index}
+title-alignment = left
+mousewheel-tab-switching = true
+padding = 0,0,5,5
+indicator-padding = 2,2,0,4
+[storage]
+download-directory =
+prompt-download-directory = true
+remember-download-directory = true
+maximum-pages-in-cache = 0
+offline-web-application-cache = true
+local-storage = true
+cache-size =
+[content]
+allow-images = true
+allow-javascript = true
+allow-plugins = false
+webgl = true
+hyperlink-auditing = false
+geolocation = ask
+notifications = ask
+media-capture = ask
+javascript-can-open-windows-automatically = false
+javascript-can-close-windows = false
+javascript-can-access-clipboard = false
+ignore-javascript-prompt = false
+ignore-javascript-alert = false
+local-content-can-access-remote-urls = false
+local-content-can-access-file-urls = true
+cookies-accept = no-3rdparty
+cookies-store = true
+host-block-lists = https://www.malwaredomainlist.com/hostslist/hosts.txt,http://someonewhocares.org/hosts/hosts,http://winhelp2002.mvps.org/hosts.zip,http://malwaredomains.lehigh.edu/files/justdomains.zip,https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&mimetype=plaintext
+host-blocking-enabled = true
+host-blocking-whitelist = piwik.org
+enable-pdfjs = false
+[hints]
+border = 1px solid #E3BE23
+mode = letter
+chars = asdfghjkl
+min-chars = 1
+scatter = true
+uppercase = false
+dictionary = /usr/share/dict/words
+auto-follow = unique-match
+auto-follow-timeout = 0
+next-regexes = \bnext\b,\bmore\b,\bnewer\b,\b[>→≫]\b,\b(>>|»)\b,\bcontinue\b
+prev-regexes = \bprev(ious)?\b,\bback\b,\bolder\b,\b[<←≪]\b,\b(<<|«)\b
+find-implementation = python
+hide-unmatched-rapid-hints = true
+[searchengines]
+DEFAULT = https://duckduckgo.com/?q={}
+[aliases]
+[colors]
+completion.fg = white
+completion.bg = #333333
+completion.alternate-bg = #444444
+completion.category.fg = white
+completion.category.bg = qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #888888, stop:1 #505050)
+completion.category.border.top = black
+completion.category.border.bottom = ${completion.category.border.top}
+completion.item.selected.fg = black
+completion.item.selected.bg = #e8c000
+completion.item.selected.border.top = #bbbb00
+completion.item.selected.border.bottom = ${completion.item.selected.border.top}
+completion.match.fg = #ff4444
+completion.scrollbar.fg = ${completion.fg}
+completion.scrollbar.bg = ${completion.bg}
+statusbar.fg = white
+statusbar.bg = black
+statusbar.fg.private = ${statusbar.fg}
+statusbar.bg.private = #666666
+statusbar.fg.insert = ${statusbar.fg}
+statusbar.bg.insert = darkgreen
+statusbar.fg.command = ${statusbar.fg}
+statusbar.bg.command = ${statusbar.bg}
+statusbar.fg.command.private = ${statusbar.fg.private}
+statusbar.bg.command.private = ${statusbar.bg.private}
+statusbar.fg.caret = ${statusbar.fg}
+statusbar.bg.caret = purple
+statusbar.fg.caret-selection = ${statusbar.fg}
+statusbar.bg.caret-selection = #a12dff
+statusbar.progress.bg = white
+statusbar.url.fg = ${statusbar.fg}
+statusbar.url.fg.success = white
+statusbar.url.fg.success.https = lime
+statusbar.url.fg.error = orange
+statusbar.url.fg.warn = yellow
+statusbar.url.fg.hover = aqua
+tabs.fg.odd = white
+tabs.bg.odd = grey
+tabs.fg.even = white
+tabs.bg.even = darkgrey
+tabs.fg.selected.odd = white
+tabs.bg.selected.odd = black
+tabs.fg.selected.even = ${tabs.fg.selected.odd}
+tabs.bg.selected.even = ${tabs.bg.selected.odd}
+tabs.bg.bar = #555555
+tabs.indicator.start = #0000aa
+tabs.indicator.stop = #00aa00
+tabs.indicator.error = #ff0000
+tabs.indicator.system = rgb
+hints.fg = black
+hints.bg = qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 rgba(255, 247, 133, 0.8), stop:1 rgba(255, 197, 66, 0.8))
+hints.fg.match = green
+downloads.bg.bar = black
+downloads.fg.start = white
+downloads.bg.start = #0000aa
+downloads.fg.stop = ${downloads.fg.start}
+downloads.bg.stop = #00aa00
+downloads.fg.system = rgb
+downloads.bg.system = rgb
+downloads.fg.error = white
+downloads.bg.error = red
+webpage.bg = white
+keyhint.fg = #FFFFFF
+keyhint.fg.suffix = #FFFF00
+keyhint.bg = rgba(0, 0, 0, 80%)
+messages.fg.error = white
+messages.bg.error = red
+messages.border.error = #bb0000
+messages.fg.warning = white
+messages.bg.warning = darkorange
+messages.border.warning = #d47300
+messages.fg.info = white
+messages.bg.info = black
+messages.border.info = #333333
+prompts.fg = white
+prompts.bg = darkblue
+prompts.selected.bg = #308cc6
+[fonts]
+_monospace = xos4 Terminus, Terminus, Monospace, "DejaVu Sans Mono", Monaco, "Bitstream Vera Sans Mono", "Andale Mono", "Courier New", Courier, "Liberation Mono", monospace, Fixed, Consolas, Terminal
+completion = 8pt ${_monospace}
+completion.category = bold ${completion}
+tabbar = 8pt ${_monospace}
+statusbar = 8pt ${_monospace}
+downloads = 8pt ${_monospace}
+hints = bold 13px ${_monospace}
+debug-console = 8pt ${_monospace}
+web-family-standard =
+web-family-fixed =
+web-family-serif =
+web-family-sans-serif =
+web-family-cursive =
+web-family-fantasy =
+web-size-minimum = 0
+web-size-minimum-logical = 6
+web-size-default = 16
+web-size-default-fixed = 13
+keyhint = 8pt ${_monospace}
+messages.error = 8pt ${_monospace}
+messages.warning = 8pt ${_monospace}
+messages.info = 8pt ${_monospace}
+prompts = 8pt sans-serif
diff --git a/tests/unit/mainwindow/statusbar/test_backforward.py b/tests/unit/mainwindow/statusbar/test_backforward.py
new file mode 100644
index 000000000..f2dec3d3f
--- /dev/null
+++ b/tests/unit/mainwindow/statusbar/test_backforward.py
@@ -0,0 +1,76 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Test Backforward widget."""
+
+import pytest
+
+from qutebrowser.mainwindow.statusbar import backforward
+
+
+@pytest.fixture
+def backforward_widget(qtbot):
+ widget = backforward.Backforward()
+ qtbot.add_widget(widget)
+ return widget
+
+
+@pytest.mark.parametrize('can_go_back, can_go_forward, expected_text', [
+ (False, False, ''),
+ (True, False, '[<]'),
+ (False, True, '[>]'),
+ (True, True, '[<>]'),
+])
+def test_backforward_widget(backforward_widget, 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.current_index = 1
+ tabbed_browser.tabs = [tab]
+ backforward_widget.on_tab_cur_url_changed(tabbed_browser)
+ assert backforward_widget.text() == expected_text
+ assert backforward_widget.isVisible() == bool(expected_text)
+
+ # Check that the widget gets reset if empty.
+ if can_go_back and can_go_forward:
+ tab = fake_web_tab(can_go_back=False, can_go_forward=False)
+ tabbed_browser.tabs = [tab]
+ backforward_widget.on_tab_cur_url_changed(tabbed_browser)
+ assert backforward_widget.text() == ''
+ assert not backforward_widget.isVisible()
+
+
+def test_none_tab(backforward_widget, 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.current_index = 1
+ tabbed_browser.tabs = [tab]
+ backforward_widget.on_tab_cur_url_changed(tabbed_browser)
+
+ assert backforward_widget.text() == '[<>]'
+ assert backforward_widget.isVisible()
+
+ tabbed_browser.current_index = -1
+ backforward_widget.on_tab_cur_url_changed(tabbed_browser)
+
+ assert backforward_widget.text() == ''
+ assert not backforward_widget.isVisible()
diff --git a/tests/unit/mainwindow/test_messageview.py b/tests/unit/mainwindow/test_messageview.py
index bb913aaa0..3a502b2bd 100644
--- a/tests/unit/mainwindow/test_messageview.py
+++ b/tests/unit/mainwindow/test_messageview.py
@@ -84,6 +84,19 @@ def test_changing_timer_with_messages_shown(qtbot, view, config_stub):
config_stub.set_obj('messages.timeout', 100)
+@pytest.mark.parametrize('count, expected', [(1, 100), (3, 300),
+ (5, 500), (7, 500)])
+def test_show_multiple_messages_longer(view, count, expected):
+ """When there are multiple messages, messages should be shown longer.
+
+ There is an upper maximum to avoid messages never disappearing.
+ """
+ for message_number in range(1, count+1):
+ view.show_message(usertypes.MessageLevel.info,
+ 'test ' + str(message_number))
+ assert view._clear_timer.interval() == expected
+
+
@pytest.mark.parametrize('replace1, replace2, length', [
(False, False, 2), # Two stacked messages
(True, True, 1), # Two replaceable messages
diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py
index b71b0c457..9ca4fdcbb 100644
--- a/tests/unit/misc/test_editor.py
+++ b/tests/unit/misc/test_editor.py
@@ -111,24 +111,30 @@ class TestFileHandling:
os.remove(filename)
- @pytest.mark.posix
def test_unreadable(self, message_mock, editor, caplog):
"""Test file handling when closing with an unreadable file."""
editor.edit("")
filename = editor._file.name
assert os.path.exists(filename)
os.chmod(filename, 0o077)
+ if os.access(filename, os.R_OK):
+ # Docker container or similar
+ pytest.skip("File was still readable")
+
with caplog.at_level(logging.ERROR):
editor._proc.finished.emit(0, QProcess.NormalExit)
assert not os.path.exists(filename)
msg = message_mock.getmsg(usertypes.MessageLevel.error)
assert msg.text.startswith("Failed to read back edited file: ")
- @pytest.mark.posix
def test_unwritable(self, monkeypatch, message_mock, editor, tmpdir,
caplog):
"""Test file handling when the initial file is not writable."""
tmpdir.chmod(0)
+ if os.access(str(tmpdir), os.W_OK):
+ # Docker container or similar
+ pytest.skip("File was still writable")
+
monkeypatch.setattr(editormod.tempfile, 'tempdir', str(tmpdir))
with caplog.at_level(logging.ERROR):
diff --git a/tests/unit/misc/test_guiprocess.py b/tests/unit/misc/test_guiprocess.py
index 3101b7427..749031367 100644
--- a/tests/unit/misc/test_guiprocess.py
+++ b/tests/unit/misc/test_guiprocess.py
@@ -128,11 +128,13 @@ def test_start_detached_error(fake_proc, message_mock, caplog):
"""Test starting a detached process with ok=False."""
argv = ['foo', 'bar']
fake_proc._proc.startDetached.return_value = (False, 0)
- fake_proc._proc.error.return_value = "Error message"
+ fake_proc._proc.error.return_value = QProcess.FailedToStart
with caplog.at_level(logging.ERROR):
fake_proc.start_detached(*argv)
msg = message_mock.getmsg(usertypes.MessageLevel.error)
- assert msg.text == "Error while spawning testprocess: Error message."
+ expected = ("Error while spawning testprocess: The process failed to "
+ "start.")
+ assert msg.text == expected
def test_double_start(qtbot, proc, py_proc):
diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py
index e364d6cc6..d0758b28d 100644
--- a/tests/unit/misc/test_ipc.py
+++ b/tests/unit/misc/test_ipc.py
@@ -26,7 +26,6 @@ import collections
import logging
import json
import hashlib
-import subprocess
from unittest import mock
import pytest
@@ -182,34 +181,34 @@ def md5(inp):
class TestSocketName:
- LEGACY_TESTS = [
- (None, 'qutebrowser-testusername'),
- ('/x', 'qutebrowser-testusername-{}'.format(md5('/x'))),
- ]
-
POSIX_TESTS = [
(None, 'ipc-{}'.format(md5('testusername'))),
('/x', 'ipc-{}'.format(md5('testusername-/x'))),
]
+ WINDOWS_TESTS = [
+ (None, 'qutebrowser-testusername'),
+ ('/x', 'qutebrowser-testusername-{}'.format(md5('/x'))),
+ ]
+
@pytest.fixture(autouse=True)
def patch_user(self, monkeypatch):
monkeypatch.setattr(ipc.getpass, 'getuser', lambda: 'testusername')
- @pytest.mark.parametrize('basedir, expected', LEGACY_TESTS)
- def test_legacy(self, basedir, expected):
- socketname = ipc._get_socketname(basedir, legacy=True)
- assert socketname == expected
-
- @pytest.mark.parametrize('basedir, expected', LEGACY_TESTS)
+ @pytest.mark.parametrize('basedir, expected', WINDOWS_TESTS)
@pytest.mark.windows
def test_windows(self, basedir, expected):
socketname = ipc._get_socketname(basedir)
assert socketname == expected
- @pytest.mark.osx
+ @pytest.mark.parametrize('basedir, expected', WINDOWS_TESTS)
+ def test_windows_on_posix(self, basedir, expected):
+ socketname = ipc._get_socketname_windows(basedir)
+ assert socketname == expected
+
+ @pytest.mark.mac
@pytest.mark.parametrize('basedir, expected', POSIX_TESTS)
- def test_os_x(self, basedir, expected):
+ def test_mac(self, basedir, expected):
socketname = ipc._get_socketname(basedir)
parts = socketname.split(os.sep)
assert parts[-2] == 'qute_test'
@@ -223,7 +222,7 @@ class TestSocketName:
assert socketname == expected_path
def test_other_unix(self):
- """Fake test for POSIX systems which aren't Linux/OS X.
+ """Fake test for POSIX systems which aren't Linux/macOS.
We probably would adjust the code first to make it work on that
platform.
@@ -512,7 +511,7 @@ class TestSendToRunningInstance:
assert msg == "No existing instance present (error 2)"
@pytest.mark.parametrize('has_cwd', [True, False])
- @pytest.mark.linux(reason="Causes random trouble on Windows and OS X")
+ @pytest.mark.linux(reason="Causes random trouble on Windows and macOS")
def test_normal(self, qtbot, tmpdir, ipc_server, mocker, has_cwd):
ipc_server.listen()
@@ -562,7 +561,7 @@ class TestSendToRunningInstance:
ipc.send_to_running_instance('qute-test', [], None, socket=socket)
-@pytest.mark.not_osx(reason="https://github.com/qutebrowser/qutebrowser/"
+@pytest.mark.not_mac(reason="https://github.com/qutebrowser/qutebrowser/"
"issues/975")
def test_timeout(qtbot, caplog, qlocalsocket, ipc_server):
ipc_server._timer.setInterval(100)
@@ -629,15 +628,7 @@ class TestSendOrListen:
setattr(m, attr, getattr(QLocalSocket, attr))
return m
- @pytest.fixture
- def legacy_server(self, args):
- legacy_name = ipc._get_socketname(args.basedir, legacy=True)
- legacy_server = ipc.IPCServer(legacy_name)
- legacy_server.listen()
- yield legacy_server
- legacy_server.shutdown()
-
- @pytest.mark.linux(reason="Flaky on Windows and OS X")
+ @pytest.mark.linux(reason="Flaky on Windows and macOS")
def test_normal_connection(self, caplog, qtbot, args):
ret_server = ipc.send_or_listen(args)
assert isinstance(ret_server, ipc.IPCServer)
@@ -652,54 +643,6 @@ class TestSendOrListen:
assert ret_client is None
@pytest.mark.posix(reason="Unneeded on Windows")
- def test_legacy_name(self, caplog, qtbot, args, legacy_server):
- with qtbot.waitSignal(legacy_server.got_args):
- ret = ipc.send_or_listen(args)
- assert ret is None
- msgs = [e.message for e in caplog.records]
- assert "Connecting to {}".format(legacy_server._socketname) in msgs
-
- @pytest.mark.posix(reason="Unneeded on Windows")
- def test_stale_legacy_server(self, caplog, qtbot, args, legacy_server,
- ipc_server, py_proc):
- legacy_name = ipc._get_socketname(args.basedir, legacy=True)
- logging.debug('== Setting up the legacy server ==')
- cmdline = py_proc("""
- import sys
-
- from PyQt5.QtCore import QCoreApplication
- from PyQt5.QtNetwork import QLocalServer
-
- app = QCoreApplication([])
-
- QLocalServer.removeServer(sys.argv[1])
- server = QLocalServer()
-
- ok = server.listen(sys.argv[1])
- assert ok
-
- print(server.fullServerName())
- """)
-
- name = subprocess.check_output(
- [cmdline[0]] + cmdline[1] + [legacy_name])
- name = name.decode('utf-8').rstrip('\n')
-
- # Closing the server should not remove the FIFO yet
- assert os.path.exists(name)
-
- ## Setting up the new server
- logging.debug('== Setting up new server ==')
- ret_server = ipc.send_or_listen(args)
- assert isinstance(ret_server, ipc.IPCServer)
-
- logging.debug('== Connecting ==')
- with qtbot.waitSignal(ret_server.got_args):
- ret_client = ipc.send_or_listen(args)
-
- assert ret_client is None
-
- @pytest.mark.posix(reason="Unneeded on Windows")
def test_correct_socket_name(self, args):
server = ipc.send_or_listen(args)
expected_dir = ipc._get_socketname(args.basedir)
@@ -723,9 +666,7 @@ class TestSendOrListen:
qlocalsocket_mock().waitForConnected.side_effect = [False, True]
qlocalsocket_mock().error.side_effect = [
- QLocalSocket.ServerNotFoundError, # legacy name
QLocalSocket.ServerNotFoundError,
- QLocalSocket.ServerNotFoundError, # legacy name
QLocalSocket.UnknownSocketError,
QLocalSocket.UnknownSocketError, # error() gets called twice
]
@@ -761,10 +702,8 @@ class TestSendOrListen:
# If it fails, that's the "not sent" case above.
qlocalsocket_mock().waitForConnected.side_effect = [False, has_error]
qlocalsocket_mock().error.side_effect = [
- QLocalSocket.ServerNotFoundError, # legacy name
QLocalSocket.ServerNotFoundError,
QLocalSocket.ServerNotFoundError,
- QLocalSocket.ServerNotFoundError, # legacy name
QLocalSocket.ConnectionRefusedError,
QLocalSocket.ConnectionRefusedError, # error() gets called twice
]
@@ -812,7 +751,7 @@ class TestSendOrListen:
@pytest.mark.windows
-@pytest.mark.osx
+@pytest.mark.mac
def test_long_username(monkeypatch):
"""See https://github.com/qutebrowser/qutebrowser/issues/888."""
username = 'alexandercogneau'
diff --git a/tests/unit/misc/test_lineparser.py b/tests/unit/misc/test_lineparser.py
index af439c006..0c78035b7 100644
--- a/tests/unit/misc/test_lineparser.py
+++ b/tests/unit/misc/test_lineparser.py
@@ -58,8 +58,8 @@ class TestBaseLineParser:
mocker.patch('builtins.open', mock.mock_open())
with lineparser._open('r'):
- with pytest.raises(IOError, match="Refusing to double-open "
- "AppendLineParser."):
+ with pytest.raises(IOError,
+ match="Refusing to double-open LineParser."):
with lineparser._open('r'):
pass
@@ -115,7 +115,8 @@ class TestLineParser:
def test_double_open(self, lineparser):
"""Test if save() bails on an already open file."""
with lineparser._open('r'):
- with pytest.raises(IOError):
+ with pytest.raises(IOError,
+ match="Refusing to double-open LineParser."):
lineparser.save()
def test_prepare_save(self, tmpdir, lineparser):
@@ -125,83 +126,3 @@ class TestLineParser:
lineparser._prepare_save = lambda: False
lineparser.save()
assert (tmpdir / 'file').read() == 'pristine\n'
-
-
-class TestAppendLineParser:
-
- BASE_DATA = ['old data 1', 'old data 2']
-
- @pytest.fixture
- def lineparser(self, tmpdir):
- """Fixture to get an AppendLineParser for tests."""
- lp = lineparsermod.AppendLineParser(str(tmpdir), 'file')
- lp.new_data = self.BASE_DATA
- lp.save()
- return lp
-
- def _get_expected(self, new_data):
- """Get the expected data with newlines."""
- return '\n'.join(self.BASE_DATA + new_data) + '\n'
-
- def test_save(self, tmpdir, lineparser):
- """Test save()."""
- new_data = ['new data 1', 'new data 2']
- lineparser.new_data = new_data
- lineparser.save()
- assert (tmpdir / 'file').read() == self._get_expected(new_data)
-
- def test_clear(self, tmpdir, lineparser):
- """Check if calling clear() empties both pending and persisted data."""
- lineparser.new_data = ['one', 'two']
- lineparser.save()
- assert (tmpdir / 'file').read() == "old data 1\nold data 2\none\ntwo\n"
-
- lineparser.new_data = ['one', 'two']
- lineparser.clear()
- lineparser.save()
- assert not lineparser.new_data
- assert (tmpdir / 'file').read() == ""
-
- def test_iter_without_open(self, lineparser):
- """Test __iter__ without having called open()."""
- with pytest.raises(ValueError):
- iter(lineparser)
-
- def test_iter(self, lineparser):
- """Test __iter__."""
- new_data = ['new data 1', 'new data 2']
- lineparser.new_data = new_data
- with lineparser.open():
- assert list(lineparser) == self.BASE_DATA + new_data
-
- def test_iter_not_found(self, mocker):
- """Test __iter__ with no file."""
- open_mock = mocker.patch(
- 'qutebrowser.misc.lineparser.AppendLineParser._open')
- open_mock.side_effect = FileNotFoundError
- new_data = ['new data 1', 'new data 2']
- linep = lineparsermod.AppendLineParser('foo', 'bar')
- linep.new_data = new_data
- with linep.open():
- assert list(linep) == new_data
-
- def test_get_recent_none(self, tmpdir):
- """Test get_recent with no data."""
- (tmpdir / 'file2').ensure()
- linep = lineparsermod.AppendLineParser(str(tmpdir), 'file2')
- assert linep.get_recent() == []
-
- def test_get_recent_little(self, lineparser):
- """Test get_recent with little data."""
- data = [e + '\n' for e in self.BASE_DATA]
- assert lineparser.get_recent() == data
-
- def test_get_recent_much(self, lineparser):
- """Test get_recent with much data."""
- size = 64
- new_data = ['new data {}'.format(i) for i in range(size)]
- lineparser.new_data = new_data
- lineparser.save()
- data = os.linesep.join(self.BASE_DATA + new_data) + os.linesep
- data = [e + '\n' for e in data[-size:].splitlines()]
- assert lineparser.get_recent(size) == data
diff --git a/tests/unit/misc/test_sessions.py b/tests/unit/misc/test_sessions.py
index 0ae55476f..231e7f997 100644
--- a/tests/unit/misc/test_sessions.py
+++ b/tests/unit/misc/test_sessions.py
@@ -215,11 +215,6 @@ class TestSave:
objreg.delete('main-window', scope='window', window=0)
objreg.delete('tabbed-browser', scope='window', window=0)
- def test_update_completion_signal(self, sess_man, tmpdir, qtbot):
- session_path = tmpdir / 'foo.yml'
- with qtbot.waitSignal(sess_man.update_completion):
- sess_man.save(str(session_path))
-
def test_no_state_config(self, sess_man, tmpdir, state_config):
session_path = tmpdir / 'foo.yml'
sess_man.save(str(session_path))
@@ -367,14 +362,6 @@ class TestLoadTab:
assert loaded_item.original_url == expected
-def test_delete_update_completion_signal(sess_man, qtbot, tmpdir):
- sess = tmpdir / 'foo.yml'
- sess.ensure()
-
- with qtbot.waitSignal(sess_man.update_completion):
- sess_man.delete(str(sess))
-
-
class TestListSessions:
def test_no_sessions(self, tmpdir):
diff --git a/tests/unit/misc/test_sql.py b/tests/unit/misc/test_sql.py
new file mode 100644
index 000000000..8997afc3b
--- /dev/null
+++ b/tests/unit/misc/test_sql.py
@@ -0,0 +1,179 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Test the SQL API."""
+
+import pytest
+from qutebrowser.misc import sql
+
+
+pytestmark = pytest.mark.usefixtures('init_sql')
+
+
+def test_init():
+ sql.SqlTable('Foo', ['name', 'val', 'lucky'])
+ # should not error if table already exists
+ sql.SqlTable('Foo', ['name', 'val', 'lucky'])
+
+
+def test_insert(qtbot):
+ table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
+ with qtbot.waitSignal(table.changed):
+ table.insert({'name': 'one', 'val': 1, 'lucky': False})
+ with qtbot.waitSignal(table.changed):
+ table.insert({'name': 'wan', 'val': 1, 'lucky': False})
+
+
+def test_insert_replace(qtbot):
+ table = sql.SqlTable('Foo', ['name', 'val', 'lucky'],
+ constraints={'name': 'PRIMARY KEY'})
+ with qtbot.waitSignal(table.changed):
+ table.insert({'name': 'one', 'val': 1, 'lucky': False}, replace=True)
+ with qtbot.waitSignal(table.changed):
+ table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=True)
+ assert list(table) == [('one', 11, True)]
+
+ with pytest.raises(sql.SqlException):
+ table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=False)
+
+
+def test_insert_batch(qtbot):
+ table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
+
+ with qtbot.waitSignal(table.changed):
+ table.insert_batch({'name': ['one', 'nine', 'thirteen'],
+ 'val': [1, 9, 13],
+ 'lucky': [False, False, True]})
+
+ assert list(table) == [('one', 1, False),
+ ('nine', 9, False),
+ ('thirteen', 13, True)]
+
+
+def test_insert_batch_replace(qtbot):
+ table = sql.SqlTable('Foo', ['name', 'val', 'lucky'],
+ constraints={'name': 'PRIMARY KEY'})
+
+ with qtbot.waitSignal(table.changed):
+ table.insert_batch({'name': ['one', 'nine', 'thirteen'],
+ 'val': [1, 9, 13],
+ 'lucky': [False, False, True]})
+
+ with qtbot.waitSignal(table.changed):
+ table.insert_batch({'name': ['one', 'nine'],
+ 'val': [11, 19],
+ 'lucky': [True, True]},
+ replace=True)
+
+ assert list(table) == [('thirteen', 13, True),
+ ('one', 11, True),
+ ('nine', 19, True)]
+
+ with pytest.raises(sql.SqlException):
+ table.insert_batch({'name': ['one', 'nine'],
+ 'val': [11, 19],
+ 'lucky': [True, True]})
+
+
+def test_iter():
+ table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
+ table.insert({'name': 'one', 'val': 1, 'lucky': False})
+ table.insert({'name': 'nine', 'val': 9, 'lucky': False})
+ table.insert({'name': 'thirteen', 'val': 13, 'lucky': True})
+ assert list(table) == [('one', 1, False),
+ ('nine', 9, False),
+ ('thirteen', 13, True)]
+
+
+@pytest.mark.parametrize('rows, sort_by, sort_order, limit, result', [
+ ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'asc', 5,
+ [(1, 6), (2, 5), (3, 4)]),
+ ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'desc', 3,
+ [(3, 4), (2, 5), (1, 6)]),
+ ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'b', 'desc', 2,
+ [(1, 6), (2, 5)]),
+ ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'asc', -1,
+ [(1, 6), (2, 5), (3, 4)]),
+])
+def test_select(rows, sort_by, sort_order, limit, result):
+ table = sql.SqlTable('Foo', ['a', 'b'])
+ for row in rows:
+ table.insert(row)
+ assert list(table.select(sort_by, sort_order, limit)) == result
+
+
+def test_delete(qtbot):
+ table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
+ table.insert({'name': 'one', 'val': 1, 'lucky': False})
+ table.insert({'name': 'nine', 'val': 9, 'lucky': False})
+ table.insert({'name': 'thirteen', 'val': 13, 'lucky': True})
+ with pytest.raises(KeyError):
+ table.delete('name', 'nope')
+ with qtbot.waitSignal(table.changed):
+ table.delete('name', 'thirteen')
+ assert list(table) == [('one', 1, False), ('nine', 9, False)]
+ with qtbot.waitSignal(table.changed):
+ table.delete('lucky', False)
+ assert not list(table)
+
+
+def test_len():
+ table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
+ assert len(table) == 0
+ table.insert({'name': 'one', 'val': 1, 'lucky': False})
+ assert len(table) == 1
+ table.insert({'name': 'nine', 'val': 9, 'lucky': False})
+ assert len(table) == 2
+ table.insert({'name': 'thirteen', 'val': 13, 'lucky': True})
+ assert len(table) == 3
+
+
+def test_contains():
+ table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
+ table.insert({'name': 'one', 'val': 1, 'lucky': False})
+ table.insert({'name': 'nine', 'val': 9, 'lucky': False})
+ table.insert({'name': 'thirteen', 'val': 13, 'lucky': True})
+
+ name_query = table.contains_query('name')
+ val_query = table.contains_query('val')
+ lucky_query = table.contains_query('lucky')
+
+ assert name_query.run(val='one').value()
+ assert name_query.run(val='thirteen').value()
+ assert val_query.run(val=9).value()
+ assert lucky_query.run(val=False).value()
+ assert lucky_query.run(val=True).value()
+ assert not name_query.run(val='oone').value()
+ assert not name_query.run(val=1).value()
+ assert not name_query.run(val='*').value()
+ assert not val_query.run(val=10).value()
+
+
+def test_delete_all(qtbot):
+ table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
+ table.insert({'name': 'one', 'val': 1, 'lucky': False})
+ table.insert({'name': 'nine', 'val': 9, 'lucky': False})
+ table.insert({'name': 'thirteen', 'val': 13, 'lucky': True})
+ with qtbot.waitSignal(table.changed):
+ table.delete_all()
+ assert list(table) == []
+
+
+def test_version():
+ assert isinstance(sql.version(), str)
diff --git a/tests/unit/utils/test_qtutils.py b/tests/unit/utils/test_qtutils.py
index 99e1091b4..ff8b81c9a 100644
--- a/tests/unit/utils/test_qtutils.py
+++ b/tests/unit/utils/test_qtutils.py
@@ -550,7 +550,7 @@ if test_file is not None and sys.platform != 'darwin':
# here which defines unittest TestCases to run the python tests over
# PyQIODevice.
- # Those are not run on OS X because that seems to cause a hang sometimes.
+ # Those are not run on macOS because that seems to cause a hang sometimes.
@pytest.fixture(scope='session', autouse=True)
def clean_up_python_testfile():
diff --git a/tests/unit/utils/test_standarddir.py b/tests/unit/utils/test_standarddir.py
index c21ff7416..ea3368862 100644
--- a/tests/unit/utils/test_standarddir.py
+++ b/tests/unit/utils/test_standarddir.py
@@ -167,8 +167,8 @@ class TestStandardDir:
(standarddir.cache, 2, ['Caches', 'qute_test']),
(standarddir.download, 1, ['Downloads']),
])
- @pytest.mark.osx
- def test_os_x(self, func, elems, expected):
+ @pytest.mark.mac
+ def test_mac(self, func, elems, expected):
assert func().split(os.sep)[-elems:] == expected
diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py
index dc925e215..255c88344 100644
--- a/tests/unit/utils/test_utils.py
+++ b/tests/unit/utils/test_utils.py
@@ -759,37 +759,6 @@ def test_sanitize_filename_empty_replacement():
assert utils.sanitize_filename(name, replacement=None) == 'Bad File'
-class TestNewestSlice:
-
- """Test newest_slice."""
-
- def test_count_minus_two(self):
- """Test with a count of -2."""
- with pytest.raises(ValueError):
- utils.newest_slice([], -2)
-
- @pytest.mark.parametrize('items, count, expected', [
- # Count of -1 (all elements).
- (range(20), -1, range(20)),
- # Count of 0 (no elements).
- (range(20), 0, []),
- # Count which is much smaller than the iterable.
- (range(20), 5, [15, 16, 17, 18, 19]),
- # Count which is exactly one smaller."""
- (range(5), 4, [1, 2, 3, 4]),
- # Count which is just as large as the iterable."""
- (range(5), 5, range(5)),
- # Count which is one bigger than the iterable.
- (range(5), 6, range(5)),
- # Count which is much bigger than the iterable.
- (range(5), 50, range(5)),
- ])
- def test_good(self, items, count, expected):
- """Test slices which shouldn't raise an exception."""
- sliced = utils.newest_slice(items, count)
- assert list(sliced) == list(expected)
-
-
class TestGetSetClipboard:
@pytest.fixture(autouse=True)
diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py
index 32c3fcf5c..f9da37841 100644
--- a/tests/unit/utils/test_version.py
+++ b/tests/unit/utils/test_version.py
@@ -21,6 +21,7 @@
import io
import sys
+import collections
import os.path
import subprocess
import contextlib
@@ -475,29 +476,31 @@ class ImportFake:
"""A fake for __import__ which is used by the import_fake fixture.
Attributes:
- exists: A dict mapping module names to bools. If True, the import will
- success. Otherwise, it'll fail with ImportError.
+ modules: A dict mapping module names to bools. If True, the import will
+ success. Otherwise, it'll fail with ImportError.
version_attribute: The name to use in the fake modules for the version
attribute.
version: The version to use for the modules.
_real_import: Saving the real __import__ builtin so the imports can be
- done normally for modules not in self.exists.
+ done normally for modules not in self. modules.
"""
def __init__(self):
- self.exists = {
- 'sip': True,
- 'colorama': True,
- 'pypeg2': True,
- 'jinja2': True,
- 'pygments': True,
- 'yaml': True,
- 'cssutils': True,
- 'typing': True,
- 'PyQt5.QtWebEngineWidgets': True,
- 'PyQt5.QtWebKitWidgets': True,
- 'OpenGL': True,
- }
+ self.modules = collections.OrderedDict([
+ ('sip', True),
+ ('colorama', True),
+ ('pypeg2', True),
+ ('jinja2', True),
+ ('pygments', True),
+ ('yaml', True),
+ ('cssutils', True),
+ ('typing', True),
+ ('PyQt5.QtWebEngineWidgets', True),
+ ('PyQt5.QtWebKitWidgets', True),
+ ])
+ self.no_version_attribute = ['sip', 'typing',
+ 'PyQt5.QtWebEngineWidgets',
+ 'PyQt5.QtWebKitWidgets']
self.version_attribute = '__version__'
self.version = '1.2.3'
self._real_import = builtins.__import__
@@ -509,10 +512,10 @@ class ImportFake:
The imported fake module, or None if normal importing should be
used.
"""
- if name not in self.exists:
+ if name not in self.modules:
# Not one of the modules to test -> use real import
return None
- elif self.exists[name]:
+ elif self.modules[name]:
ns = types.SimpleNamespace()
if self.version_attribute is not None:
setattr(ns, self.version_attribute, self.version)
@@ -551,14 +554,14 @@ class TestModuleVersions:
"""Tests for _module_versions()."""
- @pytest.mark.usefixtures('import_fake')
- def test_all_present(self):
+ def test_all_present(self, import_fake):
"""Test with all modules present in version 1.2.3."""
- expected = ['sip: yes', 'colorama: 1.2.3', 'pypeg2: 1.2.3',
- 'jinja2: 1.2.3', 'pygments: 1.2.3', 'yaml: 1.2.3',
- 'cssutils: 1.2.3', 'typing: yes', 'OpenGL: 1.2.3',
- 'PyQt5.QtWebEngineWidgets: yes',
- 'PyQt5.QtWebKitWidgets: yes']
+ expected = []
+ for name in import_fake.modules:
+ if name in import_fake.no_version_attribute:
+ expected.append('{}: yes'.format(name))
+ else:
+ expected.append('{}: 1.2.3'.format(name))
assert version._module_versions() == expected
@pytest.mark.parametrize('module, idx, expected', [
@@ -574,36 +577,31 @@ class TestModuleVersions:
idx: The index where the given text is expected.
expected: The expected text.
"""
- import_fake.exists[module] = False
+ import_fake.modules[module] = False
assert version._module_versions()[idx] == expected
- @pytest.mark.parametrize('value, expected', [
- ('VERSION', ['sip: yes', 'colorama: 1.2.3', 'pypeg2: yes',
- 'jinja2: yes', 'pygments: yes', 'yaml: yes',
- 'cssutils: yes', 'typing: yes', 'OpenGL: yes',
- 'PyQt5.QtWebEngineWidgets: yes',
- 'PyQt5.QtWebKitWidgets: yes']),
- ('SIP_VERSION_STR', ['sip: 1.2.3', 'colorama: yes', 'pypeg2: yes',
- 'jinja2: yes', 'pygments: yes', 'yaml: yes',
- 'cssutils: yes', 'typing: yes', 'OpenGL: yes',
- 'PyQt5.QtWebEngineWidgets: yes',
- 'PyQt5.QtWebKitWidgets: yes']),
- (None, ['sip: yes', 'colorama: yes', 'pypeg2: yes', 'jinja2: yes',
- 'pygments: yes', 'yaml: yes', 'cssutils: yes', 'typing: yes',
- 'OpenGL: yes', 'PyQt5.QtWebEngineWidgets: yes',
- 'PyQt5.QtWebKitWidgets: yes']),
+ @pytest.mark.parametrize('attribute, expected_modules', [
+ ('VERSION', ['colorama']),
+ ('SIP_VERSION_STR', ['sip']),
+ (None, []),
])
- def test_version_attribute(self, value, expected, import_fake):
+ def test_version_attribute(self, attribute, expected_modules, import_fake):
"""Test with a different version attribute.
VERSION is tested for old colorama versions, and None to make sure
things still work if some package suddenly doesn't have __version__.
Args:
- value: The name of the version attribute.
+ attribute: The name of the version attribute.
expected: The expected return value.
"""
- import_fake.version_attribute = value
+ import_fake.version_attribute = attribute
+ expected = []
+ for name in import_fake.modules:
+ if name in expected_modules:
+ expected.append('{}: 1.2.3'.format(name))
+ else:
+ expected.append('{}: yes'.format(name))
assert version._module_versions() == expected
@pytest.mark.parametrize('name, has_version', [
@@ -669,8 +667,8 @@ class TestOsInfo:
(('', ('', '', ''), ''), ''),
(('x', ('1', '2', '3'), 'y'), 'x, 1.2.3, y'),
])
- def test_os_x_fake(self, monkeypatch, mac_ver, mac_ver_str):
- """Test with a fake OS X.
+ def test_mac_fake(self, monkeypatch, mac_ver, mac_ver_str):
+ """Test with a fake macOS.
Args:
mac_ver: The tuple to set platform.mac_ver() to.
@@ -699,9 +697,9 @@ class TestOsInfo:
"""Make sure there are no exceptions with a real Windows."""
version._os_info()
- @pytest.mark.osx
- def test_os_x_real(self):
- """Make sure there are no exceptions with a real OS X."""
+ @pytest.mark.mac
+ def test_mac_real(self):
+ """Make sure there are no exceptions with a real macOS."""
version._os_info()
@@ -759,14 +757,16 @@ class FakeQSslSocket:
Attributes:
_version: What QSslSocket::sslLibraryVersionString() should return.
+ _support: Whether SSL is supported.
"""
- def __init__(self, version=None):
+ def __init__(self, version=None, support=True):
self._version = version
+ self._support = support
def supportsSsl(self):
"""Fake for QSslSocket::supportsSsl()."""
- return True
+ return self._support
def sslLibraryVersionString(self):
"""Fake for QSslSocket::sslLibraryVersionString()."""
@@ -799,18 +799,30 @@ def test_chromium_version_unpatched(qapp):
assert version._chromium_version() not in ['', 'unknown', 'unavailable']
-@pytest.mark.parametrize(['git_commit', 'frozen', 'style', 'with_webkit',
- 'known_distribution'], [
- (True, False, True, True, True), # normal
- (False, False, True, True, True), # no git commit
- (True, True, True, True, True), # frozen
- (True, True, False, True, True), # no style
- (True, False, True, False, True), # no webkit
- (True, False, True, 'ng', True), # QtWebKit-NG
- (True, False, True, True, False), # unknown Linux distribution
-]) # pylint: disable=too-many-locals
-def test_version_output(git_commit, frozen, style, with_webkit,
- known_distribution, stubs, monkeypatch):
+class VersionParams:
+
+ def __init__(self, name, git_commit=True, frozen=False, style=True,
+ with_webkit=True, known_distribution=True, ssl_support=True):
+ self.name = name
+ self.git_commit = git_commit
+ self.frozen = frozen
+ self.style = style
+ self.with_webkit = with_webkit
+ self.known_distribution = known_distribution
+ self.ssl_support = ssl_support
+
+
+@pytest.mark.parametrize('params', [
+ VersionParams('normal'),
+ VersionParams('no-git-commit', git_commit=False),
+ VersionParams('frozen', frozen=True),
+ VersionParams('no-style', style=False),
+ VersionParams('no-webkit', with_webkit=False),
+ VersionParams('webkit-ng', with_webkit='ng'),
+ VersionParams('unknown-dist', known_distribution=False),
+ VersionParams('no-ssl', ssl_support=False),
+], ids=lambda param: param.name)
+def test_version_output(params, stubs, monkeypatch):
"""Test version.version()."""
class FakeWebEngineProfile:
def httpUserAgent(self):
@@ -820,36 +832,38 @@ def test_version_output(git_commit, frozen, style, with_webkit,
patches = {
'qutebrowser.__file__': os.path.join(import_path, '__init__.py'),
'qutebrowser.__version__': 'VERSION',
- '_git_str': lambda: ('GIT COMMIT' if git_commit else None),
+ '_git_str': lambda: ('GIT COMMIT' if params.git_commit else None),
'platform.python_implementation': lambda: 'PYTHON IMPLEMENTATION',
'platform.python_version': lambda: 'PYTHON VERSION',
'PYQT_VERSION_STR': 'PYQT VERSION',
'earlyinit.qt_version': lambda: 'QT VERSION',
'_module_versions': lambda: ['MODULE VERSION 1', 'MODULE VERSION 2'],
'_pdfjs_version': lambda: 'PDFJS VERSION',
- 'QSslSocket': FakeQSslSocket('SSL VERSION'),
+ 'QSslSocket': FakeQSslSocket('SSL VERSION', params.ssl_support),
'platform.platform': lambda: 'PLATFORM',
'platform.architecture': lambda: ('ARCHITECTURE', ''),
'_os_info': lambda: ['OS INFO 1', 'OS INFO 2'],
'_path_info': lambda: {'PATH DESC': 'PATH NAME'},
- 'QApplication': (stubs.FakeQApplication(style='STYLE') if style else
+ 'QApplication': (stubs.FakeQApplication(style='STYLE')
+ if params.style else
stubs.FakeQApplication(instance=None)),
'QLibraryInfo.location': (lambda _loc: 'QT PATH'),
+ 'sql.version': lambda: 'SQLITE VERSION',
}
substitutions = {
- 'git_commit': '\nGit commit: GIT COMMIT' if git_commit else '',
- 'style': '\nStyle: STYLE' if style else '',
+ 'git_commit': '\nGit commit: GIT COMMIT' if params.git_commit else '',
+ 'style': '\nStyle: STYLE' if params.style else '',
'qt': 'QT VERSION',
- 'frozen': str(frozen),
+ 'frozen': str(params.frozen),
'import_path': import_path,
}
- if with_webkit:
+ if params.with_webkit:
patches['qWebKitVersion'] = lambda: 'WEBKIT VERSION'
patches['objects.backend'] = usertypes.Backend.QtWebKit
patches['QWebEngineProfile'] = None
- if with_webkit == 'ng':
+ if params.with_webkit == 'ng':
backend = 'QtWebKit-NG'
patches['qtutils.is_qtwebkit_ng'] = lambda: True
else:
@@ -862,7 +876,7 @@ def test_version_output(git_commit, frozen, style, with_webkit,
patches['QWebEngineProfile'] = FakeWebEngineProfile
substitutions['backend'] = 'QtWebEngine (Chromium CHROMIUMVERSION)'
- if known_distribution:
+ if params.known_distribution:
patches['distribution'] = lambda: version.DistributionInfo(
parsed=version.Distribution.arch, version=None,
pretty='LINUX DISTRIBUTION', id='arch')
@@ -874,10 +888,12 @@ def test_version_output(git_commit, frozen, style, with_webkit,
substitutions['linuxdist'] = ''
substitutions['osinfo'] = 'OS INFO 1\nOS INFO 2\n'
+ substitutions['ssl'] = 'SSL VERSION' if params.ssl_support else 'no'
+
for attr, val in patches.items():
monkeypatch.setattr('qutebrowser.utils.version.' + attr, val)
- if frozen:
+ if params.frozen:
monkeypatch.setattr(sys, 'frozen', True, raising=False)
else:
monkeypatch.delattr(sys, 'frozen', raising=False)
@@ -893,7 +909,8 @@ def test_version_output(git_commit, frozen, style, with_webkit,
MODULE VERSION 1
MODULE VERSION 2
pdf.js: PDFJS VERSION
- SSL: SSL VERSION
+ sqlite: SQLITE VERSION
+ QtNetwork SSL: {ssl}
{style}
Platform: PLATFORM, ARCHITECTURE{linuxdist}
Frozen: {frozen}
diff --git a/tox.ini b/tox.ini
index 2989fb1df..89be20e68 100644
--- a/tox.ini
+++ b/tox.ini
@@ -111,6 +111,28 @@ deps =
PyQt5==5.8.2
commands = {envpython} -bb -m pytest {posargs:tests}
+[testenv:py35-pyqt59]
+basepython = python3.5
+setenv =
+ {[testenv]setenv}
+ QUTE_BDD_WEBENGINE=true
+passenv = {[testenv]passenv}
+deps =
+ {[testenv]deps}
+ PyQt5==5.9
+commands = {envpython} -bb -m pytest {posargs:tests}
+
+[testenv:py36-pyqt59]
+basepython = {env:PYTHON:python3.6}
+setenv =
+ {[testenv]setenv}
+ QUTE_BDD_WEBENGINE=true
+passenv = {[testenv]passenv}
+deps =
+ {[testenv]deps}
+ PyQt5==5.9
+commands = {envpython} -bb -m pytest {posargs:tests}
+
# other envs
[testenv:mkvenv]