From 2c94efbf8a87e46f2c862b334c8f44eb031dd03a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 1 Nov 2016 16:19:15 +0100 Subject: [PATCH] First big download refactoring chunk --- qutebrowser/browser/downloads.py | 501 ++++++++++++++++++++++++ qutebrowser/browser/webkit/downloads.py | 425 +------------------- 2 files changed, 519 insertions(+), 407 deletions(-) create mode 100644 qutebrowser/browser/downloads.py diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py new file mode 100644 index 000000000..b1cf085e1 --- /dev/null +++ b/qutebrowser/browser/downloads.py @@ -0,0 +1,501 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Shared QtWebKit/QtWebEngine code for downloads.""" + +import sys +import shlex +import html +import os.path +import collections + +from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QObject, QUrl +from PyQt5.QtGui import QDesktopServices + +from qutebrowser.config import config +from qutebrowser.utils import usertypes, standarddir, utils, message, log +from qutebrowser.misc import guiprocess + + +_DownloadPath = collections.namedtuple('_DownloadPath', ['filename', + 'question']) + + +ModelRole = usertypes.enum('ModelRole', ['item'], start=Qt.UserRole, + is_int=True) + + +# Remember the last used directory +last_used_directory = None + + +# All REFRESH_INTERVAL milliseconds, speeds will be recalculated and downloads +# redrawn. +_REFRESH_INTERVAL = 500 + + +class UnsupportedAttribute: + + """Class which is used to create attributes which are not supported. + + This is used for attributes like "fileobj" for downloads which are not + supported with QtWebengine. + """ + + pass + + +def download_dir(): + """Get the download directory to use.""" + directory = config.get('storage', 'download-directory') + remember_dir = config.get('storage', 'remember-download-directory') + + if remember_dir and last_used_directory is not None: + return last_used_directory + elif directory is None: + return standarddir.download() + else: + return directory + + +def _path_suggestion(filename): + """Get the suggested file path. + + Args: + filename: The filename to use if included in the suggestion. + """ + suggestion = config.get('completion', 'download-path-suggestion') + if suggestion == 'path': + # add trailing '/' if not present + return os.path.join(download_dir(), '') + elif suggestion == 'filename': + return filename + elif suggestion == 'both': + return os.path.join(download_dir(), filename) + else: # pragma: no cover + raise ValueError("Invalid suggestion value {}!".format(suggestion)) + + +def create_full_filename(basename, filename): + """Create a full filename based on the given basename and filename. + + Args: + basename: The basename to use if filename is a directory. + filename: The path to a folder or file where you want to save. + + Return: + The full absolute path, or None if filename creation was not possible. + """ + # Remove chars which can't be encoded in the filename encoding. + # See https://github.com/The-Compiler/qutebrowser/issues/427 + encoding = sys.getfilesystemencoding() + filename = utils.force_encoding(filename, encoding) + basename = utils.force_encoding(basename, encoding) + if os.path.isabs(filename) and os.path.isdir(filename): + # We got an absolute directory from the user, so we save it under + # the default filename in that directory. + return os.path.join(filename, basename) + elif os.path.isabs(filename): + # We got an absolute filename from the user, so we save it under + # that filename. + return filename + return None + + +def ask_for_filename(suggested_filename, *, url, parent=None, + prompt_download_directory=None): + """Prepare a question for a download-path. + + If a filename can be determined directly, it is returned instead. + + Returns a (filename, question)-namedtuple, in which one component is + None. filename is a string, question is a usertypes.Question. The + question has a special .ask() method that takes no arguments for + convenience, as this function does not yet ask the question, it + only prepares it. + + Args: + suggested_filename: The "default"-name that is pre-entered as path. + url: The URL the download originated from. + parent: The parent of the question (a QObject). + prompt_download_directory: If this is something else than None, it + will overwrite the + storage->prompt-download-directory setting. + """ + if prompt_download_directory is None: + prompt_download_directory = config.get('storage', + 'prompt-download-directory') + + if not prompt_download_directory: + return _DownloadPath(filename=download_dir(), question=None) + + encoding = sys.getfilesystemencoding() + suggested_filename = utils.force_encoding(suggested_filename, encoding) + + q = usertypes.Question(parent) + q.title = "Save file to:" + q.text = "Please enter a location for {}".format( + html.escape(url.toDisplayString())) + q.mode = usertypes.PromptMode.text + q.completed.connect(q.deleteLater) + q.default = _path_suggestion(suggested_filename) + + q.ask = lambda: message.global_bridge.ask(q, blocking=False) + return _DownloadPath(filename=None, question=q) + + +class DownloadItemStats(QObject): + + """Statistics (bytes done, total bytes, time, etc.) about a download. + + Class attributes: + SPEED_AVG_WINDOW: How many seconds of speed data to average to + estimate the remaining time. + + Attributes: + done: How many bytes there are already downloaded. + total: The total count of bytes. None if the total is unknown. + speed: The current download speed, in bytes per second. + _speed_avg: A rolling average of speeds. + _last_done: The count of bytes which where downloaded when calculating + the speed the last time. + """ + + SPEED_AVG_WINDOW = 30 + + def __init__(self, parent=None): + super().__init__(parent) + self.total = None + self.done = 0 + self.speed = 0 + self._last_done = 0 + samples = int(self.SPEED_AVG_WINDOW * (1000 / _REFRESH_INTERVAL)) + self._speed_avg = collections.deque(maxlen=samples) + + def update_speed(self): + """Recalculate the current download speed. + + The caller needs to guarantee this is called all _REFRESH_INTERVAL ms. + """ + if self.done is None: + # this can happen for very fast downloads, e.g. when actually + # opening a file + return + delta = self.done - self._last_done + self.speed = delta * 1000 / _REFRESH_INTERVAL + self._speed_avg.append(self.speed) + self._last_done = self.done + + def finish(self): + """Set the download stats as finished.""" + self.done = self.total + + def percentage(self): + """The current download percentage, or None if unknown.""" + if self.done == self.total: + return 100 + elif self.total == 0 or self.total is None: + return None + else: + return 100 * self.done / self.total + + def remaining_time(self): + """The remaining download time in seconds, or None.""" + if self.total is None or not self._speed_avg: + # No average yet or we don't know the total size. + return None + remaining_bytes = self.total - self.done + avg = sum(self._speed_avg) / len(self._speed_avg) + if avg == 0: + # Download stalled + return None + else: + return remaining_bytes / avg + + @pyqtSlot('qint64', 'qint64') + def on_download_progress(self, bytes_done, bytes_total): + """Update local variables when the download progress changed. + + Args: + bytes_done: How many bytes are downloaded. + bytes_total: How many bytes there are to download in total. + """ + if bytes_total == -1: + bytes_total = None + self.done = bytes_done + self.total = bytes_total + + +class AbstractDownloadItem(QObject): + + """Shared QtNetwork/QtWebEngine part of a download item. + + FIXME + """ + + data_changed = pyqtSignal() + finished = pyqtSignal() + error = pyqtSignal(str) + cancelled = pyqtSignal() + remove_requested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.done = False + self.stats = DownloadItemStats(self) + self.index = 0 + self.error_msg = None + self.basename = '???' + self.successful = False + + self.autoclose = UnsupportedAttribute() + self.fileobj = UnsupportedAttribute() + self.raw_headers = UnsupportedAttribute() + + self._filename = None + self._dead = False + + def __repr__(self): + return utils.get_repr(self, basename=self.basename) + + def __str__(self): + """Get the download as a string. + + Example: foo.pdf [699.2kB/s|0.34|16%|4.253/25.124] + """ + speed = utils.format_size(self.stats.speed, suffix='B/s') + down = utils.format_size(self.stats.done, suffix='B') + perc = self.stats.percentage() + remaining = self.stats.remaining_time() + if self.error_msg is None: + errmsg = "" + else: + errmsg = " - {}".format(self.error_msg) + if all(e is None for e in [perc, remaining, self.stats.total]): + return ('{index}: {name} [{speed:>10}|{down}]{errmsg}'.format( + index=self.index, name=self.basename, speed=speed, + down=down, errmsg=errmsg)) + perc = round(perc) + if remaining is None: + remaining = '?' + else: + remaining = utils.format_seconds(remaining) + total = utils.format_size(self.stats.total, suffix='B') + if self.done: + return ('{index}: {name} [{perc:>2}%|{total}]{errmsg}'.format( + index=self.index, name=self.basename, perc=perc, + total=total, errmsg=errmsg)) + else: + return ('{index}: {name} [{speed:>10}|{remaining:>5}|{perc:>2}%|' + '{down}/{total}]{errmsg}'.format( + index=self.index, name=self.basename, speed=speed, + remaining=remaining, perc=perc, down=down, + total=total, errmsg=errmsg)) + + def _die(self, msg): + """Abort the download and emit an error.""" + assert not self.successful + # Prevent actions if calling _die() twice. + # + # For QtWebKit, 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.error_msg = msg + self.stats.finish() + self.error.emit(msg) + self.done = True + self.data_changed.emit() + + def get_status_color(self, position): + """Choose an appropriate color for presenting the download's status. + + Args: + position: The color type requested, can be 'fg' or 'bg'. + """ + # pylint: disable=bad-config-call + # WORKAROUND for https://bitbucket.org/logilab/astroid/issue/104/ + assert position in ["fg", "bg"] + start = config.get('colors', 'downloads.{}.start'.format(position)) + stop = config.get('colors', 'downloads.{}.stop'.format(position)) + system = config.get('colors', 'downloads.{}.system'.format(position)) + error = config.get('colors', 'downloads.{}.error'.format(position)) + if self.error_msg is not None: + assert not self.successful + return error + elif self.stats.percentage() is None: + return start + else: + return utils.interpolate_color(start, stop, + self.stats.percentage(), system) + + def _do_cancel(self): + """Actual cancel implementation.""" + raise NotImplementedError + + @pyqtSlot() + def cancel(self, *, remove_data=True): + """Cancel the download. + + Args: + remove_data: Whether to remove the downloaded data. + """ + self._do_cancel() + log.downloads.debug("cancelled") + self.cancelled.emit() + if remove_data: + self.delete() + self.done = True + self.finished.emit() + self.data_changed.emit() + + @pyqtSlot() + def remove(self): + """Remove the download from the model.""" + self.remove_requested.emit() + + def delete(self): + """Delete the downloaded file.""" + try: + if self._filename is not None and os.path.exists(self._filename): + os.remove(self._filename) + log.downloads.debug("Deleted {}".format(self._filename)) + else: + log.downloads.debug("Not deleting {}".format(self._filename)) + except OSError: + log.downloads.exception("Failed to remove partial file") + + @pyqtSlot() + def retry(self): + """Retry a failed download.""" + raise NotImplementedError + + def _get_open_filename(self): + """Get the filename to open a download. + + Returns None if no suitable filename was found. + """ + raise NotImplementedError + + @pyqtSlot() + def open_file(self, cmdline=None): + """Open the downloaded file. + + Args: + 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. + """ + assert self.successful + filename = self._get_open_filename() + if filename is None: # pragma: no cover + log.downloads.error("No filename to open the download!") + return + + if cmdline is None: + log.downloads.debug("Opening {} with the system application" + .format(filename)) + url = QUrl.fromLocalFile(filename) + QDesktopServices.openUrl(url) + return + + cmd, *args = shlex.split(cmdline) + args = [arg.replace('{}', filename) for arg in args] + if '{}' not in cmdline: + args.append(filename) + log.downloads.debug("Opening {} with {}" + .format(filename, [cmd] + args)) + proc = guiprocess.GUIProcess(what='download') + proc.start_detached(cmd, args) + + def _ensure_can_set_filename(self, filename): + """Make sure we can still set a filename.""" + raise NotImplementedError + + def _after_set_filename(self): + """Finish initialization based on self._filename.""" + raise NotImplementedError + + def set_filename(self, filename): + """Set the filename to save the download to. + + Args: + filename: The full filename to save the download to. + None: special value to stop the download. + """ + global last_used_directory + filename = os.path.expanduser(filename) + self._ensure_can_set_filename(filename) + + self._filename = create_full_filename(self.basename, filename) + if self._filename is None: + # We only got a filename (without directory) or a relative path + # from the user, so we append that to the default directory and + # try again. + self._filename = create_full_filename( + self.basename, os.path.join(download_dir(), filename)) + + # At this point, we have a misconfigured XDG_DOWNLOAD_DIR, as + # download_dir() + filename is still no absolute path. + # The config value is checked for "absoluteness", but + # ~/.config/user-dirs.dirs may be misconfigured and a non-absolute path + # may be set for XDG_DOWNLOAD_DIR + if self._filename is None: + message.error( + "XDG_DOWNLOAD_DIR points to a relative path - please check" + " your ~/.config/user-dirs.dirs. The download is saved in" + " your home directory.", + ) + # fall back to $HOME as download_dir + self._filename = create_full_filename(self.basename, + os.path.expanduser('~')) + + self.basename = os.path.basename(self._filename) + last_used_directory = os.path.dirname(self._filename) + + log.downloads.debug("Setting filename to {}".format(filename)) + if os.path.isfile(self._filename): + # The file already exists, so ask the user if it should be + # overwritten. + txt = "{} already exists. Overwrite?".format( + html.escape(self._filename)) + self._ask_confirm_question("Overwrite existing file?", txt) + # FIFO, device node, etc. Make sure we want to do this + elif (os.path.exists(self._filename) and + not os.path.isdir(self._filename)): + txt = ("{} already exists and is a special file. Write to " + "it anyways?".format(html.escape(self._filename))) + self._ask_confirm_question("Overwrite special file?", txt) + else: + self._after_set_filename() diff --git a/qutebrowser/browser/webkit/downloads.py b/qutebrowser/browser/webkit/downloads.py index 1fe6d643b..82d8c499e 100644 --- a/qutebrowser/browser/webkit/downloads.py +++ b/qutebrowser/browser/webkit/downloads.py @@ -22,7 +22,6 @@ import io import os import sys -import shlex import os.path import shutil import functools @@ -33,218 +32,21 @@ import html import sip from PyQt5.QtCore import (pyqtSlot, pyqtSignal, QObject, QTimer, Qt, QAbstractListModel, QModelIndex, QUrl) -from PyQt5.QtGui import QDesktopServices from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from qutebrowser.config import config from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.utils import (message, usertypes, log, utils, urlutils, objreg, standarddir, qtutils) -from qutebrowser.misc import guiprocess +from qutebrowser.browser import downloads from qutebrowser.browser.webkit import http from qutebrowser.browser.webkit.network import networkmanager -ModelRole = usertypes.enum('ModelRole', ['item'], start=Qt.UserRole, - is_int=True) - - _RetryInfo = collections.namedtuple('_RetryInfo', ['request', 'manager']) -_DownloadPath = collections.namedtuple('_DownloadPath', ['filename', - 'question']) -# Remember the last used directory -last_used_directory = None - - -# All REFRESH_INTERVAL milliseconds, speeds will be recalculated and downloads -# redrawn. -_REFRESH_INTERVAL = 500 - - -def download_dir(): - """Get the download directory to use.""" - directory = config.get('storage', 'download-directory') - remember_dir = config.get('storage', 'remember-download-directory') - - if remember_dir and last_used_directory is not None: - return last_used_directory - elif directory is None: - return standarddir.download() - else: - return directory - - -def _path_suggestion(filename): - """Get the suggested file path. - - Args: - filename: The filename to use if included in the suggestion. - """ - suggestion = config.get('completion', 'download-path-suggestion') - if suggestion == 'path': - # add trailing '/' if not present - return os.path.join(download_dir(), '') - elif suggestion == 'filename': - return filename - elif suggestion == 'both': - return os.path.join(download_dir(), filename) - else: # pragma: no cover - raise ValueError("Invalid suggestion value {}!".format(suggestion)) - - -def create_full_filename(basename, filename): - """Create a full filename based on the given basename and filename. - - Args: - basename: The basename to use if filename is a directory. - filename: The path to a folder or file where you want to save. - - Return: - The full absolute path, or None if filename creation was not possible. - """ - # Remove chars which can't be encoded in the filename encoding. - # See https://github.com/The-Compiler/qutebrowser/issues/427 - encoding = sys.getfilesystemencoding() - filename = utils.force_encoding(filename, encoding) - basename = utils.force_encoding(basename, encoding) - if os.path.isabs(filename) and os.path.isdir(filename): - # We got an absolute directory from the user, so we save it under - # the default filename in that directory. - return os.path.join(filename, basename) - elif os.path.isabs(filename): - # We got an absolute filename from the user, so we save it under - # that filename. - return filename - return None - - -def ask_for_filename(suggested_filename, *, url, parent=None, - prompt_download_directory=None): - """Prepare a question for a download-path. - - If a filename can be determined directly, it is returned instead. - - Returns a (filename, question)-namedtuple, in which one component is - None. filename is a string, question is a usertypes.Question. The - question has a special .ask() method that takes no arguments for - convenience, as this function does not yet ask the question, it - only prepares it. - - Args: - suggested_filename: The "default"-name that is pre-entered as path. - url: The URL the download originated from. - parent: The parent of the question (a QObject). - prompt_download_directory: If this is something else than None, it - will overwrite the - storage->prompt-download-directory setting. - """ - if prompt_download_directory is None: - prompt_download_directory = config.get('storage', - 'prompt-download-directory') - - if not prompt_download_directory: - return _DownloadPath(filename=download_dir(), question=None) - - encoding = sys.getfilesystemencoding() - suggested_filename = utils.force_encoding(suggested_filename, encoding) - - q = usertypes.Question(parent) - q.title = "Save file to:" - q.text = "Please enter a location for {}".format( - html.escape(url.toDisplayString())) - q.mode = usertypes.PromptMode.text - q.completed.connect(q.deleteLater) - q.default = _path_suggestion(suggested_filename) - - q.ask = lambda: message.global_bridge.ask(q, blocking=False) - return _DownloadPath(filename=None, question=q) - - -class DownloadItemStats(QObject): - - """Statistics (bytes done, total bytes, time, etc.) about a download. - - Class attributes: - SPEED_AVG_WINDOW: How many seconds of speed data to average to - estimate the remaining time. - - Attributes: - done: How many bytes there are already downloaded. - total: The total count of bytes. None if the total is unknown. - speed: The current download speed, in bytes per second. - _speed_avg: A rolling average of speeds. - _last_done: The count of bytes which where downloaded when calculating - the speed the last time. - """ - - SPEED_AVG_WINDOW = 30 - - def __init__(self, parent=None): - super().__init__(parent) - self.total = None - self.done = 0 - self.speed = 0 - self._last_done = 0 - samples = int(self.SPEED_AVG_WINDOW * (1000 / _REFRESH_INTERVAL)) - self._speed_avg = collections.deque(maxlen=samples) - - def update_speed(self): - """Recalculate the current download speed. - - The caller needs to guarantee this is called all _REFRESH_INTERVAL ms. - """ - if self.done is None: - # this can happen for very fast downloads, e.g. when actually - # opening a file - return - delta = self.done - self._last_done - self.speed = delta * 1000 / _REFRESH_INTERVAL - self._speed_avg.append(self.speed) - self._last_done = self.done - - def finish(self): - """Set the download stats as finished.""" - self.done = self.total - - def percentage(self): - """The current download percentage, or None if unknown.""" - if self.done == self.total: - return 100 - elif self.total == 0 or self.total is None: - return None - else: - return 100 * self.done / self.total - - def remaining_time(self): - """The remaining download time in seconds, or None.""" - if self.total is None or not self._speed_avg: - # No average yet or we don't know the total size. - return None - remaining_bytes = self.total - self.done - avg = sum(self._speed_avg) / len(self._speed_avg) - if avg == 0: - # Download stalled - return None - else: - return remaining_bytes / avg - - @pyqtSlot('qint64', 'qint64') - def on_download_progress(self, bytes_done, bytes_total): - """Update local variables when the download progress changed. - - Args: - bytes_done: How many bytes are downloaded. - bytes_total: How many bytes there are to download in total. - """ - if bytes_total == -1: - bytes_total = None - self.done = bytes_done - self.total = bytes_total - - -class DownloadItem(QObject): +class DownloadItem(downloads.AbstractDownloadItem): """A single download currently running. @@ -299,12 +101,7 @@ class DownloadItem(QObject): """ _MAX_REDIRECTS = 10 - data_changed = pyqtSignal() - finished = pyqtSignal() - error = pyqtSignal(str) - cancelled = pyqtSignal() adopt_download = pyqtSignal(object) # DownloadItem - remove_requested = pyqtSignal() def __init__(self, reply, win_id, parent=None): """Constructor. @@ -313,63 +110,22 @@ class DownloadItem(QObject): reply: The QNetworkReply to download. """ super().__init__(parent) - self._retry_info = None - self.done = False - self.stats = DownloadItemStats(self) - self.index = 0 self.autoclose = True + self.fileobj = None + self.raw_headers = {} + + self._filename = None + self._dead = False + + 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.error_msg = None - self.basename = '???' - self.successful = False - self.fileobj = None - self._filename = None 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) - - def __str__(self): - """Get the download as a string. - - Example: foo.pdf [699.2kB/s|0.34|16%|4.253/25.124] - """ - speed = utils.format_size(self.stats.speed, suffix='B/s') - down = utils.format_size(self.stats.done, suffix='B') - perc = self.stats.percentage() - remaining = self.stats.remaining_time() - if self.error_msg is None: - errmsg = "" - else: - errmsg = " - {}".format(self.error_msg) - if all(e is None for e in [perc, remaining, self.stats.total]): - return ('{index}: {name} [{speed:>10}|{down}]{errmsg}'.format( - index=self.index, name=self.basename, speed=speed, - down=down, errmsg=errmsg)) - perc = round(perc) - if remaining is None: - remaining = '?' - else: - remaining = utils.format_seconds(remaining) - total = utils.format_size(self.stats.total, suffix='B') - if self.done: - return ('{index}: {name} [{perc:>2}%|{total}]{errmsg}'.format( - index=self.index, name=self.basename, perc=perc, - total=total, errmsg=errmsg)) - else: - return ('{index}: {name} [{speed:>10}|{remaining:>5}|{perc:>2}%|' - '{down}/{total}]{errmsg}'.format( - index=self.index, name=self.basename, speed=speed, - remaining=remaining, perc=perc, down=down, - total=total, errmsg=errmsg)) def _create_fileobj(self): """Create a file object using the internal filename.""" @@ -382,38 +138,21 @@ class DownloadItem(QObject): def _ask_confirm_question(self, title, msg): """Create a Question object to be asked.""" + # FIXME:qtwebengine move this? no_action = functools.partial(self.cancel, remove_data=False) message.confirm_async(title=title, text=msg, - yes_action=self._create_fileobj, + yes_action=self._after_set_filename, no_action=no_action, cancel_action=no_action, abort_on=[self.cancelled, self.error]) 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 + super()._die(msg) self._read_timer.stop() self._reply.downloadProgress.disconnect() self._reply.finished.disconnect() self._reply.error.disconnect() self._reply.readyRead.disconnect() - self.error_msg = msg - self.stats.finish() - self.error.emit(msg) with log.hide_qt_warning('QNetworkReplyImplPrivate::error: Internal ' 'problem, this method must only be called ' 'once.'): @@ -421,8 +160,6 @@ class DownloadItem(QObject): self._reply.abort() self._reply.deleteLater() self._reply = None - self.done = True - self.data_changed.emit() if self.fileobj is not None: try: self.fileobj.close() @@ -454,38 +191,7 @@ class DownloadItem(QObject): if reply.error() != QNetworkReply.NoError: QTimer.singleShot(0, lambda: self._die(reply.errorString())) - def get_status_color(self, position): - """Choose an appropriate color for presenting the download's status. - - Args: - position: The color type requested, can be 'fg' or 'bg'. - """ - # pylint: disable=bad-config-call - # WORKAROUND for https://bitbucket.org/logilab/astroid/issue/104/ - assert position in ["fg", "bg"] - start = config.get('colors', 'downloads.{}.start'.format(position)) - stop = config.get('colors', 'downloads.{}.stop'.format(position)) - system = config.get('colors', 'downloads.{}.system'.format(position)) - error = config.get('colors', 'downloads.{}.error'.format(position)) - if self.error_msg is not None: - assert not self.successful - return error - elif self.stats.percentage() is None: - return start - else: - return utils.interpolate_color(start, stop, - self.stats.percentage(), system) - - @pyqtSlot() - def cancel(self, *, remove_data=True): - """Cancel the download. - - Args: - remove_data: Whether to remove the downloaded data. - """ - log.downloads.debug("cancelled") - self._read_timer.stop() - self.cancelled.emit() + def _do_cancel(self): if self._reply is not None: self._reply.finished.disconnect(self._on_reply_finished) self._reply.abort() @@ -493,27 +199,6 @@ class DownloadItem(QObject): self._reply = None if self.fileobj is not None: self.fileobj.close() - if remove_data: - self.delete() - self.done = True - self.finished.emit() - self.data_changed.emit() - - @pyqtSlot() - def remove(self): - """Remove the download from the model.""" - self.remove_requested.emit() - - def delete(self): - """Delete the downloaded file.""" - try: - if self._filename is not None and os.path.exists(self._filename): - os.remove(self._filename) - log.downloads.debug("Deleted {}".format(self._filename)) - else: - log.downloads.debug("Not deleting {}".format(self._filename)) - except OSError: - log.downloads.exception("Failed to remove partial file") @pyqtSlot() def retry(self): @@ -528,94 +213,20 @@ class DownloadItem(QObject): self.adopt_download.emit(new_download) self.cancel() - @pyqtSlot() - def open_file(self, cmdline=None): - """Open the downloaded file. - - Args: - 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. - """ - assert self.successful + def _get_open_filename(self): filename = self._filename if filename is None: filename = getattr(self.fileobj, 'name', None) - if filename is None: # pragma: no cover - log.downloads.error("No filename to open the download!") - return + return filename - if cmdline is None: - log.downloads.debug("Opening {} with the system application" - .format(filename)) - url = QUrl.fromLocalFile(filename) - QDesktopServices.openUrl(url) - return - - cmd, *args = shlex.split(cmdline) - args = [arg.replace('{}', filename) for arg in args] - if '{}' not in cmdline: - args.append(filename) - log.downloads.debug("Opening {} with {}" - .format(filename, [cmd] + args)) - proc = guiprocess.GUIProcess(what='download') - proc.start_detached(cmd, args) - - def set_filename(self, filename): - """Set the filename to save the download to. - - Args: - filename: The full filename to save the download to. - None: special value to stop the download. - """ - global last_used_directory + 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)) - filename = os.path.expanduser(filename) - self._filename = create_full_filename(self.basename, filename) - if self._filename is None: - # We only got a filename (without directory) or a relative path - # from the user, so we append that to the default directory and - # try again. - self._filename = create_full_filename( - self.basename, os.path.join(download_dir(), filename)) - # At this point, we have a misconfigured XDG_DOWNLOAD_DIR, as - # download_dir() + filename is still no absolute path. - # The config value is checked for "absoluteness", but - # ~/.config/user-dirs.dirs may be misconfigured and a non-absolute path - # may be set for XDG_DOWNLOAD_DIR - if self._filename is None: - message.error( - "XDG_DOWNLOAD_DIR points to a relative path - please check" - " your ~/.config/user-dirs.dirs. The download is saved in" - " your home directory.", - ) - # fall back to $HOME as download_dir - self._filename = create_full_filename(self.basename, - os.path.expanduser('~')) - - self.basename = os.path.basename(self._filename) - last_used_directory = os.path.dirname(self._filename) - - log.downloads.debug("Setting filename to {}".format(filename)) - if os.path.isfile(self._filename): - # The file already exists, so ask the user if it should be - # overwritten. - txt = "{} already exists. Overwrite?".format( - html.escape(self._filename)) - self._ask_confirm_question("Overwrite existing file?", txt) - # FIFO, device node, etc. Make sure we want to do this - elif (os.path.exists(self._filename) and - not os.path.isdir(self._filename)): - txt = ("{} already exists and is a special file. Write to " - "it anyways?".format(html.escape(self._filename))) - self._ask_confirm_question("Overwrite special file?", txt) - else: - self._create_fileobj() + def _after_set_filename(self): + self._create_fileobj() def set_fileobj(self, fileobj): """"Set the file object to write the download to.