Merge branch 'download-page' of https://github.com/Kingdread/qutebrowser into Kingdread-download-page

This commit is contained in:
Florian Bruhin 2015-11-23 13:16:16 +01:00
commit 16e1a65448
38 changed files with 1970 additions and 88 deletions

View File

@ -102,6 +102,9 @@ The following software and libraries are required to run qutebrowser:
* http://pygments.org/[pygments]
* http://pyyaml.org/wiki/PyYAML[PyYAML]
The following libraries are optional and provide a better user experience:
* http://cthedot.de/cssutils/[cssutils]
To generate the documentation for the `:help` command, when using the git
repository (rather than a release), http://asciidoc.org/[asciidoc] is needed.

View File

@ -146,13 +146,19 @@ Close the current window.
[[download]]
=== download
Syntax: +:download ['url'] ['dest']+
Syntax: +:download [*--mhtml*] [*--dest* 'DEST'] ['url'] ['dest-old']+
Download a given URL, or current page if no URL given.
The form `:download [url] [dest]` is deprecated, use `:download --dest [dest] [url]` instead.
==== positional arguments
* +'url'+: The URL to download. If not given, download the current page.
* +'dest'+: The file path to write the download to, or not given to ask.
* +'dest-old'+: (deprecated) Same as dest.
==== optional arguments
* +*-m*+, +*--mhtml*+: Download the current page and all assets as mhtml file.
* +*-d*+, +*--dest*+: The file path to write the download to, or not given to ask.
[[download-cancel]]
=== download-cancel

View File

@ -37,7 +37,7 @@ import pygments.formatters
from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners
from qutebrowser.config import config, configexc
from qutebrowser.browser import webelem, inspector, urlmarks
from qutebrowser.browser import webelem, inspector, urlmarks, downloads, mhtml
from qutebrowser.keyinput import modeman
from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils,
objreg, utils)
@ -1139,22 +1139,66 @@ class CommandDispatcher:
cur.inspector.show()
@cmdutils.register(instance='command-dispatcher', scope='window')
def download(self, url=None, dest=None):
def download(self, url=None, dest_old: {'hide': True}=None, *,
mhtml_=False, dest=None):
"""Download a given URL, or current page if no URL given.
The form `:download [url] [dest]` is deprecated, use `:download --dest
[dest] [url]` instead.
Args:
url: The URL to download. If not given, download the current page.
dest_old: (deprecated) Same as dest.
dest: The file path to write the download to, or None to ask.
mhtml_: Download the current page and all assets as mhtml file.
"""
if dest_old is not None:
message.warning(
self._win_id, ":download [url] [dest] is deprecated - use"
" download --dest [dest] [url]")
if dest is not None:
raise cmdexc.CommandError("Can't give two destinations for the"
" download.")
dest = dest_old
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
if url:
if mhtml_:
raise cmdexc.CommandError("Can only download the current page"
" as mhtml.")
url = urlutils.qurl_from_user_input(url)
urlutils.raise_cmdexc_if_invalid(url)
download_manager.get(url, filename=dest)
else:
page = self._current_widget().page()
download_manager.get(self._current_url(), page=page)
if mhtml_:
self._download_mhtml(dest)
else:
page = self._current_widget().page()
download_manager.get(self._current_url(), page=page,
filename=dest)
def _download_mhtml(self, dest=None):
"""Download the current page as a MHTML file, including all assets.
Args:
dest: The file path to write the download to.
"""
web_view = self._current_widget()
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, self._win_id, parent=web_view,
)
if filename is not None:
mhtml.start_download_checked(filename, web_view=web_view)
else:
q.answered.connect(functools.partial(
mhtml.start_download_checked, web_view=web_view))
q.ask()
else:
mhtml.start_download_checked(dest, web_view=web_view)
@cmdutils.register(instance='command-dispatcher', scope='window',
deprecated="Use :download instead.")

View File

@ -48,8 +48,13 @@ ModelRole = usertypes.enum('ModelRole', ['item'], start=Qt.UserRole,
RetryInfo = collections.namedtuple('RetryInfo', ['request', 'manager'])
DownloadPath = collections.namedtuple('DownloadPath', ['filename',
'question'])
# Remember the last used directory
_last_used_directory = None
last_used_directory = None
# All REFRESH_INTERVAL milliseconds, speeds will be recalculated and downloads
@ -57,20 +62,20 @@ _last_used_directory = None
REFRESH_INTERVAL = 500
def _download_dir():
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
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):
def path_suggestion(filename):
"""Get the suggested file path.
Args:
@ -79,15 +84,79 @@ def _path_suggestion(filename):
suggestion = config.get('completion', 'download-path-suggestion')
if suggestion == 'path':
# add trailing '/' if not present
return os.path.join(_download_dir(), '')
return os.path.join(download_dir(), '')
elif suggestion == 'filename':
return filename
elif suggestion == 'both':
return os.path.join(_download_dir(), filename)
return os.path.join(download_dir(), filename)
else:
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.
"""
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, win_id, *, 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.
win_id: The window where the question will be asked.
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.text = "Save file to:"
q.mode = usertypes.PromptMode.text
q.completed.connect(q.deleteLater)
q.default = path_suggestion(suggested_filename)
message_bridge = objreg.get('message-bridge', scope='window',
window=win_id)
q.ask = lambda: message_bridge.ask(q, blocking=False)
return DownloadPath(filename=None, question=q)
class DownloadItemStats(QObject):
"""Statistics (bytes done, total bytes, time, etc.) about a download.
@ -201,6 +270,7 @@ class DownloadItem(QObject):
fileobj: The file object to download the file to.
reply: The QNetworkReply associated with this download.
retry_info: A RetryInfo instance.
raw_headers: The headers sent by the server.
_filename: The filename of the download.
_redirects: How many time we were redirected already.
_buffer: A BytesIO object to buffer incoming data until we know the
@ -255,6 +325,7 @@ class DownloadItem(QObject):
self._filename = None
self.init_reply(reply)
self._win_id = win_id
self.raw_headers = {}
def __repr__(self):
return utils.get_repr(self, basename=self.basename)
@ -354,6 +425,7 @@ class DownloadItem(QObject):
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:
@ -444,7 +516,7 @@ class DownloadItem(QObject):
filename: The full filename to save the download to.
None: special value to stop the download.
"""
global _last_used_directory
global last_used_directory
if self.fileobj is not None:
raise ValueError("fileobj was already set! filename: {}, "
"existing: {}, fileobj {}".format(
@ -454,13 +526,16 @@ class DownloadItem(QObject):
# See https://github.com/The-Compiler/qutebrowser/issues/427
encoding = sys.getfilesystemencoding()
filename = utils.force_encoding(filename, encoding)
if not self._create_full_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._create_full_filename(os.path.join(_download_dir(), filename))
self._filename = create_full_filename(
self.basename, os.path.join(download_dir(), filename))
_last_used_directory = os.path.dirname(self._filename)
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):
@ -477,25 +552,6 @@ class DownloadItem(QObject):
else:
self._create_fileobj()
def _create_full_filename(self, filename):
"""Try to create the full filename.
Return:
True if the full filename was created, False otherwise.
"""
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.
self._filename = os.path.join(filename, self.basename)
return True
elif os.path.isabs(filename):
# We got an absolute filename from the user, so we save it under
# that filename.
self._filename = filename
self.basename = os.path.basename(self._filename)
return True
return False
def set_fileobj(self, fileobj):
""""Set the file object to write the download to.
@ -593,6 +649,15 @@ class DownloadItem(QObject):
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 a HTTP redirect.
@ -651,15 +716,10 @@ class DownloadManager(QAbstractListModel):
def __repr__(self):
return utils.get_repr(self, downloads=len(self.downloads))
def _prepare_question(self):
"""Prepare a Question object to be asked."""
q = usertypes.Question(self)
q.text = "Save file to:"
q.mode = usertypes.PromptMode.text
q.completed.connect(q.deleteLater)
def _postprocess_question(self, q):
"""Postprocess a Question object that is asked."""
q.destroyed.connect(functools.partial(self.questions.remove, q))
self.questions.append(q)
return q
@pyqtSlot()
def update_gui(self):
@ -716,11 +776,12 @@ class DownloadManager(QAbstractListModel):
QNetworkRequest.AlwaysNetwork)
suggested_fn = urlutils.filename_from_url(request.url())
if prompt_download_directory is None:
prompt_download_directory = config.get(
'storage', 'prompt-download-directory')
if not prompt_download_directory and not fileobj:
filename = _download_dir()
# 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,
@ -728,22 +789,13 @@ class DownloadManager(QAbstractListModel):
filename=filename,
suggested_filename=suggested_fn,
**kwargs)
if suggested_fn is None:
suggested_fn = 'qutebrowser-download'
else:
encoding = sys.getfilesystemencoding()
suggested_fn = utils.force_encoding(suggested_fn, encoding)
q = self._prepare_question()
q.default = _path_suggestion(suggested_fn)
message_bridge = objreg.get('message-bridge', scope='window',
window=self._win_id)
q.answered.connect(
lambda fn: self.fetch_request(request,
filename=fn,
suggested_filename=suggested_fn,
**kwargs))
message_bridge.ask(q, blocking=False)
self._postprocess_question(q)
q.ask()
return None
def fetch_request(self, request, *, page=None, **kwargs):
@ -817,26 +869,33 @@ class DownloadManager(QAbstractListModel):
if not self._update_timer.isActive():
self._update_timer.start()
prompt_download_directory = config.get('storage',
'prompt-download-directory')
if not prompt_download_directory and not fileobj:
filename = _download_dir()
if fileobj is not None:
download.set_fileobj(fileobj)
download.autoclose = False
return download
if filename is not None:
download.set_filename(filename)
elif fileobj is not None:
download.set_fileobj(fileobj)
download.autoclose = False
else:
q = self._prepare_question()
q.default = _path_suggestion(suggested_filename)
q.answered.connect(download.set_filename)
q.cancelled.connect(download.cancel)
download.cancelled.connect(q.abort)
download.error.connect(q.abort)
message_bridge = objreg.get('message-bridge', scope='window',
window=self._win_id)
message_bridge.ask(q, blocking=False)
return download
# Neither filename nor fileobj were given, prepare a question
filename, q = ask_for_filename(
suggested_filename, self._win_id, parent=self,
prompt_download_directory=prompt_download_directory,
)
# User doesn't want to be asked, so just use the download_dir
if filename is not None:
download.set_filename(filename)
return download
# Ask the user for a filename
self._postprocess_question(q)
q.answered.connect(download.set_filename)
q.cancelled.connect(download.cancel)
download.cancelled.connect(q.abort)
download.error.connect(q.abort)
q.ask()
return download

View File

@ -0,0 +1,532 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2015 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/>.
"""Utils for writing a MHTML file."""
import functools
import io
import os
import re
import sys
import collections
import uuid
import email.policy
import email.generator
import email.encoders
import email.mime.multipart
from PyQt5.QtCore import QUrl
from qutebrowser.browser import webelem, downloads
from qutebrowser.utils import log, objreg, message, usertypes, utils, urlutils
try:
import cssutils
except (ImportError, re.error):
# Catching re.error because cssutils in earlier releases (<= 1.0) is broken
# on Python 3.5
# See https://bitbucket.org/cthedot/cssutils/issues/52
cssutils = None
_File = collections.namedtuple('_File',
['content', 'content_type', 'content_location',
'transfer_encoding'])
_CSS_URL_PATTERNS = [re.compile(x) for x in [
r"@import\s+'(?P<url>[^']+)'",
r'@import\s+"(?P<url>[^"]+)"',
r'''url\((?P<url>[^'"][^)]*)\)''',
r'url\("(?P<url>[^"]+)"\)',
r"url\('(?P<url>[^']+)'\)",
]]
def _get_css_imports_regex(data):
"""Return all assets that are referenced in the given CSS document.
The returned URLs are relative to the stylesheet's URL.
Args:
data: The content of the stylesheet to scan as string.
"""
urls = []
for pattern in _CSS_URL_PATTERNS:
for match in pattern.finditer(data):
url = match.group("url")
if url:
urls.append(url)
return urls
def _get_css_imports_cssutils(data, inline=False):
"""Return all assets that are referenced in the given CSS document.
The returned URLs are relative to the stylesheet's URL.
Args:
data: The content of the stylesheet to scan as string.
inline: True if the argument is a inline HTML style attribute.
"""
# We don't care about invalid CSS data, this will only litter the log
# output with CSS errors
parser = cssutils.CSSParser(loglevel=100,
fetcher=lambda url: (None, ""), validate=False)
if not inline:
sheet = parser.parseString(data)
return list(cssutils.getUrls(sheet))
else:
urls = []
declaration = parser.parseStyle(data)
# prop = background, color, margin, ...
for prop in declaration:
# value = red, 10px, url(foobar), ...
for value in prop.propertyValue:
if isinstance(value, cssutils.css.URIValue):
if value.uri:
urls.append(value.uri)
return urls
def _get_css_imports(data, inline=False):
"""Return all assets that are referenced in the given CSS document.
The returned URLs are relative to the stylesheet's URL.
Args:
data: The content of the stylesheet to scan as string.
inline: True if the argument is a inline HTML style attribute.
"""
if cssutils is None:
return _get_css_imports_regex(data)
else:
return _get_css_imports_cssutils(data, inline)
def _check_rel(element):
"""Return true if the element's rel attribute fits our criteria.
rel has to contain 'stylesheet' or 'icon'. Also returns True if the rel
attribute is unset.
Args:
element: The WebElementWrapper which should be checked.
"""
if 'rel' not in element:
return True
must_have = {'stylesheet', 'icon'}
rels = [rel.lower() for rel in element['rel'].split(' ')]
return any(rel in rels for rel in must_have)
MHTMLPolicy = email.policy.default.clone(linesep='\r\n', max_line_length=0)
# Encode the file using base64 encoding.
E_BASE64 = email.encoders.encode_base64
# Encode the file using MIME quoted-printable encoding.
E_QUOPRI = email.encoders.encode_quopri
class MHTMLWriter:
"""A class for outputting multiple files to a MHTML document.
Attributes:
root_content: The root content as bytes.
content_location: The url of the page as str.
content_type: The MIME-type of the root content as str.
_files: Mapping of location->_File namedtuple.
"""
def __init__(self, root_content, content_location, content_type):
self.root_content = root_content
self.content_location = content_location
self.content_type = content_type
self._files = {}
def add_file(self, location, content, content_type=None,
transfer_encoding=E_QUOPRI):
"""Add a file to the given MHTML collection.
Args:
location: The original location (URL) of the file.
content: The binary content of the file.
content_type: The MIME-type of the content (if available)
transfer_encoding: The transfer encoding to use for this file.
"""
self._files[location] = _File(
content=content, content_type=content_type,
content_location=location, transfer_encoding=transfer_encoding,
)
def write_to(self, fp):
"""Output the MHTML file to the given file-like object.
Args:
fp: The file-object, opened in "wb" mode.
"""
msg = email.mime.multipart.MIMEMultipart(
'related', '---=_qute-{}'.format(uuid.uuid4()))
root = self._create_root_file()
msg.attach(root)
for _, file_data in sorted(self._files.items()):
msg.attach(self._create_file(file_data))
gen = email.generator.BytesGenerator(fp, policy=MHTMLPolicy)
gen.flatten(msg)
def _create_root_file(self):
"""Return the root document as MIMEMultipart."""
root_file = _File(
content=self.root_content, content_type=self.content_type,
content_location=self.content_location, transfer_encoding=E_QUOPRI,
)
return self._create_file(root_file)
def _create_file(self, f):
"""Return the single given file as MIMEMultipart."""
msg = email.mime.multipart.MIMEMultipart()
msg['Content-Location'] = f.content_location
# Get rid of the default type multipart/mixed
del msg['Content-Type']
if f.content_type:
msg.set_type(f.content_type)
msg.set_payload(f.content)
f.transfer_encoding(msg)
return msg
class _Downloader:
"""A class to download whole websites.
Attributes:
web_view: The QWebView which contains the website that will be saved.
dest: Destination filename.
writer: The MHTMLWriter object which is used to save the page.
loaded_urls: A set of QUrls of finished asset downloads.
pending_downloads: A set of unfinished (url, DownloadItem) tuples.
_finished_file: A flag indicating if the file has already been
written.
_used: A flag indicating if the downloader has already been used.
_win_id: The window this downloader belongs to.
"""
def __init__(self, web_view, dest):
self.web_view = web_view
self.dest = dest
self.writer = None
self.loaded_urls = {web_view.url()}
self.pending_downloads = set()
self._finished_file = False
self._used = False
self._win_id = web_view.win_id
def run(self):
"""Download and save the page.
The object must not be reused, you should create a new one if
you want to download another page.
"""
if self._used:
raise ValueError("Downloader already used")
self._used = True
web_url = self.web_view.url()
web_frame = self.web_view.page().mainFrame()
self.writer = MHTMLWriter(
web_frame.toHtml().encode('utf-8'),
content_location=urlutils.encoded_url(web_url),
# I've found no way of getting the content type of a QWebView, but
# since we're using .toHtml, it's probably safe to say that the
# content-type is HTML
content_type='text/html; charset="UTF-8"',
)
# Currently only downloading <link> (stylesheets), <script>
# (javascript) and <img> (image) elements.
elements = web_frame.findAllElements('link, script, img')
for element in elements:
element = webelem.WebElementWrapper(element)
# Websites are free to set whatever rel=... attribute they want.
# We just care about stylesheets and icons.
if not _check_rel(element):
continue
if 'src' in element:
element_url = element['src']
elif 'href' in element:
element_url = element['href']
else:
# Might be a local <script> tag or something else
continue
absolute_url = web_url.resolved(QUrl(element_url))
self._fetch_url(absolute_url)
styles = web_frame.findAllElements('style')
for style in styles:
style = webelem.WebElementWrapper(style)
# The Mozilla Developer Network says:
# type: This attribute defines the styling language as a MIME type
# (charset should not be specified). This attribute is optional and
# default to text/css if it's missing.
# https://developer.mozilla.org/en/docs/Web/HTML/Element/style
if 'type' in style and style['type'] != 'text/css':
continue
for element_url in _get_css_imports(str(style)):
self._fetch_url(web_url.resolved(QUrl(element_url)))
# Search for references in inline styles
for element in web_frame.findAllElements('[style]'):
element = webelem.WebElementWrapper(element)
style = element['style']
for element_url in _get_css_imports(style, inline=True):
self._fetch_url(web_url.resolved(QUrl(element_url)))
# Shortcut if no assets need to be downloaded, otherwise the file would
# never be saved. Also might happen if the downloads are fast enough to
# complete before connecting their finished signal.
self._collect_zombies()
if not self.pending_downloads and not self._finished_file:
self._finish_file()
def _fetch_url(self, url):
"""Download the given url and add the file to the collection.
Args:
url: The file to download as QUrl.
"""
if url.scheme() not in {'http', 'https'}:
return
# Prevent loading an asset twice
if url in self.loaded_urls:
return
self.loaded_urls.add(url)
log.downloads.debug("loading asset at {}".format(url))
# Using the download manager to download host-blocked urls might crash
# qute, see the comments/discussion on
# https://github.com/The-Compiler/qutebrowser/pull/962#discussion_r40256987
# and https://github.com/The-Compiler/qutebrowser/issues/1053
host_blocker = objreg.get('host-blocker')
if host_blocker.is_blocked(url):
log.downloads.debug("Skipping {}, host-blocked".format(url))
# We still need an empty file in the output, QWebView can be pretty
# picky about displaying a file correctly when not all assets are
# at least referenced in the mhtml file.
self.writer.add_file(urlutils.encoded_url(url), b'')
return
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
item = download_manager.get(url, fileobj=_NoCloseBytesIO(),
auto_remove=True)
self.pending_downloads.add((url, item))
item.finished.connect(
functools.partial(self._finished, url, item))
item.error.connect(
functools.partial(self._error, url, item))
item.cancelled.connect(
functools.partial(self._error, url, item))
def _finished(self, url, item):
"""Callback when a single asset is downloaded.
Args:
url: The original url of the asset as QUrl.
item: The DownloadItem given by the DownloadManager
"""
self.pending_downloads.remove((url, item))
mime = item.raw_headers.get(b'Content-Type', b'')
# Note that this decoding always works and doesn't produce errors
# RFC 7230 (https://tools.ietf.org/html/rfc7230) states:
# Historically, HTTP has allowed field content with text in the
# ISO-8859-1 charset [ISO-8859-1], supporting other charsets only
# through use of [RFC2047] encoding. In practice, most HTTP header
# field values use only a subset of the US-ASCII charset [USASCII].
# Newly defined header fields SHOULD limit their field values to
# US-ASCII octets. A recipient SHOULD treat other octets in field
# content (obs-text) as opaque data.
mime = mime.decode('iso-8859-1')
if mime.lower() == 'text/css' or url.fileName().endswith('.css'):
# We can't always assume that CSS files are UTF-8, but CSS files
# shouldn't contain many non-ASCII characters anyway (in most
# cases). Using "ignore" lets us decode the file even if it's
# invalid UTF-8 data.
# The file written to the MHTML file won't be modified by this
# decoding, since there we're taking the original bytestream.
try:
css_string = item.fileobj.getvalue().decode('utf-8')
except UnicodeDecodeError:
log.downloads.warning("Invalid UTF-8 data in {}".format(url))
css_string = item.fileobj.getvalue().decode('utf-8', 'ignore')
import_urls = _get_css_imports(css_string)
for import_url in import_urls:
absolute_url = url.resolved(QUrl(import_url))
self._fetch_url(absolute_url)
encode = E_QUOPRI if mime.startswith('text/') else E_BASE64
# Our MHTML handler refuses non-ASCII headers. This will replace every
# non-ASCII char with '?'. This is probably okay, as official Content-
# Type headers contain ASCII only anyway. Anything else is madness.
mime = utils.force_encoding(mime, 'ascii')
self.writer.add_file(urlutils.encoded_url(url),
item.fileobj.getvalue(), mime, encode)
item.fileobj.actual_close()
if self.pending_downloads:
return
self._finish_file()
def _error(self, url, item, *_args):
"""Callback when a download error occurred.
Args:
url: The orignal url of the asset as QUrl.
item: The DownloadItem given by the DownloadManager.
"""
try:
self.pending_downloads.remove((url, item))
except KeyError:
# This might happen if .collect_zombies() calls .finished() and the
# error handler will be called after .collect_zombies
log.downloads.debug("Oops! Download already gone: {}".format(item))
return
item.fileobj.actual_close()
# Add a stub file, see comment in .fetch_url() for more information
self.writer.add_file(urlutils.encoded_url(url), b'')
if self.pending_downloads:
return
self._finish_file()
def _finish_file(self):
"""Save the file to the filename given in __init__."""
if self._finished_file:
log.downloads.debug("finish_file called twice, ignored!")
return
self._finished_file = True
log.downloads.debug("All assets downloaded, ready to finish off!")
try:
with open(self.dest, 'wb') as file_output:
self.writer.write_to(file_output)
except OSError as error:
message.error(self._win_id,
"Could not save file: {}".format(error))
return
log.downloads.debug("File successfully written.")
message.info(self._win_id, "Page saved as {}".format(self.dest))
def _collect_zombies(self):
"""Collect done downloads and add their data to the MHTML file.
This is needed if a download finishes before attaching its
finished signal.
"""
items = set((url, item) for url, item in self.pending_downloads
if item.done)
log.downloads.debug("Zombie downloads: {}".format(items))
for url, item in items:
self._finished(url, item)
class _NoCloseBytesIO(io.BytesIO): # pylint: disable=no-init
"""BytesIO that can't be .closed().
This is needed to prevent the DownloadManager from closing the stream, thus
discarding the data.
"""
def close(self):
"""Do nothing."""
pass
def actual_close(self):
"""Close the stream."""
super().close()
def _start_download(dest, web_view):
"""Start downloading the current page and all assets to a MHTML file.
This will overwrite dest if it already exists.
Args:
dest: The filename where the resulting file should be saved.
win_id, tab_id: Specify the tab whose page should be loaded.
"""
loader = _Downloader(web_view, dest)
loader.run()
def start_download_checked(dest, web_view):
"""First check if dest is already a file, then start the download.
Args:
dest: The filename where the resulting file should be saved.
web_view: Specify the webview whose page should be loaded.
"""
# The default name is 'page title.mht'
title = web_view.title()
default_name = utils.sanitize_filename(title + '.mht')
# Remove characters which cannot be expressed in the file system encoding
encoding = sys.getfilesystemencoding()
default_name = utils.force_encoding(default_name, encoding)
dest = utils.force_encoding(dest, encoding)
dest = os.path.expanduser(dest)
# See if we already have an absolute path
path = downloads.create_full_filename(default_name, dest)
if path is None:
# We still only have a relative path, prepend download_dir and
# try again.
path = downloads.create_full_filename(
default_name, os.path.join(downloads.download_dir(), dest))
downloads.last_used_directory = os.path.dirname(path)
# Avoid downloading files if we can't save the output anyway...
# Yes, this is prone to race conditions, but we're checking again before
# saving the file anyway.
if not os.path.isdir(os.path.dirname(path)):
folder = os.path.dirname(path)
message.error(web_view.win_id,
"Directory {} does not exist.".format(folder))
return
if not os.path.isfile(path):
_start_download(path, web_view=web_view)
return
q = usertypes.Question()
q.mode = usertypes.PromptMode.yesno
q.text = "{} exists. Overwrite?".format(path)
q.completed.connect(q.deleteLater)
q.answered_yes.connect(functools.partial(
_start_download, path, web_view=web_view))
message_bridge = objreg.get('message-bridge', scope='window',
window=web_view.win_id)
message_bridge.ask(q, blocking=False)

View File

@ -159,7 +159,7 @@ class WebView(QWebView):
return page
def __repr__(self):
url = utils.elide(self.url().toDisplayString(), 100)
url = utils.elide(self.url().toDisplayString(QUrl.EncodeUnicode), 100)
return utils.get_repr(self, tab_id=self.tab_id, url=url)
def __del__(self):

View File

@ -432,6 +432,15 @@ def same_domain(url1, url2):
return domain1 == domain2
def encoded_url(url):
"""Return the fully encoded url as string.
Args:
url: The url to encode as QUrl.
"""
return bytes(url.toEncoded()).decode('ascii')
class IncDecError(Exception):
"""Exception raised by incdec_number on problems.

View File

@ -705,6 +705,27 @@ def force_encoding(text, encoding):
return text.encode(encoding, errors='replace').decode(encoding)
def sanitize_filename(name, replacement='_'):
"""Replace invalid filename characters.
Note: This should be used for the basename, as it also removes the path
separator.
Args:
name: The filename.
replacement: The replacement character (or None).
"""
if replacement is None:
replacement = ''
# Bad characters taken from Windows, there are even fewer on Linux
# See also
# https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
bad_chars = '\\/:*?"<>|'
for bad_char in bad_chars:
name = name.replace(bad_char, replacement)
return name
def newest_slice(iterable, count):
"""Get an iterable for the n newest items of the given iterable.

View File

@ -133,6 +133,7 @@ def _module_versions():
('jinja2', ['__version__']),
('pygments', ['__version__']),
('yaml', ['__version__']),
('cssutils', ['__version__']),
])
for name, attributes in modules.items():
try:

View File

@ -5,3 +5,4 @@ pyPEG2==2.15.2
PyYAML==3.11
colorama==0.3.3
colorlog==2.6.0
cssutils==1.0.1

View File

@ -55,7 +55,9 @@ def get_build_exe_options():
opts = freeze.get_build_exe_options(skip_html=True)
opts['includes'] += pytest.freeze_includes() # pylint: disable=no-member
opts['includes'] += ['unittest.mock', 'PyQt5.QtTest', 'hypothesis', 'bs4',
'httpbin', 'jinja2.ext', 'xvfbwrapper']
'httpbin', 'jinja2.ext', 'xvfbwrapper',
'cherrypy.wsgiserver',
'cherrypy.wsgiserver.wsgiserver3']
httpbin_dir = os.path.dirname(httpbin.__file__)
opts['include_files'] += [

View File

@ -80,6 +80,7 @@ def whitelist_generator():
# https://bitbucket.org/jendrikseipp/vulture/issues/10/
yield 'qutebrowser.misc.utilcmds.pyeval_output'
yield 'utils.use_color'
yield 'qutebrowser.browser.mhtml.last_used_directory'
# Other false-positives
yield ('qutebrowser.completion.models.sortfilter.CompletionFilterModel().'

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,3 @@
div.fancy {
background-color: url("div-image.png");
}

View File

@ -0,0 +1,38 @@
<html>
<head>
<!-- make sure external css is included -->
<link rel="stylesheet" href="base.css">
<!-- make sure <style> is parsed -->
<style>
@import "extern-css.css";
body {
background-image: url("background.png");
font-weight: bold;
color: white;
text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black;
}
</style>
<!-- don't parse non-CSS styles -->
<style rel="stylesheet" type="text/qss">
@import "actually-it's-css";
</style>
<!-- make sure icons are included -->
<link rel="icon" href="favicon.png">
<!-- make sure authors are NOT included -->
<link rel="author" href="author.html">
<!-- make sure scripts are included -->
<script type="text/javascript" src="script.js"></script>
<!-- ...but don't crash on scripts without src -->
<script>
var l = 1+1;
</script>
</head>
<body>
<!-- include a normal image -->
<img src="image.gif">
<!-- make sure inline styles are parsed -->
<p style="background-image: url('inline.png')">foobar</p>
<!-- make sure inline.png is only be requested once -->
<img src="inline.png">
</body>
</html>

View File

@ -0,0 +1,569 @@
Content-Type: multipart/related; boundary="---=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384"
MIME-Version: 1.0
-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384
Content-Location: http://localhost:1234/data/downloads/mhtml/complex/complex.html
MIME-Version: 1.0
Content-Type: text/html; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
<html><head>
=20=20=20=20=20=20=20=20<!--=20make=20sure=20external=20css=20is=20included=
=20-->
=20=20=20=20=20=20=20=20<link=20rel=3D"stylesheet"=20href=3D"base.css">
=20=20=20=20=20=20=20=20<!--=20make=20sure=20<style>=20is=20parsed=20-->
=20=20=20=20=20=20=20=20<style>
@import=20"extern-css.css";
body=20{
=20=20=20=20background-image:=20url("background.png");
=20=20=20=20font-weight:=20bold;
=20=20=20=20color:=20white;
=20=20=20=20text-shadow:=20-1px=200=20black,=200=201px=20black,=201px=200=
=20black,=200=20-1px=20black;
}
=20=20=20=20=20=20=20=20</style>
=20=20=20=20=20=20=20=20<!--=20don't=20parse=20non-CSS=20styles=20-->
=20=20=20=20=20=20=20=20<style=20rel=3D"stylesheet"=20type=3D"text/qss">
@import=20"actually-it's-css";
=20=20=20=20=20=20=20=20</style>
=20=20=20=20=20=20=20=20<!--=20make=20sure=20icons=20are=20included=20-->
=20=20=20=20=20=20=20=20<link=20rel=3D"icon"=20href=3D"favicon.png">
=20=20=20=20=20=20=20=20<!--=20make=20sure=20authors=20are=20NOT=20included=
=20-->
=20=20=20=20=20=20=20=20<link=20rel=3D"author"=20href=3D"author.html">
=20=20=20=20=20=20=20=20<!--=20make=20sure=20scripts=20are=20included=20-->
=20=20=20=20=20=20=20=20<script=20type=3D"text/javascript"=20src=3D"script.=
js"></script>
=20=20=20=20=20=20=20=20<!--=20...but=20don't=20crash=20on=20scripts=20with=
out=20src=20-->
=20=20=20=20=20=20=20=20<script>
var=20l=20=3D=201+1;
=20=20=20=20=20=20=20=20</script>
=20=20=20=20</head>
=20=20=20=20<body>
=20=20=20=20=20=20=20=20<!--=20include=20a=20normal=20image=20-->
=20=20=20=20=20=20=20=20<img=20src=3D"image.gif">
=20=20=20=20=20=20=20=20<!--=20make=20sure=20inline=20styles=20are=20parsed=
=20-->
=20=20=20=20=20=20=20=20<p=20style=3D"background-image:=20url('inline.png')=
">foobar</p>
=20=20=20=20=20=20=20=20<!--=20make=20sure=20inline.png=20is=20only=20be=20=
requested=20once=20-->
=20=20=20=20=20=20=20=20<img=20src=3D"inline.png">
=20=20=20=20
</body></html>
-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384
Content-Location: http://localhost:1234/data/downloads/mhtml/complex/background.png
MIME-Version: 1.0
Content-Type: image/png
Content-Transfer-Encoding: base64
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
4n8BgjXlKCMEM1EAAAAASUVORK5CYII=
-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384
Content-Location: http://localhost:1234/data/downloads/mhtml/complex/base.css
MIME-Version: 1.0
Content-Type: text/css; charset=utf-8
Content-Transfer-Encoding: quoted-printable
div.fancy=20{
=20=20=20=20background-color:=20url("div-image.png");
}
-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384
Content-Location: http://localhost:1234/data/downloads/mhtml/complex/div-image.png
MIME-Version: 1.0
Content-Type: image/png
Content-Transfer-Encoding: base64
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
m+48GgAAAABJRU5ErkJggg==
-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384
Content-Location: http://localhost:1234/data/downloads/mhtml/complex/extern-css.css
MIME-Version: 1.0
Content-Type: text/css; charset=utf-8
Content-Transfer-Encoding: quoted-printable
@import=20"external-in-external.css";
p=20{
=20=20=20=20font-family:=20"Monospace";
}
-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384
Content-Location: http://localhost:1234/data/downloads/mhtml/complex/external-in-external.css
MIME-Version: 1.0
Content-Type: text/css; charset=utf-8
Content-Transfer-Encoding: quoted-printable
img=20{
=20=20=20=20width:=20100%;
}
-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384
Content-Location: http://localhost:1234/data/downloads/mhtml/complex/favicon.png
MIME-Version: 1.0
Content-Type: image/png
Content-Transfer-Encoding: base64
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
rkJggg==
-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384
Content-Location: http://localhost:1234/data/downloads/mhtml/complex/image.gif
MIME-Version: 1.0
Content-Type: image/gif
Content-Transfer-Encoding: base64
R0lGODlhhQBkAPcAAAAAAAMADAoBDwIBFAsCEwQBGwsEHAwIGhIFHRUGGgYBIgsEJAwEKw4JKBMF
IxMKJRIELBoGLBQLKxwKLBoIJRsRLRcQJw0IMxQGMxULMxwMNBQLOxwNOxgGNR0SNB0SOxsVPCML
LSIZLCQLMywMMyYOOSMUPCsTPCQZPSobPCMXNTcUOzEMMCshOzEmPEUUOxoOQhQMQx0SRB0USxcT
RhwVUiMVRCkWQiUaRCsbRCIWSiUaTCsdSzIaRDUcSCUbUyseUiQbWSwhQy4hTDMkTTcnSi0hUywi
WyYhXDMkUzUpUzoqVTMmWzUqXDsrWzkmVT4yXT0yVD0yTy0lYTQrYTstYjEnZT0zZDszazw1dEcc
REknSVIsTUIuXEUrVVQqVUIzXEc3Wlg3WU8zTUM0Y0Q6ZUs7ZEQ2a0U6a0s8a0w3Z1k5ZUY7c0k1
ZmI9ZGA7X1ZHXE5CZU1CbEdAZ1JDbFhIaUxCc0xEe1JFc1ZKdFpMdFRKeVtMe1dFdV1SfF1SdGRH
aWNMdWdKdWJTfWdXeXJYe2VWbmxkenxqf0U+gl1Tg1xTiFNKhGNUgmVZg2pdhGRai2tdi2xUgXNc
h2pekmtihW1ijHJkjHRqjHlniGxjk3JlknRqk3lslXVrm3ltmnxylXxzm396m3xzinx1o3x2pnRs
oYN1m4R7m4Z5l4Jxj4R8pIl9pYR7qIyBnYyKmo2Hk4yDpIaCrIyErIyHqJOLrJSJqJmTqo2LtIqF
sZONtJeMs5WRtJyUtJWTuZyZvJyXt6Kas6Kcu6SataukvKilubSxvJ+cwKOewqaiw6qlwqypxa2p
ybKsw7Ouy7CnwbWyy7u6zLe1yL270rq10sG9y8G91L/Ex8XCzcTC1cbD2snG3MzL3cjL09LT3dXb
3c7P2tHO4dPU49zd5dvc6tfY5c3N4N7h69zj5d/i8OHl7OTq7uXr6u797+n07eXr8eru8+vz9O/9
8O72+fD+8fX6+vD/7wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAAAAAAALAAAAACFAGQA
AAj+AAEIHEiwoMGDCBMqXMiwocOHECNKnEixosWLGDNq3Mixo8ePIEOKHEmypMmTKFOqXMmypcuX
MGN+FCAggE2ZOC0GEEDgQQgVN3LwGIrCw4QDAXIqTRiAAIIQJXLkGLLEyRMfQJ4occK1SAoNBJaK
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
-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384
Content-Location: http://localhost:1234/data/downloads/mhtml/complex/inline.png
MIME-Version: 1.0
Content-Type: image/png
Content-Transfer-Encoding: base64
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
cmeb7jwaAAAAAElFTkSuQmCC
-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384
Content-Location: http://localhost:1234/data/downloads/mhtml/complex/script.js
MIME-Version: 1.0
Content-Type: application/javascript
Content-Transfer-Encoding: base64
ZnVuY3Rpb24gbm9vcCgpIHt9Cm5vb3AoKTsK
-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384--

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,4 @@
@import "external-in-external.css";
p {
font-family: "Monospace";
}

View File

@ -0,0 +1,3 @@
img {
width: 100%;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,9 @@
background.png
base.css
div-image.png
extern-css.css
external-in-external.css
favicon.png
image.gif
inline.png
script.js

View File

@ -0,0 +1,2 @@
function noop() {}
noop();

View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Simple MHTML test</title>
</head>
<body>
<a href="/">normal link to another page</a>
</body>
</html>

View File

@ -0,0 +1,20 @@
Content-Type: multipart/related; boundary="---=_qute-6d584056-b1e4-4882-91e6-d4a6d23adb67"
MIME-Version: 1.0
-----=_qute-6d584056-b1e4-4882-91e6-d4a6d23adb67
Content-Location: http://localhost:1234/data/downloads/mhtml/simple/simple.html
MIME-Version: 1.0
Content-Type: text/html; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
<!DOCTYPE=20html><html><head>
=20=20=20=20=20=20=20=20<meta=20charset=3D"utf-8">
=20=20=20=20=20=20=20=20<title>Simple=20MHTML=20test</title>
=20=20=20=20</head>
=20=20=20=20<body>
=20=20=20=20=20=20=20=20<a=20href=3D"/">normal=20link=20to=20another=20page=
</a>
=20=20=20=20
</body></html>
-----=_qute-6d584056-b1e4-4882-91e6-d4a6d23adb67--

View File

@ -42,3 +42,28 @@ Feature: Downloading things from a website.
Scenario: Retrying with no downloads
When I run :download-retry
Then the error "No failed downloads!" should be shown.
Scenario: :download with deprecated dest-old argument
When I run :download http://localhost:(port)/ deprecated-argument
Then the warning ":download [url] [dest] is deprecated - use download --dest [dest] [url]" should be shown.
Scenario: Two destinations given
When I run :download --dest destination2 http://localhost:(port)/ destination1
Then the warning ":download [url] [dest] is deprecated - use download --dest [dest] [url]" should be shown.
And the error "Can't give two destinations for the download." should be shown.
Scenario: :download --mhtml with an URL given
When I run :download --mhtml http://foobar/
Then the error "Can only download the current page as mhtml." should be shown.
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
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

View File

@ -32,11 +32,11 @@ import tempfile
import yaml
import pytest
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtCore import pyqtSignal, QUrl
import testprocess # pylint: disable=import-error
from qutebrowser.misc import ipc
from qutebrowser.utils import log
from qutebrowser.utils import log, utils
def is_ignored_qt_message(message):
@ -225,6 +225,9 @@ class QuteProc(testprocess.Process):
def wait_for_load_finished(self, path, timeout=15000):
"""Wait until any tab has finished loading."""
url = self._path_to_url(path)
# We really need the same representation that the webview uses in its
# __repr__
url = utils.elide(QUrl(url).toDisplayString(QUrl.EncodeUnicode), 100)
pattern = re.compile(
r"(load status for <qutebrowser.browser.webview.WebView "
r"tab_id=\d+ url='{url}'>: LoadStatus.success|fetch: "

View File

@ -0,0 +1,116 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2015 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Test mhtml downloads based on sample files."""
import os
import re
import os.path
import collections
import pytest
def collect_tests():
basedir = os.path.dirname(__file__)
datadir = os.path.join(basedir, 'data', 'downloads', 'mhtml')
files = os.listdir(datadir)
return files
def normalize_line(line):
line = line.rstrip('\n')
line = re.sub('boundary="---=_qute-[0-9a-f-]+"',
'boundary="---=_qute-UUID"', line)
line = re.sub('^-----=_qute-[0-9a-f-]+$', '-----=_qute-UUID', line)
line = re.sub(r'localhost:\d{1,5}', 'localhost:(port)', line)
# Depending on Python's mimetypes module/the system's mime files, .js
# files could be either identified as x-javascript or just javascript
line = line.replace('Content-Type: application/x-javascript',
'Content-Type: application/javascript')
return line
class DownloadDir:
"""Abstraction over a download directory."""
def __init__(self, tmpdir):
self._tmpdir = tmpdir
self.location = str(tmpdir)
def read_file(self):
files = self._tmpdir.listdir()
assert len(files) == 1
with open(str(files[0]), 'r', encoding='utf-8') as f:
return f.readlines()
def compare_mhtml(self, filename):
with open(filename, 'r', encoding='utf-8') as f:
expected_data = [normalize_line(line) for line in f]
actual_data = self.read_file()
actual_data = [normalize_line(line) for line in actual_data]
assert actual_data == expected_data
@pytest.fixture
def download_dir(tmpdir):
return DownloadDir(tmpdir)
@pytest.mark.parametrize('test_name', collect_tests())
def test_mhtml(test_name, download_dir, quteproc, httpbin):
quteproc.set_setting('storage', 'download-directory',
download_dir.location)
quteproc.set_setting('storage', 'prompt-download-directory', 'false')
test_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)),
'data', 'downloads', 'mhtml', test_name)
test_path = 'data/downloads/mhtml/{}'.format(test_name)
quteproc.open_path('{}/{}.html'.format(test_path, test_name))
download_dest = os.path.join(download_dir.location,
'{}-downloaded.mht'.format(test_name))
# Discard all requests that were necessary to display the page
httpbin.clear_data()
quteproc.send_cmd(':download --mhtml --dest "{}"'.format(download_dest))
quteproc.wait_for(category='downloads', module='mhtml',
function='_finish_file',
message='File successfully written.')
expected_file = os.path.join(test_dir, '{}.mht'.format(test_name))
download_dir.compare_mhtml(expected_file)
with open(os.path.join(test_dir, 'requests'), encoding='utf-8') as f:
expected_requests = []
for line in f:
if line.startswith('#'):
continue
path = '/{}/{}'.format(test_path, line.strip())
expected_requests.append(httpbin.ExpectedRequest('GET', path))
actual_requests = httpbin.get_requests()
# Requests are not hashable, we need to convert to ExpectedRequests
actual_requests = map(httpbin.ExpectedRequest.from_request,
actual_requests)
assert (collections.Counter(actual_requests) ==
collections.Counter(expected_requests))

View File

@ -182,13 +182,17 @@ class Process(QObject):
time.sleep(1)
# Exit the process to make sure we're in a defined state again
self.terminate()
self._data.clear()
self.clear_data()
raise InvalidLine(self._invalid)
self._data.clear()
self.clear_data()
if not self.is_running():
raise ProcessExited
def clear_data(self):
"""Clear the collected data."""
self._data.clear()
def terminate(self):
"""Clean up and shut down the process."""
self.proc.terminate()

View File

@ -97,6 +97,11 @@ class ExpectedRequest:
self.verb = verb
self.path = path
@classmethod
def from_request(cls, request):
"""Create an ExpectedRequest from a Request."""
return cls(request.verb, request.path)
def __eq__(self, other):
if isinstance(other, (Request, ExpectedRequest)):
return (self.verb == other.verb and
@ -104,6 +109,13 @@ class ExpectedRequest:
else:
return NotImplemented
def __hash__(self):
return hash(('ExpectedRequest', self.verb, self.path))
def __repr__(self):
return ('ExpectedRequest(verb={!r}, path={!r})'
.format(self.verb, self.path))
class HTTPBin(testprocess.Process):

View File

@ -24,10 +24,13 @@ This script gets called as a QProcess from integration/conftest.py.
import sys
import time
import os.path
import signal
import os
from datetime import datetime
from httpbin.core import app
from httpbin.structures import CaseInsensitiveDict
import cherrypy.wsgiserver
import flask
@ -51,11 +54,63 @@ def redirect_later():
return flask.redirect('/')
@app.after_request
def log_request(response):
request = flask.request
template = '127.0.0.1 - - [{date}] "{verb} {path} {http}" {status} -'
print(template.format(
date=datetime.now().strftime('%d/%b/%Y %H:%M:%S'),
verb=request.method,
path=request.full_path if request.query_string else request.path,
http=request.environ['SERVER_PROTOCOL'],
status=response.status_code,
), file=sys.stderr, flush=True)
return response
class WSGIServer(cherrypy.wsgiserver.CherryPyWSGIServer):
"""A custom WSGIServer that prints a line on stderr when it's ready."""
# pylint: disable=no-member
# WORKAROUND for https://bitbucket.org/logilab/pylint/issues/702
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._ready = False
self._printed_ready = False
@property
def ready(self):
return self._ready
@ready.setter
def ready(self, value):
if value and not self._printed_ready:
print(' * Running on http://127.0.0.1:{}/ (Press CTRL+C to quit)'
.format(self.bind_addr[1]), file=sys.stderr, flush=True)
self._printed_ready = True
self._ready = value
def main():
# pylint: disable=no-member
# WORKAROUND for https://bitbucket.org/logilab/pylint/issues/702
# "Instance of 'WSGIServer' has no 'start' member (no-member)"
# "Instance of 'WSGIServer' has no 'stop' member (no-member)"
if hasattr(sys, 'frozen'):
basedir = os.path.realpath(os.path.dirname(sys.executable))
app.template_folder = os.path.join(basedir, 'integration', 'templates')
app.run(port=int(sys.argv[1]), debug=True, use_reloader=False)
port = int(sys.argv[1])
server = WSGIServer(('127.0.0.1', port), app)
signal.signal(signal.SIGTERM, lambda *args: server.stop())
try:
server.start()
except KeyboardInterrupt:
server.stop()
if __name__ == '__main__':

View File

@ -0,0 +1,298 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2015 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 qutebrowser.browser.mhtml."""
import io
import textwrap
import re
import pytest
from qutebrowser.browser import mhtml
@pytest.fixture(autouse=True)
def patch_uuid(monkeypatch):
monkeypatch.setattr("uuid.uuid4", lambda: "UUID")
class Checker:
"""A helper to check mhtml output.
Attrs:
fp: A BytesIO object for passing to MHTMLWriter.write_to.
"""
def __init__(self):
self.fp = io.BytesIO()
@property
def value(self):
return self.fp.getvalue()
def expect(self, expected):
actual = self.value.decode('ascii')
# Make sure there are no stray \r or \n
assert re.search(r'\r[^\n]', actual) is None
assert re.search(r'[^\r]\n', actual) is None
actual = actual.replace('\r\n', '\n')
expected = textwrap.dedent(expected).lstrip('\n')
assert expected == actual
@pytest.fixture
def checker():
return Checker()
def test_quoted_printable_umlauts(checker):
content = 'Die süße Hündin läuft in die Höhle des Bären'
content = content.encode('iso-8859-1')
writer = mhtml.MHTMLWriter(root_content=content,
content_location='localhost',
content_type='text/plain')
writer.write_to(checker.fp)
checker.expect("""
Content-Type: multipart/related; boundary="---=_qute-UUID"
MIME-Version: 1.0
-----=_qute-UUID
Content-Location: localhost
MIME-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
Die=20s=FC=DFe=20H=FCndin=20l=E4uft=20in=20die=20H=F6hle=20des=20B=E4ren
-----=_qute-UUID--
""")
@pytest.mark.parametrize('header, value', [
('content_location', 'http://brötli.com'),
('content_type', 'text/pläin'),
])
def test_refuses_non_ascii_header_value(checker, header, value):
defaults = {
'root_content': b'',
'content_location': 'http://example.com',
'content_type': 'text/plain',
}
defaults[header] = value
writer = mhtml.MHTMLWriter(**defaults)
with pytest.raises(UnicodeEncodeError) as excinfo:
writer.write_to(checker.fp)
assert "'ascii' codec can't encode" in str(excinfo.value)
def test_file_encoded_as_base64(checker):
content = b'Image file attached'
writer = mhtml.MHTMLWriter(root_content=content, content_type='text/plain',
content_location='http://example.com')
writer.add_file(location='http://a.example.com/image.png',
content='\U0001F601 image data'.encode('utf-8'),
content_type='image/png',
transfer_encoding=mhtml.E_BASE64)
writer.write_to(checker.fp)
checker.expect("""
Content-Type: multipart/related; boundary="---=_qute-UUID"
MIME-Version: 1.0
-----=_qute-UUID
Content-Location: http://example.com
MIME-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
Image=20file=20attached
-----=_qute-UUID
Content-Location: http://a.example.com/image.png
MIME-Version: 1.0
Content-Type: image/png
Content-Transfer-Encoding: base64
8J+YgSBpbWFnZSBkYXRh
-----=_qute-UUID--
""")
@pytest.mark.parametrize('transfer_encoding', [mhtml.E_BASE64, mhtml.E_QUOPRI],
ids=['base64', 'quoted-printable'])
def test_payload_lines_wrap(checker, transfer_encoding):
payload = b'1234567890' * 10
writer = mhtml.MHTMLWriter(root_content=b'', content_type='text/plain',
content_location='http://example.com')
writer.add_file(location='http://example.com/payload', content=payload,
content_type='text/plain',
transfer_encoding=transfer_encoding)
writer.write_to(checker.fp)
for line in checker.value.split(b'\r\n'):
assert len(line) < 77
def test_files_appear_sorted(checker):
writer = mhtml.MHTMLWriter(root_content=b'root file',
content_type='text/plain',
content_location='http://www.example.com/')
for subdomain in 'ahgbizt':
writer.add_file(location='http://{}.example.com/'.format(subdomain),
content='file {}'.format(subdomain).encode('utf-8'),
content_type='text/plain',
transfer_encoding=mhtml.E_QUOPRI)
writer.write_to(checker.fp)
checker.expect("""
Content-Type: multipart/related; boundary="---=_qute-UUID"
MIME-Version: 1.0
-----=_qute-UUID
Content-Location: http://www.example.com/
MIME-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
root=20file
-----=_qute-UUID
Content-Location: http://a.example.com/
MIME-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
file=20a
-----=_qute-UUID
Content-Location: http://b.example.com/
MIME-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
file=20b
-----=_qute-UUID
Content-Location: http://g.example.com/
MIME-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
file=20g
-----=_qute-UUID
Content-Location: http://h.example.com/
MIME-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
file=20h
-----=_qute-UUID
Content-Location: http://i.example.com/
MIME-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
file=20i
-----=_qute-UUID
Content-Location: http://t.example.com/
MIME-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
file=20t
-----=_qute-UUID
Content-Location: http://z.example.com/
MIME-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
file=20z
-----=_qute-UUID--
""")
def test_empty_content_type(checker):
writer = mhtml.MHTMLWriter(root_content=b'',
content_location='http://example.com/',
content_type='text/plain')
writer.add_file('http://example.com/file', b'file content')
writer.write_to(checker.fp)
checker.expect("""
Content-Type: multipart/related; boundary="---=_qute-UUID"
MIME-Version: 1.0
-----=_qute-UUID
Content-Location: http://example.com/
MIME-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
-----=_qute-UUID
MIME-Version: 1.0
Content-Location: http://example.com/file
Content-Transfer-Encoding: quoted-printable
file=20content
-----=_qute-UUID--
""")
@pytest.mark.parametrize('has_cssutils', [
pytest.mark.skipif(mhtml.cssutils is None,
reason="requires cssutils")(True),
False,
], ids=['with_cssutils', 'no_cssutils'])
@pytest.mark.parametrize('inline, style, expected_urls', [
(False, "@import 'default.css'", ['default.css']),
(False, '@import "default.css"', ['default.css']),
(False, "@import \t 'tabbed.css'", ['tabbed.css']),
(False, "@import url('default.css')", ['default.css']),
(False, """body {
background: url("/bg-img.png")
}""", ['/bg-img.png']),
(True, 'background: url(folder/file.png) no-repeat', ['folder/file.png']),
(True, 'content: url()', []),
])
def test_css_url_scanner(monkeypatch, has_cssutils, inline, style,
expected_urls):
if not has_cssutils:
monkeypatch.setattr('qutebrowser.browser.mhtml.cssutils', None)
expected_urls.sort()
urls = mhtml._get_css_imports(style, inline=inline)
urls.sort()
assert urls == expected_urls
class TestNoCloseBytesIO:
# WORKAROUND for https://bitbucket.org/logilab/pylint/issues/540/
# pylint: disable=no-member
def test_fake_close(self):
fp = mhtml._NoCloseBytesIO()
fp.write(b'Value')
fp.close()
assert fp.getvalue() == b'Value'
fp.write(b'Eulav')
assert fp.getvalue() == b'ValueEulav'
def test_actual_close(self):
fp = mhtml._NoCloseBytesIO()
fp.write(b'Value')
fp.actual_close()
with pytest.raises(ValueError) as excinfo:
fp.getvalue()
assert str(excinfo.value) == 'I/O operation on closed file.'
with pytest.raises(ValueError) as excinfo:
fp.write(b'Closed')
assert str(excinfo.value) == 'I/O operation on closed file.'

View File

@ -527,6 +527,19 @@ def test_same_domain_invalid_url(url1, url2):
with pytest.raises(urlutils.InvalidUrlError):
urlutils.same_domain(QUrl(url1), QUrl(url2))
@pytest.mark.parametrize('url, expected', [
('http://example.com', 'http://example.com'),
('http://ünicode.com', 'http://xn--nicode-2ya.com'),
('http://foo.bar/?header=text/pläin',
'http://foo.bar/?header=text/pl%C3%A4in'),
])
def test_encoded_url(url, expected):
"""Test encoded_url"""
url = QUrl(url)
assert urlutils.encoded_url(url) == expected
class TestIncDecNumber:
"""Tests for urlutils.incdec_number()."""

View File

@ -880,6 +880,20 @@ def test_force_encoding(inp, enc, expected):
assert utils.force_encoding(inp, enc) == expected
@pytest.mark.parametrize('inp, expected', [
('normal.txt', 'normal.txt'),
('user/repo issues.mht', 'user_repo issues.mht'),
('<Test\\File> - "*?:|', '_Test_File_ - _____'),
])
def test_sanitize_filename(inp, expected):
assert utils.sanitize_filename(inp) == expected
def test_sanitize_filename_empty_replacement():
name = '/<Bad File>/'
assert utils.sanitize_filename(name, replacement=None) == 'Bad File'
class TestNewestSlice:
"""Test newest_slice."""

View File

@ -324,6 +324,7 @@ class ImportFake:
'jinja2': True,
'pygments': True,
'yaml': True,
'cssutils': True,
}
self.version_attribute = '__version__'
self.version = '1.2.3'
@ -383,12 +384,13 @@ class TestModuleVersions:
"""Test with all modules present in version 1.2.3."""
expected = ['sip: yes', 'colorlog: yes', 'colorama: 1.2.3',
'pypeg2: 1.2.3', 'jinja2: 1.2.3', 'pygments: 1.2.3',
'yaml: 1.2.3']
'yaml: 1.2.3', 'cssutils: 1.2.3']
assert version._module_versions() == expected
@pytest.mark.parametrize('module, idx, expected', [
('colorlog', 1, 'colorlog: no'),
('colorama', 2, 'colorama: no'),
('cssutils', 7, 'cssutils: no'),
])
def test_missing_module(self, module, idx, expected, import_fake):
"""Test with a module missing.
@ -404,12 +406,13 @@ class TestModuleVersions:
@pytest.mark.parametrize('value, expected', [
('VERSION', ['sip: yes', 'colorlog: yes', 'colorama: 1.2.3',
'pypeg2: yes', 'jinja2: yes', 'pygments: yes',
'yaml: yes']),
'yaml: yes', 'cssutils: yes']),
('SIP_VERSION_STR', ['sip: 1.2.3', 'colorlog: yes', 'colorama: yes',
'pypeg2: yes', 'jinja2: yes', 'pygments: yes',
'yaml: yes']),
'yaml: yes', 'cssutils: yes']),
(None, ['sip: yes', 'colorlog: yes', 'colorama: yes', 'pypeg2: yes',
'jinja2: yes', 'pygments: yes', 'yaml: yes']),
'jinja2: yes', 'pygments: yes', 'yaml: yes',
'cssutils: yes']),
])
def test_version_attribute(self, value, expected, import_fake):
"""Test with a different version attribute.
@ -432,6 +435,7 @@ class TestModuleVersions:
('jinja2', True),
('pygments', True),
('yaml', True),
('cssutils', True),
])
def test_existing_attributes(self, name, has_version):
"""Check if all dependencies have an expected __version__ attribute.

View File

@ -42,6 +42,7 @@ deps =
Werkzeug==0.11.2
wheel==0.26.0
xvfbwrapper==0.2.5
cherrypy==3.8.0
commands =
{envpython} scripts/link_pyqt.py --tox {envdir}
{envpython} -m py.test --strict -rfEsw --faulthandler-timeout=70 --cov --cov-report xml --cov-report=html --cov-report= {posargs:tests}