diff --git a/qutebrowser/app.py b/qutebrowser/app.py index f691595d4..6d7b6a3ea 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -47,7 +47,7 @@ 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 -from qutebrowser.browser.webkit import cookies, cache, history +from qutebrowser.browser.webkit import cookies, cache, history, downloads from qutebrowser.browser.webkit.network import (qutescheme, proxy, networkmanager) from qutebrowser.mainwindow import mainwindow @@ -436,6 +436,8 @@ def _init_modules(args, crash_handler): os.environ.pop('QT_WAYLAND_DISABLE_WINDOWDECORATION', None) _maybe_hide_mouse_cursor() objreg.get('config').changed.connect(_maybe_hide_mouse_cursor) + temp_downloads = downloads.TempDownloadManager(qApp) + objreg.register('temporary-downloads', temp_downloads) def _init_late_modules(args): @@ -708,6 +710,8 @@ class Quitter: not restart): atexit.register(shutil.rmtree, self._args.basedir, ignore_errors=True) + # Delete temp download dir + objreg.get('temporary-downloads').cleanup() # If we don't kill our custom handler here we might get segfaults log.destroy.debug("Deactivating message handler...") qInstallMessageHandler(None) diff --git a/qutebrowser/browser/adblock.py b/qutebrowser/browser/adblock.py index 9fc09bd7b..afdc9922e 100644 --- a/qutebrowser/browser/adblock.py +++ b/qutebrowser/browser/adblock.py @@ -27,7 +27,7 @@ import zipfile import fnmatch from qutebrowser.config import config -from qutebrowser.utils import objreg, standarddir, log, message +from qutebrowser.utils import objreg, standarddir, log, message, usertypes from qutebrowser.commands import cmdutils, cmdexc @@ -210,7 +210,8 @@ class HostBlocker: else: fobj = io.BytesIO() fobj.name = 'adblock: ' + url.host() - download = download_manager.get(url, fileobj=fobj, + target = usertypes.FileObjDownloadTarget(fobj) + download = download_manager.get(url, target=target, auto_remove=True) self._in_progress.append(download) download.finished.connect( diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 49eaa5dcb..503a9cfd9 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -1267,15 +1267,23 @@ class CommandDispatcher: " as mhtml.") url = urlutils.qurl_from_user_input(url) urlutils.raise_cmdexc_if_invalid(url) - download_manager.get(url, filename=dest) + if dest is None: + target = None + else: + target = usertypes.FileDownloadTarget(dest) + download_manager.get(url, target=target) elif mhtml_: self._download_mhtml(dest) else: # FIXME:qtwebengine have a proper API for this tab = self._current_widget() page = tab._widget.page() # pylint: disable=protected-access + if dest is None: + target = None + else: + target = usertypes.FileDownloadTarget(dest) download_manager.get(self._current_url(), page=page, - filename=dest) + target=target) def _download_mhtml(self, dest=None): """Download the current page as an MHTML file, including all assets. diff --git a/qutebrowser/browser/webkit/downloads.py b/qutebrowser/browser/webkit/downloads.py index 0673ed43f..87e038841 100644 --- a/qutebrowser/browser/webkit/downloads.py +++ b/qutebrowser/browser/webkit/downloads.py @@ -25,6 +25,7 @@ import sys import os.path import shutil import functools +import tempfile import collections import sip @@ -280,6 +281,7 @@ class DownloadItem(QObject): _read_timer: A Timer which reads the QNetworkReply into self._buffer periodically. _win_id: The window ID the DownloadItem runs in. + _dead: Whether the Download has _die()'d. Signals: data_changed: The downloads metadata changed. @@ -328,6 +330,7 @@ class DownloadItem(QObject): self.init_reply(reply) self._win_id = win_id self.raw_headers = {} + self._dead = False def __repr__(self): return utils.get_repr(self, basename=self.basename) @@ -395,6 +398,21 @@ class DownloadItem(QObject): def _die(self, msg): """Abort the download and emit an error.""" assert not self.successful + # Prevent actions if calling _die() twice. This might happen if the + # error handler correctly connects, and the error occurs in init_reply + # between reply.error.connect and the reply.error() check. In this + # case, the connected error handlers will be called twice, once via the + # direct error.emit() and once here in _die(). The stacks look like + # this then: + # -> on_reply_error -> _die -> + # self.error.emit() + # and + # [init_reply -> ->] -> + # self.error.emit() + # which may lead to duplicate error messages (and failing tests) + if self._dead: + return + self._dead = True self._read_timer.stop() self.reply.downloadProgress.disconnect() self.reply.finished.disconnect() @@ -441,7 +459,7 @@ class DownloadItem(QObject): # 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.error.emit(reply.errorString())) + QTimer.singleShot(0, lambda: self._die(reply.errorString())) def get_status_color(self, position): """Choose an appropriate color for presenting the download's status. @@ -513,7 +531,13 @@ class DownloadItem(QObject): def open_file(self): """Open the downloaded file.""" assert self.successful - url = QUrl.fromLocalFile(self._filename) + filename = self._filename + if filename is None: + filename = getattr(self.fileobj, 'name', None) + if filename is None: + log.downloads.error("No filename to open the download!") + return + url = QUrl.fromLocalFile(filename) QDesktopServices.openUrl(url) def set_filename(self, filename): @@ -738,6 +762,9 @@ class DownloadManager(QAbstractListModel): def _postprocess_question(self, q): """Postprocess a Question object that is asked.""" q.destroyed.connect(functools.partial(self.questions.remove, q)) + # We set the mode here so that other code that uses ask_for_filename + # doesn't need to handle the special download mode. + q.mode = usertypes.PromptMode.download self.questions.append(q) @pyqtSlot() @@ -757,10 +784,7 @@ class DownloadManager(QAbstractListModel): **kwargs: passed to get_request(). Return: - If the download could start immediately, (fileobj/filename given), - the created DownloadItem. - - If not, None. + The created DownloadItem. """ if not url.isValid(): urlutils.invalid_url_error(self._win_id, url, "start download") @@ -768,27 +792,17 @@ class DownloadManager(QAbstractListModel): req = QNetworkRequest(url) return self.get_request(req, **kwargs) - def get_request(self, request, *, fileobj=None, filename=None, - prompt_download_directory=None, **kwargs): + def get_request(self, request, *, target=None, **kwargs): """Start a download with a QNetworkRequest. Args: request: The QNetworkRequest to download. - fileobj: The file object to write the answer to. - filename: A path to write the data to. - prompt_download_directory: Whether to prompt for the download dir - or automatically download. If None, the - config is used. + target: Where to save the download as usertypes.DownloadTarget. **kwargs: Passed to fetch_request. Return: - If the download could start immediately, (fileobj/filename given), - the created DownloadItem. - - If not, None. + The created DownloadItem. """ - if fileobj is not None and filename is not None: # pragma: no cover - raise TypeError("Only one of fileobj/filename may be given!") # WORKAROUND for Qt corrupting data loaded from cache: # https://bugreports.qt.io/browse/QTBUG-42757 request.setAttribute(QNetworkRequest.CacheLoadControlAttribute, @@ -816,27 +830,10 @@ class DownloadManager(QAbstractListModel): if suggested_fn is None: suggested_fn = 'qutebrowser-download' - # We won't need a question if a filename or fileobj is already given - if fileobj is None and filename is None: - filename, q = ask_for_filename( - suggested_fn, self._win_id, parent=self, - prompt_download_directory=prompt_download_directory - ) - - if fileobj is not None or filename is not None: - return self.fetch_request(request, - fileobj=fileobj, - filename=filename, - suggested_filename=suggested_fn, - **kwargs) - q.answered.connect( - lambda fn: self.fetch_request(request, - filename=fn, - suggested_filename=suggested_fn, - **kwargs)) - self._postprocess_question(q) - q.ask() - return None + return self.fetch_request(request, + target=target, + suggested_filename=suggested_fn, + **kwargs) def fetch_request(self, request, *, page=None, **kwargs): """Download a QNetworkRequest to disk. @@ -857,27 +854,25 @@ class DownloadManager(QAbstractListModel): return self.fetch(reply, **kwargs) @pyqtSlot('QNetworkReply') - def fetch(self, reply, *, fileobj=None, filename=None, auto_remove=False, + 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. - fileobj: The file object to write the answer to. - filename: A path to write the data to. + target: Where to save the download as usertypes.DownloadTarget. auto_remove: Whether to remove the download even if ui -> remove-finished-downloads is set to -1. Return: The created DownloadItem. """ - if fileobj is not None and filename is not None: # pragma: no cover - raise TypeError("Only one of fileobj/filename may be given!") if not suggested_filename: - if filename is not None: - suggested_filename = os.path.basename(filename) - elif fileobj is not None and getattr(fileobj, 'name', None): - suggested_filename = fileobj.name + if isinstance(target, usertypes.FileDownloadTarget): + suggested_filename = os.path.basename(target.filename) + elif (isinstance(target, usertypes.FileObjDownloadTarget) and + getattr(target.fileobj, 'name', None)): + suggested_filename = target.fileobj.name else: _, suggested_filename = http.parse_content_disposition(reply) log.downloads.debug("fetch: {} -> {}".format(reply.url(), @@ -909,13 +904,8 @@ class DownloadManager(QAbstractListModel): if not self._update_timer.isActive(): self._update_timer.start() - if fileobj is not None: - download.set_fileobj(fileobj) - download.autoclose = False - return download - - if filename is not None: - download.set_filename(filename) + if target is not None: + self._set_download_target(download, suggested_filename, target) return download # Neither filename nor fileobj were given, prepare a question @@ -926,12 +916,15 @@ class DownloadManager(QAbstractListModel): # User doesn't want to be asked, so just use the download_dir if filename is not None: - download.set_filename(filename) + target = usertypes.FileDownloadTarget(filename) + self._set_download_target(download, suggested_filename, target) return download # Ask the user for a filename self._postprocess_question(q) - q.answered.connect(download.set_filename) + q.answered.connect( + functools.partial(self._set_download_target, download, + suggested_filename)) q.cancelled.connect(download.cancel) download.cancelled.connect(q.abort) download.error.connect(q.abort) @@ -939,6 +932,28 @@ class DownloadManager(QAbstractListModel): return download + def _set_download_target(self, download, suggested_filename, target): + """Set the target for a given download. + + Args: + download: The download to set the filename for. + suggested_filename: The suggested filename. + target: The usertypes.DownloadTarget for this download. + """ + if isinstance(target, usertypes.FileObjDownloadTarget): + download.set_fileobj(target.fileobj) + download.autoclose = False + elif isinstance(target, usertypes.FileDownloadTarget): + download.set_filename(target.filename) + elif isinstance(target, usertypes.OpenFileDownloadTarget): + tmp_manager = objreg.get('temporary-downloads') + fobj = tmp_manager.get_tmpfile(suggested_filename) + download.finished.connect(download.open_file) + download.autoclose = True + download.set_fileobj(fobj) + else: + log.downloads.error("Unknown download target: {}".format(target)) + def raise_no_download(self, count): """Raise an exception that the download doesn't exist. @@ -1249,3 +1264,59 @@ class DownloadManager(QAbstractListModel): The number of unfinished downloads. """ return sum(1 for download in self.downloads if not download.done) + + +class TempDownloadManager(QObject): + + """Manager to handle temporary download files. + + The downloads are downloaded to a temporary location and then openened with + the system standard application. The temporary files are deleted when + qutebrowser is shutdown. + + Attributes: + files: A list of NamedTemporaryFiles of downloaded items. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.files = [] + self._tmpdir = None + + def cleanup(self): + """Clean up any temporary files.""" + if self._tmpdir is not None: + self._tmpdir.cleanup() + self._tmpdir = None + + def _get_tmpdir(self): + """Return the temporary directory that is used for downloads. + + The directory is created lazily on first access. + + Return: + The tempfile.TemporaryDirectory that is used. + """ + if self._tmpdir is None: + self._tmpdir = tempfile.TemporaryDirectory( + prefix='qutebrowser-downloads-') + return self._tmpdir + + def get_tmpfile(self, suggested_name): + """Return a temporary file in the temporary downloads directory. + + The files are kept as long as qutebrowser is running and automatically + cleaned up at program exit. + + Args: + suggested_name: str of the "suggested"/original filename. Used as a + suffix, so any file extenions are preserved. + + Return: + A tempfile.NamedTemporaryFile that should be used to save the file. + """ + tmpdir = self._get_tmpdir() + fobj = tempfile.NamedTemporaryFile(dir=tmpdir.name, delete=False, + suffix=suggested_name) + self.files.append(fobj) + return fobj diff --git a/qutebrowser/browser/webkit/mhtml.py b/qutebrowser/browser/webkit/mhtml.py index c402b3041..8a157fa03 100644 --- a/qutebrowser/browser/webkit/mhtml.py +++ b/qutebrowser/browser/webkit/mhtml.py @@ -343,7 +343,8 @@ class _Downloader: download_manager = objreg.get('download-manager', scope='window', window=self._win_id) - item = download_manager.get(url, fileobj=_NoCloseBytesIO(), + target = usertypes.FileObjDownloadTarget(_NoCloseBytesIO()) + item = download_manager.get(url, target=target, auto_remove=True) self.pending_downloads.add((url, item)) item.finished.connect(functools.partial(self._finished, url, item)) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index c28244358..5110a6090 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -1574,6 +1574,7 @@ KEY_DATA = collections.OrderedDict([ ('prompt-accept', RETURN_KEYS), ('prompt-yes', ['y']), ('prompt-no', ['n']), + ('prompt-open-download', ['']), ])), ('command,prompt', collections.OrderedDict([ diff --git a/qutebrowser/mainwindow/statusbar/prompter.py b/qutebrowser/mainwindow/statusbar/prompter.py index 6d60156d6..d67c843ee 100644 --- a/qutebrowser/mainwindow/statusbar/prompter.py +++ b/qutebrowser/mainwindow/statusbar/prompter.py @@ -80,6 +80,7 @@ class Prompter(QObject): usertypes.PromptMode.text: usertypes.KeyMode.prompt, usertypes.PromptMode.user_pwd: usertypes.KeyMode.prompt, usertypes.PromptMode.alert: usertypes.KeyMode.prompt, + usertypes.PromptMode.download: usertypes.KeyMode.prompt, } show_prompt = pyqtSignal() @@ -164,12 +165,9 @@ class Prompter(QObject): suffix = " (no)" prompt.txt.setText(self._question.text + suffix) prompt.lineedit.hide() - elif self._question.mode == usertypes.PromptMode.text: - prompt.txt.setText(self._question.text) - if self._question.default: - prompt.lineedit.setText(self._question.default) - prompt.lineedit.show() - elif self._question.mode == usertypes.PromptMode.user_pwd: + elif self._question.mode in [usertypes.PromptMode.text, + usertypes.PromptMode.user_pwd, + usertypes.PromptMode.download]: prompt.txt.setText(self._question.text) if self._question.default: prompt.lineedit.setText(self._question.default) @@ -248,6 +246,13 @@ class Prompter(QObject): modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, 'prompt accept') self._question.done() + elif self._question.mode == usertypes.PromptMode.download: + # User just entered a path for a download. + target = usertypes.FileDownloadTarget(prompt.lineedit.text()) + self._question.answer = target + modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, + 'prompt accept') + self._question.done() elif self._question.mode == usertypes.PromptMode.yesno: # User wants to accept the default of a yes/no question. self._question.answer = self._question.default @@ -287,6 +292,18 @@ class Prompter(QObject): 'prompt accept') self._question.done() + @cmdutils.register(instance='prompter', hide=True, scope='window', + modes=[usertypes.KeyMode.prompt]) + def prompt_open_download(self): + """Immediately open a download.""" + if self._question.mode != usertypes.PromptMode.download: + # We just ignore this if we don't have a download question. + return + self._question.answer = usertypes.OpenFileDownloadTarget() + modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, + 'download open') + self._question.done() + @pyqtSlot(usertypes.Question, bool) def ask_question(self, question, blocking): """Display a question in the statusbar. diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 872531d4f..e365d5645 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -221,7 +221,8 @@ class NeighborList(collections.abc.Sequence): # The mode of a Question. -PromptMode = enum('PromptMode', ['yesno', 'text', 'user_pwd', 'alert']) +PromptMode = enum('PromptMode', ['yesno', 'text', 'user_pwd', 'alert', + 'download']) # Where to open a clicked link. @@ -255,6 +256,50 @@ LoadStatus = enum('LoadStatus', ['none', 'success', 'success_https', 'error', Backend = enum('Backend', ['QtWebKit', 'QtWebEngine']) +# 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.""" + + def __init__(self): + # pylint: disable=super-init-not-called + pass + + class Question(QObject): """A question asked to the user, e.g. via the status bar. diff --git a/scripts/dev/misc_checks.py b/scripts/dev/misc_checks.py index b759c39de..842888e01 100644 --- a/scripts/dev/misc_checks.py +++ b/scripts/dev/misc_checks.py @@ -81,14 +81,14 @@ def check_spelling(): """Check commonly misspelled words.""" # Words which I often misspell words = {'[Bb]ehaviour', '[Qq]uitted', 'Ll]ikelyhood', '[Ss]ucessfully', - '[Oo]ccur[^r .]', '[Ss]eperator', '[Ee]xplicitely', '[Rr]esetted', + '[Oo]ccur[^rs .]', '[Ss]eperator', '[Ee]xplicitely', '[Aa]uxillary', '[Aa]ccidentaly', '[Aa]mbigious', '[Ll]oosly', '[Ii]nitialis', '[Cc]onvienence', '[Ss]imiliar', '[Uu]ncommited', '[Rr]eproducable', '[Aa]n [Uu]ser', '[Cc]onvienience', '[Ww]ether', '[Pp]rogramatically', '[Ss]plitted', '[Ee]xitted', '[Mm]ininum', '[Rr]esett?ed', '[Rr]ecieved', '[Rr]egularily', '[Uu]nderlaying', '[Ii]nexistant', '[Ee]lipsis', 'commiting', - 'existant'} + 'existant', '[Rr]esetted'} # Words which look better when splitted, but might need some fine tuning. words |= {'[Ww]ebelements', '[Mm]ouseevent', '[Kk]eysequence', diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature index 7016f3284..7548c0dda 100644 --- a/tests/end2end/features/downloads.feature +++ b/tests/end2end/features/downloads.feature @@ -30,9 +30,8 @@ Feature: Downloading things from a website. And I open data/downloads/issue1243.html And I run :hint links download And I run :follow-hint a - And I wait for "Asking question text='Save file to:'>, *" in the log - And I run :leave-mode - Then no crash should happen + And I wait for "Asking question text='Save file to:'>, *" in the log + Then the error "Download error: No handler found for qute://!" should be shown Scenario: Downloading a data: link (issue 1214) When I set completion -> download-path-suggestion to filename @@ -40,7 +39,7 @@ Feature: Downloading things from a website. And I open data/downloads/issue1214.html And I run :hint links download And I run :follow-hint a - And I wait for "Asking question text='Save file to:'>, *" in the log + And I wait for "Asking question text='Save file to:'>, *" in the log And I run :leave-mode Then no crash should happen diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 0781a3a70..fb0c22f1a 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -312,7 +312,7 @@ Feature: Various utility commands. And I open data/misc/test.pdf And I wait for "[qute://pdfjs/*] PDF * (PDF.js: *)" in the log And I run :jseval document.getElementById("download").click() - And I wait for "Asking question text='Save file to:'>, *" in the log + And I wait for "Asking question text='Save file to:'>, *" in the log And I run :leave-mode Then no crash should happen diff --git a/tests/end2end/features/test_downloads_bdd.py b/tests/end2end/features/test_downloads_bdd.py index dc5a478eb..06271cb5c 100644 --- a/tests/end2end/features/test_downloads_bdd.py +++ b/tests/end2end/features/test_downloads_bdd.py @@ -64,7 +64,7 @@ def download_should_exist(filename, tmpdir): def download_prompt(tmpdir, quteproc, path): full_path = path.replace('{downloaddir}', str(tmpdir)).replace('/', os.sep) msg = ("Asking question " + "default={full_path!r} mode= " "text='Save file to:'>, *".format(full_path=full_path)) quteproc.wait_for(message=msg) quteproc.send_cmd(':leave-mode') diff --git a/tests/unit/browser/test_adblock.py b/tests/unit/browser/test_adblock.py index b300c8a26..8f8d4d65f 100644 --- a/tests/unit/browser/test_adblock.py +++ b/tests/unit/browser/test_adblock.py @@ -85,12 +85,12 @@ class FakeDownloadManager: """Mock browser.downloads.DownloadManager.""" - def get(self, url, fileobj, **kwargs): + def get(self, url, target, **kwargs): """Return a FakeDownloadItem instance with a fileobj. The content is copied from the file the given url links to. """ - download_item = FakeDownloadItem(fileobj, name=url.path()) + download_item = FakeDownloadItem(target.fileobj, name=url.path()) with open(url.path(), 'rb') as fake_url_file: shutil.copyfileobj(fake_url_file, download_item.fileobj) return download_item diff --git a/tests/unit/utils/usertypes/test_downloadtarget.py b/tests/unit/utils/usertypes/test_downloadtarget.py new file mode 100644 index 000000000..e89401144 --- /dev/null +++ b/tests/unit/utils/usertypes/test_downloadtarget.py @@ -0,0 +1,54 @@ +# 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 . + +"""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(): + # Just make sure no error is raised, that should be enough. + usertypes.OpenFileDownloadTarget() + + +@pytest.mark.parametrize('obj', [ + usertypes.FileDownloadTarget('foobar'), + usertypes.FileObjDownloadTarget(None), + usertypes.OpenFileDownloadTarget(), +]) +def test_class_hierarchy(obj): + assert isinstance(obj, usertypes.DownloadTarget)