diff --git a/.flake8 b/.flake8 index d967a505b..eada2c86d 100644 --- a/.flake8 +++ b/.flake8 @@ -11,6 +11,7 @@ exclude = .*,__pycache__,resources.py # (for pytest's __tracebackhide__) # F401: Unused import # N802: function name should be lowercase +# N806: variable in function should be lowercase # P101: format string does contain unindexed parameters # P102: docstring does contain unindexed parameters # P103: other string does contain unindexed parameters @@ -38,7 +39,7 @@ putty-ignore = /# pragma: no mccabe/ : +C901 tests/*/test_*.py : +D100,D101,D401 tests/conftest.py : +F403 - tests/unit/browser/webkit/test_history.py : +N806 + tests/unit/browser/test_history.py : +N806 tests/helpers/fixtures.py : +N806 tests/unit/browser/webkit/http/test_content_disposition.py : +D400 scripts/dev/ci/appveyor_install.py : +FI53 diff --git a/INSTALL.asciidoc b/INSTALL.asciidoc index 7f197d408..0cfc3d651 100644 --- a/INSTALL.asciidoc +++ b/INSTALL.asciidoc @@ -27,7 +27,7 @@ Using the packages Install the dependencies via apt-get: ---- -# apt-get install python3-lxml python-tox python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python3-sip python3-jinja2 python3-pygments python3-yaml +# apt-get install python3-lxml python-tox python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python3-sip python3-jinja2 python3-pygments python3-yaml python3-pyqt5.qtsql libqt5sql5-sqlite ---- On Debian Stretch or Ubuntu 17.04 or later, it's also recommended to use the @@ -53,7 +53,7 @@ Build it from git Install the dependencies via apt-get: ---- -# apt-get install python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python-tox python3-sip python3-dev +# apt-get install python3-pyqt5 python3-pyqt5.qtwebkit python3-pyqt5.qtquick python-tox python3-sip python3-dev python3-pyqt5.qtsql libqt5sql5-sqlite ---- On Debian Stretch or Ubuntu 17.04 or later, it's also recommended to install diff --git a/qutebrowser/app.py b/qutebrowser/app.py index e0873195e..51cacfac7 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -42,9 +42,10 @@ except ImportError: import qutebrowser import qutebrowser.resources -from qutebrowser.completion.models import instances as completionmodels +from qutebrowser.completion.models import miscmodels from qutebrowser.commands import cmdutils, runners, cmdexc from qutebrowser.config import style, config, websettings, configexc +from qutebrowser.config.parsers import keyconf from qutebrowser.browser import (urlmarks, adblock, history, browsertab, downloads) from qutebrowser.browser.network import proxy @@ -53,10 +54,10 @@ from qutebrowser.browser.webkit.network import networkmanager from qutebrowser.keyinput import macros from qutebrowser.mainwindow import mainwindow, prompt from qutebrowser.misc import (readline, ipc, savemanager, sessions, - crashsignal, earlyinit, objects) + crashsignal, earlyinit, objects, sql) from qutebrowser.misc import utilcmds # pylint: disable=unused-import from qutebrowser.utils import (log, version, message, utils, qtutils, urlutils, - objreg, usertypes, standarddir, error, debug) + objreg, usertypes, standarddir, error) # We import utilcmds to run the cmdutils.register decorators. @@ -157,7 +158,7 @@ def init(args, crash_handler): QDesktopServices.setUrlHandler('https', open_desktopservices_url) QDesktopServices.setUrlHandler('qute', open_desktopservices_url) - QTimer.singleShot(10, functools.partial(_init_late_modules, args)) + objreg.get('web-history').import_txt() log.init.debug("Init done!") crash_handler.raise_crashdlg() @@ -421,6 +422,17 @@ def _init_modules(args, crash_handler): config.init(qApp) save_manager.init_autosave() + log.init.debug("Initializing keys...") + keyconf.init(qApp) + + log.init.debug("Initializing sql...") + try: + sql.init(os.path.join(standarddir.data(), 'history.sqlite')) + except sql.SqlException as e: + error.handle_fatal_exc(e, args, 'Error initializing SQL', + pre_text='Error initializing SQL') + sys.exit(usertypes.Exit.err_init) + log.init.debug("Initializing web history...") history.init(qApp) @@ -457,9 +469,6 @@ def _init_modules(args, crash_handler): diskcache = cache.DiskCache(standarddir.cache(), parent=qApp) objreg.register('cache', diskcache) - log.init.debug("Initializing completions...") - completionmodels.init() - log.init.debug("Misc initialization...") if config.get('ui', 'hide-wayland-decoration'): os.environ['QT_WAYLAND_DISABLE_WINDOWDECORATION'] = '1' @@ -470,23 +479,6 @@ def _init_modules(args, crash_handler): browsertab.init() -def _init_late_modules(args): - """Initialize modules which can be inited after the window is shown.""" - log.init.debug("Reading web history...") - reader = objreg.get('web-history').async_read() - with debug.log_time(log.init, 'Reading history'): - while True: - QApplication.processEvents() - try: - next(reader) - except StopIteration: - break - except (OSError, UnicodeDecodeError) as e: - error.handle_fatal_exc(e, args, "Error while initializing!", - pre_text="Error while initializing") - sys.exit(usertypes.Exit.err_init) - - class Quitter: """Utility class to quit/restart the QApplication. @@ -751,7 +743,7 @@ class Quitter: QTimer.singleShot(0, functools.partial(qApp.exit, status)) @cmdutils.register(instance='quitter', name='wq') - @cmdutils.argument('name', completion=usertypes.Completion.sessions) + @cmdutils.argument('name', completion=miscmodels.session) def save_and_quit(self, name=sessions.default): """Save open pages and quit. diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index b3e9c37bf..75ef17c08 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -42,7 +42,7 @@ from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, objreg, utils, typing, debug) from qutebrowser.utils.usertypes import KeyMode from qutebrowser.misc import editor, guiprocess -from qutebrowser.completion.models import instances, sortfilter +from qutebrowser.completion.models import urlmodel, miscmodels class CommandDispatcher: @@ -272,7 +272,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', name='open', maxsplit=0, scope='window') - @cmdutils.argument('url', completion=usertypes.Completion.url) + @cmdutils.argument('url', completion=urlmodel.url) @cmdutils.argument('count', count=True) def openurl(self, url=None, implicit=False, bg=False, tab=False, window=False, count=None, secure=False, @@ -1010,7 +1010,7 @@ class CommandDispatcher: self._open(url, tab, bg, window) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('index', completion=usertypes.Completion.tab) + @cmdutils.argument('index', completion=miscmodels.buffer) def buffer(self, index): """Select tab by index or url/title best match. @@ -1026,11 +1026,10 @@ class CommandDispatcher: for part in index_parts: int(part) except ValueError: - model = instances.get(usertypes.Completion.tab) - sf = sortfilter.CompletionFilterModel(source=model) - sf.set_pattern(index) - if sf.count() > 0: - index = sf.data(sf.first_item()) + model = miscmodels.buffer() + model.set_pattern(index) + if model.count() > 0: + index = model.data(model.first_item()) index_parts = index.split('/', 1) else: raise cmdexc.CommandError( @@ -1235,8 +1234,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) - @cmdutils.argument('name', - completion=usertypes.Completion.quickmark_by_name) + @cmdutils.argument('name', completion=miscmodels.quickmark) def quickmark_load(self, name, tab=False, bg=False, window=False): """Load a quickmark. @@ -1254,8 +1252,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) - @cmdutils.argument('name', - completion=usertypes.Completion.quickmark_by_name) + @cmdutils.argument('name', completion=miscmodels.quickmark) def quickmark_del(self, name=None): """Delete a quickmark. @@ -1317,7 +1314,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) - @cmdutils.argument('url', completion=usertypes.Completion.bookmark_by_url) + @cmdutils.argument('url', completion=miscmodels.bookmark) def bookmark_load(self, url, tab=False, bg=False, window=False, delete=False): """Load a bookmark. @@ -1339,7 +1336,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) - @cmdutils.argument('url', completion=usertypes.Completion.bookmark_by_url) + @cmdutils.argument('url', completion=miscmodels.bookmark) def bookmark_del(self, url=None): """Delete a bookmark. @@ -1523,7 +1520,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', name='help', scope='window') - @cmdutils.argument('topic', completion=usertypes.Completion.helptopic) + @cmdutils.argument('topic', completion=miscmodels.helptopic) def show_help(self, tab=False, bg=False, window=False, topic=None): r"""Show help about a command or setting. diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index aaad08fb3..b9e791207 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -19,214 +19,82 @@ """Simple history which gets written to disk.""" +import os import time -import collections -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, QObject +from PyQt5.QtCore import pyqtSlot, QUrl, QTimer -from qutebrowser.commands import cmdutils -from qutebrowser.utils import (utils, objreg, standarddir, log, qtutils, - usertypes, message) -from qutebrowser.misc import lineparser, objects +from qutebrowser.commands import cmdutils, cmdexc +from qutebrowser.utils import (utils, objreg, log, usertypes, message, + debug, standarddir) +from qutebrowser.misc import objects, sql -class Entry: +class CompletionHistory(sql.SqlTable): - """A single entry in the web history. + """History which only has the newest entry for each URL.""" - Attributes: - atime: The time the page was accessed. - url: The URL which was accessed as QUrl. - redirect: If True, don't save this entry to disk - """ - - def __init__(self, atime, url, title, redirect=False): - self.atime = float(atime) - self.url = url - self.title = title - self.redirect = redirect - qtutils.ensure_valid(url) - - def __repr__(self): - return utils.get_repr(self, constructor=True, atime=self.atime, - url=self.url_str(), title=self.title, - redirect=self.redirect) - - def __str__(self): - atime = str(int(self.atime)) - if self.redirect: - atime += '-r' # redirect flag - elems = [atime, self.url_str()] - if self.title: - elems.append(self.title) - return ' '.join(elems) - - def __eq__(self, other): - return (self.atime == other.atime and - self.title == other.title and - self.url == other.url and - self.redirect == other.redirect) - - def url_str(self): - """Get the URL as a lossless string.""" - return self.url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) - - @classmethod - def from_str(cls, line): - """Parse a history line like '12345 http://example.com title'.""" - data = line.split(maxsplit=2) - if len(data) == 2: - atime, url = data - title = "" - elif len(data) == 3: - atime, url, title = data - else: - raise ValueError("2 or 3 fields expected") - - url = QUrl(url) - if not url.isValid(): - raise ValueError("Invalid URL: {}".format(url.errorString())) - - # https://github.com/qutebrowser/qutebrowser/issues/670 - atime = atime.lstrip('\0') - - if '-' in atime: - atime, flags = atime.split('-') - else: - flags = '' - - if not set(flags).issubset('r'): - raise ValueError("Invalid flags {!r}".format(flags)) - - redirect = 'r' in flags - - return cls(atime, url, title, redirect=redirect) + def __init__(self, parent=None): + super().__init__("CompletionHistory", ['url', 'title', 'last_atime'], + constraints={'url': 'PRIMARY KEY'}, parent=parent) + self.create_index('CompletionHistoryAtimeIndex', 'last_atime') -class WebHistory(QObject): +class WebHistory(sql.SqlTable): - """The global history of visited pages. + """The global history of visited pages.""" - This is a little more complex as you'd expect so the history can be read - from disk async while new history is already arriving. + def __init__(self, parent=None): + super().__init__("History", ['url', 'title', 'atime', 'redirect'], + parent=parent) + self.completion = CompletionHistory(parent=self) + self.create_index('HistoryIndex', 'url') + self.create_index('HistoryAtimeIndex', 'atime') + self._contains_query = self.contains_query('url') + self._between_query = sql.Query('SELECT * FROM History ' + 'where not redirect ' + 'and not url like "qute://%" ' + 'and atime > :earliest ' + 'and atime <= :latest ' + 'ORDER BY atime desc') - self.history_dict is the main place where the history is stored, in an - OrderedDict (sorted by time) of URL strings mapped to Entry objects. - - While reading from disk is still ongoing, the history is saved in - self._temp_history instead, and then appended to self.history_dict once - that's fully populated. - - All history which is new in this session (rather than read from disk from a - previous browsing session) is also stored in self._new_history. - self._saved_count tracks how many of those entries were already written to - disk, so we can always append to the existing data. - - Attributes: - history_dict: An OrderedDict of URLs read from the on-disk history. - _lineparser: The AppendLineParser used to save the history. - _new_history: A list of Entry items of the current session. - _saved_count: How many HistoryEntries have been written to disk. - _initial_read_started: Whether async_read was called. - _initial_read_done: Whether async_read has completed. - _temp_history: OrderedDict of temporary history entries before - async_read was called. - - Signals: - add_completion_item: Emitted before a new Entry is added. - Used to sync with the completion. - arg: The new Entry. - item_added: Emitted after a new Entry is added. - Used to tell the savemanager that the history is dirty. - arg: The new Entry. - cleared: Emitted after the history is cleared. - """ - - add_completion_item = pyqtSignal(Entry) - item_added = pyqtSignal(Entry) - cleared = pyqtSignal() - async_read_done = pyqtSignal() - - def __init__(self, hist_dir, hist_name, parent=None): - super().__init__(parent) - self._initial_read_started = False - self._initial_read_done = False - self._lineparser = lineparser.AppendLineParser(hist_dir, hist_name, - parent=self) - self.history_dict = collections.OrderedDict() - self._temp_history = collections.OrderedDict() - self._new_history = [] - self._saved_count = 0 - objreg.get('save-manager').add_saveable( - 'history', self.save, self.item_added) + self._before_query = sql.Query('SELECT * FROM History ' + 'where not redirect ' + 'and not url like "qute://%" ' + 'and atime <= :latest ' + 'ORDER BY atime desc ' + 'limit :limit offset :offset') def __repr__(self): return utils.get_repr(self, length=len(self)) - def __iter__(self): - return iter(self.history_dict.values()) - - def __len__(self): - return len(self.history_dict) - - def async_read(self): - """Read the initial history.""" - if self._initial_read_started: - log.init.debug("Ignoring async_read() because reading is started.") - return - self._initial_read_started = True - - with self._lineparser.open(): - for line in self._lineparser: - yield - - line = line.rstrip() - if not line: - continue - - try: - entry = Entry.from_str(line) - except ValueError as e: - log.init.warning("Invalid history entry {!r}: {}!".format( - line, e)) - continue - - # This de-duplicates history entries; only the latest - # entry for each URL is kept. If you want to keep - # information about previous hits change the items in - # old_urls to be lists or change Entry to have a - # list of atimes. - self._add_entry(entry) - - self._initial_read_done = True - self.async_read_done.emit() - - for entry in self._temp_history.values(): - self._add_entry(entry) - self._new_history.append(entry) - if not entry.redirect: - self.add_completion_item.emit(entry) - self._temp_history.clear() - - def _add_entry(self, entry, target=None): - """Add an entry to self.history_dict or another given OrderedDict.""" - if target is None: - target = self.history_dict - url_str = entry.url_str() - target[url_str] = entry - target.move_to_end(url_str) + def __contains__(self, url): + return self._contains_query.run(val=url).value() def get_recent(self): """Get the most recent history entries.""" - old = self._lineparser.get_recent() - return old + [str(e) for e in self._new_history] + return self.select(sort_by='atime', sort_order='desc', limit=100) - def save(self): - """Save the history to disk.""" - new = (str(e) for e in self._new_history[self._saved_count:]) - self._lineparser.new_data = new - self._lineparser.save() - self._saved_count = len(self._new_history) + def entries_between(self, earliest, latest): + """Iterate non-redirect, non-qute entries between two timestamps. + + Args: + earliest: Omit timestamps earlier than this. + latest: Omit timestamps later than this. + """ + self._between_query.run(earliest=earliest, latest=latest) + return iter(self._between_query) + + def entries_before(self, latest, limit, offset): + """Iterate non-redirect, non-qute entries occurring before a timestamp. + + Args: + latest: Omit timestamps more recent than this. + limit: Max number of entries to include. + offset: Number of entries to skip. + """ + self._before_query.run(latest=latest, limit=limit, offset=offset) + return iter(self._before_query) @cmdutils.register(name='history-clear', instance='web-history') def clear(self, force=False): @@ -246,12 +114,17 @@ class WebHistory(QObject): "history?") def _do_clear(self): - self._lineparser.clear() - self.history_dict.clear() - self._temp_history.clear() - self._new_history.clear() - self._saved_count = 0 - self.cleared.emit() + self.delete_all() + self.completion.delete_all() + + def delete_url(self, url): + """Remove all history entries with the given url. + + Args: + url: URL string to delete. + """ + self.delete('url', url) + self.completion.delete('url', url) @pyqtSlot(QUrl, QUrl, str) def add_from_tab(self, url, requested_url, title): @@ -285,17 +158,129 @@ class WebHistory(QObject): log.misc.warning("Ignoring invalid URL being added to history") return - if atime is None: - atime = time.time() - entry = Entry(atime, url, title, redirect=redirect) - if self._initial_read_done: - self._add_entry(entry) - self._new_history.append(entry) - self.item_added.emit(entry) - if not entry.redirect: - self.add_completion_item.emit(entry) + atime = int(atime) if (atime is not None) else int(time.time()) + url_str = url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) + self.insert({'url': url_str, + 'title': title, + 'atime': atime, + 'redirect': redirect}) + if not redirect: + self.completion.insert({'url': url_str, + 'title': title, + 'last_atime': atime}, + replace=True) + + def _parse_entry(self, line): + """Parse a history line like '12345 http://example.com title'.""" + if not line or line.startswith('#'): + return None + data = line.split(maxsplit=2) + if len(data) == 2: + atime, url = data + title = "" + elif len(data) == 3: + atime, url, title = data else: - self._add_entry(entry, target=self._temp_history) + raise ValueError("2 or 3 fields expected") + + # http://xn--pple-43d.com/ with + # https://bugreports.qt.io/browse/QTBUG-60364 + if url in ['http://.com/', 'https://www..com/']: + return None + + url = QUrl(url) + if not url.isValid(): + raise ValueError("Invalid URL: {}".format(url.errorString())) + + # https://github.com/qutebrowser/qutebrowser/issues/2646 + if url.scheme() == 'data': + return None + + # https://github.com/qutebrowser/qutebrowser/issues/670 + atime = atime.lstrip('\0') + + if '-' in atime: + atime, flags = atime.split('-') + else: + flags = '' + + if not set(flags).issubset('r'): + raise ValueError("Invalid flags {!r}".format(flags)) + + redirect = 'r' in flags + return (url, title, int(atime), redirect) + + def import_txt(self): + """Import a history text file into sqlite if it exists. + + In older versions of qutebrowser, history was stored in a text format. + This converts that file into the new sqlite format and moves it to a + backup location. + """ + path = os.path.join(standarddir.data(), 'history') + if not os.path.isfile(path): + return + + def action(): + with debug.log_time(log.init, 'Import old history file to sqlite'): + try: + self._read(path) + except ValueError as ex: + message.error('Failed to import history: {}'.format(ex)) + else: + bakpath = path + '.bak' + message.info('History import complete. Moving {} to {}' + .format(path, bakpath)) + os.rename(path, bakpath) + + # delay to give message time to appear before locking down for import + message.info('Converting {} to sqlite...'.format(path)) + QTimer.singleShot(100, action) + + def _read(self, path): + """Import a text file into the sql database.""" + with open(path, 'r', encoding='utf-8') as f: + data = {'url': [], 'title': [], 'atime': [], 'redirect': []} + completion_data = {'url': [], 'title': [], 'last_atime': []} + for (i, line) in enumerate(f): + try: + parsed = self._parse_entry(line.strip()) + if parsed is None: + continue + url, title, atime, redirect = parsed + data['url'].append(url) + data['title'].append(title) + data['atime'].append(atime) + data['redirect'].append(redirect) + if not redirect: + completion_data['url'].append(url) + completion_data['title'].append(title) + completion_data['last_atime'].append(atime) + except ValueError as ex: + raise ValueError('Failed to parse line #{} of {}: "{}"' + .format(i, path, ex)) + self.insert_batch(data) + self.completion.insert_batch(completion_data, replace=True) + + @cmdutils.register(instance='web-history', debug=True) + def debug_dump_history(self, dest): + """Dump the history to a file in the old pre-SQL format. + + Args: + dest: Where to write the file to. + """ + dest = os.path.expanduser(dest) + + lines = ('{}{} {} {}' + .format(int(x.atime), '-r' * x.redirect, x.url, x.title) + for x in self.select(sort_by='atime', sort_order='asc')) + + try: + with open(dest, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines)) + message.info("Dumped history to {}".format(dest)) + except OSError as e: + raise cmdexc.CommandError('Could not write history: {}', e) def init(parent=None): @@ -304,8 +289,7 @@ def init(parent=None): Args: parent: The parent to use for WebHistory. """ - history = WebHistory(hist_dir=standarddir.data(), hist_name='history', - parent=parent) + history = WebHistory(parent=parent) objreg.register('web-history', history) if objects.backend == usertypes.Backend.QtWebKit: diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 652c58726..0f86d0980 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -26,7 +26,6 @@ Module attributes: import json import os -import sys import time import urllib.parse import datetime @@ -186,88 +185,36 @@ def qute_bookmarks(_url): return 'text/html', html -def history_data(start_time): # noqa - """Return history data +def history_data(start_time, offset=None): + """Return history data. Arguments: - start_time -- select history starting from this timestamp. + start_time: select history starting from this timestamp. + offset: number of items to skip """ - def history_iter(start_time, reverse=False): - """Iterate through the history and get items we're interested. - - Arguments: - reverse -- whether to reverse the history_dict before iterating. - """ - history = objreg.get('web-history').history_dict.values() - if reverse: - history = reversed(history) - - # when history_dict is not reversed, we need to keep track of last item - # so that we can yield its atime - last_item = None - + # history atimes are stored as ints, ensure start_time is not a float + start_time = int(start_time) + hist = objreg.get('web-history') + if offset is not None: + entries = hist.entries_before(start_time, limit=1000, offset=offset) + else: # end is 24hrs earlier than start end_time = start_time - 24*60*60 + entries = hist.entries_between(end_time, start_time) - for item in history: - # Skip redirects - # Skip qute:// links - if item.redirect or item.url.scheme() == 'qute': - continue - - # Skip items out of time window - item_newer = item.atime > start_time - item_older = item.atime <= end_time - if reverse: - # history_dict is reversed, we are going back in history. - # so: - # abort if item is older than start_time+24hr - # skip if item is newer than start - if item_older: - yield {"next": int(item.atime)} - return - if item_newer: - continue - else: - # history_dict isn't reversed, we are going forward in history. - # so: - # abort if item is newer than start_time - # skip if item is older than start_time+24hrs - if item_older: - last_item = item - continue - if item_newer: - yield {"next": int(last_item.atime if last_item else -1)} - return - - # Use item's url as title if there's no title. - item_url = item.url.toDisplayString() - item_title = item.title if item.title else item_url - item_time = int(item.atime * 1000) - - yield {"url": item_url, "title": item_title, "time": item_time} - - # if we reached here, we had reached the end of history - yield {"next": int(last_item.atime if last_item else -1)} - - if sys.hexversion >= 0x03050000: - # On Python >= 3.5 we can reverse the ordereddict in-place and thus - # apply an additional performance improvement in history_iter. - # On my machine, this gets us down from 550ms to 72us with 500k old - # items. - history = history_iter(start_time, reverse=True) - else: - # On Python 3.4, we can't do that, so we'd need to copy the entire - # history to a list. There, filter first and then reverse it here. - history = reversed(list(history_iter(start_time, reverse=False))) - - return list(history) + return [{"url": e.url, "title": e.title or e.url, "time": e.atime} + for e in entries] @add_handler('history') def qute_history(url): """Handler for qute://history. Display and serve history.""" if url.path() == '/data': + try: + offset = QUrlQuery(url).queryItemValue("offset") + offset = int(offset) if offset else None + except ValueError as e: + raise QuteSchemeError("Query parameter offset is invalid", e) # Use start_time in query or current time. try: start_time = QUrlQuery(url).queryItemValue("start_time") @@ -275,7 +222,7 @@ def qute_history(url): except ValueError as e: raise QuteSchemeError("Query parameter start_time is invalid", e) - return 'text/html', json.dumps(history_data(start_time)) + return 'text/html', json.dumps(history_data(start_time, offset)) else: if ( config.get('content', 'allow-javascript') and @@ -307,9 +254,9 @@ def qute_history(url): start_time = time.mktime(next_date.timetuple()) - 1 history = [ (i["url"], i["title"], - datetime.datetime.fromtimestamp(i["time"]/1000), + datetime.datetime.fromtimestamp(i["time"]), QUrl(i["url"]).host()) - for i in history_data(start_time) if "next" not in i + for i in history_data(start_time) ] return 'text/html', jinja.render( diff --git a/qutebrowser/browser/webkit/webkithistory.py b/qutebrowser/browser/webkit/webkithistory.py index 0f9d64460..0edbb3fa3 100644 --- a/qutebrowser/browser/webkit/webkithistory.py +++ b/qutebrowser/browser/webkit/webkithistory.py @@ -19,9 +19,12 @@ """QtWebKit specific part of history.""" +import functools from PyQt5.QtWebKit import QWebHistoryInterface +from qutebrowser.utils import debug + class WebHistoryInterface(QWebHistoryInterface): @@ -34,11 +37,13 @@ class WebHistoryInterface(QWebHistoryInterface): def __init__(self, webhistory, parent=None): super().__init__(parent) self._history = webhistory + self._history.changed.connect(self.historyContains.cache_clear) def addHistoryEntry(self, url_string): """Required for a QWebHistoryInterface impl, obsoleted by add_url.""" pass + @functools.lru_cache(maxsize=32768) def historyContains(self, url_string): """Called by WebKit to determine if a URL is contained in the history. @@ -48,7 +53,8 @@ class WebHistoryInterface(QWebHistoryInterface): Return: True if the url is in the history, False otherwise. """ - return url_string in self._history.history_dict + with debug.log_time('sql', 'historyContains'): + return url_string in self._history def init(history): diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 96d937829..ae72add20 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -23,8 +23,8 @@ from PyQt5.QtCore import pyqtSlot, QObject, QTimer from qutebrowser.config import config from qutebrowser.commands import cmdutils, runners -from qutebrowser.utils import usertypes, log, utils -from qutebrowser.completion.models import instances, sortfilter +from qutebrowser.utils import log, utils, debug +from qutebrowser.completion.models import miscmodels class Completer(QObject): @@ -39,6 +39,7 @@ class Completer(QObject): _last_cursor_pos: The old cursor position so we avoid double completion updates. _last_text: The old command text so we avoid double completion updates. + _last_completion_func: The completion function used for the last text. """ def __init__(self, cmd, win_id, parent=None): @@ -52,6 +53,7 @@ class Completer(QObject): self._timer.timeout.connect(self._update_completion) self._last_cursor_pos = None self._last_text = None + self._last_completion_func = None self._cmd.update_completion.connect(self.schedule_completion_update) def __repr__(self): @@ -62,37 +64,8 @@ class Completer(QObject): completion = self.parent() return completion.model() - def _get_completion_model(self, completion, pos_args): - """Get a completion model based on an enum member. - - Args: - completion: A usertypes.Completion member. - pos_args: The positional args entered before the cursor. - - Return: - A completion model or None. - """ - if completion == usertypes.Completion.option: - section = pos_args[0] - model = instances.get(completion).get(section) - elif completion == usertypes.Completion.value: - section = pos_args[0] - option = pos_args[1] - try: - model = instances.get(completion)[section][option] - except KeyError: - # No completion model for this section/option. - model = None - else: - model = instances.get(completion) - - if model is None: - return None - else: - return sortfilter.CompletionFilterModel(source=model, parent=self) - def _get_new_completion(self, before_cursor, under_cursor): - """Get a new completion. + """Get the completion function based on the current command text. Args: before_cursor: The command chunks before the cursor. @@ -109,8 +82,8 @@ class Completer(QObject): log.completion.debug("After removing flags: {}".format(before_cursor)) if not before_cursor: # '|' or 'set|' - model = instances.get(usertypes.Completion.command) - return sortfilter.CompletionFilterModel(source=model, parent=self) + log.completion.debug('Starting command completion') + return miscmodels.command try: cmd = cmdutils.cmd_dict[before_cursor[0]] except KeyError: @@ -119,14 +92,11 @@ class Completer(QObject): return None argpos = len(before_cursor) - 1 try: - completion = cmd.get_pos_arg_info(argpos).completion + func = cmd.get_pos_arg_info(argpos).completion except IndexError: log.completion.debug("No completion in position {}".format(argpos)) return None - if completion is None: - return None - model = self._get_completion_model(completion, before_cursor[1:]) - return model + return func def _quote(self, s): """Quote s if it needs quoting for the commandline. @@ -241,6 +211,7 @@ class Completer(QObject): # FIXME complete searches # https://github.com/qutebrowser/qutebrowser/issues/32 completion.set_model(None) + self._last_completion_func = None return before_cursor, pattern, after_cursor = self._partition() @@ -249,13 +220,24 @@ class Completer(QObject): before_cursor, pattern, after_cursor)) pattern = pattern.strip("'\"") - model = self._get_new_completion(before_cursor, pattern) + func = self._get_new_completion(before_cursor, pattern) - log.completion.debug("Setting completion model to {} with pattern '{}'" - .format(model.srcmodel.__class__.__name__ if model else 'None', - pattern)) + if func is None: + log.completion.debug('Clearing completion') + completion.set_model(None) + self._last_completion_func = None + return - completion.set_model(model, pattern) + if func != self._last_completion_func: + self._last_completion_func = func + args = (x for x in before_cursor[1:] if not x.startswith('-')) + with debug.log_time(log.completion, + 'Starting {} completion'.format(func.__name__)): + model = func(*args) + with debug.log_time(log.completion, 'Set completion model'): + completion.set_model(model) + + completion.set_pattern(pattern) def _change_completed_part(self, newtext, before, after, immediate=False): """Change the part we're currently completing in the commandline. diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py index 1d5dfadf0..b2a933cef 100644 --- a/qutebrowser/completion/completiondelegate.py +++ b/qutebrowser/completion/completiondelegate.py @@ -196,8 +196,9 @@ class CompletionItemDelegate(QStyledItemDelegate): self._doc.setDocumentMargin(2) if index.parent().isValid(): - pattern = index.model().pattern - columns_to_filter = index.model().srcmodel.columns_to_filter + view = self.parent() + pattern = view.pattern + columns_to_filter = index.model().columns_to_filter(index) if index.column() in columns_to_filter and pattern: repl = r'\g<0>' text = re.sub(re.escape(pattern).replace(r'\ ', r'|'), diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index 490fcd6c0..6e1e51680 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -28,8 +28,7 @@ from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel, QSize from qutebrowser.config import config, style from qutebrowser.completion import completiondelegate -from qutebrowser.completion.models import base -from qutebrowser.utils import utils, usertypes, objreg +from qutebrowser.utils import utils, usertypes, objreg, debug, log from qutebrowser.commands import cmdexc, cmdutils @@ -41,6 +40,7 @@ class CompletionView(QTreeView): headers, and children show as flat list. Attributes: + pattern: Current filter pattern, used for highlighting. _win_id: The ID of the window this CompletionView is associated with. _height: The height to use for the CompletionView. _height_perc: Either None or a percentage if height should be relative. @@ -107,12 +107,12 @@ class CompletionView(QTreeView): def __init__(self, win_id, parent=None): super().__init__(parent) + self.pattern = '' self._win_id = win_id # FIXME handle new aliases. # objreg.get('config').changed.connect(self.init_command_completion) objreg.get('config').changed.connect(self._on_config_changed) - self._column_widths = base.BaseCompletionModel.COLUMN_WIDTHS self._active = False self._delegate = completiondelegate.CompletionItemDelegate(self) @@ -151,7 +151,8 @@ class CompletionView(QTreeView): def _resize_columns(self): """Resize the completion columns based on column_widths.""" width = self.size().width() - pixel_widths = [(width * perc // 100) for perc in self._column_widths] + column_widths = self.model().column_widths + pixel_widths = [(width * perc // 100) for perc in column_widths] if self.verticalScrollBar().isVisible(): delta = self.style().pixelMetric(QStyle.PM_ScrollBarExtent) + 5 @@ -262,47 +263,49 @@ class CompletionView(QTreeView): elif config.get('completion', 'show') == 'auto': self.show() - def set_model(self, model, pattern=None): + def set_model(self, model): """Switch completion to a new model. Called from on_update_completion(). Args: model: The model to use. - pattern: The filter pattern to set (what the user entered). """ + if self.model() is not None and model is not self.model(): + self.model().deleteLater() + self.selectionModel().deleteLater() + + self.setModel(model) + if model is None: self._active = False self.hide() return - old_model = self.model() - if model is not old_model: - sel_model = self.selectionModel() - - self.setModel(model) - self._active = True - - if sel_model is not None: - sel_model.deleteLater() - if old_model is not None: - old_model.deleteLater() - - if (config.get('completion', 'show') == 'always' and - model.count() > 0): - self.show() - else: - self.hide() + model.setParent(self) + self._active = True + self._maybe_show() + self._resize_columns() for i in range(model.rowCount()): self.expand(model.index(i, 0)) - if pattern is not None: - model.set_pattern(pattern) + def set_pattern(self, pattern): + """Set the pattern on the underlying model.""" + if not self.model(): + return + self.pattern = pattern + with debug.log_time(log.completion, 'Set pattern {}'.format(pattern)): + self.model().set_pattern(pattern) + self._maybe_update_geometry() + self._maybe_show() - self._column_widths = model.srcmodel.COLUMN_WIDTHS - self._resize_columns() - self._maybe_update_geometry() + def _maybe_show(self): + if (config.get('completion', 'show') == 'always' and + self.model().count() > 0): + self.show() + else: + self.hide() def _maybe_update_geometry(self): """Emit the update_geometry signal if the config says so.""" @@ -347,7 +350,7 @@ class CompletionView(QTreeView): indexes = selected.indexes() if not indexes: return - data = self.model().data(indexes[0]) + data = str(self.model().data(indexes[0])) self.selection_changed.emit(data) def resizeEvent(self, e): @@ -367,9 +370,7 @@ class CompletionView(QTreeView): modes=[usertypes.KeyMode.command], scope='window') def completion_item_del(self): """Delete the current completion item.""" - if not self.currentIndex().isValid(): + index = self.currentIndex() + if not index.isValid(): raise cmdexc.CommandError("No item selected!") - try: - self.model().srcmodel.delete_cur_item(self) - except NotImplementedError: - raise cmdexc.CommandError("Cannot delete this item.") + self.model().delete_cur_item(index) diff --git a/qutebrowser/completion/models/base.py b/qutebrowser/completion/models/base.py deleted file mode 100644 index b1cad276a..000000000 --- a/qutebrowser/completion/models/base.py +++ /dev/null @@ -1,130 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2014-2017 Florian Bruhin (The Compiler) -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see . - -"""The base completion model for completion in the command line. - -Module attributes: - Role: An enum of user defined model roles. -""" - -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QStandardItemModel, QStandardItem - -from qutebrowser.utils import usertypes - - -Role = usertypes.enum('Role', ['sort', 'userdata'], start=Qt.UserRole, - is_int=True) - - -class BaseCompletionModel(QStandardItemModel): - - """A simple QStandardItemModel adopted for completions. - - Used for showing completions later in the CompletionView. Supports setting - marks and adding new categories/items easily. - - Class Attributes: - COLUMN_WIDTHS: The width percentages of the columns used in the - completion view. - DUMB_SORT: the dumb sorting used by the model - """ - - COLUMN_WIDTHS = (30, 70, 0) - DUMB_SORT = None - - def __init__(self, parent=None): - super().__init__(parent) - self.setColumnCount(3) - self.columns_to_filter = [0] - - def new_category(self, name, sort=None): - """Add a new category to the model. - - Args: - name: The name of the category to add. - sort: The value to use for the sort role. - - Return: - The created QStandardItem. - """ - cat = QStandardItem(name) - if sort is not None: - cat.setData(sort, Role.sort) - self.appendRow(cat) - return cat - - def new_item(self, cat, name, desc='', misc=None, sort=None, - userdata=None): - """Add a new item to a category. - - Args: - cat: The parent category. - name: The name of the item. - desc: The description of the item. - misc: Misc text to display. - sort: Data for the sort role (int). - userdata: User data to be added for the first column. - - Return: - A (nameitem, descitem, miscitem) tuple. - """ - assert not isinstance(name, int) - assert not isinstance(desc, int) - assert not isinstance(misc, int) - - nameitem = QStandardItem(name) - descitem = QStandardItem(desc) - if misc is None: - miscitem = QStandardItem() - else: - miscitem = QStandardItem(misc) - - cat.appendRow([nameitem, descitem, miscitem]) - if sort is not None: - nameitem.setData(sort, Role.sort) - if userdata is not None: - nameitem.setData(userdata, Role.userdata) - return nameitem, descitem, miscitem - - def delete_cur_item(self, completion): - """Delete the selected item.""" - raise NotImplementedError - - def flags(self, index): - """Return the item flags for index. - - Override QAbstractItemModel::flags. - - Args: - index: The QModelIndex to get item flags for. - - Return: - The item flags, or Qt.NoItemFlags on error. - """ - if not index.isValid(): - return - - if index.parent().isValid(): - # item - return (Qt.ItemIsEnabled | Qt.ItemIsSelectable | - Qt.ItemNeverHasChildren) - else: - # category - return Qt.NoItemFlags diff --git a/qutebrowser/completion/models/completionmodel.py b/qutebrowser/completion/models/completionmodel.py new file mode 100644 index 000000000..3e48076e5 --- /dev/null +++ b/qutebrowser/completion/models/completionmodel.py @@ -0,0 +1,224 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""A model that proxies access to one or more completion categories.""" + +from PyQt5.QtCore import Qt, QModelIndex, QAbstractItemModel + +from qutebrowser.utils import log, qtutils + + +class CompletionModel(QAbstractItemModel): + + """A model that proxies access to one or more completion categories. + + Top level indices represent categories. + Child indices represent rows of those tables. + + Attributes: + column_widths: The width percentages of the columns used in the + completion view. + _categories: The sub-categories. + """ + + def __init__(self, *, column_widths=(30, 70, 0), parent=None): + super().__init__(parent) + self.column_widths = column_widths + self._categories = [] + + def _cat_from_idx(self, index): + """Return the category pointed to by the given index. + + Args: + idx: A QModelIndex + Returns: + A category if the index points at one, else None + """ + # items hold an index to the parent category in their internalPointer + # categories have an empty internalPointer + if index.isValid() and not index.internalPointer(): + return self._categories[index.row()] + return None + + def add_category(self, cat): + """Add a completion category to the model.""" + self._categories.append(cat) + cat.layoutAboutToBeChanged.connect(self.layoutAboutToBeChanged) + cat.layoutChanged.connect(self.layoutChanged) + + def data(self, index, role=Qt.DisplayRole): + """Return the item data for index. + + Override QAbstractItemModel::data. + + Args: + index: The QModelIndex to get item flags for. + + Return: The item data, or None on an invalid index. + """ + if role != Qt.DisplayRole: + return None + cat = self._cat_from_idx(index) + if cat: + # category header + if index.column() == 0: + return self._categories[index.row()].name + return None + # item + cat = self._cat_from_idx(index.parent()) + if not cat: + return None + idx = cat.index(index.row(), index.column()) + return cat.data(idx) + + def flags(self, index): + """Return the item flags for index. + + Override QAbstractItemModel::flags. + + Return: The item flags, or Qt.NoItemFlags on error. + """ + if not index.isValid(): + return Qt.NoItemFlags + if index.parent().isValid(): + # item + return (Qt.ItemIsEnabled | Qt.ItemIsSelectable | + Qt.ItemNeverHasChildren) + else: + # category + return Qt.NoItemFlags + + def index(self, row, col, parent=QModelIndex()): + """Get an index into the model. + + Override QAbstractItemModel::index. + + Return: A QModelIndex. + """ + if (row < 0 or row >= self.rowCount(parent) or + col < 0 or col >= self.columnCount(parent)): + return QModelIndex() + if parent.isValid(): + if parent.column() != 0: + return QModelIndex() + # store a pointer to the parent category in internalPointer + return self.createIndex(row, col, self._categories[parent.row()]) + return self.createIndex(row, col, None) + + def parent(self, index): + """Get an index to the parent of the given index. + + Override QAbstractItemModel::parent. + + Args: + index: The QModelIndex to get the parent index for. + """ + parent_cat = index.internalPointer() + if not parent_cat: + # categories have no parent + return QModelIndex() + row = self._categories.index(parent_cat) + return self.createIndex(row, 0, None) + + def rowCount(self, parent=QModelIndex()): + """Override QAbstractItemModel::rowCount.""" + if not parent.isValid(): + # top-level + return len(self._categories) + cat = self._cat_from_idx(parent) + if not cat or parent.column() != 0: + # item or nonzero category column (only first col has children) + return 0 + else: + # category + return cat.rowCount() + + def columnCount(self, parent=QModelIndex()): + """Override QAbstractItemModel::columnCount.""" + # pylint: disable=unused-argument + return 3 + + def canFetchMore(self, parent): + """Override to forward the call to the categories.""" + cat = self._cat_from_idx(parent) + if cat: + return cat.canFetchMore(QModelIndex()) + return False + + def fetchMore(self, parent): + """Override to forward the call to the categories.""" + cat = self._cat_from_idx(parent) + if cat: + cat.fetchMore(QModelIndex()) + + def count(self): + """Return the count of non-category items.""" + return sum(t.rowCount() for t in self._categories) + + def set_pattern(self, pattern): + """Set the filter pattern for all categories. + + Args: + pattern: The filter pattern to set. + """ + log.completion.debug("Setting completion pattern '{}'".format(pattern)) + for cat in self._categories: + cat.set_pattern(pattern) + + def first_item(self): + """Return the index of the first child (non-category) in the model.""" + for row, cat in enumerate(self._categories): + if cat.rowCount() > 0: + parent = self.index(row, 0) + index = self.index(0, 0, parent) + qtutils.ensure_valid(index) + return index + return QModelIndex() + + def last_item(self): + """Return the index of the last child (non-category) in the model.""" + for row, cat in reversed(list(enumerate(self._categories))): + childcount = cat.rowCount() + if childcount > 0: + parent = self.index(row, 0) + index = self.index(childcount - 1, 0, parent) + qtutils.ensure_valid(index) + return index + return QModelIndex() + + def columns_to_filter(self, index): + """Return the column indices the filter pattern applies to. + + Args: + index: index of the item to check. + + Return: A list of integers. + """ + cat = self._cat_from_idx(index.parent()) + return cat.columns_to_filter if cat else [] + + def delete_cur_item(self, index): + """Delete the row at the given index.""" + qtutils.ensure_valid(index) + parent = index.parent() + cat = self._cat_from_idx(parent) + assert cat, "CompletionView sent invalid index for deletion" + self.beginRemoveRows(parent, index.row(), index.row()) + cat.delete_cur_item(cat.index(index.row(), 0)) + self.endRemoveRows() diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index c9e9850d1..663a0b7f7 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -17,142 +17,80 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""CompletionModels for the config.""" +"""Functions that return config-related completion models.""" -from PyQt5.QtCore import pyqtSlot, Qt - -from qutebrowser.config import config, configdata -from qutebrowser.utils import log, qtutils, objreg -from qutebrowser.completion.models import base +from qutebrowser.config import configdata, configexc +from qutebrowser.completion.models import completionmodel, listcategory +from qutebrowser.utils import objreg -class SettingSectionCompletionModel(base.BaseCompletionModel): - +def section(): """A CompletionModel filled with settings sections.""" - - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - COLUMN_WIDTHS = (20, 70, 10) - - def __init__(self, parent=None): - super().__init__(parent) - cat = self.new_category("Sections") - for name in configdata.DATA: - desc = configdata.SECTION_DESC[name].splitlines()[0].strip() - self.new_item(cat, name, desc) + model = completionmodel.CompletionModel(column_widths=(20, 70, 10)) + sections = ((name, configdata.SECTION_DESC[name].splitlines()[0].strip()) + for name in configdata.DATA) + model.add_category(listcategory.ListCategory("Sections", sections)) + return model -class SettingOptionCompletionModel(base.BaseCompletionModel): - +def option(sectname): """A CompletionModel filled with settings and their descriptions. - Attributes: - _misc_items: A dict of the misc. column items which will be set later. - _section: The config section this model shows. + Args: + sectname: The name of the config section this model shows. """ - - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - COLUMN_WIDTHS = (20, 70, 10) - - def __init__(self, section, parent=None): - super().__init__(parent) - cat = self.new_category(section) - sectdata = configdata.DATA[section] - self._misc_items = {} - self._section = section - objreg.get('config').changed.connect(self.update_misc_column) - for name in sectdata: - try: - desc = sectdata.descriptions[name] - except (KeyError, AttributeError): - # Some stuff (especially ValueList items) don't have a - # description. - desc = "" - else: - desc = desc.splitlines()[0] - value = config.get(section, name, raw=True) - _valitem, _descitem, miscitem = self.new_item(cat, name, desc, - value) - self._misc_items[name] = miscitem - - @pyqtSlot(str, str) - def update_misc_column(self, section, option): - """Update misc column when config changed.""" - if section != self._section: - return + model = completionmodel.CompletionModel(column_widths=(20, 70, 10)) + try: + sectdata = configdata.DATA[sectname] + except KeyError: + return None + options = [] + for name in sectdata: try: - item = self._misc_items[option] - except KeyError: - log.completion.debug("Couldn't get item {}.{} from model!".format( - section, option)) - # changed before init - return - val = config.get(section, option, raw=True) - idx = item.index() - qtutils.ensure_valid(idx) - ok = self.setData(idx, val, Qt.DisplayRole) - if not ok: - raise ValueError("Setting data failed! (section: {}, option: {}, " - "value: {})".format(section, option, val)) + desc = sectdata.descriptions[name] + except (KeyError, AttributeError): + # Some stuff (especially ValueList items) don't have a + # description. + desc = "" + else: + desc = desc.splitlines()[0] + config = objreg.get('config') + val = config.get(sectname, name, raw=True) + options.append((name, desc, val)) + model.add_category(listcategory.ListCategory(sectname, options)) + return model -class SettingValueCompletionModel(base.BaseCompletionModel): - +def value(sectname, optname): """A CompletionModel filled with setting values. - Attributes: - _section: The config section this model shows. - _option: The config option this model shows. + Args: + sectname: The name of the config section this model shows. + optname: The name of the config option this model shows. """ + model = completionmodel.CompletionModel(column_widths=(20, 70, 10)) + config = objreg.get('config') - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method + try: + current = config.get(sectname, optname, raw=True) or '""' + except (configexc.NoSectionError, configexc.NoOptionError): + return None - COLUMN_WIDTHS = (20, 70, 10) + default = configdata.DATA[sectname][optname].default() or '""' - def __init__(self, section, option, parent=None): - super().__init__(parent) - self._section = section - self._option = option - objreg.get('config').changed.connect(self.update_current_value) - cur_cat = self.new_category("Current/Default", sort=0) - value = config.get(section, option, raw=True) - if not value: - value = '""' - self.cur_item, _descitem, _miscitem = self.new_item(cur_cat, value, - "Current value") - default_value = configdata.DATA[section][option].default() - if not default_value: - default_value = '""' - self.new_item(cur_cat, default_value, "Default value") - if hasattr(configdata.DATA[section], 'valtype'): - # Same type for all values (ValueList) - vals = configdata.DATA[section].valtype.complete() - else: - if option is None: - raise ValueError("option may only be None for ValueList " - "sections, but {} is not!".format(section)) - # Different type for each value (KeyValue) - vals = configdata.DATA[section][option].typ.complete() - if vals is not None: - cat = self.new_category("Completions", sort=1) - for (val, desc) in vals: - self.new_item(cat, val, desc) + if hasattr(configdata.DATA[sectname], 'valtype'): + # Same type for all values (ValueList) + vals = configdata.DATA[sectname].valtype.complete() + else: + if optname is None: + raise ValueError("optname may only be None for ValueList " + "sections, but {} is not!".format(sectname)) + # Different type for each value (KeyValue) + vals = configdata.DATA[sectname][optname].typ.complete() - @pyqtSlot(str, str) - def update_current_value(self, section, option): - """Update current value when config changed.""" - if (section, option) != (self._section, self._option): - return - value = config.get(section, option, raw=True) - if not value: - value = '""' - idx = self.cur_item.index() - qtutils.ensure_valid(idx) - ok = self.setData(idx, value, Qt.DisplayRole) - if not ok: - raise ValueError("Setting data failed! (section: {}, option: {}, " - "value: {})".format(section, option, value)) + cur_cat = listcategory.ListCategory("Current/Default", + [(current, "Current value"), (default, "Default value")]) + model.add_category(cur_cat) + if vals is not None: + model.add_category(listcategory.ListCategory("Completions", vals)) + return model diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py new file mode 100644 index 000000000..fa8443a60 --- /dev/null +++ b/qutebrowser/completion/models/histcategory.py @@ -0,0 +1,100 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""A completion category that queries the SQL History store.""" + +import re + +from PyQt5.QtSql import QSqlQueryModel + +from qutebrowser.misc import sql +from qutebrowser.utils import debug +from qutebrowser.commands import cmdexc +from qutebrowser.config import config + + +class HistoryCategory(QSqlQueryModel): + + """A completion category that queries the SQL History store.""" + + def __init__(self, *, delete_func=None, parent=None): + """Create a new History completion category.""" + super().__init__(parent=parent) + self.name = "History" + + # replace ' in timestamp-format to avoid breaking the query + timefmt = ("strftime('{}', last_atime, 'unixepoch', 'localtime')" + .format(config.get('completion', 'timestamp-format') + .replace("'", "`"))) + + self._query = sql.Query(' '.join([ + "SELECT url, title, {}".format(timefmt), + "FROM CompletionHistory", + # the incoming pattern will have literal % and _ escaped with '\' + # we need to tell sql to treat '\' as an escape character + "WHERE (url LIKE :pat escape '\\' or title LIKE :pat escape '\\')", + self._atime_expr(), + "ORDER BY last_atime DESC", + ]), forward_only=False) + + # advertise that this model filters by URL and title + self.columns_to_filter = [0, 1] + self.delete_func = delete_func + + def _atime_expr(self): + """If max_items is set, return an expression to limit the query.""" + max_items = config.get('completion', 'web-history-max-items') + if max_items < 0: + return '' + + min_atime = sql.Query(' '.join([ + 'SELECT min(last_atime) FROM', + '(SELECT last_atime FROM CompletionHistory', + 'ORDER BY last_atime DESC LIMIT :limit)', + ])).run(limit=max_items).value() + + return "AND last_atime >= {}".format(min_atime) + + def set_pattern(self, pattern): + """Set the pattern used to filter results. + + Args: + pattern: string pattern to filter by. + """ + # escape to treat a user input % or _ as a literal, not a wildcard + pattern = pattern.replace('%', '\\%') + pattern = pattern.replace('_', '\\_') + # treat spaces as wildcards to match any of the typed words + pattern = re.sub(r' +', '%', pattern) + pattern = '%{}%'.format(pattern) + with debug.log_time('sql', 'Running completion query'): + self._query.run(pat=pattern) + self.setQuery(self._query) + + def delete_cur_item(self, index): + """Delete the row at the given index.""" + if not self.delete_func: + raise cmdexc.CommandError("Cannot delete this item.") + data = [self.data(index.sibling(index.row(), i)) + for i in range(self.columnCount())] + self.delete_func(data) + # re-run query to reload updated table + with debug.log_time('sql', 'Re-running completion query post-delete'): + self._query.run() + self.setQuery(self._query) diff --git a/qutebrowser/completion/models/instances.py b/qutebrowser/completion/models/instances.py deleted file mode 100644 index f7eaaca86..000000000 --- a/qutebrowser/completion/models/instances.py +++ /dev/null @@ -1,196 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2015-2017 Florian Bruhin (The Compiler) -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see . - -"""Global instances of the completion models. - -Module attributes: - _instances: A dict of available completions. - INITIALIZERS: A {usertypes.Completion: callable} dict of functions to - initialize completions. -""" - -import functools - -from qutebrowser.completion.models import miscmodels, urlmodel, configmodel -from qutebrowser.utils import objreg, usertypes, log, debug -from qutebrowser.config import configdata, config - - -_instances = {} - - -def _init_command_completion(): - """Initialize the command completion model.""" - log.completion.debug("Initializing command completion.") - model = miscmodels.CommandCompletionModel() - _instances[usertypes.Completion.command] = model - - -def _init_helptopic_completion(): - """Initialize the helptopic completion model.""" - log.completion.debug("Initializing helptopic completion.") - model = miscmodels.HelpCompletionModel() - _instances[usertypes.Completion.helptopic] = model - - -def _init_url_completion(): - """Initialize the URL completion model.""" - log.completion.debug("Initializing URL completion.") - with debug.log_time(log.completion, 'URL completion init'): - model = urlmodel.UrlCompletionModel() - _instances[usertypes.Completion.url] = model - - -def _init_tab_completion(): - """Initialize the tab completion model.""" - log.completion.debug("Initializing tab completion.") - with debug.log_time(log.completion, 'tab completion init'): - model = miscmodels.TabCompletionModel() - _instances[usertypes.Completion.tab] = model - - -def _init_setting_completions(): - """Initialize setting completion models.""" - log.completion.debug("Initializing setting completion.") - _instances[usertypes.Completion.section] = ( - configmodel.SettingSectionCompletionModel()) - _instances[usertypes.Completion.option] = {} - _instances[usertypes.Completion.value] = {} - for sectname in configdata.DATA: - opt_model = configmodel.SettingOptionCompletionModel(sectname) - _instances[usertypes.Completion.option][sectname] = opt_model - _instances[usertypes.Completion.value][sectname] = {} - for opt in configdata.DATA[sectname]: - val_model = configmodel.SettingValueCompletionModel(sectname, opt) - _instances[usertypes.Completion.value][sectname][opt] = val_model - - -def init_quickmark_completions(): - """Initialize quickmark completion models.""" - log.completion.debug("Initializing quickmark completion.") - try: - _instances[usertypes.Completion.quickmark_by_name].deleteLater() - except KeyError: - pass - model = miscmodels.QuickmarkCompletionModel() - _instances[usertypes.Completion.quickmark_by_name] = model - - -def init_bookmark_completions(): - """Initialize bookmark completion models.""" - log.completion.debug("Initializing bookmark completion.") - try: - _instances[usertypes.Completion.bookmark_by_url].deleteLater() - except KeyError: - pass - model = miscmodels.BookmarkCompletionModel() - _instances[usertypes.Completion.bookmark_by_url] = model - - -def init_session_completion(): - """Initialize session completion model.""" - log.completion.debug("Initializing session completion.") - try: - _instances[usertypes.Completion.sessions].deleteLater() - except KeyError: - pass - model = miscmodels.SessionCompletionModel() - _instances[usertypes.Completion.sessions] = model - - -def _init_bind_completion(): - """Initialize the command completion model.""" - log.completion.debug("Initializing bind completion.") - model = miscmodels.BindCompletionModel() - _instances[usertypes.Completion.bind] = model - - -INITIALIZERS = { - usertypes.Completion.command: _init_command_completion, - usertypes.Completion.helptopic: _init_helptopic_completion, - usertypes.Completion.url: _init_url_completion, - usertypes.Completion.tab: _init_tab_completion, - usertypes.Completion.section: _init_setting_completions, - usertypes.Completion.option: _init_setting_completions, - usertypes.Completion.value: _init_setting_completions, - usertypes.Completion.quickmark_by_name: init_quickmark_completions, - usertypes.Completion.bookmark_by_url: init_bookmark_completions, - usertypes.Completion.sessions: init_session_completion, - usertypes.Completion.bind: _init_bind_completion, -} - - -def get(completion): - """Get a certain completion. Initializes the completion if needed.""" - try: - return _instances[completion] - except KeyError: - if completion in INITIALIZERS: - INITIALIZERS[completion]() - return _instances[completion] - else: - raise - - -def update(completions): - """Update an already existing completion. - - Args: - completions: An iterable of usertypes.Completions. - """ - did_run = [] - for completion in completions: - if completion in _instances: - func = INITIALIZERS[completion] - if func not in did_run: - func() - did_run.append(func) - - -@config.change_filter('aliases', function=True) -def _update_aliases(): - """Update completions that include command aliases.""" - update([usertypes.Completion.command]) - - -def init(): - """Initialize completions. Note this only connects signals.""" - quickmark_manager = objreg.get('quickmark-manager') - quickmark_manager.changed.connect( - functools.partial(update, [usertypes.Completion.quickmark_by_name])) - - bookmark_manager = objreg.get('bookmark-manager') - bookmark_manager.changed.connect( - functools.partial(update, [usertypes.Completion.bookmark_by_url])) - - session_manager = objreg.get('session-manager') - session_manager.update_completion.connect( - functools.partial(update, [usertypes.Completion.sessions])) - - history = objreg.get('web-history') - history.async_read_done.connect( - functools.partial(update, [usertypes.Completion.url])) - - keyconf = objreg.get('key-config') - keyconf.changed.connect( - functools.partial(update, [usertypes.Completion.command])) - keyconf.changed.connect( - functools.partial(update, [usertypes.Completion.bind])) - - objreg.get('config').changed.connect(_update_aliases) diff --git a/qutebrowser/completion/models/listcategory.py b/qutebrowser/completion/models/listcategory.py new file mode 100644 index 000000000..187ebcad6 --- /dev/null +++ b/qutebrowser/completion/models/listcategory.py @@ -0,0 +1,102 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Completion category that uses a list of tuples as a data source.""" + +import re + +from PyQt5.QtCore import Qt, QSortFilterProxyModel, QModelIndex, QRegExp +from PyQt5.QtGui import QStandardItem, QStandardItemModel + +from qutebrowser.utils import qtutils +from qutebrowser.commands import cmdexc + + +class ListCategory(QSortFilterProxyModel): + + """Expose a list of items as a category for the CompletionModel.""" + + def __init__(self, name, items, delete_func=None, parent=None): + super().__init__(parent) + self.name = name + self.srcmodel = QStandardItemModel(parent=self) + self._pattern = '' + # ListCategory filters all columns + self.columns_to_filter = [0, 1, 2] + self.setFilterKeyColumn(-1) + for item in items: + self.srcmodel.appendRow([QStandardItem(x) for x in item]) + self.setSourceModel(self.srcmodel) + self.delete_func = delete_func + + def set_pattern(self, val): + """Setter for pattern. + + Args: + val: The value to set. + """ + self._pattern = val + val = re.sub(r' +', r' ', val) # See #1919 + val = re.escape(val) + val = val.replace(r'\ ', '.*') + rx = QRegExp(val, Qt.CaseInsensitive) + self.setFilterRegExp(rx) + self.invalidate() + sortcol = 0 + self.sort(sortcol) + + def lessThan(self, lindex, rindex): + """Custom sorting implementation. + + Prefers all items which start with self._pattern. Other than that, uses + normal Python string sorting. + + Args: + lindex: The QModelIndex of the left item (*left* < right) + rindex: The QModelIndex of the right item (left < *right*) + + Return: + True if left < right, else False + """ + qtutils.ensure_valid(lindex) + qtutils.ensure_valid(rindex) + + left = self.srcmodel.data(lindex) + right = self.srcmodel.data(rindex) + + leftstart = left.startswith(self._pattern) + rightstart = right.startswith(self._pattern) + + if leftstart and rightstart: + return left < right + elif leftstart: + return True + elif rightstart: + return False + else: + return left < right + + def delete_cur_item(self, index): + """Delete the row at the given index.""" + if not self.delete_func: + raise cmdexc.CommandError("Cannot delete this item.") + data = [self.data(index.sibling(index.row(), i)) + for i in range(self.columnCount())] + self.delete_func(data) + self.removeRow(index.row(), QModelIndex()) diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py index 5ab381c43..167eccde8 100644 --- a/qutebrowser/completion/models/miscmodels.py +++ b/qutebrowser/completion/models/miscmodels.py @@ -17,252 +17,125 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Misc. CompletionModels.""" +"""Functions that return miscellaneous completion models.""" -from PyQt5.QtCore import Qt, QTimer, pyqtSlot - -from qutebrowser.browser import browsertab from qutebrowser.config import config, configdata -from qutebrowser.utils import objreg, log, qtutils +from qutebrowser.utils import objreg, log from qutebrowser.commands import cmdutils -from qutebrowser.completion.models import base +from qutebrowser.completion.models import completionmodel, listcategory -class CommandCompletionModel(base.BaseCompletionModel): - +def command(): """A CompletionModel filled with non-hidden commands and descriptions.""" - - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - COLUMN_WIDTHS = (20, 60, 20) - - def __init__(self, parent=None): - super().__init__(parent) - cmdlist = _get_cmd_completions(include_aliases=True, - include_hidden=False) - cat = self.new_category("Commands") - for (name, desc, misc) in cmdlist: - self.new_item(cat, name, desc, misc) + model = completionmodel.CompletionModel(column_widths=(20, 60, 20)) + cmdlist = _get_cmd_completions(include_aliases=True, include_hidden=False) + model.add_category(listcategory.ListCategory("Commands", cmdlist)) + return model -class HelpCompletionModel(base.BaseCompletionModel): - +def helptopic(): """A CompletionModel filled with help topics.""" + model = completionmodel.CompletionModel() - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method + cmdlist = _get_cmd_completions(include_aliases=False, include_hidden=True, + prefix=':') + settings = [] + for sectname, sectdata in configdata.DATA.items(): + for optname in sectdata: + try: + desc = sectdata.descriptions[optname] + except (KeyError, AttributeError): + # Some stuff (especially ValueList items) don't have a + # description. + desc = "" + else: + desc = desc.splitlines()[0] + name = '{}->{}'.format(sectname, optname) + settings.append((name, desc)) - COLUMN_WIDTHS = (20, 60, 20) - - def __init__(self, parent=None): - super().__init__(parent) - self._init_commands() - self._init_settings() - - def _init_commands(self): - """Fill completion with :command entries.""" - cmdlist = _get_cmd_completions(include_aliases=False, - include_hidden=True, prefix=':') - cat = self.new_category("Commands") - for (name, desc, misc) in cmdlist: - self.new_item(cat, name, desc, misc) - - def _init_settings(self): - """Fill completion with section->option entries.""" - cat = self.new_category("Settings") - for sectname, sectdata in configdata.DATA.items(): - for optname in sectdata: - try: - desc = sectdata.descriptions[optname] - except (KeyError, AttributeError): - # Some stuff (especially ValueList items) don't have a - # description. - desc = "" - else: - desc = desc.splitlines()[0] - name = '{}->{}'.format(sectname, optname) - self.new_item(cat, name, desc) + model.add_category(listcategory.ListCategory("Commands", cmdlist)) + model.add_category(listcategory.ListCategory("Settings", settings)) + return model -class QuickmarkCompletionModel(base.BaseCompletionModel): - +def quickmark(): """A CompletionModel filled with all quickmarks.""" - - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - def __init__(self, parent=None): - super().__init__(parent) - cat = self.new_category("Quickmarks") - quickmarks = objreg.get('quickmark-manager').marks.items() - for qm_name, qm_url in quickmarks: - self.new_item(cat, qm_name, qm_url) + model = completionmodel.CompletionModel(column_widths=(30, 70, 0)) + marks = objreg.get('quickmark-manager').marks.items() + model.add_category(listcategory.ListCategory('Quickmarks', marks)) + return model -class BookmarkCompletionModel(base.BaseCompletionModel): - +def bookmark(): """A CompletionModel filled with all bookmarks.""" - - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - def __init__(self, parent=None): - super().__init__(parent) - cat = self.new_category("Bookmarks") - bookmarks = objreg.get('bookmark-manager').marks.items() - for bm_url, bm_title in bookmarks: - self.new_item(cat, bm_url, bm_title) + model = completionmodel.CompletionModel(column_widths=(30, 70, 0)) + marks = objreg.get('bookmark-manager').marks.items() + model.add_category(listcategory.ListCategory('Bookmarks', marks)) + return model -class SessionCompletionModel(base.BaseCompletionModel): - +def session(): """A CompletionModel filled with session names.""" - - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - - def __init__(self, parent=None): - super().__init__(parent) - cat = self.new_category("Sessions") - try: - for name in objreg.get('session-manager').list_sessions(): - if not name.startswith('_'): - self.new_item(cat, name) - except OSError: - log.completion.exception("Failed to list sessions!") + model = completionmodel.CompletionModel() + try: + manager = objreg.get('session-manager') + sessions = ((name,) for name in manager.list_sessions() + if not name.startswith('_')) + model.add_category(listcategory.ListCategory("Sessions", sessions)) + except OSError: + log.completion.exception("Failed to list sessions!") + return model -class TabCompletionModel(base.BaseCompletionModel): - +def buffer(): """A model to complete on open tabs across all windows. Used for switching the buffer command. """ - - IDX_COLUMN = 0 - URL_COLUMN = 1 - TEXT_COLUMN = 2 - - COLUMN_WIDTHS = (6, 40, 54) - DUMB_SORT = Qt.DescendingOrder - - def __init__(self, parent=None): - super().__init__(parent) - - self.columns_to_filter = [self.IDX_COLUMN, self.URL_COLUMN, - self.TEXT_COLUMN] - - for win_id in objreg.window_registry: - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=win_id) - for i in range(tabbed_browser.count()): - tab = tabbed_browser.widget(i) - tab.url_changed.connect(self.rebuild) - tab.title_changed.connect(self.rebuild) - tab.shutting_down.connect(self.delayed_rebuild) - tabbed_browser.new_tab.connect(self.on_new_tab) - tabbed_browser.tabBar().tabMoved.connect(self.rebuild) - objreg.get("app").new_window.connect(self.on_new_window) - self.rebuild() - - def on_new_window(self, window): - """Add hooks to new windows.""" - window.tabbed_browser.new_tab.connect(self.on_new_tab) - - @pyqtSlot(browsertab.AbstractTab) - def on_new_tab(self, tab): - """Add hooks to new tabs.""" - tab.url_changed.connect(self.rebuild) - tab.title_changed.connect(self.rebuild) - tab.shutting_down.connect(self.delayed_rebuild) - self.rebuild() - - @pyqtSlot() - def delayed_rebuild(self): - """Fire a rebuild indirectly so widgets get a chance to update.""" - QTimer.singleShot(0, self.rebuild) - - @pyqtSlot() - def rebuild(self): - """Rebuild completion model from current tabs. - - Very lazy method of keeping the model up to date. We could connect to - signals for new tab, tab url/title changed, tab close, tab moved and - make sure we handled background loads too ... but iterating over a - few/few dozen/few hundred tabs doesn't take very long at all. - """ - window_count = 0 - for win_id in objreg.window_registry: - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=win_id) - if not tabbed_browser.shutting_down: - window_count += 1 - - if window_count < self.rowCount(): - self.removeRows(window_count, self.rowCount() - window_count) - - for i, win_id in enumerate(objreg.window_registry): - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=win_id) - if tabbed_browser.shutting_down: - continue - if i >= self.rowCount(): - c = self.new_category("{}".format(win_id)) - else: - c = self.item(i, 0) - c.setData("{}".format(win_id), Qt.DisplayRole) - if tabbed_browser.count() < c.rowCount(): - c.removeRows(tabbed_browser.count(), - c.rowCount() - tabbed_browser.count()) - for idx in range(tabbed_browser.count()): - tab = tabbed_browser.widget(idx) - if idx >= c.rowCount(): - self.new_item(c, "{}/{}".format(win_id, idx + 1), - tab.url().toDisplayString(), - tabbed_browser.page_title(idx)) - else: - c.child(idx, 0).setData("{}/{}".format(win_id, idx + 1), - Qt.DisplayRole) - c.child(idx, 1).setData(tab.url().toDisplayString(), - Qt.DisplayRole) - c.child(idx, 2).setData(tabbed_browser.page_title(idx), - Qt.DisplayRole) - - def delete_cur_item(self, completion): - """Delete the selected item. - - Args: - completion: The Completion object to use. - """ - index = completion.currentIndex() - qtutils.ensure_valid(index) - category = index.parent() - qtutils.ensure_valid(category) - index = category.child(index.row(), self.IDX_COLUMN) - win_id, tab_index = index.data().split('/') - + def delete_buffer(data): + """Close the selected tab.""" + win_id, tab_index = data[0].split('/') tabbed_browser = objreg.get('tabbed-browser', scope='window', window=int(win_id)) tabbed_browser.on_tab_close_requested(int(tab_index) - 1) + model = completionmodel.CompletionModel(column_widths=(6, 40, 54)) -class BindCompletionModel(base.BaseCompletionModel): + for win_id in objreg.window_registry: + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + if tabbed_browser.shutting_down: + continue + tabs = [] + for idx in range(tabbed_browser.count()): + tab = tabbed_browser.widget(idx) + tabs.append(("{}/{}".format(win_id, idx + 1), + tab.url().toDisplayString(), + tabbed_browser.page_title(idx))) + cat = listcategory.ListCategory("{}".format(win_id), tabs, + delete_func=delete_buffer) + model.add_category(cat) - """A CompletionModel filled with all bindable commands and descriptions.""" + return model - # https://github.com/qutebrowser/qutebrowser/issues/545 - # pylint: disable=abstract-method - COLUMN_WIDTHS = (20, 60, 20) +def bind(key): + """A CompletionModel filled with all bindable commands and descriptions. - def __init__(self, parent=None): - super().__init__(parent) - cmdlist = _get_cmd_completions(include_hidden=True, - include_aliases=True) - cat = self.new_category("Commands") - for (name, desc, misc) in cmdlist: - self.new_item(cat, name, desc, misc) + Args: + key: the key being bound. + """ + model = completionmodel.CompletionModel(column_widths=(20, 60, 20)) + cmd_name = objreg.get('key-config').get_bindings_for('normal').get(key) + + if cmd_name: + cmd = cmdutils.cmd_dict.get(cmd_name) + data = [(cmd_name, cmd.desc, key)] + model.add_category(listcategory.ListCategory("Current", data)) + + cmdlist = _get_cmd_completions(include_hidden=True, include_aliases=True) + model.add_category(listcategory.ListCategory("Commands", cmdlist)) + return model def _get_cmd_completions(include_hidden, include_aliases, prefix=''): diff --git a/qutebrowser/completion/models/sortfilter.py b/qutebrowser/completion/models/sortfilter.py deleted file mode 100644 index e2db88b9e..000000000 --- a/qutebrowser/completion/models/sortfilter.py +++ /dev/null @@ -1,191 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2014-2017 Florian Bruhin (The Compiler) -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see . - -"""A filtering/sorting base model for completions. - -Contains: - CompletionFilterModel -- A QSortFilterProxyModel subclass for completions. -""" - -import re - -from PyQt5.QtCore import QSortFilterProxyModel, QModelIndex, Qt - -from qutebrowser.utils import log, qtutils, debug -from qutebrowser.completion.models import base as completion - - -class CompletionFilterModel(QSortFilterProxyModel): - - """Subclass of QSortFilterProxyModel with custom sorting/filtering. - - Attributes: - pattern: The pattern to filter with. - srcmodel: The current source model. - Kept as attribute because calling `sourceModel` takes quite - a long time for some reason. - _sort_order: The order to use for sorting if using dumb_sort. - """ - - def __init__(self, source, parent=None): - super().__init__(parent) - super().setSourceModel(source) - self.srcmodel = source - self.pattern = '' - self.pattern_re = None - - dumb_sort = self.srcmodel.DUMB_SORT - if dumb_sort is None: - # pylint: disable=invalid-name - self.lessThan = self.intelligentLessThan - self._sort_order = Qt.AscendingOrder - else: - self.setSortRole(completion.Role.sort) - self._sort_order = dumb_sort - - def set_pattern(self, val): - """Setter for pattern. - - Invalidates the filter and re-sorts the model. - - Args: - val: The value to set. - """ - with debug.log_time(log.completion, 'Setting filter pattern'): - self.pattern = val - val = re.sub(r' +', r' ', val) # See #1919 - val = re.escape(val) - val = val.replace(r'\ ', '.*') - self.pattern_re = re.compile(val, re.IGNORECASE) - self.invalidate() - sortcol = 0 - self.sort(sortcol) - - def count(self): - """Get the count of non-toplevel items currently visible. - - Note this only iterates one level deep, as we only need root items - (categories) and children (items) in our model. - """ - count = 0 - for i in range(self.rowCount()): - cat = self.index(i, 0) - qtutils.ensure_valid(cat) - count += self.rowCount(cat) - return count - - def first_item(self): - """Return the first item in the model.""" - for i in range(self.rowCount()): - cat = self.index(i, 0) - qtutils.ensure_valid(cat) - if cat.model().hasChildren(cat): - index = self.index(0, 0, cat) - qtutils.ensure_valid(index) - return index - return QModelIndex() - - def last_item(self): - """Return the last item in the model.""" - for i in range(self.rowCount() - 1, -1, -1): - cat = self.index(i, 0) - qtutils.ensure_valid(cat) - if cat.model().hasChildren(cat): - index = self.index(self.rowCount(cat) - 1, 0, cat) - qtutils.ensure_valid(index) - return index - return QModelIndex() - - def setSourceModel(self, model): - """Override QSortFilterProxyModel's setSourceModel to clear pattern.""" - log.completion.debug("Setting source model: {}".format(model)) - self.set_pattern('') - super().setSourceModel(model) - self.srcmodel = model - - def filterAcceptsRow(self, row, parent): - """Custom filter implementation. - - Override QSortFilterProxyModel::filterAcceptsRow. - - Args: - row: The row of the item. - parent: The parent item QModelIndex. - - Return: - True if self.pattern is contained in item, or if it's a root item - (category). False in all other cases - """ - if parent == QModelIndex() or not self.pattern: - return True - - for col in self.srcmodel.columns_to_filter: - idx = self.srcmodel.index(row, col, parent) - if not idx.isValid(): # pragma: no cover - # this is a sanity check not hit by any test case - continue - data = self.srcmodel.data(idx) - if not data: - continue - elif self.pattern_re.search(data): - return True - return False - - def intelligentLessThan(self, lindex, rindex): - """Custom sorting implementation. - - Prefers all items which start with self.pattern. Other than that, uses - normal Python string sorting. - - Args: - lindex: The QModelIndex of the left item (*left* < right) - rindex: The QModelIndex of the right item (left < *right*) - - Return: - True if left < right, else False - """ - qtutils.ensure_valid(lindex) - qtutils.ensure_valid(rindex) - - left_sort = self.srcmodel.data(lindex, role=completion.Role.sort) - right_sort = self.srcmodel.data(rindex, role=completion.Role.sort) - - if left_sort is not None and right_sort is not None: - return left_sort < right_sort - - left = self.srcmodel.data(lindex) - right = self.srcmodel.data(rindex) - - leftstart = left.startswith(self.pattern) - rightstart = right.startswith(self.pattern) - - if leftstart and rightstart: - return left < right - elif leftstart: - return True - elif rightstart: - return False - else: - return left < right - - def sort(self, column, order=None): - """Extend sort to respect self._sort_order if no order was given.""" - if order is None: - order = self._sort_order - super().sort(column, order) diff --git a/qutebrowser/completion/models/urlmodel.py b/qutebrowser/completion/models/urlmodel.py index 98f68c08c..0c5fbeacc 100644 --- a/qutebrowser/completion/models/urlmodel.py +++ b/qutebrowser/completion/models/urlmodel.py @@ -17,176 +17,54 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""CompletionModels for URLs.""" +"""Function to return the url completion model for the `open` command.""" -import datetime - -from PyQt5.QtCore import pyqtSlot, Qt - -from qutebrowser.utils import objreg, utils, qtutils, log -from qutebrowser.completion.models import base -from qutebrowser.config import config +from qutebrowser.completion.models import (completionmodel, listcategory, + histcategory) +from qutebrowser.utils import log, objreg -class UrlCompletionModel(base.BaseCompletionModel): +_URLCOL = 0 +_TEXTCOL = 1 + +def _delete_history(data): + urlstr = data[_URLCOL] + log.completion.debug('Deleting history entry {}'.format(urlstr)) + hist = objreg.get('web-history') + hist.delete_url(urlstr) + + +def _delete_bookmark(data): + urlstr = data[_URLCOL] + log.completion.debug('Deleting bookmark {}'.format(urlstr)) + bookmark_manager = objreg.get('bookmark-manager') + bookmark_manager.delete(urlstr) + + +def _delete_quickmark(data): + name = data[_TEXTCOL] + quickmark_manager = objreg.get('quickmark-manager') + log.completion.debug('Deleting quickmark {}'.format(name)) + quickmark_manager.delete(name) + + +def url(): """A model which combines bookmarks, quickmarks and web history URLs. Used for the `open` command. """ + model = completionmodel.CompletionModel(column_widths=(40, 50, 10)) - URL_COLUMN = 0 - TEXT_COLUMN = 1 - TIME_COLUMN = 2 + quickmarks = ((url, name) for (name, url) + in objreg.get('quickmark-manager').marks.items()) + bookmarks = objreg.get('bookmark-manager').marks.items() - COLUMN_WIDTHS = (40, 50, 10) - DUMB_SORT = Qt.DescendingOrder + model.add_category(listcategory.ListCategory( + 'Quickmarks', quickmarks, delete_func=_delete_quickmark)) + model.add_category(listcategory.ListCategory( + 'Bookmarks', bookmarks, delete_func=_delete_bookmark)) - def __init__(self, parent=None): - super().__init__(parent) - - self.columns_to_filter = [self.URL_COLUMN, self.TEXT_COLUMN] - - self._quickmark_cat = self.new_category("Quickmarks") - self._bookmark_cat = self.new_category("Bookmarks") - self._history_cat = self.new_category("History") - - quickmark_manager = objreg.get('quickmark-manager') - quickmarks = quickmark_manager.marks.items() - for qm_name, qm_url in quickmarks: - self.new_item(self._quickmark_cat, qm_url, qm_name) - quickmark_manager.added.connect( - lambda name, url: self.new_item(self._quickmark_cat, url, name)) - quickmark_manager.removed.connect(self.on_quickmark_removed) - - bookmark_manager = objreg.get('bookmark-manager') - bookmarks = bookmark_manager.marks.items() - for bm_url, bm_title in bookmarks: - self.new_item(self._bookmark_cat, bm_url, bm_title) - bookmark_manager.added.connect( - lambda name, url: self.new_item(self._bookmark_cat, url, name)) - bookmark_manager.removed.connect(self.on_bookmark_removed) - - self._history = objreg.get('web-history') - self._max_history = config.get('completion', 'web-history-max-items') - history = utils.newest_slice(self._history, self._max_history) - for entry in history: - if not entry.redirect: - self._add_history_entry(entry) - self._history.add_completion_item.connect(self.on_history_item_added) - self._history.cleared.connect(self.on_history_cleared) - - objreg.get('config').changed.connect(self.reformat_timestamps) - - def _fmt_atime(self, atime): - """Format an atime to a human-readable string.""" - fmt = config.get('completion', 'timestamp-format') - if fmt is None: - return '' - try: - dt = datetime.datetime.fromtimestamp(atime) - except (ValueError, OSError, OverflowError): - # Different errors which can occur for too large values... - log.misc.error("Got invalid timestamp {}!".format(atime)) - return '(invalid)' - else: - return dt.strftime(fmt) - - def _remove_oldest_history(self): - """Remove the oldest history entry.""" - self._history_cat.removeRow(0) - - def _add_history_entry(self, entry): - """Add a new history entry to the completion.""" - self.new_item(self._history_cat, entry.url.toDisplayString(), - entry.title, - self._fmt_atime(entry.atime), sort=int(entry.atime), - userdata=entry.url) - - if (self._max_history != -1 and - self._history_cat.rowCount() > self._max_history): - self._remove_oldest_history() - - @config.change_filter('completion', 'timestamp-format') - def reformat_timestamps(self): - """Reformat the timestamps if the config option was changed.""" - for i in range(self._history_cat.rowCount()): - url_item = self._history_cat.child(i, self.URL_COLUMN) - atime_item = self._history_cat.child(i, self.TIME_COLUMN) - atime = url_item.data(base.Role.sort) - atime_item.setText(self._fmt_atime(atime)) - - @pyqtSlot(object) - def on_history_item_added(self, entry): - """Slot called when a new history item was added.""" - for i in range(self._history_cat.rowCount()): - url_item = self._history_cat.child(i, self.URL_COLUMN) - atime_item = self._history_cat.child(i, self.TIME_COLUMN) - title_item = self._history_cat.child(i, self.TEXT_COLUMN) - url = url_item.data(base.Role.userdata) - if url == entry.url: - atime_item.setText(self._fmt_atime(entry.atime)) - title_item.setText(entry.title) - url_item.setData(int(entry.atime), base.Role.sort) - break - else: - self._add_history_entry(entry) - - @pyqtSlot() - def on_history_cleared(self): - self._history_cat.removeRows(0, self._history_cat.rowCount()) - - def _remove_item(self, data, category, column): - """Helper function for on_quickmark_removed and on_bookmark_removed. - - Args: - data: The item to search for. - category: The category to search in. - column: The column to use for matching. - """ - for i in range(category.rowCount()): - item = category.child(i, column) - if item.data(Qt.DisplayRole) == data: - category.removeRow(i) - break - - @pyqtSlot(str) - def on_quickmark_removed(self, name): - """Called when a quickmark has been removed by the user. - - Args: - name: The name of the quickmark which has been removed. - """ - self._remove_item(name, self._quickmark_cat, self.TEXT_COLUMN) - - @pyqtSlot(str) - def on_bookmark_removed(self, url): - """Called when a bookmark has been removed by the user. - - Args: - url: The url of the bookmark which has been removed. - """ - self._remove_item(url, self._bookmark_cat, self.URL_COLUMN) - - def delete_cur_item(self, completion): - """Delete the selected item. - - Args: - completion: The Completion object to use. - """ - index = completion.currentIndex() - qtutils.ensure_valid(index) - category = index.parent() - index = category.child(index.row(), self.URL_COLUMN) - url = index.data() - qtutils.ensure_valid(category) - - if category.data() == 'Bookmarks': - bookmark_manager = objreg.get('bookmark-manager') - bookmark_manager.delete(url) - elif category.data() == 'Quickmarks': - quickmark_manager = objreg.get('quickmark-manager') - sibling = index.sibling(index.row(), self.TEXT_COLUMN) - qtutils.ensure_valid(sibling) - name = sibling.data() - quickmark_manager.delete(name) + hist_cat = histcategory.HistoryCategory(delete_func=_delete_history) + model.add_category(hist_cat) + return model diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 587da214f..8bae2bae0 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -38,13 +38,12 @@ from PyQt5.QtCore import pyqtSignal, QObject, QUrl, QSettings from PyQt5.QtGui import QColor from qutebrowser.config import configdata, configexc, textwrapper -from qutebrowser.config.parsers import keyconf from qutebrowser.config.parsers import ini from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.utils import (message, objreg, utils, standarddir, log, qtutils, error, usertypes) from qutebrowser.misc import objects -from qutebrowser.utils.usertypes import Completion +from qutebrowser.completion.models import configmodel UNSET = object() @@ -175,37 +174,6 @@ def _init_main_config(parent=None): return -def _init_key_config(parent): - """Initialize the key config. - - Args: - parent: The parent to use for the KeyConfigParser. - """ - args = objreg.get('args') - try: - key_config = keyconf.KeyConfigParser(standarddir.config(), 'keys.conf', - args.relaxed_config, - parent=parent) - except (keyconf.KeyConfigError, cmdexc.CommandError, - UnicodeDecodeError) as e: - log.init.exception(e) - errstr = "Error while reading key config:\n" - if e.lineno is not None: - errstr += "In line {}: ".format(e.lineno) - error.handle_fatal_exc(e, args, "Error while reading key config!", - pre_text=errstr) - # We didn't really initialize much so far, so we just quit hard. - sys.exit(usertypes.Exit.err_key_config) - else: - objreg.register('key-config', key_config) - save_manager = objreg.get('save-manager') - filename = os.path.join(standarddir.config(), 'keys.conf') - save_manager.add_saveable( - 'key-config', key_config.save, key_config.config_dirty, - config_opt=('general', 'auto-save-config'), filename=filename, - dirty=key_config.is_dirty) - - def _init_misc(): """Initialize misc. config-related files.""" save_manager = objreg.get('save-manager') @@ -249,7 +217,6 @@ def init(parent=None): parent: The parent to pass to QObjects which get initialized. """ _init_main_config(parent) - _init_key_config(parent) _init_misc() @@ -794,9 +761,9 @@ class ConfigManager(QObject): e.__class__.__name__, e)) @cmdutils.register(name='set', instance='config', star_args_optional=True) - @cmdutils.argument('section_', completion=Completion.section) - @cmdutils.argument('option', completion=Completion.option) - @cmdutils.argument('values', completion=Completion.value) + @cmdutils.argument('section_', completion=configmodel.section) + @cmdutils.argument('option', completion=configmodel.option) + @cmdutils.argument('values', completion=configmodel.value) @cmdutils.argument('win_id', win_id=True) def set_command(self, win_id, section_=None, option=None, *values, temp=False, print_=False): diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 32d80f3b7..23d3efb67 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -503,7 +503,7 @@ def data(readonly=False): "0: no history / -1: unlimited"), ('web-history-max-items', - SettingValue(typ.Int(minval=-1), '1000'), + SettingValue(typ.Int(minval=-1), '-1'), "How many URLs to show in the web history.\n\n" "0: no history / -1: unlimited"), diff --git a/qutebrowser/config/parsers/keyconf.py b/qutebrowser/config/parsers/keyconf.py index 53f23d7c0..751eafb71 100644 --- a/qutebrowser/config/parsers/keyconf.py +++ b/qutebrowser/config/parsers/keyconf.py @@ -22,12 +22,44 @@ import collections import os.path import itertools +import sys from PyQt5.QtCore import pyqtSignal, QObject from qutebrowser.config import configdata, textwrapper from qutebrowser.commands import cmdutils, cmdexc -from qutebrowser.utils import log, utils, qtutils, message, usertypes +from qutebrowser.utils import (log, utils, qtutils, message, usertypes, objreg, + standarddir, error) +from qutebrowser.completion.models import miscmodels + + +def init(parent=None): + """Read and save keybindings. + + Args: + parent: The parent to use for the KeyConfigParser. + """ + args = objreg.get('args') + try: + key_config = KeyConfigParser(standarddir.config(), 'keys.conf', + args.relaxed_config, parent=parent) + except (KeyConfigError, UnicodeDecodeError) as e: + log.init.exception(e) + errstr = "Error while reading key config:\n" + if e.lineno is not None: + errstr += "In line {}: ".format(e.lineno) + error.handle_fatal_exc(e, args, "Error while reading key config!", + pre_text=errstr) + # We didn't really initialize much so far, so we just quit hard. + sys.exit(usertypes.Exit.err_key_config) + else: + objreg.register('key-config', key_config) + save_manager = objreg.get('save-manager') + filename = os.path.join(standarddir.config(), 'keys.conf') + save_manager.add_saveable( + 'key-config', key_config.save, key_config.config_dirty, + config_opt=('general', 'auto-save-config'), filename=filename, + dirty=key_config.is_dirty) class KeyConfigError(Exception): @@ -153,7 +185,7 @@ class KeyConfigParser(QObject): @cmdutils.register(instance='key-config', maxsplit=1, no_cmd_split=True, no_replace_variables=True) - @cmdutils.argument('command', completion=usertypes.Completion.bind) + @cmdutils.argument('command', completion=miscmodels.bind) def bind(self, key, command=None, *, mode='normal', force=False): """Bind a key to a command. diff --git a/qutebrowser/javascript/history.js b/qutebrowser/javascript/history.js index f46ceb49d..26b4405e9 100644 --- a/qutebrowser/javascript/history.js +++ b/qutebrowser/javascript/history.js @@ -23,8 +23,12 @@ window.loadHistory = (function() { // Date of last seen item. var lastItemDate = null; - // The time to load next. + // Each request for new items includes the time of the last item and an + // offset. The offset is equal to the number of items from the previous + // request that had time=nextTime, and causes the next request to skip + // those items to avoid duplicates. var nextTime = null; + var nextOffset = 0; // The URL to fetch data from. var DATA_URL = "qute://history/data"; @@ -157,23 +161,28 @@ window.loadHistory = (function() { return; } - for (var i = 0, len = history.length - 1; i < len; i++) { - var item = history[i]; - var currentItemDate = new Date(item.time); - getSessionNode(currentItemDate).appendChild(makeHistoryRow( - item.url, item.title, currentItemDate.toLocaleTimeString() - )); - lastItemDate = currentItemDate; - } - - var next = history[history.length - 1].next; - if (next === -1) { + if (history.length === 0) { // Reached end of history window.onscroll = null; EOF_MESSAGE.style.display = "block"; LOAD_LINK.style.display = "none"; - } else { - nextTime = next; + return; + } + + nextTime = history[history.length - 1].time; + nextOffset = 0; + + for (var i = 0, len = history.length; i < len; i++) { + var item = history[i]; + // python's time.time returns seconds, but js Date expects ms + var currentItemDate = new Date(item.time * 1000); + getSessionNode(currentItemDate).appendChild(makeHistoryRow( + item.url, item.title, currentItemDate.toLocaleTimeString() + )); + lastItemDate = currentItemDate; + if (item.time === nextTime) { + nextOffset++; + } } } @@ -182,10 +191,11 @@ window.loadHistory = (function() { * @return {void} */ function loadHistory() { + var url = DATA_URL.concat("?offset=", nextOffset.toString()); if (nextTime === null) { - getJSON(DATA_URL, receiveHistory); + getJSON(url, receiveHistory); } else { - var url = DATA_URL.concat("?start_time=", nextTime.toString()); + url = url.concat("&start_time=", nextTime.toString()); getJSON(url, receiveHistory); } } diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index 59cf6a6bf..e43cd8891 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -338,6 +338,7 @@ def check_libraries(backend): "or Install via pip.", pip="PyYAML"), 'PyQt5.QtQml': _missing_str("PyQt5.QtQml"), + 'PyQt5.QtSql': _missing_str("PyQt5.QtSql"), } if backend == 'webengine': modules['PyQt5.QtWebEngineWidgets'] = _missing_str("QtWebEngine", diff --git a/qutebrowser/misc/lineparser.py b/qutebrowser/misc/lineparser.py index ea9d100b7..2256d9697 100644 --- a/qutebrowser/misc/lineparser.py +++ b/qutebrowser/misc/lineparser.py @@ -21,7 +21,6 @@ import os import os.path -import itertools import contextlib from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject @@ -96,7 +95,7 @@ class BaseLineParser(QObject): """ assert self._configfile is not None if self._opened: - raise IOError("Refusing to double-open AppendLineParser.") + raise IOError("Refusing to double-open LineParser.") self._opened = True try: if self._binary: @@ -133,73 +132,6 @@ class BaseLineParser(QObject): raise NotImplementedError -class AppendLineParser(BaseLineParser): - - """LineParser which reads lazily and appends data to existing one. - - Attributes: - _new_data: The data which was added in this session. - """ - - def __init__(self, configdir, fname, *, parent=None): - super().__init__(configdir, fname, binary=False, parent=parent) - self.new_data = [] - self._fileobj = None - - def __iter__(self): - if self._fileobj is None: - raise ValueError("Iterating without open() being called!") - file_iter = (line.rstrip('\n') for line in self._fileobj) - return itertools.chain(file_iter, iter(self.new_data)) - - @contextlib.contextmanager - def open(self): - """Open the on-disk history file. Needed for __iter__.""" - try: - with self._open('r') as f: - self._fileobj = f - yield - except FileNotFoundError: - self._fileobj = [] - yield - finally: - self._fileobj = None - - def get_recent(self, count=4096): - """Get the last count bytes from the underlying file.""" - with self._open('r') as f: - f.seek(0, os.SEEK_END) - size = f.tell() - try: - if size - count > 0: - offset = size - count - else: - offset = 0 - f.seek(offset) - data = f.readlines() - finally: - f.seek(0, os.SEEK_END) - return data - - def save(self): - do_save = self._prepare_save() - if not do_save: - return - with self._open('a') as f: - self._write(f, self.new_data) - self.new_data = [] - self._after_save() - - def clear(self): - do_save = self._prepare_save() - if not do_save: - return - with self._open('w'): - pass - self.new_data = [] - self._after_save() - - class LineParser(BaseLineParser): """Parser for configuration files which are simply line-based. @@ -240,7 +172,7 @@ class LineParser(BaseLineParser): def save(self): """Save the config file.""" if self._opened: - raise IOError("Refusing to double-open AppendLineParser.") + raise IOError("Refusing to double-open LineParser.") do_save = self._prepare_save() if not do_save: return diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index 26656e1ee..4fe0fe4c7 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -31,10 +31,11 @@ try: except ImportError: # pragma: no cover from yaml import SafeLoader as YamlLoader, SafeDumper as YamlDumper -from qutebrowser.utils import (standarddir, objreg, qtutils, log, usertypes, - message, utils) +from qutebrowser.utils import (standarddir, objreg, qtutils, log, message, + utils) from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.config import config +from qutebrowser.completion.models import miscmodels default = object() # Sentinel value @@ -436,7 +437,7 @@ class SessionManager(QObject): return sessions @cmdutils.register(instance='session-manager') - @cmdutils.argument('name', completion=usertypes.Completion.sessions) + @cmdutils.argument('name', completion=miscmodels.session) def session_load(self, name, clear=False, temp=False, force=False): """Load a session. @@ -464,7 +465,7 @@ class SessionManager(QObject): win.close() @cmdutils.register(name=['session-save', 'w'], instance='session-manager') - @cmdutils.argument('name', completion=usertypes.Completion.sessions) + @cmdutils.argument('name', completion=miscmodels.session) @cmdutils.argument('win_id', win_id=True) @cmdutils.argument('with_private', flag='p') def session_save(self, name: str = default, current=False, quiet=False, @@ -503,7 +504,7 @@ class SessionManager(QObject): message.info("Saved session {}.".format(name)) @cmdutils.register(instance='session-manager') - @cmdutils.argument('name', completion=usertypes.Completion.sessions) + @cmdutils.argument('name', completion=miscmodels.session) def session_delete(self, name, force=False): """Delete a session. diff --git a/qutebrowser/misc/sql.py b/qutebrowser/misc/sql.py new file mode 100644 index 000000000..a288df475 --- /dev/null +++ b/qutebrowser/misc/sql.py @@ -0,0 +1,256 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Provides access to an in-memory sqlite database.""" + +import collections + +from PyQt5.QtCore import QObject, pyqtSignal +from PyQt5.QtSql import QSqlDatabase, QSqlQuery + +from qutebrowser.utils import log + + +class SqlException(Exception): + + """Raised on an error interacting with the SQL database.""" + + pass + + +def init(db_path): + """Initialize the SQL database connection.""" + database = QSqlDatabase.addDatabase('QSQLITE') + if not database.isValid(): + raise SqlException('Failed to add database. ' + 'Are sqlite and Qt sqlite support installed?') + database.setDatabaseName(db_path) + if not database.open(): + raise SqlException("Failed to open sqlite database at {}: {}" + .format(db_path, database.lastError().text())) + + +def close(): + """Close the SQL connection.""" + QSqlDatabase.removeDatabase(QSqlDatabase.database().connectionName()) + + +def version(): + """Return the sqlite version string.""" + try: + if not QSqlDatabase.database().isOpen(): + init(':memory:') + ver = Query("select sqlite_version()").run().value() + close() + return ver + return Query("select sqlite_version()").run().value() + except SqlException as e: + return 'UNAVAILABLE ({})'.format(e) + + +class Query(QSqlQuery): + + """A prepared SQL Query.""" + + def __init__(self, querystr, forward_only=True): + """Prepare a new sql query. + + Args: + querystr: String to prepare query from. + forward_only: Optimization for queries that will only step forward. + Must be false for completion queries. + """ + super().__init__(QSqlDatabase.database()) + log.sql.debug('Preparing SQL query: "{}"'.format(querystr)) + if not self.prepare(querystr): + raise SqlException('Failed to prepare query "{}": "{}"'.format( + querystr, self.lastError().text())) + self.setForwardOnly(forward_only) + + def __iter__(self): + if not self.isActive(): + raise SqlException("Cannot iterate inactive query") + rec = self.record() + fields = [rec.fieldName(i) for i in range(rec.count())] + rowtype = collections.namedtuple('ResultRow', fields) + + while self.next(): + rec = self.record() + yield rowtype(*[rec.value(i) for i in range(rec.count())]) + + def run(self, **values): + """Execute the prepared query.""" + log.sql.debug('Running SQL query: "{}"'.format(self.lastQuery())) + for key, val in values.items(): + self.bindValue(':{}'.format(key), val) + log.sql.debug('query bindings: {}'.format(self.boundValues())) + if not self.exec_(): + raise SqlException('Failed to exec query "{}": "{}"'.format( + self.lastQuery(), self.lastError().text())) + return self + + def value(self): + """Return the result of a single-value query (e.g. an EXISTS).""" + if not self.next(): + raise SqlException("No result for single-result query") + return self.record().value(0) + + +class SqlTable(QObject): + + """Interface to a sql table. + + Attributes: + _name: Name of the SQL table this wraps. + + Signals: + changed: Emitted when the table is modified. + """ + + changed = pyqtSignal() + + def __init__(self, name, fields, constraints=None, parent=None): + """Create a new table in the sql database. + + Raises SqlException if the table already exists. + + Args: + name: Name of the table. + fields: A list of field names. + constraints: A dict mapping field names to constraint strings. + """ + super().__init__(parent) + self._name = name + + constraints = constraints or {} + column_defs = ['{} {}'.format(field, constraints.get(field, '')) + for field in fields] + q = Query("CREATE TABLE IF NOT EXISTS {name} ({column_defs})" + .format(name=name, column_defs=', '.join(column_defs))) + + q.run() + + def create_index(self, name, field): + """Create an index over this table. + + Args: + name: Name of the index, should be unique. + field: Name of the field to index. + """ + q = Query("CREATE INDEX IF NOT EXISTS {name} ON {table} ({field})" + .format(name=name, table=self._name, field=field)) + q.run() + + def __iter__(self): + """Iterate rows in the table.""" + q = Query("SELECT * FROM {table}".format(table=self._name)) + q.run() + return iter(q) + + def contains_query(self, field): + """Return a prepared query that checks for the existence of an item. + + Args: + field: Field to match. + """ + return Query( + "SELECT EXISTS(SELECT * FROM {table} WHERE {field} = :val)" + .format(table=self._name, field=field)) + + def __len__(self): + """Return the count of rows in the table.""" + q = Query("SELECT count(*) FROM {table}".format(table=self._name)) + q.run() + return q.value() + + def delete(self, field, value): + """Remove all rows for which `field` equals `value`. + + Args: + field: Field to use as the key. + value: Key value to delete. + + Return: + The number of rows deleted. + """ + q = Query("DELETE FROM {table} where {field} = :val" + .format(table=self._name, field=field)) + q.run(val=value) + if not q.numRowsAffected(): + raise KeyError('No row with {} = "{}"'.format(field, value)) + self.changed.emit() + + def _insert_query(self, values, replace): + params = ', '.join(':{}'.format(key) for key in values) + verb = "REPLACE" if replace else "INSERT" + return Query("{verb} INTO {table} ({columns}) values({params})".format( + verb=verb, table=self._name, columns=', '.join(values), + params=params)) + + def insert(self, values, replace=False): + """Append a row to the table. + + Args: + values: A dict with a value to insert for each field name. + replace: If set, replace existing values. + """ + q = self._insert_query(values, replace) + q.run(**values) + self.changed.emit() + + def insert_batch(self, values, replace=False): + """Performantly append multiple rows to the table. + + Args: + values: A dict with a list of values to insert for each field name. + replace: If true, overwrite rows with a primary key match. + """ + q = self._insert_query(values, replace) + for key, val in values.items(): + q.bindValue(':{}'.format(key), val) + + db = QSqlDatabase.database() + db.transaction() + if not q.execBatch(): + raise SqlException('Failed to exec query "{}": "{}"'.format( + q.lastQuery(), q.lastError().text())) + db.commit() + self.changed.emit() + + def delete_all(self): + """Remove all rows from the table.""" + Query("DELETE FROM {table}".format(table=self._name)).run() + self.changed.emit() + + def select(self, sort_by, sort_order, limit=-1): + """Prepare, run, and return a select statement on this table. + + Args: + sort_by: name of column to sort by. + sort_order: 'asc' or 'desc'. + limit: max number of rows in result, defaults to -1 (unlimited). + + Return: A prepared and executed select query. + """ + q = Query("SELECT * FROM {table} ORDER BY {sort_by} {sort_order} " + "LIMIT :limit" + .format(table=self._name, sort_by=sort_by, + sort_order=sort_order)) + q.run(limit=limit) + return q diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index 41b44de1f..d1771c212 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -170,8 +170,15 @@ def debug_cache_stats(): """Print LRU cache stats.""" config_info = objreg.get('config').get.cache_info() style_info = style.get_stylesheet.cache_info() + try: + from PyQt5.QtWebKit import QWebHistoryInterface + interface = QWebHistoryInterface.defaultInterface() + history_info = interface.historyContains.cache_info() + except ImportError: + history_info = None log.misc.debug('config: {}'.format(config_info)) log.misc.debug('style: {}'.format(style_info)) + log.misc.debug('history: {}'.format(history_info)) @cmdutils.register(debug=True) diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index c2abbfb87..6cdc61f41 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -94,7 +94,7 @@ LOGGER_NAMES = [ 'commands', 'signals', 'downloads', 'js', 'qt', 'rfc6266', 'ipc', 'shlexer', 'save', 'message', 'config', 'sessions', - 'webelem', 'prompt', 'network' + 'webelem', 'prompt', 'network', 'sql' ] @@ -141,6 +141,7 @@ sessions = logging.getLogger('sessions') webelem = logging.getLogger('webelem') prompt = logging.getLogger('prompt') network = logging.getLogger('network') +sql = logging.getLogger('sql') ram_handler = None diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 7d31ba6ac..31f2f79cb 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -236,13 +236,6 @@ KeyMode = enum('KeyMode', ['normal', 'hint', 'command', 'yesno', 'prompt', 'jump_mark', 'record_macro', 'run_macro']) -# Available command completions -Completion = enum('Completion', ['command', 'section', 'option', 'value', - 'helptopic', 'quickmark_by_name', - 'bookmark_by_url', 'url', 'tab', 'sessions', - 'bind']) - - # Exit statuses for errors. Needs to be an int for sys.exit. Exit = enum('Exit', ['ok', 'reserved', 'exception', 'err_ipc', 'err_init', 'err_config', 'err_key_config'], is_int=True, start=0) diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index c85c53f25..b9aa86f20 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -28,7 +28,6 @@ import os.path import collections import functools import contextlib -import itertools import socket import shlex @@ -737,25 +736,6 @@ def sanitize_filename(name, replacement='_'): return name -def newest_slice(iterable, count): - """Get an iterable for the n newest items of the given iterable. - - Args: - count: How many elements to get. - 0: get no items: - n: get the n newest items - -1: get all items - """ - if count < -1: - raise ValueError("count can't be smaller than -1!") - elif count == 0: - return [] - elif count == -1 or len(iterable) < count: - return iterable - else: - return itertools.islice(iterable, len(iterable) - count, len(iterable)) - - def set_clipboard(data, selection=False): """Set the clipboard to some given data.""" if selection and not supports_selection(): diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index dd36f381c..fe3e1aedb 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -45,7 +45,7 @@ except ImportError: # pragma: no cover import qutebrowser from qutebrowser.utils import log, utils, standarddir, usertypes, qtutils -from qutebrowser.misc import objects, earlyinit +from qutebrowser.misc import objects, earlyinit, sql from qutebrowser.browser import pdfjs @@ -328,6 +328,7 @@ def version(): lines += [ 'pdf.js: {}'.format(_pdfjs_version()), + 'sqlite: {}'.format(sql.version()), 'QtNetwork SSL: {}\n'.format(QSslSocket.sslLibraryVersionString() if QSslSocket.supportsSsl() else 'no'), ] diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index b03fb7ef4..e22d3a155 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -51,9 +51,9 @@ PERFECT_FILES = [ 'browser/webkit/cache.py'), ('tests/unit/browser/webkit/test_cookies.py', 'browser/webkit/cookies.py'), - ('tests/unit/browser/webkit/test_history.py', + ('tests/unit/browser/test_history.py', 'browser/history.py'), - ('tests/unit/browser/webkit/test_history.py', + ('tests/unit/browser/test_history.py', 'browser/webkit/webkithistory.py'), ('tests/unit/browser/webkit/http/test_http.py', 'browser/webkit/http.py'), @@ -157,9 +157,11 @@ PERFECT_FILES = [ 'utils/javascript.py'), ('tests/unit/completion/test_models.py', - 'completion/models/base.py'), - ('tests/unit/completion/test_sortfilter.py', - 'completion/models/sortfilter.py'), + 'completion/models/urlmodel.py'), + ('tests/unit/completion/test_histcategory.py', + 'completion/models/histcategory.py'), + ('tests/unit/completion/test_listcategory.py', + 'completion/models/listcategory.py'), ] diff --git a/scripts/dev/ci/travis_install.sh b/scripts/dev/ci/travis_install.sh index 9bcb5e07c..53bcf06e8 100644 --- a/scripts/dev/ci/travis_install.sh +++ b/scripts/dev/ci/travis_install.sh @@ -109,7 +109,7 @@ elif [[ $TRAVIS_OS_NAME == osx ]]; then exit 0 fi -pyqt_pkgs="python3-pyqt5 python3-pyqt5.qtquick python3-pyqt5.qtwebkit" +pyqt_pkgs="python3-pyqt5 python3-pyqt5.qtquick python3-pyqt5.qtwebkit python3-pyqt5.qtsql libqt5sql5-sqlite" pip_install pip pip_install -r misc/requirements/requirements-tox.txt diff --git a/tests/end2end/features/completion.feature b/tests/end2end/features/completion.feature index b6c62336c..e93518199 100644 --- a/tests/end2end/features/completion.feature +++ b/tests/end2end/features/completion.feature @@ -32,23 +32,23 @@ Feature: Using completion Scenario: Using command completion When I run :set-cmd-text : - Then the completion model should be CommandCompletionModel + Then the completion model should be command Scenario: Using help completion When I run :set-cmd-text -s :help - Then the completion model should be HelpCompletionModel + Then the completion model should be helptopic Scenario: Using quickmark completion When I run :set-cmd-text -s :quickmark-load - Then the completion model should be QuickmarkCompletionModel + Then the completion model should be quickmark Scenario: Using bookmark completion When I run :set-cmd-text -s :bookmark-load - Then the completion model should be BookmarkCompletionModel + Then the completion model should be bookmark Scenario: Using bind completion When I run :set-cmd-text -s :bind X - Then the completion model should be BindCompletionModel + Then the completion model should be bind Scenario: Using session completion Given I open data/hello.txt @@ -62,37 +62,11 @@ Feature: Using completion Scenario: Using option completion When I run :set-cmd-text -s :set colors - Then the completion model should be SettingOptionCompletionModel + Then the completion model should be option Scenario: Using value completion When I run :set-cmd-text -s :set colors statusbar.bg - Then the completion model should be SettingValueCompletionModel - - Scenario: Updating the completion in realtime - Given I have a fresh instance - And I set completion -> quick-complete to false - When I open data/hello.txt - And I run :set-cmd-text -s :buffer - And I run :completion-item-focus next - And I open data/hello2.txt in a new background tab - And I run :completion-item-focus next - And I open data/hello3.txt in a new background tab - And I run :completion-item-focus next - And I run :command-accept - Then the following tabs should be open: - - data/hello.txt - - data/hello2.txt - - data/hello3.txt (active) - - Scenario: Updating the value completion in realtime - Given I set colors -> statusbar.bg to green - When I run :set-cmd-text -s :set colors statusbar.bg - And I set colors -> statusbar.bg to yellow - And I run :completion-item-focus next - And I run :completion-item-focus next - And I set colors -> statusbar.bg to red - And I run :command-accept - Then colors -> statusbar.bg should be yellow + Then the completion model should be value Scenario: Deleting an open tab via the completion Given I have a fresh instance diff --git a/tests/end2end/features/history.feature b/tests/end2end/features/history.feature index 2d02c518a..9317bde64 100644 --- a/tests/end2end/features/history.feature +++ b/tests/end2end/features/history.feature @@ -11,44 +11,44 @@ Feature: Page history Scenario: Simple history saving When I open data/numbers/1.txt And I open data/numbers/2.txt - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/numbers/1.txt http://localhost:(port)/data/numbers/2.txt - + Scenario: History item with title When I open data/title.html - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/title.html Test title Scenario: History item with redirect When I open redirect-to?url=data/title.html without waiting And I wait until data/title.html is loaded - Then the history file should contain: + Then the history should contain: r http://localhost:(port)/redirect-to?url=data/title.html Test title http://localhost:(port)/data/title.html Test title - + Scenario: History item with spaces in URL When I open data/title with spaces.html - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/title%20with%20spaces.html Test title Scenario: History item with umlauts When I open data/äöü.html - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/%C3%A4%C3%B6%C3%BC.html Chäschüechli - + @flaky @qtwebengine_todo: Error page message is not implemented Scenario: History with an error When I run :open file:///does/not/exist And I wait for "Error while loading file:///does/not/exist: Error opening /does/not/exist: *" in the log - Then the history file should contain: + Then the history should contain: file:///does/not/exist Error loading page: file:///does/not/exist @qtwebengine_todo: Error page message is not implemented Scenario: History with a 404 When I open status/404 without waiting And I wait for "Error while loading http://localhost:*/status/404: NOT FOUND" in the log - Then the history file should contain: + Then the history should contain: http://localhost:(port)/status/404 Error loading page: http://localhost:(port)/status/404 Scenario: History with invalid URL @@ -61,32 +61,32 @@ Feature: Page history When I open data/data_link.html And I run :click-element id link And I wait until data:;base64,cXV0ZWJyb3dzZXI= is loaded - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/data_link.html data: link Scenario: History with view-source URL When I open data/title.html And I run :view-source And I wait for "Changing title for idx * to 'Source for http://localhost:*/data/title.html'" in the log - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/title.html Test title Scenario: Clearing history When I open data/title.html And I run :history-clear --force - Then the history file should be empty + Then the history should be empty Scenario: Clearing history with confirmation When I open data/title.html And I run :history-clear And I wait for "Asking question <* title='Clear all browsing history?'>, *" in the log And I run :prompt-accept yes - Then the history file should be empty + Then the history should be empty Scenario: History with yanked URL and 'add to history' flag When I open data/hints/html/simple.html And I hint with args "--add-history links yank" and follow a - Then the history file should contain: + Then the history should contain: http://localhost:(port)/data/hints/html/simple.html Simple link http://localhost:(port)/data/hello.txt diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index ca4fe5a3d..bf2b05697 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -702,3 +702,9 @@ Feature: Various utility commands. And I wait for "Renderer process was killed" in the log And I open data/numbers/3.txt Then no crash should happen + + ## Other + + Scenario: Open qute://version + When I open qute://version + Then the page should contain the plaintext "Version info" diff --git a/tests/end2end/features/test_completion_bdd.py b/tests/end2end/features/test_completion_bdd.py index f4ada848f..82e2df030 100644 --- a/tests/end2end/features/test_completion_bdd.py +++ b/tests/end2end/features/test_completion_bdd.py @@ -24,5 +24,5 @@ bdd.scenarios('completion.feature') @bdd.then(bdd.parsers.parse("the completion model should be {model}")) def check_model(quteproc, model): """Make sure the completion model was set to something.""" - pattern = "Setting completion model to {} with pattern *".format(model) + pattern = "Starting {} completion *".format(model) quteproc.wait_for(message=pattern) diff --git a/tests/end2end/features/test_history_bdd.py b/tests/end2end/features/test_history_bdd.py index 1fee533eb..319e36aee 100644 --- a/tests/end2end/features/test_history_bdd.py +++ b/tests/end2end/features/test_history_bdd.py @@ -17,36 +17,29 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -import os.path +import logging +import re import pytest_bdd as bdd + bdd.scenarios('history.feature') -@bdd.then(bdd.parsers.parse("the history file should contain:\n{expected}")) -def check_history(quteproc, httpbin, expected): - history_file = os.path.join(quteproc.basedir, 'data', 'history') - quteproc.send_cmd(':save history') - quteproc.wait_for(message=':save saved history') +@bdd.then(bdd.parsers.parse("the history should contain:\n{expected}")) +def check_history(quteproc, httpbin, tmpdir, expected): + path = tmpdir / 'history' + quteproc.send_cmd(':debug-dump-history "{}"'.format(path)) + quteproc.wait_for(category='message', loglevel=logging.INFO, + message='Dumped history to {}'.format(path)) - expected = expected.replace('(port)', str(httpbin.port)).splitlines() + with path.open('r', encoding='utf-8') as f: + # ignore access times, they will differ in each run + actual = '\n'.join(re.sub('^\\d+-?', '', line).strip() for line in f) - with open(history_file, 'r', encoding='utf-8') as f: - lines = [] - for line in f: - if not line.strip(): - continue - print('history line: ' + line) - atime, line = line.split(' ', maxsplit=1) - line = line.rstrip() - if '-' in atime: - flags = atime.split('-')[1] - line = '{} {}'.format(flags, line) - lines.append(line) - - assert lines == expected + expected = expected.replace('(port)', str(httpbin.port)) + assert actual == expected -@bdd.then("the history file should be empty") -def check_history_empty(quteproc, httpbin): - check_history(quteproc, httpbin, '') +@bdd.then("the history should be empty") +def check_history_empty(quteproc, httpbin, tmpdir): + check_history(quteproc, httpbin, tmpdir, '') diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index 3af7195f5..74c63f251 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -41,7 +41,7 @@ import helpers.stubs as stubsmod from qutebrowser.config import config from qutebrowser.utils import objreg, standarddir from qutebrowser.browser.webkit import cookies -from qutebrowser.misc import savemanager +from qutebrowser.misc import savemanager, sql from qutebrowser.keyinput import modeman from PyQt5.QtCore import PYQT_VERSION, pyqtSignal, QEvent, QSize, Qt, QObject @@ -257,18 +257,9 @@ def bookmark_manager_stub(stubs): objreg.delete('bookmark-manager') -@pytest.fixture -def web_history_stub(stubs): - """Fixture which provides a fake web-history object.""" - stub = stubs.WebHistoryStub() - objreg.register('web-history', stub) - yield stub - objreg.delete('web-history') - - @pytest.fixture def session_manager_stub(stubs): - """Fixture which provides a fake web-history object.""" + """Fixture which provides a fake session-manager object.""" stub = stubs.SessionManagerStub() objreg.register('session-manager', stub) yield stub @@ -482,3 +473,37 @@ def short_tmpdir(): """A short temporary directory for a XDG_RUNTIME_DIR.""" with tempfile.TemporaryDirectory() as tdir: yield py.path.local(tdir) # pylint: disable=no-member + + +@pytest.fixture +def init_sql(data_tmpdir): + """Initialize the SQL module, and shut it down after the test.""" + path = str(data_tmpdir / 'test.db') + sql.init(path) + yield + sql.close() + + +class ModelValidator: + + """Validates completion models.""" + + def __init__(self, modeltester): + modeltester.data_display_may_return_none = True + self._model = None + self._modeltester = modeltester + + def set_model(self, model): + self._model = model + self._modeltester.check(model) + + def validate(self, expected): + assert self._model.rowCount() == len(expected) + for row, items in enumerate(expected): + for col, item in enumerate(items): + assert self._model.data(self._model.index(row, col)) == item + + +@pytest.fixture +def model_validator(qtmodeltester): + return ModelValidator(qtmodeltester) diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 368fe33e0..c0b1e0abe 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -29,7 +29,7 @@ from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache, QNetworkCacheMetaData) from PyQt5.QtWidgets import QCommonStyle, QLineEdit, QWidget, QTabBar -from qutebrowser.browser import browsertab, history +from qutebrowser.browser import browsertab from qutebrowser.config import configexc from qutebrowser.utils import usertypes, utils from qutebrowser.mainwindow import mainwindow @@ -405,6 +405,10 @@ class InstaTimer(QObject): def setInterval(self, interval): pass + @staticmethod + def singleShot(_interval, fun): + fun() + class FakeConfigType: @@ -538,24 +542,6 @@ class QuickmarkManagerStub(UrlMarkManagerStub): self.delete(key) -class WebHistoryStub(QObject): - - """Stub for the web-history object.""" - - add_completion_item = pyqtSignal(history.Entry) - cleared = pyqtSignal() - - def __init__(self, parent=None): - super().__init__(parent) - self.history_dict = collections.OrderedDict() - - def __iter__(self): - return iter(self.history_dict.values()) - - def __len__(self): - return len(self.history_dict) - - class HostBlockerStub: """Stub for the host-blocker object.""" diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py new file mode 100644 index 000000000..81637f3d4 --- /dev/null +++ b/tests/unit/browser/test_history.py @@ -0,0 +1,349 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016-2017 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Tests for the global page history.""" + +import logging + +import pytest +from PyQt5.QtCore import QUrl + +from qutebrowser.browser import history +from qutebrowser.utils import objreg, urlutils, usertypes +from qutebrowser.commands import cmdexc + + +@pytest.fixture(autouse=True) +def prerequisites(config_stub, fake_save_manager, init_sql): + """Make sure everything is ready to initialize a WebHistory.""" + config_stub.data = {'general': {'private-browsing': False}} + + +@pytest.fixture() +def hist(tmpdir): + return history.WebHistory() + + +@pytest.fixture() +def mock_time(mocker): + m = mocker.patch('qutebrowser.browser.history.time') + m.time.return_value = 12345 + return 12345 + + +def test_iter(hist): + urlstr = 'http://www.example.com/' + url = QUrl(urlstr) + hist.add_url(url, atime=12345) + + assert list(hist) == [(urlstr, '', 12345, False)] + + +def test_len(hist): + assert len(hist) == 0 + + url = QUrl('http://www.example.com/') + hist.add_url(url) + + assert len(hist) == 1 + + +def test_contains(hist): + hist.add_url(QUrl('http://www.example.com/'), title='Title', atime=12345) + assert 'http://www.example.com/' in hist + assert 'www.example.com' not in hist + assert 'Title' not in hist + assert 12345 not in hist + + +def test_get_recent(hist): + hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890) + hist.add_url(QUrl('http://example.com/'), atime=12345) + assert list(hist.get_recent()) == [ + ('http://www.qutebrowser.org/', '', 67890, False), + ('http://example.com/', '', 12345, False), + ] + + +def test_entries_between(hist): + hist.add_url(QUrl('http://www.example.com/1'), atime=12345) + hist.add_url(QUrl('http://www.example.com/2'), atime=12346) + hist.add_url(QUrl('http://www.example.com/3'), atime=12347) + hist.add_url(QUrl('http://www.example.com/4'), atime=12348) + hist.add_url(QUrl('http://www.example.com/5'), atime=12348) + hist.add_url(QUrl('http://www.example.com/6'), atime=12349) + hist.add_url(QUrl('http://www.example.com/7'), atime=12350) + + times = [x.atime for x in hist.entries_between(12346, 12349)] + assert times == [12349, 12348, 12348, 12347] + + +def test_entries_before(hist): + hist.add_url(QUrl('http://www.example.com/1'), atime=12346) + hist.add_url(QUrl('http://www.example.com/2'), atime=12346) + hist.add_url(QUrl('http://www.example.com/3'), atime=12347) + hist.add_url(QUrl('http://www.example.com/4'), atime=12348) + hist.add_url(QUrl('http://www.example.com/5'), atime=12348) + hist.add_url(QUrl('http://www.example.com/6'), atime=12348) + hist.add_url(QUrl('http://www.example.com/7'), atime=12349) + hist.add_url(QUrl('http://www.example.com/8'), atime=12349) + + times = [x.atime for x in hist.entries_before(12348, limit=3, offset=2)] + assert times == [12348, 12347, 12346] + + +def test_clear(qtbot, tmpdir, hist, mocker): + hist.add_url(QUrl('http://example.com/')) + hist.add_url(QUrl('http://www.qutebrowser.org/')) + + m = mocker.patch('qutebrowser.browser.history.message.confirm_async', + new=mocker.Mock, spec=[]) + hist.clear() + assert m.called + + +def test_clear_force(qtbot, tmpdir, hist): + hist.add_url(QUrl('http://example.com/')) + hist.add_url(QUrl('http://www.qutebrowser.org/')) + hist.clear(force=True) + assert not len(hist) + assert not len(hist.completion) + + +def test_delete_url(hist): + hist.add_url(QUrl('http://example.com/'), atime=0) + hist.add_url(QUrl('http://example.com/1'), atime=0) + hist.add_url(QUrl('http://example.com/2'), atime=0) + + before = set(hist) + completion_before = set(hist.completion) + + hist.delete_url(QUrl('http://example.com/1')) + + diff = before.difference(set(hist)) + assert diff == {('http://example.com/1', '', 0, False)} + + completion_diff = completion_before.difference(set(hist.completion)) + assert completion_diff == {('http://example.com/1', '', 0)} + + +@pytest.mark.parametrize('url, atime, title, redirect, expected_url', [ + ('http://www.example.com', 12346, 'the title', False, + 'http://www.example.com'), + ('http://www.example.com', 12346, 'the title', True, + 'http://www.example.com'), + ('http://www.example.com/spa ce', 12346, 'the title', False, + 'http://www.example.com/spa%20ce'), + ('https://user:pass@example.com', 12346, 'the title', False, + 'https://user@example.com'), +]) +def test_add_url(qtbot, hist, url, atime, title, redirect, expected_url): + hist.add_url(QUrl(url), atime=atime, title=title, redirect=redirect) + assert list(hist) == [(expected_url, title, atime, redirect)] + if redirect: + assert not len(hist.completion) + else: + assert list(hist.completion) == [(expected_url, title, atime)] + + +def test_add_url_invalid(qtbot, hist, caplog): + with caplog.at_level(logging.WARNING): + hist.add_url(QUrl()) + assert not list(hist) + assert not list(hist.completion) + + +@pytest.mark.parametrize('level, url, req_url, expected', [ + (logging.DEBUG, 'a.com', 'a.com', [('a.com', 'title', 12345, False)]), + (logging.DEBUG, 'a.com', 'b.com', [('a.com', 'title', 12345, False), + ('b.com', 'title', 12345, True)]), + (logging.WARNING, 'a.com', '', [('a.com', 'title', 12345, False)]), + (logging.WARNING, '', '', []), + (logging.WARNING, 'data:foo', '', []), + (logging.WARNING, 'a.com', 'data:foo', []), +]) +def test_add_from_tab(hist, level, url, req_url, expected, mock_time, caplog): + with caplog.at_level(level): + hist.add_from_tab(QUrl(url), QUrl(req_url), 'title') + assert set(hist) == set(expected) + + +@pytest.fixture +def hist_interface(hist): + # pylint: disable=invalid-name + QtWebKit = pytest.importorskip('PyQt5.QtWebKit') + from qutebrowser.browser.webkit import webkithistory + QWebHistoryInterface = QtWebKit.QWebHistoryInterface + # pylint: enable=invalid-name + hist.add_url(url=QUrl('http://www.example.com/'), title='example') + interface = webkithistory.WebHistoryInterface(hist) + QWebHistoryInterface.setDefaultInterface(interface) + yield + QWebHistoryInterface.setDefaultInterface(None) + + +def test_history_interface(qtbot, webview, hist_interface): + html = b"foo" + url = urlutils.data_url('text/html', html) + with qtbot.waitSignal(webview.loadFinished): + webview.load(url) + + +@pytest.fixture +def cleanup_init(): + # prevent test_init from leaking state + yield + hist = objreg.get('web-history', None) + if hist is not None: + hist.setParent(None) + objreg.delete('web-history') + try: + from PyQt5.QtWebKit import QWebHistoryInterface + QWebHistoryInterface.setDefaultInterface(None) + except ImportError: + pass + + +@pytest.mark.parametrize('backend', [usertypes.Backend.QtWebEngine, + usertypes.Backend.QtWebKit]) +def test_init(backend, qapp, tmpdir, monkeypatch, cleanup_init): + if backend == usertypes.Backend.QtWebKit: + pytest.importorskip('PyQt5.QtWebKitWidgets') + else: + assert backend == usertypes.Backend.QtWebEngine + + monkeypatch.setattr(history.objects, 'backend', backend) + history.init(qapp) + hist = objreg.get('web-history') + assert hist.parent() is qapp + + try: + from PyQt5.QtWebKit import QWebHistoryInterface + except ImportError: + QWebHistoryInterface = None + + if backend == usertypes.Backend.QtWebKit: + default_interface = QWebHistoryInterface.defaultInterface() + assert default_interface._history is hist + else: + assert backend == usertypes.Backend.QtWebEngine + if QWebHistoryInterface is None: + default_interface = None + else: + default_interface = QWebHistoryInterface.defaultInterface() + # For this to work, nothing can ever have called setDefaultInterface + # before (so we need to test webengine before webkit) + assert default_interface is None + + +def test_import_txt(hist, data_tmpdir, monkeypatch, stubs): + monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer) + histfile = data_tmpdir / 'history' + # empty line is deliberate, to test skipping empty lines + histfile.write('''12345 http://example.com/ title + 12346 http://qutebrowser.org/ + 67890 http://example.com/path + + 68891-r http://example.com/path/other ''') + + hist.import_txt() + + assert list(hist) == [ + ('http://example.com/', 'title', 12345, False), + ('http://qutebrowser.org/', '', 12346, False), + ('http://example.com/path', '', 67890, False), + ('http://example.com/path/other', '', 68891, True) + ] + + assert not histfile.exists() + assert (data_tmpdir / 'history.bak').exists() + + +@pytest.mark.parametrize('line', [ + '', + '#12345 http://example.com/commented', + + # https://bugreports.qt.io/browse/QTBUG-60364 + '12345 http://.com/', + '12345 https://www..com/', + + # issue #2646 + '12345 data:text/html;charset=UTF-8,%3C%21DOCTYPE%20html%20PUBLIC%20%22-', +]) +def test_import_txt_skip(hist, data_tmpdir, line, monkeypatch, stubs): + """import_txt should skip certain lines silently.""" + monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer) + histfile = data_tmpdir / 'history' + histfile.write(line) + + hist.import_txt() + + assert not histfile.exists() + assert not len(hist) + + +@pytest.mark.parametrize('line', [ + 'xyz http://example.com/bad-timestamp', + '12345', + 'http://example.com/no-timestamp', + '68891-r-r http://example.com/double-flag', + '68891-x http://example.com/bad-flag', + '68891 http://.com', +]) +def test_import_txt_invalid(hist, data_tmpdir, line, monkeypatch, stubs, + caplog): + """import_txt should fail on certain lines.""" + monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer) + histfile = data_tmpdir / 'history' + histfile.write(line) + + with caplog.at_level(logging.ERROR): + hist.import_txt() + + assert any(rec.msg.startswith("Failed to import history:") + for rec in caplog.records) + + assert histfile.exists() + + +def test_import_txt_nonexistent(hist, data_tmpdir, monkeypatch, stubs): + """import_txt should do nothing if the history file doesn't exist.""" + monkeypatch.setattr(history, 'QTimer', stubs.InstaTimer) + hist.import_txt() + + +def test_debug_dump_history(hist, tmpdir): + hist.add_url(QUrl('http://example.com/1'), title="Title1", atime=12345) + hist.add_url(QUrl('http://example.com/2'), title="Title2", atime=12346) + hist.add_url(QUrl('http://example.com/3'), title="Title3", atime=12347) + hist.add_url(QUrl('http://example.com/4'), title="Title4", atime=12348, + redirect=True) + histfile = tmpdir / 'history' + hist.debug_dump_history(str(histfile)) + expected = ['12345 http://example.com/1 Title1', + '12346 http://example.com/2 Title2', + '12347 http://example.com/3 Title3', + '12348-r http://example.com/4 Title4'] + assert histfile.read() == '\n'.join(expected) + + +def test_debug_dump_history_nonexistent(hist, tmpdir): + histfile = tmpdir / 'nonexistent' / 'history' + with pytest.raises(cmdexc.CommandError): + hist.debug_dump_history(str(histfile)) diff --git a/tests/unit/browser/test_qutescheme.py b/tests/unit/browser/test_qutescheme.py index 92ad30574..693b2607c 100644 --- a/tests/unit/browser/test_qutescheme.py +++ b/tests/unit/browser/test_qutescheme.py @@ -89,16 +89,17 @@ class TestHistoryHandler: items = [] for i in range(entry_count): entry_atime = now - i * interval - entry = history.Entry(atime=str(entry_atime), - url=QUrl("www.x.com/" + str(i)), title="Page " + str(i)) + entry = {"atime": str(entry_atime), + "url": QUrl("www.x.com/" + str(i)), + "title": "Page " + str(i)} items.insert(0, entry) return items @pytest.fixture - def fake_web_history(self, fake_save_manager, tmpdir): + def fake_web_history(self, fake_save_manager, tmpdir, init_sql): """Create a fake web-history and register it into objreg.""" - web_history = history.WebHistory(tmpdir.dirname, 'fake-history') + web_history = history.WebHistory() objreg.register('web-history', web_history) yield web_history objreg.delete('web-history') @@ -107,8 +108,7 @@ class TestHistoryHandler: def fake_history(self, fake_web_history, entries): """Create fake history.""" for item in entries: - fake_web_history._add_entry(item) - fake_web_history.save() + fake_web_history.add_url(**item) @pytest.mark.parametrize("start_time_offset, expected_item_count", [ (0, 4), @@ -123,45 +123,25 @@ class TestHistoryHandler: url = QUrl("qute://history/data?start_time=" + str(start_time)) _mimetype, data = qutescheme.qute_history(url) items = json.loads(data) - items = [item for item in items if 'time' in item] # skip 'next' item assert len(items) == expected_item_count # test times end_time = start_time - 24*60*60 for item in items: - assert item['time'] <= start_time * 1000 - assert item['time'] > end_time * 1000 - - @pytest.mark.parametrize("start_time_offset, next_time", [ - (0, 24*60*60), - (24*60*60, 48*60*60), - (48*60*60, -1), - (72*60*60, -1) - ]) - def test_qutehistory_next(self, start_time_offset, next_time, now): - """Ensure qute://history/data returns correct items.""" - start_time = now - start_time_offset - url = QUrl("qute://history/data?start_time=" + str(start_time)) - _mimetype, data = qutescheme.qute_history(url) - items = json.loads(data) - items = [item for item in items if 'next' in item] # 'next' items - assert len(items) == 1 - - if next_time == -1: - assert items[0]["next"] == -1 - else: - assert items[0]["next"] == now - next_time + assert item['time'] <= start_time + assert item['time'] > end_time def test_qute_history_benchmark(self, fake_web_history, benchmark, now): - # items must be earliest-first to ensure history is sorted properly - for t in range(100000, 0, -1): # one history per second - entry = history.Entry( - atime=str(now - t), - url=QUrl('www.x.com/{}'.format(t)), - title='x at {}'.format(t)) - fake_web_history._add_entry(entry) + r = range(100000) + entries = { + 'atime': [int(now - t) for t in r], + 'url': ['www.x.com/{}'.format(t) for t in r], + 'title': ['x at {}'.format(t) for t in r], + 'redirect': [False for _ in r], + } + fake_web_history.insert_batch(entries) url = QUrl("qute://history/data?start_time={}".format(now)) _mimetype, data = benchmark(qutescheme.qute_history, url) assert len(json.loads(data)) > 1 diff --git a/tests/unit/browser/webkit/test_history.py b/tests/unit/browser/webkit/test_history.py deleted file mode 100644 index f40e41c2c..000000000 --- a/tests/unit/browser/webkit/test_history.py +++ /dev/null @@ -1,383 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2016-2017 Florian Bruhin (The Compiler) -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see . - -"""Tests for the global page history.""" - -import logging - -import pytest -import hypothesis -from hypothesis import strategies -from PyQt5.QtCore import QUrl - -from qutebrowser.browser import history -from qutebrowser.utils import objreg, urlutils, usertypes - - -class FakeWebHistory: - - """A fake WebHistory object.""" - - def __init__(self, history_dict): - self.history_dict = history_dict - - -@pytest.fixture() -def hist(tmpdir, fake_save_manager): - return history.WebHistory(hist_dir=str(tmpdir), hist_name='history') - - -def test_async_read_twice(monkeypatch, qtbot, tmpdir, caplog, - fake_save_manager): - (tmpdir / 'filled-history').write('\n'.join([ - '12345 http://example.com/ title', - '67890 http://example.com/', - '12345 http://qutebrowser.org/ blah', - ])) - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - next(hist.async_read()) - with pytest.raises(StopIteration): - next(hist.async_read()) - expected = "Ignoring async_read() because reading is started." - assert len(caplog.records) == 1 - assert caplog.records[0].msg == expected - - -@pytest.mark.parametrize('redirect', [True, False]) -def test_adding_item_during_async_read(qtbot, hist, redirect): - """Check what happens when adding URL while reading the history.""" - url = QUrl('http://www.example.com/') - - with qtbot.assertNotEmitted(hist.add_completion_item), \ - qtbot.assertNotEmitted(hist.item_added): - hist.add_url(url, redirect=redirect, atime=12345) - - if redirect: - with qtbot.assertNotEmitted(hist.add_completion_item): - with qtbot.waitSignal(hist.async_read_done): - list(hist.async_read()) - else: - with qtbot.waitSignals([hist.add_completion_item, - hist.async_read_done], order='strict'): - list(hist.async_read()) - - assert not hist._temp_history - - expected = history.Entry(url=url, atime=12345, redirect=redirect, title="") - assert list(hist.history_dict.values()) == [expected] - - -def test_iter(hist): - list(hist.async_read()) - - url = QUrl('http://www.example.com/') - hist.add_url(url, atime=12345) - - entry = history.Entry(url=url, atime=12345, redirect=False, title="") - assert list(hist) == [entry] - - -def test_len(hist): - assert len(hist) == 0 - list(hist.async_read()) - - url = QUrl('http://www.example.com/') - hist.add_url(url) - - assert len(hist) == 1 - - -@pytest.mark.parametrize('line', [ - '12345 http://example.com/ title', # with title - '67890 http://example.com/', # no title - '12345 http://qutebrowser.org/ ', # trailing space - ' ', - '', -]) -def test_read(hist, tmpdir, line): - (tmpdir / 'filled-history').write(line + '\n') - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - -def test_updated_entries(hist, tmpdir): - (tmpdir / 'filled-history').write('12345 http://example.com/\n' - '67890 http://example.com/\n') - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - assert hist.history_dict['http://example.com/'].atime == 67890 - hist.add_url(QUrl('http://example.com/'), atime=99999) - assert hist.history_dict['http://example.com/'].atime == 99999 - - -def test_invalid_read(hist, tmpdir, caplog): - (tmpdir / 'filled-history').write('foobar\n12345 http://example.com/') - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - with caplog.at_level(logging.WARNING): - list(hist.async_read()) - - entries = list(hist.history_dict.values()) - - assert len(entries) == 1 - assert len(caplog.records) == 1 - msg = "Invalid history entry 'foobar': 2 or 3 fields expected!" - assert caplog.records[0].msg == msg - - -def test_get_recent(hist, tmpdir): - (tmpdir / 'filled-history').write('12345 http://example.com/') - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890) - lines = hist.get_recent() - - expected = ['12345 http://example.com/', - '67890 http://www.qutebrowser.org/'] - assert lines == expected - - -def test_save(hist, tmpdir): - hist_file = tmpdir / 'filled-history' - hist_file.write('12345 http://example.com/\n') - - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - hist.add_url(QUrl('http://www.qutebrowser.org/'), atime=67890) - hist.save() - - lines = hist_file.read().splitlines() - expected = ['12345 http://example.com/', - '67890 http://www.qutebrowser.org/'] - assert lines == expected - - hist.add_url(QUrl('http://www.the-compiler.org/'), atime=99999) - hist.save() - expected.append('99999 http://www.the-compiler.org/') - - lines = hist_file.read().splitlines() - assert lines == expected - - -def test_clear(qtbot, hist, tmpdir): - hist_file = tmpdir / 'filled-history' - hist_file.write('12345 http://example.com/\n') - - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - hist.add_url(QUrl('http://www.qutebrowser.org/')) - - with qtbot.waitSignal(hist.cleared): - hist._do_clear() - - assert not hist_file.read() - assert not hist.history_dict - assert not hist._new_history - - hist.add_url(QUrl('http://www.the-compiler.org/'), atime=67890) - hist.save() - - lines = hist_file.read().splitlines() - assert lines == ['67890 http://www.the-compiler.org/'] - - -def test_add_item(qtbot, hist): - list(hist.async_read()) - url = 'http://www.example.com/' - - with qtbot.waitSignals([hist.add_completion_item, hist.item_added], - order='strict'): - hist.add_url(QUrl(url), atime=12345, title="the title") - - entry = history.Entry(url=QUrl(url), redirect=False, atime=12345, - title="the title") - assert hist.history_dict[url] == entry - - -def test_add_item_redirect(qtbot, hist): - list(hist.async_read()) - url = 'http://www.example.com/' - with qtbot.assertNotEmitted(hist.add_completion_item): - with qtbot.waitSignal(hist.item_added): - hist.add_url(QUrl(url), redirect=True, atime=12345) - - entry = history.Entry(url=QUrl(url), redirect=True, atime=12345, title="") - assert hist.history_dict[url] == entry - - -def test_add_item_redirect_update(qtbot, tmpdir, fake_save_manager): - """A redirect update added should override a non-redirect one.""" - url = 'http://www.example.com/' - - hist_file = tmpdir / 'filled-history' - hist_file.write('12345 {}\n'.format(url)) - hist = history.WebHistory(hist_dir=str(tmpdir), hist_name='filled-history') - list(hist.async_read()) - - with qtbot.assertNotEmitted(hist.add_completion_item): - with qtbot.waitSignal(hist.item_added): - hist.add_url(QUrl(url), redirect=True, atime=67890) - - entry = history.Entry(url=QUrl(url), redirect=True, atime=67890, title="") - assert hist.history_dict[url] == entry - - -@pytest.mark.parametrize('line, expected', [ - ( - # old format without title - '12345 http://example.com/', - history.Entry(atime=12345, url=QUrl('http://example.com/'), title='',) - ), - ( - # trailing space without title - '12345 http://example.com/ ', - history.Entry(atime=12345, url=QUrl('http://example.com/'), title='',) - ), - ( - # new format with title - '12345 http://example.com/ this is a title', - history.Entry(atime=12345, url=QUrl('http://example.com/'), - title='this is a title') - ), - ( - # weird NUL bytes - '\x0012345 http://example.com/', - history.Entry(atime=12345, url=QUrl('http://example.com/'), title=''), - ), - ( - # redirect flag - '12345-r http://example.com/ this is a title', - history.Entry(atime=12345, url=QUrl('http://example.com/'), - title='this is a title', redirect=True) - ), -]) -def test_entry_parse_valid(line, expected): - entry = history.Entry.from_str(line) - assert entry == expected - - -@pytest.mark.parametrize('line', [ - '12345', # one field - '12345 ::', # invalid URL - 'xyz http://www.example.com/', # invalid timestamp - '12345-x http://www.example.com/', # invalid flags - '12345-r-r http://www.example.com/', # double flags -]) -def test_entry_parse_invalid(line): - with pytest.raises(ValueError): - history.Entry.from_str(line) - - -@hypothesis.given(strategies.text()) -def test_entry_parse_hypothesis(text): - """Make sure parsing works or gives us ValueError.""" - try: - history.Entry.from_str(text) - except ValueError: - pass - - -@pytest.mark.parametrize('entry, expected', [ - # simple - ( - history.Entry(12345, QUrl('http://example.com/'), "the title"), - "12345 http://example.com/ the title", - ), - # timestamp as float - ( - history.Entry(12345.678, QUrl('http://example.com/'), "the title"), - "12345 http://example.com/ the title", - ), - # no title - ( - history.Entry(12345.678, QUrl('http://example.com/'), ""), - "12345 http://example.com/", - ), - # redirect flag - ( - history.Entry(12345.678, QUrl('http://example.com/'), "", - redirect=True), - "12345-r http://example.com/", - ), -]) -def test_entry_str(entry, expected): - assert str(entry) == expected - - -@pytest.fixture -def hist_interface(): - # pylint: disable=invalid-name - QtWebKit = pytest.importorskip('PyQt5.QtWebKit') - from qutebrowser.browser.webkit import webkithistory - QWebHistoryInterface = QtWebKit.QWebHistoryInterface - # pylint: enable=invalid-name - entry = history.Entry(atime=0, url=QUrl('http://www.example.com/'), - title='example') - history_dict = {'http://www.example.com/': entry} - fake_hist = FakeWebHistory(history_dict) - interface = webkithistory.WebHistoryInterface(fake_hist) - QWebHistoryInterface.setDefaultInterface(interface) - yield - QWebHistoryInterface.setDefaultInterface(None) - - -def test_history_interface(qtbot, webview, hist_interface): - html = b"foo" - url = urlutils.data_url('text/html', html) - with qtbot.waitSignal(webview.loadFinished): - webview.load(url) - - -@pytest.mark.parametrize('backend', [usertypes.Backend.QtWebEngine, - usertypes.Backend.QtWebKit]) -def test_init(backend, qapp, tmpdir, monkeypatch, fake_save_manager): - if backend == usertypes.Backend.QtWebKit: - pytest.importorskip('PyQt5.QtWebKitWidgets') - else: - assert backend == usertypes.Backend.QtWebEngine - - monkeypatch.setattr(history.standarddir, 'data', lambda: str(tmpdir)) - monkeypatch.setattr(history.objects, 'backend', backend) - history.init(qapp) - hist = objreg.get('web-history') - assert hist.parent() is qapp - - try: - from PyQt5.QtWebKit import QWebHistoryInterface - except ImportError: - QWebHistoryInterface = None - - if backend == usertypes.Backend.QtWebKit: - default_interface = QWebHistoryInterface.defaultInterface() - assert default_interface._history is hist - else: - assert backend == usertypes.Backend.QtWebEngine - if QWebHistoryInterface is None: - default_interface = None - else: - default_interface = QWebHistoryInterface.defaultInterface() - # For this to work, nothing can ever have called setDefaultInterface - # before (so we need to test webengine before webkit) - assert default_interface is None - - assert fake_save_manager.add_saveable.called - objreg.delete('web-history') diff --git a/tests/unit/completion/test_column_widths.py b/tests/unit/completion/test_column_widths.py deleted file mode 100644 index 21456ed37..000000000 --- a/tests/unit/completion/test_column_widths.py +++ /dev/null @@ -1,50 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2015-2017 Alexander Cogneau -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see . - -"""Tests for qutebrowser.completion.models column widths.""" - -import pytest - -from qutebrowser.completion.models.base import BaseCompletionModel -from qutebrowser.completion.models.configmodel import ( - SettingOptionCompletionModel, SettingSectionCompletionModel, - SettingValueCompletionModel) -from qutebrowser.completion.models.miscmodels import ( - CommandCompletionModel, HelpCompletionModel, QuickmarkCompletionModel, - BookmarkCompletionModel, SessionCompletionModel) -from qutebrowser.completion.models.urlmodel import UrlCompletionModel - - -CLASSES = [BaseCompletionModel, SettingOptionCompletionModel, - SettingOptionCompletionModel, SettingSectionCompletionModel, - SettingValueCompletionModel, CommandCompletionModel, - HelpCompletionModel, QuickmarkCompletionModel, - BookmarkCompletionModel, SessionCompletionModel, UrlCompletionModel] - - -@pytest.mark.parametrize("model", CLASSES) -def test_list_size(model): - """Test if there are 3 items in the COLUMN_WIDTHS property.""" - assert len(model.COLUMN_WIDTHS) == 3 - - -@pytest.mark.parametrize("model", CLASSES) -def test_column_width_sum(model): - """Test if the sum of the widths asserts to 100.""" - assert sum(model.COLUMN_WIDTHS) == 100 diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py index d18e6c125..537baad46 100644 --- a/tests/unit/completion/test_completer.py +++ b/tests/unit/completion/test_completer.py @@ -26,7 +26,6 @@ from PyQt5.QtCore import QObject from PyQt5.QtGui import QStandardItemModel from qutebrowser.completion import completer -from qutebrowser.utils import usertypes from qutebrowser.commands import command, cmdutils @@ -34,11 +33,10 @@ class FakeCompletionModel(QStandardItemModel): """Stub for a completion model.""" - DUMB_SORT = None - - def __init__(self, kind, parent=None): + def __init__(self, kind, *pos_args, parent=None): super().__init__(parent) self.kind = kind + self.pos_args = list(pos_args) class CompletionWidgetStub(QObject): @@ -70,39 +68,45 @@ def completer_obj(qtbot, status_command_stub, config_stub, monkeypatch, stubs, @pytest.fixture(autouse=True) -def instances(monkeypatch): - """Mock the instances module so get returns a fake completion model.""" - # populate a model for each completion type, with a nested structure for - # option and value completion - instances = {kind: FakeCompletionModel(kind) - for kind in usertypes.Completion} - instances[usertypes.Completion.option] = { - 'general': FakeCompletionModel(usertypes.Completion.option), - } - instances[usertypes.Completion.value] = { - 'general': { - 'editor': FakeCompletionModel(usertypes.Completion.value), - } - } - monkeypatch.setattr(completer, 'instances', instances) +def miscmodels_patch(mocker): + """Patch the miscmodels module to provide fake completion functions. + + Technically some of these are not part of miscmodels, but rolling them into + one module is easier and sufficient for mocking. The only one referenced + directly by Completer is miscmodels.command. + """ + m = mocker.patch('qutebrowser.completion.completer.miscmodels', + autospec=True) + m.command = lambda *args: FakeCompletionModel('command', *args) + m.helptopic = lambda *args: FakeCompletionModel('helptopic', *args) + m.quickmark = lambda *args: FakeCompletionModel('quickmark', *args) + m.bookmark = lambda *args: FakeCompletionModel('bookmark', *args) + m.session = lambda *args: FakeCompletionModel('session', *args) + m.buffer = lambda *args: FakeCompletionModel('buffer', *args) + m.bind = lambda *args: FakeCompletionModel('bind', *args) + m.url = lambda *args: FakeCompletionModel('url', *args) + m.section = lambda *args: FakeCompletionModel('section', *args) + m.option = lambda *args: FakeCompletionModel('option', *args) + m.value = lambda *args: FakeCompletionModel('value', *args) + return m @pytest.fixture(autouse=True) -def cmdutils_patch(monkeypatch, stubs): +def cmdutils_patch(monkeypatch, stubs, miscmodels_patch): """Patch the cmdutils module to provide fake commands.""" - @cmdutils.argument('section_', completion=usertypes.Completion.section) - @cmdutils.argument('option', completion=usertypes.Completion.option) - @cmdutils.argument('value', completion=usertypes.Completion.value) + @cmdutils.argument('section_', completion=miscmodels_patch.section) + @cmdutils.argument('option', completion=miscmodels_patch.option) + @cmdutils.argument('value', completion=miscmodels_patch.value) def set_command(section_=None, option=None, value=None): """docstring.""" pass - @cmdutils.argument('topic', completion=usertypes.Completion.helptopic) + @cmdutils.argument('topic', completion=miscmodels_patch.helptopic) def show_help(tab=False, bg=False, window=False, topic=None): """docstring.""" pass - @cmdutils.argument('url', completion=usertypes.Completion.url) + @cmdutils.argument('url', completion=miscmodels_patch.url) @cmdutils.argument('count', count=True) def openurl(url=None, implicit=False, bg=False, tab=False, window=False, count=None): @@ -110,7 +114,7 @@ def cmdutils_patch(monkeypatch, stubs): pass @cmdutils.argument('win_id', win_id=True) - @cmdutils.argument('command', completion=usertypes.Completion.command) + @cmdutils.argument('command', completion=miscmodels_patch.command) def bind(key, win_id, command=None, *, mode='normal', force=False): """docstring.""" pass @@ -140,60 +144,61 @@ def _set_cmd_prompt(cmd, txt): cmd.setCursorPosition(txt.index('|')) -@pytest.mark.parametrize('txt, kind, pattern', [ - (':nope|', usertypes.Completion.command, 'nope'), - (':nope |', None, ''), - (':set |', usertypes.Completion.section, ''), - (':set gen|', usertypes.Completion.section, 'gen'), - (':set general |', usertypes.Completion.option, ''), - (':set what |', None, ''), - (':set general editor |', usertypes.Completion.value, ''), - (':set general editor gv|', usertypes.Completion.value, 'gv'), - (':set general editor "gvim -f"|', usertypes.Completion.value, 'gvim -f'), - (':set general editor "gvim |', usertypes.Completion.value, 'gvim'), - (':set general huh |', None, ''), - (':help |', usertypes.Completion.helptopic, ''), - (':help |', usertypes.Completion.helptopic, ''), - (':open |', usertypes.Completion.url, ''), - (':bind |', None, ''), - (':bind |', usertypes.Completion.command, ''), - (':bind foo|', usertypes.Completion.command, 'foo'), - (':bind | foo', None, ''), - (':set| general ', usertypes.Completion.command, 'set'), - (':|set general ', usertypes.Completion.command, 'set'), - (':set gene|ral ignore-case', usertypes.Completion.section, 'general'), - (':|', usertypes.Completion.command, ''), - (': |', usertypes.Completion.command, ''), - ('/|', None, ''), - (':open -t|', None, ''), - (':open --tab|', None, ''), - (':open -t |', usertypes.Completion.url, ''), - (':open --tab |', usertypes.Completion.url, ''), - (':open | -t', usertypes.Completion.url, ''), - (':tab-detach |', None, ''), - (':bind --mode=caret |', usertypes.Completion.command, ''), - pytest.param(':bind --mode caret |', usertypes.Completion.command, - '', marks=pytest.mark.xfail(reason='issue #74')), - (':set -t -p |', usertypes.Completion.section, ''), - (':open -- |', None, ''), - (':gibberish nonesense |', None, ''), - ('/:help|', None, ''), - ('::bind|', usertypes.Completion.command, ':bind'), +@pytest.mark.parametrize('txt, kind, pattern, pos_args', [ + (':nope|', 'command', 'nope', []), + (':nope |', None, '', []), + (':set |', 'section', '', []), + (':set gen|', 'section', 'gen', []), + (':set general |', 'option', '', ['general']), + (':set what |', 'option', '', ['what']), + (':set general editor |', 'value', '', ['general', 'editor']), + (':set general editor gv|', 'value', 'gv', ['general', 'editor']), + (':set general editor "gvim -f"|', 'value', 'gvim -f', + ['general', 'editor']), + (':set general editor "gvim |', 'value', 'gvim', ['general', 'editor']), + (':set general huh |', 'value', '', ['general', 'huh']), + (':help |', 'helptopic', '', []), + (':help |', 'helptopic', '', []), + (':open |', 'url', '', []), + (':bind |', None, '', []), + (':bind |', 'command', '', ['']), + (':bind foo|', 'command', 'foo', ['']), + (':bind | foo', None, '', []), + (':set| general ', 'command', 'set', []), + (':|set general ', 'command', 'set', []), + (':set gene|ral ignore-case', 'section', 'general', []), + (':|', 'command', '', []), + (': |', 'command', '', []), + ('/|', None, '', []), + (':open -t|', None, '', []), + (':open --tab|', None, '', []), + (':open -t |', 'url', '', []), + (':open --tab |', 'url', '', []), + (':open | -t', 'url', '', []), + (':tab-detach |', None, '', []), + (':bind --mode=caret |', 'command', '', ['']), + pytest.param(':bind --mode caret |', 'command', '', [], + marks=pytest.mark.xfail(reason='issue #74')), + (':set -t -p |', 'section', '', []), + (':open -- |', None, '', []), + (':gibberish nonesense |', None, '', []), + ('/:help|', None, '', []), + ('::bind|', 'command', ':bind', []), ]) -def test_update_completion(txt, kind, pattern, status_command_stub, +def test_update_completion(txt, kind, pattern, pos_args, status_command_stub, completer_obj, completion_widget_stub): """Test setting the completion widget's model based on command text.""" # this test uses | as a placeholder for the current cursor position _set_cmd_prompt(status_command_stub, txt) completer_obj.schedule_completion_update() - assert completion_widget_stub.set_model.call_count == 1 - args = completion_widget_stub.set_model.call_args[0] - # the outer model is just for sorting; srcmodel is the completion model if kind is None: - assert args[0] is None + assert completion_widget_stub.set_pattern.call_count == 0 else: - assert args[0].srcmodel.kind == kind - assert args[1] == pattern + assert completion_widget_stub.set_model.call_count == 1 + model = completion_widget_stub.set_model.call_args[0][0] + assert model.kind == kind + assert model.pos_args == pos_args + completion_widget_stub.set_pattern.assert_called_once_with(pattern) @pytest.mark.parametrize('before, newtxt, after', [ diff --git a/tests/unit/completion/test_completionmodel.py b/tests/unit/completion/test_completionmodel.py new file mode 100644 index 000000000..4d1d3f123 --- /dev/null +++ b/tests/unit/completion/test_completionmodel.py @@ -0,0 +1,99 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Tests for CompletionModel.""" + +from unittest import mock +import hypothesis +from hypothesis import strategies + +import pytest +from PyQt5.QtCore import QModelIndex + +from qutebrowser.completion.models import completionmodel, listcategory +from qutebrowser.utils import qtutils + + +@hypothesis.given(strategies.lists(min_size=0, max_size=3, + elements=strategies.integers(min_value=0, max_value=2**31))) +def test_first_last_item(counts): + """Test that first() and last() index to the first and last items.""" + model = completionmodel.CompletionModel() + for c in counts: + cat = mock.Mock(spec=['layoutChanged', 'layoutAboutToBeChanged']) + cat.rowCount = mock.Mock(return_value=c, spec=[]) + model.add_category(cat) + data = [i for i, rowCount in enumerate(counts) if rowCount > 0] + if not data: + # with no items, first and last should be an invalid index + assert not model.first_item().isValid() + assert not model.last_item().isValid() + else: + first = data[0] + last = data[-1] + # first item of the first data category + assert model.first_item().row() == 0 + assert model.first_item().parent().row() == first + # last item of the last data category + assert model.last_item().row() == counts[last] - 1 + assert model.last_item().parent().row() == last + + +@hypothesis.given(strategies.lists(elements=strategies.integers(), + min_size=0, max_size=3)) +def test_count(counts): + model = completionmodel.CompletionModel() + for c in counts: + cat = mock.Mock(spec=['rowCount', 'layoutChanged', + 'layoutAboutToBeChanged']) + cat.rowCount = mock.Mock(return_value=c, spec=[]) + model.add_category(cat) + assert model.count() == sum(counts) + + +@hypothesis.given(strategies.text()) +def test_set_pattern(pat): + """Validate the filtering and sorting results of set_pattern.""" + model = completionmodel.CompletionModel() + cats = [mock.Mock(spec=['set_pattern', 'layoutChanged', + 'layoutAboutToBeChanged']) + for _ in range(3)] + for c in cats: + c.set_pattern = mock.Mock(spec=[]) + model.add_category(c) + model.set_pattern(pat) + for c in cats: + c.set_pattern.assert_called_with(pat) + + +def test_delete_cur_item(): + func = mock.Mock(spec=[]) + model = completionmodel.CompletionModel() + cat = listcategory.ListCategory('', [('foo', 'bar')], delete_func=func) + model.add_category(cat) + parent = model.index(0, 0) + model.delete_cur_item(model.index(0, 0, parent)) + func.assert_called_once_with(['foo', 'bar']) + + +def test_delete_cur_item_no_cat(): + """Test completion_item_del with no selected category.""" + model = completionmodel.CompletionModel() + with pytest.raises(qtutils.QtValueError): + model.delete_cur_item(QModelIndex()) diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index 9a8de3cad..207e557a8 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -19,13 +19,14 @@ """Tests for the CompletionView Object.""" -import unittest.mock +from unittest import mock import pytest -from PyQt5.QtGui import QStandardItem, QColor +from PyQt5.QtGui import QColor from qutebrowser.completion import completionwidget -from qutebrowser.completion.models import base, sortfilter +from qutebrowser.completion.models import completionmodel, listcategory +from qutebrowser.commands import cmdexc @pytest.fixture @@ -71,23 +72,28 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry, def test_set_model(completionview): """Ensure set_model actually sets the model and expands all categories.""" - model = base.BaseCompletionModel() - filtermodel = sortfilter.CompletionFilterModel(model) + model = completionmodel.CompletionModel() for i in range(3): - model.appendRow(QStandardItem(str(i))) - completionview.set_model(filtermodel) - assert completionview.model() is filtermodel - for i in range(model.rowCount()): - assert completionview.isExpanded(filtermodel.index(i, 0)) + model.add_category(listcategory.ListCategory('', [('foo',)])) + completionview.set_model(model) + assert completionview.model() is model + for i in range(3): + assert completionview.isExpanded(model.index(i, 0)) def test_set_pattern(completionview): - model = sortfilter.CompletionFilterModel(base.BaseCompletionModel()) - model.set_pattern = unittest.mock.Mock() - completionview.set_model(model, 'foo') + model = completionmodel.CompletionModel() + model.set_pattern = mock.Mock(spec=[]) + completionview.set_model(model) + completionview.set_pattern('foo') model.set_pattern.assert_called_with('foo') +def test_set_pattern_no_model(completionview): + """Ensure that setting a pattern with no model does not fail.""" + completionview.set_pattern('foo') + + def test_maybe_update_geometry(completionview, config_stub, qtbot): """Ensure completion is resized only if shrink is True.""" with qtbot.assertNotEmitted(completionview.update_geometry): @@ -148,15 +154,11 @@ def test_completion_item_focus(which, tree, expected, completionview, qtbot): successive movement. None implies no signal should be emitted. """ - model = base.BaseCompletionModel() + model = completionmodel.CompletionModel() for catdata in tree: - cat = QStandardItem() - model.appendRow(cat) - for name in catdata: - cat.appendRow(QStandardItem(name)) - filtermodel = sortfilter.CompletionFilterModel(model, - parent=completionview) - completionview.set_model(filtermodel) + cat = listcategory.ListCategory('', ((x,) for x in catdata)) + model.add_category(cat) + completionview.set_model(model) for entry in expected: if entry is None: with qtbot.assertNotEmitted(completionview.selection_changed): @@ -176,10 +178,8 @@ def test_completion_item_focus_no_model(which, completionview, qtbot): """ with qtbot.assertNotEmitted(completionview.selection_changed): completionview.completion_item_focus(which) - model = base.BaseCompletionModel() - filtermodel = sortfilter.CompletionFilterModel(model, - parent=completionview) - completionview.set_model(filtermodel) + model = completionmodel.CompletionModel() + completionview.set_model(model) completionview.set_model(None) with qtbot.assertNotEmitted(completionview.selection_changed): completionview.completion_item_focus(which) @@ -200,16 +200,13 @@ def test_completion_show(show, rows, quick_complete, completionview, config_stub.data['completion']['show'] = show config_stub.data['completion']['quick-complete'] = quick_complete - model = base.BaseCompletionModel() + model = completionmodel.CompletionModel() for name in rows: - cat = QStandardItem() - model.appendRow(cat) - cat.appendRow(QStandardItem(name)) - filtermodel = sortfilter.CompletionFilterModel(model, - parent=completionview) + cat = listcategory.ListCategory('', [(name,)]) + model.add_category(cat) assert not completionview.isVisible() - completionview.set_model(filtermodel) + completionview.set_model(model) assert completionview.isVisible() == (show == 'always' and len(rows) > 0) completionview.completion_item_focus('next') expected = (show != 'never' and len(rows) > 0 and @@ -218,3 +215,27 @@ def test_completion_show(show, rows, quick_complete, completionview, completionview.set_model(None) completionview.completion_item_focus('next') assert not completionview.isVisible() + + +def test_completion_item_del(completionview): + """Test that completion_item_del invokes delete_cur_item in the model.""" + func = mock.Mock(spec=[]) + model = completionmodel.CompletionModel() + cat = listcategory.ListCategory('', [('foo', 'bar')], delete_func=func) + model.add_category(cat) + completionview.set_model(model) + completionview.completion_item_focus('next') + completionview.completion_item_del() + func.assert_called_once_with(['foo', 'bar']) + + +def test_completion_item_del_no_selection(completionview): + """Test that completion_item_del with an invalid index.""" + func = mock.Mock(spec=[]) + model = completionmodel.CompletionModel() + cat = listcategory.ListCategory('', [('foo',)], delete_func=func) + model.add_category(cat) + completionview.set_model(model) + with pytest.raises(cmdexc.CommandError, match='No item selected!'): + completionview.completion_item_del() + assert not func.called diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py new file mode 100644 index 000000000..0b5fcb915 --- /dev/null +++ b/tests/unit/completion/test_histcategory.py @@ -0,0 +1,150 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Test the web history completion category.""" + +import unittest.mock +import datetime + +import pytest + +from qutebrowser.misc import sql +from qutebrowser.completion.models import histcategory +from qutebrowser.commands import cmdexc + + +@pytest.fixture +def hist(init_sql, config_stub): + config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', + 'web-history-max-items': -1} + return sql.SqlTable('CompletionHistory', ['url', 'title', 'last_atime']) + + +@pytest.mark.parametrize('pattern, before, after', [ + ('foo', + [('foo', ''), ('bar', ''), ('aafobbb', '')], + [('foo',)]), + + ('FOO', + [('foo', ''), ('bar', ''), ('aafobbb', '')], + [('foo',)]), + + ('foo', + [('FOO', ''), ('BAR', ''), ('AAFOBBB', '')], + [('FOO',)]), + + ('foo', + [('baz', 'bar'), ('foo', ''), ('bar', 'foo')], + [('foo', ''), ('bar', 'foo')]), + + ('foo', + [('fooa', ''), ('foob', ''), ('fooc', '')], + [('fooa', ''), ('foob', ''), ('fooc', '')]), + + ('foo', + [('foo', 'bar'), ('bar', 'foo'), ('biz', 'baz')], + [('foo', 'bar'), ('bar', 'foo')]), + + ('foo bar', + [('foo', ''), ('bar foo', ''), ('xfooyybarz', '')], + [('xfooyybarz', '')]), + + ('foo%bar', + [('foo%bar', ''), ('foo bar', ''), ('foobar', '')], + [('foo%bar', '')]), + + ('_', + [('a_b', ''), ('__a', ''), ('abc', '')], + [('a_b', ''), ('__a', '')]), + + ('%', + [('\\foo', '\\bar')], + []), + + ("can't", + [("can't touch this", ''), ('a', '')], + [("can't touch this", '')]), +]) +def test_set_pattern(pattern, before, after, model_validator, hist): + """Validate the filtering and sorting results of set_pattern.""" + for row in before: + hist.insert({'url': row[0], 'title': row[1], 'last_atime': 1}) + cat = histcategory.HistoryCategory() + model_validator.set_model(cat) + cat.set_pattern(pattern) + model_validator.validate(after) + + +@pytest.mark.parametrize('max_items, before, after', [ + (-1, [ + ('a', 'a', '2017-04-16'), + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ], [ + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ('a', 'a', '2017-04-16'), + ]), + (3, [ + ('a', 'a', '2017-04-16'), + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ], [ + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ('a', 'a', '2017-04-16'), + ]), + (2, [ + ('a', 'a', '2017-04-16'), + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ], [ + ('b', 'b', '2017-06-16'), + ('c', 'c', '2017-05-16'), + ]) +]) +def test_sorting(max_items, before, after, model_validator, hist, config_stub): + """Validate the filtering and sorting results of set_pattern.""" + config_stub.data['completion']['web-history-max-items'] = max_items + for url, title, atime in before: + timestamp = datetime.datetime.strptime(atime, '%Y-%m-%d').timestamp() + hist.insert({'url': url, 'title': title, 'last_atime': timestamp}) + cat = histcategory.HistoryCategory() + model_validator.set_model(cat) + cat.set_pattern('') + model_validator.validate(after) + + +def test_delete_cur_item(hist): + hist.insert({'url': 'foo', 'title': 'Foo'}) + hist.insert({'url': 'bar', 'title': 'Bar'}) + func = unittest.mock.Mock(spec=[]) + cat = histcategory.HistoryCategory(delete_func=func) + cat.set_pattern('') + cat.delete_cur_item(cat.index(0, 0)) + func.assert_called_with(['foo', 'Foo', '']) + + +def test_delete_cur_item_no_func(hist): + hist.insert({'url': 'foo', 'title': 1}) + hist.insert({'url': 'bar', 'title': 2}) + cat = histcategory.HistoryCategory() + cat.set_pattern('') + with pytest.raises(cmdexc.CommandError, match='Cannot delete this item'): + cat.delete_cur_item(cat.index(0, 0)) diff --git a/tests/unit/completion/test_listcategory.py b/tests/unit/completion/test_listcategory.py new file mode 100644 index 000000000..3b1c1478a --- /dev/null +++ b/tests/unit/completion/test_listcategory.py @@ -0,0 +1,73 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Tests for CompletionFilterModel.""" + +from unittest import mock + +import pytest + +from qutebrowser.completion.models import listcategory +from qutebrowser.commands import cmdexc + + +@pytest.mark.parametrize('pattern, before, after', [ + ('foo', + [('foo', ''), ('bar', '')], + [('foo', '')]), + + ('foo', + [('foob', ''), ('fooc', ''), ('fooa', '')], + [('fooa', ''), ('foob', ''), ('fooc', '')]), + + # prefer foobar as it starts with the pattern + ('foo', + [('barfoo', ''), ('foobar', '')], + [('foobar', ''), ('barfoo', '')]), + + ('foo', + [('foo', 'bar'), ('bar', 'foo'), ('bar', 'bar')], + [('foo', 'bar'), ('bar', 'foo')]), +]) +def test_set_pattern(pattern, before, after, model_validator): + """Validate the filtering and sorting results of set_pattern.""" + cat = listcategory.ListCategory('Foo', before) + model_validator.set_model(cat) + cat.set_pattern(pattern) + model_validator.validate(after) + + +def test_delete_cur_item(model_validator): + func = mock.Mock(spec=[]) + cat = listcategory.ListCategory('Foo', [('a', 'b'), ('c', 'd')], + delete_func=func) + model_validator.set_model(cat) + idx = cat.index(0, 0) + cat.delete_cur_item(idx) + func.assert_called_once_with(['a', 'b']) + model_validator.validate([('c', 'd')]) + + +def test_delete_cur_item_no_func(model_validator): + cat = listcategory.ListCategory('Foo', [('a', 'b'), ('c', 'd')]) + model_validator.set_model(cat) + idx = cat.index(0, 0) + with pytest.raises(cmdexc.CommandError, match="Cannot delete this item."): + cat.delete_cur_item(idx) + model_validator.validate([('a', 'b'), ('c', 'd')]) diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index ff00a11a9..5b632eb3a 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -24,12 +24,11 @@ from datetime import datetime import pytest from PyQt5.QtCore import QUrl -from PyQt5.QtWidgets import QTreeView -from qutebrowser.completion.models import (miscmodels, urlmodel, configmodel, - sortfilter) -from qutebrowser.browser import history +from qutebrowser.completion.models import miscmodels, urlmodel, configmodel from qutebrowser.config import sections, value +from qutebrowser.utils import objreg +from qutebrowser.browser import history def _check_completions(model, expected): @@ -43,19 +42,21 @@ def _check_completions(model, expected): ... } """ + actual = {} assert model.rowCount() == len(expected) for i in range(0, model.rowCount()): - actual_cat = model.item(i) - catname = actual_cat.text() - assert catname in expected - expected_cat = expected[catname] - assert actual_cat.rowCount() == len(expected_cat) - for j in range(0, actual_cat.rowCount()): - name = actual_cat.child(j, 0) - desc = actual_cat.child(j, 1) - misc = actual_cat.child(j, 2) - actual_item = (name.text(), desc.text(), misc.text()) - assert actual_item in expected_cat + catidx = model.index(i, 0) + catname = model.data(catidx) + actual[catname] = [] + for j in range(model.rowCount(catidx)): + name = model.data(model.index(j, 0, parent=catidx)) + desc = model.data(model.index(j, 1, parent=catidx)) + misc = model.data(model.index(j, 2, parent=catidx)) + actual[catname].append((name, desc, misc)) + assert actual == expected + # sanity-check the column_widths + assert len(model.column_widths) == 3 + assert sum(model.column_widths) == 100 def _patch_cmdutils(monkeypatch, stubs, symbol): @@ -113,22 +114,6 @@ def _patch_config_section_desc(monkeypatch, stubs, symbol): monkeypatch.setattr(symbol, section_desc) -def _mock_view_index(model, category_idx, child_idx, qtbot): - """Create a tree view from a model and set the current index. - - Args: - model: model to create a fake view for. - category_idx: index of the category to select. - child_idx: index of the child item under that category to select. - """ - view = QTreeView() - qtbot.add_widget(view) - view.setModel(model) - idx = model.indexFromItem(model.item(category_idx).child(child_idx)) - view.setCurrentIndex(idx) - return view - - @pytest.fixture def quickmarks(quickmark_manager_stub): """Pre-populate the quickmark-manager stub with some quickmarks.""" @@ -152,20 +137,35 @@ def bookmarks(bookmark_manager_stub): @pytest.fixture -def web_history(stubs, web_history_stub): - """Pre-populate the web-history stub with some history entries.""" - web_history_stub.history_dict = collections.OrderedDict([ - ('http://qutebrowser.org', history.Entry( - datetime(2015, 9, 5).timestamp(), - QUrl('http://qutebrowser.org'), 'qutebrowser | qutebrowser')), - ('https://python.org', history.Entry( - datetime(2016, 3, 8).timestamp(), - QUrl('https://python.org'), 'Welcome to Python.org')), - ('https://github.com', history.Entry( - datetime(2016, 5, 1).timestamp(), - QUrl('https://github.com'), 'GitHub')), - ]) - return web_history_stub +def web_history(init_sql, stubs, config_stub): + """Fixture which provides a web-history object.""" + config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', + 'web-history-max-items': -1} + stub = history.WebHistory() + objreg.register('web-history', stub) + yield stub + objreg.delete('web-history') + + +@pytest.fixture +def web_history_populated(web_history): + """Pre-populate the web-history database.""" + web_history.add_url( + url=QUrl('http://qutebrowser.org'), + title='qutebrowser', + atime=datetime(2015, 9, 5).timestamp() + ) + web_history.add_url( + url=QUrl('https://python.org'), + title='Welcome to Python.org', + atime=datetime(2016, 3, 8).timestamp() + ) + web_history.add_url( + url=QUrl('https://github.com'), + title='https://github.com', + atime=datetime(2016, 5, 1).timestamp() + ) + return web_history def test_command_completion(qtmodeltester, monkeypatch, stubs, config_stub, @@ -184,16 +184,17 @@ def test_command_completion(qtmodeltester, monkeypatch, stubs, config_stub, key_config_stub.set_bindings_for('normal', {'s': 'stop', 'rr': 'roll', 'ro': 'rock'}) - model = miscmodels.CommandCompletionModel() + model = miscmodels.command() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Commands": [ - ('stop', 'stop qutebrowser', 's'), ('drop', 'drop all user data', ''), - ('roll', 'never gonna give you up', 'rr'), ('rock', "Alias for 'roll'", 'ro'), + ('roll', 'never gonna give you up', 'rr'), + ('stop', 'stop qutebrowser', 's'), ] }) @@ -212,135 +213,200 @@ def test_help_completion(qtmodeltester, monkeypatch, stubs, key_config_stub): key_config_stub.set_bindings_for('normal', {'s': 'stop', 'rr': 'roll'}) _patch_cmdutils(monkeypatch, stubs, module + '.cmdutils') _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') - model = miscmodels.HelpCompletionModel() + model = miscmodels.helptopic() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Commands": [ - (':stop', 'stop qutebrowser', 's'), (':drop', 'drop all user data', ''), - (':roll', 'never gonna give you up', 'rr'), (':hide', '', ''), + (':roll', 'never gonna give you up', 'rr'), + (':stop', 'stop qutebrowser', 's'), ], "Settings": [ - ('general->time', 'Is an illusion.', ''), - ('general->volume', 'Goes to 11', ''), - ('ui->gesture', 'Waggle your hands to control qutebrowser', ''), - ('ui->mind', 'Enable mind-control ui (experimental)', ''), - ('ui->voice', 'Whether to respond to voice commands', ''), - ('searchengines->DEFAULT', '', ''), + ('general->time', 'Is an illusion.', None), + ('general->volume', 'Goes to 11', None), + ('searchengines->DEFAULT', '', None), + ('ui->gesture', 'Waggle your hands to control qutebrowser', None), + ('ui->mind', 'Enable mind-control ui (experimental)', None), + ('ui->voice', 'Whether to respond to voice commands', None), ] }) def test_quickmark_completion(qtmodeltester, quickmarks): """Test the results of quickmark completion.""" - model = miscmodels.QuickmarkCompletionModel() + model = miscmodels.quickmark() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Quickmarks": [ - ('aw', 'https://wiki.archlinux.org', ''), - ('ddg', 'https://duckduckgo.com', ''), - ('wiki', 'https://wikipedia.org', ''), + ('aw', 'https://wiki.archlinux.org', None), + ('ddg', 'https://duckduckgo.com', None), + ('wiki', 'https://wikipedia.org', None), ] }) def test_bookmark_completion(qtmodeltester, bookmarks): """Test the results of bookmark completion.""" - model = miscmodels.BookmarkCompletionModel() + model = miscmodels.bookmark() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Bookmarks": [ - ('https://github.com', 'GitHub', ''), - ('https://python.org', 'Welcome to Python.org', ''), - ('http://qutebrowser.org', 'qutebrowser | qutebrowser', ''), + ('http://qutebrowser.org', 'qutebrowser | qutebrowser', None), + ('https://github.com', 'GitHub', None), + ('https://python.org', 'Welcome to Python.org', None), ] }) -def test_url_completion(qtmodeltester, config_stub, web_history, quickmarks, - bookmarks): +def test_url_completion(qtmodeltester, web_history_populated, + quickmarks, bookmarks): """Test the results of url completion. Verify that: - quickmarks, bookmarks, and urls are included - - no more than 'web-history-max-items' history entries are included - - the most recent entries are included + - entries are sorted by access time + - only the most recent entry is included for each url """ - config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', - 'web-history-max-items': 2} - model = urlmodel.UrlCompletionModel() + model = urlmodel.url() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Quickmarks": [ - ('https://wiki.archlinux.org', 'aw', ''), - ('https://duckduckgo.com', 'ddg', ''), - ('https://wikipedia.org', 'wiki', ''), + ('https://duckduckgo.com', 'ddg', None), + ('https://wiki.archlinux.org', 'aw', None), + ('https://wikipedia.org', 'wiki', None), ], "Bookmarks": [ - ('https://github.com', 'GitHub', ''), - ('https://python.org', 'Welcome to Python.org', ''), - ('http://qutebrowser.org', 'qutebrowser | qutebrowser', ''), + ('http://qutebrowser.org', 'qutebrowser | qutebrowser', None), + ('https://github.com', 'GitHub', None), + ('https://python.org', 'Welcome to Python.org', None), ], "History": [ + ('https://github.com', 'https://github.com', '2016-05-01'), ('https://python.org', 'Welcome to Python.org', '2016-03-08'), - ('https://github.com', 'GitHub', '2016-05-01'), + ('http://qutebrowser.org', 'qutebrowser', '2015-09-05'), ], }) -def test_url_completion_delete_bookmark(qtmodeltester, config_stub, - web_history, quickmarks, bookmarks, - qtbot): +@pytest.mark.parametrize('url, title, pattern, rowcount', [ + ('example.com', 'Site Title', '', 1), + ('example.com', 'Site Title', 'ex', 1), + ('example.com', 'Site Title', 'am', 1), + ('example.com', 'Site Title', 'com', 1), + ('example.com', 'Site Title', 'ex com', 1), + ('example.com', 'Site Title', 'com ex', 0), + ('example.com', 'Site Title', 'ex foo', 0), + ('example.com', 'Site Title', 'foo com', 0), + ('example.com', 'Site Title', 'exm', 0), + ('example.com', 'Site Title', 'Si Ti', 1), + ('example.com', 'Site Title', 'Ti Si', 0), + ('example.com', '', 'foo', 0), + ('foo_bar', '', '_', 1), + ('foobar', '', '_', 0), + ('foo%bar', '', '%', 1), + ('foobar', '', '%', 0), +]) +def test_url_completion_pattern(web_history, quickmark_manager_stub, + bookmark_manager_stub, url, title, pattern, + rowcount): + """Test that url completion filters by url and title.""" + web_history.add_url(QUrl(url), title) + model = urlmodel.url() + model.set_pattern(pattern) + # 2, 0 is History + assert model.rowCount(model.index(2, 0)) == rowcount + + +def test_url_completion_delete_bookmark(qtmodeltester, bookmarks, + web_history, quickmarks): """Test deleting a bookmark from the url completion model.""" - config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', - 'web-history-max-items': 2} - model = urlmodel.UrlCompletionModel() + model = urlmodel.url() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - # delete item (1, 0) -> (bookmarks, 'https://github.com' ) - view = _mock_view_index(model, 1, 0, qtbot) - model.delete_cur_item(view) + parent = model.index(1, 0) + idx = model.index(1, 0, parent) + + # sanity checks + assert model.data(parent) == "Bookmarks" + assert model.data(idx) == 'https://github.com' + assert 'https://github.com' in bookmarks.marks + + len_before = len(bookmarks.marks) + model.delete_cur_item(idx) assert 'https://github.com' not in bookmarks.marks - assert 'https://python.org' in bookmarks.marks - assert 'http://qutebrowser.org' in bookmarks.marks + assert len_before == len(bookmarks.marks) + 1 -def test_url_completion_delete_quickmark(qtmodeltester, config_stub, - web_history, quickmarks, bookmarks, +def test_url_completion_delete_quickmark(qtmodeltester, + quickmarks, web_history, bookmarks, qtbot): """Test deleting a bookmark from the url completion model.""" - config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', - 'web-history-max-items': 2} - model = urlmodel.UrlCompletionModel() + model = urlmodel.url() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - # delete item (0, 1) -> (quickmarks, 'ddg' ) - view = _mock_view_index(model, 0, 1, qtbot) - model.delete_cur_item(view) - assert 'aw' in quickmarks.marks + parent = model.index(0, 0) + idx = model.index(0, 0, parent) + + # sanity checks + assert model.data(parent) == "Quickmarks" + assert model.data(idx) == 'https://duckduckgo.com' + assert 'ddg' in quickmarks.marks + + len_before = len(quickmarks.marks) + model.delete_cur_item(idx) assert 'ddg' not in quickmarks.marks - assert 'wiki' in quickmarks.marks + assert len_before == len(quickmarks.marks) + 1 + + +def test_url_completion_delete_history(qtmodeltester, + web_history_populated, + quickmarks, bookmarks): + """Test deleting a history entry.""" + model = urlmodel.url() + model.set_pattern('') + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + parent = model.index(2, 0) + idx = model.index(1, 0, parent) + + # sanity checks + assert model.data(parent) == "History" + assert model.data(idx) == 'https://python.org' + + assert 'https://python.org' in web_history_populated + model.delete_cur_item(idx) + assert 'https://python.org' not in web_history_populated def test_session_completion(qtmodeltester, session_manager_stub): session_manager_stub.sessions = ['default', '1', '2'] - model = miscmodels.SessionCompletionModel() + model = miscmodels.session() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { - "Sessions": [('default', '', ''), ('1', '', ''), ('2', '', '')] + "Sessions": [('1', None, None), + ('2', None, None), + ('default', None, None)] }) @@ -354,7 +420,8 @@ def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, tabbed_browser_stubs[1].tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] - model = miscmodels.TabCompletionModel() + model = miscmodels.buffer() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) @@ -370,7 +437,7 @@ def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, }) -def test_tab_completion_delete(qtmodeltester, fake_web_tab, qtbot, app_stub, +def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub, win_registry, tabbed_browser_stubs): """Verify closing a tab by deleting it from the completion widget.""" tabbed_browser_stubs[0].tabs = [ @@ -381,13 +448,19 @@ def test_tab_completion_delete(qtmodeltester, fake_web_tab, qtbot, app_stub, tabbed_browser_stubs[1].tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] - model = miscmodels.TabCompletionModel() + model = miscmodels.buffer() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - view = _mock_view_index(model, 0, 1, qtbot) - qtbot.add_widget(view) - model.delete_cur_item(view) + parent = model.index(0, 0) + idx = model.index(1, 0, parent) + + # sanity checks + assert model.data(parent) == "0" + assert model.data(idx) == '0/2' + + model.delete_cur_item(idx) actual = [tab.url() for tab in tabbed_browser_stubs[0].tabs] assert actual == [QUrl('https://github.com'), QUrl('https://duckduckgo.com')] @@ -398,15 +471,16 @@ def test_setting_section_completion(qtmodeltester, monkeypatch, stubs): _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') _patch_config_section_desc(monkeypatch, stubs, module + '.configdata.SECTION_DESC') - model = configmodel.SettingSectionCompletionModel() + model = configmodel.section() + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Sections": [ - ('general', 'General/miscellaneous options.', ''), - ('ui', 'General options related to the user interface.', ''), - ('searchengines', 'Definitions of search engines ...', ''), + ('general', 'General/miscellaneous options.', None), + ('searchengines', 'Definitions of search engines ...', None), + ('ui', 'General options related to the user interface.', None), ] }) @@ -418,7 +492,8 @@ def test_setting_option_completion(qtmodeltester, monkeypatch, stubs, config_stub.data = {'ui': {'gesture': 'off', 'mind': 'on', 'voice': 'sometimes'}} - model = configmodel.SettingOptionCompletionModel('ui') + model = configmodel.option('ui') + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) @@ -431,6 +506,12 @@ def test_setting_option_completion(qtmodeltester, monkeypatch, stubs, }) +def test_setting_option_completion_empty(monkeypatch, stubs, config_stub): + module = 'qutebrowser.completion.models.configmodel' + _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') + assert configmodel.option('typo') is None + + def test_setting_option_completion_valuelist(qtmodeltester, monkeypatch, stubs, config_stub): module = 'qutebrowser.completion.models.configmodel' @@ -440,7 +521,8 @@ def test_setting_option_completion_valuelist(qtmodeltester, monkeypatch, stubs, 'DEFAULT': 'https://duckduckgo.com/?q={}' } } - model = configmodel.SettingOptionCompletionModel('searchengines') + model = configmodel.option('searchengines') + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) @@ -454,22 +536,30 @@ def test_setting_value_completion(qtmodeltester, monkeypatch, stubs, module = 'qutebrowser.completion.models.configmodel' _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') config_stub.data = {'general': {'volume': '0'}} - model = configmodel.SettingValueCompletionModel('general', 'volume') + model = configmodel.value('general', 'volume') + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { "Current/Default": [ - ('0', 'Current value', ''), - ('11', 'Default value', ''), + ('0', 'Current value', None), + ('11', 'Default value', None), ], "Completions": [ - ('0', '', ''), - ('11', '', ''), + ('0', '', None), + ('11', '', None), ] }) +def test_setting_value_completion_empty(monkeypatch, stubs, config_stub): + module = 'qutebrowser.completion.models.configmodel' + _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') + config_stub.data = {'general': {}} + assert configmodel.value('general', 'typo') is None + + def test_bind_completion(qtmodeltester, monkeypatch, stubs, config_stub, key_config_stub): """Test the results of keybinding command completion. @@ -486,55 +576,55 @@ def test_bind_completion(qtmodeltester, monkeypatch, stubs, config_stub, key_config_stub.set_bindings_for('normal', {'s': 'stop', 'rr': 'roll', 'ro': 'rock'}) - model = miscmodels.BindCompletionModel() + model = miscmodels.bind('s') + model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) _check_completions(model, { - "Commands": [ + "Current": [ ('stop', 'stop qutebrowser', 's'), + ], + "Commands": [ ('drop', 'drop all user data', ''), ('hide', '', ''), - ('roll', 'never gonna give you up', 'rr'), ('rock', "Alias for 'roll'", 'ro'), + ('roll', 'never gonna give you up', 'rr'), + ('stop', 'stop qutebrowser', 's'), ] }) -def test_url_completion_benchmark(benchmark, config_stub, +def test_url_completion_benchmark(benchmark, quickmark_manager_stub, bookmark_manager_stub, - web_history_stub): + web_history): """Benchmark url completion.""" - config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', - 'web-history-max-items': 1000} + r = range(100000) + entries = { + 'last_atime': list(r), + 'url': ['http://example.com/{}'.format(i) for i in r], + 'title': ['title{}'.format(i) for i in r] + } - entries = [history.Entry( - atime=i, - url=QUrl('http://example.com/{}'.format(i)), - title='title{}'.format(i)) - for i in range(100000)] + web_history.completion.insert_batch(entries) - web_history_stub.history_dict = collections.OrderedDict( - ((e.url_str(), e) for e in entries)) + quickmark_manager_stub.marks = collections.OrderedDict([ + ('title{}'.format(i), 'example.com/{}'.format(i)) + for i in range(1000)]) - quickmark_manager_stub.marks = collections.OrderedDict( - (e.title, e.url_str()) - for e in entries[0:1000]) - - bookmark_manager_stub.marks = collections.OrderedDict( - (e.url_str(), e.title) - for e in entries[0:1000]) + bookmark_manager_stub.marks = collections.OrderedDict([ + ('example.com/{}'.format(i), 'title{}'.format(i)) + for i in range(1000)]) def bench(): - model = urlmodel.UrlCompletionModel() - filtermodel = sortfilter.CompletionFilterModel(model) - filtermodel.set_pattern('') - filtermodel.set_pattern('e') - filtermodel.set_pattern('ex') - filtermodel.set_pattern('ex ') - filtermodel.set_pattern('ex 1') - filtermodel.set_pattern('ex 12') - filtermodel.set_pattern('ex 123') + model = urlmodel.url() + model.set_pattern('') + model.set_pattern('e') + model.set_pattern('ex') + model.set_pattern('ex ') + model.set_pattern('ex 1') + model.set_pattern('ex 12') + model.set_pattern('ex 123') benchmark(bench) diff --git a/tests/unit/completion/test_sortfilter.py b/tests/unit/completion/test_sortfilter.py deleted file mode 100644 index 2d4a4e25d..000000000 --- a/tests/unit/completion/test_sortfilter.py +++ /dev/null @@ -1,230 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2015-2017 Florian Bruhin (The Compiler) -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see . - -"""Tests for CompletionFilterModel.""" - -import pytest - -from PyQt5.QtCore import Qt - -from qutebrowser.completion.models import base, sortfilter - - -def _create_model(data): - """Create a completion model populated with the given data. - - data: A list of lists, where each sub-list represents a category, each - tuple in the sub-list represents an item, and each value in the - tuple represents the item data for that column - """ - model = base.BaseCompletionModel() - for catdata in data: - cat = model.new_category('') - for itemdata in catdata: - model.new_item(cat, *itemdata) - return model - - -def _extract_model_data(model): - """Express a model's data as a list for easier comparison. - - Return: A list of lists, where each sub-list represents a category, each - tuple in the sub-list represents an item, and each value in the - tuple represents the item data for that column - """ - data = [] - for i in range(0, model.rowCount()): - cat_idx = model.index(i, 0) - row = [] - for j in range(0, model.rowCount(cat_idx)): - row.append((model.data(cat_idx.child(j, 0)), - model.data(cat_idx.child(j, 1)), - model.data(cat_idx.child(j, 2)))) - data.append(row) - return data - - -@pytest.mark.parametrize('pattern, data, expected', [ - ('foo', 'barfoobar', True), - ('foo bar', 'barfoobar', True), - ('foo bar', 'barfoobar', True), - ('foo bar', 'barfoobazbar', True), - ('foo bar', 'barfoobazbar', True), - ('foo', 'barFOObar', True), - ('Foo', 'barfOObar', True), - ('ab', 'aonebtwo', False), - ('33', 'l33t', True), - ('x', 'blah', False), - ('4', 'blah', False), -]) -def test_filter_accepts_row(pattern, data, expected): - source_model = base.BaseCompletionModel() - cat = source_model.new_category('test') - source_model.new_item(cat, data) - - filter_model = sortfilter.CompletionFilterModel(source_model) - filter_model.set_pattern(pattern) - assert filter_model.rowCount() == 1 # "test" category - idx = filter_model.index(0, 0) - assert idx.isValid() - - row_count = filter_model.rowCount(idx) - assert row_count == (1 if expected else 0) - - -@pytest.mark.parametrize('tree, first, last', [ - ([[('Aa',)]], 'Aa', 'Aa'), - ([[('Aa',)], [('Ba',)]], 'Aa', 'Ba'), - ([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]], - 'Aa', 'Ca'), - ([[], [('Ba',)]], 'Ba', 'Ba'), - ([[], [], [('Ca',)]], 'Ca', 'Ca'), - ([[], [], [('Ca',), ('Cb',)]], 'Ca', 'Cb'), - ([[('Aa',)], []], 'Aa', 'Aa'), - ([[('Aa',)], []], 'Aa', 'Aa'), - ([[('Aa',)], [], []], 'Aa', 'Aa'), - ([[('Aa',)], [], [('Ca',)]], 'Aa', 'Ca'), - ([[], []], None, None), -]) -def test_first_last_item(tree, first, last): - """Test that first() and last() return indexes to the first and last items. - - Args: - tree: Each list represents a completion category, with each string - being an item under that category. - first: text of the first item - last: text of the last item - """ - model = _create_model(tree) - filter_model = sortfilter.CompletionFilterModel(model) - assert filter_model.data(filter_model.first_item()) == first - assert filter_model.data(filter_model.last_item()) == last - - -def test_set_source_model(): - """Ensure setSourceModel sets source_model and clears the pattern.""" - model1 = base.BaseCompletionModel() - model2 = base.BaseCompletionModel() - filter_model = sortfilter.CompletionFilterModel(model1) - filter_model.set_pattern('foo') - # sourceModel() is cached as srcmodel, so make sure both match - assert filter_model.srcmodel is model1 - assert filter_model.sourceModel() is model1 - assert filter_model.pattern == 'foo' - filter_model.setSourceModel(model2) - assert filter_model.srcmodel is model2 - assert filter_model.sourceModel() is model2 - assert not filter_model.pattern - - -@pytest.mark.parametrize('tree, expected', [ - ([[('Aa',)]], 1), - ([[('Aa',)], [('Ba',)]], 2), - ([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]], 6), - ([[], [('Ba',)]], 1), - ([[], [], [('Ca',)]], 1), - ([[], [], [('Ca',), ('Cb',)]], 2), - ([[('Aa',)], []], 1), - ([[('Aa',)], []], 1), - ([[('Aa',)], [], []], 1), - ([[('Aa',)], [], [('Ca',)]], 2), -]) -def test_count(tree, expected): - model = _create_model(tree) - filter_model = sortfilter.CompletionFilterModel(model) - assert filter_model.count() == expected - - -@pytest.mark.parametrize('pattern, dumb_sort, filter_cols, before, after', [ - ('foo', None, [0], - [[('foo', '', ''), ('bar', '', '')]], - [[('foo', '', '')]]), - - ('foo', None, [0], - [[('foob', '', ''), ('fooc', '', ''), ('fooa', '', '')]], - [[('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')]]), - - ('foo', None, [0], - [[('foo', '', '')], [('bar', '', '')]], - [[('foo', '', '')], []]), - - # prefer foobar as it starts with the pattern - ('foo', None, [0], - [[('barfoo', '', ''), ('foobar', '', '')]], - [[('foobar', '', ''), ('barfoo', '', '')]]), - - # however, don't rearrange categories - ('foo', None, [0], - [[('barfoo', '', '')], [('foobar', '', '')]], - [[('barfoo', '', '')], [('foobar', '', '')]]), - - ('foo', None, [1], - [[('foo', 'bar', ''), ('bar', 'foo', '')]], - [[('bar', 'foo', '')]]), - - ('foo', None, [0, 1], - [[('foo', 'bar', ''), ('bar', 'foo', ''), ('bar', 'bar', '')]], - [[('foo', 'bar', ''), ('bar', 'foo', '')]]), - - ('foo', None, [0, 1, 2], - [[('foo', '', ''), ('bar', '')]], - [[('foo', '', '')]]), - - # the fourth column is the sort role, which overrides data-based sorting - ('', None, [0], - [[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]], - [[('one', '', ''), ('two', '', ''), ('three', '', '')]]), - - ('', Qt.AscendingOrder, [0], - [[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]], - [[('one', '', ''), ('two', '', ''), ('three', '', '')]]), - - ('', Qt.DescendingOrder, [0], - [[('two', '', '', 2), ('one', '', '', 1), ('three', '', '', 3)]], - [[('three', '', ''), ('two', '', ''), ('one', '', '')]]), -]) -def test_set_pattern(pattern, dumb_sort, filter_cols, before, after): - """Validate the filtering and sorting results of set_pattern.""" - model = _create_model(before) - model.DUMB_SORT = dumb_sort - model.columns_to_filter = filter_cols - filter_model = sortfilter.CompletionFilterModel(model) - filter_model.set_pattern(pattern) - actual = _extract_model_data(filter_model) - assert actual == after - - -def test_sort(): - """Ensure that a sort argument passed to sort overrides DUMB_SORT. - - While test_set_pattern above covers most of the sorting logic, this - particular case is easier to test separately. - """ - model = _create_model([[('B', '', '', 1), - ('C', '', '', 2), - ('A', '', '', 0)]]) - filter_model = sortfilter.CompletionFilterModel(model) - - filter_model.sort(0, Qt.AscendingOrder) - actual = _extract_model_data(filter_model) - assert actual == [[('A', '', ''), ('B', '', ''), ('C', '', '')]] - - filter_model.sort(0, Qt.DescendingOrder) - actual = _extract_model_data(filter_model) - assert actual == [[('C', '', ''), ('B', '', ''), ('A', '', '')]] diff --git a/tests/unit/misc/test_lineparser.py b/tests/unit/misc/test_lineparser.py index af439c006..0c78035b7 100644 --- a/tests/unit/misc/test_lineparser.py +++ b/tests/unit/misc/test_lineparser.py @@ -58,8 +58,8 @@ class TestBaseLineParser: mocker.patch('builtins.open', mock.mock_open()) with lineparser._open('r'): - with pytest.raises(IOError, match="Refusing to double-open " - "AppendLineParser."): + with pytest.raises(IOError, + match="Refusing to double-open LineParser."): with lineparser._open('r'): pass @@ -115,7 +115,8 @@ class TestLineParser: def test_double_open(self, lineparser): """Test if save() bails on an already open file.""" with lineparser._open('r'): - with pytest.raises(IOError): + with pytest.raises(IOError, + match="Refusing to double-open LineParser."): lineparser.save() def test_prepare_save(self, tmpdir, lineparser): @@ -125,83 +126,3 @@ class TestLineParser: lineparser._prepare_save = lambda: False lineparser.save() assert (tmpdir / 'file').read() == 'pristine\n' - - -class TestAppendLineParser: - - BASE_DATA = ['old data 1', 'old data 2'] - - @pytest.fixture - def lineparser(self, tmpdir): - """Fixture to get an AppendLineParser for tests.""" - lp = lineparsermod.AppendLineParser(str(tmpdir), 'file') - lp.new_data = self.BASE_DATA - lp.save() - return lp - - def _get_expected(self, new_data): - """Get the expected data with newlines.""" - return '\n'.join(self.BASE_DATA + new_data) + '\n' - - def test_save(self, tmpdir, lineparser): - """Test save().""" - new_data = ['new data 1', 'new data 2'] - lineparser.new_data = new_data - lineparser.save() - assert (tmpdir / 'file').read() == self._get_expected(new_data) - - def test_clear(self, tmpdir, lineparser): - """Check if calling clear() empties both pending and persisted data.""" - lineparser.new_data = ['one', 'two'] - lineparser.save() - assert (tmpdir / 'file').read() == "old data 1\nold data 2\none\ntwo\n" - - lineparser.new_data = ['one', 'two'] - lineparser.clear() - lineparser.save() - assert not lineparser.new_data - assert (tmpdir / 'file').read() == "" - - def test_iter_without_open(self, lineparser): - """Test __iter__ without having called open().""" - with pytest.raises(ValueError): - iter(lineparser) - - def test_iter(self, lineparser): - """Test __iter__.""" - new_data = ['new data 1', 'new data 2'] - lineparser.new_data = new_data - with lineparser.open(): - assert list(lineparser) == self.BASE_DATA + new_data - - def test_iter_not_found(self, mocker): - """Test __iter__ with no file.""" - open_mock = mocker.patch( - 'qutebrowser.misc.lineparser.AppendLineParser._open') - open_mock.side_effect = FileNotFoundError - new_data = ['new data 1', 'new data 2'] - linep = lineparsermod.AppendLineParser('foo', 'bar') - linep.new_data = new_data - with linep.open(): - assert list(linep) == new_data - - def test_get_recent_none(self, tmpdir): - """Test get_recent with no data.""" - (tmpdir / 'file2').ensure() - linep = lineparsermod.AppendLineParser(str(tmpdir), 'file2') - assert linep.get_recent() == [] - - def test_get_recent_little(self, lineparser): - """Test get_recent with little data.""" - data = [e + '\n' for e in self.BASE_DATA] - assert lineparser.get_recent() == data - - def test_get_recent_much(self, lineparser): - """Test get_recent with much data.""" - size = 64 - new_data = ['new data {}'.format(i) for i in range(size)] - lineparser.new_data = new_data - lineparser.save() - data = os.linesep.join(self.BASE_DATA + new_data) + os.linesep - data = [e + '\n' for e in data[-size:].splitlines()] - assert lineparser.get_recent(size) == data diff --git a/tests/unit/misc/test_sql.py b/tests/unit/misc/test_sql.py new file mode 100644 index 000000000..8997afc3b --- /dev/null +++ b/tests/unit/misc/test_sql.py @@ -0,0 +1,179 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Test the SQL API.""" + +import pytest +from qutebrowser.misc import sql + + +pytestmark = pytest.mark.usefixtures('init_sql') + + +def test_init(): + sql.SqlTable('Foo', ['name', 'val', 'lucky']) + # should not error if table already exists + sql.SqlTable('Foo', ['name', 'val', 'lucky']) + + +def test_insert(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + with qtbot.waitSignal(table.changed): + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + with qtbot.waitSignal(table.changed): + table.insert({'name': 'wan', 'val': 1, 'lucky': False}) + + +def test_insert_replace(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky'], + constraints={'name': 'PRIMARY KEY'}) + with qtbot.waitSignal(table.changed): + table.insert({'name': 'one', 'val': 1, 'lucky': False}, replace=True) + with qtbot.waitSignal(table.changed): + table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=True) + assert list(table) == [('one', 11, True)] + + with pytest.raises(sql.SqlException): + table.insert({'name': 'one', 'val': 11, 'lucky': True}, replace=False) + + +def test_insert_batch(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + + with qtbot.waitSignal(table.changed): + table.insert_batch({'name': ['one', 'nine', 'thirteen'], + 'val': [1, 9, 13], + 'lucky': [False, False, True]}) + + assert list(table) == [('one', 1, False), + ('nine', 9, False), + ('thirteen', 13, True)] + + +def test_insert_batch_replace(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky'], + constraints={'name': 'PRIMARY KEY'}) + + with qtbot.waitSignal(table.changed): + table.insert_batch({'name': ['one', 'nine', 'thirteen'], + 'val': [1, 9, 13], + 'lucky': [False, False, True]}) + + with qtbot.waitSignal(table.changed): + table.insert_batch({'name': ['one', 'nine'], + 'val': [11, 19], + 'lucky': [True, True]}, + replace=True) + + assert list(table) == [('thirteen', 13, True), + ('one', 11, True), + ('nine', 19, True)] + + with pytest.raises(sql.SqlException): + table.insert_batch({'name': ['one', 'nine'], + 'val': [11, 19], + 'lucky': [True, True]}) + + +def test_iter(): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + table.insert({'name': 'nine', 'val': 9, 'lucky': False}) + table.insert({'name': 'thirteen', 'val': 13, 'lucky': True}) + assert list(table) == [('one', 1, False), + ('nine', 9, False), + ('thirteen', 13, True)] + + +@pytest.mark.parametrize('rows, sort_by, sort_order, limit, result', [ + ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'asc', 5, + [(1, 6), (2, 5), (3, 4)]), + ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'desc', 3, + [(3, 4), (2, 5), (1, 6)]), + ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'b', 'desc', 2, + [(1, 6), (2, 5)]), + ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'asc', -1, + [(1, 6), (2, 5), (3, 4)]), +]) +def test_select(rows, sort_by, sort_order, limit, result): + table = sql.SqlTable('Foo', ['a', 'b']) + for row in rows: + table.insert(row) + assert list(table.select(sort_by, sort_order, limit)) == result + + +def test_delete(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + table.insert({'name': 'nine', 'val': 9, 'lucky': False}) + table.insert({'name': 'thirteen', 'val': 13, 'lucky': True}) + with pytest.raises(KeyError): + table.delete('name', 'nope') + with qtbot.waitSignal(table.changed): + table.delete('name', 'thirteen') + assert list(table) == [('one', 1, False), ('nine', 9, False)] + with qtbot.waitSignal(table.changed): + table.delete('lucky', False) + assert not list(table) + + +def test_len(): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + assert len(table) == 0 + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + assert len(table) == 1 + table.insert({'name': 'nine', 'val': 9, 'lucky': False}) + assert len(table) == 2 + table.insert({'name': 'thirteen', 'val': 13, 'lucky': True}) + assert len(table) == 3 + + +def test_contains(): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + table.insert({'name': 'nine', 'val': 9, 'lucky': False}) + table.insert({'name': 'thirteen', 'val': 13, 'lucky': True}) + + name_query = table.contains_query('name') + val_query = table.contains_query('val') + lucky_query = table.contains_query('lucky') + + assert name_query.run(val='one').value() + assert name_query.run(val='thirteen').value() + assert val_query.run(val=9).value() + assert lucky_query.run(val=False).value() + assert lucky_query.run(val=True).value() + assert not name_query.run(val='oone').value() + assert not name_query.run(val=1).value() + assert not name_query.run(val='*').value() + assert not val_query.run(val=10).value() + + +def test_delete_all(qtbot): + table = sql.SqlTable('Foo', ['name', 'val', 'lucky']) + table.insert({'name': 'one', 'val': 1, 'lucky': False}) + table.insert({'name': 'nine', 'val': 9, 'lucky': False}) + table.insert({'name': 'thirteen', 'val': 13, 'lucky': True}) + with qtbot.waitSignal(table.changed): + table.delete_all() + assert list(table) == [] + + +def test_version(): + assert isinstance(sql.version(), str) diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index 4b03dcf8c..47595ebf5 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -759,37 +759,6 @@ def test_sanitize_filename_empty_replacement(): assert utils.sanitize_filename(name, replacement=None) == 'Bad File' -class TestNewestSlice: - - """Test newest_slice.""" - - def test_count_minus_two(self): - """Test with a count of -2.""" - with pytest.raises(ValueError): - utils.newest_slice([], -2) - - @pytest.mark.parametrize('items, count, expected', [ - # Count of -1 (all elements). - (range(20), -1, range(20)), - # Count of 0 (no elements). - (range(20), 0, []), - # Count which is much smaller than the iterable. - (range(20), 5, [15, 16, 17, 18, 19]), - # Count which is exactly one smaller.""" - (range(5), 4, [1, 2, 3, 4]), - # Count which is just as large as the iterable.""" - (range(5), 5, range(5)), - # Count which is one bigger than the iterable. - (range(5), 6, range(5)), - # Count which is much bigger than the iterable. - (range(5), 50, range(5)), - ]) - def test_good(self, items, count, expected): - """Test slices which shouldn't raise an exception.""" - sliced = utils.newest_slice(items, count) - assert list(sliced) == list(expected) - - class TestGetSetClipboard: @pytest.fixture(autouse=True) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 4c9baf6eb..339e70987 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -849,6 +849,7 @@ def test_version_output(params, stubs, monkeypatch): if params.style else stubs.FakeQApplication(instance=None)), 'QLibraryInfo.location': (lambda _loc: 'QT PATH'), + 'sql.version': lambda: 'SQLITE VERSION', } substitutions = { @@ -909,6 +910,7 @@ def test_version_output(params, stubs, monkeypatch): MODULE VERSION 1 MODULE VERSION 2 pdf.js: PDFJS VERSION + sqlite: SQLITE VERSION QtNetwork SSL: {ssl} {style} Platform: PLATFORM, ARCHITECTURE{linuxdist}