Merge branch 'webengine-downloads-3'

This commit is contained in:
Florian Bruhin 2016-11-09 19:09:58 +01:00
commit ac2df2f253
22 changed files with 1109 additions and 833 deletions

View File

@ -75,4 +75,5 @@ ignored-modules=pytest
# UnsetObject because pylint infers any objreg.get(...) as UnsetObject.
ignored-classes=qutebrowser.utils.objreg.UnsetObject,
qutebrowser.browser.webkit.webelem.WebElementWrapper,
scripts.dev.check_coverage.MsgType
scripts.dev.check_coverage.MsgType,
qutebrowser.browser.downloads.UnsupportedAttribute

View File

@ -45,8 +45,9 @@ import qutebrowser.resources
from qutebrowser.completion.models import instances as completionmodels
from qutebrowser.commands import cmdutils, runners, cmdexc
from qutebrowser.config import style, config, websettings, configexc
from qutebrowser.browser import urlmarks, adblock, history, browsertab
from qutebrowser.browser.webkit import cookies, cache, downloads
from qutebrowser.browser import (urlmarks, adblock, history, browsertab,
downloads)
from qutebrowser.browser.webkit import cookies, cache
from qutebrowser.browser.webkit.network import networkmanager
from qutebrowser.mainwindow import mainwindow, prompt
from qutebrowser.misc import (readline, ipc, savemanager, sessions,
@ -371,7 +372,6 @@ def _init_modules(args, crash_handler):
args: The argparse namespace.
crash_handler: The CrashHandler instance.
"""
# pylint: disable=too-many-statements
log.init.debug("Initializing prompts...")
prompt.init()
@ -435,8 +435,6 @@ def _init_modules(args, crash_handler):
os.environ['QT_WAYLAND_DISABLE_WINDOWDECORATION'] = '1'
else:
os.environ.pop('QT_WAYLAND_DISABLE_WINDOWDECORATION', None)
temp_downloads = downloads.TempDownloadManager(qApp)
objreg.register('temporary-downloads', temp_downloads)
# Init backend-specific stuff
browsertab.init(args)
@ -705,6 +703,7 @@ class Quitter:
atexit.register(shutil.rmtree, self._args.basedir,
ignore_errors=True)
# Delete temp download dir
downloads.temp_download_manager.cleanup()
# If we don't kill our custom handler here we might get segfaults
log.destroy.debug("Deactivating message handler...")
qInstallMessageHandler(None)

View File

@ -26,8 +26,9 @@ import posixpath
import zipfile
import fnmatch
from qutebrowser.browser import downloads
from qutebrowser.config import config
from qutebrowser.utils import objreg, standarddir, log, message, usertypes
from qutebrowser.utils import objreg, standarddir, log, message
from qutebrowser.commands import cmdutils, cmdexc
@ -190,8 +191,8 @@ class HostBlocker:
self._blocked_hosts = set()
self._done_count = 0
urls = config.get('content', 'host-block-lists')
download_manager = objreg.get('download-manager', scope='window',
window='last-focused')
download_manager = objreg.get('qtnetwork-download-manager',
scope='window', window='last-focused')
if urls is None:
return
for url in urls:
@ -208,7 +209,7 @@ class HostBlocker:
else:
fobj = io.BytesIO()
fobj.name = 'adblock: ' + url.host()
target = usertypes.FileObjDownloadTarget(fobj)
target = downloads.FileObjDownloadTarget(fobj)
download = download_manager.get(url, target=target,
auto_remove=True)
self._in_progress.append(download)

View File

@ -43,8 +43,7 @@ import pygments.formatters
from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners
from qutebrowser.config import config, configexc
from qutebrowser.browser import (urlmarks, browsertab, inspector, navigate,
webelem)
from qutebrowser.browser.webkit import downloads
webelem, downloads)
try:
from qutebrowser.browser.webkit import mhtml
except ImportError:
@ -1295,8 +1294,7 @@ class CommandDispatcher:
except inspector.WebInspectorError as e:
raise cmdexc.CommandError(e)
@cmdutils.register(instance='command-dispatcher', scope='window',
backend=usertypes.Backend.QtWebKit)
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('dest_old', hide=True)
def download(self, url=None, dest_old=None, *, mhtml_=False, dest=None):
"""Download a given URL, or current page if no URL given.
@ -1318,8 +1316,9 @@ class CommandDispatcher:
" download.")
dest = dest_old
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
# FIXME:qtwebengine do this with the QtWebEngine download manager?
download_manager = objreg.get('qtnetwork-download-manager',
scope='window', window=self._win_id)
if url:
if mhtml_:
raise cmdexc.CommandError("Can only download the current page"
@ -1329,21 +1328,16 @@ class CommandDispatcher:
if dest is None:
target = None
else:
target = usertypes.FileDownloadTarget(dest)
target = downloads.FileDownloadTarget(dest)
download_manager.get(url, target=target)
elif mhtml_:
self._download_mhtml(dest)
else:
tab = self._current_widget()
# FIXME:qtwebengine have a proper API for this
# pylint: disable=protected-access
qnam = tab._widget.page().networkAccessManager()
# pylint: enable=protected-access
if dest is None:
target = None
else:
target = usertypes.FileDownloadTarget(dest)
download_manager.get(self._current_url(), qnam=qnam, target=target)
target = downloads.FileDownloadTarget(dest)
download_manager.get(self._current_url(), target=target)
def _download_mhtml(self, dest=None):
"""Download the current page as an MHTML file, including all assets.
@ -1352,17 +1346,23 @@ class CommandDispatcher:
dest: The file path to write the download to.
"""
tab = self._current_widget()
if tab.backend == usertypes.Backend.QtWebEngine:
raise cmdexc.CommandError("Download --mhtml is not implemented "
"with QtWebEngine yet")
if dest is None:
suggested_fn = self._current_title() + ".mht"
suggested_fn = utils.sanitize_filename(suggested_fn)
filename, q = downloads.ask_for_filename(suggested_fn, parent=tab,
url=tab.url())
filename = downloads.immediate_download_path()
if filename is not None:
mhtml.start_download_checked(filename, tab=tab)
else:
q.answered.connect(functools.partial(
question = downloads.get_filename_question(
suggested_filename=suggested_fn, url=tab.url(), parent=tab)
question.answered.connect(functools.partial(
mhtml.start_download_checked, tab=tab))
q.ask()
message.global_bridge.ask(question, blocking=False)
else:
mhtml.start_download_checked(dest, tab=tab)

View File

@ -25,7 +25,7 @@ import sip
from PyQt5.QtCore import pyqtSlot, QSize, Qt, QTimer
from PyQt5.QtWidgets import QListView, QSizePolicy, QMenu
from qutebrowser.browser.webkit import downloads
from qutebrowser.browser import downloads
from qutebrowser.config import style
from qutebrowser.utils import qtutils, utils, objreg

View File

@ -283,14 +283,10 @@ class HintActions:
else:
prompt = None
# FIXME:qtwebengine get a proper API for this
# pylint: disable=protected-access
qnam = elem._elem.webFrame().page().networkAccessManager()
# pylint: enable=protected-access
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
download_manager.get(url, qnam=qnam, prompt_download_directory=prompt)
# FIXME:qtwebengine do this with QtWebEngine downloads?
download_manager = objreg.get('qtnetwork-download-manager',
scope='window', window=self._win_id)
download_manager.get(url, prompt_download_directory=prompt)
def call_userscript(self, elem, context):
"""Call a userscript from a hint.
@ -662,11 +658,6 @@ class HintManager(QObject):
tab = tabbed_browser.currentWidget()
if tab is None:
raise cmdexc.CommandError("No WebView available yet!")
if (tab.backend == usertypes.Backend.QtWebEngine and
target == Target.download):
message.error("The download target is not available yet with "
"QtWebEngine.")
return
mode_manager = objreg.get('mode-manager', scope='window',
window=self._win_id)

View File

@ -0,0 +1,469 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2014-2016 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/>.
"""Download manager."""
import io
import shutil
import functools
import collections
from PyQt5.QtCore import pyqtSlot, QTimer
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from qutebrowser.utils import message, usertypes, log, urlutils
from qutebrowser.browser import downloads
from qutebrowser.browser.webkit import http
from qutebrowser.browser.webkit.network import networkmanager
_RetryInfo = collections.namedtuple('_RetryInfo', ['request', 'manager'])
class DownloadItem(downloads.AbstractDownloadItem):
"""A single download currently running.
There are multiple ways the data can flow from the QNetworkReply to the
disk.
If the filename/file object is known immediately when starting the
download, QNetworkReply's readyRead writes to the target file directly.
If not, readyRead is ignored and with self._read_timer we periodically read
into the self._buffer BytesIO slowly, so some broken servers don't close
our connection.
As soon as we know the file object, we copy self._buffer over and the next
readyRead will write to the real file object.
Class attributes:
_MAX_REDIRECTS: The maximum redirection count.
Attributes:
_retry_info: A _RetryInfo instance.
_redirects: How many time we were redirected already.
_buffer: A BytesIO object to buffer incoming data until we know the
target file.
_read_timer: A Timer which reads the QNetworkReply into self._buffer
periodically.
_manager: The DownloadManager which started this download
_reply: The QNetworkReply associated with this download.
_autoclose: Whether to close the associated file when the download is
done.
"""
_MAX_REDIRECTS = 10
def __init__(self, reply, manager):
"""Constructor.
Args:
reply: The QNetworkReply to download.
"""
super().__init__(parent=manager)
self.fileobj = None
self.raw_headers = {}
self._autoclose = True
self._manager = manager
self._retry_info = None
self._reply = None
self._buffer = io.BytesIO()
self._read_timer = usertypes.Timer(self, name='download-read-timer')
self._read_timer.setInterval(500)
self._read_timer.timeout.connect(self._on_read_timer_timeout)
self._redirects = 0
self._init_reply(reply)
def _create_fileobj(self):
"""Create a file object using the internal filename."""
try:
fileobj = open(self._filename, 'wb')
except OSError as e:
self._die(e.strerror)
else:
self._set_fileobj(fileobj)
def _do_die(self):
"""Abort the download and emit an error."""
self._read_timer.stop()
self._reply.downloadProgress.disconnect()
self._reply.finished.disconnect()
self._reply.error.disconnect()
self._reply.readyRead.disconnect()
with log.hide_qt_warning('QNetworkReplyImplPrivate::error: Internal '
'problem, this method must only be called '
'once.'):
# See https://codereview.qt-project.org/#/c/107863/
self._reply.abort()
self._reply.deleteLater()
self._reply = None
if self.fileobj is not None:
try:
self.fileobj.close()
except OSError:
log.downloads.exception("Error while closing file object")
def _init_reply(self, reply):
"""Set a new reply and connect its signals.
Args:
reply: The QNetworkReply to handle.
"""
self.done = False
self.successful = False
self._reply = reply
reply.setReadBufferSize(16 * 1024 * 1024) # 16 MB
reply.downloadProgress.connect(self.stats.on_download_progress)
reply.finished.connect(self._on_reply_finished)
reply.error.connect(self._on_reply_error)
reply.readyRead.connect(self._on_ready_read)
reply.metaDataChanged.connect(self._on_meta_data_changed)
self._retry_info = _RetryInfo(request=reply.request(),
manager=reply.manager())
if not self.fileobj:
self._read_timer.start()
# We could have got signals before we connected slots to them.
# Here no signals are connected to the DownloadItem yet, so we use a
# singleShot QTimer to emit them after they are connected.
if reply.error() != QNetworkReply.NoError:
QTimer.singleShot(0, lambda: self._die(reply.errorString()))
def _do_cancel(self):
if self._reply is not None:
self._reply.finished.disconnect(self._on_reply_finished)
self._reply.abort()
self._reply.deleteLater()
self._reply = None
if self.fileobj is not None:
self.fileobj.close()
self.cancelled.emit()
@pyqtSlot()
def retry(self):
"""Retry a failed download."""
assert self.done
assert not self.successful
new_reply = self._retry_info.manager.get(self._retry_info.request)
self._manager.fetch(new_reply, suggested_filename=self.basename)
self.cancel()
def _get_open_filename(self):
filename = self._filename
if filename is None:
filename = getattr(self.fileobj, 'name', None)
return filename
def _ensure_can_set_filename(self, filename):
if self.fileobj is not None: # pragma: no cover
raise ValueError("fileobj was already set! filename: {}, "
"existing: {}, fileobj {}".format(
filename, self._filename, self.fileobj))
def _after_set_filename(self):
self._create_fileobj()
def _ask_confirm_question(self, title, msg):
no_action = functools.partial(self.cancel, remove_data=False)
message.confirm_async(title=title, text=msg,
yes_action=self._after_set_filename,
no_action=no_action, cancel_action=no_action,
abort_on=[self.cancelled, self.error])
def _set_fileobj(self, fileobj, *, autoclose=True):
""""Set the file object to write the download to.
Args:
fileobj: A file-like object.
"""
if self.fileobj is not None: # pragma: no cover
raise ValueError("fileobj was already set! Old: {}, new: "
"{}".format(self.fileobj, fileobj))
self.fileobj = fileobj
self._autoclose = autoclose
try:
self._read_timer.stop()
log.downloads.debug("buffer: {} bytes".format(self._buffer.tell()))
self._buffer.seek(0)
shutil.copyfileobj(self._buffer, fileobj)
self._buffer.close()
if self._reply.isFinished():
# Downloading to the buffer in RAM has already finished so we
# write out the data and clean up now.
self._on_reply_finished()
else:
# Since the buffer already might be full, on_ready_read might
# not be called at all anymore, so we force it here to flush
# the buffer and continue receiving new data.
self._on_ready_read()
except OSError as e:
self._die(e.strerror)
def _set_tempfile(self, fileobj):
self._set_fileobj(fileobj)
def _finish_download(self):
"""Write buffered data to disk and finish the QNetworkReply."""
log.downloads.debug("Finishing download...")
if self._reply.isOpen():
self.fileobj.write(self._reply.readAll())
if self._autoclose:
self.fileobj.close()
self.successful = self._reply.error() == QNetworkReply.NoError
self._reply.close()
self._reply.deleteLater()
self._reply = None
self.finished.emit()
self.done = True
log.downloads.debug("Download {} finished".format(self.basename))
self.data_changed.emit()
@pyqtSlot()
def _on_reply_finished(self):
"""Clean up when the download was finished.
Note when this gets called, only the QNetworkReply has finished. This
doesn't mean the download (i.e. writing data to the disk) is finished
as well. Therefore, we can't close() the QNetworkReply in here yet.
"""
if self._reply is None:
return
self._read_timer.stop()
self.stats.finish()
is_redirected = self._handle_redirect()
if is_redirected:
return
log.downloads.debug("Reply finished, fileobj {}".format(self.fileobj))
if self.fileobj is not None:
# We can do a "delayed" write immediately to empty the buffer and
# clean up.
self._finish_download()
@pyqtSlot()
def _on_ready_read(self):
"""Read available data and save file when ready to read."""
if self.fileobj is None or self._reply is None:
# No filename has been set yet (so we don't empty the buffer) or we
# got a readyRead after the reply was finished (which happens on
# qute:log for example).
return
if not self._reply.isOpen():
raise OSError("Reply is closed!")
try:
self.fileobj.write(self._reply.readAll())
except OSError as e:
self._die(e.strerror)
@pyqtSlot('QNetworkReply::NetworkError')
def _on_reply_error(self, code):
"""Handle QNetworkReply errors."""
if code == QNetworkReply.OperationCanceledError:
return
else:
self._die(self._reply.errorString())
@pyqtSlot()
def _on_read_timer_timeout(self):
"""Read some bytes from the QNetworkReply periodically."""
if not self._reply.isOpen():
raise OSError("Reply is closed!")
data = self._reply.read(1024)
if data is not None:
self._buffer.write(data)
@pyqtSlot()
def _on_meta_data_changed(self):
"""Update the download's metadata."""
if self._reply is None:
return
self.raw_headers = {}
for key, value in self._reply.rawHeaderPairs():
self.raw_headers[bytes(key)] = bytes(value)
def _handle_redirect(self):
"""Handle an HTTP redirect.
Return:
True if the download was redirected, False otherwise.
"""
redirect = self._reply.attribute(
QNetworkRequest.RedirectionTargetAttribute)
if redirect is None or redirect.isEmpty():
return False
new_url = self._reply.url().resolved(redirect)
new_request = self._reply.request()
if new_url == new_request.url():
return False
if self._redirects > self._MAX_REDIRECTS:
self._die("Maximum redirection count reached!")
self.delete()
return True # so on_reply_finished aborts
log.downloads.debug("{}: Handling redirect".format(self))
self._redirects += 1
new_request.setUrl(new_url)
old_reply = self._reply
old_reply.finished.disconnect(self._on_reply_finished)
self._read_timer.stop()
self._reply = None
if self.fileobj is not None:
self.fileobj.seek(0)
log.downloads.debug("redirected: {} -> {}".format(
old_reply.url(), new_request.url()))
new_reply = old_reply.manager().get(new_request)
self._init_reply(new_reply)
old_reply.deleteLater()
return True
class DownloadManager(downloads.AbstractDownloadManager):
"""Manager for currently running downloads.
Attributes:
_networkmanager: A NetworkManager for generic downloads.
"""
def __init__(self, win_id, parent=None):
super().__init__(parent)
self._networkmanager = networkmanager.NetworkManager(
win_id, None, self)
@pyqtSlot('QUrl')
def get(self, url, **kwargs):
"""Start a download with a link URL.
Args:
url: The URL to get, as QUrl
**kwargs: passed to get_request().
Return:
The created DownloadItem.
"""
if not url.isValid():
urlutils.invalid_url_error(url, "start download")
return
req = QNetworkRequest(url)
return self.get_request(req, **kwargs)
def get_request(self, request, *, target=None, **kwargs):
"""Start a download with a QNetworkRequest.
Args:
request: The QNetworkRequest to download.
target: Where to save the download as downloads.DownloadTarget.
**kwargs: Passed to _fetch_request.
Return:
The created DownloadItem.
"""
# WORKAROUND for Qt corrupting data loaded from cache:
# https://bugreports.qt.io/browse/QTBUG-42757
request.setAttribute(QNetworkRequest.CacheLoadControlAttribute,
QNetworkRequest.AlwaysNetwork)
if request.url().scheme().lower() != 'data':
suggested_fn = urlutils.filename_from_url(request.url())
else:
# We might be downloading a binary blob embedded on a page or even
# generated dynamically via javascript. We try to figure out a more
# sensible name than the base64 content of the data.
origin = request.originatingObject()
try:
origin_url = origin.url()
except AttributeError:
# Raised either if origin is None or some object that doesn't
# have its own url. We're probably fine with a default fallback
# then.
suggested_fn = 'binary blob'
else:
# Use the originating URL as a base for the filename (works
# e.g. for pdf.js).
suggested_fn = urlutils.filename_from_url(origin_url)
if suggested_fn is None:
suggested_fn = 'qutebrowser-download'
return self._fetch_request(request,
target=target,
suggested_filename=suggested_fn,
**kwargs)
def _fetch_request(self, request, **kwargs):
"""Download a QNetworkRequest to disk.
Args:
request: The QNetworkRequest to download.
**kwargs: passed to fetch().
Return:
The created DownloadItem.
"""
reply = self._networkmanager.get(request)
return self.fetch(reply, **kwargs)
@pyqtSlot('QNetworkReply')
def fetch(self, reply, *, target=None, auto_remove=False,
suggested_filename=None, prompt_download_directory=None):
"""Download a QNetworkReply to disk.
Args:
reply: The QNetworkReply to download.
target: Where to save the download as downloads.DownloadTarget.
auto_remove: Whether to remove the download even if
ui -> remove-finished-downloads is set to -1.
Return:
The created DownloadItem.
"""
if not suggested_filename:
try:
suggested_filename = target.suggested_filename()
except downloads.NoFilenameError:
_, suggested_filename = http.parse_content_disposition(reply)
log.downloads.debug("fetch: {} -> {}".format(reply.url(),
suggested_filename))
download = DownloadItem(reply, manager=self)
self._init_item(download, auto_remove, suggested_filename)
if target is not None:
download.set_target(target)
return download
# Neither filename nor fileobj were given
filename = downloads.immediate_download_path(prompt_download_directory)
if filename is not None:
# User doesn't want to be asked, so just use the download_dir
target = downloads.FileDownloadTarget(filename)
download.set_target(target)
return download
# Ask the user for a filename
question = downloads.get_filename_question(
suggested_filename=suggested_filename, url=reply.url(),
parent=self)
self._init_filename_question(question, download)
message.global_bridge.ask(question, blocking=False)
return download

View File

@ -0,0 +1,158 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2014-2016 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/>.
"""QtWebEngine specific code for downloads."""
import os.path
import functools
from PyQt5.QtCore import pyqtSlot, Qt
# pylint: disable=no-name-in-module,import-error,useless-suppression
from PyQt5.QtWebEngineWidgets import QWebEngineDownloadItem
# pylint: enable=no-name-in-module,import-error,useless-suppression
from qutebrowser.browser import downloads
from qutebrowser.utils import debug, usertypes, message, log
class DownloadItem(downloads.AbstractDownloadItem):
"""A wrapper over a QWebEngineDownloadItem.
Attributes:
_qt_item: The wrapped item.
"""
def __init__(self, qt_item, parent=None):
super().__init__(parent)
self._qt_item = qt_item
qt_item.downloadProgress.connect(self.stats.on_download_progress)
qt_item.stateChanged.connect(self._on_state_changed)
@pyqtSlot(QWebEngineDownloadItem.DownloadState)
def _on_state_changed(self, state):
state_name = debug.qenum_key(QWebEngineDownloadItem, state)
log.downloads.debug("State for {!r} changed to {}".format(
self, state_name))
if state == QWebEngineDownloadItem.DownloadRequested:
pass
elif state == QWebEngineDownloadItem.DownloadInProgress:
pass
elif state == QWebEngineDownloadItem.DownloadCompleted:
log.downloads.debug("Download {} finished".format(self.basename))
self.successful = True
self.done = True
self.finished.emit()
self.stats.finish()
elif state == QWebEngineDownloadItem.DownloadCancelled:
self.successful = False
self.done = True
self.cancelled.emit()
self.stats.finish()
elif state == QWebEngineDownloadItem.DownloadInterrupted:
self.successful = False
self.done = True
# https://bugreports.qt.io/browse/QTBUG-56839
self.error.emit("Download failed")
self.stats.finish()
else:
raise ValueError("_on_state_changed was called with unknown state "
"{}".format(state_name))
def _do_die(self):
self._qt_item.downloadProgress.disconnect()
self._qt_item.cancel()
def _do_cancel(self):
self._qt_item.cancel()
def retry(self):
# https://bugreports.qt.io/browse/QTBUG-56840
raise downloads.UnsupportedOperationError
def _get_open_filename(self):
return self._filename
def _set_fileobj(self, fileobj):
raise downloads.UnsupportedOperationError
def _set_tempfile(self, fileobj):
self._set_filename(fileobj.name, force_overwrite=True)
def _ensure_can_set_filename(self, filename):
state = self._qt_item.state()
if state != QWebEngineDownloadItem.DownloadRequested:
state_name = debug.qenum_key(QWebEngineDownloadItem, state)
raise ValueError("Trying to set filename {} on {!r} which is "
"state {} (not in requested state)!".format(
filename, self, state_name))
def _ask_confirm_question(self, title, msg):
no_action = functools.partial(self.cancel, remove_data=False)
question = usertypes.Question()
question.title = title
question.text = msg
question.mode = usertypes.PromptMode.yesno
question.answered_yes.connect(self._after_set_filename)
question.answered_no.connect(no_action)
question.cancelled.connect(no_action)
self.cancelled.connect(question.abort)
self.error.connect(question.abort)
message.global_bridge.ask(question, blocking=True)
def _after_set_filename(self):
self._qt_item.setPath(self._filename)
self._qt_item.accept()
class DownloadManager(downloads.AbstractDownloadManager):
"""Manager for currently running downloads."""
def install(self, profile):
"""Set up the download manager on a QWebEngineProfile."""
profile.downloadRequested.connect(self.handle_download,
Qt.DirectConnection)
@pyqtSlot(QWebEngineDownloadItem)
def handle_download(self, qt_item):
"""Start a download coming from a QWebEngineProfile."""
suggested_filename = os.path.basename(qt_item.path())
download = DownloadItem(qt_item)
self._init_item(download, auto_remove=False,
suggested_filename=suggested_filename)
filename = downloads.immediate_download_path()
if filename is not None:
# User doesn't want to be asked, so just use the download_dir
target = downloads.FileDownloadTarget(filename)
download.set_target(target)
return
# Ask the user for a filename - needs to be blocking!
question = downloads.get_filename_question(
suggested_filename=suggested_filename, url=qt_item.url(),
parent=self)
self._init_filename_question(question, download)
message.global_bridge.ask(question, blocking=True)
# The filename is set via the question.answered signal, connected in
# _init_filename_question.

View File

@ -34,7 +34,8 @@ from PyQt5.QtWebEngineWidgets import (QWebEnginePage, QWebEngineScript,
from qutebrowser.browser import browsertab, mouse
from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory,
interceptor, webenginequtescheme)
interceptor, webenginequtescheme,
webenginedownloads)
from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils,
objreg)
@ -61,6 +62,11 @@ def init():
host_blocker, parent=app)
req_interceptor.install(profile)
log.init.debug("Initializing QtWebEngine downloads...")
download_manager = webenginedownloads.DownloadManager(parent=app)
download_manager.install(profile)
objreg.register('webengine-download-manager', download_manager)
# Mapping worlds from usertypes.JsWorld to QWebEngineScript world IDs.
_JS_WORLD_MAP = {

View File

@ -19,6 +19,7 @@
"""Utils for writing an MHTML file."""
import html
import functools
import io
import os
@ -34,7 +35,8 @@ import email.message
from PyQt5.QtCore import QUrl
from qutebrowser.browser.webkit import webkitelem, downloads
from qutebrowser.browser import downloads
from qutebrowser.browser.webkit import webkitelem
from qutebrowser.utils import log, objreg, message, usertypes, utils, urlutils
_File = collections.namedtuple('_File',
@ -341,9 +343,9 @@ class _Downloader:
self.writer.add_file(urlutils.encoded_url(url), b'')
return
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
target = usertypes.FileObjDownloadTarget(_NoCloseBytesIO())
download_manager = objreg.get('qtnetwork-download-manager',
scope='window', window=self._win_id)
target = downloads.FileObjDownloadTarget(_NoCloseBytesIO())
item = download_manager.get(url, target=target,
auto_remove=True)
self.pending_downloads.add((url, item))
@ -536,10 +538,10 @@ def start_download_checked(dest, tab):
q = usertypes.Question()
q.mode = usertypes.PromptMode.yesno
q.text = "{} exists. Overwrite?".format(path)
q.title = "Overwrite existing file?"
q.text = "<b>{}</b> already exists. Overwrite?".format(
html.escape(path))
q.completed.connect(q.deleteLater)
q.answered_yes.connect(functools.partial(
_start_download, path, tab=tab))
message_bridge = objreg.get('message-bridge', scope='window',
window=tab.win_id)
message_bridge.ask(q, blocking=False)
message.global_bridge.ask(q, blocking=False)

View File

@ -135,11 +135,6 @@ class NetworkManager(QNetworkAccessManager):
"""Our own QNetworkAccessManager.
Attributes:
adopted_downloads: If downloads are running with this QNAM but the
associated tab gets closed already, the NAM gets
reparented to the DownloadManager. This counts the
still running downloads, so the QNAM can clean
itself up when this reaches zero again.
_requests: Pending requests.
_scheme_handlers: A dictionary (scheme -> handler) of supported custom
schemes.
@ -161,7 +156,6 @@ class NetworkManager(QNetworkAccessManager):
# http://www.riverbankcomputing.com/pipermail/pyqt/2014-November/035045.html
super().__init__(parent)
log.init.debug("NetworkManager init done")
self.adopted_downloads = 0
self._win_id = win_id
self._tab_id = tab_id
self._requests = []
@ -394,28 +388,6 @@ class NetworkManager(QNetworkAccessManager):
# switched from private mode to normal mode
self._set_cookiejar()
@pyqtSlot()
def on_adopted_download_destroyed(self):
"""Check if we can clean up if an adopted download was destroyed.
See the description for adopted_downloads for details.
"""
self.adopted_downloads -= 1
log.downloads.debug("Adopted download destroyed, {} left.".format(
self.adopted_downloads))
assert self.adopted_downloads >= 0
if self.adopted_downloads == 0:
self.deleteLater()
@pyqtSlot(object) # DownloadItem
def adopt_download(self, download):
"""Adopt a new DownloadItem."""
self.adopted_downloads += 1
log.downloads.debug("Adopted download, {} adopted.".format(
self.adopted_downloads))
download.destroyed.connect(self.on_adopted_download_destroyed)
download.do_retry.connect(self.adopt_download)
def set_referer(self, req, current_url):
"""Set the referer header."""
referer_header_conf = config.get('network', 'referer-header')

View File

@ -219,13 +219,7 @@ class BrowserPage(QWebPage):
"""Prepare the web page for being deleted."""
self._is_shutting_down = True
self.shutting_down.emit()
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
nam = self.networkAccessManager()
if download_manager.has_downloads_with_nam(nam):
nam.setParent(download_manager)
else:
nam.shutdown()
self.networkAccessManager().shutdown()
def display_content(self, reply, mimetype):
"""Display a QNetworkReply with an explicitly set mimetype."""
@ -252,9 +246,9 @@ class BrowserPage(QWebPage):
after this slot returns.
"""
req = QNetworkRequest(request)
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
download_manager.get_request(req, qnam=self.networkAccessManager())
download_manager = objreg.get('qtnetwork-download-manager',
scope='window', window=self._win_id)
download_manager.get_request(req)
@pyqtSlot('QNetworkReply*')
def on_unsupported_content(self, reply):
@ -267,8 +261,8 @@ class BrowserPage(QWebPage):
here: http://mimesniff.spec.whatwg.org/
"""
inline, suggested_filename = http.parse_content_disposition(reply)
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
download_manager = objreg.get('qtnetwork-download-manager',
scope='window', window=self._win_id)
if not inline:
# Content-Disposition: attachment -> force download
download_manager.fetch(reply,

View File

@ -29,7 +29,7 @@ from qutebrowser.utils import message, log, objreg, standarddir
from qutebrowser.commands import runners
from qutebrowser.config import config
from qutebrowser.misc import guiprocess
from qutebrowser.browser.webkit import downloads
from qutebrowser.browser import downloads
class _QtFIFOReader(QObject):

View File

@ -35,8 +35,8 @@ from qutebrowser.mainwindow import tabbedbrowser, messageview, prompt
from qutebrowser.mainwindow.statusbar import bar
from qutebrowser.completion import completionwidget, completer
from qutebrowser.keyinput import modeman
from qutebrowser.browser import commands, downloadview, hints
from qutebrowser.browser.webkit import downloads
from qutebrowser.browser import (commands, downloadview, hints,
qtnetworkdownloads, downloads)
from qutebrowser.misc import crashsignal, keyhintwidget
@ -258,10 +258,20 @@ class MainWindow(QWidget):
def _init_downloadmanager(self):
log.init.debug("Initializing downloads...")
download_manager = downloads.DownloadManager(self.win_id, self)
objreg.register('download-manager', download_manager, scope='window',
window=self.win_id)
download_model = downloads.DownloadModel(download_manager)
qtnetwork_download_manager = qtnetworkdownloads.DownloadManager(
self.win_id, self)
objreg.register('qtnetwork-download-manager',
qtnetwork_download_manager,
scope='window', window=self.win_id)
try:
webengine_download_manager = objreg.get(
'webengine-download-manager')
except KeyError:
webengine_download_manager = None
download_model = downloads.DownloadModel(qtnetwork_download_manager,
webengine_download_manager)
objreg.register('download-model', download_model, scope='window',
window=self.win_id)

View File

@ -29,6 +29,7 @@ from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QTimer, QDir, QModelIndex,
from PyQt5.QtWidgets import (QWidget, QGridLayout, QVBoxLayout, QLineEdit,
QLabel, QFileSystemModel, QTreeView, QSizePolicy)
from qutebrowser.browser import downloads
from qutebrowser.config import style
from qutebrowser.utils import usertypes, log, utils, qtutils, objreg, message
from qutebrowser.keyinput import modeman
@ -687,11 +688,11 @@ class DownloadFilenamePrompt(FilenamePrompt):
def accept(self, value=None):
text = value if value is not None else self._lineedit.text()
self.question.answer = usertypes.FileDownloadTarget(text)
self.question.answer = downloads.FileDownloadTarget(text)
return True
def download_open(self, cmdline):
self.question.answer = usertypes.OpenFileDownloadTarget(cmdline)
self.question.answer = downloads.OpenFileDownloadTarget(cmdline)
self.question.done()
message.global_bridge.prompt_done.emit(self.KEY_MODE)

View File

@ -268,56 +268,6 @@ JsWorld = enum('JsWorld', ['main', 'application', 'user', 'jseval'])
MessageLevel = enum('MessageLevel', ['error', 'warning', 'info'])
# Where a download should be saved
class DownloadTarget:
"""Abstract base class for different download targets."""
def __init__(self):
raise NotImplementedError
class FileDownloadTarget(DownloadTarget):
"""Save the download to the given file.
Attributes:
filename: Filename where the download should be saved.
"""
def __init__(self, filename):
# pylint: disable=super-init-not-called
self.filename = filename
class FileObjDownloadTarget(DownloadTarget):
"""Save the download to the given file-like object.
Attributes:
fileobj: File-like object where the download should be written to.
"""
def __init__(self, fileobj):
# pylint: disable=super-init-not-called
self.fileobj = fileobj
class OpenFileDownloadTarget(DownloadTarget):
"""Save the download in a temp dir and directly open it.
Attributes:
cmdline: The command to use as string. A `{}` is expanded to the
filename. None means to use the system's default application.
If no `{}` is found, the filename is appended to the cmdline.
"""
def __init__(self, cmdline=None):
# pylint: disable=super-init-not-called
self.cmdline = cmdline
class Question(QObject):
"""A question asked to the user, e.g. via the status bar.

View File

@ -75,6 +75,7 @@ Feature: Downloading things from a website.
And I run :leave-mode
Then no crash should happen
@qtwebengine_todo: ssl-strict is not implemented yet
Scenario: Downloading with SSL errors (issue 1413)
When I run :debug-clear-ssl-errors
And I set network -> ssl-strict to ask
@ -85,7 +86,7 @@ Feature: Downloading things from a website.
Scenario: Closing window with remove-finished-downloads timeout (issue 1242)
When I set ui -> remove-finished-downloads to 500
And I open data/downloads/download.bin in a new window
And I open data/downloads/download.bin in a new window without waiting
And I wait until the download is finished
And I run :close
And I wait 0.5s
@ -95,7 +96,7 @@ Feature: Downloading things from a website.
Given I have a fresh instance
When I set storage -> prompt-download-directory to false
And I set ui -> confirm-quit to downloads
And I open data/downloads/download.bin
And I open data/downloads/download.bin without waiting
And I wait until the download is finished
And I run :close
Then qutebrowser should quit
@ -171,18 +172,36 @@ Feature: Downloading things from a website.
## mhtml downloads
@qtwebengine_todo: :download --mhtml is not implemented yet
Scenario: Downloading as mhtml is available
When I open html
And I run :download --mhtml
And I wait for "File successfully written." in the log
Then no crash should happen
@qtwebengine_todo: :download --mhtml is not implemented yet
Scenario: Downloading as mhtml with non-ASCII headers
When I open response-headers?Content-Type=text%2Fpl%C3%A4in
And I run :download --mhtml --dest mhtml-response-headers.mht
And I wait for "File successfully written." in the log
Then no crash should happen
@qtwebengine_todo: :download --mhtml is not implemented yet
Scenario: Overwriting existing mhtml file
When I set storage -> prompt-download-directory to true
And I open html
And I run :download --mhtml
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.text: 2> text='Please enter a location for <b>http://localhost:*/html</b>' title='Save file to:'>, *" in the log
And I run :prompt-accept
And I wait for "File successfully written." in the log
And I run :download --mhtml
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.text: 2> text='Please enter a location for <b>http://localhost:*/html</b>' title='Save file to:'>, *" in the log
And I run :prompt-accept
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> text='<b>*</b> already exists. Overwrite?' title='Overwrite existing file?'>, *" in the log
And I run :prompt-accept yes
And I wait for "File successfully written." in the log
Then no crash should happen
## :download-cancel
Scenario: Cancelling a download
@ -199,13 +218,13 @@ Feature: Downloading things from a website.
Then the error "There's no download 42!" should be shown
Scenario: Cancelling a download which is already done
When I open data/downloads/download.bin
When I open data/downloads/download.bin without waiting
And I wait until the download is finished
And I run :download-cancel
Then the error "Download 1 is already done!" should be shown
Scenario: Cancelling a download which is already done (with count)
When I open data/downloads/download.bin
When I open data/downloads/download.bin without waiting
And I wait until the download is finished
And I run :download-cancel with count 1
Then the error "Download 1 is already done!" should be shown
@ -218,6 +237,7 @@ Feature: Downloading things from a website.
And "cancelled" should be logged
# https://github.com/The-Compiler/qutebrowser/issues/1535
@qtwebengine_todo: :download --mhtml is not implemented yet
Scenario: Cancelling an MHTML download (issue 1535)
When I open data/downloads/issue1535.html
And I run :download --mhtml
@ -228,7 +248,7 @@ Feature: Downloading things from a website.
## :download-remove / :download-clear
Scenario: Removing a download
When I open data/downloads/download.bin
When I open data/downloads/download.bin without waiting
And I wait until the download is finished
And I run :download-remove
Then "Removed download *" should be logged
@ -248,17 +268,17 @@ Feature: Downloading things from a website.
Then the error "Download 1 is not done!" should be shown
Scenario: Removing all downloads via :download-remove
When I open data/downloads/download.bin
When I open data/downloads/download.bin without waiting
And I wait until the download is finished
And I open data/downloads/download2.bin
And I open data/downloads/download2.bin without waiting
And I wait until the download is finished
And I run :download-remove --all
Then "Removed download *" should be logged
Scenario: Removing all downloads via :download-clear
When I open data/downloads/download.bin
When I open data/downloads/download.bin without waiting
And I wait until the download is finished
And I open data/downloads/download2.bin
And I open data/downloads/download2.bin without waiting
And I wait until the download is finished
And I run :download-clear
Then "Removed download *" should be logged
@ -266,7 +286,7 @@ Feature: Downloading things from a website.
## :download-delete
Scenario: Deleting a download
When I open data/downloads/download.bin
When I open data/downloads/download.bin without waiting
And I wait until the download is finished
And I run :download-delete
And I wait for "deleted download *" in the log
@ -289,13 +309,13 @@ Feature: Downloading things from a website.
## :download-open
Scenario: Opening a download
When I open data/downloads/download.bin
When I open data/downloads/download.bin without waiting
And I wait until the download is finished
And I open the download
Then "Opening *download.bin* with [*python*]" should be logged
Scenario: Opening a download with a placeholder
When I open data/downloads/download.bin
When I open data/downloads/download.bin without waiting
And I wait until the download is finished
And I open the download with a placeholder
Then "Opening *download.bin* with [*python*]" should be logged
@ -318,7 +338,8 @@ Feature: Downloading things from a website.
Scenario: Opening a download directly
When I set storage -> prompt-download-directory to true
And I open data/downloads/download.bin
And I open data/downloads/download.bin without waiting
And I wait for the download prompt for "*"
And I directly open the download
And I wait until the download is finished
Then "Opening *download.bin* with [*python*]" should be logged
@ -328,6 +349,7 @@ Feature: Downloading things from a website.
Scenario: Cancelling a download that should be opened
When I set storage -> prompt-download-directory to true
And I run :download http://localhost:(port)/drip?numbytes=128&duration=5
And I wait for the download prompt for "*"
And I directly open the download
And I run :download-cancel
Then "* finished but not successful, not opening!" should be logged
@ -338,7 +360,7 @@ Feature: Downloading things from a website.
When I set storage -> prompt-download-directory to true
And I open data/downloads/issue1725.html
And I run :click-element id long-link
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=* mode=<PromptMode.download: 5> text=* title='Save file to:'>, *" in the log
And I wait for the download prompt for "*"
And I directly open the download
And I wait until the download is finished
Then "Opening * with [*python*]" should be logged
@ -348,19 +370,19 @@ Feature: Downloading things from a website.
Scenario: completion -> download-path-suggestion = path
When I set storage -> prompt-download-directory to true
And I set completion -> download-path-suggestion to path
And I open data/downloads/download.bin
And I open data/downloads/download.bin without waiting
Then the download prompt should be shown with "(tmpdir)/"
Scenario: completion -> download-path-suggestion = filename
When I set storage -> prompt-download-directory to true
And I set completion -> download-path-suggestion to filename
And I open data/downloads/download.bin
And I open data/downloads/download.bin without waiting
Then the download prompt should be shown with "download.bin"
Scenario: completion -> download-path-suggestion = both
When I set storage -> prompt-download-directory to true
And I set completion -> download-path-suggestion to both
And I open data/downloads/download.bin
And I open data/downloads/download.bin without waiting
Then the download prompt should be shown with "(tmpdir)/download.bin"
## storage -> remember-download-directory
@ -369,20 +391,20 @@ Feature: Downloading things from a website.
When I set storage -> prompt-download-directory to true
And I set completion -> download-path-suggestion to both
And I set storage -> remember-download-directory to true
And I open data/downloads/download.bin
And I open data/downloads/download.bin without waiting
And I wait for the download prompt for "*/download.bin"
And I run :prompt-accept (tmpdir)(dirsep)subdir
And I open data/downloads/download2.bin
And I open data/downloads/download2.bin without waiting
Then the download prompt should be shown with "(tmpdir)/subdir/download2.bin"
Scenario: Not remembering the last download directory
When I set storage -> prompt-download-directory to true
And I set completion -> download-path-suggestion to both
And I set storage -> remember-download-directory to false
And I open data/downloads/download.bin
And I open data/downloads/download.bin without waiting
And I wait for the download prompt for "(tmpdir)/download.bin"
And I run :prompt-accept (tmpdir)(dirsep)subdir
And I open data/downloads/download2.bin
And I open data/downloads/download2.bin without waiting
Then the download prompt should be shown with "(tmpdir)/download2.bin"
# Overwriting files
@ -475,6 +497,7 @@ Feature: Downloading things from a website.
And I run :download foo!
Then the error "Invalid URL" should be shown
@qtwebengine_todo: pdfjs is not implemented yet
Scenario: Downloading via pdfjs
Given pdfjs is available
When I set storage -> prompt-download-directory to false
@ -496,3 +519,9 @@ Feature: Downloading things from a website.
And I wait until the download is finished
Then the downloaded file download.bin should exist
And the downloaded file download2.bin should not exist
Scenario: Downloading a file with unknown size
When I set storage -> prompt-download-directory to false
And I open stream-bytes/1024 without waiting
And I wait until the download is finished
Then the downloaded file 1024 should exist

View File

@ -21,15 +21,10 @@ import os
import sys
import shlex
import pytest
import pytest_bdd as bdd
bdd.scenarios('downloads.feature')
pytestmark = pytest.mark.qtwebengine_todo("Downloads not implemented yet",
run=False)
PROMPT_MSG = ("Asking question <qutebrowser.utils.usertypes.Question "
"default={!r} mode=<PromptMode.download: 5> text=* "
"title='Save file to:'>, *")

View File

@ -101,10 +101,11 @@ class FakeDownloadManager:
def download_stub(win_registry):
"""Register a FakeDownloadManager."""
stub = FakeDownloadManager()
objreg.register('download-manager', stub,
objreg.register('qtnetwork-download-manager', stub,
scope='window', window='last-focused')
yield
objreg.delete('download-manager', scope='window', window='last-focused')
objreg.delete('qtnetwork-download-manager', scope='window',
window='last-focused')
def create_zipfile(directory, files, zipname='test'):

View File

@ -17,13 +17,46 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
import pytest
from qutebrowser.browser.webkit import downloads
from qutebrowser.browser import downloads, qtnetworkdownloads
def test_download_model(qapp, qtmodeltester, config_stub, cookiejar_and_cache):
"""Simple check for download model internals."""
config_stub.data = {'general': {'private-browsing': False}}
manager = downloads.DownloadManager(win_id=0)
manager = qtnetworkdownloads.DownloadManager(win_id=0)
model = downloads.DownloadModel(manager)
qtmodeltester.check(model)
class TestDownloadTarget:
def test_base(self):
with pytest.raises(NotImplementedError):
downloads._DownloadTarget()
def test_filename(self):
target = downloads.FileDownloadTarget("/foo/bar")
assert target.filename == "/foo/bar"
def test_fileobj(self):
fobj = object()
target = downloads.FileObjDownloadTarget(fobj)
assert target.fileobj is fobj
def test_openfile(self):
target = downloads.OpenFileDownloadTarget()
assert target.cmdline is None
def test_openfile_custom_command(self):
target = downloads.OpenFileDownloadTarget('echo')
assert target.cmdline == 'echo'
@pytest.mark.parametrize('obj', [
downloads.FileDownloadTarget('foobar'),
downloads.FileObjDownloadTarget(None),
downloads.OpenFileDownloadTarget(),
])
def test_class_hierarchy(self, obj):
assert isinstance(obj, downloads._DownloadTarget)

View File

@ -1,59 +0,0 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2016 Daniel Schadt
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Tests for the DownloadTarget class."""
from qutebrowser.utils import usertypes
import pytest
def test_base():
with pytest.raises(NotImplementedError):
usertypes.DownloadTarget()
def test_filename():
target = usertypes.FileDownloadTarget("/foo/bar")
assert target.filename == "/foo/bar"
def test_fileobj():
fobj = object()
target = usertypes.FileObjDownloadTarget(fobj)
assert target.fileobj is fobj
def test_openfile():
target = usertypes.OpenFileDownloadTarget()
assert target.cmdline is None
def test_openfile_custom_command():
target = usertypes.OpenFileDownloadTarget('echo')
assert target.cmdline == 'echo'
@pytest.mark.parametrize('obj', [
usertypes.FileDownloadTarget('foobar'),
usertypes.FileObjDownloadTarget(None),
usertypes.OpenFileDownloadTarget(),
])
def test_class_hierarchy(obj):
assert isinstance(obj, usertypes.DownloadTarget)