From 98421e3c3519cca40704e293168289a7aa4ddade Mon Sep 17 00:00:00 2001 From: toofar Date: Tue, 9 Apr 2024 08:09:31 +1200 Subject: Move webkit.http to webkit.httpheaders flake8 got a new warning about a module name shadowing a builtin module: https://github.com/gforcada/flake8-builtins/pull/121 Probably would have been safe enough to ignore it. But I don't think moving it is that hard anyway. Hopefully I didn't miss anything! --- qutebrowser/browser/qtnetworkdownloads.py | 4 +- qutebrowser/browser/webkit/http.py | 187 --------------------- qutebrowser/browser/webkit/httpheaders.py | 187 +++++++++++++++++++++ qutebrowser/browser/webkit/webpage.py | 6 +- scripts/dev/check_coverage.py | 4 +- .../webkit/http/test_content_disposition.py | 10 +- tests/unit/browser/webkit/http/test_http.py | 94 ----------- tests/unit/browser/webkit/http/test_httpheaders.py | 94 +++++++++++ 8 files changed, 293 insertions(+), 293 deletions(-) delete mode 100644 qutebrowser/browser/webkit/http.py create mode 100644 qutebrowser/browser/webkit/httpheaders.py delete mode 100644 tests/unit/browser/webkit/http/test_http.py create mode 100644 tests/unit/browser/webkit/http/test_httpheaders.py diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py index 9dd507ab5..0360eed66 100644 --- a/qutebrowser/browser/qtnetworkdownloads.py +++ b/qutebrowser/browser/qtnetworkdownloads.py @@ -19,7 +19,7 @@ from qutebrowser.config import config, websettings from qutebrowser.utils import message, usertypes, log, urlutils, utils, debug, objreg, qtlog from qutebrowser.misc import quitter from qutebrowser.browser import downloads -from qutebrowser.browser.webkit import http +from qutebrowser.browser.webkit import httpheaders from qutebrowser.browser.webkit.network import networkmanager @@ -533,7 +533,7 @@ class DownloadManager(downloads.AbstractDownloadManager): try: suggested_filename = target.suggested_filename() except downloads.NoFilenameError: - _, suggested_filename = http.parse_content_disposition(reply) + _, suggested_filename = httpheaders.parse_content_disposition(reply) log.downloads.debug("fetch: {} -> {}".format(reply.url(), suggested_filename)) download = DownloadItem(reply, manager=self) diff --git a/qutebrowser/browser/webkit/http.py b/qutebrowser/browser/webkit/http.py deleted file mode 100644 index 95b7b7104..000000000 --- a/qutebrowser/browser/webkit/http.py +++ /dev/null @@ -1,187 +0,0 @@ -# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Parsing functions for various HTTP headers.""" - -import email.headerregistry -import email.errors -import dataclasses -import os.path -from typing import Type - -from qutebrowser.qt.network import QNetworkRequest - -from qutebrowser.utils import log, utils - - -class ContentDispositionError(Exception): - - """Base class for RFC6266 errors.""" - - -@dataclasses.dataclass -class DefectWrapper: - - """Wrapper around a email.error for comparison.""" - - error_class: Type[email.errors.MessageDefect] - line: str - - def __eq__(self, other): - return ( - isinstance(other, self.error_class) - and other.line == self.line # type: ignore[attr-defined] - ) - - -class ContentDisposition: - - """Records various indications and hints about content disposition. - - These can be used to know if a file should be downloaded or - displayed directly, and to hint what filename it should have - in the download case. - """ - - # Ignoring this defect fixes the attfnboth2 test case. It does *not* fix attfnboth - # one which has a slightly different wording ("duplicate(s) ignored" instead of - # "duplicate ignored"), because even if we did ignore that one, it still wouldn't - # work properly... - _IGNORED_DEFECT = DefectWrapper( - email.errors.InvalidHeaderDefect, - 'duplicate parameter name; duplicate ignored' - ) - - def __init__(self, disposition, params): - """Used internally after parsing the header.""" - self.disposition = disposition - self.params = params - assert 'filename*' not in self.params # Handled by headerregistry - - @classmethod - def parse(cls, value): - """Build a _ContentDisposition from header values.""" - # We allow non-ascii here (it will only be parsed inside of qdtext, and - # rejected by the grammar if it appears in other places), although parsing - # it can be ambiguous. Parsing it ensures that a non-ambiguous filename* - # value won't get dismissed because of an unrelated ambiguity in the - # filename parameter. But it does mean we occasionally give - # less-than-certain values for some legacy senders. - decoded = value.decode('iso-8859-1') - - reg = email.headerregistry.HeaderRegistry() - try: - parsed = reg('Content-Disposition', decoded) - except IndexError: # pragma: no cover - # WORKAROUND for https://github.com/python/cpython/issues/81672 - # Fixed in Python 3.7.5 and 3.8.0. - # Still getting failures on 3.10 on CI though - raise ContentDispositionError("Missing closing quote character") - except ValueError: - # WORKAROUND for https://github.com/python/cpython/issues/87112 - raise ContentDispositionError("Non-ASCII digit") - except AttributeError: # pragma: no cover - # WORKAROUND for https://github.com/python/cpython/issues/93010 - raise ContentDispositionError("Section number has an invalid leading 0") - - if parsed.defects: - defects = list(parsed.defects) - if defects != [cls._IGNORED_DEFECT]: - raise ContentDispositionError(defects) - - # https://github.com/python/mypy/issues/12314 - assert isinstance( - parsed, # type: ignore[unreachable] - email.headerregistry.ContentDispositionHeader, - ), parsed - return cls( # type: ignore[unreachable] - disposition=parsed.content_disposition, - params=parsed.params, - ) - - def filename(self): - """The filename from the Content-Disposition header or None. - - On safety: - - This property records the intent of the sender. - - You shouldn't use this sender-controlled value as a filesystem path, it - can be insecure. Serving files with this filename can be dangerous as - well, due to a certain browser using the part after the dot for - mime-sniffing. Saving it to a database is fine by itself though. - """ - return self.params.get('filename') - - def is_inline(self): - """Return if the file should be handled inline. - - If not, and unless your application supports other dispositions - than the standard inline and attachment, it should be handled - as an attachment. - """ - return self.disposition in {None, 'inline'} - - def __repr__(self): - return utils.get_repr(self, constructor=True, - disposition=self.disposition, params=self.params) - - -def parse_content_disposition(reply): - """Parse a content_disposition header. - - Args: - reply: The QNetworkReply to get a filename for. - - Return: - A (is_inline, filename) tuple. - """ - is_inline = True - filename = None - content_disposition_header = b'Content-Disposition' - # First check if the Content-Disposition header has a filename - # attribute. - if reply.hasRawHeader(content_disposition_header): - # We use the unsafe variant of the filename as we sanitize it via - # os.path.basename later. - try: - value = bytes(reply.rawHeader(content_disposition_header)) - log.network.debug(f"Parsing Content-Disposition: {value!r}") - content_disposition = ContentDisposition.parse(value) - filename = content_disposition.filename() - except ContentDispositionError as e: - log.network.error(f"Error while parsing filename: {e}") - else: - is_inline = content_disposition.is_inline() - # Then try to get filename from url - if not filename: - filename = reply.url().path().rstrip('/') - # If that fails as well, use a fallback - if not filename: - filename = 'qutebrowser-download' - return is_inline, os.path.basename(filename) - - -def parse_content_type(reply): - """Parse a Content-Type header. - - The parsing done here is very cheap, as we really only want to get the - Mimetype. Parameters aren't parsed specially. - - Args: - reply: The QNetworkReply to handle. - - Return: - A [mimetype, rest] list, or [None, None] if unset. - Rest can be None. - """ - content_type = reply.header(QNetworkRequest.KnownHeaders.ContentTypeHeader) - if content_type is None: - return [None, None] - if ';' in content_type: - ret = content_type.split(';', maxsplit=1) - else: - ret = [content_type, None] - ret[0] = ret[0].strip() - return ret diff --git a/qutebrowser/browser/webkit/httpheaders.py b/qutebrowser/browser/webkit/httpheaders.py new file mode 100644 index 000000000..95b7b7104 --- /dev/null +++ b/qutebrowser/browser/webkit/httpheaders.py @@ -0,0 +1,187 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Parsing functions for various HTTP headers.""" + +import email.headerregistry +import email.errors +import dataclasses +import os.path +from typing import Type + +from qutebrowser.qt.network import QNetworkRequest + +from qutebrowser.utils import log, utils + + +class ContentDispositionError(Exception): + + """Base class for RFC6266 errors.""" + + +@dataclasses.dataclass +class DefectWrapper: + + """Wrapper around a email.error for comparison.""" + + error_class: Type[email.errors.MessageDefect] + line: str + + def __eq__(self, other): + return ( + isinstance(other, self.error_class) + and other.line == self.line # type: ignore[attr-defined] + ) + + +class ContentDisposition: + + """Records various indications and hints about content disposition. + + These can be used to know if a file should be downloaded or + displayed directly, and to hint what filename it should have + in the download case. + """ + + # Ignoring this defect fixes the attfnboth2 test case. It does *not* fix attfnboth + # one which has a slightly different wording ("duplicate(s) ignored" instead of + # "duplicate ignored"), because even if we did ignore that one, it still wouldn't + # work properly... + _IGNORED_DEFECT = DefectWrapper( + email.errors.InvalidHeaderDefect, + 'duplicate parameter name; duplicate ignored' + ) + + def __init__(self, disposition, params): + """Used internally after parsing the header.""" + self.disposition = disposition + self.params = params + assert 'filename*' not in self.params # Handled by headerregistry + + @classmethod + def parse(cls, value): + """Build a _ContentDisposition from header values.""" + # We allow non-ascii here (it will only be parsed inside of qdtext, and + # rejected by the grammar if it appears in other places), although parsing + # it can be ambiguous. Parsing it ensures that a non-ambiguous filename* + # value won't get dismissed because of an unrelated ambiguity in the + # filename parameter. But it does mean we occasionally give + # less-than-certain values for some legacy senders. + decoded = value.decode('iso-8859-1') + + reg = email.headerregistry.HeaderRegistry() + try: + parsed = reg('Content-Disposition', decoded) + except IndexError: # pragma: no cover + # WORKAROUND for https://github.com/python/cpython/issues/81672 + # Fixed in Python 3.7.5 and 3.8.0. + # Still getting failures on 3.10 on CI though + raise ContentDispositionError("Missing closing quote character") + except ValueError: + # WORKAROUND for https://github.com/python/cpython/issues/87112 + raise ContentDispositionError("Non-ASCII digit") + except AttributeError: # pragma: no cover + # WORKAROUND for https://github.com/python/cpython/issues/93010 + raise ContentDispositionError("Section number has an invalid leading 0") + + if parsed.defects: + defects = list(parsed.defects) + if defects != [cls._IGNORED_DEFECT]: + raise ContentDispositionError(defects) + + # https://github.com/python/mypy/issues/12314 + assert isinstance( + parsed, # type: ignore[unreachable] + email.headerregistry.ContentDispositionHeader, + ), parsed + return cls( # type: ignore[unreachable] + disposition=parsed.content_disposition, + params=parsed.params, + ) + + def filename(self): + """The filename from the Content-Disposition header or None. + + On safety: + + This property records the intent of the sender. + + You shouldn't use this sender-controlled value as a filesystem path, it + can be insecure. Serving files with this filename can be dangerous as + well, due to a certain browser using the part after the dot for + mime-sniffing. Saving it to a database is fine by itself though. + """ + return self.params.get('filename') + + def is_inline(self): + """Return if the file should be handled inline. + + If not, and unless your application supports other dispositions + than the standard inline and attachment, it should be handled + as an attachment. + """ + return self.disposition in {None, 'inline'} + + def __repr__(self): + return utils.get_repr(self, constructor=True, + disposition=self.disposition, params=self.params) + + +def parse_content_disposition(reply): + """Parse a content_disposition header. + + Args: + reply: The QNetworkReply to get a filename for. + + Return: + A (is_inline, filename) tuple. + """ + is_inline = True + filename = None + content_disposition_header = b'Content-Disposition' + # First check if the Content-Disposition header has a filename + # attribute. + if reply.hasRawHeader(content_disposition_header): + # We use the unsafe variant of the filename as we sanitize it via + # os.path.basename later. + try: + value = bytes(reply.rawHeader(content_disposition_header)) + log.network.debug(f"Parsing Content-Disposition: {value!r}") + content_disposition = ContentDisposition.parse(value) + filename = content_disposition.filename() + except ContentDispositionError as e: + log.network.error(f"Error while parsing filename: {e}") + else: + is_inline = content_disposition.is_inline() + # Then try to get filename from url + if not filename: + filename = reply.url().path().rstrip('/') + # If that fails as well, use a fallback + if not filename: + filename = 'qutebrowser-download' + return is_inline, os.path.basename(filename) + + +def parse_content_type(reply): + """Parse a Content-Type header. + + The parsing done here is very cheap, as we really only want to get the + Mimetype. Parameters aren't parsed specially. + + Args: + reply: The QNetworkReply to handle. + + Return: + A [mimetype, rest] list, or [None, None] if unset. + Rest can be None. + """ + content_type = reply.header(QNetworkRequest.KnownHeaders.ContentTypeHeader) + if content_type is None: + return [None, None] + if ';' in content_type: + ret = content_type.split(';', maxsplit=1) + else: + ret = [content_type, None] + ret[0] = ret[0].strip() + return ret diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index ea19174ec..595432dc9 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -18,7 +18,7 @@ from qutebrowser.qt.webkitwidgets import QWebPage, QWebFrame from qutebrowser.config import websettings, config from qutebrowser.browser import pdfjs, shared, downloads, greasemonkey -from qutebrowser.browser.webkit import http +from qutebrowser.browser.webkit import httpheaders from qutebrowser.browser.webkit.network import networkmanager from qutebrowser.utils import message, usertypes, log, jinja, objreg from qutebrowser.qt import sip @@ -263,14 +263,14 @@ class BrowserPage(QWebPage): At some point we might want to implement the MIME Sniffing standard here: https://mimesniff.spec.whatwg.org/ """ - inline, suggested_filename = http.parse_content_disposition(reply) + inline, suggested_filename = httpheaders.parse_content_disposition(reply) download_manager = objreg.get('qtnetwork-download-manager') if not inline: # Content-Disposition: attachment -> force download download_manager.fetch(reply, suggested_filename=suggested_filename) return - mimetype, _rest = http.parse_content_type(reply) + mimetype, _rest = httpheaders.parse_content_type(reply) if mimetype == 'image/jpg': # Some servers (e.g. the LinkedIn CDN) send a non-standard # image/jpg (instead of image/jpeg, defined in RFC 1341 section diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 38a8f6ca1..e1d0d8642 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -73,8 +73,8 @@ PERFECT_FILES = [ 'qutebrowser/browser/history.py'), ('tests/unit/browser/test_pdfjs.py', 'qutebrowser/browser/pdfjs.py'), - ('tests/unit/browser/webkit/http/test_http.py', - 'qutebrowser/browser/webkit/http.py'), + ('tests/unit/browser/webkit/http/test_httpheaders.py', + 'qutebrowser/browser/webkit/httpheaders.py'), # ('tests/unit/browser/webkit/test_webkitelem.py', # 'qutebrowser/browser/webkit/webkitelem.py'), # ('tests/unit/browser/webkit/test_webkitelem.py', diff --git a/tests/unit/browser/webkit/http/test_content_disposition.py b/tests/unit/browser/webkit/http/test_content_disposition.py index 7cf80e3fd..4f3ef13c7 100644 --- a/tests/unit/browser/webkit/http/test_content_disposition.py +++ b/tests/unit/browser/webkit/http/test_content_disposition.py @@ -6,7 +6,7 @@ import logging import pytest -from qutebrowser.browser.webkit import http +from qutebrowser.browser.webkit import httpheaders DEFAULT_NAME = 'qutebrowser-download' @@ -30,7 +30,7 @@ class HeaderChecker: """Check if the passed header has the given filename.""" reply = self.stubs.FakeNetworkReply( headers={'Content-Disposition': header}) - cd_inline, cd_filename = http.parse_content_disposition(reply) + cd_inline, cd_filename = httpheaders.parse_content_disposition(reply) assert cd_filename is not None assert cd_filename == filename assert cd_inline == expected_inline @@ -40,7 +40,7 @@ class HeaderChecker: reply = self.stubs.FakeNetworkReply( headers={'Content-Disposition': header}) with self.caplog.at_level(logging.ERROR, 'network'): - cd_inline, cd_filename = http.parse_content_disposition(reply) + cd_inline, cd_filename = httpheaders.parse_content_disposition(reply) assert cd_filename == DEFAULT_NAME assert cd_inline @@ -48,7 +48,7 @@ class HeaderChecker: """Check if the passed header results in an unnamed attachment.""" reply = self.stubs.FakeNetworkReply( headers={'Content-Disposition': header}) - cd_inline, cd_filename = http.parse_content_disposition(reply) + cd_inline, cd_filename = httpheaders.parse_content_disposition(reply) assert cd_filename == DEFAULT_NAME assert not cd_inline @@ -164,7 +164,7 @@ class TestAttachment: """ reply = stubs.FakeNetworkReply( headers={'Content-Disposition': 'attachment'}) - cd_inline, cd_filename = http.parse_content_disposition(reply) + cd_inline, cd_filename = httpheaders.parse_content_disposition(reply) assert not cd_inline assert cd_filename == DEFAULT_NAME diff --git a/tests/unit/browser/webkit/http/test_http.py b/tests/unit/browser/webkit/http/test_http.py deleted file mode 100644 index 210d79486..000000000 --- a/tests/unit/browser/webkit/http/test_http.py +++ /dev/null @@ -1,94 +0,0 @@ -# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Tests for qutebrowser.browser.webkit.http.""" - -import logging - -import pytest -import hypothesis -from hypothesis import strategies -from qutebrowser.qt.core import QUrl - -from qutebrowser.browser.webkit import http - - -@pytest.mark.parametrize('url, expected', [ - # Filename in the URL - ('http://example.com/path', 'path'), - ('http://example.com/foo/path', 'path'), - # No filename at all - ('http://example.com', 'qutebrowser-download'), - ('http://example.com/', 'qutebrowser-download'), -]) -def test_no_content_disposition(stubs, url, expected): - reply = stubs.FakeNetworkReply(url=QUrl(url)) - inline, filename = http.parse_content_disposition(reply) - assert inline - assert filename == expected - - -@pytest.mark.parametrize('value', [ - # https://github.com/python/cpython/issues/87112 - 'inline; 0*²'.encode("iso-8859-1"), - # https://github.com/python/cpython/issues/81672 - b'"', - # https://github.com/python/cpython/issues/93010 - b'attachment; 0*00="foo"', - # FIXME: Should probably have more tests if this is still relevant after - # dropping QtWebKit. -]) -def test_parse_content_disposition_invalid(value): - with pytest.raises(http.ContentDispositionError): - http.ContentDisposition.parse(value) - - -@pytest.mark.parametrize('template', [ - '{}', - 'attachment; filename="{}"', - 'inline; {}', - 'attachment; {}="foo"', - "attachment; filename*=iso-8859-1''{}", - 'attachment; filename*={}', -]) -@hypothesis.given(strategies.text(alphabet=[chr(x) for x in range(255)])) -def test_parse_content_disposition_hypothesis(caplog, template, stubs, s): - """Test parsing headers based on templates which hypothesis completes.""" - header = template.format(s) - reply = stubs.FakeNetworkReply(headers={'Content-Disposition': header}) - with caplog.at_level(logging.ERROR, 'network'): - http.parse_content_disposition(reply) - - -@hypothesis.given(strategies.binary()) -def test_content_disposition_directly_hypothesis(s): - """Test rfc6266 parsing directly with binary data.""" - try: - cd = http.ContentDisposition.parse(s) - cd.filename() - except http.ContentDispositionError: - pass - - -@pytest.mark.parametrize('content_type, expected_mimetype, expected_rest', [ - (None, None, None), - ('image/example', 'image/example', None), - ('', '', None), - ('image/example; encoding=UTF-8', 'image/example', ' encoding=UTF-8'), -]) -def test_parse_content_type(stubs, content_type, expected_mimetype, - expected_rest): - if content_type is None: - reply = stubs.FakeNetworkReply() - else: - reply = stubs.FakeNetworkReply(headers={'Content-Type': content_type}) - mimetype, rest = http.parse_content_type(reply) - assert mimetype == expected_mimetype - assert rest == expected_rest - - -@hypothesis.given(strategies.text()) -def test_parse_content_type_hypothesis(stubs, s): - reply = stubs.FakeNetworkReply(headers={'Content-Type': s}) - http.parse_content_type(reply) diff --git a/tests/unit/browser/webkit/http/test_httpheaders.py b/tests/unit/browser/webkit/http/test_httpheaders.py new file mode 100644 index 000000000..7368575e8 --- /dev/null +++ b/tests/unit/browser/webkit/http/test_httpheaders.py @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for qutebrowser.browser.webkit.httpheaders.""" + +import logging + +import pytest +import hypothesis +from hypothesis import strategies +from qutebrowser.qt.core import QUrl + +from qutebrowser.browser.webkit import httpheaders + + +@pytest.mark.parametrize('url, expected', [ + # Filename in the URL + ('http://example.com/path', 'path'), + ('http://example.com/foo/path', 'path'), + # No filename at all + ('http://example.com', 'qutebrowser-download'), + ('http://example.com/', 'qutebrowser-download'), +]) +def test_no_content_disposition(stubs, url, expected): + reply = stubs.FakeNetworkReply(url=QUrl(url)) + inline, filename = httpheaders.parse_content_disposition(reply) + assert inline + assert filename == expected + + +@pytest.mark.parametrize('value', [ + # https://github.com/python/cpython/issues/87112 + 'inline; 0*²'.encode("iso-8859-1"), + # https://github.com/python/cpython/issues/81672 + b'"', + # https://github.com/python/cpython/issues/93010 + b'attachment; 0*00="foo"', + # FIXME: Should probably have more tests if this is still relevant after + # dropping QtWebKit. +]) +def test_parse_content_disposition_invalid(value): + with pytest.raises(httpheaders.ContentDispositionError): + httpheaders.ContentDisposition.parse(value) + + +@pytest.mark.parametrize('template', [ + '{}', + 'attachment; filename="{}"', + 'inline; {}', + 'attachment; {}="foo"', + "attachment; filename*=iso-8859-1''{}", + 'attachment; filename*={}', +]) +@hypothesis.given(strategies.text(alphabet=[chr(x) for x in range(255)])) +def test_parse_content_disposition_hypothesis(caplog, template, stubs, s): + """Test parsing headers based on templates which hypothesis completes.""" + header = template.format(s) + reply = stubs.FakeNetworkReply(headers={'Content-Disposition': header}) + with caplog.at_level(logging.ERROR, 'network'): + httpheaders.parse_content_disposition(reply) + + +@hypothesis.given(strategies.binary()) +def test_content_disposition_directly_hypothesis(s): + """Test rfc6266 parsing directly with binary data.""" + try: + cd = httpheaders.ContentDisposition.parse(s) + cd.filename() + except httpheaders.ContentDispositionError: + pass + + +@pytest.mark.parametrize('content_type, expected_mimetype, expected_rest', [ + (None, None, None), + ('image/example', 'image/example', None), + ('', '', None), + ('image/example; encoding=UTF-8', 'image/example', ' encoding=UTF-8'), +]) +def test_parse_content_type(stubs, content_type, expected_mimetype, + expected_rest): + if content_type is None: + reply = stubs.FakeNetworkReply() + else: + reply = stubs.FakeNetworkReply(headers={'Content-Type': content_type}) + mimetype, rest = httpheaders.parse_content_type(reply) + assert mimetype == expected_mimetype + assert rest == expected_rest + + +@hypothesis.given(strategies.text()) +def test_parse_content_type_hypothesis(stubs, s): + reply = stubs.FakeNetworkReply(headers={'Content-Type': s}) + httpheaders.parse_content_type(reply) -- cgit v1.2.3-54-g00ecf