diff --git a/.flake8 b/.flake8
index d967a505b..eada2c86d 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/INSTALL.asciidoc b/INSTALL.asciidoc
index 7f197d408..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
diff --git a/qutebrowser/app.py b/qutebrowser/app.py
index e0873195e..51cacfac7 100644
--- a/qutebrowser/app.py
+++ b/qutebrowser/app.py
@@ -42,9 +42,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 style, config, websettings, configexc
+from qutebrowser.config.parsers import keyconf
from qutebrowser.browser import (urlmarks, adblock, history, browsertab,
downloads)
from qutebrowser.browser.network import proxy
@@ -53,10 +54,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.
@@ -157,7 +158,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()
@@ -421,6 +422,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)
@@ -457,9 +469,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.get('ui', 'hide-wayland-decoration'):
os.environ['QT_WAYLAND_DISABLE_WINDOWDECORATION'] = '1'
@@ -470,23 +479,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.
@@ -751,7 +743,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/commands.py b/qutebrowser/browser/commands.py
index b3e9c37bf..75ef17c08 100644
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -42,7 +42,7 @@ from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils,
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:
@@ -272,7 +272,7 @@ class CommandDispatcher:
@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, implicit=False,
bg=False, tab=False, window=False, count=None, secure=False,
@@ -1010,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.
@@ -1026,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(
@@ -1235,8 +1234,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.
@@ -1254,8 +1252,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.
@@ -1317,7 +1314,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.
@@ -1339,7 +1336,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.
@@ -1523,7 +1520,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.
diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py
index aaad08fb3..b9e791207 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)
+ def __init__(self, parent=None):
+ super().__init__("CompletionHistory", ['url', 'title', 'last_atime'],
+ constraints={'url': 'PRIMARY KEY'}, parent=parent)
+ self.create_index('CompletionHistoryAtimeIndex', 'last_atime')
-class WebHistory(QObject):
+class WebHistory(sql.SqlTable):
- """The global history of visited pages.
+ """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.
+ 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.history_dict is the main place where the history is stored, in an
- OrderedDict (sorted by time) of URL strings mapped to Entry objects.
-
- 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.
-
- 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.
-
- 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)
+ 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 __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
-
- with self._lineparser.open():
- for line in self._lineparser:
- yield
-
- line = line.rstrip()
- if not line:
- continue
-
- 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 __contains__(self, url):
+ return self._contains_query.run(val=url).value()
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]
+ return self.select(sort_by='atime', sort_order='desc', limit=100)
- 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)
+ def entries_between(self, earliest, latest):
+ """Iterate non-redirect, non-qute entries between two timestamps.
+
+ 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)
+
+ def entries_before(self, latest, limit, offset):
+ """Iterate non-redirect, non-qute entries occurring before a timestamp.
+
+ 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,129 @@ 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://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 +289,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/qutescheme.py b/qutebrowser/browser/qutescheme.py
index 652c58726..0f86d0980 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
@@ -186,88 +185,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")
@@ -275,7 +222,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.get('content', 'allow-javascript') and
@@ -307,9 +254,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/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/completion/completer.py b/qutebrowser/completion/completer.py
index 96d937829..ae72add20 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):
@@ -39,6 +39,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, win_id, parent=None):
@@ -52,6 +53,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):
@@ -62,37 +64,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.
@@ -109,8 +82,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:
@@ -119,14 +92,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.
@@ -241,6 +211,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()
@@ -249,13 +220,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)
- log.completion.debug("Setting completion model to {} with pattern '{}'"
- .format(model.srcmodel.__class__.__name__ if model else 'None',
- pattern))
+ if func is None:
+ log.completion.debug('Clearing completion')
+ completion.set_model(None)
+ self._last_completion_func = None
+ return
- completion.set_model(model, 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_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 1d5dfadf0..b2a933cef 100644
--- a/qutebrowser/completion/completiondelegate.py
+++ b/qutebrowser/completion/completiondelegate.py
@@ -196,8 +196,9 @@ class CompletionItemDelegate(QStyledItemDelegate):
self._doc.setDocumentMargin(2)
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'\g<0>'
text = re.sub(re.escape(pattern).replace(r'\ ', r'|'),
diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py
index 490fcd6c0..6e1e51680 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, style
from qutebrowser.completion import completiondelegate
-from qutebrowser.completion.models import base
-from qutebrowser.utils import utils, usertypes, objreg
+from qutebrowser.utils import utils, usertypes, objreg, 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,12 @@ class CompletionView(QTreeView):
def __init__(self, win_id, parent=None):
super().__init__(parent)
+ self.pattern = ''
self._win_id = win_id
# FIXME handle new aliases.
# objreg.get('config').changed.connect(self.init_command_completion)
objreg.get('config').changed.connect(self._on_config_changed)
- self._column_widths = base.BaseCompletionModel.COLUMN_WIDTHS
self._active = False
self._delegate = completiondelegate.CompletionItemDelegate(self)
@@ -151,7 +151,8 @@ class CompletionView(QTreeView):
def _resize_columns(self):
"""Resize the completion columns based on column_widths."""
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
@@ -262,47 +263,49 @@ class CompletionView(QTreeView):
elif config.get('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()
-
- self.setModel(model)
- self._active = True
-
- if sel_model is not None:
- sel_model.deleteLater()
- if old_model is not None:
- old_model.deleteLater()
-
- if (config.get('completion', 'show') == 'always' and
- model.count() > 0):
- self.show()
- else:
- self.hide()
+ model.setParent(self)
+ self._active = True
+ self._maybe_show()
+ self._resize_columns()
for i in range(model.rowCount()):
self.expand(model.index(i, 0))
- if pattern is not None:
- model.set_pattern(pattern)
+ 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._maybe_update_geometry()
+ self._maybe_show()
- self._column_widths = model.srcmodel.COLUMN_WIDTHS
- self._resize_columns()
- self._maybe_update_geometry()
+ def _maybe_show(self):
+ if (config.get('completion', 'show') == 'always' and
+ self.model().count() > 0):
+ self.show()
+ else:
+ self.hide()
def _maybe_update_geometry(self):
"""Emit the update_geometry signal if the config says so."""
@@ -347,7 +350,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):
@@ -367,9 +370,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)
-#
-# 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 .
-
-"""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..3e48076e5
--- /dev/null
+++ b/qutebrowser/completion/models/completionmodel.py
@@ -0,0 +1,224 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Ryan Roden-Corrent (rcorre)
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see .
+
+"""A model that proxies access to one or more completion categories."""
+
+from PyQt5.QtCore import Qt, QModelIndex, QAbstractItemModel
+
+from qutebrowser.utils import log, qtutils
+
+
+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"
+ self.beginRemoveRows(parent, index.row(), index.row())
+ cat.delete_cur_item(cat.index(index.row(), 0))
+ self.endRemoveRows()
diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py
index c9e9850d1..663a0b7f7 100644
--- a/qutebrowser/completion/models/configmodel.py
+++ b/qutebrowser/completion/models/configmodel.py
@@ -17,142 +17,80 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see .
-"""CompletionModels for the config."""
+"""Functions that return config-related completion models."""
-from PyQt5.QtCore import pyqtSlot, Qt
-
-from qutebrowser.config import config, configdata
-from qutebrowser.utils import log, qtutils, objreg
-from qutebrowser.completion.models import base
+from qutebrowser.config import configdata, configexc
+from qutebrowser.completion.models import completionmodel, listcategory
+from qutebrowser.utils import objreg
-class SettingSectionCompletionModel(base.BaseCompletionModel):
-
+def section():
"""A CompletionModel filled with settings sections."""
-
- # 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)
+ 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
-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
- objreg.get('config').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.
"""
+ model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
+ config = objreg.get('config')
- # https://github.com/qutebrowser/qutebrowser/issues/545
- # pylint: disable=abstract-method
+ try:
+ current = config.get(sectname, optname, raw=True) or '""'
+ except (configexc.NoSectionError, configexc.NoOptionError):
+ return None
- COLUMN_WIDTHS = (20, 70, 10)
+ default = configdata.DATA[sectname][optname].default() or '""'
- def __init__(self, section, option, parent=None):
- super().__init__(parent)
- self._section = section
- self._option = option
- objreg.get('config').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)
+ 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()
- @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))
+ 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..fa8443a60
--- /dev/null
+++ b/qutebrowser/completion/models/histcategory.py
@@ -0,0 +1,100 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Ryan Roden-Corrent (rcorre)
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see .
+
+"""A 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.commands import cmdexc
+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')
+ 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()
+
+ 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 delete_cur_item(self, index):
+ """Delete the row at the given index."""
+ if not self.delete_func:
+ raise cmdexc.CommandError("Cannot delete this item.")
+ data = [self.data(index.sibling(index.row(), i))
+ for i in range(self.columnCount())]
+ self.delete_func(data)
+ # 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)
diff --git a/qutebrowser/completion/models/instances.py b/qutebrowser/completion/models/instances.py
deleted file mode 100644
index f7eaaca86..000000000
--- a/qutebrowser/completion/models/instances.py
+++ /dev/null
@@ -1,196 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2015-2017 Florian Bruhin (The Compiler)
-#
-# This file is part of qutebrowser.
-#
-# qutebrowser is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# qutebrowser is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with qutebrowser. If not, see .
-
-"""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, configmodel
-from qutebrowser.utils import objreg, usertypes, log, debug
-from qutebrowser.config import configdata, config
-
-
-_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_setting_completions():
- """Initialize setting completion models."""
- log.completion.debug("Initializing setting completion.")
- _instances[usertypes.Completion.section] = (
- configmodel.SettingSectionCompletionModel())
- _instances[usertypes.Completion.option] = {}
- _instances[usertypes.Completion.value] = {}
- for sectname in configdata.DATA:
- opt_model = configmodel.SettingOptionCompletionModel(sectname)
- _instances[usertypes.Completion.option][sectname] = opt_model
- _instances[usertypes.Completion.value][sectname] = {}
- for opt in configdata.DATA[sectname]:
- val_model = configmodel.SettingValueCompletionModel(sectname, opt)
- _instances[usertypes.Completion.value][sectname][opt] = val_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.section: _init_setting_completions,
- usertypes.Completion.option: _init_setting_completions,
- usertypes.Completion.value: _init_setting_completions,
- 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)
-
-
-@config.change_filter('aliases', function=True)
-def _update_aliases():
- """Update completions that include command aliases."""
- update([usertypes.Completion.command])
-
-
-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]))
-
- keyconf = objreg.get('key-config')
- keyconf.changed.connect(
- functools.partial(update, [usertypes.Completion.command]))
- keyconf.changed.connect(
- functools.partial(update, [usertypes.Completion.bind]))
-
- objreg.get('config').changed.connect(_update_aliases)
diff --git a/qutebrowser/completion/models/listcategory.py b/qutebrowser/completion/models/listcategory.py
new file mode 100644
index 000000000..187ebcad6
--- /dev/null
+++ b/qutebrowser/completion/models/listcategory.py
@@ -0,0 +1,102 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Ryan Roden-Corrent (rcorre)
+#
+# 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 .
+
+"""Completion category that uses a list of tuples as a data source."""
+
+import re
+
+from PyQt5.QtCore import Qt, QSortFilterProxyModel, QModelIndex, QRegExp
+from PyQt5.QtGui import QStandardItem, QStandardItemModel
+
+from qutebrowser.utils import qtutils
+from qutebrowser.commands import cmdexc
+
+
+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
+
+ def delete_cur_item(self, index):
+ """Delete the row at the given index."""
+ if not self.delete_func:
+ raise cmdexc.CommandError("Cannot delete this item.")
+ data = [self.data(index.sibling(index.row(), i))
+ for i in range(self.columnCount())]
+ self.delete_func(data)
+ self.removeRow(index.row(), QModelIndex())
diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py
index 5ab381c43..167eccde8 100644
--- a/qutebrowser/completion/models/miscmodels.py
+++ b/qutebrowser/completion/models/miscmodels.py
@@ -17,252 +17,125 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see .
-"""Misc. CompletionModels."""
+"""Functions that return miscellaneous completion models."""
-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()
- # https://github.com/qutebrowser/qutebrowser/issues/545
- # pylint: disable=abstract-method
+ 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))
- 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)
+ model.add_category(listcategory.ListCategory("Commands", cmdlist))
+ model.add_category(listcategory.ListCategory("Settings", settings))
+ return model
-class QuickmarkCompletionModel(base.BaseCompletionModel):
-
+def quickmark():
"""A CompletionModel filled with all quickmarks."""
-
- # 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)
+ model = completionmodel.CompletionModel(column_widths=(30, 70, 0))
+ marks = objreg.get('quickmark-manager').marks.items()
+ model.add_category(listcategory.ListCategory('Quickmarks', marks))
+ return model
-class BookmarkCompletionModel(base.BaseCompletionModel):
-
+def bookmark():
"""A CompletionModel filled with all bookmarks."""
-
- # 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))
+ 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!")
+ 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
-class TabCompletionModel(base.BaseCompletionModel):
-
+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_name = objreg.get('key-config').get_bindings_for('normal').get(key)
+
+ if cmd_name:
+ cmd = cmdutils.cmd_dict.get(cmd_name)
+ data = [(cmd_name, 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)
-#
-# This file is part of qutebrowser.
-#
-# qutebrowser is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# qutebrowser is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with qutebrowser. If not, see .
-
-"""A 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 98f68c08c..0c5fbeacc 100644
--- a/qutebrowser/completion/models/urlmodel.py
+++ b/qutebrowser/completion/models/urlmodel.py
@@ -17,176 +17,54 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see .
-"""CompletionModels for URLs."""
+"""Function to return the url completion model for the `open` command."""
-import datetime
-
-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
+from qutebrowser.completion.models import (completionmodel, listcategory,
+ histcategory)
+from qutebrowser.utils import log, objreg
-class UrlCompletionModel(base.BaseCompletionModel):
+_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)
+
+
+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))
- URL_COLUMN = 0
- TEXT_COLUMN = 1
- TIME_COLUMN = 2
+ quickmarks = ((url, name) for (name, url)
+ in objreg.get('quickmark-manager').marks.items())
+ bookmarks = objreg.get('bookmark-manager').marks.items()
- COLUMN_WIDTHS = (40, 50, 10)
- DUMB_SORT = Qt.DescendingOrder
+ model.add_category(listcategory.ListCategory(
+ 'Quickmarks', quickmarks, delete_func=_delete_quickmark))
+ model.add_category(listcategory.ListCategory(
+ 'Bookmarks', bookmarks, delete_func=_delete_bookmark))
- 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.get('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)
-
- objreg.get('config').changed.connect(self.reformat_timestamps)
-
- def _fmt_atime(self, atime):
- """Format an atime to a human-readable string."""
- fmt = config.get('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)
+ 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 587da214f..8bae2bae0 100644
--- a/qutebrowser/config/config.py
+++ b/qutebrowser/config/config.py
@@ -38,13 +38,12 @@ from PyQt5.QtCore import pyqtSignal, QObject, QUrl, QSettings
from PyQt5.QtGui import QColor
from qutebrowser.config import configdata, configexc, textwrapper
-from qutebrowser.config.parsers import keyconf
from qutebrowser.config.parsers import ini
from qutebrowser.commands import cmdexc, cmdutils
from qutebrowser.utils import (message, objreg, utils, standarddir, log,
qtutils, error, usertypes)
from qutebrowser.misc import objects
-from qutebrowser.utils.usertypes import Completion
+from qutebrowser.completion.models import configmodel
UNSET = object()
@@ -175,37 +174,6 @@ def _init_main_config(parent=None):
return
-def _init_key_config(parent):
- """Initialize the key config.
-
- Args:
- parent: The parent to use for the KeyConfigParser.
- """
- args = objreg.get('args')
- try:
- key_config = keyconf.KeyConfigParser(standarddir.config(), 'keys.conf',
- args.relaxed_config,
- parent=parent)
- except (keyconf.KeyConfigError, cmdexc.CommandError,
- UnicodeDecodeError) as e:
- log.init.exception(e)
- errstr = "Error while reading key config:\n"
- if e.lineno is not None:
- errstr += "In line {}: ".format(e.lineno)
- error.handle_fatal_exc(e, args, "Error while reading key config!",
- pre_text=errstr)
- # We didn't really initialize much so far, so we just quit hard.
- sys.exit(usertypes.Exit.err_key_config)
- else:
- objreg.register('key-config', key_config)
- save_manager = objreg.get('save-manager')
- filename = os.path.join(standarddir.config(), 'keys.conf')
- save_manager.add_saveable(
- 'key-config', key_config.save, key_config.config_dirty,
- config_opt=('general', 'auto-save-config'), filename=filename,
- dirty=key_config.is_dirty)
-
-
def _init_misc():
"""Initialize misc. config-related files."""
save_manager = objreg.get('save-manager')
@@ -249,7 +217,6 @@ def init(parent=None):
parent: The parent to pass to QObjects which get initialized.
"""
_init_main_config(parent)
- _init_key_config(parent)
_init_misc()
@@ -794,9 +761,9 @@ class ConfigManager(QObject):
e.__class__.__name__, e))
@cmdutils.register(name='set', instance='config', star_args_optional=True)
- @cmdutils.argument('section_', completion=Completion.section)
- @cmdutils.argument('option', completion=Completion.option)
- @cmdutils.argument('values', completion=Completion.value)
+ @cmdutils.argument('section_', completion=configmodel.section)
+ @cmdutils.argument('option', completion=configmodel.option)
+ @cmdutils.argument('values', completion=configmodel.value)
@cmdutils.argument('win_id', win_id=True)
def set_command(self, win_id, section_=None, option=None, *values,
temp=False, print_=False):
diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py
index 32d80f3b7..23d3efb67 100644
--- a/qutebrowser/config/configdata.py
+++ b/qutebrowser/config/configdata.py
@@ -503,7 +503,7 @@ def data(readonly=False):
"0: no history / -1: unlimited"),
('web-history-max-items',
- SettingValue(typ.Int(minval=-1), '1000'),
+ SettingValue(typ.Int(minval=-1), '-1'),
"How many URLs to show in the web history.\n\n"
"0: no history / -1: unlimited"),
diff --git a/qutebrowser/config/parsers/keyconf.py b/qutebrowser/config/parsers/keyconf.py
index 53f23d7c0..751eafb71 100644
--- a/qutebrowser/config/parsers/keyconf.py
+++ b/qutebrowser/config/parsers/keyconf.py
@@ -22,12 +22,44 @@
import collections
import os.path
import itertools
+import sys
from PyQt5.QtCore import pyqtSignal, QObject
from qutebrowser.config import configdata, textwrapper
from qutebrowser.commands import cmdutils, cmdexc
-from qutebrowser.utils import log, utils, qtutils, message, usertypes
+from qutebrowser.utils import (log, utils, qtutils, message, usertypes, objreg,
+ standarddir, error)
+from qutebrowser.completion.models import miscmodels
+
+
+def init(parent=None):
+ """Read and save keybindings.
+
+ Args:
+ parent: The parent to use for the KeyConfigParser.
+ """
+ args = objreg.get('args')
+ try:
+ key_config = KeyConfigParser(standarddir.config(), 'keys.conf',
+ args.relaxed_config, parent=parent)
+ except (KeyConfigError, UnicodeDecodeError) as e:
+ log.init.exception(e)
+ errstr = "Error while reading key config:\n"
+ if e.lineno is not None:
+ errstr += "In line {}: ".format(e.lineno)
+ error.handle_fatal_exc(e, args, "Error while reading key config!",
+ pre_text=errstr)
+ # We didn't really initialize much so far, so we just quit hard.
+ sys.exit(usertypes.Exit.err_key_config)
+ else:
+ objreg.register('key-config', key_config)
+ save_manager = objreg.get('save-manager')
+ filename = os.path.join(standarddir.config(), 'keys.conf')
+ save_manager.add_saveable(
+ 'key-config', key_config.save, key_config.config_dirty,
+ config_opt=('general', 'auto-save-config'), filename=filename,
+ dirty=key_config.is_dirty)
class KeyConfigError(Exception):
@@ -153,7 +185,7 @@ class KeyConfigParser(QObject):
@cmdutils.register(instance='key-config', maxsplit=1, no_cmd_split=True,
no_replace_variables=True)
- @cmdutils.argument('command', completion=usertypes.Completion.bind)
+ @cmdutils.argument('command', completion=miscmodels.bind)
def bind(self, key, command=None, *, mode='normal', force=False):
"""Bind a key to a command.
diff --git a/qutebrowser/javascript/history.js b/qutebrowser/javascript/history.js
index f46ceb49d..26b4405e9 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++) {
- var item = history[i];
- var currentItemDate = new Date(item.time);
- getSessionNode(currentItemDate).appendChild(makeHistoryRow(
- item.url, item.title, currentItemDate.toLocaleTimeString()
- ));
- lastItemDate = currentItemDate;
- }
-
- var next = history[history.length - 1].next;
- if (next === -1) {
+ if (history.length === 0) {
// Reached end of history
window.onscroll = null;
EOF_MESSAGE.style.display = "block";
LOAD_LINK.style.display = "none";
- } else {
- nextTime = next;
+ return;
+ }
+
+ nextTime = history[history.length - 1].time;
+ nextOffset = 0;
+
+ for (var i = 0, len = history.length; i < len; i++) {
+ var item = history[i];
+ // 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;
+ 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/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py
index 59cf6a6bf..e43cd8891 100644
--- a/qutebrowser/misc/earlyinit.py
+++ b/qutebrowser/misc/earlyinit.py
@@ -338,6 +338,7 @@ 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",
diff --git a/qutebrowser/misc/lineparser.py b/qutebrowser/misc/lineparser.py
index ea9d100b7..2256d9697 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 26656e1ee..4fe0fe4c7 100644
--- a/qutebrowser/misc/sessions.py
+++ b/qutebrowser/misc/sessions.py
@@ -31,10 +31,11 @@ try:
except ImportError: # pragma: no cover
from yaml import SafeLoader as YamlLoader, SafeDumper as YamlDumper
-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
@@ -436,7 +437,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.
@@ -464,7 +465,7 @@ class SessionManager(QObject):
win.close()
@cmdutils.register(name=['session-save', 'w'], 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,
@@ -503,7 +504,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)
+#
+# 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 .
+
+"""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/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py
index 41b44de1f..d1771c212 100644
--- a/qutebrowser/misc/utilcmds.py
+++ b/qutebrowser/misc/utilcmds.py
@@ -170,8 +170,15 @@ def debug_cache_stats():
"""Print LRU cache stats."""
config_info = objreg.get('config').get.cache_info()
style_info = style.get_stylesheet.cache_info()
+ try:
+ from PyQt5.QtWebKit import QWebHistoryInterface
+ interface = QWebHistoryInterface.defaultInterface()
+ history_info = interface.historyContains.cache_info()
+ except ImportError:
+ history_info = None
log.misc.debug('config: {}'.format(config_info))
log.misc.debug('style: {}'.format(style_info))
+ log.misc.debug('history: {}'.format(history_info))
@cmdutils.register(debug=True)
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/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 c85c53f25..b9aa86f20 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
@@ -737,25 +736,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 dd36f381c..fe3e1aedb 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
@@ -328,6 +328,7 @@ def version():
lines += [
'pdf.js: {}'.format(_pdfjs_version()),
+ 'sqlite: {}'.format(sql.version()),
'QtNetwork SSL: {}\n'.format(QSslSocket.sslLibraryVersionString()
if QSslSocket.supportsSsl() else 'no'),
]
diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py
index b03fb7ef4..e22d3a155 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'),
@@ -157,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/tests/end2end/features/completion.feature b/tests/end2end/features/completion.feature
index b6c62336c..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
@@ -62,37 +62,11 @@ Feature: Using completion
Scenario: Using option completion
When I run :set-cmd-text -s :set colors
- Then the completion model should be SettingOptionCompletionModel
+ Then the completion model should be option
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-complete 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)
-
- Scenario: Updating the value completion in realtime
- Given I set colors -> statusbar.bg to green
- When I run :set-cmd-text -s :set colors statusbar.bg
- And I set colors -> statusbar.bg to yellow
- And I run :completion-item-focus next
- And I run :completion-item-focus next
- And I set colors -> statusbar.bg to red
- And I run :command-accept
- Then colors -> statusbar.bg should be yellow
+ 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/history.feature b/tests/end2end/features/history.feature
index 2d02c518a..9317bde64 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 ca4fe5a3d..bf2b05697 100644
--- a/tests/end2end/features/misc.feature
+++ b/tests/end2end/features/misc.feature
@@ -702,3 +702,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/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_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 .
-import os.path
+import logging
+import re
import pytest_bdd as bdd
+
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')
+@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))
- expected = expected.replace('(port)', str(httpbin.port)).splitlines()
+ 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)
- 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)
-
- 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/helpers/fixtures.py b/tests/helpers/fixtures.py
index 3af7195f5..74c63f251 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
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
@@ -257,18 +257,9 @@ def bookmark_manager_stub(stubs):
objreg.delete('bookmark-manager')
-@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
@@ -482,3 +473,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 368fe33e0..c0b1e0abe 100644
--- a/tests/helpers/stubs.py
+++ b/tests/helpers/stubs.py
@@ -29,7 +29,7 @@ from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache,
QNetworkCacheMetaData)
from PyQt5.QtWidgets import QCommonStyle, QLineEdit, QWidget, QTabBar
-from qutebrowser.browser import browsertab, history
+from qutebrowser.browser import browsertab
from qutebrowser.config import configexc
from qutebrowser.utils import usertypes, utils
from qutebrowser.mainwindow import mainwindow
@@ -405,6 +405,10 @@ class InstaTimer(QObject):
def setInterval(self, interval):
pass
+ @staticmethod
+ def singleShot(_interval, fun):
+ fun()
+
class FakeConfigType:
@@ -538,24 +542,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."""
diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py
new file mode 100644
index 000000000..81637f3d4
--- /dev/null
+++ b/tests/unit/browser/test_history.py
@@ -0,0 +1,349 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2016-2017 Florian Bruhin (The Compiler)
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see .
+
+"""Tests for 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"foo"
+ 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://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_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)
-#
-# This file is part of qutebrowser.
-#
-# qutebrowser is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# qutebrowser is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with qutebrowser. If not, see .
-
-"""Tests for 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"foo"
- 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
-#
-# This file is part of qutebrowser.
-#
-# qutebrowser is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# qutebrowser is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with qutebrowser. If not, see .
-
-"""Tests for qutebrowser.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 d18e6c125..537baad46 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
@@ -34,11 +33,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):
@@ -70,39 +68,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, implicit=False, bg=False, tab=False, window=False,
count=None):
@@ -110,7 +114,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
@@ -140,60 +144,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 |', usertypes.Completion.command, ''),
- (':bind foo|', usertypes.Completion.command, 'foo'),
- (':bind | foo', None, ''),
- (':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 |', usertypes.Completion.command, ''),
- pytest.param(':bind --mode caret |', 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 |', 'command', '', ['']),
+ (':bind foo|', 'command', 'foo', ['']),
+ (':bind | foo', None, '', []),
+ (':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 |', 'command', '', ['']),
+ pytest.param(':bind --mode caret |', '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..4d1d3f123
--- /dev/null
+++ b/tests/unit/completion/test_completionmodel.py
@@ -0,0 +1,99 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Ryan Roden-Corrent (rcorre)
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see .
+
+"""Tests for 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
+
+
+@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_cat():
+ """Test completion_item_del with no selected category."""
+ model = completionmodel.CompletionModel()
+ with pytest.raises(qtutils.QtValueError):
+ model.delete_cur_item(QModelIndex())
diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py
index 9a8de3cad..207e557a8 100644
--- a/tests/unit/completion/test_completionwidget.py
+++ b/tests/unit/completion/test_completionwidget.py
@@ -19,13 +19,14 @@
"""Tests for the CompletionView Object."""
-import unittest.mock
+from unittest import mock
import pytest
-from PyQt5.QtGui import QStandardItem, QColor
+from PyQt5.QtGui import QColor
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
@@ -71,23 +72,28 @@ 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')
+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):
"""Ensure completion is resized only if shrink is True."""
with qtbot.assertNotEmitted(completionview.update_geometry):
@@ -148,15 +154,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):
@@ -176,10 +178,8 @@ 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)
@@ -200,16 +200,13 @@ def test_completion_show(show, rows, quick_complete, completionview,
config_stub.data['completion']['show'] = show
config_stub.data['completion']['quick-complete'] = 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
@@ -218,3 +215,27 @@ 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
diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py
new file mode 100644
index 000000000..0b5fcb915
--- /dev/null
+++ b/tests/unit/completion/test_histcategory.py
@@ -0,0 +1,150 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2016-2017 Ryan Roden-Corrent (rcorre)
+#
+# 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 .
+
+"""Test the web history completion category."""
+
+import unittest.mock
+import datetime
+
+import pytest
+
+from qutebrowser.misc import sql
+from qutebrowser.completion.models import histcategory
+from qutebrowser.commands import cmdexc
+
+
+@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, [
+ ('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'),
+ ])
+])
+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_delete_cur_item(hist):
+ hist.insert({'url': 'foo', 'title': 'Foo'})
+ hist.insert({'url': 'bar', 'title': 'Bar'})
+ func = unittest.mock.Mock(spec=[])
+ cat = histcategory.HistoryCategory(delete_func=func)
+ cat.set_pattern('')
+ cat.delete_cur_item(cat.index(0, 0))
+ func.assert_called_with(['foo', 'Foo', ''])
+
+
+def test_delete_cur_item_no_func(hist):
+ hist.insert({'url': 'foo', 'title': 1})
+ hist.insert({'url': 'bar', 'title': 2})
+ cat = histcategory.HistoryCategory()
+ cat.set_pattern('')
+ with pytest.raises(cmdexc.CommandError, match='Cannot delete this item'):
+ cat.delete_cur_item(cat.index(0, 0))
diff --git a/tests/unit/completion/test_listcategory.py b/tests/unit/completion/test_listcategory.py
new file mode 100644
index 000000000..3b1c1478a
--- /dev/null
+++ b/tests/unit/completion/test_listcategory.py
@@ -0,0 +1,73 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2017 Ryan Roden-Corrent (rcorre)
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see .
+
+"""Tests for CompletionFilterModel."""
+
+from unittest import mock
+
+import pytest
+
+from qutebrowser.completion.models import listcategory
+from qutebrowser.commands import cmdexc
+
+
+@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)
+
+
+def test_delete_cur_item(model_validator):
+ func = mock.Mock(spec=[])
+ cat = listcategory.ListCategory('Foo', [('a', 'b'), ('c', 'd')],
+ delete_func=func)
+ model_validator.set_model(cat)
+ idx = cat.index(0, 0)
+ cat.delete_cur_item(idx)
+ func.assert_called_once_with(['a', 'b'])
+ model_validator.validate([('c', 'd')])
+
+
+def test_delete_cur_item_no_func(model_validator):
+ cat = listcategory.ListCategory('Foo', [('a', 'b'), ('c', 'd')])
+ model_validator.set_model(cat)
+ idx = cat.index(0, 0)
+ with pytest.raises(cmdexc.CommandError, match="Cannot delete this item."):
+ cat.delete_cur_item(idx)
+ model_validator.validate([('a', 'b'), ('c', 'd')])
diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py
index ff00a11a9..5b632eb3a 100644
--- a/tests/unit/completion/test_models.py
+++ b/tests/unit/completion/test_models.py
@@ -24,12 +24,11 @@ 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
+from qutebrowser.completion.models import miscmodels, urlmodel, configmodel
from qutebrowser.config import sections, value
+from qutebrowser.utils import objreg
+from qutebrowser.browser import history
def _check_completions(model, expected):
@@ -43,19 +42,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):
@@ -113,22 +114,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."""
@@ -152,20 +137,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,
@@ -184,16 +184,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'),
]
})
@@ -212,135 +213,200 @@ 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),
]
})
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):
+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_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)]
})
@@ -354,7 +420,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)
@@ -370,7 +437,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 = [
@@ -381,13 +448,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')]
@@ -398,15 +471,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),
]
})
@@ -418,7 +492,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)
@@ -431,6 +506,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'
@@ -440,7 +521,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)
@@ -454,22 +536,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.
@@ -486,55 +576,55 @@ def test_bind_completion(qtmodeltester, monkeypatch, stubs, config_stub,
key_config_stub.set_bindings_for('normal', {'s': 'stop',
'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, {
- "Commands": [
+ "Current": [
('stop', 'stop qutebrowser', 's'),
+ ],
+ "Commands": [
('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', 's'),
]
})
-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}
+ 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]
+ }
- entries = [history.Entry(
- atime=i,
- url=QUrl('http://example.com/{}'.format(i)),
- title='title{}'.format(i))
- for i in range(100000)]
+ web_history.completion.insert_batch(entries)
- web_history_stub.history_dict = collections.OrderedDict(
- ((e.url_str(), e) for e in entries))
+ quickmark_manager_stub.marks = collections.OrderedDict([
+ ('title{}'.format(i), 'example.com/{}'.format(i))
+ for i in range(1000)])
- quickmark_manager_stub.marks = collections.OrderedDict(
- (e.title, e.url_str())
- for e in entries[0: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)
-#
-# This file is part of qutebrowser.
-#
-# qutebrowser is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# qutebrowser is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with qutebrowser. If not, see .
-
-"""Tests for 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/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_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)
+#
+# 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 .
+
+"""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_utils.py b/tests/unit/utils/test_utils.py
index 4b03dcf8c..47595ebf5 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 4c9baf6eb..339e70987 100644
--- a/tests/unit/utils/test_version.py
+++ b/tests/unit/utils/test_version.py
@@ -849,6 +849,7 @@ def test_version_output(params, stubs, monkeypatch):
if params.style else
stubs.FakeQApplication(instance=None)),
'QLibraryInfo.location': (lambda _loc: 'QT PATH'),
+ 'sql.version': lambda: 'SQLITE VERSION',
}
substitutions = {
@@ -909,6 +910,7 @@ def test_version_output(params, stubs, monkeypatch):
MODULE VERSION 1
MODULE VERSION 2
pdf.js: PDFJS VERSION
+ sqlite: SQLITE VERSION
QtNetwork SSL: {ssl}
{style}
Platform: PLATFORM, ARCHITECTURE{linuxdist}