All patches and comments are welcome. Please squash your changes to logical
commits before using git-format-patch and git-send-email to
patches@git.madduck.net.
If you'd read over the Git project's submission guidelines and adhered to them,
I'd be especially grateful.
3 # NeoMutt helper script to create multipart/* emails with Markdown → HTML
4 # alternative conversion, and handling of inline images, using NeoMutt's
5 # ability to manually craft MIME trees, but automating this process.
8 # neomuttrc (needs to be a single line):
9 # set my_mdwn_extensions="extra,admonition,codehilite,sane_lists,smarty"
11 # <enter-command> source '$my_confdir/buildmimetree.py setup \
12 # --tempdir $tempdir --extensions $my_mdwn_extensions|'<enter>\
13 # <enter-command> sourc e \$my_mdwn_postprocess_cmd_file<enter>\
14 # " "Convert message into a modern MIME tree with inline images"
16 # (Yes, we need to call source twice, as mutt only starts to process output
17 # from a source command when the command exits, and since we need to react
18 # to the output, we need to be invoked again, using a $my_ variable to pass
27 # - Pygments, if installed, then syntax highlighting is enabled
30 # https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
32 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
33 # Released under the GPL-2+ licence, just like Mutt itself.
43 from collections import namedtuple, OrderedDict
44 from markdown.extensions import Extension
45 from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE
46 from email.utils import make_msgid
47 from urllib import request
50 def parse_cli_args(*args, **kwargs):
51 parser = argparse.ArgumentParser(
53 "NeoMutt helper to turn text/markdown email parts "
54 "into full-fledged MIME trees"
58 "Copyright © 2022 martin f. krafft <madduck@madduck.net>.\n"
59 "Released under the MIT licence"
62 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
63 parser_setup = subp.add_parser("setup", help="Setup phase")
64 parser_massage = subp.add_parser("massage", help="Massaging phase")
66 parser_setup.add_argument(
70 help="Markdown extension to use (comma-separated list)"
73 parser_setup.add_argument(
76 help="Only build, don't send the message",
79 parser_setup.add_argument(
82 help="Specify temporary directory to use for attachments",
85 parser_setup.add_argument(
88 help="Turn on debug logging of commands generated to stderr",
91 parser_massage.add_argument(
92 "--write-commands-to",
95 help="Temporary file path to write commands to",
98 parser_massage.add_argument(
100 metavar="EXTENSIONS",
103 help="Markdown extension to use (comma-separated list)",
106 parser_massage.add_argument(
109 help="Only build, don't send the message",
112 parser_massage.add_argument(
115 help="Specify temporary directory to use for attachments",
118 parser_massage.add_argument(
121 help="Turn on debug logging of commands generated to stderr",
124 parser_massage.add_argument(
127 help="Turn on debugging to stderr of the MIME tree walk",
130 parser_massage.add_argument(
133 help="If provided, the script is invoked as editor on the mail draft",
136 return parser.parse_args(*args, **kwargs)
139 # [ MARKDOWN WRAPPING ] #######################################################
142 InlineImageInfo = namedtuple(
143 "InlineImageInfo", ["cid", "desc"], defaults=[None]
147 class InlineImageExtension(Extension):
148 class RelatedImageInlineProcessor(ImageInlineProcessor):
149 def __init__(self, re, md, ext):
150 super().__init__(re, md)
153 def handleMatch(self, m, data):
154 el, start, end = super().handleMatch(m, data)
155 if "src" in el.attrib:
156 src = el.attrib["src"]
157 if "://" not in src or src.startswith("file://"):
158 # We only inline local content
159 cid = self._ext.get_cid_for_image(el.attrib)
160 el.attrib["src"] = f"cid:{cid}"
161 return el, start, end
165 self._images = OrderedDict()
167 def extendMarkdown(self, md):
168 md.registerExtension(self)
169 inline_image_proc = self.RelatedImageInlineProcessor(
170 IMAGE_LINK_RE, md, self
172 md.inlinePatterns.register(inline_image_proc, "image_link", 150)
174 def get_cid_for_image(self, attrib):
175 msgid = make_msgid()[1:-1]
177 if path.startswith("/"):
178 path = f"file://{path}"
179 self._images[path] = InlineImageInfo(
180 msgid, attrib.get("title", attrib.get("alt"))
184 def get_images(self):
188 def markdown_with_inline_image_support(text, *, extensions=None):
189 inline_image_handler = InlineImageExtension()
190 extensions = extensions or []
191 extensions.append(inline_image_handler)
192 mdwn = markdown.Markdown(extensions=extensions)
193 htmltext = mdwn.convert(text)
195 images = inline_image_handler.get_images()
197 def replace_image_with_cid(matchobj):
198 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
200 return f"(cid:{images[m].cid}"
201 return matchobj.group(0)
203 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
204 return text, htmltext, images
207 # [ PARTS GENERATION ] ########################################################
213 ["type", "subtype", "path", "desc", "cid", "orig"],
214 defaults=[None, None, False],
218 ret = f"<{self.type}/{self.subtype}>"
220 ret = f"{ret} cid:{self.cid}"
222 ret = f"{ret} ORIGINAL"
227 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
230 return f"<multipart/{self.subtype}> children={len(self.children)}"
233 def filewriter_fn(path, content, mode="w", **kwargs):
234 with open(path, mode, **kwargs) as out_f:
238 def collect_inline_images(
239 images, *, tempdir=None, filewriter_fn=filewriter_fn
242 for path, info in images.items():
243 data = request.urlopen(path)
245 mimetype = data.headers["Content-Type"]
246 ext = mimetypes.guess_extension(mimetype)
247 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
248 path = pathlib.Path(tempfilename[1])
250 filewriter_fn(path, data.read(), "w+b")
253 Part(*mimetype.split("/"), path, cid=info.cid, desc=f"Image: {info.desc}")
259 def convert_markdown_to_html(
263 filewriter_fn=filewriter_fn,
267 origtext, htmltext, images = markdown_with_inline_image_support(
268 origtext, extensions=extensions
271 filewriter_fn(draftpath, origtext, encoding="utf-8")
273 "text", "plain", draftpath, "Plain-text version", orig=True
276 htmlpath = draftpath.with_suffix(".html")
278 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
280 htmlpart = Part("text", "html", htmlpath, "HTML version")
283 "alternative", [textpart, htmlpart], "Group of alternative content"
286 imgparts = collect_inline_images(
287 images, tempdir=tempdir, filewriter_fn=filewriter_fn
291 "relative", [altpart] + imgparts, "Group of related content"
297 class MIMETreeDFWalker:
298 def __init__(self, *, visitor_fn=None, debug=False):
299 self._visitor_fn = visitor_fn
302 def walk(self, root, *, visitor_fn=None):
304 Recursive function to implement a depth-dirst walk of the MIME-tree
308 if isinstance(root, list):
309 root = Multipart("mixed", children=root)
314 visitor_fn=visitor_fn or self._visitor_fn,
317 def _walk(self, node, *, stack, visitor_fn):
318 # Let's start by enumerating the parts at the current level. At the
319 # root level, stack will be the empty list, and we expect a multipart/*
320 # container at this level. Later, e.g. within a mutlipart/alternative
321 # container, the subtree will just be the alternative parts, while the
322 # top of the stack will be the multipart/alternative container, which
323 # we will process after the following loop.
325 lead = f"{'| '*len(stack)}|-"
326 if isinstance(node, Multipart):
328 f"{lead}{node} parents={[s.subtype for s in stack]}"
331 # Depth-first, so push the current container onto the stack,
334 self.debugprint("| " * (len(stack) + 1))
335 for child in node.children:
339 visitor_fn=visitor_fn,
341 self.debugprint("| " * len(stack))
342 assert stack.pop() == node
345 self.debugprint(f"{lead}{node}")
348 visitor_fn(node, stack, debugprint=self.debugprint)
350 def debugprint(self, s, **kwargs):
352 print(s, file=sys.stderr, **kwargs)
355 # [ RUN MODES ] ###############################################################
360 Stupid class to interface writing out Mutt commands. This is quite a hack
361 to deal with the fact that Mutt runs "push" commands in reverse order, so
362 all of a sudden, things become very complicated when mixing with "real"
365 Hence we keep two sets of commands, and one set of pushes. Commands are
366 added to the first until a push is added, after which commands are added to
367 the second set of commands.
369 On flush(), the first set is printed, followed by the pushes in reverse,
370 and then the second set is printed. All 3 sets are then cleared.
373 def __init__(self, out_f=sys.stdout, *, debug=False):
374 self._cmd1, self._push, self._cmd2 = [], [], []
386 s = s.replace('"', '"')
389 self._push.insert(0, s)
393 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
395 self._cmd1, self._push, self._cmd2 = [], [], []
397 def debugprint(self, s, **kwargs):
399 print(s, file=sys.stderr, **kwargs)
409 debug_commands=False,
411 temppath = temppath or pathlib.Path(
412 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
414 cmds = MuttCommands(out_f, debug=debug_commands)
416 editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
418 editor = f'{editor} --extensions {extensions}'
420 editor = f'{editor} --only-build'
422 editor = f"{editor} --tempdir {tempdir}"
424 editor = f"{editor} --debug-commands"
426 cmds.cmd('set my_editor="$editor"')
427 cmds.cmd('set my_edit_headers="$edit_headers"')
428 cmds.cmd(f'set editor="{editor}"')
429 cmds.cmd("unset edit_headers")
430 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
431 cmds.push("<first-entry><edit-file>")
441 converter=convert_markdown_to_html,
444 debug_commands=False,
447 # Here's the big picture: we're being invoked as the editor on the email
448 # draft, and whatever commands we write to the file given as cmdpath will
449 # be run by the second source command in the macro definition.
451 # Let's start by cleaning up what the setup did (see above), i.e. we
452 # restore the $editor and $edit_headers variables, and also unset the
453 # variable used to identify the command file we're currently writing
455 cmds = MuttCommands(cmd_f, debug=debug_commands)
456 cmds.cmd('set editor="$my_editor"')
457 cmds.cmd('set edit_headers="$my_edit_headers"')
458 cmds.cmd("unset my_editor")
459 cmds.cmd("unset my_edit_headers")
461 # let's flush those commands, as there'll be a lot of pushes from now
462 # on, which need to be run in reverse order
465 extensions = extensions.split(",") if extensions else []
466 tree = converter(draft_f.read(), draftpath, tempdir=tempdir, extensions=extensions)
468 mimetree = MIMETreeDFWalker(debug=debug_walk)
470 def visitor_fn(item, stack, *, debugprint=None):
472 Visitor function called for every node (part) of the MIME tree,
473 depth-first, and responsible for telling NeoMutt how to assemble
478 if isinstance(item, Part):
479 # We've hit a leaf-node, i.e. an alternative or a related part
480 # with actual content.
484 # The original source already exists in the NeoMutt tree, but
485 # the underlying file may have been modified, so we need to
486 # update the encoding, but that's it:
487 cmds.push("<update-encoding>")
489 # … whereas all other parts need to be added, and they're all
490 # considered to be temporary and inline:
491 cmds.push(f"<attach-file>{item.path}<enter>")
492 cmds.push("<toggle-unlink><toggle-disposition>")
494 # If the item (including the original) comes with additional
495 # information, then we might just as well update the NeoMutt
498 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
500 elif isinstance(item, Multipart):
501 # This node has children, but we already visited them (see
502 # above), and so they have been tagged in NeoMutt's compose
503 # window. Now it's just a matter of telling NeoMutt to do the
504 # appropriate grouping:
505 if item.subtype == "alternative":
506 cmds.push("<group-alternatives>")
507 elif item.subtype in ("relative", "related"):
508 cmds.push("<group-related>")
509 elif item.subtype == "multilingual":
510 cmds.push("<group-multilingual>")
513 # We should never get here
514 assert not "is valid part"
516 # If the item has a description, we might just as well add it
518 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
520 # Finally, if we're at non-root level, tag the new container,
521 # as it might itself be part of a container, to be processed
524 cmds.push("<tag-entry>")
529 # Let's walk the tree and visit every node with our fancy visitor
531 mimetree.walk(tree, visitor_fn=visitor_fn)
534 cmds.push("<send-message>")
536 # Finally, cleanup. Since we're responsible for removing the temporary
537 # file, how's this for a little hack?
539 filename = cmd_f.name
540 except AttributeError:
541 filename = "pytest_internal_file"
542 cmds.cmd(f"source 'rm -f {filename}|'")
543 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
547 # [ CLI ENTRY ] ###############################################################
549 if __name__ == "__main__":
550 args = parse_cli_args()
552 if args.mode == "setup":
555 only_build=args.only_build,
556 tempdir=args.tempdir,
557 debug_commands=args.debug_commands,
560 elif args.mode == "massage":
561 with open(args.MAILDRAFT, "r") as draft_f, open(
566 pathlib.Path(args.MAILDRAFT),
568 extensions=args.extensions,
569 only_build=args.only_build,
570 tempdir=args.tempdir,
571 debug_commands=args.debug_commands,
572 debug_walk=args.debug_walk,
576 # [ TESTS ] ###################################################################
580 from io import StringIO
585 return "CONSTANT STRING 1"
589 return "CONSTANT STRING 2"
591 # NOTE: tests using the capsys fixture must specify sys.stdout to the
592 # functions they call, else old stdout is used and not captured
594 def test_MuttCommands_cmd(self, const1, const2, capsys):
595 "Assert order of commands"
596 cmds = MuttCommands(out_f=sys.stdout)
600 captured = capsys.readouterr()
601 assert captured.out == "\n".join((const1, const2, ""))
603 def test_MuttCommands_push(self, const1, const2, capsys):
604 "Assert reverse order of pushes"
605 cmds = MuttCommands(out_f=sys.stdout)
609 captured = capsys.readouterr()
612 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
615 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
616 "Assert reverse order of pushes"
617 cmds = MuttCommands(out_f=sys.stdout)
618 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
620 cmds.cmd(lines[4 * i + 0])
621 cmds.cmd(lines[4 * i + 1])
622 cmds.push(lines[4 * i + 2])
623 cmds.push(lines[4 * i + 3])
626 captured = capsys.readouterr()
627 lines_out = captured.out.splitlines()
628 assert lines[0] in lines_out[0]
629 assert lines[1] in lines_out[1]
630 assert lines[7] in lines_out[2]
631 assert lines[6] in lines_out[3]
632 assert lines[3] in lines_out[4]
633 assert lines[2] in lines_out[5]
634 assert lines[4] in lines_out[6]
635 assert lines[5] in lines_out[7]
638 def basic_mime_tree(self):
652 Part("text", "html", "part.html", desc="HTML"),
657 "text", "png", "logo.png", cid="logo.png", desc="Logo"
663 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
664 mimetree = MIMETreeDFWalker()
668 def visitor_fn(item, stack, debugprint):
669 items.append((item, len(stack)))
671 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
672 assert len(items) == 5
673 assert items[0][0].subtype == "plain"
674 assert items[0][1] == 2
675 assert items[1][0].subtype == "html"
676 assert items[1][1] == 2
677 assert items[2][0].subtype == "alternative"
678 assert items[2][1] == 1
679 assert items[3][0].subtype == "png"
680 assert items[3][1] == 1
681 assert items[4][0].subtype == "relative"
682 assert items[4][1] == 0
684 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
685 mimetree = MIMETreeDFWalker()
688 def visitor_fn(item, stack, debugprint):
691 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
692 assert items[-1].subtype == "mixed"
694 def test_MIMETreeDFWalker_visitor_in_constructor(
695 self, basic_mime_tree
699 def visitor_fn(item, stack, debugprint):
702 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
703 mimetree.walk(basic_mime_tree)
704 assert len(items) == 5
706 def test_do_setup_no_extensions(self, const1, capsys):
707 "Assert basics about the setup command output"
708 do_setup(temppath=const1, out_f=sys.stdout)
709 captout = capsys.readouterr()
710 lines = captout.out.splitlines()
711 assert lines[2].endswith(f'{const1}"')
712 assert lines[4].endswith(const1)
713 assert "first-entry" in lines[-1]
714 assert "edit-file" in lines[-1]
716 def test_do_setup_extensions(self, const1, const2, capsys):
717 "Assert that extensions are passed to editor"
719 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
721 captout = capsys.readouterr()
722 lines = captout.out.splitlines()
723 # assert comma-separated list of extensions passed
724 assert lines[2].endswith(f'{const2},{const1}"')
725 assert lines[4].endswith(const1)
728 def string_io(self, const1, text=None):
729 return StringIO(text or const1)
731 def test_do_massage_basic(self, const1, string_io, capsys):
732 def converter(drafttext, draftpath, extensions, tempdir):
733 return Part("text", "plain", draftpath, orig=True)
742 captured = capsys.readouterr()
743 lines = captured.out.splitlines()
744 assert '="$my_editor"' in lines.pop(0)
745 assert '="$my_edit_headers"' in lines.pop(0)
746 assert "unset my_editor" == lines.pop(0)
747 assert "unset my_edit_headers" == lines.pop(0)
748 assert "send-message" in lines.pop(0)
749 assert "update-encoding" in lines.pop(0)
750 assert "source 'rm -f " in lines.pop(0)
751 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
753 def test_do_massage_fulltree(
754 self, string_io, const1, basic_mime_tree, capsys
756 def converter(drafttext, draftpath, extensions, tempdir):
757 return basic_mime_tree
766 captured = capsys.readouterr()
767 lines = captured.out.splitlines()[4:]
768 assert "send-message" in lines.pop(0)
769 assert "Related" in lines.pop(0)
770 assert "group-related" in lines.pop(0)
771 assert "tag-entry" in lines.pop(0)
772 assert "Logo" in lines.pop(0)
773 assert "content-id" in lines.pop(0)
774 assert "toggle-unlink" in lines.pop(0)
775 assert "logo.png" in lines.pop(0)
776 assert "tag-entry" in lines.pop(0)
777 assert "Alternative" in lines.pop(0)
778 assert "group-alternatives" in lines.pop(0)
779 assert "tag-entry" in lines.pop(0)
780 assert "HTML" in lines.pop(0)
781 assert "toggle-unlink" in lines.pop(0)
782 assert "part.html" in lines.pop(0)
783 assert "tag-entry" in lines.pop(0)
784 assert "Plain" in lines.pop(0)
785 assert "update-encoding" in lines.pop(0)
786 assert len(lines) == 2
789 def fake_filewriter(self):
794 def __call__(self, path, content, mode="w", **kwargs):
795 self._writes.append((path, content))
797 def pop(self, index=-1):
798 return self._writes.pop(index)
803 def markdown_non_converter(self, const1, const2):
804 return lambda s, text: f"{const1}{text}{const2}"
806 def test_converter_tree_basic(
807 self, const1, const2, fake_filewriter, markdown_non_converter
809 path = pathlib.Path(const2)
810 tree = convert_markdown_to_html(
811 const1, path, filewriter_fn=fake_filewriter
814 assert tree.subtype == "alternative"
815 assert len(tree.children) == 2
816 assert tree.children[0].subtype == "plain"
817 assert tree.children[0].path == path
818 assert tree.children[0].orig
819 assert tree.children[1].subtype == "html"
820 assert tree.children[1].path == path.with_suffix(".html")
822 def test_converter_writes(
828 markdown_non_converter,
830 path = pathlib.Path(const2)
832 with monkeypatch.context() as m:
833 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
834 convert_markdown_to_html(
835 const1, path, filewriter_fn=fake_filewriter
838 assert (path, const1) == fake_filewriter.pop(0)
840 path.with_suffix(".html"),
841 markdown_non_converter(None, const1),
842 ) == fake_filewriter.pop(0)
844 def test_markdown_inline_image_processor(self):
845 imgpath1 = "file:/path/to/image.png"
846 imgpath2 = "file:///path/to/image.png?url=params"
847 imgpath3 = "/path/to/image.png"
848 text = f"""![inline local image]({imgpath1})
850 with newline]({imgpath2})
851 ![image local path]({imgpath3})"""
852 text, html, images = markdown_with_inline_image_support(text)
854 # local paths have been normalised to URLs:
855 imgpath3 = f"file://{imgpath3}"
857 assert 'src="cid:' in html
858 assert "](cid:" in text
859 assert len(images) == 3
860 assert imgpath1 in images
861 assert imgpath2 in images
862 assert imgpath3 in images
863 assert images[imgpath1].cid != images[imgpath2].cid
864 assert images[imgpath1].cid != images[imgpath3].cid
865 assert images[imgpath2].cid != images[imgpath3].cid
867 def test_markdown_inline_image_processor_title_to_desc(self, const1):
868 imgpath = "file:///path/to/image.png"
869 text = f'![inline local image]({imgpath} "{const1}")'
870 text, html, images = markdown_with_inline_image_support(text)
871 assert images[imgpath].desc == const1
873 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
874 imgpath = "file:///path/to/image.png"
875 text = f"![{const1}]({imgpath})"
876 text, html, images = markdown_with_inline_image_support(text)
877 assert images[imgpath].desc == const1
879 def test_markdown_inline_image_processor_title_over_alt_desc(
882 imgpath = "file:///path/to/image.png"
883 text = f'![{const1}]({imgpath} "{const2}")'
884 text, html, images = markdown_with_inline_image_support(text)
885 assert images[imgpath].desc == const2
887 def test_markdown_inline_image_not_external(self):
888 imgpath = "https://path/to/image.png"
889 text = f"![inline image]({imgpath})"
890 text, html, images = markdown_with_inline_image_support(text)
892 assert 'src="cid:' not in html
893 assert "](cid:" not in text
894 assert len(images) == 0
896 def test_markdown_inline_image_local_file(self):
897 imgpath = "/path/to/image.png"
898 text = f"![inline image]({imgpath})"
899 text, html, images = markdown_with_inline_image_support(text)
901 for k, v in images.items():
902 assert k == f"file://{imgpath}"
908 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
909 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
912 def test_markdown_inline_image_processor_base64(self, test_png):
913 text = f"![1px white inlined]({test_png})"
914 text, html, images = markdown_with_inline_image_support(text)
916 assert 'src="cid:' in html
917 assert "](cid:" in text
918 assert len(images) == 1
919 assert test_png in images
921 def test_converter_tree_inline_image_base64(
922 self, test_png, const1, fake_filewriter
924 text = f"![inline base64 image]({test_png})"
925 path = pathlib.Path(const1)
926 tree = convert_markdown_to_html(
927 text, path, filewriter_fn=fake_filewriter
930 assert tree.subtype == "relative"
931 assert tree.children[1].subtype == "png"
932 written = fake_filewriter.pop()
933 assert tree.children[1].path == written[0]
934 assert written[1] == request.urlopen(test_png).read()
936 def test_inline_image_collection(
937 self, test_png, const1, const2, fake_filewriter
939 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
940 relparts = collect_inline_images(
941 test_images, filewriter_fn=fake_filewriter
944 written = fake_filewriter.pop()
945 assert b"PNG" in written[1]
947 assert relparts[0].subtype == "png"
948 assert relparts[0].path == written[0]
949 assert relparts[0].cid == const1
950 assert relparts[0].desc.endswith(const2)