Add session support.
Closes #12. See #499. See #11. This adds PyYAML as a new dependency. It adds the following new commands: :session-delete <name> Delete a session. :session-load <name> Load a session. :session-save [<name>] Save a session. :wq [<name>] Save open pages and quit. And the following new settings: general -> save-session: Whether to always save the open pages.
This commit is contained in:
parent
53b024f246
commit
8f1d81a644
3
.flake8
3
.flake8
@ -6,5 +6,6 @@
|
||||
# F841: unused variable
|
||||
# F401: Unused import
|
||||
# E402: module level import not at top of file
|
||||
ignore=E265,E501,F841,F401,E402
|
||||
# E266: too many leading '#' for block comment
|
||||
ignore=E265,E501,F841,F401,E402,E266
|
||||
max_complexity = 12
|
||||
|
@ -93,6 +93,7 @@ The following software and libraries are required to run qutebrowser:
|
||||
* http://fdik.org/pyPEG/[pyPEG2]
|
||||
* http://jinja.pocoo.org/[jinja2]
|
||||
* http://pygments.org/[pygments]
|
||||
* http://pyyaml.org/wiki/PyYAML[PyYAML]
|
||||
|
||||
To generate the documentation for the `:help` command, when using the git
|
||||
repository (rather than a release), http://asciidoc.org/[asciidoc] is needed.
|
||||
|
@ -36,6 +36,9 @@
|
||||
|<<restart,restart>>|Restart qutebrowser while keeping existing tabs open.
|
||||
|<<save,save>>|Save configs and state.
|
||||
|<<search,search>>|Search for a text on the current page.
|
||||
|<<session-delete,session-delete>>|Delete a session.
|
||||
|<<session-load,session-load>>|Load a session.
|
||||
|<<session-save,session-save>>|Save a session.
|
||||
|<<set,set>>|Set an option.
|
||||
|<<set-cmd-text,set-cmd-text>>|Preset the statusbar to some text.
|
||||
|<<spawn,spawn>>|Spawn a command in a shell.
|
||||
@ -50,6 +53,7 @@
|
||||
|<<unbind,unbind>>|Unbind a keychain.
|
||||
|<<undo,undo>>|Re-open a closed tab (optionally skipping [count] closed tabs).
|
||||
|<<view-source,view-source>>|Show the source of the current page.
|
||||
|<<wq,wq>>|Save open pages and quit.
|
||||
|<<yank,yank>>|Yank the current URL/title to the clipboard or primary selection.
|
||||
|<<zoom,zoom>>|Set the zoom level for the current tab.
|
||||
|<<zoom-in,zoom-in>>|Increase the zoom level for the current tab.
|
||||
@ -396,6 +400,33 @@ Search for a text on the current page.
|
||||
==== optional arguments
|
||||
* +*-r*+, +*--reverse*+: Reverse search direction.
|
||||
|
||||
[[session-delete]]
|
||||
=== session-delete
|
||||
Syntax: +:session-delete 'name'+
|
||||
|
||||
Delete a session.
|
||||
|
||||
==== positional arguments
|
||||
* +'name'+: The name of the session.
|
||||
|
||||
[[session-load]]
|
||||
=== session-load
|
||||
Syntax: +:session-load 'name'+
|
||||
|
||||
Load a session.
|
||||
|
||||
==== positional arguments
|
||||
* +'name'+: The name of the session.
|
||||
|
||||
[[session-save]]
|
||||
=== session-save
|
||||
Syntax: +:session-save ['name']+
|
||||
|
||||
Save a session.
|
||||
|
||||
==== positional arguments
|
||||
* +'name'+: The name of the session.
|
||||
|
||||
[[set]]
|
||||
=== set
|
||||
Syntax: +:set [*--temp*] [*--print*] ['section'] ['option'] ['value']+
|
||||
@ -538,6 +569,15 @@ Re-open a closed tab (optionally skipping [count] closed tabs).
|
||||
=== view-source
|
||||
Show the source of the current page.
|
||||
|
||||
[[wq]]
|
||||
=== wq
|
||||
Syntax: +:wq ['name']+
|
||||
|
||||
Save open pages and quit.
|
||||
|
||||
==== positional arguments
|
||||
* +'name'+: The name of the session.
|
||||
|
||||
[[yank]]
|
||||
=== yank
|
||||
Syntax: +:yank [*--title*] [*--sel*]+
|
||||
|
@ -20,6 +20,7 @@
|
||||
|<<general-default-encoding,default-encoding>>|Default encoding to use for websites.
|
||||
|<<general-new-instance-open-target,new-instance-open-target>>|How to open links in an existing instance if a new one is launched.
|
||||
|<<general-log-javascript-console,log-javascript-console>>|Whether to log javascript console messages.
|
||||
|<<general-save-session,save-session>>|Whether to always save the open pages.
|
||||
|==============
|
||||
|
||||
.Quick reference for section ``ui''
|
||||
@ -386,6 +387,17 @@ Valid values:
|
||||
|
||||
Default: +pass:[false]+
|
||||
|
||||
[[general-save-session]]
|
||||
=== save-session
|
||||
Whether to always save the open pages.
|
||||
|
||||
Valid values:
|
||||
|
||||
* +true+
|
||||
* +false+
|
||||
|
||||
Default: +pass:[false]+
|
||||
|
||||
== ui
|
||||
General options related to the user interface.
|
||||
|
||||
|
@ -43,6 +43,9 @@ It was inspired by other browsers/addons like dwb and Vimperator/Pentadactyl.
|
||||
*-s* 'SECTION' 'OPTION' 'VALUE', *--set* 'SECTION' 'OPTION' 'VALUE'::
|
||||
Set a temporary setting for this session.
|
||||
|
||||
*-r* 'SESSION', *--restore* 'SESSION'::
|
||||
Restore a named session.
|
||||
|
||||
=== debug arguments
|
||||
*-l* 'LOGLEVEL', *--loglevel* 'LOGLEVEL'::
|
||||
Set loglevel
|
||||
|
@ -43,7 +43,8 @@ from qutebrowser.config import style, config, websettings, configexc
|
||||
from qutebrowser.browser import quickmarks, cookies, cache, adblock, history
|
||||
from qutebrowser.browser.network import qutescheme, proxy
|
||||
from qutebrowser.mainwindow import mainwindow
|
||||
from qutebrowser.misc import crashdialog, readline, ipc, earlyinit, savemanager
|
||||
from qutebrowser.misc import (crashdialog, readline, ipc, earlyinit,
|
||||
savemanager, sessions)
|
||||
from qutebrowser.misc import utilcmds # pylint: disable=unused-import
|
||||
from qutebrowser.keyinput import modeman
|
||||
from qutebrowser.utils import (log, version, message, utils, qtutils, urlutils,
|
||||
@ -179,6 +180,9 @@ class Application(QApplication):
|
||||
history.init()
|
||||
log.init.debug("Initializing crashlog...")
|
||||
self._handle_segfault()
|
||||
log.init.debug("Initializing sessions...")
|
||||
session_manager = sessions.SessionManager(self)
|
||||
objreg.register('session-manager', session_manager)
|
||||
log.init.debug("Initializing js-bridge...")
|
||||
js_bridge = qutescheme.JSBridge(self)
|
||||
objreg.register('js-bridge', js_bridge)
|
||||
@ -199,11 +203,13 @@ class Application(QApplication):
|
||||
log.init.debug("Initializing cache...")
|
||||
diskcache = cache.DiskCache(self)
|
||||
objreg.register('cache', diskcache)
|
||||
log.init.debug("Initializing main window...")
|
||||
win_id = mainwindow.MainWindow.spawn(
|
||||
False if self._args.nowindow else True)
|
||||
main_window = objreg.get('main-window', scope='window', window=win_id)
|
||||
self.setActiveWindow(main_window)
|
||||
if not session_manager.exists(self._args.session):
|
||||
log.init.debug("Initializing main window...")
|
||||
win_id = mainwindow.MainWindow.spawn(
|
||||
False if self._args.nowindow else True)
|
||||
main_window = objreg.get('main-window', scope='window',
|
||||
window=win_id)
|
||||
self.setActiveWindow(main_window)
|
||||
|
||||
def _init_icon(self):
|
||||
"""Initialize the icon of qutebrowser."""
|
||||
@ -261,10 +267,27 @@ class Application(QApplication):
|
||||
except (configexc.Error, configparser.Error) as e:
|
||||
message.error('current', "set: {} - {}".format(
|
||||
e.__class__.__name__, e))
|
||||
self._load_session(self._args.session)
|
||||
self.process_pos_args(self._args.command)
|
||||
self._open_startpage()
|
||||
self._open_quickstart()
|
||||
|
||||
def _load_session(self, name):
|
||||
"""Load the default session.
|
||||
|
||||
Args:
|
||||
name: The name of the session to load.
|
||||
"""
|
||||
session_manager = objreg.get('session-manager')
|
||||
try:
|
||||
session_manager.load(name)
|
||||
except sessions.SessionNotFoundError:
|
||||
pass
|
||||
except sessions.SessionError:
|
||||
log.init.exception("Failed to load default session")
|
||||
else:
|
||||
session_manager.delete('default')
|
||||
|
||||
def _get_window(self, via_ipc, force_window=False, force_tab=False):
|
||||
"""Helper function for process_pos_args to get a window id.
|
||||
|
||||
|
179
qutebrowser/browser/tabhistory.py
Normal file
179
qutebrowser/browser/tabhistory.py
Normal file
@ -0,0 +1,179 @@
|
||||
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
||||
|
||||
# Copyright 2015 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
|
||||
#
|
||||
# This file is part of qutebrowser.
|
||||
#
|
||||
# qutebrowser is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# qutebrowser is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Utilities related to QWebHistory."""
|
||||
|
||||
|
||||
from PyQt5.QtCore import QByteArray, QDataStream, QIODevice, QUrl
|
||||
|
||||
from qutebrowser.utils import utils, qtutils
|
||||
|
||||
|
||||
HISTORY_STREAM_VERSION = 2
|
||||
BACK_FORWARD_TREE_VERSION = 2
|
||||
|
||||
|
||||
class TabHistoryItem:
|
||||
|
||||
"""A single item in the tab history.
|
||||
|
||||
Attributes:
|
||||
url: The QUrl of this item.
|
||||
title: The title as string of this item.
|
||||
active: Whether this item is the item currently navigated to.
|
||||
user_data: The user data for this item.
|
||||
"""
|
||||
|
||||
def __init__(self, url, original_url, title, active=False, user_data=None):
|
||||
self.url = url
|
||||
self.original_url = original_url
|
||||
self.title = title
|
||||
self.active = active
|
||||
self.user_data = user_data
|
||||
|
||||
def __repr__(self):
|
||||
return utils.get_repr(self, constructor=True, url=self.url,
|
||||
original_url=self.original_url, title=self.title,
|
||||
active=self.active, user_data=self.user_data)
|
||||
|
||||
|
||||
def _encode_url(url):
|
||||
"""Encode an QUrl suitable to pass to QWebHistory."""
|
||||
data = bytes(QUrl.toPercentEncoding(url.toString(), b':/#?&+=@%*'))
|
||||
return data.decode('ascii')
|
||||
|
||||
|
||||
def _serialize_item(i, item, stream):
|
||||
"""Serialize a single WebHistoryItem into a QDataStream.
|
||||
|
||||
Args:
|
||||
i: The index of the current item.
|
||||
item: The WebHistoryItem to write.
|
||||
stream: The QDataStream to write to.
|
||||
"""
|
||||
### Source/WebCore/history/qt/HistoryItemQt.cpp restoreState
|
||||
## urlString
|
||||
stream.writeQString(_encode_url(item.url))
|
||||
## title
|
||||
stream.writeQString(item.title)
|
||||
## originalURLString
|
||||
stream.writeQString(_encode_url(item.original_url))
|
||||
|
||||
### Source/WebCore/history/HistoryItem.cpp decodeBackForwardTree
|
||||
## backForwardTreeEncodingVersion
|
||||
stream.writeUInt32(BACK_FORWARD_TREE_VERSION)
|
||||
## size (recursion stack)
|
||||
stream.writeUInt64(0)
|
||||
## node->m_documentSequenceNumber
|
||||
# If two HistoryItems have the same document sequence number, then they
|
||||
# refer to the same instance of a document. Traversing history from one
|
||||
# such HistoryItem to another preserves the document.
|
||||
stream.writeInt64(i + 1)
|
||||
## size (node->m_documentState)
|
||||
stream.writeUInt64(0)
|
||||
## node->m_formContentType
|
||||
# info used to repost form data
|
||||
stream.writeQString(None)
|
||||
## hasFormData
|
||||
stream.writeBool(False)
|
||||
## node->m_itemSequenceNumber
|
||||
# If two HistoryItems have the same item sequence number, then they are
|
||||
# clones of one another. Traversing history from one such HistoryItem to
|
||||
# another is a no-op. HistoryItem clones are created for parent and
|
||||
# sibling frames when only a subframe navigates.
|
||||
stream.writeInt64(i + 1)
|
||||
## node->m_referrer
|
||||
stream.writeQString(None)
|
||||
## node->m_scrollPoint (x)
|
||||
try:
|
||||
stream.writeInt32(item.user_data['scroll-pos'].x())
|
||||
except (KeyError, TypeError):
|
||||
stream.writeInt32(0)
|
||||
## node->m_scrollPoint (y)
|
||||
try:
|
||||
stream.writeInt32(item.user_data['scroll-pos'].y())
|
||||
except (KeyError, TypeError):
|
||||
stream.writeInt32(0)
|
||||
## node->m_pageScaleFactor
|
||||
try:
|
||||
stream.writeFloat(item.user_data['zoom'])
|
||||
except (KeyError, TypeError):
|
||||
stream.writeFloat(1)
|
||||
## hasStateObject
|
||||
# Support for HTML5 History
|
||||
stream.writeBool(False)
|
||||
## node->m_target
|
||||
stream.writeQString(None)
|
||||
|
||||
### Source/WebCore/history/qt/HistoryItemQt.cpp restoreState
|
||||
## validUserData
|
||||
# We could restore the user data here, but we prefer to use the
|
||||
# QWebHistoryItem API for that.
|
||||
stream.writeBool(False)
|
||||
|
||||
|
||||
def serialize(items):
|
||||
"""Serialize a list of QWebHistoryItems to a data stream.
|
||||
|
||||
Args:
|
||||
items: An iterable of WebHistoryItems.
|
||||
|
||||
Return:
|
||||
A (stream, data, user_data) tuple.
|
||||
stream: The resetted QDataStream.
|
||||
data: The QByteArray with the raw data.
|
||||
user_data: A list with each item's user data.
|
||||
|
||||
Warning:
|
||||
If 'data' goes out of scope, reading from 'stream' will result in a
|
||||
segfault!
|
||||
"""
|
||||
|
||||
data = QByteArray()
|
||||
stream = QDataStream(data, QIODevice.ReadWrite)
|
||||
user_data = []
|
||||
|
||||
current_idx = None
|
||||
|
||||
for i, item in enumerate(items):
|
||||
if item.active:
|
||||
if current_idx is not None:
|
||||
raise ValueError("Multiple active items ({} and {}) "
|
||||
"found!".format(current_idx, i))
|
||||
else:
|
||||
current_idx = i
|
||||
|
||||
if items:
|
||||
if current_idx is None:
|
||||
raise ValueError("No active item found!")
|
||||
else:
|
||||
current_idx = 0
|
||||
|
||||
### Source/WebKit/qt/Api/qwebhistory.cpp operator<<
|
||||
stream.writeInt(HISTORY_STREAM_VERSION)
|
||||
stream.writeInt(len(items))
|
||||
stream.writeInt(current_idx)
|
||||
|
||||
for i, item in enumerate(items):
|
||||
_serialize_item(i, item, stream)
|
||||
user_data.append(item.user_data)
|
||||
|
||||
stream.device().reset()
|
||||
qtutils.check_qdatastream(stream)
|
||||
return stream, data, user_data
|
@ -21,7 +21,8 @@
|
||||
|
||||
import functools
|
||||
|
||||
from PyQt5.QtCore import pyqtSlot, pyqtSignal, PYQT_VERSION, Qt, QUrl
|
||||
from PyQt5.QtCore import (pyqtSlot, pyqtSignal, PYQT_VERSION, Qt, QUrl, QPoint,
|
||||
QTimer)
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
|
||||
from PyQt5.QtWidgets import QFileDialog
|
||||
@ -29,7 +30,7 @@ from PyQt5.QtPrintSupport import QPrintDialog
|
||||
from PyQt5.QtWebKitWidgets import QWebPage
|
||||
|
||||
from qutebrowser.config import config
|
||||
from qutebrowser.browser import http
|
||||
from qutebrowser.browser import http, tabhistory
|
||||
from qutebrowser.browser.network import networkmanager
|
||||
from qutebrowser.utils import (message, usertypes, log, jinja, qtutils, utils,
|
||||
objreg)
|
||||
@ -73,6 +74,10 @@ class BrowserPage(QWebPage):
|
||||
self.loadStarted.connect(self.on_load_started)
|
||||
self.featurePermissionRequested.connect(
|
||||
self.on_feature_permission_requested)
|
||||
self.saveFrameStateRequested.connect(
|
||||
self.on_save_frame_state_requested)
|
||||
self.restoreFrameStateRequested.connect(
|
||||
self.on_restore_frame_state_requested)
|
||||
|
||||
if PYQT_VERSION > 0x050300:
|
||||
# WORKAROUND (remove this when we bump the requirements to 5.3.1)
|
||||
@ -213,6 +218,23 @@ class BrowserPage(QWebPage):
|
||||
else:
|
||||
nam.shutdown()
|
||||
|
||||
def load_history(self, entries):
|
||||
"""Load the history from a list of TabHistoryItem objects."""
|
||||
stream, _data, user_data = tabhistory.serialize(entries)
|
||||
history = self.history()
|
||||
qtutils.deserialize_stream(stream, history)
|
||||
for i, data in enumerate(user_data):
|
||||
history.itemAt(i).setUserData(data)
|
||||
cur_data = history.currentItem().userData()
|
||||
if cur_data is not None:
|
||||
frame = self.mainFrame()
|
||||
if 'zoom' in cur_data:
|
||||
frame.setZoomFactor(cur_data['zoom'])
|
||||
if ('scroll-pos' in cur_data and
|
||||
frame.scrollPosition() == QPoint(0, 0)):
|
||||
QTimer.singleShot(0, functools.partial(
|
||||
frame.setScrollPosition, cur_data['scroll-pos']))
|
||||
|
||||
def display_content(self, reply, mimetype):
|
||||
"""Display a QNetworkReply with an explicitely set mimetype."""
|
||||
self.mainFrame().setContent(reply.readAll(), mimetype, reply.url())
|
||||
@ -338,6 +360,37 @@ class BrowserPage(QWebPage):
|
||||
if frame is cancelled_frame and feature == cancelled_feature:
|
||||
question.abort()
|
||||
|
||||
def on_save_frame_state_requested(self, frame, item):
|
||||
"""Save scroll position and zoom in history.
|
||||
|
||||
Args:
|
||||
frame: The QWebFrame which gets saved.
|
||||
item: The QWebHistoryItem to be saved.
|
||||
"""
|
||||
if frame != self.mainFrame():
|
||||
return
|
||||
data = {
|
||||
'zoom': frame.zoomFactor(),
|
||||
'scroll-pos': frame.scrollPosition(),
|
||||
}
|
||||
item.setUserData(data)
|
||||
|
||||
def on_restore_frame_state_requested(self, frame):
|
||||
"""Restore scroll position and zoom from history.
|
||||
|
||||
Args:
|
||||
frame: The QWebFrame which gets restored.
|
||||
"""
|
||||
if frame != self.mainFrame():
|
||||
return
|
||||
data = self.history().currentItem().userData()
|
||||
if data is None:
|
||||
return
|
||||
if 'zoom' in data:
|
||||
frame.setZoomFactor(data['zoom'])
|
||||
if 'scroll-pos' in data and frame.scrollPosition() == QPoint(0, 0):
|
||||
frame.setScrollPosition(data['scroll-pos'])
|
||||
|
||||
def userAgentForUrl(self, url):
|
||||
"""Override QWebPage::userAgentForUrl to customize the user agent."""
|
||||
ua = config.get('network', 'user-agent')
|
||||
|
@ -61,6 +61,7 @@ class Completer(QObject):
|
||||
self._init_static_completions()
|
||||
self._init_setting_completions()
|
||||
self.init_quickmark_completions()
|
||||
self.init_session_completion()
|
||||
self._timer = QTimer()
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.setInterval(0)
|
||||
@ -114,6 +115,16 @@ class Completer(QObject):
|
||||
self._models[usertypes.Completion.quickmark_by_name] = CFM(
|
||||
models.QuickmarkCompletionModel('name', self), self)
|
||||
|
||||
@pyqtSlot()
|
||||
def init_session_completion(self):
|
||||
"""Initialize session completion model."""
|
||||
try:
|
||||
self._models[usertypes.Completion.sessions].deleteLater()
|
||||
except KeyError:
|
||||
pass
|
||||
self._models[usertypes.Completion.sessions] = CFM(
|
||||
models.SessionCompletionModel(self), self)
|
||||
|
||||
def _get_completion_model(self, completion, parts, cursor_part):
|
||||
"""Get a completion model based on an enum member.
|
||||
|
||||
|
@ -236,3 +236,16 @@ class QuickmarkCompletionModel(base.BaseCompletionModel):
|
||||
else:
|
||||
raise ValueError("Invalid value '{}' for match_field!".format(
|
||||
match_field))
|
||||
|
||||
|
||||
class SessionCompletionModel(base.BaseCompletionModel):
|
||||
|
||||
"""A CompletionModel filled with session names."""
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
cat = self.new_category("Sessions")
|
||||
for name in objreg.get('session-manager').list_sessions():
|
||||
self.new_item(cat, name)
|
||||
|
@ -193,6 +193,10 @@ DATA = collections.OrderedDict([
|
||||
('log-javascript-console',
|
||||
SettingValue(typ.Bool(), 'false'),
|
||||
"Whether to log javascript console messages."),
|
||||
|
||||
('save-session',
|
||||
SettingValue(typ.Bool(), 'false'),
|
||||
"Whether to always save the open pages."),
|
||||
)),
|
||||
|
||||
('ui', sect.KeyValue(
|
||||
|
@ -54,7 +54,14 @@ class MainWindow(QWidget):
|
||||
_commandrunner: The main CommandRunner instance.
|
||||
"""
|
||||
|
||||
def __init__(self, win_id, parent=None):
|
||||
def __init__(self, win_id, geometry=None, parent=None):
|
||||
"""Create a new main window.
|
||||
|
||||
Args:
|
||||
win_id: The ID the new window whouls get.
|
||||
geometry: The geometry to load, as a bytes-object (or None).
|
||||
parent: The parent the window should get.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose)
|
||||
self._commandrunner = None
|
||||
@ -71,8 +78,10 @@ class MainWindow(QWidget):
|
||||
window=win_id)
|
||||
|
||||
self.setWindowTitle('qutebrowser')
|
||||
if win_id == 0:
|
||||
self._load_geometry()
|
||||
if geometry is not None:
|
||||
self._load_geometry(geometry)
|
||||
elif win_id == 0:
|
||||
self._load_state_geometry()
|
||||
else:
|
||||
self._set_default_geometry()
|
||||
log.init.debug("Initial mainwindow geometry: {}".format(
|
||||
@ -134,27 +143,27 @@ class MainWindow(QWidget):
|
||||
self.resize_completion()
|
||||
|
||||
@classmethod
|
||||
def spawn(cls, show=True):
|
||||
def spawn(cls, show=True, geometry=None):
|
||||
"""Create a new main window.
|
||||
|
||||
Args:
|
||||
show: Show the window after creating.
|
||||
geometry: The geometry to load, as a bytes-object.
|
||||
|
||||
Return:
|
||||
The new window id.
|
||||
"""
|
||||
win_id = next(win_id_gen)
|
||||
win = MainWindow(win_id)
|
||||
win = MainWindow(win_id, geometry=geometry)
|
||||
if show:
|
||||
win.show()
|
||||
return win_id
|
||||
|
||||
def _load_geometry(self):
|
||||
def _load_state_geometry(self):
|
||||
"""Load the geometry from the state file."""
|
||||
state_config = objreg.get('state-config')
|
||||
try:
|
||||
data = state_config['geometry']['mainwindow']
|
||||
log.init.debug("Restoring mainwindow from {}".format(data))
|
||||
geom = base64.b64decode(data, validate=True)
|
||||
except KeyError:
|
||||
# First start
|
||||
@ -163,14 +172,18 @@ class MainWindow(QWidget):
|
||||
log.init.exception("Error while reading geometry")
|
||||
self._set_default_geometry()
|
||||
else:
|
||||
try:
|
||||
ok = self.restoreGeometry(geom)
|
||||
except KeyError:
|
||||
log.init.exception("Error while restoring geometry.")
|
||||
self._set_default_geometry()
|
||||
if not ok:
|
||||
log.init.warning("Error while restoring geometry.")
|
||||
self._set_default_geometry()
|
||||
self._load_geometry(geom)
|
||||
|
||||
def _load_geometry(self, geom):
|
||||
"""Load geometry from a bytes object.
|
||||
|
||||
If loading fails, loads default geometry.
|
||||
"""
|
||||
log.init.debug("Loading mainwindow from {}".format(geom))
|
||||
ok = self.restoreGeometry(geom)
|
||||
if not ok:
|
||||
log.init.warning("Error while loading geometry.")
|
||||
self._set_default_geometry()
|
||||
|
||||
def _connect_resize_completion(self):
|
||||
"""Connect the resize_completion signal and resize it once."""
|
||||
@ -266,6 +279,11 @@ class MainWindow(QWidget):
|
||||
quickmark_manager = objreg.get('quickmark-manager')
|
||||
quickmark_manager.changed.connect(completer.init_quickmark_completions)
|
||||
|
||||
# sessions completion
|
||||
session_manager = objreg.get('session-manager')
|
||||
session_manager.update_completion.connect(
|
||||
completer.init_session_completion)
|
||||
|
||||
@pyqtSlot()
|
||||
def resize_completion(self):
|
||||
"""Adjust completion according to config."""
|
||||
|
@ -245,6 +245,14 @@ def check_libraries():
|
||||
windows="Install from http://www.lfd.uci.edu/"
|
||||
"~gohlke/pythonlibs/#pygments or via pip.",
|
||||
pip="pygments"),
|
||||
'yaml':
|
||||
_missing_str("PyYAML",
|
||||
debian="apt-get install python3-yaml",
|
||||
arch="pacman -S python-yaml",
|
||||
windows="Use the installers at "
|
||||
"http://pyyaml.org/download/pyyaml/ (py3.4) "
|
||||
"or Install via pip.",
|
||||
pip="PyYAML"),
|
||||
}
|
||||
for name, text in modules.items():
|
||||
try:
|
||||
|
298
qutebrowser/misc/sessions.py
Normal file
298
qutebrowser/misc/sessions.py
Normal file
@ -0,0 +1,298 @@
|
||||
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
||||
|
||||
# Copyright 2015 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
|
||||
#
|
||||
# This file is part of qutebrowser.
|
||||
#
|
||||
# qutebrowser is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# qutebrowser is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Management of sessions - saved tabs/windows."""
|
||||
|
||||
import os
|
||||
import os.path
|
||||
import functools
|
||||
|
||||
from PyQt5.QtCore import (pyqtSignal, QStandardPaths, QUrl, QObject, QPoint,
|
||||
QTimer)
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
import yaml
|
||||
try:
|
||||
from yaml import CSafeLoader as YamlLoader, CSafeDumper as YamlDumper
|
||||
except ImportError:
|
||||
from yaml import SafeLoader as YamlLoader, SafeDumper as YamlDumper
|
||||
|
||||
from qutebrowser.browser import tabhistory
|
||||
from qutebrowser.utils import standarddir, objreg, qtutils, log, usertypes
|
||||
from qutebrowser.commands import cmdexc, cmdutils
|
||||
from qutebrowser.mainwindow import mainwindow
|
||||
|
||||
|
||||
class SessionError(Exception):
|
||||
|
||||
"""Exception raised when a session failed to load/save."""
|
||||
|
||||
|
||||
class SessionNotFoundError(SessionError):
|
||||
|
||||
"""Exception raised when a session to be loaded was not found."""
|
||||
|
||||
|
||||
class SessionManager(QObject):
|
||||
|
||||
"""Manager for sessions.
|
||||
|
||||
Attributes:
|
||||
_base_path: The path to store sessions under.
|
||||
|
||||
Signals:
|
||||
update_completion: Emitted when the session completion should get
|
||||
updated.
|
||||
"""
|
||||
|
||||
update_completion = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
save_manager = objreg.get('save-manager')
|
||||
save_manager.add_saveable(
|
||||
'default-session', functools.partial(self.save, 'default'),
|
||||
config_opt=('general', 'save-session'))
|
||||
self._base_path = os.path.join(
|
||||
standarddir.get(QStandardPaths.DataLocation), 'sessions')
|
||||
if not os.path.exists(self._base_path):
|
||||
os.mkdir(self._base_path)
|
||||
|
||||
def _get_session_path(self, name, check_exists=False):
|
||||
"""Get the session path based on a session name or absolute path.
|
||||
|
||||
Args:
|
||||
name: The name of the session.
|
||||
check_exists: Whether it should also be checked if the session
|
||||
exists.
|
||||
"""
|
||||
path = os.path.expanduser(name)
|
||||
if os.path.isabs(path) and ((not check_exists) or
|
||||
os.path.exists(path)):
|
||||
return path
|
||||
else:
|
||||
path = os.path.join(self._base_path, name + '.yml')
|
||||
if check_exists and not os.path.exists(path):
|
||||
raise SessionNotFoundError(path)
|
||||
else:
|
||||
return path
|
||||
|
||||
def exists(self, name):
|
||||
"""Check if a named session exists."""
|
||||
try:
|
||||
self._get_session_path(name, check_exists=True)
|
||||
except SessionNotFoundError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def _save_tab(self, tab, active):
|
||||
"""Get a dict with data for a single tab.
|
||||
|
||||
Args:
|
||||
tab: The WebView to save.
|
||||
active: Whether the tab is currently active.
|
||||
"""
|
||||
data = {'history': []}
|
||||
if active:
|
||||
data['active'] = True
|
||||
history = tab.page().history()
|
||||
for idx, item in enumerate(history.items()):
|
||||
qtutils.ensure_valid(item)
|
||||
item_data = {
|
||||
'url': bytes(item.url().toEncoded()).decode('ascii'),
|
||||
'title': item.title(),
|
||||
}
|
||||
if item.originalUrl() != item.url():
|
||||
encoded = item.originalUrl().toEncoded()
|
||||
item_data['original-url'] = bytes(encoded).decode('ascii')
|
||||
user_data = item.userData()
|
||||
if history.currentItemIndex() == idx:
|
||||
item_data['active'] = True
|
||||
if user_data is None:
|
||||
pos = tab.page().mainFrame().scrollPosition()
|
||||
data['zoom'] = tab.zoomFactor()
|
||||
data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()}
|
||||
data['history'].append(item_data)
|
||||
|
||||
if user_data is not None:
|
||||
if 'zoom' in user_data:
|
||||
data['zoom'] = user_data['zoom']
|
||||
if 'scroll-pos' in user_data:
|
||||
pos = user_data['scroll-pos']
|
||||
data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()}
|
||||
return data
|
||||
|
||||
def _save_all(self):
|
||||
"""Get a dict with data for all windows/tabs."""
|
||||
data = {'windows': []}
|
||||
for win_id in objreg.window_registry:
|
||||
tabbed_browser = objreg.get('tabbed-browser', scope='window',
|
||||
window=win_id)
|
||||
main_window = objreg.get('main-window', scope='window',
|
||||
window=win_id)
|
||||
win_data = {}
|
||||
active_window = QApplication.instance().activeWindow()
|
||||
if getattr(active_window, 'win_id', None) == win_id:
|
||||
win_data['active'] = True
|
||||
win_data['geometry'] = bytes(main_window.saveGeometry())
|
||||
win_data['tabs'] = []
|
||||
for i, tab in enumerate(tabbed_browser.widgets()):
|
||||
active = i == tabbed_browser.currentIndex()
|
||||
win_data['tabs'].append(self._save_tab(tab, active))
|
||||
data['windows'].append(win_data)
|
||||
return data
|
||||
|
||||
def save(self, name):
|
||||
"""Save a named session."""
|
||||
path = self._get_session_path(name)
|
||||
|
||||
log.misc.debug("Saving session {} to {}...".format(name, path))
|
||||
data = self._save_all()
|
||||
log.misc.vdebug("Saving data: {}".format(data))
|
||||
try:
|
||||
with qtutils.savefile_open(path) as f:
|
||||
yaml.dump(data, f, Dumper=YamlDumper, default_flow_style=False,
|
||||
encoding='utf-8', allow_unicode=True)
|
||||
except (OSError, UnicodeEncodeError, yaml.YAMLError) as e:
|
||||
raise SessionError(e)
|
||||
else:
|
||||
self.update_completion.emit()
|
||||
|
||||
def _load_tab(self, new_tab, data):
|
||||
"""Load yaml data into a newly opened tab."""
|
||||
entries = []
|
||||
for histentry in data['history']:
|
||||
user_data = {}
|
||||
if 'zoom' in data:
|
||||
user_data['zoom'] = data['zoom']
|
||||
if 'scroll-pos' in data:
|
||||
pos = data['scroll-pos']
|
||||
user_data['scroll-pos'] = QPoint(pos['x'], pos['y'])
|
||||
active = histentry.get('active', False)
|
||||
url = QUrl.fromEncoded(histentry['url'].encode('ascii'))
|
||||
if 'original-url' in histentry:
|
||||
orig_url = QUrl.fromEncoded(
|
||||
histentry['original-url'].encode('ascii'))
|
||||
else:
|
||||
orig_url = url
|
||||
entry = tabhistory.TabHistoryItem(
|
||||
url=url, original_url=orig_url, title=histentry['title'],
|
||||
active=active, user_data=user_data)
|
||||
entries.append(entry)
|
||||
if active:
|
||||
new_tab.titleChanged.emit(histentry['title'])
|
||||
try:
|
||||
new_tab.page().load_history(entries)
|
||||
except ValueError as e:
|
||||
raise SessionError(e)
|
||||
|
||||
def load(self, name):
|
||||
"""Load a named session."""
|
||||
path = self._get_session_path(name, check_exists=True)
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
data = yaml.load(f, Loader=YamlLoader)
|
||||
except (OSError, UnicodeDecodeError, yaml.YAMLError) as e:
|
||||
raise SessionError(e)
|
||||
log.misc.debug("Loading session {} from {}...".format(name, path))
|
||||
for win in data['windows']:
|
||||
win_id = mainwindow.MainWindow.spawn(geometry=win['geometry'])
|
||||
tabbed_browser = objreg.get('tabbed-browser', scope='window',
|
||||
window=win_id)
|
||||
tab_to_focus = None
|
||||
for i, tab in enumerate(win['tabs']):
|
||||
new_tab = tabbed_browser.tabopen()
|
||||
self._load_tab(new_tab, tab)
|
||||
if tab.get('active', False):
|
||||
tab_to_focus = i
|
||||
if tab_to_focus is not None:
|
||||
tabbed_browser.setCurrentIndex(tab_to_focus)
|
||||
if win.get('active', False):
|
||||
QTimer.singleShot(0, tabbed_browser.activateWindow)
|
||||
|
||||
def delete(self, name):
|
||||
"""Delete a session."""
|
||||
path = self._get_session_path(name, check_exists=True)
|
||||
os.remove(path)
|
||||
self.update_completion.emit()
|
||||
|
||||
def list_sessions(self):
|
||||
"""Get a list of all session names."""
|
||||
sessions = []
|
||||
for filename in os.listdir(self._base_path):
|
||||
base, ext = os.path.splitext(filename)
|
||||
if ext == '.yml':
|
||||
sessions.append(base)
|
||||
return sessions
|
||||
|
||||
@cmdutils.register(completion=[usertypes.Completion.sessions],
|
||||
instance='session-manager')
|
||||
def session_load(self, name):
|
||||
"""Load a session.
|
||||
|
||||
Args:
|
||||
name: The name of the session.
|
||||
"""
|
||||
try:
|
||||
self.load(name)
|
||||
except SessionNotFoundError:
|
||||
raise cmdexc.CommandError("Session {} not found!".format(name))
|
||||
except SessionError as e:
|
||||
raise cmdexc.CommandError("Error while loading session: {}"
|
||||
.format(e))
|
||||
|
||||
@cmdutils.register(name=['session-save', 'w'],
|
||||
completion=[usertypes.Completion.sessions],
|
||||
instance='session-manager')
|
||||
def session_save(self, name='default'):
|
||||
"""Save a session.
|
||||
|
||||
Args:
|
||||
name: The name of the session.
|
||||
"""
|
||||
try:
|
||||
self.save(name)
|
||||
except SessionError as e:
|
||||
raise cmdexc.CommandError("Error while saving session: {}"
|
||||
.format(e))
|
||||
|
||||
@cmdutils.register(name='wq', completion=[usertypes.Completion.sessions],
|
||||
instance='session-manager')
|
||||
def save_and_quit(self, name='default'):
|
||||
"""Save open pages and quit.
|
||||
|
||||
Args:
|
||||
name: The name of the session.
|
||||
"""
|
||||
self.session_save(name)
|
||||
QApplication.closeAllWindows()
|
||||
|
||||
@cmdutils.register(completion=[usertypes.Completion.sessions],
|
||||
instance='session-manager')
|
||||
def session_delete(self, name):
|
||||
"""Delete a session.
|
||||
|
||||
Args:
|
||||
name: The name of the session.
|
||||
"""
|
||||
try:
|
||||
self.delete(name)
|
||||
except OSError as e:
|
||||
raise cmdexc.CommandError("Error while deleting session: {}"
|
||||
.format(e))
|
@ -53,6 +53,8 @@ def get_argparser():
|
||||
"this session.", nargs=3, action='append',
|
||||
dest='temp_settings', default=[],
|
||||
metavar=('SECTION', 'OPTION', 'VALUE'))
|
||||
parser.add_argument('-r', '--restore', help="Restore a named session.",
|
||||
dest='session', default='default')
|
||||
|
||||
debug = parser.add_argument_group('debug arguments')
|
||||
debug.add_argument('-l', '--loglevel', dest='loglevel',
|
||||
|
@ -18,3 +18,9 @@
|
||||
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""The qutebrowser test suite."""
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
# We create a singleton QApplication here.
|
||||
|
||||
qApp = QApplication([])
|
||||
|
130
qutebrowser/test/browser/test_tabhistory.py
Normal file
130
qutebrowser/test/browser/test_tabhistory.py
Normal file
@ -0,0 +1,130 @@
|
||||
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
||||
|
||||
# Copyright 2015 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
|
||||
#
|
||||
# This file is part of qutebrowser.
|
||||
#
|
||||
# qutebrowser is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# qutebrowser is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Tests for webelement.tabhistory."""
|
||||
|
||||
import unittest
|
||||
|
||||
from PyQt5.QtCore import QUrl
|
||||
from PyQt5.QtWebKitWidgets import QWebPage
|
||||
|
||||
from qutebrowser.browser import tabhistory
|
||||
from qutebrowser.browser.tabhistory import TabHistoryItem as Item
|
||||
from qutebrowser.utils import qtutils
|
||||
|
||||
|
||||
class SerializeHistoryTests(unittest.TestCase):
|
||||
|
||||
"""Tests for serialize()."""
|
||||
|
||||
def setUp(self):
|
||||
self.page = QWebPage()
|
||||
self.history = self.page.history()
|
||||
self.assertEqual(self.history.count(), 0)
|
||||
|
||||
self.items = [Item(QUrl('https://www.heise.de/'),
|
||||
QUrl('http://www.heise.de/'),
|
||||
'heise'),
|
||||
Item(QUrl('http://example.com/%E2%80%A6'),
|
||||
QUrl('http://example.com/%E2%80%A6'),
|
||||
'percent', active=True),
|
||||
Item(QUrl('http://example.com/?foo=bar'),
|
||||
QUrl('http://original.url.example.com/'),
|
||||
'arg', user_data={'foo': 23, 'bar': 42})]
|
||||
stream, _data, self.user_data = tabhistory.serialize(self.items)
|
||||
qtutils.deserialize_stream(stream, self.history)
|
||||
|
||||
def test_count(self):
|
||||
"""Check if the history's count was loaded correctly."""
|
||||
self.assertEqual(self.history.count(), len(self.items))
|
||||
|
||||
def test_valid(self):
|
||||
"""Check if all items are valid."""
|
||||
for i, _item in enumerate(self.items):
|
||||
self.assertTrue(self.history.itemAt(i).isValid())
|
||||
|
||||
def test_no_userdata(self):
|
||||
"""Check if all items have no user data."""
|
||||
for i, _item in enumerate(self.items):
|
||||
self.assertIsNone(self.history.itemAt(i).userData())
|
||||
|
||||
def test_userdata(self):
|
||||
"""Check if all user data has been restored to self.user_data."""
|
||||
for item, user_data in zip(self.items, self.user_data):
|
||||
self.assertEqual(user_data, item.user_data)
|
||||
|
||||
def test_currentitem(self):
|
||||
"""Check if the current item index was loaded correctly."""
|
||||
self.assertEqual(self.history.currentItemIndex(), 1)
|
||||
|
||||
def test_urls(self):
|
||||
"""Check if the URLs were loaded correctly."""
|
||||
for i, item in enumerate(self.items):
|
||||
with self.subTest(i=i, item=item):
|
||||
self.assertEqual(self.history.itemAt(i).url(), item.url)
|
||||
|
||||
def test_original_urls(self):
|
||||
"""Check if the original URLs were loaded correctly."""
|
||||
for i, item in enumerate(self.items):
|
||||
with self.subTest(i=i, item=item):
|
||||
self.assertEqual(self.history.itemAt(i).originalUrl(),
|
||||
item.original_url)
|
||||
|
||||
def test_titles(self):
|
||||
"""Check if the titles were loaded correctly."""
|
||||
for i, item in enumerate(self.items):
|
||||
with self.subTest(i=i, item=item):
|
||||
self.assertEqual(self.history.itemAt(i).title(), item.title)
|
||||
|
||||
|
||||
class SerializeHistorySpecialTests(unittest.TestCase):
|
||||
|
||||
"""Tests for serialize() without items set up in setUp."""
|
||||
|
||||
def setUp(self):
|
||||
self.page = QWebPage()
|
||||
self.history = self.page.history()
|
||||
self.assertEqual(self.history.count(), 0)
|
||||
|
||||
def test_no_active_item(self):
|
||||
"""Check tabhistory.serialize with no active item."""
|
||||
items = [Item(QUrl(), QUrl(), '')]
|
||||
with self.assertRaises(ValueError):
|
||||
tabhistory.serialize(items)
|
||||
|
||||
def test_two_active_items(self):
|
||||
"""Check tabhistory.serialize with two active items."""
|
||||
items = [Item(QUrl(), QUrl(), '', active=True),
|
||||
Item(QUrl(), QUrl(), ''),
|
||||
Item(QUrl(), QUrl(), '', active=True)]
|
||||
with self.assertRaises(ValueError):
|
||||
tabhistory.serialize(items)
|
||||
|
||||
def test_empty(self):
|
||||
"""Check tabhistory.serialize with no items."""
|
||||
items = []
|
||||
stream, _data, user_data = tabhistory.serialize(items)
|
||||
qtutils.deserialize_stream(stream, self.history)
|
||||
self.assertEqual(self.history.count(), 0)
|
||||
self.assertEqual(self.history.currentItemIndex(), 0)
|
||||
self.assertFalse(user_data)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
@ -133,7 +133,7 @@ def ensure_not_null(obj):
|
||||
raise QtValueError(obj)
|
||||
|
||||
|
||||
def _check_qdatastream(stream):
|
||||
def check_qdatastream(stream):
|
||||
"""Check the status of a QDataStream and raise OSError if it's not ok."""
|
||||
status_to_str = {
|
||||
QDataStream.Ok: "The data stream is operating normally.",
|
||||
@ -151,16 +151,28 @@ def serialize(obj):
|
||||
"""Serialize an object into a QByteArray."""
|
||||
data = QByteArray()
|
||||
stream = QDataStream(data, QIODevice.WriteOnly)
|
||||
stream << obj # pylint: disable=pointless-statement
|
||||
_check_qdatastream(stream)
|
||||
serialize_stream(stream, obj)
|
||||
return data
|
||||
|
||||
|
||||
def deserialize(data, obj):
|
||||
"""Deserialize an object from a QByteArray."""
|
||||
stream = QDataStream(data, QIODevice.ReadOnly)
|
||||
deserialize_stream(stream, obj)
|
||||
|
||||
|
||||
def serialize_stream(stream, obj):
|
||||
"""Serialize an object into a QDataStream."""
|
||||
check_qdatastream(stream)
|
||||
stream << obj # pylint: disable=pointless-statement
|
||||
check_qdatastream(stream)
|
||||
|
||||
|
||||
def deserialize_stream(stream, obj):
|
||||
"""Deserialize a QDataStream into an object."""
|
||||
check_qdatastream(stream)
|
||||
stream >> obj # pylint: disable=pointless-statement
|
||||
_check_qdatastream(stream)
|
||||
check_qdatastream(stream)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
|
@ -237,7 +237,7 @@ KeyMode = enum('KeyMode', ['normal', 'hint', 'command', 'yesno', 'prompt',
|
||||
# Available command completions
|
||||
Completion = enum('Completion', ['command', 'section', 'option', 'value',
|
||||
'helptopic', 'quickmark_by_url',
|
||||
'quickmark_by_name'])
|
||||
'quickmark_by_name', 'sessions'])
|
||||
|
||||
|
||||
class Question(QObject):
|
||||
|
@ -144,6 +144,7 @@ def _module_versions():
|
||||
('pypeg2', ['__version__']),
|
||||
('jinja2', ['__version__']),
|
||||
('pygments', ['__version__']),
|
||||
('yaml', ['__version__']),
|
||||
])
|
||||
for name, attributes in modules.items():
|
||||
try:
|
||||
|
@ -98,7 +98,7 @@ setupdata = {
|
||||
'description': _get_constant('description'),
|
||||
'long_description': read_file('README.asciidoc'),
|
||||
'url': 'http://www.qutebrowser.org/',
|
||||
'requires': ['pypeg2', 'jinja2', 'pygments'],
|
||||
'requires': ['pypeg2', 'jinja2', 'pygments', 'PyYAML'],
|
||||
'author': _get_constant('author'),
|
||||
'author_email': _get_constant('email'),
|
||||
'license': _get_constant('license'),
|
||||
|
Loading…
Reference in New Issue
Block a user