Merge pull request #2295 from rcorre/really_complete

Completion refactor V3
This commit is contained in:
Florian Bruhin 2017-07-21 15:05:43 +02:00 committed by GitHub
commit fba25338be
56 changed files with 2600 additions and 2749 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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:

View File

@ -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(

View File

@ -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):

View File

@ -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.

View File

@ -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'<span class="highlight">\g<0></span>'
text = re.sub(re.escape(pattern).replace(r'\ ', r'|'),

View File

@ -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)

View File

@ -1,130 +0,0 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2014-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""The base completion model for completion in the command line.
Module attributes:
Role: An enum of user defined model roles.
"""
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QStandardItemModel, QStandardItem
from qutebrowser.utils import usertypes
Role = usertypes.enum('Role', ['sort', 'userdata'], start=Qt.UserRole,
is_int=True)
class BaseCompletionModel(QStandardItemModel):
"""A simple QStandardItemModel adopted for completions.
Used for showing completions later in the CompletionView. Supports setting
marks and adding new categories/items easily.
Class Attributes:
COLUMN_WIDTHS: The width percentages of the columns used in the
completion view.
DUMB_SORT: the dumb sorting used by the model
"""
COLUMN_WIDTHS = (30, 70, 0)
DUMB_SORT = None
def __init__(self, parent=None):
super().__init__(parent)
self.setColumnCount(3)
self.columns_to_filter = [0]
def new_category(self, name, sort=None):
"""Add a new category to the model.
Args:
name: The name of the category to add.
sort: The value to use for the sort role.
Return:
The created QStandardItem.
"""
cat = QStandardItem(name)
if sort is not None:
cat.setData(sort, Role.sort)
self.appendRow(cat)
return cat
def new_item(self, cat, name, desc='', misc=None, sort=None,
userdata=None):
"""Add a new item to a category.
Args:
cat: The parent category.
name: The name of the item.
desc: The description of the item.
misc: Misc text to display.
sort: Data for the sort role (int).
userdata: User data to be added for the first column.
Return:
A (nameitem, descitem, miscitem) tuple.
"""
assert not isinstance(name, int)
assert not isinstance(desc, int)
assert not isinstance(misc, int)
nameitem = QStandardItem(name)
descitem = QStandardItem(desc)
if misc is None:
miscitem = QStandardItem()
else:
miscitem = QStandardItem(misc)
cat.appendRow([nameitem, descitem, miscitem])
if sort is not None:
nameitem.setData(sort, Role.sort)
if userdata is not None:
nameitem.setData(userdata, Role.userdata)
return nameitem, descitem, miscitem
def delete_cur_item(self, completion):
"""Delete the selected item."""
raise NotImplementedError
def flags(self, index):
"""Return the item flags for index.
Override QAbstractItemModel::flags.
Args:
index: The QModelIndex to get item flags for.
Return:
The item flags, or Qt.NoItemFlags on error.
"""
if not index.isValid():
return
if index.parent().isValid():
# item
return (Qt.ItemIsEnabled | Qt.ItemIsSelectable |
Qt.ItemNeverHasChildren)
else:
# category
return Qt.NoItemFlags

View File

@ -0,0 +1,224 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""A model that proxies access to one or more completion categories."""
from PyQt5.QtCore import Qt, QModelIndex, QAbstractItemModel
from qutebrowser.utils import log, qtutils
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()

View File

@ -17,142 +17,80 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""CompletionModels for the config."""
"""Functions that return config-related completion models."""
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

View File

@ -0,0 +1,100 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""A completion category that queries the SQL History store."""
import re
from PyQt5.QtSql import QSqlQueryModel
from qutebrowser.misc import sql
from qutebrowser.utils import debug
from qutebrowser.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)

View File

@ -1,196 +0,0 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2015-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Global instances of the completion models.
Module attributes:
_instances: A dict of available completions.
INITIALIZERS: A {usertypes.Completion: callable} dict of functions to
initialize completions.
"""
import functools
from qutebrowser.completion.models import miscmodels, urlmodel, 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)

View File

@ -0,0 +1,102 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Completion category that uses a list of tuples as a data source."""
import re
from PyQt5.QtCore import Qt, QSortFilterProxyModel, 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())

View File

@ -17,252 +17,125 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Misc. CompletionModels."""
"""Functions that return miscellaneous completion models."""
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=''):

View File

@ -1,191 +0,0 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2014-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""A filtering/sorting base model for completions.
Contains:
CompletionFilterModel -- A QSortFilterProxyModel subclass for completions.
"""
import re
from PyQt5.QtCore import QSortFilterProxyModel, QModelIndex, Qt
from qutebrowser.utils import log, qtutils, debug
from qutebrowser.completion.models import base as completion
class CompletionFilterModel(QSortFilterProxyModel):
"""Subclass of QSortFilterProxyModel with custom sorting/filtering.
Attributes:
pattern: The pattern to filter with.
srcmodel: The current source model.
Kept as attribute because calling `sourceModel` takes quite
a long time for some reason.
_sort_order: The order to use for sorting if using dumb_sort.
"""
def __init__(self, source, parent=None):
super().__init__(parent)
super().setSourceModel(source)
self.srcmodel = source
self.pattern = ''
self.pattern_re = None
dumb_sort = self.srcmodel.DUMB_SORT
if dumb_sort is None:
# pylint: disable=invalid-name
self.lessThan = self.intelligentLessThan
self._sort_order = Qt.AscendingOrder
else:
self.setSortRole(completion.Role.sort)
self._sort_order = dumb_sort
def set_pattern(self, val):
"""Setter for pattern.
Invalidates the filter and re-sorts the model.
Args:
val: The value to set.
"""
with debug.log_time(log.completion, 'Setting filter pattern'):
self.pattern = val
val = re.sub(r' +', r' ', val) # See #1919
val = re.escape(val)
val = val.replace(r'\ ', '.*')
self.pattern_re = re.compile(val, re.IGNORECASE)
self.invalidate()
sortcol = 0
self.sort(sortcol)
def count(self):
"""Get the count of non-toplevel items currently visible.
Note this only iterates one level deep, as we only need root items
(categories) and children (items) in our model.
"""
count = 0
for i in range(self.rowCount()):
cat = self.index(i, 0)
qtutils.ensure_valid(cat)
count += self.rowCount(cat)
return count
def first_item(self):
"""Return the first item in the model."""
for i in range(self.rowCount()):
cat = self.index(i, 0)
qtutils.ensure_valid(cat)
if cat.model().hasChildren(cat):
index = self.index(0, 0, cat)
qtutils.ensure_valid(index)
return index
return QModelIndex()
def last_item(self):
"""Return the last item in the model."""
for i in range(self.rowCount() - 1, -1, -1):
cat = self.index(i, 0)
qtutils.ensure_valid(cat)
if cat.model().hasChildren(cat):
index = self.index(self.rowCount(cat) - 1, 0, cat)
qtutils.ensure_valid(index)
return index
return QModelIndex()
def setSourceModel(self, model):
"""Override QSortFilterProxyModel's setSourceModel to clear pattern."""
log.completion.debug("Setting source model: {}".format(model))
self.set_pattern('')
super().setSourceModel(model)
self.srcmodel = model
def filterAcceptsRow(self, row, parent):
"""Custom filter implementation.
Override QSortFilterProxyModel::filterAcceptsRow.
Args:
row: The row of the item.
parent: The parent item QModelIndex.
Return:
True if self.pattern is contained in item, or if it's a root item
(category). False in all other cases
"""
if parent == QModelIndex() or not self.pattern:
return True
for col in self.srcmodel.columns_to_filter:
idx = self.srcmodel.index(row, col, parent)
if not idx.isValid(): # pragma: no cover
# this is a sanity check not hit by any test case
continue
data = self.srcmodel.data(idx)
if not data:
continue
elif self.pattern_re.search(data):
return True
return False
def intelligentLessThan(self, lindex, rindex):
"""Custom sorting implementation.
Prefers all items which start with self.pattern. Other than that, uses
normal Python string sorting.
Args:
lindex: The QModelIndex of the left item (*left* < right)
rindex: The QModelIndex of the right item (left < *right*)
Return:
True if left < right, else False
"""
qtutils.ensure_valid(lindex)
qtutils.ensure_valid(rindex)
left_sort = self.srcmodel.data(lindex, role=completion.Role.sort)
right_sort = self.srcmodel.data(rindex, role=completion.Role.sort)
if left_sort is not None and right_sort is not None:
return left_sort < right_sort
left = self.srcmodel.data(lindex)
right = self.srcmodel.data(rindex)
leftstart = left.startswith(self.pattern)
rightstart = right.startswith(self.pattern)
if leftstart and rightstart:
return left < right
elif leftstart:
return True
elif rightstart:
return False
else:
return left < right
def sort(self, column, order=None):
"""Extend sort to respect self._sort_order if no order was given."""
if order is None:
order = self._sort_order
super().sort(column, order)

View File

@ -17,176 +17,54 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""CompletionModels for URLs."""
"""Function to return the url completion model for the `open` command."""
import datetime
from 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

View File

@ -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):

View File

@ -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"),

View File

@ -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.

View File

@ -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);
}
}

View File

@ -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",

View File

@ -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

View File

@ -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.

256
qutebrowser/misc/sql.py Normal file
View File

@ -0,0 +1,256 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Provides access to an in-memory sqlite database."""
import collections
from PyQt5.QtCore import QObject, pyqtSignal
from PyQt5.QtSql import QSqlDatabase, QSqlQuery
from qutebrowser.utils import log
class SqlException(Exception):
"""Raised on an error interacting with the SQL database."""
pass
def init(db_path):
"""Initialize the SQL database connection."""
database = QSqlDatabase.addDatabase('QSQLITE')
if not database.isValid():
raise SqlException('Failed to add database. '
'Are sqlite and Qt sqlite support installed?')
database.setDatabaseName(db_path)
if not database.open():
raise SqlException("Failed to open sqlite database at {}: {}"
.format(db_path, database.lastError().text()))
def close():
"""Close the SQL connection."""
QSqlDatabase.removeDatabase(QSqlDatabase.database().connectionName())
def version():
"""Return the sqlite version string."""
try:
if not QSqlDatabase.database().isOpen():
init(':memory:')
ver = Query("select sqlite_version()").run().value()
close()
return ver
return Query("select sqlite_version()").run().value()
except SqlException as e:
return 'UNAVAILABLE ({})'.format(e)
class Query(QSqlQuery):
"""A prepared SQL Query."""
def __init__(self, querystr, forward_only=True):
"""Prepare a new sql query.
Args:
querystr: String to prepare query from.
forward_only: Optimization for queries that will only step forward.
Must be false for completion queries.
"""
super().__init__(QSqlDatabase.database())
log.sql.debug('Preparing SQL query: "{}"'.format(querystr))
if not self.prepare(querystr):
raise SqlException('Failed to prepare query "{}": "{}"'.format(
querystr, self.lastError().text()))
self.setForwardOnly(forward_only)
def __iter__(self):
if not self.isActive():
raise SqlException("Cannot iterate inactive query")
rec = self.record()
fields = [rec.fieldName(i) for i in range(rec.count())]
rowtype = collections.namedtuple('ResultRow', fields)
while self.next():
rec = self.record()
yield rowtype(*[rec.value(i) for i in range(rec.count())])
def run(self, **values):
"""Execute the prepared query."""
log.sql.debug('Running SQL query: "{}"'.format(self.lastQuery()))
for key, val in values.items():
self.bindValue(':{}'.format(key), val)
log.sql.debug('query bindings: {}'.format(self.boundValues()))
if not self.exec_():
raise SqlException('Failed to exec query "{}": "{}"'.format(
self.lastQuery(), self.lastError().text()))
return self
def value(self):
"""Return the result of a single-value query (e.g. an EXISTS)."""
if not self.next():
raise SqlException("No result for single-result query")
return self.record().value(0)
class SqlTable(QObject):
"""Interface to a sql table.
Attributes:
_name: Name of the SQL table this wraps.
Signals:
changed: Emitted when the table is modified.
"""
changed = pyqtSignal()
def __init__(self, name, fields, constraints=None, parent=None):
"""Create a new table in the sql database.
Raises SqlException if the table already exists.
Args:
name: Name of the table.
fields: A list of field names.
constraints: A dict mapping field names to constraint strings.
"""
super().__init__(parent)
self._name = name
constraints = constraints or {}
column_defs = ['{} {}'.format(field, constraints.get(field, ''))
for field in fields]
q = Query("CREATE TABLE IF NOT EXISTS {name} ({column_defs})"
.format(name=name, column_defs=', '.join(column_defs)))
q.run()
def create_index(self, name, field):
"""Create an index over this table.
Args:
name: Name of the index, should be unique.
field: Name of the field to index.
"""
q = Query("CREATE INDEX IF NOT EXISTS {name} ON {table} ({field})"
.format(name=name, table=self._name, field=field))
q.run()
def __iter__(self):
"""Iterate rows in the table."""
q = Query("SELECT * FROM {table}".format(table=self._name))
q.run()
return iter(q)
def contains_query(self, field):
"""Return a prepared query that checks for the existence of an item.
Args:
field: Field to match.
"""
return Query(
"SELECT EXISTS(SELECT * FROM {table} WHERE {field} = :val)"
.format(table=self._name, field=field))
def __len__(self):
"""Return the count of rows in the table."""
q = Query("SELECT count(*) FROM {table}".format(table=self._name))
q.run()
return q.value()
def delete(self, field, value):
"""Remove all rows for which `field` equals `value`.
Args:
field: Field to use as the key.
value: Key value to delete.
Return:
The number of rows deleted.
"""
q = Query("DELETE FROM {table} where {field} = :val"
.format(table=self._name, field=field))
q.run(val=value)
if not q.numRowsAffected():
raise KeyError('No row with {} = "{}"'.format(field, value))
self.changed.emit()
def _insert_query(self, values, replace):
params = ', '.join(':{}'.format(key) for key in values)
verb = "REPLACE" if replace else "INSERT"
return Query("{verb} INTO {table} ({columns}) values({params})".format(
verb=verb, table=self._name, columns=', '.join(values),
params=params))
def insert(self, values, replace=False):
"""Append a row to the table.
Args:
values: A dict with a value to insert for each field name.
replace: If set, replace existing values.
"""
q = self._insert_query(values, replace)
q.run(**values)
self.changed.emit()
def insert_batch(self, values, replace=False):
"""Performantly append multiple rows to the table.
Args:
values: A dict with a list of values to insert for each field name.
replace: If true, overwrite rows with a primary key match.
"""
q = self._insert_query(values, replace)
for key, val in values.items():
q.bindValue(':{}'.format(key), val)
db = QSqlDatabase.database()
db.transaction()
if not q.execBatch():
raise SqlException('Failed to exec query "{}": "{}"'.format(
q.lastQuery(), q.lastError().text()))
db.commit()
self.changed.emit()
def delete_all(self):
"""Remove all rows from the table."""
Query("DELETE FROM {table}".format(table=self._name)).run()
self.changed.emit()
def select(self, sort_by, sort_order, limit=-1):
"""Prepare, run, and return a select statement on this table.
Args:
sort_by: name of column to sort by.
sort_order: 'asc' or 'desc'.
limit: max number of rows in result, defaults to -1 (unlimited).
Return: A prepared and executed select query.
"""
q = Query("SELECT * FROM {table} ORDER BY {sort_by} {sort_order} "
"LIMIT :limit"
.format(table=self._name, sort_by=sort_by,
sort_order=sort_order))
q.run(limit=limit)
return q

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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():

View File

@ -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'),
]

View File

@ -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'),
]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -17,36 +17,29 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
import os.path
import logging
import re
import pytest_bdd as bdd
bdd.scenarios('history.feature')
@bdd.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, '')

View File

@ -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)

View File

@ -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."""

View File

@ -0,0 +1,349 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2016-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Tests for the global page history."""
import logging
import pytest
from PyQt5.QtCore import QUrl
from qutebrowser.browser import history
from qutebrowser.utils import objreg, urlutils, usertypes
from qutebrowser.commands import cmdexc
@pytest.fixture(autouse=True)
def prerequisites(config_stub, fake_save_manager, init_sql):
"""Make sure everything is ready to initialize a WebHistory."""
config_stub.data = {'general': {'private-browsing': False}}
@pytest.fixture()
def hist(tmpdir):
return history.WebHistory()
@pytest.fixture()
def mock_time(mocker):
m = mocker.patch('qutebrowser.browser.history.time')
m.time.return_value = 12345
return 12345
def test_iter(hist):
urlstr = 'http://www.example.com/'
url = QUrl(urlstr)
hist.add_url(url, atime=12345)
assert list(hist) == [(urlstr, '', 12345, False)]
def test_len(hist):
assert len(hist) == 0
url = QUrl('http://www.example.com/')
hist.add_url(url)
assert len(hist) == 1
def test_contains(hist):
hist.add_url(QUrl('http://www.example.com/'), title='Title', atime=12345)
assert 'http://www.example.com/' in hist
assert 'www.example.com' not in hist
assert 'Title' not in hist
assert 12345 not in hist
def test_get_recent(hist):
hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890)
hist.add_url(QUrl('http://example.com/'), atime=12345)
assert list(hist.get_recent()) == [
('http://www.qutebrowser.org/', '', 67890, False),
('http://example.com/', '', 12345, False),
]
def test_entries_between(hist):
hist.add_url(QUrl('http://www.example.com/1'), atime=12345)
hist.add_url(QUrl('http://www.example.com/2'), atime=12346)
hist.add_url(QUrl('http://www.example.com/3'), atime=12347)
hist.add_url(QUrl('http://www.example.com/4'), atime=12348)
hist.add_url(QUrl('http://www.example.com/5'), atime=12348)
hist.add_url(QUrl('http://www.example.com/6'), atime=12349)
hist.add_url(QUrl('http://www.example.com/7'), atime=12350)
times = [x.atime for x in hist.entries_between(12346, 12349)]
assert times == [12349, 12348, 12348, 12347]
def test_entries_before(hist):
hist.add_url(QUrl('http://www.example.com/1'), atime=12346)
hist.add_url(QUrl('http://www.example.com/2'), atime=12346)
hist.add_url(QUrl('http://www.example.com/3'), atime=12347)
hist.add_url(QUrl('http://www.example.com/4'), atime=12348)
hist.add_url(QUrl('http://www.example.com/5'), atime=12348)
hist.add_url(QUrl('http://www.example.com/6'), atime=12348)
hist.add_url(QUrl('http://www.example.com/7'), atime=12349)
hist.add_url(QUrl('http://www.example.com/8'), atime=12349)
times = [x.atime for x in hist.entries_before(12348, limit=3, offset=2)]
assert times == [12348, 12347, 12346]
def test_clear(qtbot, tmpdir, hist, mocker):
hist.add_url(QUrl('http://example.com/'))
hist.add_url(QUrl('http://www.qutebrowser.org/'))
m = mocker.patch('qutebrowser.browser.history.message.confirm_async',
new=mocker.Mock, spec=[])
hist.clear()
assert m.called
def test_clear_force(qtbot, tmpdir, hist):
hist.add_url(QUrl('http://example.com/'))
hist.add_url(QUrl('http://www.qutebrowser.org/'))
hist.clear(force=True)
assert not len(hist)
assert not len(hist.completion)
def test_delete_url(hist):
hist.add_url(QUrl('http://example.com/'), atime=0)
hist.add_url(QUrl('http://example.com/1'), atime=0)
hist.add_url(QUrl('http://example.com/2'), atime=0)
before = set(hist)
completion_before = set(hist.completion)
hist.delete_url(QUrl('http://example.com/1'))
diff = before.difference(set(hist))
assert diff == {('http://example.com/1', '', 0, False)}
completion_diff = completion_before.difference(set(hist.completion))
assert completion_diff == {('http://example.com/1', '', 0)}
@pytest.mark.parametrize('url, atime, title, redirect, expected_url', [
('http://www.example.com', 12346, 'the title', False,
'http://www.example.com'),
('http://www.example.com', 12346, 'the title', True,
'http://www.example.com'),
('http://www.example.com/spa ce', 12346, 'the title', False,
'http://www.example.com/spa%20ce'),
('https://user:pass@example.com', 12346, 'the title', False,
'https://user@example.com'),
])
def test_add_url(qtbot, hist, url, atime, title, redirect, expected_url):
hist.add_url(QUrl(url), atime=atime, title=title, redirect=redirect)
assert list(hist) == [(expected_url, title, atime, redirect)]
if redirect:
assert not len(hist.completion)
else:
assert list(hist.completion) == [(expected_url, title, atime)]
def test_add_url_invalid(qtbot, hist, caplog):
with caplog.at_level(logging.WARNING):
hist.add_url(QUrl())
assert not list(hist)
assert not list(hist.completion)
@pytest.mark.parametrize('level, url, req_url, expected', [
(logging.DEBUG, 'a.com', 'a.com', [('a.com', 'title', 12345, False)]),
(logging.DEBUG, 'a.com', 'b.com', [('a.com', 'title', 12345, False),
('b.com', 'title', 12345, True)]),
(logging.WARNING, 'a.com', '', [('a.com', 'title', 12345, False)]),
(logging.WARNING, '', '', []),
(logging.WARNING, 'data:foo', '', []),
(logging.WARNING, 'a.com', 'data:foo', []),
])
def test_add_from_tab(hist, level, url, req_url, expected, mock_time, caplog):
with caplog.at_level(level):
hist.add_from_tab(QUrl(url), QUrl(req_url), 'title')
assert set(hist) == set(expected)
@pytest.fixture
def hist_interface(hist):
# pylint: disable=invalid-name
QtWebKit = pytest.importorskip('PyQt5.QtWebKit')
from qutebrowser.browser.webkit import webkithistory
QWebHistoryInterface = QtWebKit.QWebHistoryInterface
# pylint: enable=invalid-name
hist.add_url(url=QUrl('http://www.example.com/'), title='example')
interface = webkithistory.WebHistoryInterface(hist)
QWebHistoryInterface.setDefaultInterface(interface)
yield
QWebHistoryInterface.setDefaultInterface(None)
def test_history_interface(qtbot, webview, hist_interface):
html = b"<a href='about:blank'>foo</a>"
url = urlutils.data_url('text/html', html)
with qtbot.waitSignal(webview.loadFinished):
webview.load(url)
@pytest.fixture
def cleanup_init():
# prevent test_init from leaking state
yield
hist = objreg.get('web-history', None)
if hist is not None:
hist.setParent(None)
objreg.delete('web-history')
try:
from PyQt5.QtWebKit import QWebHistoryInterface
QWebHistoryInterface.setDefaultInterface(None)
except ImportError:
pass
@pytest.mark.parametrize('backend', [usertypes.Backend.QtWebEngine,
usertypes.Backend.QtWebKit])
def test_init(backend, qapp, tmpdir, monkeypatch, cleanup_init):
if backend == usertypes.Backend.QtWebKit:
pytest.importorskip('PyQt5.QtWebKitWidgets')
else:
assert backend == usertypes.Backend.QtWebEngine
monkeypatch.setattr(history.objects, 'backend', backend)
history.init(qapp)
hist = objreg.get('web-history')
assert hist.parent() is qapp
try:
from PyQt5.QtWebKit import QWebHistoryInterface
except ImportError:
QWebHistoryInterface = None
if backend == usertypes.Backend.QtWebKit:
default_interface = QWebHistoryInterface.defaultInterface()
assert default_interface._history is hist
else:
assert backend == usertypes.Backend.QtWebEngine
if QWebHistoryInterface is None:
default_interface = None
else:
default_interface = QWebHistoryInterface.defaultInterface()
# For this to work, nothing can ever have called setDefaultInterface
# before (so we need to test webengine before webkit)
assert default_interface is None
def test_import_txt(hist, data_tmpdir, monkeypatch, stubs):
monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer)
histfile = data_tmpdir / 'history'
# empty line is deliberate, to test skipping empty lines
histfile.write('''12345 http://example.com/ title
12346 http://qutebrowser.org/
67890 http://example.com/path
68891-r http://example.com/path/other ''')
hist.import_txt()
assert list(hist) == [
('http://example.com/', 'title', 12345, False),
('http://qutebrowser.org/', '', 12346, False),
('http://example.com/path', '', 67890, False),
('http://example.com/path/other', '', 68891, True)
]
assert not histfile.exists()
assert (data_tmpdir / 'history.bak').exists()
@pytest.mark.parametrize('line', [
'',
'#12345 http://example.com/commented',
# https://bugreports.qt.io/browse/QTBUG-60364
'12345 http://.com/',
'12345 https://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))

View File

@ -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

View File

@ -1,383 +0,0 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2016-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Tests for the global page history."""
import logging
import pytest
import hypothesis
from hypothesis import strategies
from PyQt5.QtCore import QUrl
from qutebrowser.browser import history
from qutebrowser.utils import objreg, urlutils, usertypes
class FakeWebHistory:
"""A fake WebHistory object."""
def __init__(self, history_dict):
self.history_dict = history_dict
@pytest.fixture()
def hist(tmpdir, fake_save_manager):
return history.WebHistory(hist_dir=str(tmpdir), hist_name='history')
def test_async_read_twice(monkeypatch, qtbot, tmpdir, caplog,
fake_save_manager):
(tmpdir / 'filled-history').write('\n'.join([
'12345 http://example.com/ title',
'67890 http://example.com/',
'12345 http://qutebrowser.org/ blah',
]))
hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
next(hist.async_read())
with pytest.raises(StopIteration):
next(hist.async_read())
expected = "Ignoring async_read() because reading is started."
assert len(caplog.records) == 1
assert caplog.records[0].msg == expected
@pytest.mark.parametrize('redirect', [True, False])
def test_adding_item_during_async_read(qtbot, hist, redirect):
"""Check what happens when adding URL while reading the history."""
url = QUrl('http://www.example.com/')
with qtbot.assertNotEmitted(hist.add_completion_item), \
qtbot.assertNotEmitted(hist.item_added):
hist.add_url(url, redirect=redirect, atime=12345)
if redirect:
with qtbot.assertNotEmitted(hist.add_completion_item):
with qtbot.waitSignal(hist.async_read_done):
list(hist.async_read())
else:
with qtbot.waitSignals([hist.add_completion_item,
hist.async_read_done], order='strict'):
list(hist.async_read())
assert not hist._temp_history
expected = history.Entry(url=url, atime=12345, redirect=redirect, title="")
assert list(hist.history_dict.values()) == [expected]
def test_iter(hist):
list(hist.async_read())
url = QUrl('http://www.example.com/')
hist.add_url(url, atime=12345)
entry = history.Entry(url=url, atime=12345, redirect=False, title="")
assert list(hist) == [entry]
def test_len(hist):
assert len(hist) == 0
list(hist.async_read())
url = QUrl('http://www.example.com/')
hist.add_url(url)
assert len(hist) == 1
@pytest.mark.parametrize('line', [
'12345 http://example.com/ title', # with title
'67890 http://example.com/', # no title
'12345 http://qutebrowser.org/ ', # trailing space
' ',
'',
])
def test_read(hist, tmpdir, line):
(tmpdir / 'filled-history').write(line + '\n')
hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
list(hist.async_read())
def test_updated_entries(hist, tmpdir):
(tmpdir / 'filled-history').write('12345 http://example.com/\n'
'67890 http://example.com/\n')
hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
list(hist.async_read())
assert hist.history_dict['http://example.com/'].atime == 67890
hist.add_url(QUrl('http://example.com/'), atime=99999)
assert hist.history_dict['http://example.com/'].atime == 99999
def test_invalid_read(hist, tmpdir, caplog):
(tmpdir / 'filled-history').write('foobar\n12345 http://example.com/')
hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
with caplog.at_level(logging.WARNING):
list(hist.async_read())
entries = list(hist.history_dict.values())
assert len(entries) == 1
assert len(caplog.records) == 1
msg = "Invalid history entry 'foobar': 2 or 3 fields expected!"
assert caplog.records[0].msg == msg
def test_get_recent(hist, tmpdir):
(tmpdir / 'filled-history').write('12345 http://example.com/')
hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
list(hist.async_read())
hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890)
lines = hist.get_recent()
expected = ['12345 http://example.com/',
'67890 http://www.qutebrowser.org/']
assert lines == expected
def test_save(hist, tmpdir):
hist_file = tmpdir / 'filled-history'
hist_file.write('12345 http://example.com/\n')
hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
list(hist.async_read())
hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890)
hist.save()
lines = hist_file.read().splitlines()
expected = ['12345 http://example.com/',
'67890 http://www.qutebrowser.org/']
assert lines == expected
hist.add_url(QUrl('http://www.the-compiler.org/'), atime=99999)
hist.save()
expected.append('99999 http://www.the-compiler.org/')
lines = hist_file.read().splitlines()
assert lines == expected
def test_clear(qtbot, hist, tmpdir):
hist_file = tmpdir / 'filled-history'
hist_file.write('12345 http://example.com/\n')
hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
list(hist.async_read())
hist.add_url(QUrl('http://www.qutebrowser.org/'))
with qtbot.waitSignal(hist.cleared):
hist._do_clear()
assert not hist_file.read()
assert not hist.history_dict
assert not hist._new_history
hist.add_url(QUrl('http://www.the-compiler.org/'), atime=67890)
hist.save()
lines = hist_file.read().splitlines()
assert lines == ['67890 http://www.the-compiler.org/']
def test_add_item(qtbot, hist):
list(hist.async_read())
url = 'http://www.example.com/'
with qtbot.waitSignals([hist.add_completion_item, hist.item_added],
order='strict'):
hist.add_url(QUrl(url), atime=12345, title="the title")
entry = history.Entry(url=QUrl(url), redirect=False, atime=12345,
title="the title")
assert hist.history_dict[url] == entry
def test_add_item_redirect(qtbot, hist):
list(hist.async_read())
url = 'http://www.example.com/'
with qtbot.assertNotEmitted(hist.add_completion_item):
with qtbot.waitSignal(hist.item_added):
hist.add_url(QUrl(url), redirect=True, atime=12345)
entry = history.Entry(url=QUrl(url), redirect=True, atime=12345, title="")
assert hist.history_dict[url] == entry
def test_add_item_redirect_update(qtbot, tmpdir, fake_save_manager):
"""A redirect update added should override a non-redirect one."""
url = 'http://www.example.com/'
hist_file = tmpdir / 'filled-history'
hist_file.write('12345 {}\n'.format(url))
hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history')
list(hist.async_read())
with qtbot.assertNotEmitted(hist.add_completion_item):
with qtbot.waitSignal(hist.item_added):
hist.add_url(QUrl(url), redirect=True, atime=67890)
entry = history.Entry(url=QUrl(url), redirect=True, atime=67890, title="")
assert hist.history_dict[url] == entry
@pytest.mark.parametrize('line, expected', [
(
# old format without title
'12345 http://example.com/',
history.Entry(atime=12345, url=QUrl('http://example.com/'), title='',)
),
(
# trailing space without title
'12345 http://example.com/ ',
history.Entry(atime=12345, url=QUrl('http://example.com/'), title='',)
),
(
# new format with title
'12345 http://example.com/ this is a title',
history.Entry(atime=12345, url=QUrl('http://example.com/'),
title='this is a title')
),
(
# weird NUL bytes
'\x0012345 http://example.com/',
history.Entry(atime=12345, url=QUrl('http://example.com/'), title=''),
),
(
# redirect flag
'12345-r http://example.com/ this is a title',
history.Entry(atime=12345, url=QUrl('http://example.com/'),
title='this is a title', redirect=True)
),
])
def test_entry_parse_valid(line, expected):
entry = history.Entry.from_str(line)
assert entry == expected
@pytest.mark.parametrize('line', [
'12345', # one field
'12345 ::', # invalid URL
'xyz http://www.example.com/', # invalid timestamp
'12345-x http://www.example.com/', # invalid flags
'12345-r-r http://www.example.com/', # double flags
])
def test_entry_parse_invalid(line):
with pytest.raises(ValueError):
history.Entry.from_str(line)
@hypothesis.given(strategies.text())
def test_entry_parse_hypothesis(text):
"""Make sure parsing works or gives us ValueError."""
try:
history.Entry.from_str(text)
except ValueError:
pass
@pytest.mark.parametrize('entry, expected', [
# simple
(
history.Entry(12345, QUrl('http://example.com/'), "the title"),
"12345 http://example.com/ the title",
),
# timestamp as float
(
history.Entry(12345.678, QUrl('http://example.com/'), "the title"),
"12345 http://example.com/ the title",
),
# no title
(
history.Entry(12345.678, QUrl('http://example.com/'), ""),
"12345 http://example.com/",
),
# redirect flag
(
history.Entry(12345.678, QUrl('http://example.com/'), "",
redirect=True),
"12345-r http://example.com/",
),
])
def test_entry_str(entry, expected):
assert str(entry) == expected
@pytest.fixture
def hist_interface():
# pylint: disable=invalid-name
QtWebKit = pytest.importorskip('PyQt5.QtWebKit')
from qutebrowser.browser.webkit import webkithistory
QWebHistoryInterface = QtWebKit.QWebHistoryInterface
# pylint: enable=invalid-name
entry = history.Entry(atime=0, url=QUrl('http://www.example.com/'),
title='example')
history_dict = {'http://www.example.com/': entry}
fake_hist = FakeWebHistory(history_dict)
interface = webkithistory.WebHistoryInterface(fake_hist)
QWebHistoryInterface.setDefaultInterface(interface)
yield
QWebHistoryInterface.setDefaultInterface(None)
def test_history_interface(qtbot, webview, hist_interface):
html = b"<a href='about:blank'>foo</a>"
url = urlutils.data_url('text/html', html)
with qtbot.waitSignal(webview.loadFinished):
webview.load(url)
@pytest.mark.parametrize('backend', [usertypes.Backend.QtWebEngine,
usertypes.Backend.QtWebKit])
def test_init(backend, qapp, tmpdir, monkeypatch, fake_save_manager):
if backend == usertypes.Backend.QtWebKit:
pytest.importorskip('PyQt5.QtWebKitWidgets')
else:
assert backend == usertypes.Backend.QtWebEngine
monkeypatch.setattr(history.standarddir, 'data', lambda: str(tmpdir))
monkeypatch.setattr(history.objects, 'backend', backend)
history.init(qapp)
hist = objreg.get('web-history')
assert hist.parent() is qapp
try:
from PyQt5.QtWebKit import QWebHistoryInterface
except ImportError:
QWebHistoryInterface = None
if backend == usertypes.Backend.QtWebKit:
default_interface = QWebHistoryInterface.defaultInterface()
assert default_interface._history is hist
else:
assert backend == usertypes.Backend.QtWebEngine
if QWebHistoryInterface is None:
default_interface = None
else:
default_interface = QWebHistoryInterface.defaultInterface()
# For this to work, nothing can ever have called setDefaultInterface
# before (so we need to test webengine before webkit)
assert default_interface is None
assert fake_save_manager.add_saveable.called
objreg.delete('web-history')

View File

@ -1,50 +0,0 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2015-2017 Alexander Cogneau <alexander.cogneau@gmail.com>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Tests for qutebrowser.completion.models column widths."""
import pytest
from qutebrowser.completion.models.base import BaseCompletionModel
from qutebrowser.completion.models.configmodel import (
SettingOptionCompletionModel, SettingSectionCompletionModel,
SettingValueCompletionModel)
from qutebrowser.completion.models.miscmodels import (
CommandCompletionModel, HelpCompletionModel, QuickmarkCompletionModel,
BookmarkCompletionModel, SessionCompletionModel)
from qutebrowser.completion.models.urlmodel import UrlCompletionModel
CLASSES = [BaseCompletionModel, SettingOptionCompletionModel,
SettingOptionCompletionModel, SettingSectionCompletionModel,
SettingValueCompletionModel, CommandCompletionModel,
HelpCompletionModel, QuickmarkCompletionModel,
BookmarkCompletionModel, SessionCompletionModel, UrlCompletionModel]
@pytest.mark.parametrize("model", CLASSES)
def test_list_size(model):
"""Test if there are 3 items in the COLUMN_WIDTHS property."""
assert len(model.COLUMN_WIDTHS) == 3
@pytest.mark.parametrize("model", CLASSES)
def test_column_width_sum(model):
"""Test if the sum of the widths asserts to 100."""
assert sum(model.COLUMN_WIDTHS) == 100

View File

@ -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 <c-x> |', usertypes.Completion.command, ''),
(':bind <c-x> foo|', usertypes.Completion.command, 'foo'),
(':bind <c-x>| foo', None, '<c-x>'),
(':set| general ', usertypes.Completion.command, 'set'),
(':|set general ', usertypes.Completion.command, 'set'),
(':set gene|ral ignore-case', usertypes.Completion.section, 'general'),
(':|', usertypes.Completion.command, ''),
(': |', usertypes.Completion.command, ''),
('/|', None, ''),
(':open -t|', None, ''),
(':open --tab|', None, ''),
(':open -t |', usertypes.Completion.url, ''),
(':open --tab |', usertypes.Completion.url, ''),
(':open | -t', usertypes.Completion.url, ''),
(':tab-detach |', None, ''),
(':bind --mode=caret <c-x> |', usertypes.Completion.command, ''),
pytest.param(':bind --mode caret <c-x> |', usertypes.Completion.command,
'', marks=pytest.mark.xfail(reason='issue #74')),
(':set -t -p |', usertypes.Completion.section, ''),
(':open -- |', None, ''),
(':gibberish nonesense |', None, ''),
('/:help|', None, ''),
('::bind|', usertypes.Completion.command, ':bind'),
@pytest.mark.parametrize('txt, kind, pattern, pos_args', [
(':nope|', 'command', 'nope', []),
(':nope |', None, '', []),
(':set |', 'section', '', []),
(':set gen|', 'section', 'gen', []),
(':set general |', 'option', '', ['general']),
(':set what |', 'option', '', ['what']),
(':set general editor |', 'value', '', ['general', 'editor']),
(':set general editor gv|', 'value', 'gv', ['general', 'editor']),
(':set general editor "gvim -f"|', 'value', 'gvim -f',
['general', 'editor']),
(':set general editor "gvim |', 'value', 'gvim', ['general', 'editor']),
(':set general huh |', 'value', '', ['general', 'huh']),
(':help |', 'helptopic', '', []),
(':help |', 'helptopic', '', []),
(':open |', 'url', '', []),
(':bind |', None, '', []),
(':bind <c-x> |', 'command', '', ['<c-x>']),
(':bind <c-x> foo|', 'command', 'foo', ['<c-x>']),
(':bind <c-x>| foo', None, '<c-x>', []),
(':set| general ', 'command', 'set', []),
(':|set general ', 'command', 'set', []),
(':set gene|ral ignore-case', 'section', 'general', []),
(':|', 'command', '', []),
(': |', 'command', '', []),
('/|', None, '', []),
(':open -t|', None, '', []),
(':open --tab|', None, '', []),
(':open -t |', 'url', '', []),
(':open --tab |', 'url', '', []),
(':open | -t', 'url', '', []),
(':tab-detach |', None, '', []),
(':bind --mode=caret <c-x> |', 'command', '', ['<c-x>']),
pytest.param(':bind --mode caret <c-x> |', 'command', '', [],
marks=pytest.mark.xfail(reason='issue #74')),
(':set -t -p |', 'section', '', []),
(':open -- |', None, '', []),
(':gibberish nonesense |', None, '', []),
('/:help|', None, '', []),
('::bind|', 'command', ':bind', []),
])
def test_update_completion(txt, kind, pattern, status_command_stub,
def test_update_completion(txt, kind, pattern, pos_args, status_command_stub,
completer_obj, completion_widget_stub):
"""Test setting the completion widget's model based on command text."""
# this test uses | as a placeholder for the current cursor position
_set_cmd_prompt(status_command_stub, txt)
completer_obj.schedule_completion_update()
assert completion_widget_stub.set_model.call_count == 1
args = completion_widget_stub.set_model.call_args[0]
# the outer model is just for sorting; srcmodel is the completion model
if kind is None:
assert args[0] is None
assert completion_widget_stub.set_pattern.call_count == 0
else:
assert args[0].srcmodel.kind == kind
assert args[1] == pattern
assert completion_widget_stub.set_model.call_count == 1
model = completion_widget_stub.set_model.call_args[0][0]
assert model.kind == kind
assert model.pos_args == pos_args
completion_widget_stub.set_pattern.assert_called_once_with(pattern)
@pytest.mark.parametrize('before, newtxt, after', [

View File

@ -0,0 +1,99 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Tests for CompletionModel."""
from unittest import mock
import hypothesis
from hypothesis import strategies
import pytest
from PyQt5.QtCore import QModelIndex
from qutebrowser.completion.models import completionmodel, listcategory
from qutebrowser.utils import qtutils
@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())

View File

@ -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

View File

@ -0,0 +1,150 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Test the web history completion category."""
import 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))

View File

@ -0,0 +1,73 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Tests for CompletionFilterModel."""
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')])

View File

@ -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)

View File

@ -1,230 +0,0 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2015-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Tests for CompletionFilterModel."""
import pytest
from PyQt5.QtCore import Qt
from qutebrowser.completion.models import base, sortfilter
def _create_model(data):
"""Create a completion model populated with the given data.
data: A list of lists, where each sub-list represents a category, each
tuple in the sub-list represents an item, and each value in the
tuple represents the item data for that column
"""
model = base.BaseCompletionModel()
for catdata in data:
cat = model.new_category('')
for itemdata in catdata:
model.new_item(cat, *itemdata)
return model
def _extract_model_data(model):
"""Express a model's data as a list for easier comparison.
Return: A list of lists, where each sub-list represents a category, each
tuple in the sub-list represents an item, and each value in the
tuple represents the item data for that column
"""
data = []
for i in range(0, model.rowCount()):
cat_idx = model.index(i, 0)
row = []
for j in range(0, model.rowCount(cat_idx)):
row.append((model.data(cat_idx.child(j, 0)),
model.data(cat_idx.child(j, 1)),
model.data(cat_idx.child(j, 2))))
data.append(row)
return data
@pytest.mark.parametrize('pattern, data, expected', [
('foo', 'barfoobar', True),
('foo bar', 'barfoobar', True),
('foo bar', 'barfoobar', True),
('foo bar', 'barfoobazbar', True),
('foo bar', 'barfoobazbar', True),
('foo', 'barFOObar', True),
('Foo', 'barfOObar', True),
('ab', 'aonebtwo', False),
('33', 'l33t', True),
('x', 'blah', False),
('4', 'blah', False),
])
def test_filter_accepts_row(pattern, data, expected):
source_model = base.BaseCompletionModel()
cat = source_model.new_category('test')
source_model.new_item(cat, data)
filter_model = sortfilter.CompletionFilterModel(source_model)
filter_model.set_pattern(pattern)
assert filter_model.rowCount() == 1 # "test" category
idx = filter_model.index(0, 0)
assert idx.isValid()
row_count = filter_model.rowCount(idx)
assert row_count == (1 if expected else 0)
@pytest.mark.parametrize('tree, first, last', [
([[('Aa',)]], 'Aa', 'Aa'),
([[('Aa',)], [('Ba',)]], 'Aa', 'Ba'),
([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]],
'Aa', 'Ca'),
([[], [('Ba',)]], 'Ba', 'Ba'),
([[], [], [('Ca',)]], 'Ca', 'Ca'),
([[], [], [('Ca',), ('Cb',)]], 'Ca', 'Cb'),
([[('Aa',)], []], 'Aa', 'Aa'),
([[('Aa',)], []], 'Aa', 'Aa'),
([[('Aa',)], [], []], 'Aa', 'Aa'),
([[('Aa',)], [], [('Ca',)]], 'Aa', 'Ca'),
([[], []], None, None),
])
def test_first_last_item(tree, first, last):
"""Test that first() and last() return indexes to the first and last items.
Args:
tree: Each list represents a completion category, with each string
being an item under that category.
first: text of the first item
last: text of the last item
"""
model = _create_model(tree)
filter_model = sortfilter.CompletionFilterModel(model)
assert filter_model.data(filter_model.first_item()) == first
assert filter_model.data(filter_model.last_item()) == last
def test_set_source_model():
"""Ensure setSourceModel sets source_model and clears the pattern."""
model1 = base.BaseCompletionModel()
model2 = base.BaseCompletionModel()
filter_model = sortfilter.CompletionFilterModel(model1)
filter_model.set_pattern('foo')
# sourceModel() is cached as srcmodel, so make sure both match
assert filter_model.srcmodel is model1
assert filter_model.sourceModel() is model1
assert filter_model.pattern == 'foo'
filter_model.setSourceModel(model2)
assert filter_model.srcmodel is model2
assert filter_model.sourceModel() is model2
assert not filter_model.pattern
@pytest.mark.parametrize('tree, expected', [
([[('Aa',)]], 1),
([[('Aa',)], [('Ba',)]], 2),
([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]], 6),
([[], [('Ba',)]], 1),
([[], [], [('Ca',)]], 1),
([[], [], [('Ca',), ('Cb',)]], 2),
([[('Aa',)], []], 1),
([[('Aa',)], []], 1),
([[('Aa',)], [], []], 1),
([[('Aa',)], [], [('Ca',)]], 2),
])
def test_count(tree, expected):
model = _create_model(tree)
filter_model = sortfilter.CompletionFilterModel(model)
assert filter_model.count() == expected
@pytest.mark.parametrize('pattern, dumb_sort, filter_cols, before, after', [
('foo', None, [0],
[[('foo', '', ''), ('bar', '', '')]],
[[('foo', '', '')]]),
('foo', None, [0],
[[('foob', '', ''), ('fooc', '', ''), ('fooa', '', '')]],
[[('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')]]),
('foo', None, [0],
[[('foo', '', '')], [('bar', '', '')]],
[[('foo', '', '')], []]),
# prefer foobar as it starts with the pattern
('foo', None, [0],
[[('barfoo', '', ''), ('foobar', '', '')]],
[[('foobar', '', ''), ('barfoo', '', '')]]),
# however, don't rearrange categories
('foo', None, [0],
[[('barfoo', '', '')], [('foobar', '', '')]],
[[('barfoo', '', '')], [('foobar', '', '')]]),
('foo', None, [1],
[[('foo', 'bar', ''), ('bar', 'foo', '')]],
[[('bar', 'foo', '')]]),
('foo', None, [0, 1],
[[('foo', 'bar', ''), ('bar', 'foo', ''), ('bar', 'bar', '')]],
[[('foo', 'bar', ''), ('bar', 'foo', '')]]),
('foo', None, [0, 1, 2],
[[('foo', '', ''), ('bar', '')]],
[[('foo', '', '')]]),
# the fourth column is the sort role, which overrides data-based sorting
('', None, [0],
[[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]],
[[('one', '', ''), ('two', '', ''), ('three', '', '')]]),
('', Qt.AscendingOrder, [0],
[[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]],
[[('one', '', ''), ('two', '', ''), ('three', '', '')]]),
('', Qt.DescendingOrder, [0],
[[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]],
[[('three', '', ''), ('two', '', ''), ('one', '', '')]]),
])
def test_set_pattern(pattern, dumb_sort, filter_cols, before, after):
"""Validate the filtering and sorting results of set_pattern."""
model = _create_model(before)
model.DUMB_SORT = dumb_sort
model.columns_to_filter = filter_cols
filter_model = sortfilter.CompletionFilterModel(model)
filter_model.set_pattern(pattern)
actual = _extract_model_data(filter_model)
assert actual == after
def test_sort():
"""Ensure that a sort argument passed to sort overrides DUMB_SORT.
While test_set_pattern above covers most of the sorting logic, this
particular case is easier to test separately.
"""
model = _create_model([[('B', '', '', 1),
('C', '', '', 2),
('A', '', '', 0)]])
filter_model = sortfilter.CompletionFilterModel(model)
filter_model.sort(0, Qt.AscendingOrder)
actual = _extract_model_data(filter_model)
assert actual == [[('A', '', ''), ('B', '', ''), ('C', '', '')]]
filter_model.sort(0, Qt.DescendingOrder)
actual = _extract_model_data(filter_model)
assert actual == [[('C', '', ''), ('B', '', ''), ('A', '', '')]]

View File

@ -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

179
tests/unit/misc/test_sql.py Normal file
View File

@ -0,0 +1,179 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Test the SQL API."""
import pytest
from qutebrowser.misc import sql
pytestmark = pytest.mark.usefixtures('init_sql')
def test_init():
sql.SqlTable('Foo', ['name', 'val', 'lucky'])
# should not error if table already exists
sql.SqlTable('Foo', ['name', 'val', 'lucky'])
def test_insert(qtbot):
table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
with qtbot.waitSignal(table.changed):
table.insert({'name': 'one', 'val': 1, 'lucky': False})
with qtbot.waitSignal(table.changed):
table.insert({'name': 'wan', 'val': 1, 'lucky': False})
def test_insert_replace(qtbot):
table = sql.SqlTable('Foo', ['name', 'val', 'lucky'],
constraints={'name': 'PRIMARY KEY'})
with qtbot.waitSignal(table.changed):
table.insert({'name': 'one', 'val': 1, 'lucky': False}, replace=True)
with qtbot.waitSignal(table.changed):
table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=True)
assert list(table) == [('one', 11, True)]
with pytest.raises(sql.SqlException):
table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=False)
def test_insert_batch(qtbot):
table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
with qtbot.waitSignal(table.changed):
table.insert_batch({'name': ['one', 'nine', 'thirteen'],
'val': [1, 9, 13],
'lucky': [False, False, True]})
assert list(table) == [('one', 1, False),
('nine', 9, False),
('thirteen', 13, True)]
def test_insert_batch_replace(qtbot):
table = sql.SqlTable('Foo', ['name', 'val', 'lucky'],
constraints={'name': 'PRIMARY KEY'})
with qtbot.waitSignal(table.changed):
table.insert_batch({'name': ['one', 'nine', 'thirteen'],
'val': [1, 9, 13],
'lucky': [False, False, True]})
with qtbot.waitSignal(table.changed):
table.insert_batch({'name': ['one', 'nine'],
'val': [11, 19],
'lucky': [True, True]},
replace=True)
assert list(table) == [('thirteen', 13, True),
('one', 11, True),
('nine', 19, True)]
with pytest.raises(sql.SqlException):
table.insert_batch({'name': ['one', 'nine'],
'val': [11, 19],
'lucky': [True, True]})
def test_iter():
table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
table.insert({'name': 'one', 'val': 1, 'lucky': False})
table.insert({'name': 'nine', 'val': 9, 'lucky': False})
table.insert({'name': 'thirteen', 'val': 13, 'lucky': True})
assert list(table) == [('one', 1, False),
('nine', 9, False),
('thirteen', 13, True)]
@pytest.mark.parametrize('rows, sort_by, sort_order, limit, result', [
([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'asc', 5,
[(1, 6), (2, 5), (3, 4)]),
([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'desc', 3,
[(3, 4), (2, 5), (1, 6)]),
([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'b', 'desc', 2,
[(1, 6), (2, 5)]),
([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'asc', -1,
[(1, 6), (2, 5), (3, 4)]),
])
def test_select(rows, sort_by, sort_order, limit, result):
table = sql.SqlTable('Foo', ['a', 'b'])
for row in rows:
table.insert(row)
assert list(table.select(sort_by, sort_order, limit)) == result
def test_delete(qtbot):
table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
table.insert({'name': 'one', 'val': 1, 'lucky': False})
table.insert({'name': 'nine', 'val': 9, 'lucky': False})
table.insert({'name': 'thirteen', 'val': 13, 'lucky': True})
with pytest.raises(KeyError):
table.delete('name', 'nope')
with qtbot.waitSignal(table.changed):
table.delete('name', 'thirteen')
assert list(table) == [('one', 1, False), ('nine', 9, False)]
with qtbot.waitSignal(table.changed):
table.delete('lucky', False)
assert not list(table)
def test_len():
table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
assert len(table) == 0
table.insert({'name': 'one', 'val': 1, 'lucky': False})
assert len(table) == 1
table.insert({'name': 'nine', 'val': 9, 'lucky': False})
assert len(table) == 2
table.insert({'name': 'thirteen', 'val': 13, 'lucky': True})
assert len(table) == 3
def test_contains():
table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
table.insert({'name': 'one', 'val': 1, 'lucky': False})
table.insert({'name': 'nine', 'val': 9, 'lucky': False})
table.insert({'name': 'thirteen', 'val': 13, 'lucky': True})
name_query = table.contains_query('name')
val_query = table.contains_query('val')
lucky_query = table.contains_query('lucky')
assert name_query.run(val='one').value()
assert name_query.run(val='thirteen').value()
assert val_query.run(val=9).value()
assert lucky_query.run(val=False).value()
assert lucky_query.run(val=True).value()
assert not name_query.run(val='oone').value()
assert not name_query.run(val=1).value()
assert not name_query.run(val='*').value()
assert not val_query.run(val=10).value()
def test_delete_all(qtbot):
table = sql.SqlTable('Foo', ['name', 'val', 'lucky'])
table.insert({'name': 'one', 'val': 1, 'lucky': False})
table.insert({'name': 'nine', 'val': 9, 'lucky': False})
table.insert({'name': 'thirteen', 'val': 13, 'lucky': True})
with qtbot.waitSignal(table.changed):
table.delete_all()
assert list(table) == []
def test_version():
assert isinstance(sql.version(), str)

View File

@ -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)

View File

@ -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}