launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #25865
[Merge] ~cjwatson/launchpad:filebug-data-parser-unittest into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:filebug-data-parser-unittest into launchpad:master.
Commit message:
Convert filebug-data-parser.txt to unittest
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/395358
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:filebug-data-parser-unittest into launchpad:master.
diff --git a/lib/lp/bugs/doc/filebug-data-parser.txt b/lib/lp/bugs/doc/filebug-data-parser.txt
deleted file mode 100644
index 39e7c38..0000000
--- a/lib/lp/bugs/doc/filebug-data-parser.txt
+++ /dev/null
@@ -1,405 +0,0 @@
-= Filebug Data Parser =
-
-An application like Apport can upload data to Launchpad, and have the
-information added to the bug report that the user will file. The
-information is uploaded as a MIME multipart message, where the different
-headers tells Launchpad what kind of information it is.
-
-
-== FileBugDataParser Internals ==
-
-FileBugDataParser is used to parse the MIME message with the information
-to be added to the bug report. The information is passed as a file
-object to the constructor.
-
- >>> from io import BytesIO
- >>> from lp.bugs.utilities.filebugdataparser import FileBugDataParser
- >>> parser = FileBugDataParser(BytesIO(b'123456789'))
-
-To make parsing easier and more efficient, it has a buffer where it
-stores the next few bytes of the file. To begin with, it's empty.
-
- >>> parser._buffer
- ''
-
-Whenever it needs to read some bytes of the file, it will read a fixed
-number of bytes into the buffer. The number of bytes is specified by the
-BUFFER_SIZE variable.
-
- >>> parser.BUFFER_SIZE = 3
-
-There is helper method, _consumeBytes(), which will read from the file
-until a certain delimiter string is encountered.
-
- >>> parser._consumeBytes(b'4')
- '1234'
-
-In order to find the delimiter string, it had to read '123456' into
-the buffer. Up to the delimiter string is read, but the rest of the
-string is kept in the buffer.
-
- >>> parser._buffer
- '56'
-
-The delimiter string isn't limited to one character.
-
- >>> parser._consumeBytes(b'67')
- '567'
-
- >>> parser._buffer
- '89'
-
-If the delimiter isn't found in the file, the rest of the file is
-returned.
-
- >>> parser._consumeBytes(b'0')
- '89'
- >>> parser._buffer
- ''
-
-Subsequent reads will result in the empty string.
-
- >>> parser._consumeBytes(b'0')
- ''
- >>> parser._consumeBytes(b'0')
- ''
-
-
-=== readLine() ===
-
-readLine() is a helper method to read a single line of the file.
-
- >>> parser = FileBugDataParser(BytesIO(b'123\n456\n789'))
- >>> parser.readLine()
- '123\n'
- >>> parser.readLine()
- '456\n'
- >>> parser.readLine()
- '789'
-
-If we try to read past the end of the file an AssertionError will be
-raised. This is to ensure that invalid messages won't cause an infinite
-loop, or something like that.
-
- >>> parser.readLine()
- Traceback (most recent call last):
- ...
- AssertionError: End of file reached.
-
-
-=== readHeaders() ====
-
-readHeaders() reads the headers of a MIME message. It reads all the
-headers, untils it sees a blank line.
-
- >>> msg = b"""Header: value
- ... Space-Folded-Header: this header
- ... is folded with a space.
- ... Tab-Folded-Header: this header
- ... \tis folded with a tab.
- ... Another-header: another-value
- ...
- ... Not-A-Header: not-a-value
- ... """
- >>> parser = FileBugDataParser(BytesIO(msg))
- >>> headers = parser.readHeaders()
- >>> headers['Header']
- 'value'
- >>> headers['Space-Folded-Header']
- 'this header\n is folded with a space.'
- >>> headers['Tab-Folded-Header']
- 'this header\n\tis folded with a tab.'
- >>> headers['Another-Header']
- 'another-value'
- >>> 'Not-A-Header' in headers
- False
-
-
-== Parsing the data ==
-
-The parse() method returns a FileBugData object, with the information
-from the message as attributes.
-
-
-=== Headers ===
-
-The headers are processed by the _setDataFromHeaders() method. It
-accepts a FileBugData object and a dictionary of the headers.
-
-
-==== Subject ====
-
-The Subject header is available in the initial_summary attribute.
-
- >>> from lp.bugs.browser.bugtarget import FileBugData
- >>> data = FileBugData()
- >>> parser = FileBugDataParser(None)
- >>> parser._setDataFromHeaders(data, {'Subject': 'Bug Subject'})
- >>> data.initial_summary
- u'Bug Subject'
-
-
-==== Tags ====
-
-The Tags headers is translated into a list of strings as the
-initial_tags attributes. The tags are translated to lower case
-automatically.
-
- >>> data = FileBugData()
- >>> parser._setDataFromHeaders(data, {'Tags': 'Tag-One Tag-Two'})
- >>> sorted(data.initial_tags)
- [u'tag-one', u'tag-two']
-
-
-==== Private ====
-
-The Private header gets translated into a boolean, as the private
-attribute. The values "yes" and "no" are accepted, which get translated
-into True and False.
-
- >>> data = FileBugData()
- >>> parser._setDataFromHeaders(data, {'Private': 'yes'})
- >>> data.private
- True
- >>> data = FileBugData()
- >>> parser._setDataFromHeaders(data, {'Private': 'no'})
- >>> data.private
- False
-
-We're in no position of presenting a good error message to the user at
-this point, so invalid values get ignored.
-
- >>> data = FileBugData()
- >>> parser._setDataFromHeaders(data, {'Private': 'not-valid'})
- >>> print(data.private)
- None
-
-
-==== Subscribers ====
-
-The Subscribers header is turned into a list of strings, available
-through the subscribers attribute. The strings get lowercased
-automatically.
-
- >>> data = FileBugData()
- >>> parser._setDataFromHeaders(data, {'Subscribers': 'sub-one SUB-TWO'})
- >>> sorted(data.subscribers)
- [u'sub-one', u'sub-two']
-
-
-=== Message Parts ===
-
-Different parts of the message gets treated differently. In short, we
-look at the Content-Disposition header. If it's inline, it's a comment,
-if it's attachment, it's an attachment.
-
-
-==== Inline parts ====
-
-The first inline part is special. Instead of being treated as a comment,
-it gets appended to the bug description. It's available through the
-extra_description attribute.
-
- >>> used_parsers = []
- >>> def parse_message(message):
- ... parser = FileBugDataParser(BytesIO(message))
- ... used_parsers.append(parser)
- ... return parser.parse()
-
- >>> debug_data = b"""MIME-Version: 1.0
- ... Content-type: multipart/mixed; boundary=boundary
- ...
- ... --boundary
- ... Content-disposition: inline
- ... Content-type: text/plain; charset=utf-8
- ...
- ... This should be added to the description.
- ...
- ... Another line.
- ...
- ... --boundary--
- ... """
- >>> data = parse_message(debug_data)
- >>> data.extra_description
- u'This should be added to the description.\n\nAnother line.'
-
-
-The text can also be base64 decoded.
-
- >>> encoded_text = b"""VGhpcyBzaG91bGQgYmUgYWRkZWQgdG8g
- ... dGhlIGRlc2NyaXB0aW9uLgoKQW5vdGhl
- ... ciBsaW5lLg=="""
- >>> encoded_text.decode('base64')
- 'This should be added to the description.\n\nAnother line.'
-
- >>> debug_data = b"""MIME-Version: 1.0
- ... Content-type: multipart/mixed; boundary=boundary
- ...
- ... --boundary
- ... Content-disposition: inline
- ... Content-type: text/plain; charset=utf-8
- ... Content-transfer-encoding: base64
- ...
- ... %s
- ...
- ... --boundary--
- ... """ % encoded_text
- >>> data = parse_message(debug_data)
- >>> data.extra_description
- u'This should be added to the description.\n\nAnother line.'
-
-
-==== Other inline parts ====
-
-If there are more than one inline part, those will be added as comments
-to the bug. The comments are simple ext strings, accessible through the
-comments attribute.
-
- >>> debug_data = b"""MIME-Version: 1.0
- ... Content-type: multipart/mixed; boundary=boundary
- ...
- ... --boundary
- ... Content-disposition: inline
- ... Content-type: text/plain; charset=utf-8
- ...
- ... This should be added to the description.
- ...
- ... --boundary
- ... Content-disposition: inline
- ... Content-type: text/plain; charset=utf-8
- ...
- ... This should be added as a comment.
- ...
- ... --boundary
- ... Content-disposition: inline
- ... Content-type: text/plain; charset=utf-8
- ...
- ... This should be added as another comment.
- ...
- ... Line 2.
- ...
- ... --boundary--
- ... """
- >>> data = parse_message(debug_data)
- >>> len(data.comments)
- 2
- >>> data.comments[0]
- u'This should be added as a comment.'
- >>> data.comments[1]
- u'This should be added as another comment.\n\nLine 2.'
-
-
-=== Attachment parts ===
-
-All the parts that have a 'Content-disposition: attachment' header
-will get added as attachments to the bug. The attachment description can
-be specified using a Content-description header, but it's not required.
-
- >>> debug_data = b"""MIME-Version: 1.0
- ... Content-type: multipart/mixed; boundary=boundary
- ...
- ... --boundary
- ... Content-disposition: attachment; filename='attachment1'
- ... Content-type: text/plain; charset=utf-8
- ...
- ... This is an attachment.
- ...
- ... Another line.
- ...
- ... --boundary
- ... Content-disposition: attachment; filename='attachment2'
- ... Content-description: Attachment description.
- ... Content-type: text/plain; charset=ISO-8859-1
- ...
- ... This is another attachment, with a description.
- ... --boundary--
- ... """
- >>> data = parse_message(debug_data)
- >>> len(data.attachments)
- 2
- >>> first_attachment, second_attachment = data.attachments
-
-The filename is copied into the 'filename' item.
-
- >>> first_attachment['filename']
- u'attachment1'
- >>> second_attachment['filename']
- u'attachment2'
-
-The Content-Type header is copied as is.
-
- >>> first_attachment['content_type']
- u'text/plain; charset=utf-8'
- >>> second_attachment['content_type']
- u'text/plain; charset=ISO-8859-1'
-
-If there is a Content-Description header, it's accessible as
-'description'.
-
- >>> second_attachment['description']
- u'Attachment description.'
-
-If there isn't any Content-Description header, the file name is used
-instead.
-
- >>> first_attachment['description']
- u'attachment1'
-
-The contents of the attachments are stored in a file.
-
- >>> first_file = first_attachment['content']
- >>> first_file.read()
- 'This is an attachment.\n\nAnother line.\n\n'
- >>> first_file.close()
-
- >>> second_file = second_attachment['content']
- >>> second_file.read()
- 'This is another attachment, with a description.\n'
- >>> second_file.close()
-
-Binary files are base64 encoded. They are decoded automatically.
-
- >>> binary_data = b'\n'.join([b'\x00'*5, b'\x01'*5])
- >>> debug_data = b"""MIME-Version: 1.0
- ... Content-type: multipart/mixed; boundary=boundary
- ...
- ... --boundary
- ... Content-disposition: attachment; filename='attachment1'
- ... Content-type: application/octet-stream
- ... Content-transfer-encoding: base64
- ...
- ... %s
- ... --boundary--
- ... """ % binary_data.encode('base64')
- >>> data = parse_message(debug_data)
- >>> len(data.attachments)
- 1
-
- >>> contents = data.attachments[0]['content']
- >>> contents.read()
- '\x00\x00\x00\x00\x00\n\x01\x01\x01\x01\x01'
- >>> contents.close()
-
-
-== Invalid messages ==
-
-If someone gives an invalid message, for example one that doesn't have
-an end boundary, and AssertionError will be raised. This is mainly to
-assert that nothing bad will happen if we can't parse the message. We
-don't care about giving the user a good error message, since the format
-is well-known.
-
- >>> debug_data = b"""MIME-Version: 1.0
- ... Content-type: multipart/mixed; boundary=boundary
- ...
- ... --boundary
- ... Content-disposition: attachment; filename='attachment1'
- ... Content-type: text/plain; charset=utf-8
- ...
- ... This is an attachment.
- ...
- ... Another line."""
- >>> data = parse_message(debug_data)
- Traceback (most recent call last):
- ...
- AssertionError: End of file reached.
diff --git a/lib/lp/bugs/tests/test_doc.py b/lib/lp/bugs/tests/test_doc.py
index f22cd6f..f7a1e78 100644
--- a/lib/lp/bugs/tests/test_doc.py
+++ b/lib/lp/bugs/tests/test_doc.py
@@ -444,10 +444,6 @@ special = {
tearDown=tearDown,
layer=LaunchpadZopelessLayer
),
- 'filebug-data-parser.txt': LayeredDocFileSuite(
- '../doc/filebug-data-parser.txt',
- setUp=lambda test: setGlobs(test, future=True),
- ),
'product-update-remote-product.txt': LayeredDocFileSuite(
'../doc/product-update-remote-product.txt',
setUp=updateRemoteProductSetup,
diff --git a/lib/lp/bugs/utilities/tests/__init__.py b/lib/lp/bugs/utilities/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/bugs/utilities/tests/__init__.py
diff --git a/lib/lp/bugs/utilities/tests/test_filebugdataparser.py b/lib/lp/bugs/utilities/tests/test_filebugdataparser.py
new file mode 100644
index 0000000..10858f7
--- /dev/null
+++ b/lib/lp/bugs/utilities/tests/test_filebugdataparser.py
@@ -0,0 +1,310 @@
+# Copyright 2020 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""FileBugDataParser tests.
+
+An application like Apport can upload data to Launchpad, and have the
+information added to the bug report that the user will file. The
+information is uploaded as a MIME multipart message, where the different
+headers tells Launchpad what kind of information it is.
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+import base64
+import io
+from textwrap import dedent
+
+from lp.bugs.model.bug import FileBugData
+from lp.bugs.utilities.filebugdataparser import FileBugDataParser
+from lp.testing import TestCase
+
+
+class TestFileBugDataParser(TestCase):
+
+ def test_initial_buffer(self):
+ # The parser's buffer starts out empty.
+ parser = FileBugDataParser(io.BytesIO(b"123456789"))
+ self.assertEqual(b"", parser._buffer)
+
+ def test__consumeBytes(self):
+ # _consumeBytes reads from the file until a delimiter is
+ # encountered.
+ parser = FileBugDataParser(io.BytesIO(b"123456789"))
+ parser.BUFFER_SIZE = 3
+ self.assertEqual(b"1234", parser._consumeBytes(b"4"))
+ # In order to find the delimiter string, it had to read b"123456"
+ # into the buffer, so two bytes remain.
+ self.assertEqual(b"56", parser._buffer)
+ # The delimiter string isn't limited to one character.
+ self.assertEqual(b"567", parser._consumeBytes(b"67"))
+ self.assertEqual(b"89", parser._buffer)
+ # If the delimiter isn't found in the file, the rest of the file is
+ # returned.
+ self.assertEqual(b"89", parser._consumeBytes(b"0"))
+ self.assertEqual(b"", parser._buffer)
+ # Subsequent reads result in the empty string.
+ self.assertEqual(b"", parser._consumeBytes(b"0"))
+ self.assertEqual(b"", parser._consumeBytes(b"0"))
+
+ def test_readLine(self):
+ # readLine reads a single line of the file.
+ parser = FileBugDataParser(io.BytesIO(b"123\n456\n789"))
+ self.assertEqual(b"123\n", parser.readLine())
+ self.assertEqual(b"456\n", parser.readLine())
+ self.assertEqual(b"789", parser.readLine())
+ # If we try to read past the end of the file, an AssertionError is
+ # raised. This ensures that invalid messages don't cause an
+ # infinite loop or similar.
+ self.assertRaisesWithContent(
+ AssertionError, "End of file reached.", parser.readLine)
+
+ def test_readHeaders(self):
+ # readHeaders reads the headers of a MIME message. It reads all the
+ # headers until it sees a blank line.
+ msg = dedent("""\
+ Header: value
+ Space-Folded-Header: this header
+ is folded with a space.
+ Tab-Folded-Header: this header
+ \tis folded with a tab.
+ Another-header: another-value
+
+ Not-A-Header: not-a-value
+ """).encode("ASCII")
+ parser = FileBugDataParser(io.BytesIO(msg))
+ headers = parser.readHeaders()
+ self.assertEqual("value", headers["Header"])
+ self.assertEqual(
+ "this header\n is folded with a space.",
+ headers["Space-Folded-Header"])
+ self.assertEqual(
+ "this header\n\tis folded with a tab.",
+ headers["Tab-Folded-Header"])
+ self.assertEqual("another-value", headers["Another-Header"])
+ self.assertNotIn("Not-A-Header", headers)
+
+ def test__setDataFromHeaders_subject(self):
+ # _setDataFromHeaders makes the Subject header available in
+ # FileBugData.initial_summary.
+ data = FileBugData()
+ parser = FileBugDataParser(None)
+ parser._setDataFromHeaders(data, {"Subject": "Bug Subject"})
+ self.assertEqual("Bug Subject", data.initial_summary)
+
+ def test__setDataFromHeaders_tags(self):
+ # _setDataFromHeaders translates the Tags header into a list of
+ # lower-case strings as FileBugData.initial_tags.
+ data = FileBugData()
+ parser = FileBugDataParser(None)
+ parser._setDataFromHeaders(data, {"Tags": "Tag-One Tag-Two"})
+ self.assertContentEqual(["tag-one", "tag-two"], data.initial_tags)
+
+ def test__setDataFromHeaders_private(self):
+ # _setDataFromHeaders translates the Private header into a boolean
+ # as FileBugData.private. It accepts "yes" for True and "no" for
+ # False.
+ data = FileBugData()
+ parser = FileBugDataParser(None)
+ parser._setDataFromHeaders(data, {"Private": "yes"})
+ self.assertIs(True, data.private)
+ data = FileBugData()
+ parser._setDataFromHeaders(data, {"Private": "no"})
+ self.assertIs(False, data.private)
+ # We're in no position to present a good error message to the user
+ # at this point, so invalid values get ignored.
+ data = FileBugData()
+ parser._setDataFromHeaders(data, {"Private": "not-valid"})
+ self.assertIsNone(data.private)
+
+ def test__setDataFromHeaders_subscribers(self):
+ # _setDataFromHeaders translates the Subscriber header into a list
+ # of lower-case strings as FileBugData.subscribers.
+ data = FileBugData()
+ parser = FileBugDataParser(None)
+ parser._setDataFromHeaders(data, {"Subscribers": "sub-one SUB-TWO"})
+ self.assertContentEqual(["sub-one", "sub-two"], data.subscribers)
+
+ def test_parse_first_inline_part(self):
+ # The first inline part is special. Instead of being treated as a
+ # comment, it gets appended to the bug description. It's available
+ # as FileBugData.extra_description.
+ message = dedent("""\
+ MIME-Version: 1.0
+ Content-type: multipart/mixed; boundary=boundary
+
+ --boundary
+ Content-disposition: inline
+ Content-type: text/plain; charset=utf-8
+
+ This should be added to the description.
+
+ Another line.
+
+ --boundary--
+ """).encode("ASCII")
+ parser = FileBugDataParser(io.BytesIO(message))
+ data = parser.parse()
+ self.assertEqual(
+ "This should be added to the description.\n\nAnother line.",
+ data.extra_description)
+
+ def test_parse_first_inline_part_base64(self):
+ # An inline part can be base64-encoded.
+ encoded_text = base64.b64encode(
+ b"This should be added to the description.\n\n"
+ b"Another line.").decode("ASCII")
+ message = dedent("""\
+ MIME-Version: 1.0
+ Content-type: multipart/mixed; boundary=boundary
+
+ --boundary
+ Content-disposition: inline
+ Content-type: text/plain; charset=utf-8
+ Content-transfer-encoding: base64
+
+ %s
+
+ --boundary--
+ """ % encoded_text).encode("ASCII")
+ parser = FileBugDataParser(io.BytesIO(message))
+ data = parser.parse()
+ self.assertEqual(
+ "This should be added to the description.\n\nAnother line.",
+ data.extra_description)
+
+ def test_parse_other_inline_parts(self):
+ # If there is more than one inline part, the second and subsequent
+ # parts are added as comments to the bug. These are simple text
+ # strings, available as FileBugData.comments.
+ message = dedent("""\
+ MIME-Version: 1.0
+ Content-type: multipart/mixed; boundary=boundary
+
+ --boundary
+ Content-disposition: inline
+ Content-type: text/plain; charset=utf-8
+
+ This should be added to the description.
+
+ --boundary
+ Content-disposition: inline
+ Content-type: text/plain; charset=utf-8
+
+ This should be added as a comment.
+
+ --boundary
+ Content-disposition: inline
+ Content-type: text/plain; charset=utf-8
+
+ This should be added as another comment.
+
+ Line 2.
+
+ --boundary--
+ """).encode("ASCII")
+ parser = FileBugDataParser(io.BytesIO(message))
+ data = parser.parse()
+ self.assertEqual(
+ ["This should be added as a comment.",
+ "This should be added as another comment.\n\nLine 2."],
+ data.comments)
+
+ def test_parse_text_attachments(self):
+ # Parts with a "Content-Disposition: attachment" header are added as
+ # attachments to the bug. The attachment description can be
+ # specified using a Content-Description header, but it's not
+ # required.
+ message = dedent("""\
+ MIME-Version: 1.0
+ Content-type: multipart/mixed; boundary=boundary
+
+ --boundary
+ Content-disposition: attachment; filename='attachment1'
+ Content-type: text/plain; charset=utf-8
+
+ This is an attachment.
+
+ Another line.
+
+ --boundary
+ Content-disposition: attachment; filename='attachment2'
+ Content-description: Attachment description.
+ Content-type: text/plain; charset=ISO-8859-1
+
+ This is another attachment, with a description.
+ --boundary--
+ """).encode("ASCII")
+ parser = FileBugDataParser(io.BytesIO(message))
+ data = parser.parse()
+ self.assertEqual(2, len(data.attachments))
+ # The filename is copied into the "filename" item.
+ self.assertEqual("attachment1", data.attachments[0]["filename"])
+ self.assertEqual("attachment2", data.attachments[1]["filename"])
+ # The Content-Type header is copied as is.
+ self.assertEqual(
+ "text/plain; charset=utf-8",
+ data.attachments[0]["content_type"])
+ self.assertEqual(
+ "text/plain; charset=ISO-8859-1",
+ data.attachments[1]["content_type"])
+ # If there is a Content-Description header, it's accessible as
+ # "description". If there isn't any, the file name is used instead.
+ self.assertEqual("attachment1", data.attachments[0]["description"])
+ self.assertEqual(
+ "Attachment description.", data.attachments[1]["description"])
+ # The contents of the attachments are stored in files.
+ files = [attachment["content"] for attachment in data.attachments]
+ self.assertEqual(
+ b"This is an attachment.\n\nAnother line.\n\n", files[0].read())
+ files[0].close()
+ self.assertEqual(
+ b"This is another attachment, with a description.\n",
+ files[1].read())
+ files[1].close()
+
+ def test_parse_binary_attachments(self):
+ # Binary files are base64-encoded. They are decoded automatically.
+ encoded_data = base64.b64encode(
+ b"\n".join([b"\x00" * 5, b"\x01" * 5])).decode("ASCII")
+ message = dedent("""\
+ MIME-Version: 1.0
+ Content-type: multipart/mixed; boundary=boundary
+
+ --boundary
+ Content-disposition: attachment; filename='attachment1'
+ Content-type: application/octet-stream
+ Content-transfer-encoding: base64
+
+ %s
+ --boundary--
+ """ % encoded_data).encode("ASCII")
+ parser = FileBugDataParser(io.BytesIO(message))
+ data = parser.parse()
+ self.assertEqual(1, len(data.attachments))
+ self.assertEqual(
+ b"\x00\x00\x00\x00\x00\n\x01\x01\x01\x01\x01",
+ data.attachments[0]["content"].read())
+ data.attachments[0]["content"].close()
+
+ def test_invalid_message(self):
+ # If someone gives an invalid message, for example one that doesn't
+ # have an end boundary, the parser raises an AssertionError. We
+ # don't care about giving the user a good error message, since the
+ # format is well-known.
+ message = dedent("""\
+ MIME-Version: 1.0
+ Content-type: multipart/mixed; boundary=boundary
+
+ --boundary
+ Content-disposition: attachment; filename='attachment1'
+ Content-type: text/plain; charset=utf-8
+
+ This is an attachment.
+
+ Another line.""").encode("ASCII")
+ parser = FileBugDataParser(io.BytesIO(message))
+ self.assertRaisesWithContent(
+ AssertionError, "End of file reached.", parser.parse)